scripts.check_secret_leaks
Reference › scripts › check_secret_leaks
The secret-leak gate: it catches a live credential committed into tracked source. It scans the files that git tracks, whole files rather than a diff, and complements the commit-time check_staged_secrets. It uses the waiver mechanism in gate_waivers. No test imports it.
Invocation: main takes --repo-root (default the current directory) and --json. It exits 2 if the root does not exist or git cannot be read (GitError); otherwise the exit code comes from run_check, which fails when findings remain.
iter_tracked_files lists tracked files through git, and scan_file reads each text file and looks at name and value pairs. A name is suspect when its tokens match the credential-name vocabulary (name_matches), and the value counts only if it is a literal that is not an obvious placeholder (extract_literal, looks_like_placeholder). Lines can carry a recorded waiver, which _finding honours. Each result is a Finding with file, line, name and reason; build_report and print_text_report produce the output, and the report omits the value text.
At a glance
| Source | scripts/check_secret_leaks.py |
| Lines of code | 476 |
| Classes | 2 |
| Functions | 13 |
| Imports (internal) | orchestrata_ultimate.core.gate_waivers |
| Imported by | none |
| Tests that import it | none |
Class diagram
Dependency diagram
API
Constants
| Name | Type | Value |
|---|---|---|
SHAPE_PATTERNS |
tuple[tuple[str, re.Pattern[str]], ...] |
(('gitlab-pat', re.compile('glpat-[A-Za-z0-9_-]{20,}')), ('secret-key-sk-prefix… |
Classes
GitError
Bases: Exception · Line 218
git could not be asked what it tracks.
Finding (dataclass)
Decorators: dataclass · Line 223
| Attribute | Type | Default |
|---|---|---|
path |
str |
|
line_no |
int |
|
name |
str |
|
reason |
str |
|
value_len |
int |
|
value_sha256_prefix |
str |
|
waived |
bool |
False |
waiver_reason |
str |
'' |
| Method | Signature | Summary |
|---|---|---|
render |
render() -> str |
|
as_dict |
as_dict() -> dict[str, object] |
Functions
| Function | Signature | Summary |
|---|---|---|
_name_tokens |
_name_tokens(name: str) -> set[str] |
Segment name on non-alnum boundaries AND camelCase transitions. |
name_matches |
name_matches(name: str) -> bool |
|
looks_like_placeholder |
looks_like_placeholder(value: str) -> bool |
|
extract_literal |
extract_literal(raw_value: str) -> tuple[str, bool] |
Return (clean_value, was_quoted) for an assignment's RHS text. |
iter_tracked_files |
iter_tracked_files(repo_root: Path) -> list[str] |
|
_read_text |
_read_text(path: Path) -> str | None |
|
_finding |
_finding(rel_path: str, line_no: int, name: str, reason: str, flagged_text: str, waivers: dict[int, str]) -> Finding |
|
scan_file |
scan_file(repo_root: Path, rel_path: str) -> list[Finding] |
|
build_report |
build_report(findings: list[Finding]) -> dict |
|
print_text_report |
print_text_report(report: dict) -> None |
|
run_check |
run_check(repo_root: Path) -> tuple[int, dict] |
|
build_parser |
build_parser() -> argparse.ArgumentParser |
|
main |
main(argv: list[str] | None = None) -> int |
Source
#!/usr/bin/env python3
"""Secret-leak gate — catches a live credential committed into TRACKED source.
MEASURED INCIDENT (2026-08-12). The file
``.env.pre-orchgraph-cutover.1783689323`` was committed and PUSHED to a
GitLab remote carrying four non-empty secret-shaped values (NEO4J_PASSWORD,
GITLAB_TOKEN, DEEPSEEK_API_KEY, OPENROUTER_API_KEY) — two were still live
when found. None of the 16 gates already in ``scripts/gates.py`` noticed,
because none of them read tracked-file CONTENT looking for secret shapes;
they read Python structure (imports, AST, ``os.environ`` call sites),
Prolog facts, or CI config. This gate closes that hole: it is a content
scanner over what ``git`` actually tracks, independent of file extension.
DETECTION RULES
1. NAME-based. An assignment (``NAME=value``, ``NAME: value``, ``export
NAME=value``, or a quoted ``"NAME": "value"`` JSON/dict entry) whose NAME,
split on underscores/hyphens/case-boundaries, contains a token from
{KEY, TOKEN, SECRET, PASSWORD, PASS, API, CREDENTIAL(S), AUTH} — matched as
a whole SEGMENT, not a raw substring, so ``BYPASS_CACHE`` and
``COMPASS_DIRECTION`` do not collide with ``PASS`` the way naive substring
matching would, while ``NEO4J_PASSWORD`` / ``GITLAB_TOKEN`` /
``DEEPSEEK_API_KEY`` / ``OPENROUTER_API_KEY`` — the exact names that
leaked — still match every one of their component tokens.
The value must be non-empty and not a placeholder (see below). In a CODE
file (``.py``, ``.js``, ...) the value must additionally be a quoted
string literal — an unquoted RHS in code is a variable/expression
reference (``self.token = token``), not literal secret content, and that
pattern is the single largest false-positive source a bare substring/
assignment scan would otherwise produce across ~76 agent modules.
2. SHAPE-based, regardless of NAME or quoting: 'glpat-' + 20 or more
token chars (GitLab PAT), 'sk-' + 20 or more token chars (OpenAI/
DeepSeek-style secret key), 'AKIA' + 16 upper/digit chars (AWS access key
id), and a PEM private-key header. These are checked against the whole
RAW LINE, not just an assignment's value, so a PAT embedded in a URL
(``https://gitlab-ci-token:glpat-XXXX@...``) is caught too. They are
high-confidence by construction (fixed prefix + minimum random-looking
length), so they do not need the NAME or quoting gate.
EXEMPTIONS
- ``git ls-files`` scope only. An untracked plaintext ``.env`` is the
design's legitimate, gitignored-forever secrets file — flagging it would
be flagging the correct state, not a leak.
- ``.env.example`` (placeholders, e.g. ``changeme``), ``env/dev.env``,
``env/test.env``, ``env/prod.env`` (endpoint profiles by contract — no
secrets; each file's own header comment says so and was read to confirm
it before this list was written), ``secrets/env.sops.env`` (the design's
tracked SOPS ciphertext — its values are ``ENC[...]`` blobs, not
plaintext, and NAME-based matching alone cannot tell that apart from a
real leak without a path exemption), and this gate's own acceptance-test
fixture file, which plants fake secret-shaped literals on purpose.
- A per-line ``# gate-ok: <reason>`` waiver, read from the one shared reader
(``core.gate_waivers``) that six other checkers already honour, per this
task's explicit instruction not to invent a second suppression syntax.
- Purely numeric / boolean / empty / known-placeholder values (``changeme``,
``xxx``, ``<your-key-here>``, ...) — real secrets are never a bare integer
or ``true``/``false``, and this project's own ``.env.example`` uses
exactly these placeholder idioms for every secret it documents.
REPORTING. Never a value. Every finding prints file:line, the KEY NAME (or a
synthetic label for a shape hit with no assignment context), its length, and
a 12-hex-char sha256 prefix — enough for a human to correlate "yes, that's
the credential I rotated" without the gate output becoming a second leak.
Usage::
python scripts/check_secret_leaks.py # scan this checkout
python scripts/check_secret_leaks.py --repo-root DIR # scan another tree
python scripts/check_secret_leaks.py --json # machine-readable
Exit code: 0 clean, 1 violations found, 2 cannot run (not a git repository).
"""
from __future__ import annotations
import argparse
import dataclasses
import hashlib
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from orchestrata_ultimate.core.gate_waivers import waiver_reasons
RULE_ID = "SECRET-LEAK"
# Files whose values are placeholders, endpoint-only, or ciphertext BY
# CONTRACT (each was read in full before being listed here), plus this
# gate's own acceptance-test fixture, which plants fake secret shapes on
# purpose. See the module docstring for why each one is safe to skip.
EXEMPT_PATHS = frozenset(
{
".env.example",
"env/dev.env",
"env/test.env",
"env/prod.env",
"secrets/env.sops.env",
"tests/scripts/test_check_secret_leaks.py",
}
)
# Only require a quoted string literal (not a bare identifier reference) for
# the NAME-based check when the file is CODE — a dotenv/yaml/ini/shell value
# is a literal by construction, so no such gate applies to those files.
CODE_EXTENSIONS = frozenset(
{
".py",
".pyi",
".js",
".jsx",
".ts",
".tsx",
".go",
".rs",
".java",
".rb",
".c",
".cc",
".cpp",
".h",
".hpp",
".cs",
".php",
".swift",
".kt",
}
)
MAX_FILE_BYTES = 2_000_000 # generated dumps (AST/RTM/coverage JSON) skipped
SECRET_NAME_TOKENS = frozenset(
{
"KEY",
"TOKEN",
"SECRET",
"PASSWORD",
"PASS",
"API",
"CREDENTIAL",
"CREDENTIALS",
"AUTH",
}
)
# NAME=value / NAME: value / export NAME=value / "NAME": "value". Anchored at
# line start (after indentation) so this only fires on a real assignment/
# mapping-entry SHAPE, never on an identifier that merely appears mid-line
# (e.g. `self.password = password` still matches — see the docstring on why
# that specific shape is handled by the quoted-literal gate below, not here).
# `(?!=)` after the operator excludes `==` comparisons and `:=` walrus.
ASSIGNMENT_RE = re.compile(
r"^\s*(?:export\s+)?[\'\"]?([A-Za-z_][A-Za-z0-9_.\-]*)[\'\"]?\s*[:=](?!=)\s*(.*)$"
)
# A leading quote (with an optional 1-2 letter string-prefix: r, b, f, rb...)
# captures the literal's inner content up to the matching close quote, and
# ignores anything after it (a trailing comment, a `+ "..."` continuation).
QUOTED_LITERAL_RE = re.compile(r"^[A-Za-z]{0,2}([\'\"])(.*?)\1")
NUMERIC_RE = re.compile(r"^-?\d+(\.\d+)?$")
PLACEHOLDER_EXACT = frozenset(
{
"changeme",
"change_me",
"change-me",
"changethis",
"change-this",
"your_key_here",
"your-key-here",
"your_api_key_here",
"your-api-key-here",
"your_token_here",
"your-token-here",
"xxx",
"xxxx",
"xxxxx",
"placeholder",
"todo",
"fixme",
"example",
"sample",
"dummy",
"fake",
"n/a",
"na",
"none",
"null",
"redacted",
"unset",
"tbd",
"true",
"false",
"yes",
"no",
"on",
"off",
"insert-key-here",
"insert_key_here",
"not-a-real-key",
"notarealkey",
}
)
PLACEHOLDER_PREFIXES = ("your-", "your_", "insert-", "insert_", "<", "{{", "${")
# label -> compiled pattern. Checked against the RAW LINE, independent of
# assignment structure and of the NAME-based gate above.
SHAPE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("gitlab-pat", re.compile(r"glpat-[A-Za-z0-9_-]{20,}")),
("secret-key-sk-prefix", re.compile(r"sk-[A-Za-z0-9_-]{20,}")),
("aws-access-key-id", re.compile(r"AKIA[A-Z0-9]{16}")),
)
PEM_HEADER_RE = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")
class GitError(Exception):
"""``git`` could not be asked what it tracks."""
@dataclass
class Finding:
path: str
line_no: int
name: str
reason: str
value_len: int
value_sha256_prefix: str
waived: bool = False
waiver_reason: str = ""
def render(self) -> str:
head = f"{self.path}:{self.line_no} {self.name} [{self.reason}]"
tail = f" len={self.value_len} sha256={self.value_sha256_prefix}"
if self.waived:
tail += f" WAIVED ({self.waiver_reason})"
return head + tail
def as_dict(self) -> dict[str, object]:
return dataclasses.asdict(self)
def _name_tokens(name: str) -> set[str]:
"""Segment *name* on non-alnum boundaries AND camelCase transitions.
``NEO4J_PASSWORD`` -> {NEO4J, PASSWORD}; ``apiKey`` -> {API, KEY};
``bypass_cache`` -> {BYPASS, CACHE} (a whole segment, so it does not
collide with the ``PASS`` token the way a raw substring search would).
"""
boundary = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)
return {tok.upper() for tok in re.split(r"[^A-Za-z0-9]+", boundary) if tok}
def name_matches(name: str) -> bool:
return bool(_name_tokens(name) & SECRET_NAME_TOKENS)
def looks_like_placeholder(value: str) -> bool:
v = value.strip()
if not v:
return True
low = v.lower()
if low in PLACEHOLDER_EXACT:
return True
if any(low.startswith(p) for p in PLACEHOLDER_PREFIXES):
return True
if NUMERIC_RE.match(v):
return True
return False
def extract_literal(raw_value: str) -> tuple[str, bool]:
"""Return ``(clean_value, was_quoted)`` for an assignment's RHS text."""
v = raw_value.strip()
m = QUOTED_LITERAL_RE.match(v)
if m:
return m.group(2), True
unquoted = re.split(r"\s+#", v, maxsplit=1)[0].strip()
return unquoted, False
def iter_tracked_files(repo_root: Path) -> list[str]:
try:
proc = subprocess.run(
["git", "ls-files"],
cwd=repo_root,
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError as exc:
raise GitError("git is not installed / not on PATH") from exc
if proc.returncode != 0:
raise GitError((proc.stderr or proc.stdout or "git ls-files failed").strip())
return [line for line in proc.stdout.split("\n") if line]
def _read_text(path: Path) -> str | None:
try:
raw = path.read_bytes()
except OSError:
return None
if len(raw) > MAX_FILE_BYTES:
return None
if b"\x00" in raw[:8192]:
return None # binary heuristic — never text-scan a binary blob
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return None
def _finding(
rel_path: str,
line_no: int,
name: str,
reason: str,
flagged_text: str,
waivers: dict[int, str],
) -> Finding:
digest = hashlib.sha256(flagged_text.encode("utf-8", "replace")).hexdigest()[:12]
waived = line_no in waivers
return Finding(
path=rel_path,
line_no=line_no,
name=name,
reason=reason,
value_len=len(flagged_text),
value_sha256_prefix=digest,
waived=waived,
waiver_reason=waivers.get(line_no, ""),
)
def scan_file(repo_root: Path, rel_path: str) -> list[Finding]:
if rel_path in EXEMPT_PATHS:
return []
text = _read_text(repo_root / rel_path)
if text is None:
return []
code_file = Path(rel_path).suffix.lower() in CODE_EXTENSIONS
waivers = waiver_reasons(text)
findings: list[Finding] = []
for line_no, line in enumerate(text.splitlines(), start=1):
m = ASSIGNMENT_RE.match(line)
assign_name: str | None = None
if m:
assign_name = m.group(1)
assert assign_name is not None
clean_value, was_quoted = extract_literal(m.group(2))
if (
name_matches(assign_name)
and not looks_like_placeholder(clean_value)
and (not code_file or was_quoted)
):
findings.append(
_finding(
rel_path,
line_no,
assign_name,
"name-pattern",
clean_value,
waivers,
)
)
for label, pattern in SHAPE_PATTERNS:
hit = pattern.search(line)
if hit:
findings.append(
_finding(
rel_path,
line_no,
assign_name or f"<{label} match>",
f"shape:{label}",
hit.group(0),
waivers,
)
)
pem_hit = PEM_HEADER_RE.search(line)
if pem_hit:
findings.append(
_finding(
rel_path,
line_no,
assign_name or "<PEM private key header>",
"shape:pem-private-key",
pem_hit.group(0),
waivers,
)
)
return findings
def build_report(findings: list[Finding]) -> dict:
active = [f for f in findings if not f.waived]
waived = [f for f in findings if f.waived]
return {
"rule_id": RULE_ID,
"ok": not active,
"exit_code": 1 if active else 0,
"violations": [f.as_dict() for f in active],
"waived": [f.as_dict() for f in waived],
}
def print_text_report(report: dict) -> None:
print(f"[{RULE_ID}] secret-leak gate — tracked-file content scan")
for f in report["violations"]:
print(f" FAIL {Finding(**f).render()}")
for f in report["waived"]:
print(f" WAIVED {Finding(**f).render()}")
n_new = len(report["violations"])
n_waived = len(report["waived"])
if n_new:
print(
f"FAIL: {n_new} secret-shaped value(s) in tracked files "
f"({n_waived} waived, not counted)."
)
print(
" Rotate any live credential this points at immediately. Remove "
"the value from tracked history (it is not enough to delete it in "
"a later commit), or move it into secrets/env.sops.env (SOPS-"
"encrypted) if it must be tracked at all. A reviewed false "
"positive is waived with `# gate-ok: <reason>` on that line."
)
else:
print(f"PASS: no secret-shaped values in tracked files ({n_waived} waived).")
def run_check(repo_root: Path) -> tuple[int, dict]:
files = iter_tracked_files(repo_root)
findings: list[Finding] = []
for rel in files:
findings.extend(scan_file(repo_root, rel))
report = build_report(findings)
return report["exit_code"], report
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--repo-root",
default=None,
help="Tree to scan (default: this checkout's root)",
)
parser.add_argument("--json", action="store_true", help="Machine-readable output")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
repo_root = Path(args.repo_root).resolve() if args.repo_root else Path.cwd()
if not repo_root.is_dir():
print(f"ERROR: repo root does not exist: {repo_root}", file=sys.stderr)
return 2
try:
exit_code, report = run_check(repo_root)
except GitError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if args.json:
print(json.dumps(report, indent=2))
else:
print_text_report(report)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())