Skip to content
Closed
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
3 changes: 3 additions & 0 deletions changelog.d/secret-gate-union.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Changed
- The privacy gate now unions `.banlist.txt` with the gitleaks default rules (`scripts/secret_scan.py`, rules vendored as `scripts/gitleaks-v8.30.1.toml`, MIT, pinned in `docs/bench/security-gates-1024/rivals/SOURCES.md`). The rival bench imports the same module, so there is one port. The frozen-corpus exact-path skip is kept and extended to held-out-3; 15 frozen paths that only hold placeholders or synthetic values are listed by exact path in `scripts/secret-scan-allow.txt`. Gate wall time on this repo: 426s with the union against 659s without in one noisy back-to-back run (the gitleaks half fans out over 8 processes).
- Held-out-3 secrets corpus (`docs/bench/security-gates-heldout3-1024`), scored once with frozen rules: union gate 54 of 60 leaks (16 of 60 decoys flagged), banlist only 41 of 60 (12 of 60), gitleaks alone 47 of 60 (8 of 60). Pinned by `scripts/test_security_gates_heldout3.py`.
2 changes: 1 addition & 1 deletion docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Generated by `python3 .claude/skills/agentic-delivery/scripts/focus_kit.py docs-
| `docs/idea-critic-outcome-1019/README.md` | idea-critic outcome bench (2026-10-19), exploratory (not preregistered): on 36 historically resolved go/no-go decisions, the idea-critic sk… | 2085 |
| `docs/peer-survey-2026-10-16.md` | Peer survey and field measurements, 2026-10-16: The roadmap in roadmap.md cites this page as the source for peer-reported pain points. | 335 |
| `docs/perun-doctor.md` | Perun doctor and uninstall: Installed scripts and hooks can sit in a repo and never run: an operating layer that needed an extra flag, a st… | 1588 |
| `docs/perun-vs-rivals.md` | Perun vs other skill bundles: Harness: run rival rows with scripts/rival_eval_keys.py run ..., a wrapper around the hash-pinned scripts/riv… | 32305 |
| `docs/perun-vs-rivals.md` | Perun vs other skill bundles: Harness: run rival rows with scripts/rival_eval_keys.py run ..., a wrapper around the hash-pinned scripts/riv… | 32802 |
| `docs/review-cost-per-recall.md` | Review cost per real bug caught, and false alarms per case: on the one set with per-case data (S1, n=22, patch-only), Perun's real skill ca… | 2915 |
| `docs/roadmap.md` | Roadmap: Two parts. The product roadmap below is what to build or fix next for Perun, produced by the CEO improvement loop: ranked by a pri… | 15600 |
| `docs/scoreboard.md` | Perun scoreboard: Generated by scripts/scoreboard.py from committed results | 4422 |
Expand Down
4 changes: 2 additions & 2 deletions docs/bench/security-gates-1024/rivals/SOURCES.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Rival rule sources (data only, never executed)

Rules ported to Python, not the rival binary; ported-rule fidelity is the main threat. Fetched 2026-10-24 over HTTPS from raw.githubusercontent.com at pinned tags or commits. `score_rivals.py` reads only the two vendored files below.
Rules ported to Python, not the rival binary; ported-rule fidelity is the main threat. Fetched 2026-10-24 over HTTPS from raw.githubusercontent.com at pinned tags or commits. `score_rivals.py` and `scripts/secret_scan.py` read only the two vendored files below.

| Rival | Pin | Source | sha256 |
|---|---|---|---|
| gitleaks default config | tag v8.30.1 | https://raw.githubusercontent.com/gitleaks/gitleaks/v8.30.1/config/gitleaks.toml (vendored as `gitleaks-v8.30.1.toml`, 97731 bytes upstream; the 17 `AIza` occurrences (16 public sample Google keys in an allowlist, 1 rule regex) carry `@@` markers after `AI` so the repo privacy gate passes, and deleting every `@@` restores the upstream bytes) | e163e53b9e7e8a8511e77271e2b323ed057759542a6d988258afe3a1fa329caf |
| gitleaks default config | tag v8.30.1 | https://raw.githubusercontent.com/gitleaks/gitleaks/v8.30.1/config/gitleaks.toml (vendored as `scripts/gitleaks-v8.30.1.toml`, MIT licence, Copyright (c) 2019 Zachary Rice, scanned by the shared `scripts/secret_scan.py`, 97731 bytes upstream; the 17 `AIza` occurrences (16 public sample Google keys in an allowlist, 1 rule regex) carry `@@` markers after `AI` so the repo privacy gate passes, and deleting every `@@` restores the upstream bytes) | e163e53b9e7e8a8511e77271e2b323ed057759542a6d988258afe3a1fa329caf |
| hooks-mastery `pre_tool_use.py` | commit 052ad1cbd5aeb1ec4a1def22012d1293c6225625 | https://raw.githubusercontent.com/disler/claude-code-hooks-mastery/052ad1cbd5aeb1ec4a1def22012d1293c6225625/.claude/hooks/pre_tool_use.py (vendored as `hooks-mastery-pre_tool_use.py.txt`) | 78006866f793ccd394bc52011582ce48707ceef9d3496e474ab7bcb63365a5da |
| claude-code-safety-net (read, not vendored) | commit 71baf747e2a5246bdf613a2894f2d6e7a5ad80bf | https://github.com/kenryu42/claude-code-safety-net, files under `src/` listed below | see below |

Expand Down
94 changes: 6 additions & 88 deletions docs/bench/security-gates-1024/rivals/score_rivals.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,104 +6,22 @@
These are ports of the rivals' published rules, NOT the rival binaries: ported-rule fidelity is the main threat
to every number printed here (see README.md next to this file's parent and SOURCES.md for pins and gaps).
gitleaks full port of the pinned default config (keywords prefilter, regex, secret group, entropy,
rule and global allowlists). Not ported: decode-and-rescan of base64/hex/percent, path rules.
rule and global allowlists; shared with the privacy gate as scripts/secret_scan.py). Not ported: decode-and-rescan of base64/hex/percent, path rules.
hooks-mastery exact port of the rm and .env regexes of the pinned pre_tool_use.py.
safety-net a documented SUBSET port of a large shell-AST analyzer, scored three-state: block / allow /
unported. "unported" rows are counted in neither the lower bound nor as a rival miss.
"""
import importlib.util, math, pathlib, posixpath, re, shlex, sys, tomllib
import importlib.util, pathlib, posixpath, re, shlex, sys

R = pathlib.Path(__file__).resolve().parent
D = R.parent
spec = importlib.util.spec_from_file_location("score", D / "score.py")
sc = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sc)

# ---------------------------------------------------------------- gitleaks
POSIX = {"alnum": "0-9A-Za-z", "alpha": "A-Za-z", "digit": "0-9", "lower": "a-z", "upper": "A-Z", "space": r"\t\n\v\f\r ",
"punct": r"!-/:-@\[-`{-~", "xdigit": "0-9A-Fa-f", "word": r"0-9A-Za-z_"}
PATH = "input.txt" # path handed to path allowlists; real scans pass the file name


def go2py(p):
"""Translate a Go RE2 pattern to Python re: scoped inline flags, `$`/`\\z` as end-of-text, POSIX classes."""
out, i, cls, stack = [], 0, False, [0]
while i < len(p):
c = p[i]
if c == "\\":
out.append(r"\Z" if p[i + 1:i + 2] == "z" else p[i:i + 2]); i += 2; continue
if cls:
if p.startswith("[:", i):
j = p.index(":]", i); out.append(POSIX[p[i + 2:j]]); i = j + 2; continue
cls = c != "]"; out.append(c); i += 1; continue
if c == "[":
cls = True; out.append(c); i += 1
if p[i:i + 1] == "^": out.append("^"); i += 1
if p[i:i + 1] == "]": out.append(r"\]"); i += 1
continue
if c == "(":
m = re.match(r"\(\?([imsU]+)\)", p[i:])
if m: out.append(f"(?{m.group(1)}:"); stack[-1] += 1; i += m.end(); continue
stack.append(0)
elif c == ")" and len(stack) > 1:
out.append(")" * stack.pop())
elif c == "$":
out.append(r"\Z"); i += 1; continue
out.append(c); i += 1
return "".join(out) + ")" * stack[0]


def entropy(s):
return -sum(s.count(c) / len(s) * math.log2(s.count(c) / len(s)) for c in set(s)) if s else 0.0


class AL:
def __init__(s, d):
c = lambda xs: [re.compile(go2py(x)) for x in xs]
s.rx, s.paths = c(d.get("regexes", [])), c(d.get("paths", []))
s.stop, s.rt, s.cond = [w.lower() for w in d.get("stopwords", [])], d.get("regexTarget", ""), d.get("condition", "OR")

def hit(s, secret, match, line):
t = {"": secret, "secret": secret, "match": match, "line": line}[s.rt]
ch = ([any(r.search(t) for r in s.rx)] if s.rx else []) + ([any(w in secret.lower() for w in s.stop)] if s.stop else []) \
+ ([any(r.search(PATH) for r in s.paths)] if s.paths else [])
return bool(ch) and (all(ch) if s.cond == "AND" else any(ch))


def load_gitleaks():
# the vendored copy carries `@@` markers inside public sample Google keys (privacy gate); deleting them restores upstream bytes
cfg = tomllib.loads((R / "gitleaks-v8.30.1.toml").read_text().replace("@@", ""))
glob = [AL(a) for a in cfg.get("allowlists", [])] + ([AL(cfg["allowlist"])] if "allowlist" in cfg else [])
rules, fail = [], 0
for r in cfg["rules"]:
if "regex" not in r: continue # path-only rules (file-type rules) do not apply to text strings
try: rx = re.compile(go2py(r["regex"]))
except re.error: fail += 1; continue
pr = re.compile(go2py(r["path"])) if "path" in r else None
als = [AL(a) for a in r.get("allowlists", [])] + ([AL(r["allowlist"])] if "allowlist" in r else [])
rules.append((r["id"], rx, [k.lower() for k in r.get("keywords", [])], r.get("secretGroup", 0), r.get("entropy", 0.0), als, pr))
return glob, rules, fail


GL = load_gitleaks()


def gitleaks_rule(text):
"""Return the ids of every rule that flags text (scanned as a file body); empty means clean."""
glob, rules, _ = GL
frag = text + "\n"; low = frag.lower(); out = []
for rid, rx, kw, sg, ent, als, pr in rules:
if (kw and not any(k in low for k in kw)) or (pr and not pr.search(PATH)): continue
for m in rx.finditer(frag):
line = frag[frag.rfind("\n", 0, m.start()) + 1:frag.find("\n", m.end())]
if "gitleaks:allow" in line: continue
gs = [g for g in m.groups() if g]
sec = m.group(sg) or "" if sg else (gs[0] if gs else m.group(0))
if ent and entropy(sec) <= ent: continue
if any(a.hit(sec, m.group(0), line) for a in glob + als): continue
out.append(rid); break
return out

spec = importlib.util.spec_from_file_location("secret_scan", D.parents[2] / "scripts" / "secret_scan.py")
ss = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ss)
go2py, gitleaks_rule, GL = ss.go2py, ss.gitleaks_rule, ss.GL # the one shared gitleaks port; its rules are the data file next to it

# ---------------------------------------------------------------- hooks-mastery (exact port of pre_tool_use.py)
HM_RM = [r'\brm\s+.*-[a-z]*r[a-z]*f', r'\brm\s+.*-[a-z]*f[a-z]*r', r'\brm\s+--recursive\s+--force',
Expand Down
4 changes: 4 additions & 0 deletions docs/bench/security-gates-heldout2-1024/RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ Known gaps, not in this corpus and not fixed: `redis-cli` fed `FLUSHALL` on stdi

- Secret gate missed 17: AWS secret key, GitHub and npm tokens, a CircleCI PAT, a Telegram bot token, Stripe webhook secret, PayPal, Square, Replicate, Groq, credentialed Postgres, MongoDB, Redis and JDBC URLs, a New Relic key and a Sentry DSN. Gitleaks missed 14 (it catches 10 of Perun's 17 misses; Perun catches 7 of its 14; 7 are missed by both).
- Destructive guard missed 13: `eval` of an echoed command, `> /etc/passwd`, `git filter-branch --force`, bare SQL statements (`DROP DATABASE`, `DROP TABLE`, `TRUNCATE`, `DELETE FROM` typed without a client), `gcloud sql instances delete`, `kubectl delete pods --all --all-namespaces`, `kubectl delete pv --all`, `helm uninstall`, `terragrunt run-all destroy` and a printf-built `rm -rf /`.

## Addendum, 2026-10-25: the secret gate is now a union

The privacy gate now also runs the gitleaks default rules (see `docs/bench/security-gates-heldout3-1024/RESULTS.md`). Re-scored on this corpus it finds 33 of 40 leaks (2 of 40 decoys flagged). That 33/40 is partly in-sample: unioning gitleaks was chosen after this corpus showed gitleaks ahead, so it is not an out-of-sample number. The 23/40 above is the banlist alone and stays the out-of-sample figure; the held-out-3 corpus is the out-of-sample test of the union.
1 change: 1 addition & 0 deletions docs/bench/security-gates-heldout3-1024/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Third blind held-out secrets corpus (60 live-format leaks, 60 hard decoys), written without reading prior gates, banlists or rival rule sets. Every live-format value contains `@@` and is fictional; frozen by SHA256 before any scoring, and it was scored once, after the rules were frozen; see `RESULTS.md`. Two rows (L053, D037) gained an extra `@@` before scoring, disclosed in `ledger.jsonl`; run `score.py` here.
18 changes: 18 additions & 0 deletions docs/bench/security-gates-heldout3-1024/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Held-out 3 results, 2026-10-25

**BLUF.** On a third blind corpus (60 live-format leaks, 60 hard decoys) the secret gate as shipped, the banlist unioned with the gitleaks default rules, found 54 of 60 leaks and flagged 16 of 60 decoys. The banlist alone found 41 of 60 (12 of 60 decoys flagged); ported gitleaks alone found 47 of 60 (8 of 60 flagged). The union beats both parts on recall by 7 to 13 leaks, and pays for it with the union of their false positives (16 flagged against 12 and 8). Out of sample, the gate no longer trails gitleaks on recall, at the cost stated above: twice gitleaks' false positives.

**Method.** Corpora were committed as 8068504a6 (prereg/heldout3) before any scoring; `SHA256-frozen.txt` pins them. The rules (the banlist at the base commit and the vendored gitleaks v8.30.1 config) were frozen before scoring and nothing changed after seeing a row; this was scored once. Before scoring two rows gained an `@@` marker (L053, D037), disclosed as ledger amendments; stripped content and scores are unaffected. Run: `python3 -I docs/bench/security-gates-heldout3-1024/score.py`, which drives the real `ci-gates.sh privacy` for the union and asserts union equals banlist OR gitleaks on every row. Pins: `scripts/test_security_gates_heldout3.py`.

| System | Leaks found (60) | Decoys flagged (60) |
|---|---|---|
| Perun union (banlist + gitleaks rules, the shipped gate) | 54 | 16 |
| Perun banlist only | 41 | 12 |
| gitleaks v8.30.1 (ported) | 47 | 8 |

**Caveats.** The decision to union gitleaks into the gate was made after seeing held-out-2, where the same union finds 33/40 (up from 23/40 for the banlist alone); that 33/40 is partly in-sample and is not evidence. Held-out-3 is the out-of-sample test of the union, because the union and every rule were frozen before it was scored. Same-model-family author as Perun's rule writers; gitleaks is a Python port without decode-and-rescan, not the binary; n is small. The gitleaks half scores each row as a one-line file named `x.txt`, with no path context. Push-protection was not queryable offline, so the `@@` amendments used raw-text hits of the gitleaks rules as the criterion.

## Leads (misses and false positives, not fixed here)

- Missed by the union: L004, L006, L021, L045, L056, L057 (also missed by both parts).
- Flagged decoys: 16 of 60 (D004, D006, D007, D008, D009, D020, D021, D025, D029, D030, D032, D033, D037, D038, D040, D050). Placeholder-looking `generic-api-key` hits dominate the gitleaks side; the banlist side flags 12 of them already.
5 changes: 5 additions & 0 deletions docs/bench/security-gates-heldout3-1024/SHA256-frozen.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
05aa01dbedd88abc5da120d6f0406c1ea11a9e6e65386a0d3fd6402042be9387 secret-leaks.txt
b09bbb867e33b21790657f4ed6d45621b18ff198a68159189b48613ba3c53854 secret-decoys.txt
9f671b80bef33b7c79657d39ed50f94852f226863811adfd93f5492aca937cb2 secret-leaks.txt
2a6c85278f95673ade17770bb1aaf7c22474b2c2a84f56630789f272cd663dae secret-decoys.txt
7a3a50147b632c86a7d6beb0affc277e34a496fc7c322006119dceafe4da32d8 secret-decoys.txt
4 changes: 4 additions & 0 deletions docs/bench/security-gates-heldout3-1024/ledger.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"iso": "2026-10-24T20:56:26Z", "event": "preregistered", "sha256": "441e83437b2742171d6159454555b431bcc7e1fd8e1378d16e362023e8954ee6"}
{"iso": "2026-10-25T01:55:00Z", "event": "amendment", "file": "secret-leaks.txt", "sha256": "9f671b80bef33b7c79657d39ed50f94852f226863811adfd93f5492aca937cb2", "text": "Before scoring (time approximate: this line was re-timed from `date -u` at fix time; the amendment itself was made earlier in the same session, before the one scoring run): an @@ marker was added after `Authorization:` because the raw row still matched a gitleaks header rule (GitHub push protection cannot be queried offline; a raw-text hit of the gitleaks rules or a provider-format token was the criterion, and no other row had one). Row L053; the @@-stripped content and every score are unchanged."}
{"iso": "2026-10-25T01:55:00Z", "event": "amendment", "file": "secret-decoys.txt", "sha256": "2a6c85278f95673ade17770bb1aaf7c22474b2c2a84f56630789f272cd663dae", "text": "Before scoring (time approximate: this line was re-timed from `date -u` at fix time; the amendment itself was made earlier in the same session, before the one scoring run): an @@ marker was added inside the unsigned JWT because the raw row still matched the gitleaks jwt rule (GitHub push protection cannot be queried offline; a raw-text hit of the gitleaks rules or a provider-format token was the criterion, and no other row had one). Row D037; the @@-stripped content and every score are unchanged."}
{"iso": "2026-10-25T04:00:47Z", "event": "amendment", "file": "secret-decoys.txt", "sha256": "7a3a50147b632c86a7d6beb0affc277e34a496fc7c322006119dceafe4da32d8", "text": "After scoring: @@ markers were added to rows D020 (Slack token shape) and D033 (SendGrid key shape) because GitHub push protection blocked the push on D020. The @@-stripped content is unchanged, so every score is unchanged (re-scored to confirm)."}
58 changes: 58 additions & 0 deletions docs/bench/security-gates-heldout3-1024/score.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Score the secret gate on held-out-3: Perun-union (the real privacy gate), Perun-banlist-only, and gitleaks alone.

Usage: python3 -I docs/bench/security-gates-heldout3-1024/score.py (run from anywhere; offline, deterministic; exit 1 on hash drift)
Rows are `id<TAB>text`; `@@` markers are deleted before scoring. Union is the actual `ci-gates.sh privacy` run; it must equal
banlist OR gitleaks, which this script asserts so the three columns can never disagree about the gate.
"""
import hashlib, importlib.util, pathlib, subprocess, sys, tempfile

D = pathlib.Path(__file__).resolve().parent
ROOT = D.parents[2]
spec = importlib.util.spec_from_file_location("ss", ROOT / "scripts/secret_scan.py")
ss = importlib.util.module_from_spec(spec); spec.loader.exec_module(ss)
BAN = ROOT / ".banlist.txt"


def rows(name):
return [(l.split("\t")[0], l.split("\t", 1)[1].replace("@@", "")) for l in (D / name).read_text().splitlines() if l and not l.startswith("#")]


def drift():
"""Files whose sha256 differs from the LAST line naming them in SHA256-frozen.txt (earlier lines are disclosed amendments)."""
want = {p[1]: p[0] for p in (l.split() for l in (D / "SHA256-frozen.txt").read_text().splitlines() if not l.startswith("#"))}
return [f for f, h in want.items() if hashlib.sha256((D / f).read_bytes()).hexdigest() != h]


def scores():
pats = [l.lstrip() for l in BAN.read_text().splitlines() if l.strip() and not l.lstrip().startswith("#")]
out = {}
with tempfile.TemporaryDirectory() as t:
tp = pathlib.Path(t); (tp / ".banlist.txt").write_text(BAN.read_text())
for name in ("secret-leaks.txt", "secret-decoys.txt"):
res = {"union": [], "banlist": [], "gitleaks": []}
for i, text in rows(name):
f = tp / "x.txt"; f.write_text(text + "\n")
u = subprocess.run(["bash", str(ROOT / "scripts/ci-gates.sh"), "privacy", "--banlist", str(tp / ".banlist.txt"), str(f)],
capture_output=True, text=True).returncode != 0
b = any(subprocess.run(["grep", "-qE", "-e", p, str(f)]).returncode == 0 for p in pats)
g = bool(ss.gitleaks_rule(text + "\n"))
assert u == (b or g), (i, u, b, g)
for k, v in (("union", u), ("banlist", b), ("gitleaks", g)):
if v: res[k].append(i)
out[name] = res
return out


def main():
if drift(): print("HASH DRIFT:", drift()); return 1
s = scores(); n = {k: len(rows(k)) for k in s}
for k in ("union", "banlist", "gitleaks"):
lk, dc = s["secret-leaks.txt"][k], s["secret-decoys.txt"][k]
print(f"{k}: recall {len(lk)}/{n['secret-leaks.txt']} FP {len(dc)}/{n['secret-decoys.txt']}")
print(" missed:", [i for i, _ in rows("secret-leaks.txt") if i not in lk]); print(" false positives:", dc)
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading