Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions engine/examples/dashboard_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
84 changes: 84 additions & 0 deletions engine/src/engine/dashboard/admission_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>` 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 = "<script>alert('pwn')</script>";
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("<script>alert"),
"raw script from either identity must not reach output"
);
assert!(
html.contains("&lt;script&gt;"),
"the crafted identity is escaped in the regression banner"
);
}

#[test]
fn signing_inventory_honest_empty_when_no_images_observed() {
// Decision rows present, but no signing-sweep observation rows → the inventory is honestly
Expand Down
88 changes: 75 additions & 13 deletions engine/src/engine/dashboard/components/admission_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
use maud::{Markup, html};

use crate::engine::dashboard::view_model::props::{
AdmissionViewProps, DecisionRowProps, GateStatus, SigningPosture, SigningRepoProps,
SigningRowProps,
AdmissionViewProps, DecisionRowProps, GateStatus, SigningPosture, SigningRegressionProps,
SigningRepoProps, SigningRowProps,
};

/// Render the Admission view: the tallies header, then the per-image signing inventory, then the
Expand Down Expand Up @@ -87,23 +87,85 @@ fn signing_inventory(v: &AdmissionViewProps) -> Markup {
}
}

/// One repo group: the registry/repo header + a real `<table>` of the images observed under it (so
/// the machine columns align — the keyboard/semantics gate).
/// One repo group: the registry/repo header + (when the repo's signed history has drifted) a loud
/// signing-regression banner + a real `<table>` of the images observed under it (so the machine
/// columns align — the keyboard/semantics gate). The table is omitted when the regressed image has
/// aged out of the observation window, so a standing regression still surfaces on its own.
fn signing_repo(g: &SigningRepoProps) -> Markup {
html! {
div.signing-repo {
h4.signing-repo-h.t-data-strong { (g.repo) }
table.signing {
thead {
tr {
th.t-micro { "image" }
th.t-micro { "signature" }
th.t-micro { "if enforced" }
@if let Some(regression) = &g.regression {
(signing_regression(regression))
}
@if !g.images.is_empty() {
table.signing {
thead {
tr {
th.t-micro { "image" }
th.t-micro { "signature" }
th.t-micro { "if enforced" }
}
}
tbody {
@for img in &g.images {
(signing_row(img))
}
}
}
tbody {
@for img in &g.images {
(signing_row(img))
}
}
}
}

/// The loud signing-regression banner (JEF-264, ADR-0020 §3): a repo's signed history drifted —
/// now unsigned/invalid, or signed by a new identity. The breach-rail channel (glyph + word,
/// distinct from calm "not signed"), stating before→after with BOTH identities in FULL. An
/// established baseline reads as the strong signal; a cold baseline is honestly flagged a weak lead.
///
/// Security: every identity/issuer is UNTRUSTED Fulcio SAN, emitted ONLY via maud interpolation
/// `(x)` (auto-escaped) — never `PreEscaped`, never concatenated into markup, and never used to
/// derive a `class=`/CSS value (the `data-regression` attribute is the fixed low-cardinality kind
/// token, not identity text).
fn signing_regression(r: &SigningRegressionProps) -> Markup {
let strength = if r.established {
"established baseline"
} else {
"weak baseline \u{2014} treat as a lead"
};
html! {
div.signing-regression.signing-row-attention data-regression=(r.kind.token()) role="alert" {
div.signing-regression-head {
span.glyph aria-hidden="true" { "\u{25CF}" }
span.signing-regression-word.t-data-strong { (r.kind.word()) }
span.signing-regression-strength.t-micro.muted { "(" (strength) ")" }
}
div.signing-regression-detail {
p.t-data { "image: " span.mono { (r.image) } }
@if r.before_identities.is_empty() {
p.t-data.muted { "before: baseline signer not recorded" }
} @else {
p.t-data {
"before \u{2014} baseline signer"
@if r.before_identities.len() != 1 { "s" }
":"
}
ul.signing-regression-before {
@for identity in &r.before_identities {
li.t-data { span.mono { (identity) } }
}
}
}
@match &r.after_identity {
Some(identity) => {
p.t-data { "after \u{2014} now signed by:" }
p.t-data { span.mono { (identity) } }
@if let Some(issuer) = &r.after_issuer {
p.t-data.muted { "issuer: " span.mono { (issuer) } }
}
}
None => {
p.t-data { "after \u{2014} " (r.kind.after_word()) }
}
}
}
Expand Down
18 changes: 15 additions & 3 deletions engine/src/engine/dashboard/components/status_strip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ fn judging_axis(s: &StatusStripProps) -> Markup {
}
};
}
// Model up but not finished (awaiting/uncertain still open, or a feed degraded): elevated
// "watching" — calm, NOT green. Quiet here is "hasn't finished", not "cleared".
if s.watching() {
// Model up but not finished (awaiting/uncertain still open, or a feed degraded), OR a standing
// signing regression (JEF-264): elevated "watching" — calm, NOT green. A standing regression
// must never read as the bare green-dot "model judging"; the loud count sits in the headline.
if s.watching() || (s.model_is_up() && s.has_signing_regression()) {
return html! {
span.axis.judging.watching {
span.glyph { "\u{25CC}" }
Expand Down Expand Up @@ -166,6 +167,17 @@ fn headline(s: &StatusStripProps) -> Markup {
(s.escalated_count) " escalated since last pass"
}
}
@let regressions = s.signing_regression_breach + s.signing_regression_uncertain;
@if regressions > 0 {
// The signing-regression chip (JEF-264): loud (breach-rail), kept lexically distinct
// from the reachability breach count. A standing regression is why the strip is not
// green — surface it explicitly.
span.count.count-breach.count-regression {
span.glyph aria-hidden="true" { "\u{25CF}" }
(regressions)
@if regressions == 1 { " signing regression" } @else { " signing regressions" }
}
}
}
}
}
23 changes: 19 additions & 4 deletions engine/src/engine/dashboard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,27 +92,42 @@ impl DashboardState {
let findings = self.findings.snapshot();
let judgements = self.judgements.snapshot();
let readiness = self.readiness();
view_model::build_status_strip(
let strip = view_model::build_status_strip(
self.cluster.clone(),
&findings,
&judgements,
&readiness,
self.findings.last_pass(),
)
);
let (breach, uncertain) = self.signing_regression_counts();
strip.with_signing_regressions(breach, uncertain)
}

/// The standing signing-regression counts `(established, cold)` from the admission-decision log
/// (JEF-264) — folded into the persistent strip so a standing regression keeps it non-green on
/// EVERY tab, without routing through the reachability findings pipeline.
fn signing_regression_counts(&self) -> (usize, usize) {
view_model::signing_regression_counts(&self.policy_log.snapshot())
}

/// Build the whole Findings view props from the live state.
fn findings_view(&self) -> FindingsViewProps {
let findings = self.findings.snapshot();
let judgements = self.judgements.snapshot();
let readiness = self.readiness();
view_model::build_findings_view(
let mut view = view_model::build_findings_view(
self.cluster.clone(),
&findings,
&judgements,
&readiness,
self.findings.last_pass(),
)
);
// The findings-derived strip carries no signing regressions of its own; fold in the
// admission-decision log's counts so the Findings strip is non-green when a regression
// stands too (the honesty invariant holds on every tab).
let (breach, uncertain) = self.signing_regression_counts();
view.strip = view.strip.with_signing_regressions(breach, uncertain);
view
}

/// Build the Action view props (the merged Trust + Activity story): the persistent strip + the
Expand Down
2 changes: 2 additions & 0 deletions engine/src/engine/dashboard/view_model/action/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ fn strip() -> StatusStripProps {
uncertain_count: 0,
cleared_count: 0,
escalated_count: 0,
signing_regression_breach: 0,
signing_regression_uncertain: 0,
}
}

Expand Down
2 changes: 2 additions & 0 deletions engine/src/engine/dashboard/view_model/admission/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ fn strip() -> StatusStripProps {
uncertain_count: 0,
cleared_count: 0,
escalated_count: 0,
signing_regression_breach: 0,
signing_regression_uncertain: 0,
}
}

Expand Down
10 changes: 10 additions & 0 deletions engine/src/engine/dashboard/view_model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,13 @@ pub fn build_admission_view(
) -> AdmissionViewProps {
admission::build(strip, rows)
}

/// The standing signing-regression counts `(established, cold)` derived from the admission-decision
/// log's regression rows (`SigningRegression/<repo>`, JEF-264) — established-baseline regressions
/// count toward breach, cold-baseline ones toward uncertain. The caller folds these into the
/// persistent strip (via [`StatusStripProps::with_signing_regressions`]) so a standing regression
/// keeps the strip non-green on EVERY tab, WITHOUT routing through the reachability findings
/// pipeline. Pure given its input.
pub fn signing_regression_counts(rows: &[PolicyDecisionRecord]) -> (usize, usize) {
signing_inventory::counts(rows)
}
Loading