diff --git a/api/tests/test_secret_guard.py b/api/tests/test_secret_guard.py index e1fbf2e..e7b2156 100644 --- a/api/tests/test_secret_guard.py +++ b/api/tests/test_secret_guard.py @@ -117,3 +117,32 @@ def test_public_scan_excuses_by_hash_and_by_path(): # a PEM header anywhere outside the allowed paths still counts assert not ps.excused("windy-agent", "private key block", "ffff0000", ["tests/keys/a.pem", "deploy/k.pem"], allow) assert not ps.excused("other", "openai key", "aaaa1111", ["x"], allow) + + +HEX32 = "0123456789abcdef" * 2 # synthetic + + +def test_twilio_shapes_hash_only_and_no_md5_noise(): + kinds = lambda t: [k for k, _ in ss.find(t)] # noqa: E731 + assert kinds(f'TWILIO_AUTH_TOKEN = "{HEX32}"') == ["32-hex secret assignment"] + assert kinds(f"auth_token: {HEX32}") == ["32-hex secret assignment"] + assert kinds("AC" + HEX32) == ["twilio sid/api key"] + assert kinds("SK" + HEX32) == ["twilio sid/api key"] + # plain md5 / uuid-without-dashes / a 64-hex sha256 are NOT secrets by shape + assert kinds(f"md5 = {HEX32}") == [] + assert kinds(f"checksum_key = {HEX32}{HEX32}") == [] + assert kinds(f"name = 'x{HEX32}'") == [] + # the hash is of the value alone, so renaming the variable keeps the same allow hash + a = ss.find(f"A_TOKEN={HEX32}")[0][1] + b = ss.find(f"OTHER_SECRET: '{HEX32}'")[0][1] + assert a == b == ss.h8(HEX32) + assert HEX32 not in repr(ss.find(f"A_TOKEN={HEX32}")) + + +def test_warn_kinds_do_not_block(monkeypatch): + f = sg.cg.Finding("a.py", 1, "32-hex secret assignment", "32-hex secret assignment #abcd1234") + monkeypatch.setattr(sg, "MODE", "block") + monkeypatch.setattr(sg, "WARN_KINDS", {"32-hex secret assignment"}) + assert sg.status_for([f], True)[0] == "success" + monkeypatch.setattr(sg, "WARN_KINDS", set()) + assert sg.status_for([f], True)[0] == "failure" diff --git a/scripts/secret_guard.py b/scripts/secret_guard.py index d658111..05b6ed2 100644 --- a/scripts/secret_guard.py +++ b/scripts/secret_guard.py @@ -27,6 +27,8 @@ import secret_shapes as ss # noqa: E402 ROOT = Path(__file__).resolve().parents[1] ALLOW_FILE = Path(os.environ.get("SECRET_GUARD_ALLOW", ROOT / "ci" / "secret-guard-allow.yml")) MODE = os.environ.get("SECRET_GUARD_MODE", "warn") +# Kinds that only WARN (rolled out warn-first); empty = every kind blocks in block mode. +WARN_KINDS = {k for k in os.environ.get("SECRET_GUARD_WARN_KINDS", "").split(",") if k} NEVER = re.compile(r"(^|/)(node_modules|vendor|third_party)/") @@ -94,8 +96,11 @@ def status_for(findings, whole_tree: bool, grant=()): if not findings: return "success", f"OK: no secret-shaped strings {scope}", None f, n = findings[0], len(findings) - state = "failure" if MODE == "block" else "success" - lead = "BLOCKED" if MODE == "block" else "⚠ WARN (not blocking)" + soft = MODE != "block" or all(x.kind in WARN_KINDS for x in findings) + state = "success" if soft else "failure" + lead = "⚠ WARN (not blocking)" if soft else "BLOCKED" + if not soft: + f = next(x for x in findings if x.kind not in WARN_KINDS) return state, f"{lead}: {n} secret-shaped string{'s' if n > 1 else ''} {scope}, e.g. {f.path}:{f.line} {f.match}"[:140], f diff --git a/scripts/secret_shapes.py b/scripts/secret_shapes.py index 6a962c4..533f9ba 100644 --- a/scripts/secret_shapes.py +++ b/scripts/secret_shapes.py @@ -18,13 +18,18 @@ PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("openai key", re.compile(r"\bsk-(?:proj-|svcacct-)?(?!ant-)[A-Za-z0-9_-]{32,}")), ("stripe live key", re.compile(r"\b[rs]k_live_[A-Za-z0-9]{20,}")), ("google api key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])")), + # Twilio (Windy Text 10-01: a live auth token sat in test files for months). An auth token + # is a bare 32-hex with no prefix, so it is only caught when ASSIGNED to a secret-ish name. + ("twilio sid/api key", re.compile(r"\b(?:AC|SK)[0-9a-f]{32}\b")), + ("32-hex secret assignment", re.compile( + r"(?i)\b[a-z0-9_.-]*(?:token|secret|key|password)[a-z0-9_.-]*[\"']?\s*[:=]\s*[\"']?(?P(? str: @@ -36,5 +41,5 @@ def find(text: str) -> list[tuple[str, str]]: out = [] for kind, rx in PATTERNS: for m in rx.finditer(text): - out.append((kind, h8(m.group(0)))) + out.append((kind, h8(m.group('v') if 'v' in rx.groupindex else m.group(0)))) return out