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
14 changes: 11 additions & 3 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, SigningRegressionProps,
SigningRepoProps, SigningRowProps,
AdmissionViewProps, DecisionRowProps, GateStatus, RepoStrength, SigningPosture,
SigningRegressionProps, SigningRepoProps, SigningRowProps,
};

/// Render the Admission view: the tallies header, then the per-image signing inventory, then the
Expand Down Expand Up @@ -94,7 +94,15 @@ fn signing_inventory(v: &AdmissionViewProps) -> Markup {
fn signing_repo(g: &SigningRepoProps) -> Markup {
html! {
div.signing-repo {
h4.signing-repo-h.t-data-strong { (g.repo) }
div.signing-repo-head {
h4.signing-repo-h.t-data-strong { (g.repo) }
@if g.strength != RepoStrength::Unknown {
span.signing-strength.t-micro.muted data-strength=(g.strength.token())
title="whether the public transparency log corroborates this repo's signing history (JEF-266)" {
(g.strength.word())
}
}
}
@if let Some(regression) = &g.regression {
(signing_regression(regression))
}
Expand Down
4 changes: 2 additions & 2 deletions engine/src/engine/dashboard/view_model/props/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,6 @@ pub struct AdmissionViewProps {
// unchanged for every consumer.
mod signing;
pub use signing::{
RegressionKind, SignerProps, SigningPosture, SigningRegressionProps, SigningRepoProps,
SigningRowProps,
RegressionKind, RepoStrength, SignerProps, SigningPosture, SigningRegressionProps,
SigningRepoProps, SigningRowProps,
};
66 changes: 66 additions & 0 deletions engine/src/engine/dashboard/view_model/props/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,50 @@ pub struct SigningRowProps {
pub count: u64,
}

/// The strength of a repo's learned signing baseline (JEF-266, ADR-0020 §4): whether the public
/// Rekor transparency log corroborates its history (real provenance) or it rests on local
/// trust-on-first-sight alone. Surfaced as a small header badge so the operator can weigh a
/// baseline's evidence honestly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoStrength {
/// The public transparency log vouches for this repo's signing history — a STRONGER baseline.
LogCorroborated,
/// Trust-on-first-local-sight only — the weaker default, and the only state when the Rekor lane
/// is off. Honestly flagged so a fresh baseline is not read as ground truth.
LocalOnly,
/// No strength row observed for this repo (no learned baseline yet) — no badge.
Unknown,
}

impl RepoStrength {
/// Parse the strength row's low-cardinality word (`log-corroborated` / `local-only`).
pub fn parse(word: &str) -> RepoStrength {
match word {
"log-corroborated" => RepoStrength::LogCorroborated,
"local-only" => RepoStrength::LocalOnly,
_ => RepoStrength::Unknown,
}
}

/// The CSS token suffix + `data-strength` value for this strength (fixed, never untrusted text).
pub fn token(self) -> &'static str {
match self {
RepoStrength::LogCorroborated => "corroborated",
RepoStrength::LocalOnly => "local",
RepoStrength::Unknown => "unknown",
}
}

/// The badge word shown in the repo header.
pub fn word(self) -> &'static str {
match self {
RepoStrength::LogCorroborated => "log-corroborated",
RepoStrength::LocalOnly => "new baseline (local only)",
RepoStrength::Unknown => "",
}
}
}

/// A repo group in the signing inventory: one registry/repo header with the images observed under
/// it (JEF-262 — the inventory unit is the image, grouped under its repo), plus an optional loud
/// signing-regression banner (JEF-264) when the repo's signed history has drifted.
Expand All @@ -133,6 +177,9 @@ pub struct SigningRepoProps {
/// A standing signing regression against this repo's baseline (JEF-264), rendered as the LOUD
/// channel above the image rows; `None` when the repo is continuous.
pub regression: Option<SigningRegressionProps>,
/// The strength of this repo's baseline (JEF-266): log-corroborated vs local-only, rendered as
/// a small header badge. [`RepoStrength::Unknown`] when no baseline strength was observed.
pub strength: RepoStrength,
}

/// Which kind of signing regression a repo drifted into (JEF-264) — the presentation mirror of the
Expand All @@ -146,6 +193,12 @@ pub enum RegressionKind {
Invalid,
/// A repo is now signed by an identity never before seen under it (a new signer).
IdentityChange,
/// Registry↔log divergence (JEF-266): the registry serves a signature the public transparency
/// log has NO entry for (a signature that never reached the append-only log).
DivergenceRegistrySigned,
/// Registry↔log divergence (JEF-266): the transparency log records a signature the registry now
/// serves UNSIGNED (a signature stripped at the registry while the log remembers it).
DivergenceLogSigned,
}

impl RegressionKind {
Expand All @@ -156,6 +209,8 @@ impl RegressionKind {
match word {
"invalid" => RegressionKind::Invalid,
"identity" => RegressionKind::IdentityChange,
"divergence-registry" => RegressionKind::DivergenceRegistrySigned,
"divergence-log" => RegressionKind::DivergenceLogSigned,
_ => RegressionKind::Unsigned,
}
}
Expand All @@ -166,6 +221,8 @@ impl RegressionKind {
RegressionKind::Unsigned => "unsigned",
RegressionKind::Invalid => "invalid",
RegressionKind::IdentityChange => "identity",
RegressionKind::DivergenceRegistrySigned => "divergence-registry",
RegressionKind::DivergenceLogSigned => "divergence-log",
}
}

Expand All @@ -176,6 +233,9 @@ impl RegressionKind {
RegressionKind::Unsigned => "signing regression \u{2014} now unsigned",
RegressionKind::Invalid => "signing regression \u{2014} now invalid signature",
RegressionKind::IdentityChange => "signing regression \u{2014} new signer",
RegressionKind::DivergenceRegistrySigned | RegressionKind::DivergenceLogSigned => {
"signing regression \u{2014} registry\u{2194}log divergence"
}
}
}

Expand All @@ -186,6 +246,12 @@ impl RegressionKind {
RegressionKind::Unsigned => "no signature present",
RegressionKind::Invalid => "signature present but does not verify",
RegressionKind::IdentityChange => "signed by a new identity",
RegressionKind::DivergenceRegistrySigned => {
"registry serves a signature absent from the public transparency log"
}
RegressionKind::DivergenceLogSigned => {
"the transparency log records a signature the registry now serves unsigned"
}
}
}
}
Expand Down
47 changes: 41 additions & 6 deletions engine/src/engine/dashboard/view_model/signing_inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
use crate::engine::policy_log::PolicyDecisionRecord;

use super::props::{
RegressionKind, SignerProps, SigningPosture, SigningRegressionProps, SigningRepoProps,
SigningRowProps,
RegressionKind, RepoStrength, SignerProps, SigningPosture, SigningRegressionProps,
SigningRepoProps, SigningRowProps,
};

/// The subject prefix the signing sweep keys its posture rows under (`Image/<ref>`). A row whose
Expand All @@ -29,6 +29,11 @@ const IMAGE_SUBJECT_PREFIX: &str = "Image/";
/// partitioned out of the decision tallies and feeds the repo group's regression banner.
const REGRESSION_SUBJECT_PREFIX: &str = "SigningRegression/";

/// The subject prefix the sweep keys a per-repo baseline-**strength** row under (`SigningStrength/
/// <repo>`, JEF-266) — one row per repo, log-corroborated vs local-only. A signing row (not a
/// webhook decision), partitioned out of the tallies and feeding the repo group's strength badge.
const STRENGTH_SUBJECT_PREFIX: &str = "SigningStrength/";

/// The signature-column prefix marking a regression row's drift token (`regression-<kind>-
/// <strength>`), written by `engine::signing_sweep::regression_record`.
const REGRESSION_STATUS_PREFIX: &str = "regression-";
Expand All @@ -41,7 +46,7 @@ const BEFORE_SEP: &str = " | before: ";
/// regression finding (`SigningRegression/<repo>`) — as opposed to a webhook workload decision.
/// Both are partitioned out of the Admission view's decision tallies.
pub(super) fn is_inventory_row(r: &PolicyDecisionRecord) -> bool {
is_observation_row(r) || is_regression_row(r)
is_observation_row(r) || is_regression_row(r) || is_strength_row(r)
}

/// Whether a record is a per-image posture observation row (`Image/<ref>`).
Expand All @@ -54,6 +59,11 @@ fn is_regression_row(r: &PolicyDecisionRecord) -> bool {
r.subject.starts_with(REGRESSION_SUBJECT_PREFIX)
}

/// Whether a record is a per-repo baseline-strength row (`SigningStrength/<repo>`, JEF-266).
fn is_strength_row(r: &PolicyDecisionRecord) -> bool {
r.subject.starts_with(STRENGTH_SUBJECT_PREFIX)
}

/// Split an image ref into `(repo, remainder)`: the digest form `repo@sha256:…` splits at the `@`;
/// the tag form `repo:tag` splits at the `:` in the LAST path segment (so a registry port —
/// `registry:5000/org/app` — is never mistaken for a tag). A bare ref with neither has an empty
Expand Down Expand Up @@ -238,11 +248,28 @@ pub(super) fn counts(rows: &[PolicyDecisionRecord]) -> (usize, usize) {
(established, cold)
}

/// The standing baseline strength per repo (JEF-266), newest wins — `(repo, strength)`. Only
/// `log-corroborated` / `local-only` words map to a badge; anything else is skipped.
fn strengths_by_repo(rows: &[PolicyDecisionRecord]) -> Vec<(String, RepoStrength)> {
let mut out: Vec<(String, RepoStrength)> = Vec::new();
for record in rows.iter().filter(|r| is_strength_row(r)) {
let Some(repo) = record.subject.strip_prefix(STRENGTH_SUBJECT_PREFIX) else {
continue;
};
let strength = RepoStrength::parse(&record.signature);
if strength != RepoStrength::Unknown && !out.iter().any(|(existing, _)| existing == repo) {
out.push((repo.to_string(), strength));
}
}
out
}

/// Build the signing inventory from the admission-decision log rows: the observation rows (`Image/
/// <ref>`) grouped under their repo (JEF-262), each repo carrying its standing signing-regression
/// banner (`SigningRegression/<repo>`, JEF-264) when one stands. Repo groups preserve first-seen
/// order (the caller passes newest-first rows), so a steady inventory renders stably. The webhook's
/// workload decision rows are ignored — they drive the decision log, not the inventory.
/// banner (`SigningRegression/<repo>`, JEF-264) when one stands and its baseline-strength badge
/// (`SigningStrength/<repo>`, JEF-266). Repo groups preserve first-seen order (the caller passes
/// newest-first rows), so a steady inventory renders stably. The webhook's workload decision rows
/// are ignored — they drive the decision log, not the inventory.
pub(super) fn build(rows: &[PolicyDecisionRecord]) -> Vec<SigningRepoProps> {
let mut groups: Vec<SigningRepoProps> = Vec::new();
for record in rows.iter().filter(|r| is_observation_row(r)) {
Expand All @@ -254,6 +281,7 @@ pub(super) fn build(rows: &[PolicyDecisionRecord]) -> Vec<SigningRepoProps> {
repo: repo.to_string(),
images: vec![row],
regression: None,
strength: RepoStrength::Unknown,
}),
}
}
Expand All @@ -266,9 +294,16 @@ pub(super) fn build(rows: &[PolicyDecisionRecord]) -> Vec<SigningRepoProps> {
repo,
images: Vec::new(),
regression: Some(regression),
strength: RepoStrength::Unknown,
}),
}
}
// Attach each repo's baseline strength badge (JEF-266) to its existing group.
for (repo, strength) in strengths_by_repo(rows) {
if let Some(group) = groups.iter_mut().find(|g| g.repo == repo) {
group.strength = strength;
}
}
groups
}

Expand Down
95 changes: 95 additions & 0 deletions engine/src/engine/dashboard/view_model/signing_inventory/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,98 @@ fn counts_are_per_repo_not_per_row() {
RegressionKind::Unsigned
);
}

// ---- JEF-266: baseline strength badge + registry↔log divergence surfacing ---------------------

/// A per-repo baseline-strength row exactly as `engine::signing_baseline_strength` records it.
fn strength(repo: &str, word: &str) -> PolicyDecisionRecord {
PolicyDecisionRecord::now(
"signing-strength",
"allow",
format!("SigningStrength/{repo}"),
repo,
word,
"",
"",
"first_seen:0",
)
}

#[test]
fn log_corroborated_strength_surfaces_on_the_repo_group() {
let rows = vec![
observed("ghcr.io/acme/app@sha256:abc", "signed", "signed by x"),
strength("ghcr.io/acme/app", "log-corroborated"),
];
let groups = build(&rows);
assert_eq!(groups[0].strength, RepoStrength::LogCorroborated);
assert_eq!(groups[0].strength.word(), "log-corroborated");
}

#[test]
fn local_only_strength_is_the_honest_weaker_default() {
let rows = vec![
observed("ghcr.io/acme/app@sha256:abc", "signed", "signed by x"),
strength("ghcr.io/acme/app", "local-only"),
];
let groups = build(&rows);
assert_eq!(groups[0].strength, RepoStrength::LocalOnly);
assert_eq!(groups[0].strength.word(), "new baseline (local only)");
}

#[test]
fn a_repo_without_a_strength_row_has_no_badge() {
let rows = vec![observed(
"ghcr.io/acme/app@sha256:abc",
"signed",
"signed by x",
)];
assert_eq!(build(&rows)[0].strength, RepoStrength::Unknown);
}

#[test]
fn strength_rows_are_partitioned_out_of_the_inventory_images() {
// A strength row must not become a phantom image row under the repo.
let rows = vec![
observed("ghcr.io/acme/app@sha256:abc", "signed", "signed by x"),
strength("ghcr.io/acme/app", "log-corroborated"),
];
let groups = build(&rows);
assert_eq!(
groups[0].images.len(),
1,
"only the observed image is a row"
);
}

#[test]
fn divergence_findings_render_through_the_regression_channel() {
// Both directions ride the SigningRegression channel with a distinct divergence kind + reason.
let registry_dir = vec![regression(
"ghcr.io/acme/app",
"ghcr.io/acme/app:2",
"regression-divergence-registry-established",
"registry\u{2194}log divergence: the registry serves a signature the public transparency \
log has no entry for | before: a",
)];
let groups = build(&registry_dir);
assert_eq!(
groups[0].regression.as_ref().unwrap().kind,
RegressionKind::DivergenceRegistrySigned
);

let log_dir = vec![regression(
"ghcr.io/acme/app",
"ghcr.io/acme/app:2",
"regression-divergence-log-cold",
"registry\u{2194}log divergence: the transparency log records a signature the registry now \
serves unsigned | before: a",
)];
let groups = build(&log_dir);
let reg = groups[0].regression.as_ref().unwrap();
assert_eq!(reg.kind, RegressionKind::DivergenceLogSigned);
assert!(
!reg.established,
"the cold-strength divergence is a weak lead"
);
}
6 changes: 6 additions & 0 deletions engine/src/engine/journal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ pub enum Decision {
/// `false` is a freshly-learned baseline (weaker evidence).
#[serde(default)]
established: bool,
/// Whether the public Rekor transparency log corroborates this repo's signing history
/// (JEF-266, ADR-0020 §4) — `true` is real provenance read from the append-only log
/// (stronger than local-only TOFU). `#[serde(default)]` so lines predating the Rekor lane
/// replay as local-only (`false`), never a fabricated corroboration.
#[serde(default)]
log_corroborated: bool,
},
}

Expand Down
10 changes: 10 additions & 0 deletions engine/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -969,5 +969,15 @@ pub mod signing_sweep;
// sweep can surface an audit-only signing-regression finding on drift from the baseline.
pub mod signing_drift;

// The per-repo signing-baseline strength row (ADR-0020 §4, JEF-266): surfaces whether a repo's
// baseline is log-corroborated (Rekor vouches for it) or local-only (weaker TOFU) in the inventory.
pub mod signing_baseline_strength;

// The opt-in Rekor transparency-log lane (ADR-0020 §4, JEF-266): after the sweep observes each
// image, corroborates the repo baseline against the public signing history (marking it stronger
// than local-only TOFU) and surfaces registry↔log divergence as a finding. OFF by default —
// zero egress preserved unless the operator enables it.
pub mod signing_rekor;

#[cfg(test)]
mod tests;
Loading
Loading