Prepare exact guarded JSON-digest scanner rollout from founder preflight
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Reject generated-only commits / generated-commit-guard (push) Successful in 14s

This commit is contained in:
codex 2026-11-02 01:25:37 +01:00
parent ecb3044259
commit 26d4efd280
32 changed files with 1727 additions and 4 deletions

View file

@ -8,6 +8,10 @@ The existing reviewed YAML filename/digest exception rejects the equivalent JSON
Validation: 12 local real-Gitleaks tests passed (37 unrelated tests deselected); full receiver `FORGE_TEST_GITLEAKS=... go test -count=1 ./...` passed in 150.087s. A prior Go invocation without that environment passed only scanner-independent tests and was superseded by this real-scanner run.
Native preparation is pending read-only inventory. Current Kubernetes identity `ops-mason-builder` cannot list nodes, deployments or pods in `forgejo`. Do not borrow the founder's admin kubeconfig. The prepared `forge-metadata-proposal.json` uses gated-executor's exact-action format for only names, UIDs and images. Its digest is `sha256:0fceb2dc3c1963a16f40a0221b2eb6cb5384b91e2fe8607bb0121afe47a5d2e1`. The founder was asked to return this metadata or provide the supervised execution result. A native update needs its own exact inspected rollout proposal and supervisor approval under `docs/agent-autonomy-decision.md` and environment-orientation §4.
The founder returned the read-only artifact/config/guard/headroom receipt on 2026-11-02. It verifies the expected deployment, pod, node, old adapter, hook and pinned Gitleaks. CPU requests are 68%, memory requests 76%, and Forgejo uses Recreate. The original failed/refined metadata attempts remain recorded; the successful read-only execution grants no mutation authority.
The exact native preparation is now [revision 2](native-r2/PLAN.md), under the same proposal ID; revision 1 remains unchanged. Approval packet SHA256: `d22b1f555af96c203b7d68efd25e80815ce18ca4e3bf0dd3ab96b6f699d50237`. It includes guarded stage/rollout/rollback, both scanner source changes and Apps source followthrough. Thirteen isolated filesystem/real receive cases and five controller tests passed. The initial missing temporary dispatcher fixture was retained as an unverified preparation attempt and replaced with an explicit offline fixture; native probes separately copy the live dispatcher. Live server dry-run, diff, probes and activation remain mandatory execution checks and have not run yet.
Native activation awaits exact founder approval under `docs/agent-autonomy-decision.md` ("The supervisor approves that proposal or performs it through the privileged execution path."). The agent must not borrow the founder admin kubeconfig. The prepared founder launcher is `/tmp/forge-scanner-rollout.sh`. Its sealed runner refuses unknown initial identity/guard/hook/source state and any packet drift.
The staged scanner in repo-manager and live receiver have not been altered by this candidate. Independent clean-file commits continue under the original policy.

View file

@ -0,0 +1,8 @@
{
"decision_id": "5410707c-ce73-4f80-9c03-805102c31c58",
"status": "open",
"proposal_id": "SYNC-SCANNER-JSON-DIGEST-20261101",
"revision": 2,
"packet_sha256": "d22b1f555af96c203b7d68efd25e80815ce18ca4e3bf0dd3ab96b6f699d50237",
"approval": null
}

View file

@ -0,0 +1,37 @@
# Exact JSON filename/digest correction, revision 2
Proposal: `SYNC-SCANNER-JSON-DIGEST-20261101`, revision 2. Revision 1 remains preserved in the parent directory. This revision adds executable native preparation to the already-tested paired profile correction. No native mutation or source-policy activation has occurred.
The founder's read-only preflight verified deployment `forgejo/forge` UID `1faefb26-cccd-4d0e-b509-70a7eb0b4f4d`, pod UID `2c9f911a-403e-4183-a027-2b8d48742348`, node UID `0659e49e-6539-480a-8b0f-2969dd88cb05`, and the exact previous adapter, Gitleaks and hook hashes. CPU requests were 68% and memory requests 76%. Strategy is Recreate; main container requests remain 200m/1104Mi and memory limit 1536Mi.
## Action and scope
The existing YAML filename/exact lowercase 64-hex exception gains its quoted JSON equivalent in both local and receiver adapters. It still applies only to generic-api-key matches whose filename has one of the existing extensions. It does not adjudicate UUID resource identities, public fingerprints, natural-language findings, other digest formats, or every held Custodian artifact.
1. Recheck six owner-source preimages, deployment/node/pod identities, existing guard and selected ConfigMap, and every current repository's hook inventory. Refuse unknown hook bytes, symlinks, missing scanner fragments or more than 500 repositories. Snapshot protected hook hashes and permissions. The initial pod identity must still match; drift requires new preparation/review.
2. Server dry-run the new immutable ConfigMap and conditional deployment patch, and capture client ConfigMap diff without printing raw objects or errors. The receipt lists the exact two deployment fields being changed. These server checks are prepared; they have **not** been run against the live cluster yet.
3. Stage binary `d7dbd0ef57312ab9507874bb5fd67c49bef637b6d7dee121e526227307af4bf0` in `/data/security/receive-secret-scan/20261102-d7dbd0ef5731`; copy the unchanged pinned Gitleaks from the verified old directory. Check owner, hash, non-symlink and executable modes. Retain every previous directory.
4. Before activation, run a disposable tmpfs receive-pack probe with the staged binary, pinned real Gitleaks and a copy of an actual current dispatcher: accept the JSON digest; reject an intermediate synthetic private key even after removal from the tip; discard quarantine; reject unavailable scanner; accept clean recovery. No hosted repository is used for probe writes.
5. Create immutable `forgejo-secret-template-619d94208139`. Atomically replace only each repository's scanner fragment, keeping permissions/group and protected dispatcher/authorization hooks. Append the new artifact guard to all existing guards and select the new hook ConfigMap with UID/resourceVersion/before-value JSON-patch tests. No image, resource, strategy, credential or Git object changes.
6. Wait up to 180 seconds for the current deployment's Ready pod; reconcile known old hooks on any repositories created during the transition. Verify protected hooks, mounted template hash and full artifact guard. Repeat the isolated receive probe after activation. Retain a sanitized local receipt at `/tmp/forge-scanner-rollout-receipt.json`.
7. After native verification, `source_followthrough.py` applies the six exact reviewed source changes: two repo-manager files, two railiance-forge files, and Apps Helm values/immutable ConfigMap. It checks all preimages before writing and requires this packet's successful native receipt. The local pre-commit hook directly imports the repo-manager source, so this paired source update activates its identical local profile. Commit and publish these sources through normal hooks, then retry eligible held publication and audit remaining failures separately.
`packet.json` seals executable scripts, selected inputs, source diffs/candidates, tests and this plan. Its SHA256 is the approval digest; the runner rechecks every sealed file. `--approved-digest` is an acknowledgement for founder execution, not a credential boundary or agent-issued approval. The agent must not use the founder kubeconfig. Native execution is attended by the founder or an independently authorized exact privileged execution path. No owner-agent approval has been invented; the founder reviews the paired owner source in this packet.
## Verification and recovery
The original paired candidate passed 12 local real-Gitleaks tests and the full receiver suite. This native preparation passed 13 isolated filesystem/real receive cases and 5 controller tests. Tests cover staging/hash failure, symlinks, unchanged protected hooks, complete-scope refusal before writing, partial hook failure restoration, unknown-drift preservation, conditional patch roundtrip and recovery after a lost patch response. The offline dispatcher is an explicit scanner-only fixture; native execution separately copies and tests a real current dispatcher, excluding the authorization engine's native invocation as documented by the probe.
Any exception after staging starts is recorded as failure and attempts guarded rollback, including an uncertain patch response. Rollback accepts only the exact known old or proposed guard/template pair. It restores the old deployment reference/guard, waits up to 180 seconds, restores only known old/new scanner fragments, verifies all preserved hooks, rechecks the mounted old template and runs the old clean/private-key/quarantine/recovery probe. Unknown concurrent changes are retained and reported as unverified rather than overwritten. Old artifacts are never deleted. New immutable artifacts/ConfigMap are retained for evidence; a retry requires preserving/reviewing the prior receipt. Separate rollback mode is prepared in the same runner. A failed rollback requires founder recovery; automatic recovery is not guaranteed.
The controller does not coordinate unrelated native administrators or freeze repository creation. It catches known old hooks again after rollout and verifies template inheritance for future repositories. Concurrent destructive repository or protected-hook changes fail verification. All scans retain complete introduced-object/history scope and existing limits; no suppression switch or raw finding values are introduced.
## Cost, interruption and residual risk
No new service, image pull, infrastructure commitment or increased resource request is proposed. Estimated attended execution is 15–30 minutes; local probes use existing compute. Monetary infrastructure increment is EUR 0; founder time is unpriced. Recreate causes an expected 1–3 minute Git-hosting interruption, with up to another 3 minutes for readiness rollback, plus bounded probe/command time. This is an estimate, not an availability guarantee.
Modelled recovery effort is up to one hour (illustratively EUR 100 at EUR 100/hour); there is no defensible complete EUR loss bound for a scanner exception or failed native recovery. The security consequence remains subject to explicit supervisor judgement. A real credential deliberately shaped as an approved filename/digest is indistinguishable by this exception alone; this limitation already exists for the YAML form and extends to its JSON representation. Other rules and provider/private-key negative tests remain active. No autopilot budget, risk grant, promotion or scored approval follows from this preparation.
## Founder execution after exact approval
The convenience launcher `/tmp/forge-scanner-rollout.sh` pins this packet's SHA256, explicitly selects the attended admin context, and runs `apply`. Review the plan, six source diffs and runner before execution. Return the sanitized receipt/output. Source followthrough and publication then continue under the same exact approved scope. Do not rerun an uncertain apply; preserve its receipt and inspect or use the reviewed rollback.

View file

@ -0,0 +1,4 @@
#!/bin/sh
set +vx
set -eu
exec /data/security/receive-secret-scan/20261102-d7dbd0ef5731/receive-secret-scan --scanner /data/security/receive-secret-scan/20261102-d7dbd0ef5731/gitleaks

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
#!/bin/sh
set +vx
set -eu
exec /data/security/receive-secret-scan/20261027-b735d052a49f/receive-secret-scan --scanner /data/security/receive-secret-scan/20261027-b735d052a49f/gitleaks

View file

@ -0,0 +1,16 @@
{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "forgejo-secret-template-619d94208139",
"namespace": "forgejo",
"labels": {
"app.kubernetes.io/part-of": "forgejo",
"custodian.railiance.org/workplan": "CUST-WP-0083"
}
},
"immutable": true,
"data": {
"hook": "#!/bin/sh\nset +vx\nset -eu\nexec /data/security/receive-secret-scan/20261102-d7dbd0ef5731/receive-secret-scan --scanner /data/security/receive-secret-scan/20261102-d7dbd0ef5731/gitleaks\n"
}
}

View file

@ -0,0 +1 @@
{"result":"unverified"}

View file

@ -0,0 +1,73 @@
{
"result": "passed",
"cases": [
{
"case": "guard-new-drift",
"returncode": 0,
"seconds": 0.16
},
{
"case": "guard-symlink",
"returncode": 0,
"seconds": 0.13
},
{
"case": "guard-unexpected-mode",
"returncode": 0,
"seconds": 0.16
},
{
"case": "stage-roundtrip",
"returncode": 0,
"seconds": 0.34
},
{
"case": "stage-bad-hash-cleanup",
"returncode": 0,
"seconds": 0.19
},
{
"case": "stage-existing-unowned",
"returncode": 0,
"seconds": 0.16
},
{
"case": "stage-symlink-refused",
"returncode": 0,
"seconds": 0.14
},
{
"case": "hooks-roundtrip",
"returncode": 0,
"seconds": 0.26
},
{
"case": "hooks-refuse-complete-scope-before-writing",
"returncode": 0,
"seconds": 0.14
},
{
"case": "hooks-partial-failure-recovers",
"returncode": 0,
"seconds": 0.17
},
{
"case": "hooks-symlink-refused",
"returncode": 0,
"seconds": 0.12
},
{
"case": "hooks-rollback-unknown-drift-retained",
"returncode": 0,
"seconds": 0.21
},
{
"case": "real-receive-and-quarantine",
"returncode": 0,
"seconds": 4.57
}
],
"native_acceptance": false,
"real_remote_refs_changed": false,
"scope": "Exact new guard, stage/hook scripts, real candidate/Gitleaks receive probes; original guard bytes unchanged."
}

View file

@ -0,0 +1,4 @@
#!/bin/sh
# Offline scanner-only dispatcher; native probe separately copies the real dispatcher.
set -eu
exec "$GIT_DIR/hooks/pre-receive.d/10-custodian-secret-scan"

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,66 @@
#!/bin/sh
# Caller supplies a sealed, exact path list and known public before/after bytes.
set +vx
set -eu
PATH=/usr/bin:/bin
export PATH
area=$1 direction=$2 before_sum=$3 after_sum=$4
test "$(id -u)" = 1000
for file in before after paths; do test -f "$area/$file" && test ! -L "$area/$file"; done
test "$(sha256sum "$area/before" | cut -d ' ' -f 1)" = "$before_sum"
test "$(sha256sum "$area/after" | cut -d ' ' -f 1)" = "$after_sum"
case "$direction" in apply) ;; rollback) ;; *) exit 35;; esac
validate_path() {
path=$1
case "$path" in /data/git/gitea-repositories/*/*.git/hooks/pre-receive.d/10-custodian-secret-scan) ;; *) exit 36;; esac
parent=${path%/*}
while test "$parent" != /data; do
test -d "$parent" && test ! -L "$parent"
parent=${parent%/*}
done
test -f "$path" && test ! -L "$path"
test "$(stat -c %u "$path")" = 1000
case "$(stat -c %a "$path")" in 755|775) ;; *) exit 37;; esac
current=$(sha256sum "$path" | cut -d ' ' -f 1)
if test "$direction" = apply; then test "$current" = "$before_sum"
else test "$current" = "$before_sum" || test "$current" = "$after_sum"; fi
}
# Validate the complete scope before the first write.
count=0
while IFS= read -r path; do validate_path "$path"; count=$((count+1)); done < "$area/paths"
test "$count" -gt 0
temporary=
restore_partial() {
code=$?
trap - EXIT HUP INT TERM
if test -n "$temporary"; then rm -f -- "$temporary" || code=38; fi
# Only our exact replacement may be restored. Unknown drift is retained.
if test "$direction" = apply && test "$code" != 0; then
while IFS= read -r path; do
if test -f "$path" && test ! -L "$path" &&
test "$(sha256sum "$path" | cut -d ' ' -f 1)" = "$after_sum"; then
staged=$(mktemp "${path%/*}/.custodian-restore-XXXXXX") || { code=38; continue; }
if cp "$area/before" "$staged" && chmod --reference="$path" "$staged" && mv -T -- "$staged" "$path"; then :
else rm -f -- "$staged"; code=38; fi
fi
done < "$area/paths"
fi
exit "$code"
}
trap restore_partial EXIT
trap 'exit 39' HUP INT TERM
while IFS= read -r path; do
validate_path "$path"
if test "$direction" = rollback; then source="$area/before"; sum=$before_sum; mode=775
else source="$area/after"; sum=$after_sum; mode=755; fi
if test "$(sha256sum "$path" | cut -d ' ' -f 1)" != "$sum"; then
temporary=$(mktemp "${path%/*}/.custodian-replace-XXXXXX")
cp "$source" "$temporary"
chmod --reference="$path" "$temporary"
test "$(sha256sum "$temporary" | cut -d ' ' -f 1)" = "$sum"
mv -T -- "$temporary" "$path"
temporary=
fi
test "$(sha256sum "$path" | cut -d ' ' -f 1)" = "$sum"
done < "$area/paths"
printf '{"hook_files_verified":%s,"direction":"%s"}\n' "$count" "$direction"

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,29 @@
{
"proposal_id": "SYNC-SCANNER-JSON-DIGEST-20261101",
"revision": 2,
"files": {
"PLAN.md": "093a18d5e66316af7f5dc91d466467000e5b31e7b064eeca6cfd979583d65424",
"after-hook": "619d9420813905f34413c1d7766d6da79ac2713cb9b02d1eacd61ad610dafd23",
"apps-source-changes.json": "311970f9155f704713145dfd864b3d73f42518cd86e4ffae12604b4f04cb4713",
"apps-source.patch": "cecc1f0aa2e1751c61b9c9453849ae29d09cdd5116802a084b01f0677496addb",
"before-hook": "d1df801be39f2cd4ac2a9e71bbd1341ccd2723d236c9c265853b289cc6df221f",
"configmap.json": "e91a3405b6524c7dcdfa82d6fd4efc17570e430f833b265892e4f223a19d7689",
"filesystem-r1.json": "0f3f9ddc7a52e7ed583536f5804d737b55ce4075ced77fba35c0bc7f81f70072",
"filesystem-r2.json": "0a17b575761aeb3deee8b4b54d8440b74e1d3688128d5ab96d43af801ee4d832",
"fixture-dispatcher.sh": "38a6d661aee7dd3c64e15b0baefec4c2410f75de4543d9465d69c1d26a80d2b8",
"forgejo-values.yaml": "4bd65a32c97c9aa75f7fb08025b463c3acf060e82ac32f56feab58f1193cbd7d",
"hooks.sh": "d6d6903983d673381afd37f14004069b47cc1e3a28410ccfed93720945924b34",
"inputs.json": "a2c28e5fd3a572d2b57a839a36c1f9bbf47c369b6a46748da7212ce82595fa86",
"preflight-receipt.json": "4050e9a2d17e1cfc69621e2c48d3339d46fbde62be2a2ce4cc85e8970647f82e",
"probe.sh": "b65f4bc001e779888c35ca857485195da16df199a5ab1ec4c0f60fc369e6c5b8",
"proposal.json": "8c1e23c3b179f2a316042c43a4847f212a0c03466845fa92e00c2fa5c6c4e375",
"rollback-probe.sh": "503760ec33d429cf4d02cacc3437088e40b5cbe07957a279652f605a1bf00476",
"rollout.py": "bffa9d66ce9ae471a0abe7179c30a6005ef8719b526cfdfd9e3a34f1dae4b1fb",
"scanner-source-changes.json": "20e7ded830d575006969db85bda144b179bb886e44ba92947d4790d110131cf0",
"source_followthrough.py": "3fb99358829f0891d01e056ebf8b65f9dfca96aafa211a2674065fd20cfd811b",
"stage.sh": "10cfdbeb308036ba153842943e8b6fa600cac94be842081ea7ce88245f787eeb",
"test-validation.json": "386b9558ed7af725e93ea2315a0ec489b4ff2071eed92c87a43bbf36163099c7",
"test_filesystem.py": "f02df3c07e222fa833ad1b7322c01d56f626606dfe64b87c59656c6edf916ace",
"test_rollout.py": "42bf0d411e39befbfe39ef4f18857e3b5c2f548f38ff52f94c8186418820b0b2"
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,80 @@
#!/bin/sh
# Exact staged executable, real receive-pack, private synthetic tmpfs inputs.
set +vx
set -eu
PATH=/usr/bin:/bin
export PATH
version=$1 dispatcher=$2
test "$(id -u)" = 1000
test "$(stat -f -c %t /dev/shm)" = 1021994
umask 077
base=$(mktemp -d /dev/shm/forge-receive-activation-XXXXXXXX)
printf '%s' receive-batch-probe > "$base/.owner"
phase=setup
cleanup() {
code=$?
trap - EXIT HUP INT TERM
if test "$code" != 0; then printf '{"probe_phase":"%s","exit_code":%s,"result":"unverified"}\n' "$phase" "$code" >&2; fi
if test -d "$base" && test ! -L "$base" && test "$(cat "$base/.owner")" = receive-batch-probe; then
rm -rf -- "$base" || code=41
else code=41; fi
exit "$code"
}
trap cleanup EXIT
trap 'exit 42' HUP INT TERM
export HOME="$base" TMPDIR="$base" GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0
mkdir -p "$base/template/hooks/pre-receive.d"
hook="$base/template/hooks/pre-receive.d/10-custodian-secret-scan"
printf '#!/bin/sh\nset +vx\nset -eu\nexec %s/receive-secret-scan --scanner %s/gitleaks\n' "$version" "$version" > "$hook"
chmod 755 "$hook"
git -c init.templateDir="$base/template" init -q --bare "$base/server.git"
cp "$dispatcher" "$base/server.git/hooks/pre-receive"
chmod 755 "$base/server.git/hooks/pre-receive"
git init -q -b main "$base/client"
git -C "$base/client" config core.hooksPath "$base/no-hooks"
git -C "$base/client" config user.name 'Custodian isolated receive probe'
git -C "$base/client" config user.email 'probe@example.test'
printf 'clean baseline\n' > "$base/client/source.txt"
printf '{"values/secrets-engine.yaml": "%s"}\n' "$(printf synthetic-public-digest | sha256sum | cut -d ' ' -f 1)" > "$base/client/source-pin.json"
git -C "$base/client" add source.txt source-pin.json
git -C "$base/client" commit -qm 'isolated clean baseline'
phase=clean_push
git -C "$base/client" push "$base/server.git" HEAD:refs/heads/main > "$base/clean.out" 2>&1
grep -q '"status":"clean"' "$base/clean.out"
initial=$(git -C "$base/client" rev-parse HEAD)
phase=synthetic_key
ssh-keygen -q -t ed25519 -N '' -f "$base/synthetic-key" >/dev/null 2>&1
cp "$base/synthetic-key" "$base/client/fixture.txt"
git -C "$base/client" add fixture.txt
git -C "$base/client" commit -qm 'isolated synthetic fixture'
rejected=$(git -C "$base/client" rev-parse HEAD)
git -C "$base/client" rm -q fixture.txt
git -C "$base/client" commit -qm 'remove fixture from tip'
phase=blocked_history
if git -C "$base/client" push "$base/server.git" HEAD:refs/heads/main > "$base/blocked.out" 2>&1; then exit 43; fi
grep -q '"status":"blocked"' "$base/blocked.out"
if grep -q 'BEGIN OPENSSH PRIVATE KEY' "$base/blocked.out"; then exit 44; fi
if git -C "$base/server.git" cat-file -e "$rejected" >/dev/null 2>&1; then exit 45; fi
test "$(git -C "$base/server.git" rev-parse refs/heads/main)" = "$initial"
git -C "$base/client" checkout -qb recovery "$initial"
printf 'clean recovery\n' > "$base/client/source.txt"
git -C "$base/client" add source.txt
git -C "$base/client" commit -qm 'isolated clean recovery'
phase=unavailable_scanner
recovery=$(git -C "$base/client" rev-parse HEAD)
cp "$hook" "$base/saved-hook"
printf '#!/bin/sh\nexec %s/receive-secret-scan --scanner /nonexistent\n' "$version" > "$base/server.git/hooks/pre-receive.d/10-custodian-secret-scan"
if git -C "$base/client" push "$base/server.git" HEAD:refs/heads/main > "$base/unavailable.out" 2>&1; then exit 46; fi
grep -q '"status":"unverified"' "$base/unavailable.out"
test "$(git -C "$base/server.git" rev-parse refs/heads/main)" = "$initial"
if git -C "$base/server.git" cat-file -e "$recovery" >/dev/null 2>&1; then exit 47; fi
cp "$base/saved-hook" "$base/server.git/hooks/pre-receive.d/10-custodian-secret-scan"
phase=clean_recovery
git -C "$base/client" push "$base/server.git" HEAD:refs/heads/main > "$base/recovered.out" 2>&1
grep -q '"status":"clean"' "$base/recovered.out"
test "$(git -C "$base/server.git" rev-parse refs/heads/main)" = "$recovery"
trap - EXIT HUP INT TERM
test "$(cat "$base/.owner")" = receive-batch-probe
rm -rf -- "$base"
test ! -e "$base"
printf '{"json_digest_clean_push":true,"intermediate_secret_refused":true,"quarantine_discarded":true,"scanner_unavailable_refused":true,"clean_recovery":true,"temporary_probe_removed":true,"builtin_authorization_excluded":true}\n'

View file

@ -0,0 +1,31 @@
{
"proposal_id": "SYNC-SCANNER-JSON-DIGEST-20261101",
"revision": 2,
"original_revision": 1,
"original_proposal_sha256": "75bc6e4bf1a98444cd844306bf8f3f81aca1e31b1609081905fd42ed1b76ae42",
"target": "railiance01 forgejo/forge and all existing known scanner fragments (maximum 500 repositories); paired repo-manager, railiance-forge and Apps source",
"expected_result": "Equivalent JSON filename/exact lowercase SHA256 metadata accepted; full scan scope and secret refusal preserved",
"verification_ref": "PLAN.md; test-validation.json; native guarded dry-run and probes are execution prerequisites",
"rollback_ref": "rollout.py rollback; PLAN.md; old artifacts retained",
"cost_estimate_eur": {
"additional_infrastructure": 0,
"attended_minutes": [
15,
30
],
"founder_time": "unpriced"
},
"risk_estimate_eur": {
"complete_credible_loss_bound": null,
"illustrative_recovery": 100,
"basis": "one recovery hour at illustrative EUR100/hour; scanner-policy loss unpriced; supervised judgement required"
},
"estimated_git_hosting_interruption_minutes": [
1,
3
],
"rollback_readiness_timeout_seconds": 180,
"authority": "pending exact founder approval; not executed; no authority or promotion inferred",
"source_preimages": "scanner-source-changes.json and apps-source-changes.json",
"scope_limits": "PLAN.md; no UUID, fingerprint or prose exceptions; preserve all history/object scan coverage"
}

View file

@ -0,0 +1,79 @@
#!/bin/sh
# Exact staged executable, real receive-pack, private synthetic tmpfs inputs.
set +vx
set -eu
PATH=/usr/bin:/bin
export PATH
version=$1 dispatcher=$2
test "$(id -u)" = 1000
test "$(stat -f -c %t /dev/shm)" = 1021994
umask 077
base=$(mktemp -d /dev/shm/forge-receive-activation-XXXXXXXX)
printf '%s' receive-batch-probe > "$base/.owner"
phase=setup
cleanup() {
code=$?
trap - EXIT HUP INT TERM
if test "$code" != 0; then printf '{"probe_phase":"%s","exit_code":%s,"result":"unverified"}\n' "$phase" "$code" >&2; fi
if test -d "$base" && test ! -L "$base" && test "$(cat "$base/.owner")" = receive-batch-probe; then
rm -rf -- "$base" || code=41
else code=41; fi
exit "$code"
}
trap cleanup EXIT
trap 'exit 42' HUP INT TERM
export HOME="$base" TMPDIR="$base" GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0
mkdir -p "$base/template/hooks/pre-receive.d"
hook="$base/template/hooks/pre-receive.d/10-custodian-secret-scan"
printf '#!/bin/sh\nset +vx\nset -eu\nexec %s/receive-secret-scan --scanner %s/gitleaks\n' "$version" "$version" > "$hook"
chmod 755 "$hook"
git -c init.templateDir="$base/template" init -q --bare "$base/server.git"
cp "$dispatcher" "$base/server.git/hooks/pre-receive"
chmod 755 "$base/server.git/hooks/pre-receive"
git init -q -b main "$base/client"
git -C "$base/client" config core.hooksPath "$base/no-hooks"
git -C "$base/client" config user.name 'Custodian isolated receive probe'
git -C "$base/client" config user.email 'probe@example.test'
printf 'clean baseline\n' > "$base/client/source.txt"
git -C "$base/client" add source.txt
git -C "$base/client" commit -qm 'isolated clean baseline'
phase=clean_push
git -C "$base/client" push "$base/server.git" HEAD:refs/heads/main > "$base/clean.out" 2>&1
grep -q '"status":"clean"' "$base/clean.out"
initial=$(git -C "$base/client" rev-parse HEAD)
phase=synthetic_key
ssh-keygen -q -t ed25519 -N '' -f "$base/synthetic-key" >/dev/null 2>&1
cp "$base/synthetic-key" "$base/client/fixture.txt"
git -C "$base/client" add fixture.txt
git -C "$base/client" commit -qm 'isolated synthetic fixture'
rejected=$(git -C "$base/client" rev-parse HEAD)
git -C "$base/client" rm -q fixture.txt
git -C "$base/client" commit -qm 'remove fixture from tip'
phase=blocked_history
if git -C "$base/client" push "$base/server.git" HEAD:refs/heads/main > "$base/blocked.out" 2>&1; then exit 43; fi
grep -q '"status":"blocked"' "$base/blocked.out"
if grep -q 'BEGIN OPENSSH PRIVATE KEY' "$base/blocked.out"; then exit 44; fi
if git -C "$base/server.git" cat-file -e "$rejected" >/dev/null 2>&1; then exit 45; fi
test "$(git -C "$base/server.git" rev-parse refs/heads/main)" = "$initial"
git -C "$base/client" checkout -qb recovery "$initial"
printf 'clean recovery\n' > "$base/client/source.txt"
git -C "$base/client" add source.txt
git -C "$base/client" commit -qm 'isolated clean recovery'
phase=unavailable_scanner
recovery=$(git -C "$base/client" rev-parse HEAD)
cp "$hook" "$base/saved-hook"
printf '#!/bin/sh\nexec %s/receive-secret-scan --scanner /nonexistent\n' "$version" > "$base/server.git/hooks/pre-receive.d/10-custodian-secret-scan"
if git -C "$base/client" push "$base/server.git" HEAD:refs/heads/main > "$base/unavailable.out" 2>&1; then exit 46; fi
grep -q '"status":"unverified"' "$base/unavailable.out"
test "$(git -C "$base/server.git" rev-parse refs/heads/main)" = "$initial"
if git -C "$base/server.git" cat-file -e "$recovery" >/dev/null 2>&1; then exit 47; fi
cp "$base/saved-hook" "$base/server.git/hooks/pre-receive.d/10-custodian-secret-scan"
phase=clean_recovery
git -C "$base/client" push "$base/server.git" HEAD:refs/heads/main > "$base/recovered.out" 2>&1
grep -q '"status":"clean"' "$base/recovered.out"
test "$(git -C "$base/server.git" rev-parse refs/heads/main)" = "$recovery"
trap - EXIT HUP INT TERM
test "$(cat "$base/.owner")" = receive-batch-probe
rm -rf -- "$base"
test ! -e "$base"
printf '{"clean_push":true,"intermediate_secret_refused":true,"quarantine_discarded":true,"scanner_unavailable_refused":true,"clean_recovery":true,"temporary_probe_removed":true,"builtin_authorization_excluded":true}\n'

View file

@ -0,0 +1,330 @@
"""Founder-run exact scanner rollout. Default: read-only inventory and server dry-run.
This acknowledgement check binds reviewed bytes; it is not an approval service or
credential boundary. Run only in the founder's attended execution environment.
Raw Kubernetes errors/objects, Git contents and scanner findings are never printed.
"""
from pathlib import Path
import argparse
import base64
import copy
import hashlib
import json
import re
import shlex
import subprocess
import time
HERE = Path(__file__).resolve().parent
HOOK = 'pre-receive.d/10-custodian-secret-scan'
REPO = r'/data/git/gitea-repositories/[a-z0-9_.-]+/[a-z0-9_.-]+\.git'
RECEIPT = Path('/tmp/forge-scanner-rollout-receipt.json')
def sha(raw):
return hashlib.sha256(raw).hexdigest()
def encode(value):
return json.dumps(value, sort_keys=True, separators=(',', ':')).encode()
def ensure(condition, reason):
if not condition:
raise RuntimeError(reason)
def verify_packet():
manifest = json.loads((HERE / 'packet.json').read_bytes())
for name, digest in manifest['files'].items():
ensure(Path(name).name == name, 'unsafe-packet-path')
ensure(sha((HERE / name).read_bytes()) == digest, 'packet-file-drift:' + name)
return sha((HERE / 'packet.json').read_bytes())
def patch_for(d, cfg, reverse=False):
spec = d['spec']['template']['spec']
gi, vi = cfg['guard_index'], cfg['volume_index']
guard = cfg['new_guard_command'] if reverse else cfg['guard_command']
replacement = cfg['guard_command'] if reverse else cfg['new_guard_command']
cm = cfg['new_configmap'] if reverse else cfg['old_configmap']
dest = cfg['old_configmap'] if reverse else cfg['new_configmap']
ensure(d['metadata']['uid'] == cfg['deployment_uid'], 'deployment-identity-drift')
ensure(d['spec'].get('strategy') == cfg['strategy'], 'strategy-drift')
ensure(spec['initContainers'][gi]['name'] == 'verify-secret-scanner', 'guard-index-drift')
ensure(spec['initContainers'][gi].get('args') == cfg['guard_args'], 'guard-args-drift')
ensure(spec['initContainers'][gi]['command'] == guard, 'guard-command-drift')
vol = copy.deepcopy(cfg['volume']); vol['configMap']['name'] = cm
ensure(spec['volumes'][vi] == vol, 'volume-drift')
a = f'/spec/template/spec/initContainers/{gi}/command'
b = f'/spec/template/spec/volumes/{vi}'
return [
{'op': 'test', 'path': '/metadata/uid', 'value': cfg['deployment_uid']},
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': d['metadata']['resourceVersion']},
{'op': 'test', 'path': a, 'value': guard},
{'op': 'test', 'path': b, 'value': vol},
{'op': 'replace', 'path': a, 'value': replacement},
{'op': 'replace', 'path': b + '/configMap/name', 'value': dest},
]
def inventory_script():
return r'''set +vx
set -eu
export PATH=/usr/bin:/bin
test "$(id -u)" = 1000
for parent in /data /data/git /data/git/gitea-repositories; do test -d "$parent" && test ! -L "$parent"; done
count=0
for repo in /data/git/gitea-repositories/*/*.git; do
test -d "$repo" || continue
count=$((count+1)); test "$count" -le 500
for parent in "${repo%/*}" "$repo" "$repo/hooks" "$repo/hooks/pre-receive.d"; do test -d "$parent" && test ! -L "$parent"; done
test -z "$(find "$repo/hooks" -type l -print -quit)"
test -f "$repo/hooks/pre-receive" && test -x "$repo/hooks/pre-receive"
test -f "$repo/hooks/pre-receive.d/10-custodian-secret-scan"
find "$repo/hooks" -type f ! -name '*.sample' -exec sh -c '
for file do printf "%s %s %s " "$(stat -c %a "$file")" "$(stat -c %u "$file")" "$(stat -c %g "$file")"; sha256sum "$file"; done
' sh {} +
done
test "$count" -gt 0
'''
def parse_inventory(raw, cfg, allowed):
result = {}
for line in raw.decode().splitlines():
parts = line.split(' ', 4)
ensure(len(parts) == 5, 'malformed-inventory')
mode, uid, gid, digest, path = parts
path = path.lstrip(' ')
match = re.fullmatch('(' + REPO + r')/hooks/([a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*)', path)
ensure(match is not None and re.fullmatch('[0-9a-f]{64}', digest), 'unsafe-inventory-entry')
repo, name = match.groups()
entries = result.setdefault(repo, {})
ensure(name not in entries, 'duplicate-hook')
entries[name] = dict(mode=mode, uid=int(uid), gid=int(gid), sha256=digest)
ensure(0 < len(result) <= 500, 'empty-or-oversized-inventory')
for files in result.values():
ensure(HOOK in files and 'pre-receive' in files, 'uncovered-repository')
f = files[HOOK]
ensure(f['uid'] == 1000 and f['gid'] == 1000 and f['mode'] in ('755', '775'), 'scanner-hook-custody-drift')
ensure(f['sha256'] in allowed, 'unknown-scanner-hook')
return result
def protected_equal(before, after):
ensure(set(before) <= set(after), 'repository-disappeared')
for repo, files in before.items():
ensure({k: v for k, v in files.items() if k != HOOK} ==
{k: v for k, v in after[repo].items() if k != HOOK}, 'protected-hook-drift')
for k in ('mode', 'uid', 'gid'):
ensure(files[HOOK][k] == after[repo][HOOK][k], 'scanner-hook-metadata-drift')
class Rollout:
def __init__(self, cfg):
self.c = cfg
self.receipt = {'proposal_id': 'SYNC-SCANNER-JSON-DIGEST-20261101', 'revision': 2,
'events': [], 'native_mutation': False}
def k(self, *args, data=None, timeout=60):
q = subprocess.run(['kubectl', *args], input=data, capture_output=True, timeout=timeout)
ensure(q.returncode == 0, 'kubectl-command-failed; raw diagnostics withheld')
return q.stdout
def event(self, name, **details):
self.receipt['events'].append({'event': name, **details})
RECEIPT.write_text(json.dumps(self.receipt, indent=2) + '\n')
print(json.dumps({'event': name, **details}), flush=True)
def deployment(self):
d = json.loads(self.k('-n', 'forgejo', 'get', 'deployment', 'forge', '-o', 'json'))
ensure(d['metadata']['uid'] == self.c['deployment_uid'], 'deployment-identity-drift')
return d
def target(self):
nodes = json.loads(self.k('get', 'nodes', '-o', 'json'))['items']
ensure(len(nodes) == 1 and nodes[0]['metadata']['uid'] == self.c['node_uid'], 'node-identity-drift')
ensure(any(a['address'] == '92.205.62.239' for a in nodes[0]['status']['addresses']), 'node-address-drift')
def ready_pod(self, d, seconds=180):
selector = ','.join(k + '=' + v for k, v in sorted(d['spec']['selector']['matchLabels'].items()))
until = time.monotonic() + seconds
while True:
rs = json.loads(self.k('-n', 'forgejo', 'get', 'replicasets', '-l', selector, '-o', 'json'))['items']
owners = {x['metadata']['uid'] for x in rs if x['spec']['template']['spec'] == d['spec']['template']['spec'] and any(o['uid'] == self.c['deployment_uid'] for o in x['metadata'].get('ownerReferences', []))}
pods = json.loads(self.k('-n', 'forgejo', 'get', 'pods', '-l', selector, '-o', 'json'))['items']
ready = [p for p in pods if not p['metadata'].get('deletionTimestamp') and
any(o['uid'] in owners for o in p['metadata'].get('ownerReferences', [])) and
any(c['type'] == 'Ready' and c['status'] == 'True' for c in p.get('status', {}).get('conditions', []))]
if len(ready) == 1:
# Require the current Deployment rollout, not a surviving old pod.
now = self.deployment()
if now.get('status', {}).get('observedGeneration', 0) >= now['metadata']['generation'] and now.get('status', {}).get('updatedReplicas') == now['spec'].get('replicas', 1) and now.get('status', {}).get('readyReplicas') == now['spec'].get('replicas', 1):
return ready[0]['metadata']['name']
ensure(time.monotonic() < until, 'readiness-timeout')
time.sleep(2)
def shell(self, pod, script, args=(), data=None, timeout=90):
return self.k('-n', 'forgejo', 'exec', '-i', pod, '-c', self.c['container'], '--',
'sh', '-c', script, 'sh', *args, data=data, timeout=timeout)
def inventory(self, pod, allowed):
return parse_inventory(self.shell(pod, inventory_script()), self.c, allowed)
def dry_run(self, d):
cm = (HERE / 'configmap.json').read_bytes()
self.k('create', '--dry-run=server', '-f', '-', '-o', 'name', data=cm)
patch = patch_for(d, self.c)
self.k('-n', 'forgejo', 'patch', 'deployment', 'forge', '--type=json', '--dry-run=server',
'-p', json.dumps(patch), '-o', 'name')
diff = subprocess.run(['kubectl', 'diff', '-f', '-'], input=cm, capture_output=True, timeout=60)
ensure(diff.returncode in (0, 1), 'client-diff-failed; raw diagnostics withheld')
self.event('server-dry-run-passed', client_configmap_diff_exit=diff.returncode, changes=['append exact artifact guard', 'select immutable hook ConfigMap'],
old_configmap=self.c['old_configmap'], new_configmap=self.c['new_configmap'],
strategy='Recreate', resources_unchanged=True)
def preflight(self):
for name in ('scanner-source-changes.json', 'apps-source-changes.json'):
for source in json.loads((HERE / name).read_bytes()):
ensure(sha(Path(source['target']).read_bytes()) == source['original_sha256'], 'owner-source-drift')
self.target()
d = self.deployment(); patch_for(d, self.c)
pod = self.ready_pod(d)
ensure(pod == self.c['pod'], 'initial-pod-name-drift')
uid = self.k('-n', 'forgejo', 'get', 'pod', pod, '-o', 'jsonpath={.metadata.uid}').decode()
ensure(uid == self.c['pod_uid'], 'initial-pod-identity-drift')
current = self.k('-n', 'forgejo', 'get', 'configmap', self.c['old_configmap'], '-o', 'jsonpath={.data.hook}')
ensure(sha(current) == self.c['old_hook_sha256'], 'old-template-drift')
self.shell(pod, self.c['guard_command'][2], ('check',))
before = self.inventory(pod, {self.c['old_hook_sha256']})
self.receipt['before_hooks'] = before
self.event('preflight-passed', repositories=len(before), deployment_uid=self.c['deployment_uid'])
self.dry_run(d)
return d, pod, before
def migrate(self, pod, direction):
c = self.c
# Only public hooks/path names in private tmpfs. Child restores partial writes.
wrapper = '''set +vx
set -eu
umask 077
test "$(stat -f -c %t /dev/shm)" = 1021994
area=$(mktemp -d /dev/shm/forge-scanner-hooks-XXXXXXXX)
cleanup() { code=$?; trap - EXIT; rm -f -- "$area/before" "$area/after" "$area/paths"; rmdir "$area" || code=50; exit "$code"; }
trap cleanup EXIT
'''
for name in ('before', 'after'):
value = base64.b64encode((HERE / (name + '-hook')).read_bytes()).decode()
wrapper += 'printf %s ' + shlex.quote(value) + ' | base64 -d > "$area/' + name + '"\n'
wrapper += 'cat > "$area/paths"\nsh -c ' + shlex.quote((HERE / 'hooks.sh').read_text()) + ' sh "$area" "$1" "$2" "$3"\n'
before = self.inventory(pod, {c['old_hook_sha256'], c['new_hook_sha256']})
paths = ''.join(repo + '/hooks/' + HOOK + '\n' for repo, files in sorted(before.items())
if direction == 'rollback' or files[HOOK]['sha256'] == c['old_hook_sha256'])
if paths:
self.shell(pod, wrapper, (direction, c['old_hook_sha256'], c['new_hook_sha256']), paths.encode())
expected = c['old_hook_sha256'] if direction == 'rollback' else c['new_hook_sha256']
after = self.inventory(pod, {expected})
protected_equal(before, after)
self.event('hooks-' + direction, repositories=len(after))
return after
def probe(self, pod, version, inventory):
dispatcher = sorted(inventory)[0] + '/hooks/pre-receive'
result = json.loads(self.shell(pod, (HERE / 'probe.sh').read_text(), (version, dispatcher), timeout=300))
ensure(all(result[k] for k in ('json_digest_clean_push', 'intermediate_secret_refused', 'scanner_unavailable_refused', 'clean_recovery', 'temporary_probe_removed')), 'probe-incomplete')
self.event('isolated-receiver-probe-passed', version=version, checks=result)
def rollback(self, before=None):
self.target(); d = self.deployment()
spec = d['spec']['template']['spec']; cmd = spec['initContainers'][self.c['guard_index']]['command']
is_new = cmd == self.c['new_guard_command']
ensure(is_new or cmd == self.c['guard_command'], 'rollback-refused-unknown-guard')
# The exact reverse patch tests both guard and volume, and current resourceVersion.
if is_new:
patch = patch_for(d, self.c, reverse=True)
self.k('-n', 'forgejo', 'patch', 'deployment', 'forge', '--type=json', '--dry-run=server', '-p', json.dumps(patch), '-o', 'name')
self.k('-n', 'forgejo', 'patch', 'deployment', 'forge', '--type=json', '-p', json.dumps(patch), '-o', 'name')
else:
patch_for(d, self.c)
pod = self.ready_pod(self.deployment())
after = self.migrate(pod, 'rollback')
self.shell(pod, self.c['guard_command'][2], ('check',))
mounted = self.shell(pod, 'cat /opt/forge-secret-template/hooks/pre-receive.d/10-custodian-secret-scan')
ensure(sha(mounted) == self.c['old_hook_sha256'], 'rollback-mounted-template-drift')
if before:
protected_equal(before, after)
result = json.loads(self.shell(pod, (HERE / 'rollback-probe.sh').read_text(), (self.c['old_dir'], sorted(after)[0] + '/hooks/pre-receive'), timeout=300))
ensure(all(result[k] for k in ('clean_push', 'intermediate_secret_refused', 'scanner_unavailable_refused', 'clean_recovery', 'temporary_probe_removed')), 'rollback-probe-incomplete')
self.event('rollback-probe-passed', checks=result)
self.event('rollback-verified', old_artifacts_retained=True, staged_artifacts_retained=True)
def apply(self, binary):
d, pod, before = self.preflight()
raw = binary.read_bytes(); ensure(sha(raw) == self.c['new_adapter_sha256'], 'candidate-binary-drift')
self.receipt['native_mutation'] = True
self.event('staging-started')
try:
self.shell(pod, (HERE / 'stage.sh').read_text(),
(self.c['new_dir'], self.c['old_dir'], self.c['new_adapter_sha256'], self.c['scanner_sha256'],
'SYNC-SCANNER-JSON-DIGEST-20261101-R2'), data=raw)
self.shell(pod, self.c['new_guard_command'][2], ('check',))
self.probe(pod, self.c['new_dir'], before)
# Append-only immutable ConfigMap: do not overwrite an existing object.
self.k('create', '-f', '-', '-o', 'name', data=(HERE / 'configmap.json').read_bytes())
self.migrate(pod, 'apply')
# Refresh version immediately before guarded patch; unrelated fields preserved.
patch = patch_for(self.deployment(), self.c)
self.k('-n', 'forgejo', 'patch', 'deployment', 'forge', '--type=json', '-p', json.dumps(patch), '-o', 'name')
self.event('deployment-patched')
pod = self.ready_pod(self.deployment())
after = self.migrate(pod, 'apply') # Includes repos created during transition.
protected_equal(before, after)
self.shell(pod, self.c['new_guard_command'][2], ('check',))
mounted = self.shell(pod, 'cat /opt/forge-secret-template/hooks/pre-receive.d/10-custodian-secret-scan')
ensure(sha(mounted) == self.c['new_hook_sha256'], 'mounted-template-drift')
self.probe(pod, self.c['new_dir'], after)
self.event('activation-verified', repositories=len(after), hook_sha256=self.c['new_hook_sha256'])
except Exception as exc:
self.event('activation-failed', reason=str(exc) if isinstance(exc, RuntimeError) else type(exc).__name__)
try:
self.rollback(before)
except Exception as recovery:
self.event('rollback-unverified', reason=str(recovery) if isinstance(recovery, RuntimeError) else type(recovery).__name__)
raise
def main():
global RECEIPT
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=('preflight', 'apply', 'rollback'), nargs='?', default='preflight')
parser.add_argument('--approved-digest')
parser.add_argument('--binary', type=Path, default=Path('/tmp/sync-scanner-candidate/receive-secret-scan-candidate'))
args = parser.parse_args()
if args.mode == 'preflight':
RECEIPT = Path('/tmp/forge-scanner-rollout-preflight-receipt.json')
digest = verify_packet()
cfg = json.loads((HERE / 'inputs.json').read_bytes())
if args.mode != 'preflight':
ensure(args.approved_digest == digest, 'exact-founder-acknowledgement-required')
runner = Rollout(cfg)
runner.receipt.update(packet_sha256=digest, mode=args.mode)
if args.mode == 'apply':
ensure(not RECEIPT.exists(), 'prior-receipt-present; preserve-and-review-before-retry')
with RECEIPT.open('x') as stream:
stream.write(json.dumps(runner.receipt) + '\n')
runner.apply(args.binary)
elif args.mode == 'rollback':
prior = json.loads(RECEIPT.read_bytes()) if RECEIPT.exists() else {}
runner.receipt['prior_receipt'] = prior
runner.rollback(prior.get('before_hooks'))
else:
runner.preflight()
if __name__ == '__main__':
try:
main()
except Exception as error:
print(json.dumps({'result': 'refused-or-unverified', 'reason': str(error) if isinstance(error, RuntimeError) else type(error).__name__}), flush=True)
raise SystemExit(1)

View file

@ -0,0 +1,26 @@
[
{
"target": "/home/worsch/repo-manager/src/repo_manager/secret_scan.py",
"original_sha256": "12d0e1b10c4d613401e33bfdb4012c6cb3e3719b6b1480f8d3ae2f7a27aeb73f",
"candidate_sha256": "740f64dc1e8421669e6f84fccadef50fcd3d9a802df07f62c6718f917ba55fa9",
"patch": "--- /home/worsch/repo-manager/src/repo_manager/secret_scan.py\n+++ /home/worsch/repo-manager/src/repo_manager/secret_scan.py\n@@ -55,7 +55,7 @@\n description = \"Filename key whose value is a lowercase 64-hex content digest\"\n targetRules = [\"generic-api-key\"]\n regexTarget = \"match\"\n-regexes = ['^[\\w.-]+\\.(?:md|markdown|txt|yaml|yml|json|py|toml):[ \\t]+[a-f0-9]{64}$']\n+regexes = ['^[\\w.-]+\\.(?:md|markdown|txt|yaml|yml|json|py|toml)(?::[ \\t]+[a-f0-9]{64}|\":[ \\t]+\"[a-f0-9]{64}\")$']\n \"\"\"\n \n \n"
},
{
"target": "/home/worsch/repo-manager/tests/test_secret_scan.py",
"original_sha256": "5560f57163dc2189d189cce1c7b772ebf1adc8f8a84d388b2b42b546c10cc1c8",
"candidate_sha256": "2e6d53651ca18e787487cd961efcddc7f483637c63e6e10df399a6d4fcdae962",
"patch": "--- /home/worsch/repo-manager/tests/test_secret_scan.py\n+++ /home/worsch/repo-manager/tests/test_secret_scan.py\n@@ -479,3 +479,21 @@\n if process.poll() is None:\n process.kill(); process.wait(timeout=5)\n process.stdout.close()\n+\n+\n+@pytest.mark.parametrize(\"key\", [\"values/secrets-engine.yaml\", \"docs/runtime-externalsecret.r1.yaml\"])\n+def test_json_filename_digest_is_allowed(binary, repo, key):\n+ digest = os.urandom(32).hex()\n+ stage(repo, json.dumps({key: digest}).encode(), \"source-pin.json\")\n+ assert secret_scan.scan_staged(repo, binary)[\"status\"] == \"clean\"\n+\n+\n+@pytest.mark.parametrize(\"content\", [\n+ lambda digest: json.dumps({\"secret\": digest}),\n+ lambda digest: json.dumps({\"values/secrets-engine.yaml\": \"glpat-\" + digest}),\n+ lambda digest: json.dumps({\"values/secrets-engine.yaml\": digest + \"a\"}),\n+ lambda digest: json.dumps({\"values/secrets-engine.yaml\": digest.upper()}),\n+])\n+def test_json_digest_exception_does_not_allow_secret_shapes(binary, repo, content):\n+ stage(repo, content(os.urandom(32).hex()).encode(), \"manifest.json\")\n+ assert secret_scan.scan_staged(repo, binary)[\"status\"] == \"blocked\"\n"
},
{
"target": "/home/worsch/railiance-forge/tools/receive-secret-scan/main.go",
"original_sha256": "a9835dfc830bde5091354debf26ae40c37fab5fdc2c1b0e483774dda0b93521a",
"candidate_sha256": "4eb494b30b845e090332d3c20070ff417c2114a479823a6097cfe0292ee18aac",
"patch": "--- /home/worsch/railiance-forge/tools/receive-secret-scan/main.go\n+++ /home/worsch/railiance-forge/tools/receive-secret-scan/main.go\n@@ -36,7 +36,7 @@\n description = \"Filename key whose value is a lowercase 64-hex content digest\"\n targetRules = [\"generic-api-key\"]\n regexTarget = \"match\"\n-regexes = ['^[\\w.-]+\\.(?:md|markdown|txt|yaml|yml|json|py|toml):[ \\t]+[a-f0-9]{64}$']\n+regexes = ['^[\\w.-]+\\.(?:md|markdown|txt|yaml|yml|json|py|toml)(?::[ \\t]+[a-f0-9]{64}|\":[ \\t]+\"[a-f0-9]{64}\")$']\n `\n const maxInput = 512 * 1024\n const maxObject = 8 * 1024 * 1024\n"
},
{
"target": "/home/worsch/railiance-forge/tools/receive-secret-scan/batch_test.go",
"original_sha256": "4ef5669786c49cadc9e8985d77c215e34effd3e4aabeb5387cafbe51e5e89e17",
"candidate_sha256": "d94d7812a74b1cb7ff53ee9a3e137d8b524d96f4dca813ca52a9f84f800323b9",
"patch": "--- /home/worsch/railiance-forge/tools/receive-secret-scan/batch_test.go\n+++ /home/worsch/railiance-forge/tools/receive-secret-scan/batch_test.go\n@@ -188,6 +188,11 @@\n \t\t{\"inline-suppression\", append([]byte(\"# gitleaks:allow\\n\"), privateKey...), true},\n \t\t{\"filename-digest\", []byte(\" cards/access.md: \" + digest + \"\\n\"), false},\n \t\t{\"bare-keyword-digest\", []byte(\"access: \" + digest + \"\\n\"), true},\n+\t\t{\"json-filename-digest\", []byte(\"{\\\"values/secrets-engine.yaml\\\": \\\"\" + digest + \"\\\"}\\n\"), false},\n+\t\t{\"json-bare-secret-digest\", []byte(\"{\\\"secret\\\": \\\"\" + digest + \"\\\"}\\n\"), true},\n+\t\t{\"json-filename-token\", []byte(\"{\\\"values/secrets-engine.yaml\\\": \\\"\" + string(canary) + \"\\\"}\\n\"), true},\n+\t\t{\"json-filename-long-digest\", []byte(\"{\\\"values/secrets-engine.yaml\\\": \\\"\" + digest + \"a\\\"}\\n\"), true},\n+\t\t{\"json-filename-uppercase-digest\", []byte(\"{\\\"values/secrets-engine.yaml\\\": \\\"\" + strings.ToUpper(digest) + \"\\\"}\\n\"), true},\n \t} {\n \t\tt.Run(tc.name, func(t *testing.T) {\n \t\t\tstdin, e := scan(context.Background(), f, config, tc.input)\n"
}
]

View file

@ -0,0 +1,86 @@
"""Apply exactly reviewed paired scanner and Apps source after native verification.
No Git operations or native actions. Requires founder acknowledgement of packet.
All six original hashes are checked before writing; differing owner work is refused.
"""
from pathlib import Path
import argparse
import hashlib
import importlib.util
import json
import os
import re
import tempfile
HERE = Path(__file__).resolve().parent
def sha(raw): return hashlib.sha256(raw).hexdigest()
def patched(original, diff):
before = original.decode().splitlines(keepends=True)
result = []; cursor = 0; lines = diff.splitlines(keepends=True); i = 2
while i < len(lines):
match = re.fullmatch(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\n]*\n?', lines[i])
if not match: raise ValueError('malformed-hunk')
start = int(match[1]) - 1
if start < cursor: raise ValueError('overlapping-hunk')
result.extend(before[cursor:start]); cursor = start; i += 1
while i < len(lines) and not lines[i].startswith('@@ '):
line = lines[i]; tag, content = line[0], line[1:]
if tag in (' ', '-'):
if cursor >= len(before) or before[cursor] != content: raise ValueError('hunk-preimage-drift')
cursor += 1
if tag in (' ', '+'): result.append(content)
elif tag != '-': raise ValueError('unsupported-patch-line')
i += 1
result.extend(before[cursor:])
return ''.join(result).encode()
def candidates():
changes = json.loads((HERE / 'scanner-source-changes.json').read_bytes())
changes += json.loads((HERE / 'apps-source-changes.json').read_bytes())
result = []
for change in changes:
target = Path(change['target']); raw = target.read_bytes()
if sha(raw) != change['original_sha256']: raise RuntimeError('owner-source-drift:' + str(target))
candidate = ((HERE / change['candidate_file']).read_bytes() if 'candidate_file' in change else patched(raw, change['patch']))
if sha(candidate) != change['candidate_sha256']: raise RuntimeError('candidate-hash-mismatch')
result.append((target, raw, candidate))
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--apply', action='store_true')
parser.add_argument('--approved-digest')
args = parser.parse_args()
spec = importlib.util.spec_from_file_location('rollout', HERE / 'rollout.py')
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
digest = module.verify_packet()
prepared = candidates()
if args.apply:
if args.approved_digest != digest: raise RuntimeError('exact-founder-acknowledgement-required')
receipt = json.loads(Path('/tmp/forge-scanner-rollout-receipt.json').read_bytes())
if receipt.get('packet_sha256') != digest or receipt['events'][-1]['event'] != 'activation-verified':
raise RuntimeError('native-activation-not-verified')
for target, original, candidate in prepared:
if target.read_bytes() != original: raise RuntimeError('concurrent-owner-source-drift')
mode = target.stat().st_mode & 0o777
fd, name = tempfile.mkstemp(prefix='.scanner-reviewed-', dir=target.parent)
try:
with os.fdopen(fd, 'wb') as stream: stream.write(candidate); stream.flush(); os.fsync(stream.fileno())
os.chmod(name, mode)
os.replace(name, target)
finally:
if os.path.exists(name): os.unlink(name)
print(json.dumps({'result': 'applied' if args.apply else 'preimages-and-candidates-verified', 'files': len(prepared), 'packet_sha256': digest}))
if __name__ == '__main__':
try: main()
except Exception as error:
print(json.dumps({'result': 'refused-or-unverified', 'reason': str(error) if isinstance(error, RuntimeError) else type(error).__name__}))
raise SystemExit(1)

View file

@ -0,0 +1,58 @@
#!/bin/sh
# Exact public executable staging. No repository/configuration changes.
set +vx
set -eu
PATH=/usr/bin:/bin
export PATH
new=$1 old=$2 adapter_sum=$3 scanner_sum=$4 marker=$5
test "$(id -u)" = 1000
for parent in /data /data/security /data/security/receive-secret-scan "$old"; do
test -d "$parent" && test ! -L "$parent"
done
verify() {
file=$1 sum=$2
test -f "$file" && test ! -L "$file"
test "$(stat -c %u "$file")" = 1000
test "$(sha256sum "$file" | cut -d ' ' -f 1)" = "$sum"
}
verify "$old/gitleaks" "$scanner_sum"
if test -e "$new" || test -L "$new"; then
test -d "$new" && test ! -L "$new"
test "$(cat "$new/.migration-owner")" = "$marker"
verify "$new/receive-secret-scan" "$adapter_sum"
verify "$new/gitleaks" "$scanner_sum"
test "$(stat -c %a "$new/receive-secret-scan")" = 755
test "$(stat -c %a "$new/gitleaks")" = 755
printf '{"stage_verified":true,"already_staged":true}\n'
exit 0
fi
temporary="$new.staging-$$"
test ! -e "$temporary" && test ! -L "$temporary"
umask 077
mkdir "$temporary"
printf '%s' "$marker" > "$temporary/.migration-owner"
cleanup() {
code=$?
trap - EXIT HUP INT TERM
if test -d "$temporary" && test ! -L "$temporary" &&
test "$(cat "$temporary/.migration-owner")" = "$marker"; then
rm -f -- "$temporary/receive-secret-scan" "$temporary/gitleaks" "$temporary/.migration-owner"
rmdir "$temporary" || code=33
fi
exit "$code"
}
trap cleanup EXIT
trap 'exit 34' HUP INT TERM
cat > "$temporary/receive-secret-scan"
cp "$old/gitleaks" "$temporary/gitleaks"
verify "$temporary/receive-secret-scan" "$adapter_sum"
verify "$temporary/gitleaks" "$scanner_sum"
chmod 755 "$temporary/receive-secret-scan" "$temporary/gitleaks" "$temporary"
test ! -e "$new" && test ! -L "$new"
mv -T -- "$temporary" "$new"
test "$(cat "$new/.migration-owner")" = "$marker"
verify "$new/receive-secret-scan" "$adapter_sum"
verify "$new/gitleaks" "$scanner_sum"
test "$(stat -c %a "$new/receive-secret-scan")" = 755
test "$(stat -c %a "$new/gitleaks")" = 755
printf '{"stage_verified":true,"already_staged":false}\n'

View file

@ -0,0 +1,26 @@
{
"paired_source_candidate": {
"local_real_gitleaks": "12 passed, 37 deselected",
"receiver_real_gitleaks": "full suite passed in 150.087s"
},
"native_preparation": {
"controller_tests": 5,
"filesystem_and_real_receive_cases": 13,
"filesystem_receipt": "filesystem-r2.json",
"apps_semantic_diff": "only guard append and template selection; all other values equal",
"six_owner_source_preimages_and_candidates": "verified"
},
"preparation_attempts": [
{
"attempt": "filesystem-r1",
"outcome": "unverified; stopped before any case because historical temporary dispatcher fixture was absent",
"refinement": "use explicit committed scanner-only offline dispatcher; native probe still copies real current dispatcher"
},
{
"attempt": "filesystem-r2",
"outcome": "13/13 passed"
}
],
"native_server_dry_run": "not executed; mandatory in founder-run apply",
"native_mutation": false
}

View file

@ -0,0 +1,164 @@
"""Actual scripts in private PID/network namespaces and tmpfs; no remote writes."""
from pathlib import Path
import hashlib
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import time
def sha(raw): return hashlib.sha256(raw).hexdigest()
def run(argv, data=None, success=True):
result = subprocess.run(argv, input=data, capture_output=True, timeout=180)
if (result.returncode == 0) != success:
# Inputs are public or disposable synthetic material. Do not print raw diagnostics.
phases = []
for line in result.stderr.splitlines():
try:
value = json.loads(line)
if isinstance(value, dict) and value.get('result') == 'unverified': phases.append(value.get('probe_phase', 'unknown'))
except (ValueError, UnicodeError): pass
raise AssertionError('unexpected-script-status:' + str(result.returncode) + ';phase=' + ','.join(phases))
return result
def inside(case):
c = json.loads(Path('/fixtures/control.json').read_text())
old, new = Path(c['old_directory']), Path(c['new_directory'])
old.mkdir(parents=True)
for name, src in [('receive-secret-scan', '/old-adapter'), ('gitleaks', '/provider')]:
shutil.copyfile(src, old / name); (old / name).chmod(0o775)
new.mkdir()
for name, src in [('receive-secret-scan', '/new-adapter'), ('gitleaks', '/provider')]:
shutil.copyfile(src, new / name); (new / name).chmod(0o775)
def guard(command, success=True): return run(command, success=success)
def clean_new(): shutil.rmtree(new)
def stage(success=True, payload=None):
return run(['sh', '/scripts/stage.sh', str(new), str(old), c['candidate_sha256'], c['provider_sha256'], 'offline-fixture'],
data=Path('/new-adapter').read_bytes() if payload is None else payload, success=success)
if case == 'old-single-guard-counterexample':
guard(c['single_command'])
assert all((new / n).stat().st_mode & 0o777 == 0o755 for n in ['receive-secret-scan', 'gitleaks'])
assert all((old / n).stat().st_mode & 0o777 == 0o775 for n in ['receive-secret-scan', 'gitleaks'])
elif case == 'both-version-guard':
guard(c['combined_command'])
assert all((p / n).stat().st_mode & 0o777 == 0o755 for p in [old, new] for n in ['receive-secret-scan', 'gitleaks'])
elif case in ['guard-old-drift', 'guard-new-drift', 'guard-symlink', 'guard-unexpected-mode']:
if case == 'guard-old-drift': (old / 'gitleaks').write_bytes(b'changed')
if case == 'guard-new-drift': (new / 'receive-secret-scan').write_bytes(b'changed')
if case == 'guard-symlink':
(new / 'gitleaks').unlink(); (new / 'gitleaks').symlink_to(old / 'gitleaks')
if case == 'guard-unexpected-mode': (new / 'gitleaks').chmod(0o777)
guard(c['combined_command'], success=False)
elif case == 'stage-roundtrip':
clean_new(); stage(); stage()
assert sha((new / 'receive-secret-scan').read_bytes()) == c['candidate_sha256']
assert not list(new.parent.glob(new.name + '.staging-*'))
elif case == 'stage-bad-hash-cleanup':
clean_new(); stage(success=False, payload=b'incomplete-transfer')
assert not new.exists() and not list(new.parent.glob(new.name + '.staging-*'))
elif case == 'stage-existing-unowned':
(new / 'unrelated').write_bytes(b'preserve-me')
stage(success=False)
assert (new / 'unrelated').read_bytes() == b'preserve-me'
elif case == 'stage-symlink-refused':
clean_new(); new.symlink_to(old)
stage(success=False)
assert (old / 'receive-secret-scan').stat().st_mode & 0o777 == 0o775
elif case.startswith('hooks-'):
area = Path('/tmp/control'); area.mkdir()
before, after = b'#!/bin/sh\nexit 0\n', b'#!/bin/sh\nexit 2\n'
(area / 'before').write_bytes(before); (area / 'after').write_bytes(after)
paths = [Path('/data/git/gitea-repositories/test/' + name + '.git/hooks/pre-receive.d/10-custodian-secret-scan') for name in ['first', 'second']]
for path in paths:
path.parent.mkdir(parents=True); path.write_bytes(before); path.chmod(0o775)
(path.parent / 'protected').write_bytes(b'unchanged authorization')
(area / 'paths').write_text(''.join(str(p) + '\n' for p in paths))
def hooks(direction, success=True): return run(['sh', '/scripts/hooks.sh', str(area), direction, sha(before), sha(after)], success=success)
if case == 'hooks-roundtrip':
hooks('apply')
assert all(p.read_bytes() == after and p.stat().st_mode & 0o777 == 0o775 for p in paths)
hooks('rollback')
assert all(p.read_bytes() == before and p.stat().st_mode & 0o777 == 0o775 for p in paths)
elif case == 'hooks-refuse-complete-scope-before-writing':
paths[1].write_bytes(b'unexpected owner change'); hooks('apply', success=False)
assert paths[0].read_bytes() == before
assert paths[1].read_bytes() == b'unexpected owner change'
elif case == 'hooks-partial-failure-recovers':
paths[1].parent.chmod(0o555); hooks('apply', success=False)
assert all(p.read_bytes() == before for p in paths)
assert all(p.stat().st_mode & 0o777 == 0o775 for p in paths)
elif case == 'hooks-symlink-refused':
paths[1].unlink(); paths[1].symlink_to(paths[0]); hooks('apply', success=False)
assert paths[0].read_bytes() == before
elif case == 'hooks-rollback-unknown-drift-retained':
hooks('apply'); paths[1].write_bytes(b'unknown-drift'); hooks('rollback', success=False)
assert paths[0].read_bytes() == after and paths[1].read_bytes() == b'unknown-drift'
assert all((p.parent / 'protected').read_bytes() == b'unchanged authorization' for p in paths)
assert not list(Path('/data').rglob('.custodian-*'))
elif case == 'real-receive-and-quarantine':
guard(c['combined_command'])
result = json.loads(run(['sh', '/scripts/probe.sh', str(new), '/dispatcher']).stdout)
assert all(value is True for value in result.values())
assert not list(Path('/dev/shm').iterdir())
else: raise ValueError('unknown-case')
print(json.dumps({'case': case, 'passed': True, 'native_acceptance': False}))
def outside():
import argparse
parser = argparse.ArgumentParser(); parser.add_argument('--attempt', required=True)
args = parser.parse_args()
if not args.attempt.isalnum(): raise ValueError('unsafe-attempt')
here = Path(__file__).resolve().parent
receipt = here / ('filesystem-' + args.attempt + '.json')
with receipt.open('x') as f: f.write('{"result":"unverified"}\n')
cfg = json.loads((here / 'inputs.json').read_bytes())
fixtures = Path('/tmp/sync-scanner-native-fixtures'); fixtures.mkdir(exist_ok=True)
guard = cfg['new_guard_command'].copy()
guard[2] = guard[2][len(cfg['guard_command'][2]) + 1:]
control = {'old_directory': cfg['old_dir'], 'new_directory': cfg['new_dir'],
'candidate_sha256': cfg['new_adapter_sha256'], 'provider_sha256': cfg['scanner_sha256'],
'combined_command': guard, 'single_command': guard}
(fixtures / 'control.json').write_text(json.dumps(control))
(fixtures / 'passwd').write_text('gitea:x:1000:1000:Offline fixture:/tmp:/bin/sh\n')
(fixtures / 'group').write_text('gitea:x:1000:\n')
binaries = {'old-adapter': Path('/tmp/sync-scanner-candidate/receive-secret-scan-candidate'),
'new-adapter': Path('/tmp/sync-scanner-candidate/receive-secret-scan-candidate'),
'provider': Path('/home/worsch/.local/lib/repo-manager/gitleaks/8.30.1/gitleaks'),
'dispatcher': here / 'fixture-dispatcher.sh'}
assert sha(binaries['new-adapter'].read_bytes()) == cfg['new_adapter_sha256']
assert sha(binaries['provider'].read_bytes()) == cfg['scanner_sha256']
prefix = ['bwrap', '--unshare-net', '--unshare-pid', '--die-with-parent', '--new-session']
for path in ['/usr', '/lib', '/lib64']: prefix += ['--ro-bind', path, path]
prefix += ['--symlink', 'usr/bin', '/bin', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/dev/shm', '--tmpfs', '/tmp', '--tmpfs', '/data',
'--ro-bind', str(here), '/scripts', '--ro-bind', str(fixtures), '/fixtures', '--dir', '/etc',
'--ro-bind', str(fixtures / 'passwd'), '/etc/passwd', '--ro-bind', str(fixtures / 'group'), '/etc/group']
for name, path in binaries.items(): prefix += ['--ro-bind', str(path), '/' + name]
prefix += ['--clearenv', '--setenv', 'PATH', '/usr/bin:/bin', '--setenv', 'HOME', '/tmp', '--chdir', '/', '--', 'python3', '/scripts/test_filesystem.py', '--inside']
cases = ['guard-new-drift', 'guard-symlink', 'guard-unexpected-mode',
'stage-roundtrip', 'stage-bad-hash-cleanup', 'stage-existing-unowned', 'stage-symlink-refused',
'hooks-roundtrip', 'hooks-refuse-complete-scope-before-writing', 'hooks-partial-failure-recovers',
'hooks-symlink-refused', 'hooks-rollback-unknown-drift-retained', 'real-receive-and-quarantine']
results = []
try:
for case in cases:
started = time.monotonic(); completed = subprocess.run(prefix + [case], capture_output=True, timeout=200)
failure = [line for line in completed.stderr.decode(errors='replace').splitlines() if line.startswith('AssertionError: unexpected-script-status:')]
results.append({'case': case, 'returncode': completed.returncode, 'seconds': round(time.monotonic()-started, 2), **({'failure': failure[-1]} if failure else {})})
receipt.write_text(json.dumps({'result': 'unverified', 'cases': results}, indent=2) + '\n')
if completed.returncode: raise ValueError('offline-case-failed:' + case)
receipt.write_text(json.dumps({'result': 'passed', 'cases': results, 'native_acceptance': False, 'real_remote_refs_changed': False,
'scope': 'Exact new guard, stage/hook scripts, real candidate/Gitleaks receive probes; original guard bytes unchanged.'}, indent=2) + '\n')
finally:
print(json.dumps({'receipt': str(receipt), 'passed': sum(x['returncode'] == 0 for x in results), 'total': len(cases)}))
if __name__ == '__main__':
if len(sys.argv) == 3 and sys.argv[1] == '--inside': inside(sys.argv[2])
else: outside()

View file

@ -0,0 +1,97 @@
"""Guard semantics and recovery tests; no Kubernetes access."""
from pathlib import Path
import copy
import importlib.util
import json
import unittest
from unittest.mock import patch
HERE = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location('scanner_rollout', HERE / 'rollout.py')
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
C = json.loads((HERE / 'inputs.json').read_bytes())
def deployment():
init = [{'name': 'unrelated'}] * (C['guard_index'] + 1)
init[C['guard_index']] = {'name': 'verify-secret-scanner', 'command': C['guard_command']}
volumes = [{'name': 'unrelated'}] * (C['volume_index'] + 1)
volumes[C['volume_index']] = C['volume']
return copy.deepcopy({'metadata': {'uid': C['deployment_uid'], 'resourceVersion': '123'},
'spec': {'strategy': C['strategy'], 'template': {'spec': {
'initContainers': init, 'volumes': volumes,
'containers': [{'name': 'gitea', 'image': 'existing-image', 'env': [{'name': 'public', 'value': 'preserved'}]}]}}}})
def apply_patch(d, operations):
value = copy.deepcopy(d)
for op in operations:
parts = op['path'].strip('/').split('/'); obj = value
for key in parts[:-1]: obj = obj[int(key)] if isinstance(obj, list) else obj[key]
key = int(parts[-1]) if isinstance(obj, list) else parts[-1]
if op['op'] == 'test':
if obj[key] != op['value']: raise ValueError('test-failed')
else: obj[key] = op['value']
return value
class Tests(unittest.TestCase):
def test_forward_and_reverse_change_only_two_selected_fields(self):
before = deployment(); ops = m.patch_for(before, C)
self.assertEqual(sum(o['op'] == 'replace' for o in ops), 2)
after = apply_patch(before, ops)
self.assertEqual(after['spec']['template']['spec']['containers'], before['spec']['template']['spec']['containers'])
self.assertEqual(apply_patch(after, m.patch_for(after, C, True)), before)
def test_identity_guard_volume_strategy_and_resource_version_drift_refused(self):
for key in ('uid', 'guard', 'volume', 'strategy'):
d = deployment(); s = d['spec']['template']['spec']
if key == 'uid': d['metadata']['uid'] = 'other'
elif key == 'guard': s['initContainers'][C['guard_index']]['command'] = ['other']
elif key == 'volume': s['volumes'][C['volume_index']]['configMap']['name'] = 'other'
else: d['spec']['strategy'] = {'type': 'RollingUpdate'}
with self.subTest(key=key), self.assertRaises(RuntimeError): m.patch_for(d, C)
d = deployment(); ops = m.patch_for(d, C); d['metadata']['resourceVersion'] = '124'
with self.assertRaises(ValueError): apply_patch(d, ops)
def test_inventory_requires_all_scanner_hooks_and_preserves_protected_set(self):
repo = '/data/git/gitea-repositories/coulomb/example.git'
raw = ('775 1000 1000 ' + C['old_hook_sha256'] + ' ' + repo + '/hooks/' + m.HOOK + '\n' +
'755 1000 1000 ' + 'a'*64 + ' ' + repo + '/hooks/pre-receive\n').encode()
before = m.parse_inventory(raw, C, {C['old_hook_sha256']})
m.protected_equal(before, copy.deepcopy(before))
changed = copy.deepcopy(before); changed[repo]['pre-receive']['sha256'] = 'b'*64
with self.assertRaises(RuntimeError): m.protected_equal(before, changed)
with self.assertRaises(RuntimeError): m.parse_inventory(raw.splitlines(keepends=True)[1], C, {C['old_hook_sha256']})
with self.assertRaises(RuntimeError): m.parse_inventory(raw + raw, C, {C['old_hook_sha256']})
def test_lost_patch_response_attempts_guarded_recovery_and_retains_failure(self):
class Simulated(m.Rollout):
def __init__(self): super().__init__(C); self.recovered = False
def preflight(self): return deployment(), 'pod', {}
def event(self, name, **kw): self.receipt['events'].append({'event': name, **kw})
def shell(self, *a, **kw): return b'{}'
def probe(self, *a): pass
def migrate(self, *a): return {}
def deployment(self): return deployment()
def k(self, *a, **kw):
if 'patch' in a: raise RuntimeError('lost-response-after-patch')
return b''
def rollback(self, before=None): self.recovered = True
x = Simulated()
with self.assertRaisesRegex(RuntimeError, 'lost-response'): x.apply(Path('/tmp/sync-scanner-candidate/receive-secret-scan-candidate'))
self.assertTrue(x.recovered)
self.assertIn('activation-failed', [e['event'] for e in x.receipt['events']])
self.assertNotIn('activation-verified', [e['event'] for e in x.receipt['events']])
def test_rollback_refuses_unknown_deployment_instead_of_overwriting(self):
class Simulated(m.Rollout):
def target(self): pass
def deployment(self):
d = deployment(); d['spec']['template']['spec']['initContainers'][C['guard_index']]['command'] = ['unknown']
return d
def k(self, *a, **kw): raise AssertionError('must not mutate')
with self.assertRaisesRegex(RuntimeError, 'unknown-guard'): Simulated(C).rollback()
if __name__ == '__main__': unittest.main()

View file

@ -0,0 +1,13 @@
{
"proposal_id": "SYNC-FORGE-READONLY-PREFLIGHT-20261101",
"revision": 1,
"proposal_sha256": "b6a0abfdc98996d5ac2cc0438a7128866255e50c76b584b452bbae3cb1b86816",
"script_sha256": "e41c1183dae2499e62d6762b0714cc60356152494b593a45a868938f439a5f51",
"executed_by": "founder in attended terminal",
"authorization_evidence": "User returned bash /tmp/forge-scanner-preflight.sh output and local receipt in active conversation, 2026-11-02",
"outcome": "succeeded; selected exact identities/artifact/template hashes and allocation returned",
"receipt_ref": "native-r2/preflight-receipt.json",
"native_mutation": false,
"promotion_evidence": false,
"score": "unscored; no signed approval or scored success backfilled"
}

View file

@ -0,0 +1,14 @@
{
"proposal_id": "SYNC-FORGE-READONLY-PREFLIGHT-20261101",
"revision": 1,
"target": "railiance01 forgejo/forge; existing public scanner artifacts and metadata only",
"original_script_sha256": "e41c1183dae2499e62d6762b0714cc60356152494b593a45a868938f439a5f51",
"invocation_script_sha256": "1217346155fa2ea730153c6fbf94f7ec548840cb07fbfa3cfaa61db59fe02d3a",
"expected_result": "selected public artifact/config/guard and node allocation receipt for exact rollout preparation",
"verification": "checks target deployment/pod UID, node address, artifact and hook identities; returns selected metadata only",
"rollback": "not applicable; read-only inspection, no state change",
"cost_estimate_eur": 0,
"risk_estimate_eur": 0,
"estimate_basis": "bounded reads of existing metadata and public scanner binaries only; no new spend or mutation",
"disposition": "requested founder execution; pending result; no exact approval token and no scored success"
}

View file

@ -0,0 +1,57 @@
"""Founder-run read-only scanner preflight; prints only selected public evidence."""
import subprocess,json,hashlib,pathlib,sys
NS='forgejo';NAME='forge';POD='forge-79c7fb84db-k5wtm'
EXPECTED_UID='1faefb26-cccd-4d0e-b509-70a7eb0b4f4d'
POD_UID='2c9f911a-403e-4183-a027-2b8d48742348'
BASE='/data/security/receive-secret-scan/20261027-b735d052a49f'
ADAPTER='b735d052a49fc8e9cca14cddee074c5429352ad71841eb6548459294f15683a2'
SCANNER='88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509'
def k(*args):
q=subprocess.run(['kubectl',*args],capture_output=True,timeout=60)
if q.returncode:raise RuntimeError('read-only-command-failed; raw diagnostics withheld')
return q.stdout
def main():
d=json.loads(k('-n',NS,'get','deployment',NAME,'-o','json'))
if d['metadata']['uid']!=EXPECTED_UID:raise RuntimeError('deployment-identity-drift')
uid=k('-n',NS,'get','pod',POD,'-o','jsonpath={.metadata.uid}').decode()
if uid!=POD_UID:raise RuntimeError('pod-identity-drift')
spec=d['spec']['template']['spec'];inits=spec.get('initContainers',[])
candidates=[c['name'] for c in spec['containers'] if c.get('image')=='code.forgejo.org/forgejo/forgejo:11.0.3-rootless']
if len(candidates)!=1:raise RuntimeError('forge-container-ambiguous')
container=candidates[0]
guards=[(i,x) for i,x in enumerate(inits) if x['name']=='verify-secret-scanner']
if len(guards)!=1:raise RuntimeError('scanner-guard-ambiguous')
index,guard=guards[0]
volumes=spec.get('volumes',[]);selected=[(i,x) for i,x in enumerate(volumes) if x['name']=='secret-scan-template']
if len(selected)!=1:raise RuntimeError('scanner-volume-ambiguous')
vindex,volume=selected[0];cmname=volume['configMap']['name']
if cmname!='forgejo-secret-template-d1df801be39f':raise RuntimeError('template-configmap-drift')
# This exact public ConfigMap contains only the receiver hook, per owner source.
hook=k('-n',NS,'get','configmap',cmname,'-o','jsonpath={.data.hook}')
expected=('#!/bin/sh\nset +vx\nset -eu\nexec '+BASE+'/receive-secret-scan --scanner '+BASE+'/gitleaks\n').encode()
if hook!=expected:raise RuntimeError('mounted-hook-source-drift')
metadata=k('-n',NS,'exec',POD,'-c',container,'--','sh','-c',
'set -eu; test -f "$1" && test ! -L "$1"; test -f "$2" && test ! -L "$2"; sha256sum "$1" "$2"',
'sh',BASE+'/receive-secret-scan',BASE+'/gitleaks').decode().splitlines()
hashes=[s.split()[0] for s in metadata]
if hashes!=[ADAPTER,SCANNER]:raise RuntimeError('installed-scanner-artifact-drift')
nodes=json.loads(k('get','nodes','-o','json'))['items']
if len(nodes)!=1 or not any(a['address']=='92.205.62.239' for a in nodes[0]['status']['addresses']):raise RuntimeError('node-target-drift')
description=k('describe','node',nodes[0]['metadata']['name']).decode()
allocation=description.split('Allocated resources:',1)[1].split('Events:',1)[0].strip() if 'Allocated resources:' in description else None
if allocation is None:raise RuntimeError('node-headroom-unavailable')
receipt=dict(read_only=True,deployment_uid=EXPECTED_UID,pod=POD,pod_uid=POD_UID,
deployment_name=NAME,container=container,node_uid=nodes[0]['metadata']['uid'],node_name=nodes[0]['metadata']['name'],
guard_index=index,guard_command=guard.get('command'),guard_args=guard.get('args'),volume_index=vindex,
volume=volume,strategy=d['spec'].get('strategy'),resources=[dict(name=c['name'],resources=c.get('resources')) for c in spec['containers']],
old_configmap=cmname,old_hook_sha256=hashlib.sha256(hook).hexdigest(),old_adapter_sha256=ADAPTER,scanner_sha256=SCANNER,
allocated_resources=allocation)
pathlib.Path('/tmp/forge-scanner-preflight-receipt.json').write_text(json.dumps(receipt,indent=2)+'\n')
# Keep long public shell commands in the local receipt; return concise checks.
print(json.dumps({k:receipt[k] for k in ['read_only','deployment_name','deployment_uid','pod_uid','node_uid','old_configmap','old_adapter_sha256','strategy','allocated_resources']},indent=2))
print('receipt: /tmp/forge-scanner-preflight-receipt.json')
try:main()
except Exception as exc:
print('preflight refused:',str(exc) if isinstance(exc,RuntimeError) else type(exc).__name__)
sys.exit(1)