diff --git a/capabilities/web-security/capability.yaml b/capabilities/web-security/capability.yaml index 8a4bca3..f061e10 100644 --- a/capabilities/web-security/capability.yaml +++ b/capabilities/web-security/capability.yaml @@ -1,6 +1,6 @@ schema: 1 name: web-security -version: "2.0.1" +version: "2.0.2" description: > Web application penetration testing with 83 attack technique playbooks covering HTTP desync/request smuggling, cache poisoning, SSRF, SSTI, DOM @@ -181,33 +181,6 @@ checks: command: 'command -v interactsh-client >/dev/null 2>&1 || test -x "$HOME/go/bin/interactsh-client"' - name: protoscope command: 'command -v protoscope >/dev/null 2>&1 || test -x "$HOME/go/bin/protoscope"' - # The capability talks to Caido over CAIDO_URL using the Python client, not - # by driving the local binary (see mcp/caido.py). Asserting only the binary - # reports a correctly configured deployment as degraded — self-hosted - # installs may not ship caido-cli at all and instead point at a Caido the - # operator runs. Local binary or reachable endpoint, either satisfies it. - - name: caido-cli - command: 'command -v caido-cli >/dev/null 2>&1 || curl -fsS --max-time 3 -o /dev/null "${CAIDO_URL:-http://localhost:8080}"' - - name: caido-mcp-server - command: 'command -v caido-mcp-server >/dev/null 2>&1 || test -x "$HOME/bin/caido-mcp-server"' - - name: caido-mode - command: 'test -f skills/caido-mode/caido-client.ts && test -d skills/caido-mode/node_modules' - - name: burp - command: test -f /opt/burp/burpsuite.jar - - name: waymore - command: command -v waymore - - name: pacu - command: command -v pacu - - name: fireprox - command: 'test -f "$HOME/git/fireprox/fire.py"' - - name: archivealchemist - command: 'test -f "$HOME/git/archivealchemist/archive-alchemist.py"' - - name: exiftool - command: command -v exiftool - - name: ast-grep - command: command -v ast-grep - - name: wrangler - command: command -v wrangler author: name: Dreadnode diff --git a/capabilities/web-security/scripts/install_tools.sh b/capabilities/web-security/scripts/install_tools.sh index e456242..15442af 100755 --- a/capabilities/web-security/scripts/install_tools.sh +++ b/capabilities/web-security/scripts/install_tools.sh @@ -22,14 +22,26 @@ failed_stages=() run_stage() { local name="$1" status shift + # Sealed images supply their tools at build time. Validate what is present + # without entering any installer (including retry loops and package managers). + if [ "${DREADNODE_CAPABILITY_INSTALL:-}" = "sealed" ] && [ "$name" != validation ]; then + return 0 + fi echo "web-security: starting $name" >&2 set +e ( set -e; "$@" ) status=$? set -e if [ "$status" -ne 0 ]; then - failed_stages+=("$name") - echo "web-security: $name failed (exit $status)" >&2 + case "$name" in + caido_cli|caido_mcp|burp|exiftool|browser|caido_mode|wrangler|ast_grep|waymore|pacu|fireprox|archivealchemist) + echo "WARN: optional stage $name failed (exit $status); continuing with available tools" >&2 + ;; + *) + failed_stages+=("$name") + echo "web-security: $name failed (exit $status)" >&2 + ;; + esac fi } @@ -462,13 +474,15 @@ validate_tools() { for tool in nuclei httpx subfinder naabu dnsx uncover alterx tlsx asnmap; do have_pd_tool "$tool" || { echo "Missing required tool: $tool" >&2; missing=1; } done - for tool in katana protoscope interactsh-client 2fa kr caido-cli caido-mcp-server \ - burp exiftool agent-browser wrangler ast-grep waymore pacu; do + for tool in katana protoscope interactsh-client 2fa kr; do have "$tool" || { echo "Missing required tool: $tool" >&2; missing=1; } done + for tool in caido-cli caido-mcp-server burp exiftool agent-browser wrangler ast-grep waymore pacu; do + have "$tool" || echo "WARN: optional tool unavailable: $tool" >&2 + done for artifact in /opt/burp/burpsuite.jar "$HOME/git/fireprox/fire.py" \ "$HOME/git/archivealchemist/archive-alchemist.py"; do - [ -s "$artifact" ] || { echo "Missing required artifact: $artifact" >&2; missing=1; } + [ -s "$artifact" ] || echo "WARN: optional artifact unavailable: $artifact" >&2 done return "$missing" } @@ -479,8 +493,8 @@ if [ "${#failed_stages[@]}" -gt 0 ]; then exit 1 fi # Retain downloaded modules after a failed pass so the next pass can reuse them. -if [ "$need_go" = true ]; then +if [ "${DREADNODE_CAPABILITY_INSTALL:-}" != "sealed" ] && [ "$need_go" = true ]; then go clean -cache -modcache 2>/dev/null || true fi -echo "web-security tools installed successfully" +echo "web-security required tools installed successfully; optional tools may be unavailable" diff --git a/capabilities/web-security/tests/test_caido_go_mcp.py b/capabilities/web-security/tests/test_caido_go_mcp.py index e53f151..e90e6ce 100644 --- a/capabilities/web-security/tests/test_caido_go_mcp.py +++ b/capabilities/web-security/tests/test_caido_go_mcp.py @@ -74,13 +74,10 @@ def test_coexists_with_python_caido_server(self) -> None: class TestCaidoGoCheck: - def test_presence_check_registered(self) -> None: - checks = {c["name"]: c["command"] for c in MANIFEST["checks"]} - assert "caido-mcp-server" in checks - cmd = checks["caido-mcp-server"] - # Accept a PATH install or the c0tton-fluff install.sh default (~/bin). - assert "command -v caido-mcp-server" in cmd - assert "$HOME/bin/caido-mcp-server" in cmd + def test_optional_tool_is_not_health_checked(self) -> None: + # Optional tools may be absent at runtime, so their presence is not asserted. + checks = {c["name"] for c in MANIFEST["checks"]} + assert "caido-mcp-server" not in checks # ============================================================================= diff --git a/capabilities/web-security/tests/test_caido_mode_skill.py b/capabilities/web-security/tests/test_caido_mode_skill.py index 1771f97..b64fdef 100644 --- a/capabilities/web-security/tests/test_caido_mode_skill.py +++ b/capabilities/web-security/tests/test_caido_mode_skill.py @@ -242,14 +242,10 @@ def test_node_is_available_before_skill_install(self) -> None: skill_install = INSTALL_SCRIPT.index("CAIDO_MODE_DIR=") assert node_setup < skill_install - def test_presence_check_registered(self) -> None: - checks = {c["name"]: c["command"] for c in MANIFEST["checks"]} - assert "caido-mode" in checks - command = checks["caido-mode"] - # Both the entrypoint and the installed deps — either alone is a - # false green. - assert "skills/caido-mode/caido-client.ts" in command - assert "skills/caido-mode/node_modules" in command + def test_optional_tool_is_not_health_checked(self) -> None: + # Optional tools may be absent at runtime, so their presence is not asserted. + checks = {c["name"] for c in MANIFEST["checks"]} + assert "caido-mode" not in checks # ============================================================================= @@ -260,9 +256,10 @@ def test_presence_check_registered(self) -> None: class TestCaidoSurfacesCoexist: def test_all_four_surfaces_are_declared(self) -> None: servers = MANIFEST["mcp"]["servers"] - checks = {c["name"] for c in MANIFEST["checks"]} assert "caido" in servers and "caido-go" in servers - assert {"caido-cli", "caido-mcp-server", "caido-mode"} <= checks + # Optional tools may be absent at runtime, so their presence is not asserted. + checks = {c["name"] for c in MANIFEST["checks"]} + assert not ({"caido-cli", "caido-mcp-server", "caido-mode"} & checks) def test_sibling_caido_skills_present(self) -> None: for name in ("caido-sdk", "caido-proxy"): diff --git a/capabilities/web-security/tests/test_install_tools_offline.py b/capabilities/web-security/tests/test_install_tools_offline.py index 4266be4..4ecfaff 100644 --- a/capabilities/web-security/tests/test_install_tools_offline.py +++ b/capabilities/web-security/tests/test_install_tools_offline.py @@ -540,8 +540,8 @@ def test_partial_npm_directory_does_not_prevent_repair( script = _installer_fixture(tmp_path) (tmp_path / "skills/caido-mode/node_modules/complete").unlink() first = _run_installer(script, NPM_FAIL="1") - assert first.returncode == 1 - assert "failed stages: caido_mode" in first.stderr + assert first.returncode == 0, first.stderr + assert "optional stage caido_mode failed" in first.stderr second = _run_installer(script) assert second.returncode == 0, second.stderr assert (tmp_path / "skills/caido-mode/node_modules/complete").is_file() @@ -551,8 +551,8 @@ def test_fireprox_requirements_retry_after_successful_clone(tmp_path: Path) -> N script = _installer_fixture(tmp_path) (tmp_path / "git/fireprox/.dreadnode-deps-installed").unlink() first = _run_installer(script) - assert first.returncode == 1 - assert "failed stages: fireprox" in first.stderr + assert first.returncode == 0, first.stderr + assert "optional stage fireprox failed" in first.stderr assert not (tmp_path / "git/fireprox/.dreadnode-deps-installed").exists() _command(tmp_path / "bin/uv", "exit 0") second = _run_installer(script) @@ -574,7 +574,8 @@ def test_failed_burp_download_is_not_published_and_recovers(tmp_path: Path) -> N """, ) first = _run_installer(script, DOWNLOAD_FAIL="1") - assert first.returncode == 1 + assert first.returncode == 0, first.stderr + assert "optional stage burp failed" in first.stderr assert not jar.exists() assert len((tmp_path / "downloads").read_text().splitlines()) == 3 second = _run_installer(script) @@ -585,9 +586,10 @@ def test_failed_burp_download_is_not_published_and_recovers(tmp_path: Path) -> N def test_sealed_install_does_not_download_missing_browser(tmp_path: Path) -> None: script = _installer_fixture(tmp_path) (tmp_path / ".cache/agent-browser").rename(tmp_path / "saved-browser") + _command(tmp_path / "bin/nuclei", "exit 0") result = _run_installer(script, DREADNODE_CAPABILITY_INSTALL="sealed") assert result.returncode == 0, result.stderr - assert "browser-install" not in (tmp_path / "commands").read_text() + assert not (tmp_path / "commands").exists() def test_clone_retry_uses_fresh_staging_after_partial_failure(tmp_path: Path) -> None: @@ -697,13 +699,15 @@ def test_python_probe_failure_does_not_attempt_install(tmp_path: Path) -> None: @pytest.mark.parametrize("sealed", [True, False]) def test_existing_browser_cache_needs_no_marker(tmp_path: Path, sealed: bool) -> None: script = _installer_fixture(tmp_path) + _command(tmp_path / "bin/nuclei", "exit 0") result = _run_installer( script, BROWSER_FAIL="1", DREADNODE_CAPABILITY_INSTALL="sealed" if sealed else "", ) assert result.returncode == 0, result.stderr - assert "browser-install" not in (tmp_path / "commands").read_text() + commands = tmp_path / "commands" + assert not commands.exists() or "browser-install" not in commands.read_text() def test_optional_browser_download_failure_does_not_fail_install( @@ -771,3 +775,39 @@ def test_python_probe_checks_install_destinations( else: assert result.returncode != 0 assert "installed" not in result.stdout + + +@pytest.mark.parametrize("missing_required", [False, True]) +def test_sealed_install_checks_local_tools_without_fetches_or_retries( + tmp_path: Path, missing_required: bool +) -> None: + script = _installer_fixture(tmp_path) + if not missing_required: + _command(tmp_path / "bin/nuclei", "exit 0") + for name in ("caido-cli", "caido-mcp-server", "burp", "waymore", "pacu"): + (tmp_path / "bin" / name).unlink() + for relative in ( + "opt/burp", + "git/fireprox", + "git/archivealchemist", + "skills/caido-mode/node_modules", + ): + (tmp_path / relative).rename(tmp_path / Path(relative).name) + _command(tmp_path / "bin/node", "echo 18") + _command(tmp_path / "bin/sleep", 'echo sleep >> "$HOME/commands"; exit 97') + result = _run_installer(script, DREADNODE_CAPABILITY_INSTALL="sealed") + assert result.returncode == (1 if missing_required else 0), result.stderr + assert not (tmp_path / "commands").exists() + assert not (tmp_path / "go-attempts").exists() + for name in ( + "caido-cli", + "caido-mcp-server", + "burp", + "wrangler", + "waymore", + "pacu", + ): + assert f"WARN: optional tool unavailable: {name}" in result.stderr + assert "WARN: optional artifact unavailable:" in result.stderr + if missing_required: + assert "Missing required tool: nuclei" in result.stderr