#!/usr/bin/env python3 # -*- coding: utf-8 -*- """security_scan_gate.py — a dependency and secret scanner on every push, WARN-FIRST (R-986). Usage: python3 scripts/security_scan_gate.py Shared by the four felhom repos (it lives HERE, in felhom.eu/scripts/, and is never copied — the reuse_refs_check.py rule). Each repo's gate runner calls it with its own root, in the FAST set, so the pre-push hook runs it too. WHAT IT RUNS (pinned versions; `documentation/audits/security-review-2026-10-12/SCANNERS.md`): govulncheck v1.8.0 on every Go module of the repo (each directory with a tracked go.mod), source mode. Only CALLED vulnerabilities are listed (a trace that reaches a function); an imported- or required-only one is counted, not listed — the brief's „called vulnerabilities only". gitleaks 8.24.3 on the commits being pushed: `@{upstream}..HEAD` in a clone with an upstream, the newest commit when that range is empty, and the pushed commit in CI (a depth-1 clone). Values are redacted (`--redact=100`): this gate never prints a secret. WARN-FIRST. A hit is printed and listed; the exit code stays 0. A tool that is missing, a toolchain that is absent (CI's runner has python3 and git, no Go), or a network that cannot reach the vulnerability database is printed as NOT CHECKED — never a pass in silence, never a refusal. Turning a hit into a refusal is a later step with its own row. EXIT: 0 always, except a usage error (2). TOUCHES THE NETWORK, and is still in the fast set — deliberately, and only because it cannot refuse: govulncheck reads vuln.go.dev. Offline, it says NOT CHECKED and the push goes on (the fast set's promise is „a push never fails because the network blinked", and that promise holds). Each tool has a time limit. ACCEPTED HITS live in `/.security-accepted`, one per line, with a reason: govulncheck GO-2026-1234 hub # why it is accepted gitleaks generic-api-key@3f2a9c01d4e5 hub/internal/x_test.go # why A govulncheck line names the OSV id and the module directory („." for the root). A gitleaks line names the rule id, `@`, and the first 12 hex of the sha256 of the hit's whole LINE (read from git, never printed), then the file — so one accepted test constant does not accept the next secret written into the same file. The WARN line prints that `rule@hash` to copy. A line with no `# reason` is itself a WARN. An accepted hit is listed as ACCEPTED. CI (`FELHOM_SCAN_FETCH=1`, set in each repo's gates.yml): when gitleaks is not on this machine, the pinned release tarball is fetched from GitHub and both the tarball and the binary are checked against the sha256 values below before it runs; a mismatch is NOT CHECKED, never a run. SEAMS (for the decoy suite only): `FELHOM_SCAN_GOVULNCHECK_JSON=` reads govulncheck's JSON from a file instead of running it (each module gets that file); `FELHOM_SCAN_RANGE=` pins the commits. """ import hashlib import io import json import os import shutil import subprocess import sys import tarfile import tempfile import urllib.request for _stream in (sys.stdout, sys.stderr): try: _stream.reconfigure(encoding="utf-8", errors="replace") except (AttributeError, ValueError, OSError): # pragma: no cover pass GOVULNCHECK_VERSION = "v1.8.0" GITLEAKS_VERSION = "8.24.3" GITLEAKS_URL = ("https://github.com/gitleaks/gitleaks/releases/download/v%s/gitleaks_%s_linux_x64.tar.gz" % (GITLEAKS_VERSION, GITLEAKS_VERSION)) # The release's own checksums file (gitleaks_8.24.3_checksums.txt) and the binary inside, read 2026-10-19; # the binary's sha is the one SCANNERS.md recorded for the 2026-10-12 review. GITLEAKS_TGZ_SHA256 = "9991e0b2903da4c8f6122b5c3186448b927a5da4deef1fe45271c3793f4ee29c" GITLEAKS_BIN_SHA256 = "e18325d568268b6efb6cd0cc721f8bea5667f4cd1c8d03618ab61987f41269ad" # FELHOM_SECURITY_TOOLS_DIR moves the default (the decoy suite points it at an empty dir to make a tool „missing"). TOOLS_DIR = os.environ.get("FELHOM_SECURITY_TOOLS_DIR", "/mnt/5_hdd/felhom.eu/tools/security/bin") TIMEOUT = 180 ACCEPTED = ".security-accepted" def find_tool(name): for d in (os.environ.get("FELHOM_SECURITY_BIN", ""), TOOLS_DIR): p = os.path.join(d, name) if d else "" if p and os.path.isfile(p) and os.access(p, os.X_OK): return p return shutil.which(name) def sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def fetch_gitleaks(): """CI only: the pinned release, both checksums verified. Returns (path, note).""" cache = os.path.join(os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache"), "felhom-scan", GITLEAKS_VERSION) binp = os.path.join(cache, "gitleaks") if os.path.isfile(binp) and sha256(binp) == GITLEAKS_BIN_SHA256: return binp, "cached pinned release" try: os.makedirs(cache, exist_ok=True) req = urllib.request.Request(GITLEAKS_URL, headers={"User-Agent": "felhom-security-scan/1.0"}) data = urllib.request.urlopen(req, timeout=60).read() except Exception as e: # network, DNS, TLS return None, "could not fetch the pinned release (%s)" % str(e)[:120] if hashlib.sha256(data).hexdigest() != GITLEAKS_TGZ_SHA256: return None, "the fetched tarball's sha256 is not the pinned one — not run" with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as t: m = t.getmember("gitleaks") with open(binp + ".new", "wb") as out: out.write(t.extractfile(m).read()) if sha256(binp + ".new") != GITLEAKS_BIN_SHA256: os.remove(binp + ".new") return None, "the binary inside the tarball is not the pinned one — not run" os.chmod(binp + ".new", 0o755) os.replace(binp + ".new", binp) return binp, "fetched pinned release, sha256 checked" def read_accepted(root, warns): out = {"govulncheck": {}, "gitleaks": {}} p = os.path.join(root, ACCEPTED) if not os.path.isfile(p): return out for n, line in enumerate(io.open(p, encoding="utf-8"), 1): body, _, reason = line.partition("#") f = body.split() if not f: continue if len(f) != 3 or f[0] not in out: warns.append("%s:%d: not „ # reason\" — ignored" % (ACCEPTED, n)) continue if not reason.strip(): warns.append("%s:%d: %s %s %s has no reason — accepted anyway, but say why" % (ACCEPTED, n, f[0], f[1], f[2])) out[f[0]][(f[1], f[2])] = reason.strip() or "(no reason given)" return out def git(root, *args): r = subprocess.run(["git", "-C", root] + list(args), capture_output=True, text=True, timeout=60) return r.returncode, r.stdout.strip() def go_modules(root): rc, out = git(root, "ls-files", "*go.mod", "go.mod") if rc != 0: return [] mods = [] for f in out.splitlines(): parts = f.split("/") # product modules only: not a test fixture, a vendored copy, or an audit's throwaway program if f.endswith("go.mod") and not {"testdata", "vendor", "documentation", "audits"} & set(parts[:-1]): mods.append(os.path.dirname(f) or ".") return sorted(set(mods)) def parse_govulncheck(text): """govulncheck -format json is a stream of objects. Returns (called {id: (module, summary)}, other_ids, error).""" dec = json.JSONDecoder() i, osv, called, other = 0, {}, {}, set() while i < len(text): while i < len(text) and text[i].isspace(): i += 1 if i >= len(text): break try: o, i = dec.raw_decode(text, i) except ValueError: return called, other, text[i:i + 200].strip().splitlines()[0] if text[i:].strip() else "unreadable output" if "osv" in o: osv[o["osv"].get("id", "?")] = o["osv"].get("summary", "") if "finding" in o: f = o["finding"] tr = f.get("trace") or [] if tr and tr[0].get("function"): called[f.get("osv", "?")] = tr[0].get("module", "") else: other.add(f.get("osv", "?")) return {k: (m, osv.get(k, "")) for k, m in called.items()}, other - set(called), None def run_govulncheck(root, accepted, lines): new = acc = 0 mods = go_modules(root) if not mods: lines.append(" govulncheck: no Go module in this repo — nothing to scan") return 0, 0 seam = os.environ.get("FELHOM_SCAN_GOVULNCHECK_JSON") tool = None if seam else find_tool("govulncheck") if not seam and not tool: lines.append(" govulncheck: NOT CHECKED — govulncheck %s is not on this machine (tried $FELHOM_SECURITY_BIN, %s, PATH)" % (GOVULNCHECK_VERSION, TOOLS_DIR)) return 0, 0 if not seam and not shutil.which("go"): lines.append(" govulncheck: NOT CHECKED — no Go toolchain here (CI's runner has python3 and git only); the " "pre-push hook runs it") return 0, 0 for m in mods: if seam: text, note = io.open(seam, encoding="utf-8").read(), "(seam: %s)" % os.path.basename(seam) else: try: r = subprocess.run([tool, "-format", "json", "./..."], cwd=os.path.join(root, m), capture_output=True, text=True, timeout=TIMEOUT) except subprocess.TimeoutExpired: lines.append(" govulncheck %s: NOT CHECKED — over %ds" % (m, TIMEOUT)) continue text, note = r.stdout + r.stderr, "" called, other, err = parse_govulncheck(text) if err and not called: lines.append(" govulncheck %s: NOT CHECKED — %s %s" % (m, err[:160], note)) continue lines.append(" govulncheck %s: %d called vulnerabilit%s (%d imported or required only, not listed) %s" % (m, len(called), "y" if len(called) == 1 else "ies", len(other), note)) for vid in sorted(called): mod, summary = called[vid] why = accepted.get((vid, m)) if why: acc += 1 lines.append(" ACCEPTED %s (%s) — %s" % (vid, mod, why)) else: new += 1 lines.append(" WARN %s called, in %s — %s" % (vid, mod, summary[:100])) return new, acc def scan_range(root): r = os.environ.get("FELHOM_SCAN_RANGE") if r: return r, "pinned by FELHOM_SCAN_RANGE" if os.environ.get("GITHUB_ACTIONS") or os.environ.get("GITEA_ACTIONS"): return "-n 1 HEAD", "the pushed commit; CI clones at depth 1" rc, up = git(root, "rev-parse", "--verify", "-q", "@{upstream}") if rc == 0 and up: rc2, n = git(root, "rev-list", "--count", "@{upstream}..HEAD") if rc2 == 0 and n.isdigit() and int(n) > 0: return "@{upstream}..HEAD", "the %s commit(s) not yet on the upstream" % n return "-n 1 HEAD", "the newest commit; nothing unpushed" def line_hash(root, commit, path, lineno): """sha256[:12] of the hit's stripped line, read from the commit (or the working tree) — in memory only.""" text = None if commit: r = subprocess.run(["git", "-C", root, "show", "%s:%s" % (commit, path)], capture_output=True, timeout=60) if r.returncode == 0: text = r.stdout.decode("utf-8", "replace") if text is None: try: text = io.open(os.path.join(root, path), encoding="utf-8", errors="replace").read() except OSError: return "unread" ls = text.splitlines() if not 0 < lineno <= len(ls): return "unread" return hashlib.sha256(ls[lineno - 1].strip().encode("utf-8")).hexdigest()[:12] def run_gitleaks(root, accepted, lines): tool = find_tool("gitleaks") note = "" if not tool and os.environ.get("FELHOM_SCAN_FETCH") == "1": tool, note = fetch_gitleaks() if not tool: lines.append(" gitleaks: NOT CHECKED — gitleaks %s is not on this machine%s" % (GITLEAKS_VERSION, "; " + note if note else "")) return 0, 0 for f in (".gitleaks.toml", ".gitleaksignore"): if os.path.exists(os.path.join(root, f)): lines.append(" gitleaks: WARN this repo carries %s — the scan below ignores it (its config and ignore list are " "pinned by this gate, so a file in the repo cannot switch rules off)" % f) rng, why = scan_range(root) tmp = tempfile.mkdtemp(prefix="felhom-scan-") try: rep = os.path.join(tmp, "report.json") empty_ignore = os.path.join(tmp, "ignore") os.makedirs(empty_ignore) cfg_env = dict(os.environ) for k in ("GITLEAKS_CONFIG", "GITLEAKS_CONFIG_TOML"): cfg_env.pop(k, None) argv = [tool, "git", root, "--log-opts=" + rng, "--redact=100", "--no-banner", "--report-format", "json", "--report-path", rep, "--exit-code", "0", "--gitleaks-ignore-path", empty_ignore] # The default rules, never a .gitleaks.toml from the scanned tree: hand gitleaks its own default config. dflt = os.path.join(tmp, "default.toml") io.open(dflt, "w").write('[extend]\nuseDefault = true\n') argv += ["--config", dflt] try: r = subprocess.run(argv, capture_output=True, text=True, timeout=TIMEOUT, env=cfg_env) except subprocess.TimeoutExpired: lines.append(" gitleaks: NOT CHECKED — over %ds" % TIMEOUT) return 0, 0 if r.returncode != 0 or not os.path.isfile(rep): lines.append(" gitleaks: NOT CHECKED — exit %d: %s" % (r.returncode, (r.stderr or r.stdout).strip()[-160:])) return 0, 0 hits = json.load(io.open(rep, encoding="utf-8")) or [] finally: shutil.rmtree(tmp, ignore_errors=True) lines.append(" gitleaks %s on %s (%s)%s: %d hit(s)" % (GITLEAKS_VERSION, rng, why, " [" + note + "]" if note else "", len(hits))) new = acc = 0 for h in hits: rule, path = h.get("RuleID", "?"), h.get("File", "?") key = "%s@%s" % (rule, line_hash(root, h.get("Commit") or "", path, h.get("StartLine") or 0)) why = accepted.get((key, path)) where = "%s:%s, commit %s" % (path, h.get("StartLine", "?"), (h.get("Commit") or "")[:10]) if why: acc += 1 lines.append(" ACCEPTED %s %s — %s" % (key, where, why)) else: new += 1 lines.append(" WARN %s %s (value redacted)" % (key, where)) return new, acc def main(argv): if len(argv) != 2 or not os.path.isdir(argv[1]): print(__doc__.split("\n\n")[1]) return 2 root = os.path.abspath(argv[1]) warns, lines = [], [] accepted = read_accepted(root, warns) print("security-scan (R-986, WARN-FIRST — it lists, it never refuses): %s" % root) n1, a1 = run_govulncheck(root, accepted["govulncheck"], lines) n2, a2 = run_gitleaks(root, accepted["gitleaks"], lines) for l in lines: print(l) for w in warns: print(" WARN %s" % w) print("security-scan: %d new hit(s), %d accepted — warn-first, the push goes on" % (n1 + n2, a1 + a2)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))