From a09536f33242f7146b42d28a7c0ef8a876a1021f Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 1 Jul 2026 00:13:05 -0700 Subject: [PATCH] feat(signature): surface an audit-only signing-regression finding on drift from the repo baseline (JEF-264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo with an established signed history suddenly serving unsigned/invalid, or signed by a NEW identity, is the push-access-compromise signal — invisible until now even with the JEF-263 baseline. This adds a pure, deterministic drift classifier over (baseline, fresh posture) and surfaces a loud, audit-only signing-regression finding on drift, still admitting (shadow invariant). - Classifier (engine/signing_drift.rs): pure Continuous / Regression{Unsigned | Invalid | IdentityChange} / NewRepo over (SigningBaseline, SigningPosture), reading the baseline's `established` flag for reduced-intensity on cold baselines. Never a clock/IO — exhaustively unit-tested. - Sweep wiring: classify each observed posture against the CURRENT baseline BEFORE learning, and record a regression finding onto the same admission log (SigningRegression/, decision `allow` — never denies). No egress; not journaled, rebuilt per pass. - Render: a loud "signing regression" banner on the repo group (breach rail + ● glyph + word, lexically distinct from calm "not signed"), stating before→after with BOTH identities in FULL. Every untrusted Fulcio SAN escaped via maud interpolation only (no PreEscaped, no class= from untrusted text). - Status-strip honesty: established regressions count toward breach, cold ones toward uncertain — both forbid green — wired WITHOUT the reachability Decision/Finding pipeline, via SigningRegressionCounts folded into the strip on every tab. - Split props.rs into props/{mod,signing}.rs to stay under the 1,000-line cap. Tests: classifier (12, pure), sweep detection (6: →unsigned/→invalid/identity/ normal-redeploy-none/cold-start-none/cold-reduced), inventory mapping + counts (8), regression render + crafted-identity escaping (3), strip honesty (2). Closes JEF-264 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- engine/examples/dashboard_preview.rs | 15 ++ .../src/engine/dashboard/admission_tests.rs | 84 ++++++ .../dashboard/components/admission_view.rs | 88 +++++- .../dashboard/components/status_strip.rs | 18 +- engine/src/engine/dashboard/mod.rs | 23 +- .../dashboard/view_model/action/tests.rs | 2 + .../dashboard/view_model/admission/tests.rs | 2 + engine/src/engine/dashboard/view_model/mod.rs | 10 + .../view_model/{props.rs => props/mod.rs} | 176 ++++-------- .../dashboard/view_model/props/signing.rs | 217 +++++++++++++++ .../dashboard/view_model/signing_inventory.rs | 145 +++++++++- .../view_model/signing_inventory/tests.rs | 190 +++++++++++++ .../src/engine/dashboard/view_model/strip.rs | 42 +++ engine/src/engine/mod.rs | 5 + engine/src/engine/signing_drift.rs | 160 +++++++++++ engine/src/engine/signing_drift_tests.rs | 176 ++++++++++++ engine/src/engine/signing_sweep.rs | 251 +++++++++++++++++- 17 files changed, 1437 insertions(+), 167 deletions(-) rename engine/src/engine/dashboard/view_model/{props.rs => props/mod.rs} (82%) create mode 100644 engine/src/engine/dashboard/view_model/props/signing.rs create mode 100644 engine/src/engine/signing_drift.rs create mode 100644 engine/src/engine/signing_drift_tests.rs diff --git a/engine/examples/dashboard_preview.rs b/engine/examples/dashboard_preview.rs index f7967b5..92bbb97 100644 --- a/engine/examples/dashboard_preview.rs +++ b/engine/examples/dashboard_preview.rs @@ -378,6 +378,21 @@ fn record_signing_inventory(log: &PolicyDecisionLog) { "checking", "signing posture not yet known (registry/log unreachable)", ); + // A signing-regression finding (JEF-264): the api-gateway repo — with an established signed + // history — is now signed by a NEW identity (the push-access-compromise signal). Audit-only: + // the image is still admitted; the loud banner surfaces before→after in full. + log.record(PolicyDecisionRecord::now( + "signing-regression", + "allow", + "SigningRegression/ghcr.io/acme/api-gateway", + "ghcr.io/acme/api-gateway:v1.9.0", + "regression-identity-established", + "", + "", + "signed by https://github.com/acme-forks/api-gateway/.github/workflows/build.yaml@refs/heads/main \ + via https://token.actions.githubusercontent.com | before: \ + https://github.com/acme/api-gateway/.github/workflows/release.yaml@refs/tags/v1.8.2", + )); } /// A representative bake/coverage summary used by the covered scenarios. diff --git a/engine/src/engine/dashboard/admission_tests.rs b/engine/src/engine/dashboard/admission_tests.rs index 78c47c8..57224cf 100644 --- a/engine/src/engine/dashboard/admission_tests.rs +++ b/engine/src/engine/dashboard/admission_tests.rs @@ -303,6 +303,90 @@ fn signing_inventory_escapes_an_attacker_chosen_identity() { assert!(html.contains("<script>"), "the identity is escaped"); } +// ---- signing-regression banner (JEF-264) render tests -------------------------------------- + +/// A signing-regression finding row (JEF-264 shape): `SigningRegression/` subject, the drift +/// token in `signature`, the before→after prose in `reason`. +fn regression_rec(repo: &str, image: &str, signature: &str, reason: &str) -> PolicyDecisionRecord { + PolicyDecisionRecord::now( + "signing-regression", + "allow", + format!("SigningRegression/{repo}"), + image, + signature, + "", + "", + reason, + ) +} + +#[test] +fn signing_regression_renders_loud_word_and_before_after_in_full() { + let rows = vec![regression_rec( + "ghcr.io/acme/app", + "ghcr.io/acme/app:2", + "regression-identity-established", + "signed by https://github.com/evil/app/.github/workflows/pwn.yaml@refs/heads/main via \ + https://token.actions.githubusercontent.com | before: \ + https://github.com/acme/app/.github/workflows/r.yaml@refs/tags/v1", + )]; + let html = render(&rows); + // The loud, lexically-distinct posture word (not the calm "not signed"). + assert!( + html.contains("signing regression"), + "the loud regression word is rendered" + ); + // Both identities in FULL — the before (old signer) and the after (new signer). + assert!( + html.contains("https://github.com/acme/app/.github/workflows/r.yaml@refs/tags/v1"), + "the before signer is shown in full" + ); + assert!( + html.contains("https://github.com/evil/app/.github/workflows/pwn.yaml@refs/heads/main"), + "the after signer is shown in full" + ); +} + +#[test] +fn signing_regression_cold_baseline_reads_as_a_weak_lead() { + let rows = vec![regression_rec( + "ghcr.io/acme/app", + "ghcr.io/acme/app:2", + "regression-unsigned-cold", + "now not signed (was signed) | before: releng@acme.example", + )]; + let html = render(&rows); + assert!(html.contains("signing regression")); + assert!( + html.contains("treat as a lead"), + "a cold-baseline regression is honestly flagged a weak lead" + ); +} + +#[test] +fn signing_regression_escapes_an_attacker_chosen_identity() { + // The before/after signer identities are attacker-influenceable Fulcio SANs — a crafted SAN in a + // regression row must not inject markup (maud auto-escape; never PreEscaped). + let evil = ""; + let rows = vec![regression_rec( + "ghcr.io/acme/app", + "ghcr.io/acme/app:2", + "regression-identity-established", + &format!( + "signed by {evil} via https://token.actions.githubusercontent.com | before: {evil}" + ), + )]; + let html = render(&rows); + assert!( + !html.contains("