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
gates / gates (push) Failing after 12m59s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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))
|
||||
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user