diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d388703 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +# Argus security CI — shift-left gates for a tool that ships offensive capability. +# Hardening per OpenSSF post-tj-actions guidance: every action pinned to a full +# commit SHA (never a mutable tag), least-privilege GITHUB_TOKEN, no secrets. +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +defaults: + run: + working-directory: aegis + +jobs: + test: + name: tests (pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: aegis/requirements.txt + - name: Install deps + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run test suite + run: python -m pytest -q + + sast: + name: SAST (Bandit) — blocking + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.12" + - name: Install Bandit + run: pip install "bandit[toml]==1.8.6" + # Blocks the build on medium+ severity / medium+ confidence findings. + - name: Run Bandit (medium+) + run: bandit -c pyproject.toml -r aegis --severity-level medium --confidence-level medium + + deps: + name: Dependency audit (pip-audit) — informational + runs-on: ubuntu-latest + # Informational until requirements are hash-pinned + triaged (see NEXT_STEPS.md + # P0.3). Flip to blocking once the dependency tree is locked. + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.12" + - name: Install pip-audit + run: pip install pip-audit==2.10.0 + - name: Audit dependencies + run: pip-audit -r requirements.txt --desc + + secrets: + name: Secret scan (detect-secrets) — informational + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.12" + - name: Install detect-secrets + run: pip install detect-secrets==1.5.0 + - name: Scan working tree for secrets + run: detect-secrets scan aegis targets diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e0f9d8f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +# Shift-left mirror of the CI gates (.github/workflows/ci.yml). Run locally with: +# pip install pre-commit && pre-commit install +# CI remains the authoritative gate (pre-commit can be skipped with --no-verify). +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-merge-conflict + - id: detect-private-key + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix] + files: ^aegis/ + + - repo: https://github.com/PyCQA/bandit + rev: 1.8.6 + hooks: + - id: bandit + args: ["-c", "aegis/pyproject.toml", "--severity-level", "medium", + "--confidence-level", "medium"] + files: ^aegis/aegis/ diff --git a/aegis/aegis/agent/planner.py b/aegis/aegis/agent/planner.py index 0e53625..9869608 100644 --- a/aegis/aegis/agent/planner.py +++ b/aegis/aegis/agent/planner.py @@ -15,7 +15,7 @@ from dataclasses import dataclass, field -from ..tools import Observation, PROFILES +from ..tools import PROFILES, Observation # Which follow-up profile each evidence signal suggests (read-only profiles only). # signal-substring -> profile key diff --git a/aegis/aegis/ai_analyzer.py b/aegis/aegis/ai_analyzer.py index 27f4ed6..f01b516 100644 --- a/aegis/aegis/ai_analyzer.py +++ b/aegis/aegis/ai_analyzer.py @@ -43,7 +43,7 @@ def resolve_provider(override: str | None = None) -> str: def list_ollama_models() -> list[str]: """Installed local models via GET /api/tags (empty list if Ollama is down).""" try: - with urllib.request.urlopen(f"{OLLAMA_HOST}/api/tags", timeout=3) as resp: # noqa: S310 + with urllib.request.urlopen(f"{OLLAMA_HOST}/api/tags", timeout=3) as resp: # nosec B310 fixed http scheme # noqa: S310 data = json.loads(resp.read()) return sorted(m.get("name", "") for m in data.get("models", []) if m.get("name")) except Exception: # noqa: BLE001 @@ -184,10 +184,10 @@ def _llm_complete(system: str, user: str, model: str, *, budget=None, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], }).encode() - req = urllib.request.Request( + req = urllib.request.Request( # noqa: S310 f"{OLLAMA_HOST}/api/chat", data=payload, headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req, timeout=120) as resp: # noqa: S310 (local host) + with urllib.request.urlopen(req, timeout=120) as resp: # nosec B310 fixed http scheme # noqa: S310 data = json.loads(resp.read()) if budget: tok = data.get("prompt_eval_count", 0) + data.get("eval_count", 0) diff --git a/aegis/aegis/cli.py b/aegis/aegis/cli.py index 1e3b62b..006e491 100644 --- a/aegis/aegis/cli.py +++ b/aegis/aegis/cli.py @@ -74,7 +74,12 @@ def cmd_host(args) -> int: if _refuse_if_out_of_scope(guard, args.target): return 2 if args.os == "windows": - from .host.winrm_collector import DryRunWinRM, WinHostOrchestrator, WinRMCollector, WinRMCreds + from .host.winrm_collector import ( + DryRunWinRM, + WinHostOrchestrator, + WinRMCollector, + WinRMCreds, + ) creds = WinRMCreds(user=args.user, password=args.password or "", https=not args.winrm_http, verify_cert=not args.winrm_insecure) if args.fixture: diff --git a/aegis/aegis/config.py b/aegis/aegis/config.py index 7896ecb..4f1f2f5 100644 --- a/aegis/aegis/config.py +++ b/aegis/aegis/config.py @@ -37,7 +37,7 @@ class Policy: redact_patterns: tuple[str, ...] = field(default=()) @staticmethod - def load(path: Path | str = DEFAULT_POLICY) -> "Policy": + def load(path: Path | str = DEFAULT_POLICY) -> Policy: data = yaml.safe_load(Path(path).read_text()) scope = data.get("scope", {}) fw = data.get("tool_firewall", {}) diff --git a/aegis/aegis/orchestrator.py b/aegis/aegis/orchestrator.py index 4b167cc..814051e 100644 --- a/aegis/aegis/orchestrator.py +++ b/aegis/aegis/orchestrator.py @@ -49,10 +49,12 @@ def run(self, plan: list[ScanStep]) -> ScanResult: ex = self.sandbox.run(argv, timeout=timeout) self.guard.record(tool.binary, exit_code=ex.exit_code, summary=self.guard.sanitize(ex.stdout[:300])) - # Surface a missing binary (shell exit 127 = command not found) so a tool - # never silently no-ops. (Don't match 'not found' in output — tools like + # Surface a missing binary so a tool never silently no-ops. The sandbox + # sets tool_missing only when the binary was never launched — a tool that + # ran and exited 127 is a real failure, not a missing tool, and is left to + # parse/observe normally. (Don't match 'not found' in output — tools like # snmpwalk legitimately print that for empty results.) - if ex.exit_code == 127: + if ex.tool_missing: result.errors.append(f"{step.tool_key}: tool unavailable in sandbox (not installed)") obs = tool.parse(self.guard.sanitize(ex.stdout), step.target) result.observations.extend(obs) diff --git a/aegis/aegis/recon/web.py b/aegis/aegis/recon/web.py index b626c72..c12ef37 100644 --- a/aegis/aegis/recon/web.py +++ b/aegis/aegis/recon/web.py @@ -15,7 +15,7 @@ import urllib.request from dataclasses import dataclass -from typing import Callable, Protocol +from typing import Protocol from ..tools import Observation @@ -79,17 +79,17 @@ def __init__(self, max_body: int = 4096): self.max_body = max_body def get(self, url: str, timeout: int) -> HttpResult: - req = urllib.request.Request(url, method="GET", + req = urllib.request.Request(url, method="GET", # noqa: S310 headers={"User-Agent": "Argus-Recon/1.0"}) try: - with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 scheme validated by scope guard # noqa: S310 body = resp.read(self.max_body).decode("utf-8", "replace") return HttpResult(resp.status, body, resp.headers.get("Server", "")) except urllib.error.HTTPError as exc: # 401/403/404 still informative body = b"" try: body = exc.read(self.max_body) - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001, S110 - error body is best-effort pass return HttpResult(exc.code, body.decode("utf-8", "replace"), exc.headers.get("Server", "") if exc.headers else "") diff --git a/aegis/aegis/reporting.py b/aegis/aegis/reporting.py index e2ece1b..ecf7cd7 100644 --- a/aegis/aegis/reporting.py +++ b/aegis/aegis/reporting.py @@ -9,8 +9,6 @@ from datetime import date from pathlib import Path -from .ai_analyzer import Finding - def collapse_errors(errors: list[str]) -> list[str]: """Collapse repeated denials across targets into one summarized line each. diff --git a/aegis/aegis/sandbox.py b/aegis/aegis/sandbox.py index c05becf..b1b57bd 100644 --- a/aegis/aegis/sandbox.py +++ b/aegis/aegis/sandbox.py @@ -27,6 +27,10 @@ class ExecResult: stdout: str stderr: str timed_out: bool = False + # True only when the sandbox KNOWS the binary was never launched (missing on + # PATH / empty argv). A tool that actually ran and exited 127 leaves this False, + # so the orchestrator can stop mislabeling a real failure as "not installed". + tool_missing: bool = False def _exec_argv(argv: list[str], timeout: int, @@ -41,10 +45,10 @@ def _exec_argv(argv: list[str], timeout: int, scrubbed allowlist. """ try: - p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, env=env, start_new_session=True) # noqa: S603 (argv, no shell) + p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, # noqa: S603 + text=True, env=env, start_new_session=True) # argv array, never shell=True except FileNotFoundError as exc: - return ExecResult(127, "", str(exc)) + return ExecResult(127, "", str(exc), tool_missing=True) try: out, err = p.communicate(timeout=timeout) return ExecResult(p.returncode, out, err) @@ -68,7 +72,12 @@ def __init__(self, compose_file: Path | str, service: str = "attacker"): def run(self, argv: list[str], timeout: int) -> ExecResult: cmd = ["docker", "compose", "-f", self.compose_file, "exec", "-T", self.service, *argv] - return _exec_argv(cmd, timeout) + res = _exec_argv(cmd, timeout) + # `docker exec` returns 127 for a missing in-container binary; we can't tell + # that apart from a tool that ran and exited 127, so keep the heuristic here. + if res.exit_code == 127 and not res.timed_out: + res.tool_missing = True + return res # Minimal environment for host-spawned tools. This is an ALLOWLIST, not a @@ -110,9 +119,10 @@ def _safe_env(self) -> dict[str, str]: def run(self, argv: list[str], timeout: int) -> ExecResult: if not argv: - return ExecResult(127, "", "empty argv") + return ExecResult(127, "", "empty argv", tool_missing=True) if not shutil.which(argv[0]): - return ExecResult(127, "", f"{argv[0]}: not installed on host") + return ExecResult(127, "", f"{argv[0]}: not installed on host", tool_missing=True) + # A real run that exits 127 keeps tool_missing=False — it is NOT a missing tool. return _exec_argv(argv, timeout, env=self._safe_env()) diff --git a/aegis/aegis/tools.py b/aegis/aegis/tools.py index 1ef6617..9d2d9bc 100644 --- a/aegis/aegis/tools.py +++ b/aegis/aegis/tools.py @@ -9,8 +9,8 @@ import json import re +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable # defusedxml hardens against XXE / billion-laughs — tool output is UNTRUSTED. try: @@ -50,7 +50,7 @@ class Tool: def _nmap_xml(text: str, target: str) -> list[Observation]: obs: list[Observation] = [] try: - root = ET.fromstring(text) + root = ET.fromstring(text) # nosec B314 ET is defusedxml (see import) # noqa: S314 except ParseError: return obs for host in root.findall("host"): diff --git a/aegis/aegis/web.py b/aegis/aegis/web.py index 01fdfb4..fcb6a21 100644 --- a/aegis/aegis/web.py +++ b/aegis/aegis/web.py @@ -45,7 +45,7 @@ class HostRequest(BaseModel): target: str os: str = "linux" # linux | windows user: str = "pentest" - password: str = "pentest" + password: str = "pentest" # noqa: S105 - lab default cred, overridden by operator profile: str = "host-linux" dry_run: bool = False # windows dry-run when no live host provider: str | None = None diff --git a/aegis/docs/NEXT_STEPS.md b/aegis/docs/NEXT_STEPS.md new file mode 100644 index 0000000..8ef9ad5 --- /dev/null +++ b/aegis/docs/NEXT_STEPS.md @@ -0,0 +1,113 @@ +# Argus — Recommended Next Steps + +Prioritized, best-practice-grounded roadmap for hardening the **process** around Argus +(an agentic, read-only pentest orchestrator operated against a HIPAA/PHI network). Each +item cites the external guidance it is grounded in. The guardrail architecture itself is +already strong and maps cleanly onto the reference-monitor pattern these sources describe — +so this list is about closing **process / operational** gaps, not redesigning what works. + +Legend: ✅ done in this pass · 🔭 tracked as a GitHub issue. + +--- + +## P0 — Supply-chain & CI (the biggest process hole) + +### ✅ 1. Security CI pipeline (`.github/workflows/ci.yml`) +The repo previously had **zero CI** — for a tool that ships offensive capability and a +`--sandbox local` host-exec path, that was the highest-leverage gap. Added blocking gates: +- **pytest** — the 81-test suite is now enforced on every PR. +- **Bandit (medium+)** — AST SAST for the `subprocess` / `shell` / partial-path classes + this codebase lives in. +- **pip-audit** — dependency CVEs (informational until deps are pinned, see P0.3). +- **detect-secrets** — secret scan (informational). + +> *Best practice:* the 4-gate Python DevSecOps pattern (SAST + secret-scan + AST + dep-audit) +> is the consensus baseline; CI is "the authoritative gate" because pre-commit can be skipped +> with `--no-verify`. — `thunderstornX/secure-python-pipeline-template`, +> `developmentseed/action-python-security-auditing`. + +### ✅ 2. Workflow supply-chain hardening +Every GitHub Action is **pinned to a full commit SHA** (never `@v4`/`@main`); jobs use +least-privilege `permissions: contents: read`; no long-lived secrets. + +> *Best practice:* OpenSSF post-`tj-actions`/`reviewdog` guide — mutable tags were the root +> cause of those 2025 supply-chain compromises. — OpenSSF Maintainers' Guide (2025). + +### 🔭 3. Pin dependencies + generate an SBOM +`requirements.txt` still uses `>=` ranges. Move to a hash-pinned lockfile +(`pip-compile --generate-hashes` or `uv lock`), emit a Syft SBOM as a CI artifact, then flip +the `pip-audit` job from informational to **blocking**. For a security tool, reproducible +builds are table stakes. + +--- + +## P1 — Audit-integrity & authorization, to best-of-breed + +### 🔭 4. Move the HMAC signing key out-of-band from the tool runner +The merged fix stopped the key leaking into *child* tools, but the **orchestrator process +still holds the key** while it spawns offensive tools. Short term: enforce `SECURITY.md`'s +own guidance in code — refuse to start if the audit key path is owned by the same uid as the +runner or has loose perms (fail-to-start, don't bypass). Longer term: a tiny out-of-band +signer the orchestrator talks to but never holds the key for. + +> *Best practice:* "the agent never touches the signing keys" (ROE Gate, reference-monitor / +> Anderson 1972); "the key sits outside the log volume so an attacker who can write the log +> can't read or rotate the key" (`bernstein` audit-log operations doc). + +### 🔭 5. External anchoring + WORM for the audit chain +HMAC chaining alone does not survive a key compromise. On a timer, write the latest chain +tip to a destination the runner can't rewrite (S3 Object Lock compliance mode, or a +KMS-signed artifact) and have the verifier cross-check it. + +> *Best practice:* "HMAC alone does not defend against a compromised app — layer append-only +> storage and external anchoring on top." — Tracehold; SystemsHardening audit-logging +> architecture (CloudTrail-style log-file validation). + +### 🔭 6. Risk-tiered approval gate for `--sandbox local` / `--arm` +Replace the current warning banner with a real, parameter-bound acknowledgment: bind +approval to the exact action (actor + tool + normalized target + expiry), fail closed on +missing approval, and forbid wildcard grants for the un-isolated local path. + +> *Best practice:* "system-prompt guardrails don't guard anything… agents take risky actions +> 23.9% of the time even with explicit safety instructions" (ROE Gate); "bind approval to the +> exact action… fail closed when approval validation fails" (OWASP AI Agent Security Cheat +> Sheet); avoid wildcard trust grants (AWS Well-Architected, agentic-AI lens). + +### 🔭 7. Network-layer egress control for the local path +`--sandbox local` now relies *solely* on the app-layer scope guard (the container boundary is +gone). Best-of-breed pentest agents enforce scope at the **network/DNS layer** too, so a +guardrail bug or HTTP redirect can't reach an out-of-scope host. Even a documented +`nftables` egress-allowlist helper (matching the project's own two-node disposable-host +guidance) adds the missing second layer. + +> *Best practice:* "we don't rely on prompts or humans for scope enforcement — we enforce at +> the network layer, intercepting HTTP and DNS" (Aikido); "exclusions beat authorizations; +> DNS failures are blocks" (IntegSec agentic-pentest proxy). + +--- + +## P2 — Correctness & polish + +### ✅ 8. Fix the orchestrator exit-code `127` conflation +`ExecResult` now carries a `tool_missing` flag set only when the sandbox *knows* a binary was +never launched. A tool that runs and genuinely exits 127 is no longer mislabeled +"not installed" — it parses and observes normally. DockerSandbox keeps its 127 heuristic +(`docker exec` can't distinguish), LocalSandbox flags only synthesized-missing. Covered by +new tests in `tests/test_sandbox.py`. + +### ✅ 9. Centralize tool config + pre-commit +Added `aegis/pyproject.toml` (Bandit / Ruff / pytest config) and `.pre-commit-config.yaml` +mirroring the CI gates for shift-left feedback. + +### 🔭 10. Pre-flight "is this production?" checks +Before a `--sandbox local` run, validate reachability and surface "this resembles production" +warnings early — "configuration mistakes are more likely than malicious behaviour." — Aikido +pre-flight checks. + +--- + +## Suggested sequencing +`P0 (CI + pinning)` → `P1.6 approval gate + P2.8 127-fix (done)` → `P1.4/5 key isolation + +anchoring` → `P1.7 network egress` → `P2 polish`. + +_Tracking issues are linked from each 🔭 item once opened._ diff --git a/aegis/pyproject.toml b/aegis/pyproject.toml new file mode 100644 index 0000000..95bbbb6 --- /dev/null +++ b/aegis/pyproject.toml @@ -0,0 +1,27 @@ +# Centralized tool config for Argus (Bandit / Ruff / pytest). Consumed by CI and +# pre-commit so local and gated checks stay identical. + +[tool.bandit] +# Test fixtures intentionally contain insecure patterns; scan only shipped code. +exclude_dirs = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +# High-signal set: S = flake8-bandit (subprocess/shell rules this codebase relies on), +# F = pyflakes, I = import sorting, B = bugbear, UP = pyupgrade. E (pycodestyle line +# length etc.) is intentionally omitted — Bandit + these cover the security-relevant +# ground without 36 cosmetic line-length nits. +select = ["F", "I", "B", "UP", "S"] +# S404 (import subprocess) is unavoidable for a tool runner; argv-only call sites are +# individually vetted with inline `# noqa: S603`. +ignore = ["S404"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "S603", "S607"] # asserts + subprocess are expected in tests + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/aegis/tests/test_integration_agentic.py b/aegis/tests/test_integration_agentic.py index 22894b8..9c89969 100644 --- a/aegis/tests/test_integration_agentic.py +++ b/aegis/tests/test_integration_agentic.py @@ -16,6 +16,7 @@ class _Exec: exit_code: int stdout: str + tool_missing: bool = False class _FakeSandbox: diff --git a/aegis/tests/test_sandbox.py b/aegis/tests/test_sandbox.py index f400d0c..1868d88 100644 --- a/aegis/tests/test_sandbox.py +++ b/aegis/tests/test_sandbox.py @@ -54,3 +54,21 @@ def test_localsandbox_empty_argv_does_not_crash(_which_ok): sb = LocalSandbox(audit_key_env="X") res = sb.run([], timeout=5) assert res.exit_code == 127 + assert res.tool_missing is True + + +def test_localsandbox_real_127_is_not_flagged_missing(_which_ok): + # A tool that actually runs and exits 127 must NOT be reported as "not installed". + sb = LocalSandbox(audit_key_env="X") + res = sb.run([sys.executable, "-c", "raise SystemExit(127)"], timeout=30) + assert res.exit_code == 127 + assert res.tool_missing is False + + +def test_localsandbox_missing_binary_sets_tool_missing(monkeypatch): + monkeypatch.setattr("aegis.sandbox.shutil.which", + lambda b: "/usr/bin/nmap" if b in ("nmap", "fping") else None) + sb = LocalSandbox(audit_key_env="X") + res = sb.run(["definitely-not-installed", "172.30.0.10"], timeout=5) + assert res.exit_code == 127 + assert res.tool_missing is True