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

Class diagram scripts.check_secret_leaks

Dependency diagram

Dependency diagram scripts.check_secret_leaks

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

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/scripts/check_secret_leaks.py

#!/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())