-
Notifications
You must be signed in to change notification settings - Fork 0
feat: P1 hardening suite — approval gate, audit anchoring, egress, pre-flight, key-strength #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| """External anchoring of the audit-chain tip (#5). | ||
|
|
||
| In-file HMAC chaining proves tamper-evidence *only while the signing key is secret*. | ||
| If the key leaks, an attacker can rewrite the whole log and recompute every HMAC so it | ||
| re-verifies. Anchoring defends against that: the latest chain tip ``{seq, tip, ts}`` is | ||
| written to a SEPARATE file that production points at a write-once store the tool runner | ||
| cannot rewrite (S3 Object Lock compliance mode, a KMS-signed object, or a WORM volume | ||
| owned by a different uid). ``aegis audit`` then cross-checks the live chain against the | ||
| anchor, so a full-log rewrite is still detectable unless the attacker also forges the | ||
| out-of-band anchor — which the runner has no path to. | ||
|
|
||
| Best practice: "HMAC alone does not defend against a compromised app — layer append-only | ||
| storage and external anchoring on top" (Tracehold; CloudTrail log-file validation). | ||
|
|
||
| This module owns only the small, infra-agnostic anchor file format. The production WORM | ||
| target is an operational choice (point ``audit.anchor_path`` at the mounted WORM path). | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| def write_anchor(path: Path, seq: int, tip: str, ts: float) -> None: | ||
| """Overwrite the anchor with the current chain tip. One small JSON object, no chaining | ||
| (the anchor's protection comes from living on a write-once medium, not from a MAC).""" | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| path.write_text(json.dumps({"seq": seq, "tip": tip, "ts": round(ts, 3)}, | ||
| separators=(",", ":")) + "\n") | ||
|
|
||
|
|
||
| def read_anchor(path: Path) -> dict | None: | ||
| """Return the anchor record, or None if absent/unreadable.""" | ||
| if not path.exists(): | ||
| return None | ||
| try: | ||
| return json.loads(path.read_text()) | ||
| except (json.JSONDecodeError, OSError): | ||
| return None |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| """Parameter-bound, fail-closed approval tokens for high-risk run modes (#6). | ||
|
|
||
| A token authorizes a specific SET of modes (e.g. ``local`` and/or ``arm``) against an | ||
| EXACT set of targets until a hard expiry. It is an HMAC over | ||
| ``(canonical-modes, canonical-sorted-targets, expiry)`` keyed by the audit signing key, so: | ||
|
|
||
| * only an operator who holds the key can mint one (the LLM/agent never can), | ||
| * it cannot be replayed against different targets, and | ||
| * it stops working after it expires. | ||
|
|
||
| Best practice: bind approval to the exact action and fail closed — telling the model | ||
| "stay in scope" is not enforcement (OWASP AI Agent Security Cheat Sheet; ROE Gate). | ||
| Wildcard / open-ended grants are intentionally impossible: every token names its | ||
| targets and carries a TTL. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import base64 | ||
| import binascii | ||
| import hashlib | ||
| import hmac | ||
| import time | ||
|
|
||
| from .guardrail import canon_network | ||
|
|
||
|
|
||
| class ApprovalError(Exception): | ||
| """Raised when an approval token is missing, malformed, expired, or mismatched.""" | ||
|
|
||
|
|
||
| def _norm_modes(modes: str | list[str]) -> list[str]: | ||
| return [modes] if isinstance(modes, str) else list(modes) | ||
|
|
||
|
|
||
| def _canon_modes(modes: str | list[str]) -> str: | ||
| """Canonical mode string: deduped + sorted so {arm,local} == {local,arm}.""" | ||
| return "+".join(sorted(set(_norm_modes(modes)))) | ||
|
|
||
|
|
||
| def _canon_targets(targets: list[str]) -> str: | ||
| """Normalize targets to canonical networks, deduped + sorted, so the token binds to | ||
| the same set regardless of ordering or obfuscation (decimal/hex/leading-zero).""" | ||
| nets = sorted({str(canon_network(t)) for t in targets}) | ||
| return ",".join(nets) | ||
|
|
||
|
|
||
| def _body(modes: str | list[str], targets: list[str], expiry: int) -> str: | ||
| return f"v1|{_canon_modes(modes)}|{_canon_targets(targets)}|{expiry}" | ||
|
|
||
|
|
||
| def mint(modes: str | list[str], targets: list[str], key: str, *, ttl: int = 3600, | ||
| now: float | None = None) -> str: | ||
| """Mint a base64 token authorizing `modes` against `targets` for `ttl` seconds.""" | ||
| if ttl <= 0: | ||
| raise ApprovalError("ttl must be positive") | ||
| expiry = int((time.time() if now is None else now) + ttl) | ||
| mac = hmac.new(key.encode(), _body(modes, targets, expiry).encode(), hashlib.sha256).hexdigest() | ||
| return base64.urlsafe_b64encode(f"{expiry}.{mac}".encode()).decode() | ||
|
|
||
|
|
||
| def verify(token: str, modes: str | list[str], targets: list[str], key: str, | ||
| *, now: float | None = None) -> None: | ||
| """Raise ApprovalError unless `token` authorizes exactly `modes`+`targets` and is unexpired.""" | ||
| if not token: | ||
| raise ApprovalError( | ||
| f"{_canon_modes(modes)} mode requires an approval token (mint one with `aegis approve`)") | ||
| try: | ||
| raw = base64.urlsafe_b64decode(token.encode()).decode() | ||
| expiry_s, mac = raw.split(".", 1) | ||
| expiry = int(expiry_s) | ||
| except (binascii.Error, ValueError, UnicodeDecodeError) as exc: | ||
| raise ApprovalError("malformed approval token") from exc | ||
| now = time.time() if now is None else now | ||
| if now > expiry: | ||
| raise ApprovalError("approval token expired — mint a fresh one") | ||
| expect = hmac.new(key.encode(), _body(modes, targets, expiry).encode(), hashlib.sha256).hexdigest() | ||
| # compare_digest over the full token guards target/mode tampering AND a forged MAC. | ||
| if not hmac.compare_digest(mac, expect): | ||
| raise ApprovalError("approval token does not authorize this mode/target set") |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,49 @@ | ||||||||||||||||||||||||||||||||||||
| """Generate an nftables egress allow-list from the scope policy (#7). | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ``--sandbox local`` removes the container's network boundary, leaving the app-layer scope | ||||||||||||||||||||||||||||||||||||
| guard as the only thing between the tool runner and the wider network. A kernel firewall | ||||||||||||||||||||||||||||||||||||
| restores defense-in-depth: permit egress ONLY to the policy's allowed CIDRs (minus the | ||||||||||||||||||||||||||||||||||||
| denied ones), drop everything else. Apply this on the disposable recon host BEFORE an | ||||||||||||||||||||||||||||||||||||
| off-lab run — a guardrail bug or an HTTP redirect then still cannot reach an out-of-scope | ||||||||||||||||||||||||||||||||||||
| host, because the packet never leaves the box. | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Best practice: enforce scope at the network layer, not just the app (Aikido); exclusions | ||||||||||||||||||||||||||||||||||||
| beat authorizations (IntegSec agentic-pentest proxy). This emits the ruleset; the operator | ||||||||||||||||||||||||||||||||||||
| applies it with ``nft -f`` (it does not touch the live firewall itself). | ||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| from .config import Policy | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| _TABLE = "argus_egress" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def nftables_ruleset(policy: Policy) -> str: | ||||||||||||||||||||||||||||||||||||
| """Render a deterministic nftables script: allow established + loopback + the policy's | ||||||||||||||||||||||||||||||||||||
| in-scope networks (denied ones carved out first), default-drop new egress.""" | ||||||||||||||||||||||||||||||||||||
| allowed = [n for n in policy.allowed_networks] | ||||||||||||||||||||||||||||||||||||
| denied = [str(d) for d in policy.denied_networks] | ||||||||||||||||||||||||||||||||||||
| lines = [ | ||||||||||||||||||||||||||||||||||||
| "#!/usr/sbin/nft -f", | ||||||||||||||||||||||||||||||||||||
| "# Argus egress allow-list — generated from the scope policy. Apply on the", | ||||||||||||||||||||||||||||||||||||
| "# disposable recon host before `--sandbox local`. Fail-closed: default drop.", | ||||||||||||||||||||||||||||||||||||
| f"add table inet {_TABLE}", | ||||||||||||||||||||||||||||||||||||
| f"delete table inet {_TABLE}", | ||||||||||||||||||||||||||||||||||||
| f"table inet {_TABLE} {{", | ||||||||||||||||||||||||||||||||||||
| " chain output {", | ||||||||||||||||||||||||||||||||||||
| " type filter hook output priority 0; policy drop;", | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+26
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (bug_risk): The generated nftables script adds and then deletes the table before redefining it, which is confusing and potentially error-prone. The
Suggested change
|
||||||||||||||||||||||||||||||||||||
| " ct state established,related accept", | ||||||||||||||||||||||||||||||||||||
| " oifname \"lo\" accept", | ||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||
| # Denied carve-outs first so a /32 deny inside an allowed /24 wins (firewall semantics). | ||||||||||||||||||||||||||||||||||||
| for d in denied: | ||||||||||||||||||||||||||||||||||||
| lines.append(f" ip daddr {d} drop") | ||||||||||||||||||||||||||||||||||||
| if allowed: | ||||||||||||||||||||||||||||||||||||
| joined = ", ".join(str(a) for a in allowed) | ||||||||||||||||||||||||||||||||||||
| lines.append(f" ip daddr {{ {joined} }} accept") | ||||||||||||||||||||||||||||||||||||
| lines += [ | ||||||||||||||||||||||||||||||||||||
| " # everything else: dropped by chain policy", | ||||||||||||||||||||||||||||||||||||
| " }", | ||||||||||||||||||||||||||||||||||||
| "}", | ||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||
| return "\n".join(lines) + "\n" | ||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚨 suggestion (security): Approval minting only checks that the audit key env is non-empty, not that it meets the minimum key-length requirement enforced elsewhere.
AuditLog enforces
MIN_AUDIT_KEY_LEN, butcmd_approveonly checks thataudit_key_envis non-empty. This lets operators mint approval tokens with weak or placeholder keys, breaking the security assumptions shared with the audit chain. Please reuse the same minimum-length check (or initialize AuditLog here) so approval minting fails on weak keys.Suggested implementation:
To fully implement this change you will also need to:
Ensure
os,sys, andAuditLogare imported at the top ofaegis/aegis/cli.py. For example, add:import osimport sysfrom aegis.audit import AuditLog(or the correct module path whereAuditLogis defined).If
AuditLog.MIN_AUDIT_KEY_LENis not a public attribute, expose or import the constant used byAuditLogfor the minimum key length and reference that instead.If
cmd_approveneeds the actual key material later (e.g., to mint the token), reuse the validatedaudit_keyvariable rather than re-reading from the environment.