From a03e503be87f950c62c7371d9474640202e1b0c8 Mon Sep 17 00:00:00 2001 From: RomualdLemesle Date: Wed, 4 Feb 2026 11:10:53 +0100 Subject: [PATCH] [netexec] feat(netexec-injector): Introduce netexec injector --- .../injector_common/data_helpers.py | 25 ++ injector_common/pyproject.toml | 2 +- netexec/.dockerignore | 3 + netexec/.gitignore | 2 + netexec/Dockerfile | 61 +++++ netexec/README.md | 222 ++++++++++++++++++ netexec/config.yml.sample | 9 + netexec/docker-compose.yml | 15 ++ netexec/netexec/__init__.py | 0 netexec/netexec/configuration/__init__.py | 0 .../netexec/configuration/config_loader.py | 32 +++ .../configuration/injector_config_override.py | 23 ++ netexec/netexec/contracts_netexec.py | 173 ++++++++++++++ netexec/netexec/helpers/__init__.py | 0 .../helpers/netexec_command_builder.py | 52 ++++ netexec/netexec/helpers/netexec_process.py | 17 ++ netexec/netexec/img/icon-netexec.png | 78 ++++++ netexec/netexec/openaev_netexec.py | 168 +++++++++++++ netexec/pyproject.toml | 40 ++++ 19 files changed, 921 insertions(+), 1 deletion(-) create mode 100644 injector_common/injector_common/data_helpers.py create mode 100644 netexec/.dockerignore create mode 100644 netexec/.gitignore create mode 100644 netexec/Dockerfile create mode 100644 netexec/README.md create mode 100644 netexec/config.yml.sample create mode 100644 netexec/docker-compose.yml create mode 100644 netexec/netexec/__init__.py create mode 100644 netexec/netexec/configuration/__init__.py create mode 100644 netexec/netexec/configuration/config_loader.py create mode 100644 netexec/netexec/configuration/injector_config_override.py create mode 100644 netexec/netexec/contracts_netexec.py create mode 100644 netexec/netexec/helpers/__init__.py create mode 100644 netexec/netexec/helpers/netexec_command_builder.py create mode 100644 netexec/netexec/helpers/netexec_process.py create mode 100644 netexec/netexec/img/icon-netexec.png create mode 100644 netexec/netexec/openaev_netexec.py create mode 100644 netexec/pyproject.toml diff --git a/injector_common/injector_common/data_helpers.py b/injector_common/injector_common/data_helpers.py new file mode 100644 index 00000000..b78a815f --- /dev/null +++ b/injector_common/injector_common/data_helpers.py @@ -0,0 +1,25 @@ +from typing import Dict + + +class DataHelpers: + + @staticmethod + def get_injector_contract_id(data: Dict) -> str: + try: + return data["injection"]["inject_injector_contract"]["injector_contract_id"] + except KeyError as e: + raise ValueError("Invalid data: missing injector contract id") from e + + @staticmethod + def get_content(data: Dict) -> Dict: + try: + return data["injection"]["inject_content"] + except KeyError as e: + raise ValueError("Invalid data: missing inject content") from e + + @staticmethod + def get_inject_id(data: Dict) -> str: + try: + return data["injection"]["inject_id"] + except KeyError as e: + raise ValueError("Invalid data: missing inject id") from e diff --git a/injector_common/pyproject.toml b/injector_common/pyproject.toml index 6bae4a70..70332438 100644 --- a/injector_common/pyproject.toml +++ b/injector_common/pyproject.toml @@ -3,7 +3,7 @@ name="injector_common" version="1.0.0" dependencies = [ - "pyoaev==2.1.0", + "pyoaev==2.1.3", ] [project.optional-dependencies] dev = [ diff --git a/netexec/.dockerignore b/netexec/.dockerignore new file mode 100644 index 00000000..8f7bc48f --- /dev/null +++ b/netexec/.dockerignore @@ -0,0 +1,3 @@ +config.yml +src/__pycache__ +__pycache__ diff --git a/netexec/.gitignore b/netexec/.gitignore new file mode 100644 index 00000000..b93baaf2 --- /dev/null +++ b/netexec/.gitignore @@ -0,0 +1,2 @@ +config.yml +__pycache__ \ No newline at end of file diff --git a/netexec/Dockerfile b/netexec/Dockerfile new file mode 100644 index 00000000..9fb3dc13 --- /dev/null +++ b/netexec/Dockerfile @@ -0,0 +1,61 @@ +# Builder +FROM python:3.13-alpine AS builder + +# System dependencies required by netexec +RUN apk add --no-cache \ + gcc \ + musl-dev \ + libffi-dev \ + openssl-dev \ + cargo + +# Install Poetry +RUN pip install --no-cache-dir poetry==2.1.3 + +# Copy shared injector code +WORKDIR /opt/injector_common +COPY --from=injector_common ./ ./ + +# Copy injector source +ARG installdir=/opt/injector +ADD . ${installdir} +WORKDIR ${installdir} + +RUN poetry build + +# Runner +FROM python:3.13-alpine AS runner + +# Runtime deps only +RUN apk add --no-cache \ + gcc \ + musl-dev \ + python3-dev \ + libffi-dev \ + openssl-dev \ + cargo \ + git + +WORKDIR /opt/injector_common +COPY --from=injector_common ./ ./ + +ARG installdir=/opt/injector +WORKDIR ${installdir} +COPY --from=builder ${installdir}/dist ./dist + +RUN pip3 install --no-cache-dir "$(ls dist/*.whl)[prod]" + +# Optional: override client-python version +ARG PYOAEV_GIT_BRANCH_OVERRIDE +RUN if [ -n "${PYOAEV_GIT_BRANCH_OVERRIDE}" ]; then \ + echo "Forcing specific version of client-python" && \ + pip install pip3-autoremove && \ + pip-autoremove pyoaev -y && \ + pip install git+https://github.com/OpenAEV-Platform/client-python@${PYOAEV_GIT_BRANCH_OVERRIDE} ; \ + fi + +# Install netexec +RUN pip install --no-cache-dir git+https://github.com/Pennyw0rth/NetExec.git + +# Default command +CMD ["python3", "-m", "netexec.openaev_netexec"] diff --git a/netexec/README.md b/netexec/README.md new file mode 100644 index 00000000..a06602da --- /dev/null +++ b/netexec/README.md @@ -0,0 +1,222 @@ +# OpenAEV NetExec Injector + +## Table of Contents + +- [OpenAEV NetExec Injector](#openaev-netexec-injector) + - [Prerequisites](#prerequisites) + - [Configuration variables](#configuration-variables) + - [OpenAEV environment variables](#openaev-environment-variables) + - [Base injector environment variables](#base-injector-environment-variables) + - [Deployment](#deployment) + - [Docker Deployment](#docker-deployment) + - [Manual Deployment](#manual-deployment) + - [Behavior](#behavior) + - [Template Selection](#template-selection) + - [Target Selection](#target-selection) + - [Resources](#resources) + +--- + +## Prerequisites + +This injector uses [NetExex](https://www.netexec.wiki/smb-protocol/enumeration). + +This injector communicates with the OpenAEV platform through **RabbitMQ**, using the configuration provided by OpenAEV. + +To function properly, the injector **must be able to reach the RabbitMQ service** (hostname and port) defined in the +OpenAEV configuration. + +## Configuration + +Configuration values can be provided either: + +* via `docker-compose.yml` (Docker deployment), or +* via `config.yml` (manual deployment). + +### OpenAEV Environment Variables + +The following parameters are required to connect the injector to the OpenAEV platform: + +| Parameter | `config.yml` | Docker Variable | Mandatory | Description | +|---------------|--------------|-----------------|-----------|-----------------------------------------------------| +| OpenAEV URL | `url` | `OPENAEV_URL` | Yes | Base URL of the OpenAEV platform. | +| OpenAEV Token | `token` | `OPENAEV_TOKEN` | Yes | Admin API token configured in the OpenAEV platform. | + +### Injector Environment Variables + +The following parameters control the injector runtime behavior: + +| Parameter | `config.yml` | Docker Variable | Default | Mandatory | Description | +|---------------|--------------|----------------------|---------|-----------|---------------------------------------------------------| +| Injector ID | `id` | `INJECTOR_ID` | — | Yes | Unique `UUIDv4` identifying this injector instance. | +| Injector Name | `name` | `INJECTOR_NAME` | — | Yes | Human-readable name of the injector. | +| Log Level | `log_level` | `INJECTOR_LOG_LEVEL` | `info` | Yes | Logging verbosity: `debug`, `info`, `warn`, or `error`. | + +## Deployment + +### Docker Deployment + +Build the Docker image using the provided `Dockerfile`. + +```shell +docker build --build-context injector_common=../injector_common . -t openaev/injector-netexec:latest +``` + +Then configure the environment variables in `docker-compose.yml` and start the injector: + +```shell +docker compose up -d +``` + +> ✅ The Docker image **already contains NetExec**. No further installation is needed inside the container. + +### Manual Deployment + +1. Create a `config.yml` file based on `config.yml.sample` +2. Adjust the configuration values to match your environment + +#### Prerequisites + +* **NetExec must be installed locally** and accessible via the command line (`netexec` command). +* You can install it + from: [https://www.netexec.wiki/getting-started/installation](https://www.netexec.wiki/getting-started/installation) + +* Python package manager **Poetry** (version 2.1 or later) + 👉 [https://python-poetry.org/](https://python-poetry.org/) + +#### Installation + +**Production environment** + +```shell +poetry install --extras prod +``` + +**Development environment** + +For development, you should also clone the `pyoaev` repository following the instructions provided in the OpenAEV +documentation. + +```shell +poetry install --extras dev +``` + +## Development + +This project follows strict code formatting rules to ensure consistency and readability across the OpenAEV ecosystem. + +Before submitting any **Pull Request**, contributors **must** format the codebase using **isort** and **black**. + +### Code Formatting + +The following tools are required (already included in the development dependencies): + +* **isort** – import sorting +* **black** – code formatter + +Run them from the project root: + +```shell +poetry run isort --profile black . +poetry run black . +``` + +Both commands must complete **without errors or changes** before opening a PR. + +> ⚠️ Pull Requests that do not respect formatting rules may be rejected or require additional review cycles. + +#### Run the Injector + +```shell +poetry run python -m netexec.openaev_netexec +``` + +#### Test it + +SMB de test + +``` +podman run -d --name smb-test --network openaev-dev_default -p 445:445 dperson/samba -u "testuser;testpass" -s "share;/share;yes;no;no;testuser" +``` + +Run command to test + +``` +netexec smb smb-test -u testuser -p testpass --shares +``` + +## Behavior + +The NetExec injector performs **contract-based network reconnaissance and authentication checks** by dynamically +building and executing **NetExec commands** based on the contract configuration and user inputs. + +Each execution translates contract fields (targets, credentials, and options) into a NetExec command and runs it against +the selected targets. + +NetExec is used as a **client-only tool**: it connects to remote services (such as SMB) and never exposes or hosts +services itself. + +## Supported Contracts + +The injector currently supports **SMB-based contracts**, focused on authentication checks and safe enumeration actions. + +### SMB Authentication Contract + +This contract allows validating credentials and performing controlled SMB enumeration using NetExec. + +Supported SMB actions include: + +* Share enumeration +* User enumeration +* Group enumeration +* Session enumeration +* Logged-on user discovery + +Only **non-destructive, read-only actions** are exposed. + +## Target Selection + +Targets are resolved using the `target_selector` field defined in the contract. + +### When the target type is **Assets** + +| Selected Property | Asset Field Used | +|-------------------|-------------------------------| +| Seen IP | `endpoint_seen_ip` | +| Local IP | First entry in `endpoint_ips` | +| Hostname | `endpoint_hostname` | + +### When the target type is **Manual** + +Targets are provided directly as **comma-separated IP addresses or hostnames**. + +## Options + +The injector supports the following SMB options, mapped directly to NetExec arguments: + +* `--shares` — Enumerate SMB shares +* `--users` — Enumerate users +* `--groups` — Enumerate groups +* `--sessions` — List active SMB sessions +* `--loggedon-users` — List currently logged-on users + +If no option is explicitly selected, the injector defaults to a **safe enumeration action** (typically `--shares`) to +ensure meaningful output. + +## Example Executions + +Basic SMB share enumeration: + +```bash +netexec smb 192.168.1.50 --shares +``` + +SMB authentication test with credentials: + +```bash +netexec smb 192.168.1.50 -u admin -p Password123 +``` +## Resources + +* [NetExec GitHub Repository](https://github.com/Pennyw0rth/NetExec) +* [NetExec Documentation](https://github.com/Pennyw0rth/NetExec/wiki) \ No newline at end of file diff --git a/netexec/config.yml.sample b/netexec/config.yml.sample new file mode 100644 index 00000000..284c08e6 --- /dev/null +++ b/netexec/config.yml.sample @@ -0,0 +1,9 @@ +openaev: + url: 'http://localhost:3001' + token: 'ChangeMe' + +injector: + id: 'changeme' + name: 'NetExec' + log_level: 'info' + diff --git a/netexec/docker-compose.yml b/netexec/docker-compose.yml new file mode 100644 index 00000000..112558cc --- /dev/null +++ b/netexec/docker-compose.yml @@ -0,0 +1,15 @@ +services: + injector-netexec: + image: openaev/openaev-netexec:${INJECTOR_VERSION} + environment: + - OPENAEV_URL=${OPENAEV_URL} + - OPENAEV_TOKEN=${OPENAEV_TOKEN} + - INJECTOR_ID=${INJECTOR_ID} + - INJECTOR_NAME=NetExec + - INJECTOR_LOG_LEVEL=debug + restart: always + networks: + - openaev-dev_default +networks: + openaev-dev_default: + external: true \ No newline at end of file diff --git a/netexec/netexec/__init__.py b/netexec/netexec/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/netexec/netexec/configuration/__init__.py b/netexec/netexec/configuration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/netexec/netexec/configuration/config_loader.py b/netexec/netexec/configuration/config_loader.py new file mode 100644 index 00000000..33b94b11 --- /dev/null +++ b/netexec/netexec/configuration/config_loader.py @@ -0,0 +1,32 @@ +from pydantic import Field +from pyoaev.configuration import ConfigLoaderOAEV, Configuration, SettingsLoader + +from netexec.configuration.injector_config_override import InjectorConfigOverride +from netexec.contracts_netexec import NetExecContracts + + +class ConfigLoader(SettingsLoader): + openaev: ConfigLoaderOAEV = Field( + default_factory=ConfigLoaderOAEV, description="OpenAEV platform configuration" + ) + injector: InjectorConfigOverride = Field( + default_factory=InjectorConfigOverride, + description="NetExec injector configuration", + ) + + def to_daemon_config(self) -> Configuration: + return Configuration( + config_hints={ + # OpenAEV configuration (flattened) + "openaev_url": {"data": str(self.openaev.url)}, + "openaev_token": {"data": self.openaev.token}, + # Injector configuration (flattened) + "injector_id": {"data": self.injector.id}, + "injector_name": {"data": self.injector.name}, + "injector_type": {"data": "openaev_netexec_injector"}, + "injector_contracts": {"data": NetExecContracts.build()}, + "injector_log_level": {"data": self.injector.log_level}, + "injector_icon_filepath": {"data": self.injector.icon_filepath}, + }, + config_base_model=self, + ) diff --git a/netexec/netexec/configuration/injector_config_override.py b/netexec/netexec/configuration/injector_config_override.py new file mode 100644 index 00000000..aeddd229 --- /dev/null +++ b/netexec/netexec/configuration/injector_config_override.py @@ -0,0 +1,23 @@ +from typing import Literal + +from pydantic import Field +from pyoaev.configuration import ConfigLoaderCollector + + +# To be change ConfigLoaderCollector +class InjectorConfigOverride(ConfigLoaderCollector): + id: str = Field( + description="A unique UUIDv4 identifier for this injector instance.", + ) + name: str = Field( + description="Name of the injector.", + default="NetExec", + ) + icon_filepath: str = Field( + description="Path to the icon file", + default="netexec/img/icon-netexec.png", + ) + log_level: Literal["debug", "info", "warn", "error"] = Field( + description="Determines the verbosity of the logs. Options: debug, info, warn, or error.", + default="info", + ) diff --git a/netexec/netexec/contracts_netexec.py b/netexec/netexec/contracts_netexec.py new file mode 100644 index 00000000..07a89b6f --- /dev/null +++ b/netexec/netexec/contracts_netexec.py @@ -0,0 +1,173 @@ +from typing import List + +from pyoaev.contracts import ContractBuilder +from pyoaev.contracts.contract_config import ( + Contract, + ContractAsset, + ContractAssetGroup, + ContractConfig, + ContractElement, + ContractExpectations, + ContractSelect, + ContractText, + Expectation, + ExpectationType, + SupportedLanguage, + prepare_contracts, +) +from pyoaev.contracts.contract_utils import ContractCardinality +from pyoaev.security_domain.types import SecurityDomains + +from injector_common.constants import ( + TARGET_PROPERTY_SELECTOR_KEY, + TARGET_SELECTOR_KEY, + TARGETS_KEY, +) +from injector_common.targets import TargetProperty, target_property_choices_dict + +CONTRACT_TYPE = "openaev_netexec" +CONTRACT_ID_SMB_AUTH = "b6b9b5c4-1a0e-4b3c-9a8a-netexec-smb-auth" + + +class NetExecContracts: + + @staticmethod + def core_contract_fields(): + # -- FIELDS -- + target_selector = ContractSelect( + key=TARGET_SELECTOR_KEY, + label="Type of targets", + defaultValue=["asset-groups"], + mandatory=True, + choices={ + "assets": "Assets", + "manual": "Manual", + "asset-groups": "Asset groups", + }, + ) + targets_assets = ContractAsset( + cardinality=ContractCardinality.Multiple, + label="Targeted assets", + mandatory=False, + mandatoryConditionFields=[target_selector.key], + mandatoryConditionValues={target_selector.key: "assets"}, + visibleConditionFields=[target_selector.key], + visibleConditionValues={target_selector.key: "assets"}, + ) + target_asset_groups = ContractAssetGroup( + cardinality=ContractCardinality.Multiple, + label="Targeted asset groups", + mandatory=False, + mandatoryConditionFields=[target_selector.key], + mandatoryConditionValues={target_selector.key: "asset-groups"}, + visibleConditionFields=[target_selector.key], + visibleConditionValues={target_selector.key: "asset-groups"}, + ) + target_property_selector = ContractSelect( + key=TARGET_PROPERTY_SELECTOR_KEY, + label="Targeted assets property", + defaultValue=[TargetProperty.AUTOMATIC.name.lower()], + mandatory=False, + choices=target_property_choices_dict, + mandatoryConditionFields=[target_selector.key], + mandatoryConditionValues={target_selector.key: ["assets", "asset-groups"]}, + visibleConditionFields=[target_selector.key], + visibleConditionValues={target_selector.key: ["assets", "asset-groups"]}, + ) + targets_manual = ContractText( + key=TARGETS_KEY, + label="Manual targets (comma-separated)", + mandatory=False, + mandatoryConditionFields=[target_selector.key], + mandatoryConditionValues={target_selector.key: "manual"}, + visibleConditionFields=[target_selector.key], + visibleConditionValues={target_selector.key: "manual"}, + ) + expectations = ContractExpectations( + key="expectations", + label="Expectations", + mandatory=False, + cardinality=ContractCardinality.Multiple, + predefinedExpectations=[ + Expectation( + expectation_type=ExpectationType.vulnerability, + expectation_name="Not vulnerable", + expectation_description="", + expectation_score=100, + expectation_expectation_group=False, + ) + ], + ) + + return [ + target_selector, + targets_assets, + target_asset_groups, + target_property_selector, + targets_manual, + expectations, + ] + + @staticmethod + def build() -> List[Contract]: + # Contract configuration + contract_config = ContractConfig( + type=CONTRACT_TYPE, + label={ + SupportedLanguage.en: "NetExec", + SupportedLanguage.fr: "NetExec", + }, + color_dark="#d32f2f", + color_light="#ffcdd2", + expose=True, + ) + + # Contract fields + smb_auth_fields: List[ContractElement] = ( + ContractBuilder() + .optional( + ContractText( + key="username", + label="Username", + ) + ) + .optional( + ContractText( + key="password", + label="Password", + ) + ) + .optional( + ContractSelect( + key="options", + label="Options", + choices={ + "--shares": "--shares", + "--pass-pol": "--pass-pol", + "--users": "--users", + "--groups": "--groups", + "--sessions": "--sessions", + "--loggedon-users": "--loggedon-users", + }, + ), + ) + .add_fields(NetExecContracts.core_contract_fields()) + .build_fields() + ) + + smb_auth_contract = Contract( + contract_id=CONTRACT_ID_SMB_AUTH, + config=contract_config, + label={ + SupportedLanguage.en: "NetExec - SMB authentication check", + SupportedLanguage.fr: "NetExec - Vérification authentification SMB", + }, + fields=smb_auth_fields, + outputs=[], + manual=False, + domains=[ + SecurityDomains.ENDPOINT.value, + ], + ) + + return prepare_contracts([smb_auth_contract]) diff --git a/netexec/netexec/helpers/__init__.py b/netexec/netexec/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/netexec/netexec/helpers/netexec_command_builder.py b/netexec/netexec/helpers/netexec_command_builder.py new file mode 100644 index 00000000..f4ce8590 --- /dev/null +++ b/netexec/netexec/helpers/netexec_command_builder.py @@ -0,0 +1,52 @@ +from typing import Dict, List, Optional + + +def build_command_smb( + targets: List[str], + credentials: Optional[Dict[str, str]] = None, + options: Optional[List[str]] = None, +) -> List[str]: + if not targets: + raise ValueError("At least one target is required for SMB command") + + cmd = ["netexec", "smb"] + + for target in targets: + cmd.append(target) + + if credentials is not None: + cmd += ["-u", credentials["username"], "-p", credentials["password"]] + + if options: + if isinstance(options, str): + cmd.append(options) + else: + cmd.extend(options) + + return cmd + + +def build_command_version() -> List[str]: + return ["netexec", "--version"] + + +def extract_data(content: Dict) -> Optional[Dict[str, str]]: + username = content.get("username") + password = content.get("password") + options = content.get("options") + + data: Dict = {} + + if username and password: + data["credentials"] = { + "username": username, + "password": password, + } + + if options: + if isinstance(options, list): + data["options"] = options + else: + data["options"] = [options] + + return data or None diff --git a/netexec/netexec/helpers/netexec_process.py b/netexec/netexec/helpers/netexec_process.py new file mode 100644 index 00000000..74711b94 --- /dev/null +++ b/netexec/netexec/helpers/netexec_process.py @@ -0,0 +1,17 @@ +import subprocess +from typing import Optional + + +def execute_netexec(cmd: list[str], input_data: Optional[str] = None): + kwargs = { + "capture_output": True, + "check": False, + "text": True, + } + + if input_data is not None: + kwargs["input"] = input_data + + result = subprocess.run(cmd, **kwargs) + + return result.stdout, result.stderr, result.returncode diff --git a/netexec/netexec/img/icon-netexec.png b/netexec/netexec/img/icon-netexec.png new file mode 100644 index 00000000..89736916 --- /dev/null +++ b/netexec/netexec/img/icon-netexec.png @@ -0,0 +1,78 @@ + + diff --git a/netexec/netexec/openaev_netexec.py b/netexec/netexec/openaev_netexec.py new file mode 100644 index 00000000..c8f18e5f --- /dev/null +++ b/netexec/netexec/openaev_netexec.py @@ -0,0 +1,168 @@ +import time +from importlib.resources import files +from typing import Dict + +from pyoaev.helpers import OpenAEVConfigHelper, OpenAEVInjectorHelper + +from injector_common.constants import TARGET_PROPERTY_SELECTOR_KEY, TARGET_SELECTOR_KEY +from injector_common.data_helpers import DataHelpers +from injector_common.dump_config import intercept_dump_argument +from injector_common.targets import TargetProperty, Targets +from netexec.configuration.config_loader import ConfigLoader +from netexec.contracts_netexec import ( + CONTRACT_ID_SMB_AUTH, +) +from netexec.helpers.netexec_command_builder import ( + build_command_smb, + build_command_version, + extract_data, +) +from netexec.helpers.netexec_process import execute_netexec + + +class OpenAEVNetExecInjector: + def __init__(self): + self.config = OpenAEVConfigHelper.from_configuration_object( + ConfigLoader().to_daemon_config() + ) + intercept_dump_argument(self.config.get_config_obj()) + icon_path = files("netexec").joinpath("img/icon-netexec.png") + with icon_path.open("rb") as icon_file: + icon_bytes = icon_file.read() + print(self.config) + self.helper = OpenAEVInjectorHelper(self.config, icon_bytes) + + self._check_netexec_version() + + def _check_netexec_version(self): + cmd = build_command_version() + stdout, stderr, returncode = execute_netexec(cmd) + if returncode != 0: + self.helper.injector_logger.warning( + f"Unable to determine NetExec version: {stderr}" + ) + return + self.helper.injector_logger.info("NetExec version: " + stdout.strip()) + + def execute(self, start: float, data: Dict) -> Dict: + # Contract execution + inject_id = DataHelpers.get_inject_id(data) + inject_contract = DataHelpers.get_injector_contract_id(data) + + available_contracts = { + CONTRACT_ID_SMB_AUTH: build_command_smb, + } + + if inject_contract not in available_contracts: + raise ValueError( + f"Unsupported contract '{inject_contract}' for NetExec injector" + ) + + content = DataHelpers.get_content(data) + selector_key = content[TARGET_SELECTOR_KEY] + selector_property = content[TARGET_PROPERTY_SELECTOR_KEY] + + target_results = Targets.extract_targets( + selector_key, selector_property, data, self.helper + ) + # Deduplicate targets + targets = target_results.targets + # Handle empty targets as an error + if not targets: + message = f"No target identified for the property {TargetProperty[selector_property.upper()].value}" + raise ValueError(message) + + # Build Arguments to execute + data = extract_data(content) + + credentials = data.get("credentials") if data else None + options = data.get("options") if data else None + self.helper.injector_logger.info("Data: " + str(content)) + cmd = build_command_smb( + targets=targets, + credentials=credentials, + options=options, + ) + + callback_data = { + "execution_message": Targets.build_execution_message( + selector_key=selector_key, + data=data, + command_args=cmd, + ), + "execution_status": "INFO", + "execution_duration": int(time.time() - start), + "execution_action": "command_execution", + } + + self.helper.api.inject.execution_callback( + inject_id=inject_id, + data=callback_data, + ) + + stdout, stderr, returncode = execute_netexec(cmd) + return { + "success": returncode == 0, + "stdout": stdout, + "stderr": stderr, + } + + def process_message(self, data: Dict) -> None: + start = time.time() + inject_id = DataHelpers.get_inject_id(data) + + # Notify OpenAEV that execution started + self.helper.api.inject.execution_reception( + inject_id=inject_id, + data={"tracking_total_count": 1}, + ) + + try: + result = self.execute(start, data) + + stdout = (result.get("stdout") or "").strip() + stderr = (result.get("stderr") or "").strip() + + if result["success"]: + if stdout: + execution_message = f"NetExec succeeded:\n{stdout}" + else: + execution_message = "NetExec succeeded: no results found" + else: + execution_message = ( + f"NetExec failed:\n{stderr or stdout or 'No error output'}" + ) + + callback_data = { + "execution_message": execution_message, + # "execution_output_structured": "", + "execution_status": "SUCCESS" if result["success"] else "ERROR", + "execution_duration": int(time.time() - start), + "execution_action": "complete", + } + + self.helper.api.inject.execution_callback( + inject_id=inject_id, + data=callback_data, + ) + + except Exception as e: + callback_data = { + "execution_message": str(e), + "execution_status": "ERROR", + "execution_duration": int(time.time() - start), + "execution_action": "complete", + } + + self.helper.api.inject.execution_callback( + inject_id=inject_id, + data=callback_data, + ) + + def start(self): + self.helper.listen(message_callback=self.process_message) + + +if __name__ == "__main__": + injector = OpenAEVNetExecInjector() + injector.start() diff --git a/netexec/pyproject.toml b/netexec/pyproject.toml new file mode 100644 index 00000000..a6c527ac --- /dev/null +++ b/netexec/pyproject.toml @@ -0,0 +1,40 @@ +[tool.poetry] +name = "openaev-netexec-injector" +version = "2.1.3" +description = "An injector for NetExec" +authors = ["Filigran "] +license = "Apache-2.0" +readme = "README.md" +packages = [ + { include = "netexec" } +] + + +[tool.poetry.dependencies] +python = "^3.11" +pyoaev = [ + { markers = "extra == 'prod' and extra != 'dev'", version = "2.1.3", source = "pypi" }, + { markers = "extra == 'dev' and extra != 'prod'", path = "../../client-python", develop = true }, +] +injector_common = { path = "../injector_common", develop = true } + +[tool.poetry.extras] +prod = ["pyoaev"] +dev = ["pyoaev"] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.setuptools.package-data] +netexec = ["img/*.png"] + +[tool.isort] +profile = "black" +known_local_folder = ["netexec"] + +[dependency-groups] +dev = [ + "black (>=25.12.0,<26.0.0)", + "isort (>=7.0.0,<8.0.0)" +]