diff --git a/capabilities/network-ops/capability.yaml b/capabilities/network-ops/capability.yaml index 2e71bbf..d8cd2f8 100644 --- a/capabilities/network-ops/capability.yaml +++ b/capabilities/network-ops/capability.yaml @@ -28,15 +28,39 @@ workers: path: workers/coordinator.py dependencies: + # apt tools, installed privileged by the SDK (dependencies.packages). + packages: + - nmap + - smbclient + - hashcat + - john python: - "loguru>=0.7.0" - "impacket>=0.12.0" + - "certipy-ad" + - "bloodyAD" + # NetExec (nxc) is deliberately absent here: it is not published on PyPI. The + # install script below fetches the project's prebuilt standalone binary. + # Runtime-fetched assets apt/pip can't provide: the NetExec binary, wordlists, + # hashcat rules, and the NTLM coercion scripts. scripts: - scripts/install_coercion_tools.sh checks: - name: nmap command: "command -v nmap >/dev/null 2>&1" + - name: smbclient + command: "command -v smbclient >/dev/null 2>&1" + - name: hashcat + command: "command -v hashcat >/dev/null 2>&1" + - name: john + command: "command -v john >/dev/null 2>&1" + - name: netexec + command: "command -v nxc >/dev/null 2>&1 || command -v netexec >/dev/null 2>&1" + - name: certipy + command: "command -v certipy >/dev/null 2>&1 || command -v certipy-ad >/dev/null 2>&1" + - name: bloodyad + command: "command -v bloodyAD >/dev/null 2>&1" - name: petitpotam command: "test -f /opt/PetitPotam/PetitPotam.py" - name: dfscoerce @@ -45,6 +69,10 @@ checks: command: "test -f /opt/ShadowCoerce/shadowcoerce.py" - name: rockyou-wordlist command: "test -s /usr/share/wordlists/rockyou.txt" + - name: wordlist-10k + command: "test -s /usr/share/wordlists/10k-most-common.txt" + - name: hashcat-rules + command: "test -s /usr/share/hashcat/rules/OneRuleToRuleThemAll.rule" keywords: - network-ops diff --git a/capabilities/network-ops/scripts/install_coercion_tools.sh b/capabilities/network-ops/scripts/install_coercion_tools.sh index f0d6fc8..eec21df 100755 --- a/capabilities/network-ops/scripts/install_coercion_tools.sh +++ b/capabilities/network-ops/scripts/install_coercion_tools.sh @@ -1,54 +1,68 @@ #!/usr/bin/env bash -# Install dependencies for the network-ops capability. -# Runs at sandbox provision time via dependencies.scripts. +# Fetch the network-ops runtime assets that apt and pip cannot: the +# password-cracking wordlists, the hashcat rule set, and the three NTLM +# coercion scripts. Binaries and Python packages are declared separately in +# capability.yaml (dependencies.packages / dependencies.python). +# +# The target system directories (/usr/share/wordlists, /usr/share/hashcat, +# /opt) are not writable by the unprivileged provisioning user, so every write +# goes through ensure_writable_dir and stays non-fatal: with no egress or no +# sudo the capability still loads, just without these optional assets. The +# matching checks: in capability.yaml report any asset that did not land. set -euo pipefail -# -- Impacket (ensure it's importable by the runtime Python) ---------------- -# dependencies.python should handle this, but belt-and-suspenders: if the -# runtime Python can't import impacket, install it explicitly. -if ! python3 -c "import impacket" 2>/dev/null; then - echo "[+] Installing impacket into runtime Python" - python3 -m pip install --quiet "impacket>=0.12.0" \ - || python3 -m pip install --quiet --break-system-packages "impacket>=0.12.0" -else - echo "[*] impacket already importable, skipping" -fi +# Make a (possibly root-owned) system directory exist and be writable by the +# current user, escalating with `sudo -n` (non-interactive, like the SDK's own +# apt step) when a plain mkdir is not permitted. Returns non-zero without +# aborting the script when neither works, so callers skip the dependent write. +ensure_writable_dir() { + local dir="$1" + if mkdir -p "$dir" 2>/dev/null && [ -w "$dir" ]; then + return 0 + fi + if command -v sudo >/dev/null 2>&1; then + if sudo -n mkdir -p "$dir" 2>/dev/null \ + && sudo -n chown "$(id -u):$(id -g)" "$dir" 2>/dev/null; then + return 0 + fi + fi + echo "[!] Cannot create or write $dir (non-fatal, skipping)" + return 1 +} # -- Wordlists for password cracking ----------------------------------------- WORDLIST_DIR="/usr/share/wordlists" -mkdir -p "$WORDLIST_DIR" - -# rockyou — the standard wordlist for cracking (14M passwords) -if [ -s "$WORDLIST_DIR/rockyou.txt" ]; then - echo "[*] rockyou.txt already exists, skipping" -elif [ -f "$WORDLIST_DIR/rockyou.txt.gz" ]; then - echo "[+] Decompressing rockyou.txt.gz" - rm -f "$WORDLIST_DIR/rockyou.txt" - gunzip -k "$WORDLIST_DIR/rockyou.txt.gz" \ - || echo "[!] Failed to decompress rockyou.txt.gz (non-fatal)" -else - echo "[+] Downloading rockyou.txt" - curl -fsSL --retry 3 --connect-timeout 10 "https://github.com/brannondorsey/naive-hashcat/releases/download/data/rockyou.txt" \ - -o "$WORDLIST_DIR/rockyou.txt" \ - || echo "[!] Failed to download rockyou.txt (non-fatal)" -fi +if ensure_writable_dir "$WORDLIST_DIR"; then + # rockyou — the standard cracking wordlist (14M passwords) + if [ -s "$WORDLIST_DIR/rockyou.txt" ]; then + echo "[*] rockyou.txt already exists, skipping" + elif [ -f "$WORDLIST_DIR/rockyou.txt.gz" ]; then + echo "[+] Decompressing rockyou.txt.gz" + rm -f "$WORDLIST_DIR/rockyou.txt" + gunzip -k "$WORDLIST_DIR/rockyou.txt.gz" \ + || echo "[!] Failed to decompress rockyou.txt.gz (non-fatal)" + else + echo "[+] Downloading rockyou.txt" + curl -fsSL --retry 3 --connect-timeout 10 "https://github.com/brannondorsey/naive-hashcat/releases/download/data/rockyou.txt" \ + -o "$WORDLIST_DIR/rockyou.txt" \ + || echo "[!] Failed to download rockyou.txt (non-fatal)" + fi -# Top 10k most common passwords — fast first-pass for spraying -# Non-fatal: wordlist download failures shouldn't block coercion script setup -if [ -s "$WORDLIST_DIR/10k-most-common.txt" ]; then - echo "[*] 10k-most-common.txt already exists, skipping" -else - echo "[+] Downloading SecLists 10k most common passwords" - curl -fsSL --retry 3 --connect-timeout 10 "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/10k-most-common.txt" \ - -o "$WORDLIST_DIR/10k-most-common.txt" \ - || echo "[!] Failed to download 10k-most-common.txt (non-fatal)" + # Top 10k most common passwords — fast first-pass for spraying + if [ -s "$WORDLIST_DIR/10k-most-common.txt" ]; then + echo "[*] 10k-most-common.txt already exists, skipping" + else + echo "[+] Downloading SecLists 10k most common passwords" + curl -fsSL --retry 3 --connect-timeout 10 "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/10k-most-common.txt" \ + -o "$WORDLIST_DIR/10k-most-common.txt" \ + || echo "[!] Failed to download 10k-most-common.txt (non-fatal)" + fi fi -# OneRuleToRuleThemAll — hashcat rules that multiply wordlist effectiveness -# Combines rules from Hob0Rules, KoreLogic, NSA, and hashcat generated rules -# Non-fatal: rules dir may not be writable and that's OK +# OneRuleToRuleThemAll — hashcat rules that multiply wordlist effectiveness. +# Combines rules from Hob0Rules, KoreLogic, NSA, and hashcat generated rules. RULES_DIR="/usr/share/hashcat/rules" -if mkdir -p "$RULES_DIR" 2>&1; then +if ensure_writable_dir "$RULES_DIR"; then if [ -s "$RULES_DIR/OneRuleToRuleThemAll.rule" ]; then echo "[*] OneRuleToRuleThemAll.rule already exists, skipping" else @@ -57,11 +71,37 @@ if mkdir -p "$RULES_DIR" 2>&1; then -o "$RULES_DIR/OneRuleToRuleThemAll.rule" \ || echo "[!] Failed to download OneRuleToRuleThemAll.rule (non-fatal)" fi +fi + +# -- NetExec (nxc) ---------------------------------------------------------- +# Not on PyPI and awkward to build from source, so install the project's +# prebuilt standalone binary. v1.5.0 is pinned because it is the most recent +# release carrying a Linux build (v1.5.1 shipped none). The binary needs +# glibc >= 2.38, satisfied by the ubuntu:24.04 runtime base. The tools invoke +# it as `netexec`, so the single `nxc` binary is symlinked under both names. +NXC_BIN_DIR="$HOME/.local/bin" +NXC_URL="https://github.com/Pennyw0rth/NetExec/releases/download/v1.5.0/nxc-ubuntu-latest.zip" +if command -v nxc >/dev/null 2>&1; then + echo "[*] nxc already present, skipping" else - echo "[!] Cannot create $RULES_DIR (non-fatal, skipping hashcat rules)" + echo "[+] Installing NetExec v1.5.0 (prebuilt binary)" + if mkdir -p "$NXC_BIN_DIR" \ + && curl -fsSL --retry 3 --connect-timeout 10 "$NXC_URL" -o /tmp/nxc.zip \ + && unzip -o -q /tmp/nxc.zip -d /tmp/nxc-extract \ + && install -m 0755 /tmp/nxc-extract/nxc "$NXC_BIN_DIR/nxc"; then + ln -sf nxc "$NXC_BIN_DIR/netexec" || echo "[!] netexec symlink failed (non-fatal)" + ln -sf nxc "$NXC_BIN_DIR/NetExec" || echo "[!] NetExec symlink failed (non-fatal)" + echo "[*] nxc installed to $NXC_BIN_DIR" + else + echo "[!] Failed to install NetExec (non-fatal)" + fi + rm -rf /tmp/nxc.zip /tmp/nxc-extract 2>/dev/null || true fi # -- Coercion scripts ------------------------------------------------------- +# These /opt paths are hardcoded in three places that must stay in sync: here, +# the coercion-script checks: in capability.yaml, and the path defaults in the +# coercion tools. Change one, change all three. REPOS=( "https://github.com/topotam/PetitPotam /opt/PetitPotam" "https://github.com/Wh04m1001/DFSCoerce /opt/DFSCoerce" @@ -72,8 +112,14 @@ for entry in "${REPOS[@]}"; do read -r url dest <<< "$entry" if [ -d "$dest" ]; then echo "[*] $dest already exists, skipping" - else - echo "[+] Cloning $(basename "$dest") to $dest" - git clone --depth 1 "$url" "$dest" + continue + fi + echo "[+] Cloning $(basename "$dest") to $dest" + if git clone --depth 1 "$url" "$dest" 2>/dev/null; then + continue + fi + if command -v sudo >/dev/null 2>&1 && sudo -n git clone --depth 1 "$url" "$dest" 2>/dev/null; then + continue fi + echo "[!] Failed to clone $(basename "$dest") to $dest (non-fatal)" done diff --git a/capabilities/network-ops/tests/test_provisioning.py b/capabilities/network-ops/tests/test_provisioning.py new file mode 100644 index 0000000..98994a5 --- /dev/null +++ b/capabilities/network-ops/tests/test_provisioning.py @@ -0,0 +1,130 @@ +"""Provisioning contract tests for the network-ops capability. + +The manifest must declare every binary its toolsets invoke (so provisioning +installs them and a check surfaces any that are missing), and the install +script must treat every write to a root-owned system path as non-fatal (so a +sandbox that cannot write or reach the network still loads the capability). +""" + +import os +import re +import stat +import subprocess +from pathlib import Path + +import yaml + +CAP_DIR = Path(__file__).resolve().parent.parent +MANIFEST = CAP_DIR / "capability.yaml" +INSTALL_SCRIPT = CAP_DIR / "scripts" / "install_coercion_tools.sh" + + +def _manifest() -> dict: + return yaml.safe_load(MANIFEST.read_text()) + + +def test_apt_tools_declared() -> None: + packages = _manifest()["dependencies"].get("packages", []) + for tool in ("nmap", "smbclient", "hashcat", "john"): + assert tool in packages, f"{tool} must be declared in dependencies.packages" + + +def test_pip_tools_declared() -> None: + python = _manifest()["dependencies"].get("python", []) + joined = " ".join(python) + for pkg in ("impacket", "certipy-ad", "bloodyAD"): + assert pkg in joined, f"{pkg} must be declared in dependencies.python" + # NetExec is not on PyPI; declaring it here hard-fails the SDK's combined + # pip install and takes the whole capability down. It must stay out. + assert "netexec" not in joined.lower(), "netexec is not pip-installable; provision it in the image" + + +def test_every_invoked_binary_has_a_check() -> None: + check_names = {c["name"] for c in _manifest().get("checks", [])} + # Binaries the toolsets shell out to: a missing one must show up in + # pre-flight, not as a raw command-not-found mid-run. + for name in ("nmap", "smbclient", "hashcat", "john", "netexec", "certipy", "bloodyad"): + assert name in check_names, f"missing checks: entry for {name}" + + +def test_fetched_assets_have_a_check() -> None: + check_names = {c["name"] for c in _manifest().get("checks", [])} + # Every asset the install script fetches is optional (its download is + # non-fatal), so each needs a check to report when it did not land. + for name in ( + "rockyou-wordlist", + "wordlist-10k", + "hashcat-rules", + "petitpotam", + "dfscoerce", + "shadowcoerce", + ): + assert name in check_names, f"missing checks: entry for {name}" + + +def test_install_script_defines_sudo_aware_helper() -> None: + assert "ensure_writable_dir()" in INSTALL_SCRIPT.read_text() + + +def test_netexec_provisioned_by_script() -> None: + # NetExec is not on PyPI, so the script fetches the prebuilt binary. The + # tools call it as `netexec`, so the single `nxc` binary must be symlinked. + script = INSTALL_SCRIPT.read_text() + assert "nxc-ubuntu-latest.zip" in script, "script must fetch the NetExec prebuilt binary" + assert "ln -sf nxc" in script, "the nxc binary must be symlinked to the `netexec` name" + + +def test_privilege_escalation_is_non_interactive() -> None: + # Bare `sudo` blocks on a password prompt on an image whose sudoers is not + # NOPASSWD; `sudo -n` fails fast instead, matching the SDK's apt step. + for line in INSTALL_SCRIPT.read_text().splitlines(): + stripped = line.strip() + if re.search(r"\bsudo\b", stripped) and stripped.startswith(("sudo", "if sudo", "&& sudo")): + assert re.search(r"\bsudo -n\b", stripped), f"interactive sudo: {stripped!r}" + + +def test_no_unguarded_privileged_mkdir() -> None: + # A bare `mkdir -p` of a system path aborts the load as the non-root user; + # such paths may only be created inside ensure_writable_dir. + for line in INSTALL_SCRIPT.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("mkdir ") and ("/usr/" in stripped or "/opt" in stripped): + raise AssertionError(f"unguarded privileged mkdir: {stripped!r}") + + +def test_clones_are_conditional() -> None: + # Every `git clone` must be the condition of an if/elif so a failure is + # caught; a bare `git clone` or `sudo git clone` statement aborts the script. + for line in INSTALL_SCRIPT.read_text().splitlines(): + stripped = line.strip() + if "git clone" not in stripped: + continue + assert re.match(r"(if |elif )", stripped), f"unguarded git clone: {stripped!r}" + + +def test_script_is_non_fatal_when_every_privileged_op_fails(tmp_path: Path) -> None: + """Run the real script with mkdir/sudo/git/curl stubbed to fail, so every + privileged and network operation errors; the script must still exit 0. + + This is the behavioral guard the static checks above cannot give: a future + unguarded command (a redirect, cp, tar -C /usr/local, ...) that reintroduces + the load-failure bug fails here even if it dodges the text matchers. + """ + stub_dir = tmp_path / "stubs" + stub_dir.mkdir() + for name in ("mkdir", "sudo", "git", "curl", "gunzip"): + stub = stub_dir / name + stub.write_text("#!/bin/sh\nexit 1\n") + stub.chmod(stub.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + env = dict(os.environ, PATH=f"{stub_dir}:{os.environ['PATH']}") + result = subprocess.run( + ["bash", str(INSTALL_SCRIPT)], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + assert ( + result.returncode == 0 + ), f"script aborted (exit {result.returncode}) when privileged ops failed:\n{result.stderr}"