security_scan_gate.py: govulncheck (called vulns) + gitleaks on the pushed commits in every repo's fast set and CI, warn-first, with an accepted-hits file and 8 decoys (R-986); first scan triaged: 0 real secrets, 0 called vulnerabilities; evidence for R-953 and the 0.335.0 release
gates / gates (push) Failing after 12m59s

This commit is contained in:
2026-10-19 21:03:23 +02:00
parent d92a712ee4
commit 15f7428871
15 changed files with 811 additions and 2 deletions
+16
View File
@@ -1,3 +1,19 @@
## 2026-10-19 — a dependency and secret scanner on every push, warn-first (`security_scan_gate.py`, R-986)
New shared gate `scripts/security_scan_gate.py`, registered in all four runners (felhom.eu `repo_gates.py` gate 18,
`controller_gates.py`, `agent_gates.py`, `catalog_gates.py` through `check-security-scan.py`), FAST set, and so in the
pre-push hook and in CI. **Warn-first:** a hit is listed, the exit stays 0; a missing tool, no Go (CI) or no network is
NOT CHECKED out loud. govulncheck v1.8.0 on each Go module, called vulnerabilities only; gitleaks 8.24.3 on the commits
being pushed (`@{upstream}..HEAD`; CI: the pushed commit), default rules pinned (a `.gitleaks.toml` or
`.gitleaksignore` in the tree cannot switch rules off), values redacted. Accepted hits: `.security-accepted` per repo,
with a reason per line — gitleaks by rule + a hash of the hit's line + file. CI (`FELHOM_SCAN_FETCH=1`) fetches the
pinned gitleaks release and checks the tarball's and the binary's sha256. The catalog's CI now clones the felhom.eu
sibling (the gate is shared, never copied). Decoys: `scripts/test_security_scan_gate.py` (8 cases in scratch git repos)
run from felhom.eu's decoy suite and from each other repo's; red-proved (acceptance by rule alone → the second-secret
case FAILs; an imported-only finding counted as called → the govulncheck cases FAIL). The govulncheck pin moved from
v1.1.4 to v1.8.0 (v1.1.4 cannot read Go 1.27 code; `SCANNERS.md` §5). First scan of today's `main` in all four repos:
`documentation/audits/night-2026-10-19/P7/triage.md` — 0 called vulnerabilities, 0 real secrets.
## 2026-10-19 — felhom-host-install.sh 1.35.0 (NOT published): the hub's „live box" 409 is worded (R-1167)
Written for the night brief of 2026-10-19, Part 6; **not published** (publishing is a tag `installer-v1.35.0` plus the
+8
View File
@@ -33,6 +33,9 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
16. decoy-coverage every registered gate in all four repos has a decoy, or a named exemption (R-421)
17. poster-facts WARNS (never fails) when felhom-system-poster.facts.md is newer than the poster
17b. hub-gofmt every hub Go file is gofmt-clean (R-1160; the controller's R-454 shape)
18. security-scan govulncheck (called vulnerabilities) + gitleaks on the pushed commits — WARN-FIRST: it
lists, it never refuses; it touches the network (vuln.go.dev) and says NOT CHECKED
offline, which is why it may sit in the fast set (R-986; shared, felhom.eu/scripts)
**THE `GATES` TABLE BELOW IS THE LIST; THIS IS A POINTER TO IT.** It drifted once already —
it read eleven while thirteen were registered, from 2026-08-24 until 2026-09-01, so
@@ -202,6 +205,11 @@ GATES = [
# R-1160 (2026-10-19) — every hub Go file is gofmt-clean; the controller's gofmt_gate.py shape. Fast: one gofmt -l.
# The CI runner has no Go: there it says NOT CHECKED out loud and passes; the pre-push hook (with Go) enforces it.
("hub-gofmt", os.path.join(SCRIPTS, "hub_gofmt_gate.py"), [], True, False),
# R-986 (2026-10-19) — the dependency and secret scanner, WARN-FIRST (exit 0 whatever it finds; a hit is listed).
# In the fast set although it reads vuln.go.dev: it cannot refuse, so a network blink costs a NOT CHECKED line,
# never a push. ~10 s here (govulncheck on hub/ + gitleaks on the unpushed commits). CI fetches the pinned
# gitleaks (FELHOM_SCAN_FETCH=1 in gates.yml) and has no Go, so govulncheck says NOT CHECKED there, out loud.
("security-scan", os.path.join(SCRIPTS, "security_scan_gate.py"), [ROOT], True, False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
+333
View File
@@ -0,0 +1,333 @@
#!/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 <repo-root>
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 `<repo-root>/.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=<file>` reads govulncheck's JSON from a file
instead of running it (each module gets that file); `FELHOM_SCAN_RANGE=<git log 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 „<tool> <id> <where> # 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))
+19
View File
@@ -110,6 +110,12 @@ COVERS = {
"one-register": ("R-426: an open roadmap row whose state NAMES another row's CLOSED (a live hole until "
"2026-10-06), its id only in register prose, a suffix id; genuine: a row with a counterpart, "
"and an `idea` row (R-424's accepted hole, asserted BY DESIGN)"),
"security-scan": ("R-986: EIGHT cases in scripts/test_security_scan_gate.py, run from here, each in a SCRATCH "
"git repo: a planted token is listed by rule and file and its value never printed; a second "
"secret on another line of an ACCEPTED file is still listed (acceptance is per line); an "
"accepted line with no reason warns; a CALLED govulncheck finding is listed, an imported-only "
"one is not; no tool anywhere is NOT CHECKED out loud, never a silent pass; the clean commit "
"and the accepted line pass. Warn-first: every case exits 0, the LISTING is the fact"),
"hub-gofmt": ("R-1160: an unformatted .go file in a SCRATCH module dir is convicted by name; its "
"formatted twin passes; with no gofmt reachable, CI passes OUT LOUD and a dev machine is "
"INCONCLUSIVE, never a silent pass"),
@@ -786,6 +792,19 @@ decoy("due-checks/due-tomorrow", "due_checks_gate.py",
decoy("due-checks/marker-named-in-prose", "due_checks_gate.py",
_dc_plant(u"", prose=u"\nThe dated checks sit between the DUE-CHECKS-BEGIN and DUE-CHECKS-END markers.\n"))
# ── security-scan (R-986) ────────────────────────────────────────────────────────────────────────
#
# Its decoys are scratch git repos, so they live in their own file and are RUN from here (script-tests' shape).
ran += 1
_ss = subprocess.run([sys.executable, os.path.join("scripts", "test_security_scan_gate.py")],
cwd=ROOT, capture_output=True, text=True)
if _ss.returncode != 0:
fails.append("security-scan: its decoy suite FAILED — a decoy did not convict\n%s"
% (_ss.stdout + _ss.stderr)[-800:])
else:
_n = _ss.stdout.strip().splitlines()[-1] if _ss.stdout.strip() else "?"
print(" ok %-20s %s" % ("security-scan", _n))
# ── script-tests (R-885) ─────────────────────────────────────────────────────────────────────────
#
# Its decoys are whole fake repos, so they live in their own file and are RUN from here (guide-quote's shape).
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""test_security_scan_gate.py — the decoys of security_scan_gate.py (R-986). Run from felhom.eu's decoy suite and by
the script-tests gate; each other repo's decoy suite runs the shared gate on its own scratch clone.
Every case builds a SCRATCH git repo in a temp dir (never this tree) and judges the gate's OUTPUT — warn-first, so the
exit code is 0 in every case and the listing is the fact:
1. FACT: a commit that adds a GitHub-token-shaped string → WARN with the rule and the file; the value is NOT printed.
2. GENUINE: a clean commit → 0 hits, no WARN.
3. ACCEPTED: the same hit listed in .security-accepted (rule@line-hash + file + reason) → ACCEPTED, 0 new.
4. FACT: a SECOND secret on another line of an accepted file → still WARN (acceptance is per line, not per file).
5. FACT: an accepted line with no reason → WARN naming the line.
6. govulncheck (its JSON from a file, the seam): one CALLED finding is listed by id, an imported-only one is not; the
same id accepted → ACCEPTED.
7. FACT: no gitleaks anywhere (an empty tools dir, an empty PATH) → NOT CHECKED, out loud, exit 0 — never a silent
pass.
The fake token is built at run time, so no secret-shaped literal sits in this file. Without gitleaks on this machine
cases 1-5 say NOT CHECKED HERE and pass out loud (the workspace and CI with FELHOM_SCAN_FETCH=1 run them).
"""
import json
import os
import random
import shutil
import string
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
GATE = os.path.join(HERE, "security_scan_gate.py")
fails, ran = [], 0
def fake_token(seed=1986):
rnd = random.Random(seed)
return "ghp" + "_" + "".join(rnd.choice(string.ascii_letters + string.digits) for _ in range(36))
def run(cwd, argv, env=None):
p = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, env=env)
return p.returncode, p.stdout + p.stderr
def scratch_repo(base):
r = os.path.join(base, "repo")
os.makedirs(r)
for a in (["git", "init", "-q"], ["git", "config", "user.email", "decoy@felhom.invalid"],
["git", "config", "user.name", "decoy"], ["git", "config", "commit.gpgsign", "false"]):
run(r, a)
open(os.path.join(r, "README.md"), "w").write("scratch\n")
run(r, ["git", "add", "README.md"])
run(r, ["git", "commit", "-q", "-m", "base"])
return r
def commit(repo, path, text, msg):
full = os.path.join(repo, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
open(full, "w").write(text)
run(repo, ["git", "add", path])
run(repo, ["git", "commit", "-q", "-m", msg])
def gate(repo, env_extra=None):
env = dict(os.environ)
env["FELHOM_SCAN_RANGE"] = "-n 1 HEAD"
env.pop("GITHUB_ACTIONS", None)
env.pop("GITEA_ACTIONS", None)
env.update(env_extra or {})
return run(repo, [sys.executable, GATE, repo], env)
def check(name, cond, out):
global ran
ran += 1
if cond:
print(" ok %s" % name)
else:
fails.append("%s\n%s" % (name, out[-900:]))
def main():
base = tempfile.mkdtemp(prefix="secscan-decoy-")
try:
tok = fake_token()
repo = scratch_repo(base)
rc, out = gate(repo)
gitleaks_here = "gitleaks: NOT CHECKED" not in out
check("2 GENUINE: a clean commit — 0 hits, no WARN, exit 0",
rc == 0 and ("0 hit(s)" in out or not gitleaks_here)
and not [l for l in out.splitlines() if l.strip().startswith("WARN")], out)
if gitleaks_here:
commit(repo, "pkg/config.go", 'package pkg\n\nconst Token = "%s"\n' % tok, "plant")
rc, out = gate(repo)
warn = [l for l in out.splitlines() if l.strip().startswith("WARN github-pat@")]
check("1 FACT: a planted token is listed (rule + file), the value is never printed, exit 0",
rc == 0 and warn and "pkg/config.go:3" in warn[0] and tok not in out and "1 new hit(s)" in out, out)
key = warn[0].split()[1] if warn else "github-pat@none"
open(os.path.join(repo, ".security-accepted"), "w").write(
"gitleaks %s pkg/config.go # a decoy constant\n" % key)
rc, out = gate(repo)
check("3 ACCEPTED: the same line accepted by rule@hash + file", rc == 0 and "ACCEPTED " + key in out
and "0 new hit(s), 1 accepted" in out, out)
commit(repo, "pkg/config.go", 'package pkg\n\nconst Token = "%s"\nconst Other = "%s"\n'
% (tok, fake_token(1167)), "second")
rc, out = gate(repo)
check("4 FACT: a second secret on another line of an accepted file is still WARN",
rc == 0 and "pkg/config.go:4" in out and "WARN github-pat@" in out and tok not in out, out)
open(os.path.join(repo, ".security-accepted"), "w").write("gitleaks %s pkg/config.go\n" % key)
rc, out = gate(repo)
check("5 FACT: an accepted line with no reason is a WARN", rc == 0 and "has no reason" in out, out)
else:
print(" NOT CHECKED HERE cases 1, 3, 4, 5 — no gitleaks on this machine (the workspace and CI run them)")
# 6 — govulncheck through the JSON seam (no network, no Go needed).
mod = os.path.join(repo, "svc")
os.makedirs(mod, exist_ok=True)
open(os.path.join(mod, "go.mod"), "w").write("module example.invalid/svc\n\ngo 1.22\n")
run(repo, ["git", "add", "svc/go.mod"])
run(repo, ["git", "commit", "-q", "-m", "module"])
stream = [
{"config": {"scanner_name": "govulncheck", "scanner_version": "v1.8.0"}},
{"osv": {"id": "GO-2099-0001", "summary": "a decoy called vulnerability"}},
{"osv": {"id": "GO-2099-0002", "summary": "a decoy imported-only vulnerability"}},
{"finding": {"osv": "GO-2099-0001", "trace": [{"module": "example.invalid/dep", "function": "Parse"}]}},
{"finding": {"osv": "GO-2099-0002", "trace": [{"module": "example.invalid/dep2"}]}},
]
jf = os.path.join(base, "gv.json")
open(jf, "w").write("\n".join(json.dumps(o, indent=2) for o in stream))
open(os.path.join(repo, ".security-accepted"), "w").write("")
rc, out = gate(repo, {"FELHOM_SCAN_GOVULNCHECK_JSON": jf})
check("6a FACT: a CALLED vulnerability is listed by id; an imported-only one is counted, not listed",
rc == 0 and "WARN GO-2099-0001 called" in out and "GO-2099-0002" not in out
and "1 called vulnerability (1 imported" in out, out)
open(os.path.join(repo, ".security-accepted"), "w").write("govulncheck GO-2099-0001 svc # a decoy, accepted\n")
rc, out = gate(repo, {"FELHOM_SCAN_GOVULNCHECK_JSON": jf})
check("6b ACCEPTED: the same id accepted for that module", rc == 0 and "ACCEPTED GO-2099-0001" in out
and "WARN GO-2099" not in out, out)
# 7 — no gitleaks anywhere: NOT CHECKED out loud, exit 0.
empty = os.path.join(base, "empty")
os.makedirs(empty)
git_dir = os.path.dirname(shutil.which("git") or "/usr/bin/git")
only_git = os.path.join(base, "onlygit")
os.makedirs(only_git)
os.symlink(os.path.join(git_dir, "git"), os.path.join(only_git, "git"))
rc, out = gate(repo, {"FELHOM_SECURITY_TOOLS_DIR": empty, "FELHOM_SECURITY_BIN": "", "PATH": only_git,
"FELHOM_SCAN_FETCH": "0"})
check("7 FACT: no gitleaks and no govulncheck anywhere — NOT CHECKED, out loud, exit 0",
rc == 0 and "gitleaks: NOT CHECKED" in out and "govulncheck: NOT CHECKED" in out, out)
finally:
shutil.rmtree(base, ignore_errors=True)
if fails:
for f in fails:
print("FAIL: " + f)
return 1
print("test_security_scan_gate: all %d cases passed" % ran)
return 0
if __name__ == "__main__":
sys.exit(main())