diff --git a/README.md b/README.md index 9b41c98..8b7d0c8 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ AASTF is free and open-source under MIT. **AASTF Enterprise** adds the capabilit | **Runtime Guardrails** | Deployable input/output/tool/memory/agent guards that intercept and block attacks in production. Config-driven YAML policies. | | **Compliance Reporting** | SOC 2 (Trust Service Criteria mapping), ISO 27001 (Annex A controls + SoA), EU AI Act (Art. 50 declarations, DPIA, conformity assessment), CycloneDX SBOM with VEX status. | | **Cryptographic Audit Trail** | SHA-256 hash-chained entries with tamper detection. Export to JSON/CSV for auditors. | -| **Team Management & RBAC** | JWT + API key auth, SSO (SAML 2.0 / OIDC), four built-in roles (Owner/Admin/Member/Viewer) plus custom roles. | +| **Team Management & RBAC** | JWT + API key auth, SSO via OIDC (OpenID Connect) with CSRF-protected callbacks; SAML 2.0 on the roadmap. Four built-in roles (Owner/Admin/Member/Viewer) plus custom roles. | | **Production Dashboard** | FastAPI dashboard with scan history, trends, team management, remediation workflow, and compliance report downloads. | | **SLA Monitoring** | Policy-based breach detection (max critical findings, max risk score) with Slack, Teams, and webhook alerting. | | **Cloud Deployment** | Docker, Kubernetes (Deployment + Service + Ingress), and Terraform (AWS ECS Fargate) templates. | diff --git a/src/aastf/redaction.py b/src/aastf/redaction.py new file mode 100644 index 0000000..815c553 --- /dev/null +++ b/src/aastf/redaction.py @@ -0,0 +1,116 @@ +"""Secret redaction for trace/report serialization. + +Agent traces captured during scanning can contain API keys, tokens, or other +secrets. This module scrubs credential-like strings from text and nested data +structures so secrets are never persisted into reports, logs, or storage. + +Two entry points: + +- :func:`redact_text` — redact secrets in a single string (e.g. a serialized + JSON report) in place. A no-op on content with no credential-like substrings. +- :func:`redact_secrets` — recursively redact a JSON-like structure (dict/list/ + str), additionally blanking any value whose *key name* looks sensitive + (``password``, ``api_key``, ``authorization``, ...). + +Patterns are deliberately credential-focused (not generic PII) to avoid +mangling legitimate report content. ``aggressive=True`` additionally redacts +long high-entropy tokens. +""" +from __future__ import annotations + +import math +import re +from typing import Any + +PLACEHOLDER = "[REDACTED]" + +# Key names whose values are always redacted in structured data. +_SENSITIVE_KEYS = frozenset({ + "password", "passwd", "pwd", "secret", "client_secret", "api_key", "apikey", + "api-key", "token", "access_token", "refresh_token", "id_token", "auth", + "authorization", "private_key", "secret_key", "aws_secret_access_key", + "session_token", "cookie", "set-cookie", +}) + +# High-confidence credential value patterns. +_PATTERNS: list[re.Pattern[str]] = [ + # PEM private key blocks (multi-line). + re.compile( + r"-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----.*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----", + re.DOTALL, + ), + # OpenAI / Stripe style: sk-..., sk_live_..., pk_live_..., rk_test_... + re.compile(r"\b[spr]k[-_](?:live|test|prod)?[-_]?[A-Za-z0-9]{16,}\b"), + # AWS access key id. + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + # GitHub tokens. + re.compile(r"\bgh[posru]_[A-Za-z0-9]{36}\b"), + re.compile(r"\bgithub_pat_[A-Za-z0-9_]{50,}\b"), + # Slack tokens. + re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), + # Google API key. + re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), + # JWT (three base64url segments). + re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), + # Bearer tokens in Authorization headers. + re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]{16,}"), +] + +# key=value / key: value where the key name is sensitive (redacts the value). +_KV_PATTERN = re.compile( + r"(?i)(\b(?:" + "|".join(re.escape(k) for k in _SENSITIVE_KEYS) + r")\b\s*[=:]\s*['\"]?)" + r"([^'\"\s,}]{6,})", +) + +# Long high-entropy token (aggressive mode only). +_ENTROPY_TOKEN = re.compile(r"\b[A-Za-z0-9+/=_\-]{32,}\b") + + +def _shannon_entropy(s: str) -> float: + if not s: + return 0.0 + counts: dict[str, int] = {} + for ch in s: + counts[ch] = counts.get(ch, 0) + 1 + n = len(s) + return -sum((c / n) * math.log2(c / n) for c in counts.values()) + + +def redact_text(text: str, *, aggressive: bool = False) -> str: + """Return ``text`` with credential-like substrings replaced by a placeholder.""" + if not text: + return text + redacted = text + for pattern in _PATTERNS: + redacted = pattern.sub(PLACEHOLDER, redacted) + redacted = _KV_PATTERN.sub(lambda m: m.group(1) + PLACEHOLDER, redacted) + if aggressive: + redacted = _ENTROPY_TOKEN.sub( + lambda m: PLACEHOLDER if _shannon_entropy(m.group(0)) >= 4.0 else m.group(0), + redacted, + ) + return redacted + + +def redact_secrets(value: Any, *, aggressive: bool = False) -> Any: + """Recursively redact secrets in a JSON-like structure. + + - strings are passed through :func:`redact_text`; + - dict values are recursed, but any value under a sensitive key name is + blanked entirely regardless of its content; + - lists/tuples are recursed element-wise. + Non-string scalars (int/float/bool/None) are returned unchanged. + """ + if isinstance(value, str): + return redact_text(value, aggressive=aggressive) + if isinstance(value, dict): + out: dict[Any, Any] = {} + for k, v in value.items(): + if isinstance(k, str) and k.lower() in _SENSITIVE_KEYS: + out[k] = PLACEHOLDER + else: + out[k] = redact_secrets(v, aggressive=aggressive) + return out + if isinstance(value, (list, tuple)): + return [redact_secrets(v, aggressive=aggressive) for v in value] + return value diff --git a/src/aastf/reporting/json_reporter.py b/src/aastf/reporting/json_reporter.py index 763134f..bd9446f 100644 --- a/src/aastf/reporting/json_reporter.py +++ b/src/aastf/reporting/json_reporter.py @@ -5,14 +5,24 @@ from pathlib import Path from ..models.result import ScanReport +from ..redaction import redact_text class JSONReporter: - """Serialises a ScanReport to JSON.""" + """Serialises a ScanReport to JSON. + + Captured agent traces may contain secrets; serialized output is passed + through credential redaction so API keys/tokens are not persisted to disk. + Pass ``redact=False`` to disable (e.g. for trusted internal pipelines). + """ + + def __init__(self, *, redact: bool = True) -> None: + self._redact = redact def generate(self, report: ScanReport) -> str: - """Return the report as a pretty-printed JSON string.""" - return report.model_dump_json(indent=2) + """Return the report as a pretty-printed JSON string (secrets redacted).""" + out = report.model_dump_json(indent=2) + return redact_text(out) if self._redact else out def write(self, report: ScanReport, output_path: Path) -> Path: """Write JSON report to output_path. Creates parent directories.""" diff --git a/src/aastf/threat_model.py b/src/aastf/threat_model.py index cd9ab83..f71779f 100644 --- a/src/aastf/threat_model.py +++ b/src/aastf/threat_model.py @@ -202,9 +202,10 @@ def open_threats(self) -> list[ThreatEntry]: likelihood="high", impact="high", mitigations=[ - "Automatic secret redaction in trace serialization", - "Configurable redaction patterns (regex-based)", - "Never persist raw HTTP headers in default mode", + "Automatic secret redaction in JSON report serialization " + "(aastf.redaction.redact_text, applied by JSONReporter)", + "Credential-pattern + sensitive-key redaction for nested data " + "(aastf.redaction.redact_secrets)", ], status="mitigated", ), diff --git a/tests/test_redaction.py b/tests/test_redaction.py new file mode 100644 index 0000000..116d70b --- /dev/null +++ b/tests/test_redaction.py @@ -0,0 +1,69 @@ +"""Tests for secret redaction in trace/report serialization.""" +from __future__ import annotations + +from aastf.redaction import PLACEHOLDER, redact_secrets, redact_text + + +class TestRedactText: + def test_openai_style_key(self): + out = redact_text("key is sk-abcdefghijklmnop1234567890 here") + assert "sk-abcdefghijklmnop" not in out + assert PLACEHOLDER in out + + def test_aws_access_key(self): + assert "AKIA" not in redact_text("AKIAIOSFODNN7EXAMPLE") + + def test_github_pat(self): + out = redact_text("token ghp_" + "a" * 36) + assert "ghp_" not in out + + def test_jwt(self): + jwt = "eyJhbGciOiJIUzI1NiI.eyJzdWIiOiIxMjM0NTY.SflKxwRJSMeKKF2QT4" + assert jwt not in redact_text(f"auth={jwt}") + + def test_bearer_token(self): + assert "abcdef1234567890ABCDEF" not in redact_text("Bearer abcdef1234567890ABCDEF") + + def test_pem_private_key(self): + pem = "-----BEGIN PRIVATE KEY-----\nMIIBVgIBADANBg\n-----END PRIVATE KEY-----" + out = redact_text(pem) + assert "MIIBVgIBADANBg" not in out + assert PLACEHOLDER in out + + def test_key_value_pair(self): + out = redact_text('password = "hunter2supersecret"') + assert "hunter2supersecret" not in out + + def test_clean_text_unchanged(self): + clean = "This is a normal finding description with no secrets." + assert redact_text(clean) == clean + + def test_empty_string(self): + assert redact_text("") == "" + + def test_aggressive_entropy(self): + token = "Zk9xQ2pW7mNbV3cX1aS5dF8gH0jK2lP4oR6tY9uI" # high entropy + assert token in redact_text(f"val {token}") # not redacted without aggressive + assert token not in redact_text(f"val {token}", aggressive=True) + + +class TestRedactSecrets: + def test_sensitive_key_blanked(self): + out = redact_secrets({"api_key": "anything-at-all", "name": "ok"}) + assert out["api_key"] == PLACEHOLDER + assert out["name"] == "ok" + + def test_nested_structure(self): + data = {"outer": {"password": "x", "items": ["sk-abcdefghijklmnop1234567890"]}} + out = redact_secrets(data) + assert out["outer"]["password"] == PLACEHOLDER + assert PLACEHOLDER in out["outer"]["items"][0] + + def test_non_string_scalars_unchanged(self): + data = {"count": 5, "ratio": 1.5, "ok": True, "none": None} + assert redact_secrets(data) == data + + def test_list_of_strings(self): + out = redact_secrets(["clean", "Bearer abcdef1234567890ABCDEF"]) + assert out[0] == "clean" + assert PLACEHOLDER in out[1]