secret-scan reports file:line/commit + lockbox KEY NAME or shape, never a value or fragment; env-names lists variable names/length/hash only. Same shapes as secret-guard. Seeded-fake tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
107 lines
4.1 KiB
Python
107 lines
4.1 KiB
Python
"""secret-scan + env-names: findings carry label/location/hash, NEVER a value or fragment."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SCRIPTS = ROOT / "scripts"
|
|
# Synthetic, random-looking, never real. FAKE_LB is in the fake lockbox; TWI matches a shape.
|
|
FAKE_LB = "k7Xv2QpLm9RtZw4HnB8dYc3S"
|
|
TWI = "9f3a7c1e5b2d48806a1f4e7d2c9b0835"
|
|
|
|
|
|
def run(script, *args, env=None):
|
|
e = {**os.environ, **(env or {})}
|
|
r = subprocess.run([sys.executable, str(SCRIPTS / script), *args], capture_output=True, text=True, env=e)
|
|
return r.returncode, r.stdout + r.stderr
|
|
|
|
|
|
def no_fragment(out: str, value: str, n: int = 6):
|
|
assert value not in out
|
|
for i in range(len(value) - n + 1):
|
|
assert value[i:i + n] not in out, f"fragment of the value leaked at {i}"
|
|
|
|
|
|
def seeded(tmp_path):
|
|
lb = tmp_path / "lockbox.md"
|
|
lb.write_text(f"FAKE_VENDOR_API_KEY={FAKE_LB}\nNOTE: nothing here\n")
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
g = lambda *a: subprocess.run(["git", "-C", str(repo), *a], check=True, capture_output=True) # noqa: E731
|
|
g("init", "-q", "-b", "main")
|
|
g("config", "user.email", "t@t")
|
|
g("config", "user.name", "t")
|
|
(repo / "app.py").write_text(f'KEY = "{FAKE_LB}"\nTWILIO_AUTH_TOKEN = "{TWI}"\n')
|
|
g("add", "-A")
|
|
g("commit", "-qm", "add secrets")
|
|
(repo / "app.py").write_text("KEY = None\n") # removed from HEAD, still in history
|
|
g("commit", "-qam", "remove")
|
|
return lb, repo
|
|
|
|
|
|
def test_tree_scan_labels_locations_no_value(tmp_path):
|
|
lb, repo = seeded(tmp_path)
|
|
(repo / "live.py").write_text(f'x = "{FAKE_LB}"\n')
|
|
rc, out = run("secret_scan.py", str(repo / "live.py"), env={"SECRET_SCAN_LOCKBOX_PATHS": str(lb)})
|
|
assert rc == 1
|
|
assert "live.py:1" in out and "lockbox:FAKE_VENDOR_API_KEY" in out
|
|
no_fragment(out, FAKE_LB)
|
|
|
|
|
|
def test_history_finds_removed_secret_with_commit(tmp_path):
|
|
lb, repo = seeded(tmp_path)
|
|
rc, out = run("secret_scan.py", str(repo), "--history", env={"SECRET_SCAN_LOCKBOX_PATHS": str(lb)})
|
|
assert rc == 1
|
|
assert "app.py:1" in out and "commit=" in out and "lockbox:FAKE_VENDOR_API_KEY" in out
|
|
assert "app.py:2" in out and "32-hex secret assignment" in out
|
|
no_fragment(out, FAKE_LB)
|
|
no_fragment(out, TWI)
|
|
|
|
|
|
def test_repo_flag_clones_scans_and_cleans_up(tmp_path):
|
|
lb, repo = seeded(tmp_path)
|
|
cache = tmp_path / "home"
|
|
(cache / ".cache").mkdir(parents=True)
|
|
rc, out = run("secret_scan.py", "--repo", str(repo),
|
|
env={"SECRET_SCAN_LOCKBOX_PATHS": str(lb), "HOME": str(cache)})
|
|
assert rc == 1 and "app.py:1" in out
|
|
no_fragment(out, FAKE_LB)
|
|
assert not [p for p in (cache / ".cache").iterdir() if p.name.startswith("secret-scan-")]
|
|
|
|
|
|
def test_clean_tree_and_bad_input(tmp_path):
|
|
(tmp_path / "ok.txt").write_text("hello world\n")
|
|
rc, out = run("secret_scan.py", str(tmp_path / "ok.txt"), "--no-lockbox")
|
|
assert rc == 0 and "0 finding(s)" in out
|
|
rc, out = run("secret_scan.py", str(tmp_path), "--history", "--no-lockbox")
|
|
assert rc == 2 and "needs a git repo" in out
|
|
|
|
|
|
def test_env_names_never_prints_values(tmp_path):
|
|
a = tmp_path / "a.env"
|
|
b = tmp_path / "b.env"
|
|
a.write_text(f"# c\nexport DB_PASSWORD={FAKE_LB}\nTOKEN=\"{TWI}\"\nEMPTY=\nONLY_A=1\n")
|
|
b.write_text(f"DB_PASSWORD={FAKE_LB}\nTOKEN=different-value-here\nONLY_B=2\n")
|
|
rc, out = run("env_names.py", str(a), "--hash")
|
|
assert rc == 0 and "DB_PASSWORD" in out and "set" in out and "empty" in out and "len=24" in out
|
|
assert "sha256:" in out
|
|
no_fragment(out, FAKE_LB)
|
|
no_fragment(out, TWI, 7)
|
|
rc, out = run("env_names.py", str(a), "--compare", str(b))
|
|
assert "DB_PASSWORD" in out and "SAME" in out and "DIFFERENT" in out
|
|
assert "only-in-A" in out and "only-in-B" in out
|
|
no_fragment(out, FAKE_LB)
|
|
rc, out = run("env_names.py", str(tmp_path / "missing.env"))
|
|
assert rc == 2
|
|
|
|
|
|
def test_shapes_are_the_guards_shapes():
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
import secret_scan as sc
|
|
import secret_shapes as ss
|
|
assert sc.ss is ss # one source of shapes: a new guard shape is automatically a scan shape
|