Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
116 changes: 116 additions & 0 deletions src/aastf/redaction.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 13 additions & 3 deletions src/aastf/reporting/json_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
7 changes: 4 additions & 3 deletions src/aastf/threat_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
Expand Down
69 changes: 69 additions & 0 deletions tests/test_redaction.py
Original file line number Diff line number Diff line change
@@ -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]
Loading