diff --git a/.gitea/workflows/gates.yml b/.gitea/workflows/gates.yml index 4a6f4349..dce9d4a1 100644 --- a/.gitea/workflows/gates.yml +++ b/.gitea/workflows/gates.yml @@ -184,6 +184,10 @@ jobs: # which prints at the moment a release is committed, where someone can actually act on it. # Those are the compensating controls that make this green honest. Every other gate still # fails this job on any push, and golden-currency still fails it on a push touching code. + env: + # R-986: the security-scan gate fetches the PINNED gitleaks here (sha256-checked); no Go, so govulncheck says + # NOT CHECKED in CI, out loud. Warn-first either way: the scan lists, it never fails this job. + FELHOM_SCAN_FETCH: "1" run: python3 scripts/repo_gates.py --fast --scope="${PUSH_SCOPE:-code}" - name: Manual proof - alarm key present, optional forced failure (dispatch only, R-1129) diff --git a/.security-accepted b/.security-accepted new file mode 100644 index 00000000..87959d8b --- /dev/null +++ b/.security-accepted @@ -0,0 +1,37 @@ +# .security-accepted — hits security_scan_gate.py (felhom.eu/scripts, R-986) lists as ACCEPTED, not WARN. +# One per line: # reason. govulncheck: OSV id + module dir; gitleaks: rule@ + file. +# The first scan's verdicts (2026-10-19): felhom.eu documentation/audits/night-2026-10-19/P7/triage.md +gitleaks generic-api-key@0275c9c51957 documentation/audits/usenet-2026-10-12/sabnzbd-bench/13-compose-resolved.txt # accepted: a bench app's generated admin password and API key (LXC 9401, stopped; app removed from 9202) +gitleaks generic-api-key@0a189ae60ea4 documentation/tests/campaign2-evidence-2026-07-07/state/runner.log # accepted: the same dry-run deploy values in the campaign runner's log +gitleaks generic-api-key@2a27df76f661 documentation/audits/usenet-2026-10-12/sabnzbd-bench/02-telemetry-and-net.txt # accepted: a bench app's generated API key (LXC 9401, stopped; app removed from 9202 at teardown) +gitleaks generic-api-key@3a4c7b17e349 documentation/audits/usenet-2026-10-12/nzbget-bench/03-process-and-entrypoint.txt # accepted: a bench app's generated control password (LXC 9401, stopped; app removed from 9202 at teardown) +gitleaks generic-api-key@3c180a9f957c documentation/tests/campaign2-evidence-2026-07-07/state/runner.log # accepted: the same dry-run deploy values in the campaign runner's log +gitleaks generic-api-key@486819f82f58 documentation/audits/update-night-2026-09-21/apps/actualbudget/log.txt # accepted: a demo app's session token, demo-hp 2026-09-21 (Tier 0); no running demo app has it (2026-10-12 review) +gitleaks generic-api-key@486819f82f58 documentation/audits/update-night-2026-09-21/batchA.log # accepted: the same demo app session token as actualbudget/log.txt +gitleaks generic-api-key@5a64c6ffd6df documentation/audits/signup-lock-2026-09-29/B/wd.sh # accepted: a stranger-probe test password in an audit script (signup lock, Tier 0) +gitleaks generic-api-key@6e2e899b7d6e documentation/audits/offsite-lock-build-2026-10-03/partD/demo-hp-migration.txt # false: an SSH key FINGERPRINT in a log line, not a key +gitleaks generic-api-key@70380348263d hub/internal/web/bind_language_test.go # false: a test constant or a decoy value, no live secret +gitleaks generic-api-key@7134caf03160 documentation/audits/evidence-rehearsal-2026-08-09/GATE0-demo-hp-before.txt # false: a WireGuard PUBLIC key +gitleaks generic-api-key@7134caf03160 documentation/audits/evidence-rehearsal-2026-08-09/P1-demo-hp-after.txt # false: a WireGuard PUBLIC key +gitleaks generic-api-key@71ba98258279 hub/internal/api/r1140_accesslog_test.go # false: a test constant or a decoy value, no live secret +gitleaks generic-api-key@7aa746ddb210 documentation/audits/offsite-lock-build-2026-10-03/partD/demo-hp-migration.txt # false: an SSH key FINGERPRINT in a log line, not a key +gitleaks generic-api-key@7ce547845b1e documentation/audits/night-rulings-2026-09-30/D/D3-wanderer-routes-probe.txt # accepted: a throwaway app's session cookie (wanderer probe, 2026-09-30, Tier 0) +gitleaks generic-api-key@8044eef94360 documentation/tests/campaign2-evidence-2026-07-07/state/runner.log # accepted: the same dry-run deploy values in the campaign runner's log +gitleaks generic-api-key@85b662612937 hub/internal/store/r879_box_seal_test.go # false: a test constant or a decoy value, no live secret +gitleaks generic-api-key@9530a022fde8 documentation/audits/usenet-2026-10-12/sabnzbd-bench/13-compose-resolved.txt # accepted: a bench app's generated admin password and API key (LXC 9401, stopped; app removed from 9202) +gitleaks generic-api-key@9b3a9c2be48a documentation/tests/campaign2-evidence-2026-07-07/evidence/T-DEPLOY-SET/values_bookstack.json # accepted: dry-run deploy values of a 2026-07-07 campaign (generated, demo boxes since rebuilt) +gitleaks generic-api-key@a9538cfd7063 documentation/tests/campaign2-evidence-2026-07-07/evidence/T-DEPLOY-SET/values_paperless-ngx.json # accepted: dry-run deploy values of a 2026-07-07 campaign (generated, demo boxes since rebuilt) +gitleaks generic-api-key@b0414c6f59b9 documentation/audits/offsite-lock-build-2026-10-03/partD/demo-hp-migration.txt # false: an SSH key FINGERPRINT in a log line, not a key +gitleaks generic-api-key@b1db45e463b6 documentation/audits/family-gate-2026-10-02/tools/fg_items.py # false: test strings in an audit tool (websocket probe labels) +gitleaks generic-api-key@b1db45e463b6 documentation/audits/permanent-gate-2026-10-01/tools/items12.py # false: test strings in an audit tool (websocket probe labels) +gitleaks generic-api-key@bd228c851720 documentation/tests/walk5-r201-2026-08-07/journal.md # false: sha256 digests compared in a walk journal, not secrets +gitleaks generic-api-key@c1be98b7b703 documentation/tests/walk5-r201-2026-08-07/journal.md # false: sha256 digests compared in a walk journal, not secrets +gitleaks generic-api-key@c3b2b5665c9c documentation/audits/evidence-fixes-first-tester-2026-09-30/part0/cloudflare-dns.txt # false: a tunnel ID (a UUID), not the tunnel token +gitleaks generic-api-key@cf453385b707 documentation/tests/campaign2-evidence-2026-07-07/state/runner.log # accepted: the same dry-run deploy values in the campaign runner's log +gitleaks generic-api-key@e59eaeb4aee7 documentation/audits/new-apps-2026-10-01/tools/newapp_box.py # accepted: a throwaway test user's password in an audit tool for a fresh-app walk on a Tier-0 box +gitleaks generic-api-key@eada8358f62a hub/internal/web/render_test.go # false: a test constant or a decoy value, no live secret +gitleaks generic-api-key@efd4cc86d8d9 documentation/audits/power-cut-fixes-2026-10-14/E/demo-hp-journal-boot.txt # false: the id of HP's public UEFI certificate in a boot journal +gitleaks generic-api-key@f28c207293c1 documentation/tests/campaign2-evidence-2026-07-07/evidence/T-DEPLOY-SET/values_vaultwarden.json # accepted: dry-run deploy values of a 2026-07-07 campaign (generated, demo boxes since rebuilt) +gitleaks generic-api-key@f399eb9e3059 documentation/audits/night-2026-09-23/chaos/round-01-run.txt # accepted: the same demo app session token as round-01.log +gitleaks generic-api-key@f399eb9e3059 documentation/audits/night-2026-09-23/chaos/round-01.log # accepted: a demo app's session token, demo-hp 2026-09-23 (Tier 0); the 2026-10-12 review found no running demo app with it +gitleaks generic-api-key@fdabf6a76b48 documentation/audits/r890-instructions-2026-10-06/tools/wgerwalk.py # accepted: a throwaway wger walk's API token in an audit tool (Tier 0) diff --git a/documentation/audits/night-2026-10-19/P4/r953-readback.txt b/documentation/audits/night-2026-10-19/P4/r953-readback.txt new file mode 100644 index 00000000..8f4ced24 --- /dev/null +++ b/documentation/audits/night-2026-10-19/P4/r953-readback.txt @@ -0,0 +1,46 @@ +# R-953 read-back, 2026-10-19 18:21:10 UTC (read only; no sync, no kubectl change) + +## ArgoCD app felhom +sync: Synced rev: a28f162c health: Healthy +tracked resources (25): + ConfigMap/filebrowser-config Synced + ConfigMap/git-sync-sparse-checkout Synced + ConfigMap/hub-config Synced + ConfigMap/nginx-config Synced + PersistentVolumeClaim/filebrowser-db Synced + PersistentVolumeClaim/filebrowser-files Synced + PersistentVolumeClaim/hub-data Synced + PersistentVolumeClaim/umami-db-data Synced + Service/contact-mailer Synced + Service/felhom-webpage Synced + Service/filebrowser Synced + Service/hub Synced + Service/umami Synced + Service/umami-db Synced + Deployment/contact-mailer Synced + Deployment/felhom-webpage Synced + Deployment/filebrowser Synced + Deployment/hub Synced + Deployment/umami Synced + Deployment/umami-db Synced + Ingress/contact-mailer Synced + Ingress/felhom-webpage Synced + Ingress/filebrowser Synced + Ingress/hub Synced + Ingress/umami Synced + +## the three Secrets (describe: names, labels, annotations, key sizes — no values) +Name: gitea-creds +Labels: +Annotations: +Name: umami-config +Labels: +Annotations: +Name: healthchecks-config +Labels: +Annotations: + +NAME TYPE DATA AGE +gitea-creds Opaque 2 241d +umami-config Opaque 2 255d +healthchecks-config Opaque 10 257d diff --git a/documentation/audits/night-2026-10-19/P7/triage.md b/documentation/audits/night-2026-10-19/P7/triage.md new file mode 100644 index 00000000..78bfc573 --- /dev/null +++ b/documentation/audits/night-2026-10-19/P7/triage.md @@ -0,0 +1,70 @@ +# R-986 — first scan of today's main, every hit with a verdict (2026-10-19) + +Tools: gitleaks 8.24.3 (default rules pinned, no repo ignore file), govulncheck v1.8.0 built with go1.27.2 +(v1.1.4, the 2026-10-12 pin, panics on Go 1.27 source — `unexpected expr: *ast.KeyValueExpr` — so the +newest release older than two weeks was taken). Values were never printed: each line was read with every +value-like token replaced by its length and a hash prefix. + +## govulncheck (called vulnerabilities only) + +| module | called | imported/required only | +|---|---|---| +| felhom-controller `controller` | 0 | 1 | +| felhom.eu `hub` | 0 | 1 | +| felhom-agent `.` | 0 | 1 | +| app-catalog | no Go module | — | + +## gitleaks — full history (`--all`) + +| repo | commits | hits | 2026-10-12 review | new since | +|---|---|---|---|---| +| felhom-controller | 1370 | 12 | 12 | 0 | +| felhom.eu | 2102 | 50 | 43 | 7 (below) | +| felhom-agent | 539 | 15 | 15 | 0 | +| app-catalog-felhom.eu | 519 | 1 | 1 | 0 | + +The 7 new history hits are files still in the tree (the usenet bench 5, the boot journal 1, r1140_accesslog_test.go 1) — their verdicts are in the tree table. + +## gitleaks — today's tree (`git archive HEAD`) + +Verdicts: **real** = a live secret (none); **accepted** = a value that opens nothing that matters now, listed in the repo's `.security-accepted`; **false** = not a secret. + +| repo | n | rule | file | verdict | why | +|---|---|---|---|---|---| +| felhom-controller | 1 | generic-api-key | `controller/internal/backup/offbox_offer_shapec_r241_test.go` | false | a test constant or a decoy value, no live secret | +| felhom-controller | 1 | generic-api-key | `controller/internal/stacks/samba_test.go` | false | a test constant or a decoy value, no live secret | +| felhom-controller | 2 | generic-api-key | `controller/scripts/test_gate_decoys.py` | false | a test constant or a decoy value, no live secret | +| felhom.eu | 1 | generic-api-key | `documentation/audits/evidence-fixes-first-tester-2026-09-30/part0/cloudflare-dns.txt` | false | a tunnel ID (a UUID), not the tunnel token | +| felhom.eu | 1 | generic-api-key | `documentation/audits/evidence-rehearsal-2026-08-09/GATE0-demo-hp-before.txt` | false | a WireGuard PUBLIC key | +| felhom.eu | 1 | generic-api-key | `documentation/audits/evidence-rehearsal-2026-08-09/P1-demo-hp-after.txt` | false | a WireGuard PUBLIC key | +| felhom.eu | 1 | generic-api-key | `documentation/audits/family-gate-2026-10-02/tools/fg_items.py` | false | test strings in an audit tool (websocket probe labels) | +| felhom.eu | 1 | generic-api-key | `documentation/audits/new-apps-2026-10-01/tools/newapp_box.py` | accepted | a throwaway test user's password in an audit tool for a fresh-app walk on a Tier-0 box | +| felhom.eu | 1 | generic-api-key | `documentation/audits/night-2026-09-23/chaos/round-01-run.txt` | accepted | the same demo app session token as round-01.log | +| felhom.eu | 1 | generic-api-key | `documentation/audits/night-2026-09-23/chaos/round-01.log` | accepted | a demo app's session token, demo-hp 2026-09-23 (Tier 0); the 2026-10-12 review found no running demo app with it | +| felhom.eu | 1 | generic-api-key | `documentation/audits/night-rulings-2026-09-30/D/D3-wanderer-routes-probe.txt` | accepted | a throwaway app's session cookie (wanderer probe, 2026-09-30, Tier 0) | +| felhom.eu | 3 | generic-api-key | `documentation/audits/offsite-lock-build-2026-10-03/partD/demo-hp-migration.txt` | false | an SSH key FINGERPRINT in a log line, not a key | +| felhom.eu | 1 | generic-api-key | `documentation/audits/permanent-gate-2026-10-01/tools/items12.py` | false | test strings in an audit tool (websocket probe labels) | +| felhom.eu | 1 | generic-api-key | `documentation/audits/power-cut-fixes-2026-10-14/E/demo-hp-journal-boot.txt` | false | the id of HP's public UEFI certificate in a boot journal | +| felhom.eu | 1 | generic-api-key | `documentation/audits/r890-instructions-2026-10-06/tools/wgerwalk.py` | accepted | a throwaway wger walk's API token in an audit tool (Tier 0) | +| felhom.eu | 2 | generic-api-key | `documentation/audits/signup-lock-2026-09-29/B/wd.sh` | accepted | a stranger-probe test password in an audit script (signup lock, Tier 0) | +| felhom.eu | 1 | generic-api-key | `documentation/audits/update-night-2026-09-21/apps/actualbudget/log.txt` | accepted | a demo app's session token, demo-hp 2026-09-21 (Tier 0); no running demo app has it (2026-10-12 review) | +| felhom.eu | 1 | generic-api-key | `documentation/audits/update-night-2026-09-21/batchA.log` | accepted | the same demo app session token as actualbudget/log.txt | +| felhom.eu | 1 | generic-api-key | `documentation/audits/usenet-2026-10-12/nzbget-bench/03-process-and-entrypoint.txt` | accepted | a bench app's generated control password (LXC 9401, stopped; app removed from 9202 at teardown) | +| felhom.eu | 2 | generic-api-key | `documentation/audits/usenet-2026-10-12/sabnzbd-bench/02-telemetry-and-net.txt` | accepted | a bench app's generated API key (LXC 9401, stopped; app removed from 9202 at teardown) | +| felhom.eu | 2 | generic-api-key | `documentation/audits/usenet-2026-10-12/sabnzbd-bench/13-compose-resolved.txt` | accepted | a bench app's generated admin password and API key (LXC 9401, stopped; app removed from 9202) | +| felhom.eu | 2 | generic-api-key | `documentation/tests/campaign2-evidence-2026-07-07/evidence/T-DEPLOY-SET/values_bookstack.json` | accepted | dry-run deploy values of a 2026-07-07 campaign (generated, demo boxes since rebuilt) | +| felhom.eu | 3 | generic-api-key | `documentation/tests/campaign2-evidence-2026-07-07/evidence/T-DEPLOY-SET/values_paperless-ngx.json` | accepted | dry-run deploy values of a 2026-07-07 campaign (generated, demo boxes since rebuilt) | +| felhom.eu | 1 | generic-api-key | `documentation/tests/campaign2-evidence-2026-07-07/evidence/T-DEPLOY-SET/values_vaultwarden.json` | accepted | dry-run deploy values of a 2026-07-07 campaign (generated, demo boxes since rebuilt) | +| felhom.eu | 7 | generic-api-key | `documentation/tests/campaign2-evidence-2026-07-07/state/runner.log` | accepted | the same dry-run deploy values in the campaign runner's log | +| felhom.eu | 2 | generic-api-key | `documentation/tests/walk5-r201-2026-08-07/journal.md` | false | sha256 digests compared in a walk journal, not secrets | +| felhom.eu | 1 | generic-api-key | `hub/internal/api/r1140_accesslog_test.go` | false | a test constant or a decoy value, no live secret | +| felhom.eu | 1 | generic-api-key | `hub/internal/store/r879_box_seal_test.go` | false | a test constant or a decoy value, no live secret | +| felhom.eu | 1 | generic-api-key | `hub/internal/web/bind_language_test.go` | false | a test constant or a decoy value, no live secret | +| felhom.eu | 1 | generic-api-key | `hub/internal/web/render_test.go` | false | a test constant or a decoy value, no live secret | +| felhom-agent | 1 | generic-api-key | `internal/escrow/identity_test.go` | false | a test constant or a decoy value, no live secret | +| felhom-agent | 11 | generic-api-key | `internal/escrow/recover_retained_test.go` | false | a test constant or a decoy value, no live secret | +| felhom-agent | 1 | generic-api-key | `internal/escrow/recover_test.go` | false | a test constant or a decoy value, no live secret | +| felhom-agent | 1 | generic-api-key | `internal/localapi/escrow_stage_test.go` | false | a test constant or a decoy value, no live secret | +| felhom-agent | 1 | generic-api-key | `internal/pbsdr/wrapper_read_test.go` | false | a test constant or a decoy value, no live secret | + +**Real secrets: 0.** app-catalog-felhom.eu: 0 tree hits (its one history hit is a health-probe URL, the 2026-10-12 verdict). diff --git a/documentation/audits/night-2026-10-19/P9/01-floors-and-move.txt b/documentation/audits/night-2026-10-19/P9/01-floors-and-move.txt new file mode 100644 index 00000000..691820ad --- /dev/null +++ b/documentation/audits/night-2026-10-19/P9/01-floors-and-move.txt @@ -0,0 +1,2 @@ +## per-customer floors 0.335.0 / MinAgent 0.158.1 — 18:22:21 UTC: tester-1, demo-hp, demo-felhom each HTTP 303 Location /customers/?flash=floor_set +## boxes moved (hub reports, 18:38:57 UTC read): demo-felhom 18:37:34 0.335.0; tester-1 18:37:34 0.335.0; demo-hp 18:37:36 0.335.0 (from 0.335.0-rc.1) diff --git a/documentation/audits/night-2026-10-19/P9/03-box-walk.txt b/documentation/audits/night-2026-10-19/P9/03-box-walk.txt new file mode 100644 index 00000000..9fb1a218 --- /dev/null +++ b/documentation/audits/night-2026-10-19/P9/03-box-walk.txt @@ -0,0 +1,27 @@ +=== demo-hp (enkisfelhom.hu) 18:38:57 +containers: 24 running; not healthy/running: traefik Up 3 days; +unhealthy: 0 +2026/10/19 18:22:34 updater.go:855: [INFO] [selfupdate] Post-update startup: update successful (0.335.0-rc.1 → 0.335.0) +allow-list refusals since start: 0 +2026/10/19 18:22:34 sync.go:142: [INFO] [sync] Initial sync: Sablonok naprakészek — nincs változás +installed apps (stacks with app.yaml): 12 +public https://felhom.enkisfelhom.hu/: 302 +public https://files.enkisfelhom.hu/: 200 +=== demo-felhom (demo-felhom.eu) 18:38:59 +containers: 9 running; not healthy/running: traefik Up 3 days; +unhealthy: 0 +2026/10/19 18:22:23 [INFO] [selfupdate] Post-update startup: update successful (0.334.0 → 0.335.0) +allow-list refusals since start: 0 +2026/10/19 18:22:24 [INFO] [sync] Initial sync: Sablonok naprakészek — nincs változás +installed apps (stacks with app.yaml): 5 +public https://felhom.demo-felhom.eu/: 302 +public https://files.demo-felhom.eu/: 200 +=== tester-1 (enkicsifelhom.hu) 18:39:01 +containers: 10 running; not healthy/running: traefik Up 3 days; +unhealthy: 0 +2026/10/19 18:22:33 [INFO] [selfupdate] Post-update startup: update successful (0.334.0 → 0.335.0) +allow-list refusals since start: 0 +2026/10/19 18:22:33 [INFO] [sync] Initial sync: Sablonok naprakészek — nincs változás +installed apps (stacks with app.yaml): 3 +public https://felhom.enkicsifelhom.hu/: 302 +public https://files.enkicsifelhom.hu/: 200 diff --git a/documentation/audits/night-2026-10-19/P9/04-https-only.txt b/documentation/audits/night-2026-10-19/P9/04-https-only.txt new file mode 100644 index 00000000..8682d689 --- /dev/null +++ b/documentation/audits/night-2026-10-19/P9/04-https-only.txt @@ -0,0 +1,33 @@ +control NEGATIVE (a stub answering 200 over plain http): convicted, as it must (200 -) +== tester-1 (enkicsifelhom.hu): 6 public name(s) + OK control POSITIVE http://felhom.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://felhom.enkicsifelhom.hu/r1033-check?q=1 + OK http://files.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://files.enkicsifelhom.hu/r1033-check?q=1 + OK http://no-such-name-r1033.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://no-such-name-r1033.enkicsifelhom.hu/r1033-check?q=1 + OK http://paperless.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://paperless.enkicsifelhom.hu/r1033-check?q=1 + OK http://paste.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://paste.enkicsifelhom.hu/r1033-check?q=1 + OK http://wiki.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://wiki.enkicsifelhom.hu/r1033-check?q=1 +== demo-hp (enkisfelhom.hu): 14 public name(s) + OK http://arcade.enkisfelhom.hu/r1033-check?q=1 -> 301 https://arcade.enkisfelhom.hu/r1033-check?q=1 + OK http://books.enkisfelhom.hu/r1033-check?q=1 -> 301 https://books.enkisfelhom.hu/r1033-check?q=1 + OK http://docs.enkisfelhom.hu/r1033-check?q=1 -> 301 https://docs.enkisfelhom.hu/r1033-check?q=1 + OK http://felhom.enkisfelhom.hu/r1033-check?q=1 -> 301 https://felhom.enkisfelhom.hu/r1033-check?q=1 + OK http://files.enkisfelhom.hu/r1033-check?q=1 -> 301 https://files.enkisfelhom.hu/r1033-check?q=1 + OK http://gist.enkisfelhom.hu/r1033-check?q=1 -> 301 https://gist.enkisfelhom.hu/r1033-check?q=1 + OK http://ha.enkisfelhom.hu/r1033-check?q=1 -> 301 https://ha.enkisfelhom.hu/r1033-check?q=1 + OK http://no-such-name-r1033.enkisfelhom.hu/r1033-check?q=1 -> 301 https://no-such-name-r1033.enkisfelhom.hu/r1033-check?q=1 + OK http://papir.enkisfelhom.hu/r1033-check?q=1 -> 301 https://papir.enkisfelhom.hu/r1033-check?q=1 + OK http://paste.enkisfelhom.hu/r1033-check?q=1 -> 301 https://paste.enkisfelhom.hu/r1033-check?q=1 + OK http://pdf.enkisfelhom.hu/r1033-check?q=1 -> 301 https://pdf.enkisfelhom.hu/r1033-check?q=1 + OK http://plex.enkisfelhom.hu/r1033-check?q=1 -> 301 https://plex.enkisfelhom.hu/r1033-check?q=1 + OK http://travel.enkisfelhom.hu/r1033-check?q=1 -> 301 https://travel.enkisfelhom.hu/r1033-check?q=1 + OK http://wiki.enkisfelhom.hu/r1033-check?q=1 -> 301 https://wiki.enkisfelhom.hu/r1033-check?q=1 +== demo-felhom (demo-felhom.eu): 8 public name(s) + OK http://felhom.demo-felhom.eu/r1033-check?q=1 -> 301 https://felhom.demo-felhom.eu/r1033-check?q=1 + OK http://files.demo-felhom.eu/r1033-check?q=1 -> 301 https://files.demo-felhom.eu/r1033-check?q=1 + OK http://gist.demo-felhom.eu/r1033-check?q=1 -> 301 https://gist.demo-felhom.eu/r1033-check?q=1 + OK http://no-such-name-r1033.demo-felhom.eu/r1033-check?q=1 -> 301 https://no-such-name-r1033.demo-felhom.eu/r1033-check?q=1 + OK http://prowlarr.demo-felhom.eu/r1033-check?q=1 -> 301 https://prowlarr.demo-felhom.eu/r1033-check?q=1 + OK http://radarr.demo-felhom.eu/r1033-check?q=1 -> 301 https://radarr.demo-felhom.eu/r1033-check?q=1 + OK http://sonarr.demo-felhom.eu/r1033-check?q=1 -> 301 https://sonarr.demo-felhom.eu/r1033-check?q=1 + OK http://torrent.demo-felhom.eu/r1033-check?q=1 -> 301 https://torrent.demo-felhom.eu/r1033-check?q=1 +== every public name redirects http -> https diff --git a/documentation/audits/night-2026-10-19/P9/05-files-one-sign-in.txt b/documentation/audits/night-2026-10-19/P9/05-files-one-sign-in.txt new file mode 100644 index 00000000..6c97134d --- /dev/null +++ b/documentation/audits/night-2026-10-19/P9/05-files-one-sign-in.txt @@ -0,0 +1,16 @@ +== demo-hp +dashboard sign-in: 302 +the Fajlok button goes to: https://files.enkisfelhom.hu/_felhom/sso +the pass answer goes to: https://files.enkisfelhom.hu/files/; files cookie set: 1 +FileBrowser with the files cookie: felhom-household admin: False modify: True +FileBrowser without it, a fresh browser: HTTP 401 +dashboard sign-out: 302 +after the sign-out, the files cookie sends the browser to: https://felhom.enkisfelhom.hu/__files/open?to=%2Ffiles%2F +== tester-1 +dashboard sign-in: 302 +the Fajlok button goes to: https://files.enkicsifelhom.hu/_felhom/sso +the pass answer goes to: https://files.enkicsifelhom.hu/files/; files cookie set: 1 +FileBrowser with the files cookie: felhom-household admin: False modify: True +FileBrowser without it, a fresh browser: HTTP 401 +dashboard sign-out: 302 +after the sign-out, the files cookie sends the browser to: https://felhom.enkicsifelhom.hu/__files/open?to=%2Ffiles%2F diff --git a/documentation/audits/night-2026-10-19/P9/06-global-floor.txt b/documentation/audits/night-2026-10-19/P9/06-global-floor.txt new file mode 100644 index 00000000..ec9c9fb8 --- /dev/null +++ b/documentation/audits/night-2026-10-19/P9/06-global-floor.txt @@ -0,0 +1,13 @@ +## global floor 0.335.0 / MinAgent 0.158.1 — 18:39:26 UTC +HTTP/1.1 303 See Other +Location: /configuration?flash=floor_set +## per-box floors cleared — 18:39:26 UTC +tester-1: HTTP/1.1 303 See Other Location: /customers/tester-1?flash=floor_set +demo-hp: HTTP/1.1 303 See Other Location: /customers/demo-hp?flash=floor_set +demo-felhom: HTTP/1.1 303 See Other Location: /customers/demo-felhom?flash=floor_set +## read back (hub.db + WAL, one tar) +min_controller_version|0.335.0 +min_controller_version_declared_min_agent|0.335.0=0.158.1 +demo-felhom| +demo-hp| +tester-1| diff --git a/documentation/audits/security-review-2026-10-12/SCANNERS.md b/documentation/audits/security-review-2026-10-12/SCANNERS.md index a06baa30..863cccab 100644 --- a/documentation/audits/security-review-2026-10-12/SCANNERS.md +++ b/documentation/audits/security-review-2026-10-12/SCANNERS.md @@ -76,6 +76,23 @@ verify off) — each sits beside a certificate pin, noise. G407/G404 — the exp backoff jitter uses math/rand, noise. G101 — comments and constant names, noise. G304/G301/G306/G104 — bulk noise, not filed. -## 5. Proposed push gate +## 5. The push gate (R-986, wired 2026-10-19) -Not wired in this session. Shape: R-986. +`scripts/security_scan_gate.py` (shared by the four repos, in each runner's FAST set and in CI), **warn-first**: it lists +hits and exits 0. govulncheck (called vulnerabilities only) on each Go module; gitleaks on the commits being pushed; +accepted hits per repo in `.security-accepted` (gitleaks by rule + a hash of the hit's line + file, so one accepted +test constant does not accept the next secret in the same file). First scan of every repo's `main` with verdicts: +`documentation/audits/night-2026-10-19/P7/triage.md` (0 real secrets, 0 called vulnerabilities). + +| tool | version | how installed | sha256 | +|---|---|---|---| +| govulncheck | **v1.8.0** (2026-09-08), built with go1.27.2 | `GOTOOLCHAIN=go1.27.2 go install golang.org/x/vuln/cmd/govulncheck@v1.8.0` → `tools/security/bin/govulncheck-v1.8.0` (`govulncheck` links to it) | `aec435d4c0b29cb8987d466ae61ed4c334eab7d4b58073aaa873702c50a0da02` | +| gitleaks | 8.24.3 (unchanged) | the same binary; CI fetches the release tarball | tarball `9991e0b2903da4c8f6122b5c3186448b927a5da4deef1fe45271c3793f4ee29c` (the release's own checksums file), binary `e18325d5…69ad` | + +**Why the govulncheck pin moved:** v1.1.4 (the 2026-10-12 pin) cannot read the repos since they moved to Go 1.27.2: +built with go1.26 it fails to load the standard library (`unknown field rfd in struct literal of type splicePipe`), and +rebuilt with go1.27.2 it panics in its SSA builder (`unexpected expr: *ast.KeyValueExpr`). v1.8.0 is the newest release +older than two weeks (v1.9.0 is from 2026-10-11). The old binary is kept as `govulncheck-v1.1.4-go1.26.0`. + +**CI:** the runner has python3 and git and no Go, so govulncheck says NOT CHECKED there, out loud; the pre-push hook +(with Go) runs it. gitleaks is fetched pinned (`FELHOM_SCAN_FETCH=1` in each `gates.yml`), both checksums checked. diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index de1f1110..3efb87fb 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -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 diff --git a/scripts/repo_gates.py b/scripts/repo_gates.py index 00c00a7b..50dfdc13 100644 --- a/scripts/repo_gates.py +++ b/scripts/repo_gates.py @@ -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"} diff --git a/scripts/security_scan_gate.py b/scripts/security_scan_gate.py new file mode 100644 index 00000000..86f99394 --- /dev/null +++ b/scripts/security_scan_gate.py @@ -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 + +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)) diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index a3919f0b..8fc408f7 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -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). diff --git a/scripts/test_security_scan_gate.py b/scripts/test_security_scan_gate.py new file mode 100644 index 00000000..67c0a1b9 --- /dev/null +++ b/scripts/test_security_scan_gate.py @@ -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())