Windy Text 10-01: a live Twilio auth token sat in bridged-repo tests. Auth tokens are bare 32-hex, so they are matched only when assigned to a name containing token/secret/key/password; hash is of the value alone. SECRET_GUARD_WARN_KINDS lets a new shape warn before it blocks. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
149 lines
6.5 KiB
Python
149 lines
6.5 KiB
Python
"""Secret guard: shapes, hash-only findings, allow by hash."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT / "scripts"))
|
|
_spec = importlib.util.spec_from_file_location("secret_guard", ROOT / "scripts" / "secret_guard.py")
|
|
sg = importlib.util.module_from_spec(_spec)
|
|
sys.modules["secret_guard"] = sg
|
|
_spec.loader.exec_module(sg)
|
|
ss = sg.ss
|
|
|
|
# Synthetic shapes only: none of these is a real credential.
|
|
TG = "1234567890:" + "A" * 35
|
|
CASES = [
|
|
("telegram bot token", f"TELEGRAM_BOT_TOKEN={TG}"),
|
|
("github token", "token = 'ghp_" + "a1" * 18 + "'"),
|
|
("aws access key", "aws_access_key_id = AKIA" + "ABCDEFGHIJKLMNOP"),
|
|
("slack token", "xoxb-" + "1234567890-abcdefghij"),
|
|
("anthropic key", "ANTHROPIC_API_KEY=sk-ant-" + "x" * 30),
|
|
("openai key", "OPENAI_API_KEY=sk-proj-" + "y" * 40),
|
|
("stripe live key", "STRIPE=sk_live_" + "z" * 24),
|
|
("google api key", "key=AIza" + "B" * 35),
|
|
("private key block", "-----BEGIN OPENSSH PRIVATE KEY-----"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("kind, text", CASES)
|
|
def test_each_shape_is_found_and_only_its_hash_is_kept(kind, text):
|
|
hits = sg.scan_line("app.py", text)
|
|
assert [k for k, _ in hits] == [kind]
|
|
match = hits[0][1]
|
|
assert match.startswith(f"{kind} #") and len(match.rsplit("#", 1)[1]) == 8
|
|
# house rule 10: the value itself must never appear in a finding
|
|
secret = text.split("=", 1)[-1].strip(" '")
|
|
assert secret not in match
|
|
|
|
|
|
@pytest.mark.parametrize("text", [
|
|
"sha512-" + "Q" * 86 + "==", # lockfile integrity
|
|
"version: 12345678:abc", # short, not a token
|
|
"sk-ant-short", # too short
|
|
"re_test_register_sends_verification", # windy-pro's fake Resend key
|
|
"ANTHROPIC_API_KEY=", # a name, not a value
|
|
])
|
|
def test_non_secrets_are_not_flagged(text):
|
|
assert sg.scan_line("x", text) == []
|
|
|
|
|
|
def test_anthropic_key_is_not_double_counted_as_openai():
|
|
assert [k for k, _ in sg.scan_line("x", "sk-ant-" + "q" * 40)] == ["anthropic key"]
|
|
|
|
|
|
def test_allow_is_by_hash_only():
|
|
F = sg.cg.Finding
|
|
fake = F("tests/t.py", 3, "telegram bot token", f"telegram bot token #{ss.h8(TG)}")
|
|
real = F("tests/t.py", 9, "telegram bot token", "telegram bot token #deadbeef")
|
|
allow = {"windy-chat": {"hashes": {ss.h8(TG)}, "paths": []}}
|
|
assert sg._drop_allowed("windy-chat", [fake, real], allow) == [real]
|
|
assert sg._drop_allowed("windy-mail", [fake], allow) == [fake]
|
|
|
|
|
|
def test_private_key_blocks_are_allowed_by_path_never_by_hash():
|
|
F = sg.cg.Finding
|
|
hdr = "private key block #" + ss.h8("-----BEGIN PRIVATE KEY-----")
|
|
test_key = F("tests/keys/test.pem", 1, "private key block", hdr)
|
|
prod_key = F("deploy/prod.pem", 1, "private key block", hdr)
|
|
allow = {"r": {"hashes": {hdr.rsplit("#", 1)[1]}, "paths": [("tests/keys/*", {"private key block"})]}}
|
|
assert sg._drop_allowed("r", [test_key, prod_key], allow) == [prod_key]
|
|
|
|
|
|
def test_path_allow_cannot_cover_real_token_kinds(tmp_path):
|
|
bad = tmp_path / "a.yml"
|
|
bad.write_text("allow:\n - repo: r\n paths: [tests/*]\n kinds: [telegram bot token]\n reason: no\n")
|
|
with pytest.raises(ValueError):
|
|
sg.load_allow(bad)
|
|
|
|
|
|
def test_shipped_allow_file_never_excuses_the_real_leaked_tokens():
|
|
a = sg.load_allow()
|
|
every = set().union(*(v["hashes"] for v in a.values())) if a else set()
|
|
assert not {"1354fc9b", "d49dc2ba"} & every # real (now revoked) credentials: remove, never allow
|
|
|
|
|
|
def test_allow_file_loads_and_needs_reasons(tmp_path):
|
|
assert isinstance(sg.load_allow(), dict)
|
|
bad = tmp_path / "a.yml"
|
|
bad.write_text("allow:\n - repo: r\n hashes: [abcd1234]\n")
|
|
with pytest.raises(ValueError):
|
|
sg.load_allow(bad)
|
|
|
|
|
|
def test_block_and_warn(monkeypatch):
|
|
f = sg.cg.Finding("a.py", 1, "github token", "github token #abcd1234")
|
|
monkeypatch.setattr(sg, "MODE", "block")
|
|
assert sg.status_for([f], False)[0] == "failure"
|
|
assert sg.status_for([], False, grant=[f])[0] == "success"
|
|
monkeypatch.setattr(sg, "MODE", "warn")
|
|
state, desc, _ = sg.status_for([f], True)
|
|
assert state == "success" and desc.startswith("⚠ WARN (not blocking): 1 secret-shaped string in tree")
|
|
|
|
|
|
def test_public_scan_excuses_by_hash_and_by_path():
|
|
spec = importlib.util.spec_from_file_location("public_secret_scan", ROOT / "scripts" / "public_secret_scan.py")
|
|
ps = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(ps)
|
|
allow = {"windy-agent": {"hashes": {"aaaa1111"}, "paths": [("tests/keys/*", {"private key block"})]}}
|
|
assert ps.excused("windy-agent", "openai key", "aaaa1111", ["tests/x.py"], allow)
|
|
assert not ps.excused("windy-agent", "telegram bot token", "1354fc9b", ["tests/test_log_redaction.py"], allow)
|
|
assert ps.excused("windy-agent", "private key block", "ffff0000", ["tests/keys/a.pem"], allow)
|
|
# 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"
|