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
165 changes: 149 additions & 16 deletions scripts/history-policy-gate.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
#!/usr/bin/env python3
"""Bounded full-history audit and required baseline-delta policy gate."""
"""Bounded full-history audit and required baseline-delta policy gate.
Secret detection is two-stage:
1. ``secret_rule.pickaxe_regex`` is a POSIX ERE that git evaluates natively
(``git log -S --pickaxe-regex`` for history, ``git grep -E`` for a tree).
It only narrows candidates cheaply; it is deliberately a superset of the
precise patterns on the line that carries the credential prefix, and it is
interval-free: bounded repetitions such as ``{36}`` make git's POSIX
matcher markedly slower over full history.
2. ``secret_rule.credential_patterns`` are Python regexes evaluated over the
whole blob. They decide. Whole-blob matching lets a PEM block require key
material on the lines after its BEGIN line (git's pickaxe is line-bound),
and the ``password`` group of a database URL is checked against
``secret_rule.password_policy`` so placeholders and documented local
development defaults are not reported as credentials.
Two further, narrow filters keep test fixtures out without path allowlists:
* ``synthetic_run_length``: a match containing an ascending run of that many
consecutive digits or letters (``ABCDEFGH``, ``01234567``) is a hand-made
fixture. A random credential contains such a run with negligible
probability (about 3e-11 for a 36-character token and a run of 8).
* ``reviewed_blobs``: individually reviewed git blob IDs (for example a
self-signed loopback TLS key read only by tests). The exemption is bound to
the exact content; any edit produces a new blob and is scanned again.
A history candidate counts only when the precise match count of the path rises
in that commit (an introduction). Bare prefixes in detectors, tests and
warning docs never reach a finding. The current-tree scan applies the same
precise check at the tip, so an introduction the pickaxe cannot see (its
candidate count unchanged) is still caught there.
"""

import argparse
import hashlib
Expand All @@ -15,8 +47,20 @@ class GateError(RuntimeError):
pass


FULL_AUDIT_SCANNER_VERSIONS = ["commit-path/v2", "diff-pickaxe/v2", "public-tree/v2"]
FULL_AUDIT_SCANNER_VERSIONS = ["commit-path/v2", "diff-pickaxe/v3", "public-tree/v3"]
BASELINE_SCANNER_VERSIONS = FULL_AUDIT_SCANNER_VERSIONS
SECRET_RULE_KEYS = {
"id",
"rationale",
"pickaxe_regex",
"credential_patterns",
"password_policy",
"synthetic_run_length",
"reviewed_blobs",
}
MIN_SYNTHETIC_RUN_LENGTH = 8
SEQUENCES = ("0123456789", "abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
PASSWORD_POLICY_KEYS = {"min_length", "placeholder_prefixes", "placeholder_markers", "dev_default_passwords"}


def canonical(value):
Expand Down Expand Up @@ -68,9 +112,93 @@ def load_policy(path):
or set(source_paths) - {policy["policy_source_path"]} != set(sources)
):
raise GateError("invalid scanner-source contract")
validate_secret_rule(policy["secret_rule"])
return policy


def string_list(value):
return isinstance(value, list) and all(isinstance(item, str) and item for item in value)


def validate_secret_rule(rule):
if not isinstance(rule, dict) or set(rule) != SECRET_RULE_KEYS:
raise GateError("invalid secret-rule contract")
if not all(isinstance(rule[key], str) and rule[key] for key in ("id", "rationale", "pickaxe_regex")):
raise GateError("invalid secret-rule contract")
patterns = rule["credential_patterns"]
if (
not isinstance(patterns, list)
or not patterns
or not all(isinstance(item, dict) and set(item) == {"id", "regex"} and all(isinstance(v, str) and v for v in item.values()) for item in patterns)
or len({item["id"] for item in patterns}) != len(patterns)
):
raise GateError("invalid credential-pattern contract")
for item in patterns:
try:
compiled = re.compile(item["regex"])
except re.error as exc:
raise GateError(f"invalid credential pattern: {item['id']}") from exc
if compiled.groupindex and set(compiled.groupindex) != {"password"}:
raise GateError(f"credential pattern may only name a password group: {item['id']}")
passwords = rule["password_policy"]
if (
not isinstance(passwords, dict)
or set(passwords) != PASSWORD_POLICY_KEYS
or not isinstance(passwords["min_length"], int)
or passwords["min_length"] < 1
or not all(string_list(passwords[key]) for key in PASSWORD_POLICY_KEYS - {"min_length"})
or any(value != value.lower() for value in passwords["dev_default_passwords"])
):
raise GateError("invalid password-policy contract")
run = rule["synthetic_run_length"]
if not isinstance(run, int) or isinstance(run, bool) or run < MIN_SYNTHETIC_RUN_LENGTH:
raise GateError("invalid synthetic-run contract")
reviewed = rule["reviewed_blobs"]
if (
not isinstance(reviewed, list)
or not all(
isinstance(item, dict)
and set(item) == {"blob", "reason"}
and isinstance(item["blob"], str)
and re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", item["blob"])
and isinstance(item["reason"], str)
and item["reason"].strip()
for item in reviewed
)
or len({item["blob"] for item in reviewed}) != len(reviewed)
):
raise GateError("invalid reviewed-blob contract")


def synthetic(value, run_length):
"""True when value holds an ascending run of run_length digits or letters."""
return any(sequence[start : start + run_length] in value for sequence in SEQUENCES for start in range(len(sequence) - run_length + 1))


def placeholder_password(value, passwords):
"""True when a database-URL password is a placeholder or a documented dev default."""
return (
len(value) < passwords["min_length"]
or value.startswith(tuple(passwords["placeholder_prefixes"]))
or any(marker in value for marker in passwords["placeholder_markers"])
or value.lower() in passwords["dev_default_passwords"]
)


def credential_count(text, rule):
"""Count precise credential matches in text; the decision stage of SEC001."""
count = 0
for pattern in rule["credential_patterns"]:
for match in re.finditer(pattern["regex"], text):
password = match.groupdict().get("password")
if password is not None and placeholder_password(password, rule["password_policy"]):
continue
if synthetic(match.group(0), rule["synthetic_run_length"]):
continue
count += 1
return count


def policy_fingerprint(policy):
baseline = policy["baseline"]
material = {
Expand Down Expand Up @@ -180,16 +308,26 @@ def current_tree_scan(root, policy, ref="HEAD"):
matches = git(root, timeout, "grep", "-I", "-l", "-E", policy["secret_rule"]["pickaxe_regex"], ref, allowed=(0, 1)).decode(errors="surrogateescape").splitlines()
for value in matches:
path = value.removeprefix(ref + ":")
if not scanner_source(path, policy):
object_ref = git(root, timeout, "rev-parse", f"{ref}:{path}").decode().strip()
findings.append(finding("current-secret", policy["secret_rule"]["id"], path, object_ref))
if scanner_source(path, policy) or not secret_match_count(root, timeout, ref, path, policy["secret_rule"]):
continue
object_ref = git(root, timeout, "rev-parse", f"{ref}:{path}").decode().strip()
findings.append(finding("current-secret", policy["secret_rule"]["id"], path, object_ref))
return findings


def secret_match_count(root, timeout, ref, path, regex):
"""Count secret-rule matches for path at ref. Missing path → 0."""
blob = git(root, timeout, "show", f"{ref}:{path}", allowed=(0, 128))
return len(re.findall(regex, blob.decode(errors="surrogateescape")))
def secret_match_count(root, timeout, ref, path, rule):
"""Count precise credential matches for path at ref. Missing path or ref, or a reviewed blob → 0."""
blob_id = git(root, timeout, "rev-parse", "--verify", "--quiet", f"{ref}:{path}", allowed=(0, 1, 128)).decode().strip()
if not blob_id or blob_id in {item["blob"] for item in rule["reviewed_blobs"]}:
return 0
blob = git(root, timeout, "cat-file", "blob", blob_id, allowed=(0, 128)) # 128: a gitlink, not a blob
return credential_count(blob.decode(errors="surrogateescape"), rule)


def secret_introduced(root, timeout, commit, path, rule):
"""Pickaxe -S fires on introduce AND remove; only a rising precise count is an introduction."""
before = secret_match_count(root, timeout, f"{commit}^", path, rule)
return secret_match_count(root, timeout, commit, path, rule) > before


def merge_base(root, timeout, left, right):
Expand Down Expand Up @@ -243,12 +381,7 @@ def delta_scan(root, policy, base):
continue
commit = lines_rec[0]
for path in sorted(set(lines_rec[1:])):
if scanner_source(path, policy):
continue
# Pickaxe -S fires on introduce AND remove. Only flag introductions.
before = secret_match_count(root, timeout, f"{commit}^", path, regex)
after = secret_match_count(root, timeout, commit, path, regex)
if after <= before:
if scanner_source(path, policy) or not secret_introduced(root, timeout, commit, path, policy["secret_rule"]):
continue
findings.append(finding("delta-secret", policy["secret_rule"]["id"], path, commit))
return findings
Expand Down Expand Up @@ -311,7 +444,7 @@ def full_audit(root, policy):
lines = [line for line in record.splitlines() if line]
if lines:
for path in sorted(set(lines[1:])):
if not scanner_source(path, policy):
if not scanner_source(path, policy) and secret_introduced(root, timeout, lines[0], path, policy["secret_rule"]):
findings.append(finding("full-secret", policy["secret_rule"]["id"], path, lines[0]))
findings = bounded(findings, policy)
current_ids = sorted(item["id"] for item in current_tree_scan(root, policy))
Expand Down
49 changes: 49 additions & 0 deletions security/evidence/full-history-baseline-v1-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,52 @@ objects, not a newly accepted finding class.
No rule, forbidden-path entry, source-binding requirement, or allowlist was
weakened. The pending audit status remains intentional because remediation of
other historical classes is outside this baseline rotation.

## Precise SEC001 rule and baseline rotation (2026-10-17)

The SEC001 rule matched bare prefixes (`ghp_`, `AKIA`, `sk_live_`,
`PRIVATE KEY-----`) and any database URL with a password field. Every
current-tree hit on the private integration tree (40, plus 53 history
introductions) was a secret *detector*, a test fake, a warning in
documentation, a `${VAR}` or `{placeholder}` password, a local development
default or a self-signed loopback test key. The noise hid real signals.

- **Two stages.** The git pickaxe uses an interval-free POSIX ERE that only
narrows candidates (intervals such as `{36}` made `git log -S` exceed the
per-command timeout on the larger private tree). A precise Python check over
the blob decides: GitHub tokens with their full length, fine-grained
`github_pat_`, AWS key IDs (`…EXAMPLE` documentation keys excluded), Stripe
live keys, PEM blocks only with base64 key material, and database URLs only
with a literal password of at least eight characters that is neither a
placeholder nor a documented development default.
- **Two narrow filters.** A match containing an ascending run of eight
characters (`ABCDEFGH`, `01234567`) is a hand-made fixture; one reviewed
self-signed loopback TLS test key is exempt by Git blob ID only, so any change
to its content is scanned again.
- **History** reports an introduction only when the precise match count of a
blob rises in a commit.
- **Limits.** `command_timeout_seconds` is raised from 120 to 300 s: the
candidate pickaxe over the private tree took 81 s, too close to the old limit
for slower runners.

### Baseline audit

- Audit target: `b37ee3be11f2525cd785348d3e43ccdbb590fe94`
- Audit source: fresh clone of the public repository with `main`,
`cla-signatures` and all tags — the ref set of the 16,291-commit audit above
(recomputed: `main` up to `bff280` plus `cla-signatures` plus its tags gives
exactly 16,291). Open pull-request branches are not part of the baseline.
- Scanner contract: `commit-path/v2`, `diff-pickaxe/v3`, `public-tree/v3`
- Coverage: 17,061 commits, 109,865 objects, 369 findings — 368 path findings
(IP001–IP006) and one SEC001 finding (`server.md` in commit `13e3e76353`,
reachable from an archive tag). The SEC001 finding is a credential; its
rotation is tracked privately.
- Current tree: 0 accepted finding IDs (previously 11, all removed by the
precise rule).
- Runtime: full audit 533 s (limit 1,800 s).
- The evidence that shipped with the 3.11.0 export named 8,818 commits, which
matches no public ref set; it most likely came from the private integration
tree. This rotation replaces it with an audit of the public refs.

Verified against the private integration tree: of the 93 findings the previous
rule reported there, none remains.
2 changes: 1 addition & 1 deletion security/evidence/full-history-baseline-v1.json

Large diffs are not rendered by default.

70 changes: 64 additions & 6 deletions security/history-policy-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"max_commits": 20000,
"max_objects": 300000,
"max_findings": 5000,
"command_timeout_seconds": 120,
"command_timeout_seconds": 300,
"full_audit_timeout_seconds": 1800
},
"forbidden_paths": [
Expand Down Expand Up @@ -35,7 +35,65 @@
],
"secret_rule": {
"id": "SEC001",
"pickaxe_regex": "AKIA|ghp_|gho_|ghs_|ghu_|ghr_|sk_live_|PRIVATE KEY-----|postgres://[^\\s]+:[^@\\s]+@|postgresql://[^\\s]+:[^@\\s]+@|mysql://[^\\s]+:[^@\\s]+@"
"rationale": "Two stages. pickaxe_regex is a POSIX ERE git evaluates natively to narrow candidates (git log -S --pickaxe-regex, git grep -E); it is a line-level superset of credential_patterns and deliberately interval-free, because bounded repetitions make git's POSIX matcher several times slower over full history. credential_patterns are Python regexes over the whole blob and decide: real credential shapes only, never bare prefixes. PEM blocks need base64 key material after the BEGIN line. Database URLs need a literal password of at least min_length characters that is neither a placeholder ($VAR, {var}, <var>, [var], %s, masked ***) nor a documented local development default. AWS documentation keys ending in EXAMPLE are not credentials. A match holding an ascending run of synthetic_run_length digits or letters is a hand-made fixture. reviewed_blobs exempts individually reviewed content by git blob ID only; any edit is scanned again.",
"pickaxe_regex": "gh[pousr]_[A-Za-z0-9]|github_pat_|A[KS]IA[0-9A-Z]|[rs]k_live_[0-9A-Za-z]|PRIVATE KEY-----|(postgres|postgresql|mysql|mariadb)[+a-z0-9]*://[^@[:space:]]+@",
"credential_patterns": [
{
"id": "github-token",
"regex": "\\bgh[pousr]_[A-Za-z0-9]{36,}\\b"
},
{
"id": "github-fine-grained-token",
"regex": "\\bgithub_pat_[A-Za-z0-9_]{22,}"
},
{
"id": "aws-access-key-id",
"regex": "\\b(?:AKIA|ASIA)(?![0-9A-Z]{9}EXAMPLE\\b)[0-9A-Z]{16}\\b"
},
{
"id": "stripe-live-key",
"regex": "\\b(?:sk|rk)_live_[0-9A-Za-z]{24,}\\b"
},
{
"id": "pem-private-key",
"regex": "-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----(?:\\\\r)?(?:\\\\n|\\r?\\n)(?:[ \\t\\\"'+,]*[A-Za-z-]+:[^\\r\\n]*?(?:\\\\r)?(?:\\\\n|\\r?\\n))*(?:[ \\t\\\"'+,]*(?:\\\\r)?(?:\\\\n|\\r?\\n))?[ \\t\\\"'+,]*[A-Za-z0-9+/]{40,}"
},
{
"id": "database-url-password",
"regex": "\\b(?:postgres|postgresql|mysql|mariadb)(?:\\+[a-z0-9]+)?://[^\\s:@/'\\\"`]+:(?P<password>[^\\s@/'\\\"`]+)@"
}
],
"password_policy": {
"min_length": 8,
"placeholder_prefixes": [
"$",
"{",
"<",
"[",
"%"
],
"placeholder_markers": [
"${",
"{{",
"$(",
"***",
"%s"
],
"dev_default_passwords": [
"changeme",
"password",
"leanctx-dev",
"leanctx_e2e",
"localtest"
]
},
"synthetic_run_length": 8,
"reviewed_blobs": [
{
"blob": "9698c59bbf61ff6742c4d9762ea1f452139c01c5",
"reason": "Self-signed loopback TLS test key (certificate CN=localhost, SAN localhost and 127.0.0.1, reviewed 2026-10-17). Read only by HTTPS loopback integration tests; it authenticates nothing outside the test process."
}
]
},
"policy_source_path": "security/history-policy-v1.json",
"scanner_source_paths": [
Expand All @@ -54,8 +112,8 @@
],
"scanner_source_sha256": {
".github/workflows/security-check.yml": "5afe73f93ce67b5832fb1839c707c5a75a18951cdf015aedca3b6177743e9d83",
"scripts/history-policy-gate.py": "6191eb7e86b7253aef09c9ef9bb5167ba599f91f9e23d43be61452d039511ba8",
"tests/security/test_history_policy_gate.py": "ad6c71cadb5e5896ba926f9633249c571c9449af63605cc0d91c27ccbabfd108",
"scripts/history-policy-gate.py": "d018605b96d155293854985a1627da3bfc18e2721a7640b5530c9889ecfdb899",
"tests/security/test_history_policy_gate.py": "3aac0ee607d6968a9a0e755e2020be2a0fdfb2f44760cb91dba6fbc1aabb2f29",
"tests/security/test_security_check_workflow.py": "2d0c9b9a6ceb61c97379290d549bbdcf7812ce17db9794b5d1b067e2202c2844",
"rust/src/proxy/anthropic.rs": "02be2417f1eaab0730b4328fee90c91179c46f8ed4c0c2ae2f4737d2331537b7",
"rust/src/proxy/anthropic_tests.rs": "d8e6572b778d48b2a158c2caf1ea9807ae03621b3215f31f3a2cde078b2a8c53",
Expand All @@ -66,8 +124,8 @@
"rust/src/proxy/openai_responses_ws.rs": "968b11785cc3fb3229b2dbd0c7e8339654f9b15e9af0c8a2cbe8215c35bea284"
},
"baseline": {
"commit": "bff280bfc8eba6959854fafcbf104b5745d794c3",
"commit": "b37ee3be11f2525cd785348d3e43ccdbb590fe94",
"report": "security/evidence/full-history-baseline-v1.json",
"report_sha256": "f2698fc347bd51b756b3ecbde00231e12953beb5a16db64ffd40fd9df5e2f3e6"
"report_sha256": "29fb4b0997f3f45cd80091dd6d365a3097644d7dae6dc1a41c88303a6572501e"
}
}
Loading
Loading