From 3dc817bd4a0c598a15b8a006674a824917fdb984 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 1 Jul 2026 02:24:53 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(signature):=20bootstrap=20the=20TOFU?= =?UTF-8?q?=20baseline=20from=20Rekor=20+=20detect=20registry=E2=86=94log?= =?UTF-8?q?=20divergence=20(JEF-266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ADR-0020 §4 (the transparency-log element). Two capabilities over the public Rekor log, behind an opt-in lane that is OFF by default (zero-egress preserved): 1. History bootstrap — query Rekor for a repo/identity's prior signing entries and seed/strengthen the JEF-263 per-repo baseline from the public history, so a fresh protector inherits real provenance instead of pure local TOFU. A Rekor-corroborated baseline is marked stronger than a local-only one (signing_baseline_strength) and the JEF-262 inventory renders "log-corroborated" vs "local only". 2. Divergence detection — signature-in-Rekor but registry serves unsigned/different (or the reverse) => a "registry↔log divergence" drift finding through JEF-264's regression channel. Lane gating: PROTECTOR_REKOR_ENABLE (off => lane never constructed, no query leaves, inventory/baseline/drift all work as before); PROTECTOR_REKOR_URL for a self-hosted mirror; bounded by timeout + cache-TTL + max-response-bytes. Only image identifiers leak to the public log operator (already-public); the security graph/evidence never leave. Sanctioned-egress carve-out per the ADR-0015 amendment + ADR-0020 §4. sigstore-rs Rekor: called Rekor's REST search API directly (index/retrieve + log/entries) via a bounded reqwest client rather than the crate client. Tests: rekor client + history-seed + no-history fallback + divergence both directions + unreachable-degrade + query-cache. cargo fmt/build/clippy --all-targets/test all green (469 lib + 9 dashboard_guards + file-size guard). Closes JEF-266 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- .../dashboard/components/admission_view.rs | 14 +- .../engine/dashboard/view_model/props/mod.rs | 4 +- .../dashboard/view_model/props/signing.rs | 66 ++++ .../dashboard/view_model/signing_inventory.rs | 47 ++- .../view_model/signing_inventory/tests.rs | 95 ++++++ engine/src/engine/journal.rs | 6 + engine/src/engine/mod.rs | 10 + engine/src/engine/run_loop.rs | 50 ++- .../src/engine/signing_baseline_strength.rs | 42 +++ engine/src/engine/signing_drift_tests.rs | 1 + engine/src/engine/signing_rekor.rs | 192 ++++++++++++ engine/src/engine/signing_rekor_tests.rs | 284 ++++++++++++++++++ engine/src/engine/signing_sweep.rs | 29 +- engine/src/engine/state/signing_baseline.rs | 29 ++ .../engine/state/signing_baseline_tests.rs | 37 +++ engine/src/policies/signature/mod.rs | 2 + engine/src/policies/signature/rekor.rs | 266 ++++++++++++++++ engine/src/policies/signature/rekor_tests.rs | 140 +++++++++ engine/web/dist/dashboard.css | 11 +- 19 files changed, 1309 insertions(+), 16 deletions(-) create mode 100644 engine/src/engine/signing_baseline_strength.rs create mode 100644 engine/src/engine/signing_rekor.rs create mode 100644 engine/src/engine/signing_rekor_tests.rs create mode 100644 engine/src/policies/signature/rekor.rs create mode 100644 engine/src/policies/signature/rekor_tests.rs diff --git a/engine/src/engine/dashboard/components/admission_view.rs b/engine/src/engine/dashboard/components/admission_view.rs index fd357f5..e851806 100644 --- a/engine/src/engine/dashboard/components/admission_view.rs +++ b/engine/src/engine/dashboard/components/admission_view.rs @@ -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 @@ -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)) } diff --git a/engine/src/engine/dashboard/view_model/props/mod.rs b/engine/src/engine/dashboard/view_model/props/mod.rs index 211ab96..21283fc 100644 --- a/engine/src/engine/dashboard/view_model/props/mod.rs +++ b/engine/src/engine/dashboard/view_model/props/mod.rs @@ -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, }; diff --git a/engine/src/engine/dashboard/view_model/props/signing.rs b/engine/src/engine/dashboard/view_model/props/signing.rs index 8998f53..6716526 100644 --- a/engine/src/engine/dashboard/view_model/props/signing.rs +++ b/engine/src/engine/dashboard/view_model/props/signing.rs @@ -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. @@ -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, + /// 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 @@ -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 { @@ -156,6 +209,8 @@ impl RegressionKind { match word { "invalid" => RegressionKind::Invalid, "identity" => RegressionKind::IdentityChange, + "divergence-registry" => RegressionKind::DivergenceRegistrySigned, + "divergence-log" => RegressionKind::DivergenceLogSigned, _ => RegressionKind::Unsigned, } } @@ -166,6 +221,8 @@ impl RegressionKind { RegressionKind::Unsigned => "unsigned", RegressionKind::Invalid => "invalid", RegressionKind::IdentityChange => "identity", + RegressionKind::DivergenceRegistrySigned => "divergence-registry", + RegressionKind::DivergenceLogSigned => "divergence-log", } } @@ -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" + } } } @@ -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" + } } } } diff --git a/engine/src/engine/dashboard/view_model/signing_inventory.rs b/engine/src/engine/dashboard/view_model/signing_inventory.rs index 4552215..d1fc956 100644 --- a/engine/src/engine/dashboard/view_model/signing_inventory.rs +++ b/engine/src/engine/dashboard/view_model/signing_inventory.rs @@ -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/`). A row whose @@ -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/ +/// `, 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-- /// `), written by `engine::signing_sweep::regression_record`. const REGRESSION_STATUS_PREFIX: &str = "regression-"; @@ -41,7 +46,7 @@ const BEFORE_SEP: &str = " | before: "; /// regression finding (`SigningRegression/`) — 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/`). @@ -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/`, 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 @@ -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/ /// `) grouped under their repo (JEF-262), each repo carrying its standing signing-regression -/// banner (`SigningRegression/`, 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/`, JEF-264) when one stands and its baseline-strength badge +/// (`SigningStrength/`, 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 { let mut groups: Vec = Vec::new(); for record in rows.iter().filter(|r| is_observation_row(r)) { @@ -254,6 +281,7 @@ pub(super) fn build(rows: &[PolicyDecisionRecord]) -> Vec { repo: repo.to_string(), images: vec![row], regression: None, + strength: RepoStrength::Unknown, }), } } @@ -266,9 +294,16 @@ pub(super) fn build(rows: &[PolicyDecisionRecord]) -> Vec { 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 } diff --git a/engine/src/engine/dashboard/view_model/signing_inventory/tests.rs b/engine/src/engine/dashboard/view_model/signing_inventory/tests.rs index c0c3f28..037873e 100644 --- a/engine/src/engine/dashboard/view_model/signing_inventory/tests.rs +++ b/engine/src/engine/dashboard/view_model/signing_inventory/tests.rs @@ -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(®istry_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" + ); +} diff --git a/engine/src/engine/journal.rs b/engine/src/engine/journal.rs index 8ca5807..a61311b 100644 --- a/engine/src/engine/journal.rs +++ b/engine/src/engine/journal.rs @@ -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, }, } diff --git a/engine/src/engine/mod.rs b/engine/src/engine/mod.rs index 96507d5..fd5b8c5 100644 --- a/engine/src/engine/mod.rs +++ b/engine/src/engine/mod.rs @@ -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; diff --git a/engine/src/engine/run_loop.rs b/engine/src/engine/run_loop.rs index 0fca1a3..4d79103 100644 --- a/engine/src/engine/run_loop.rs +++ b/engine/src/engine/run_loop.rs @@ -104,6 +104,37 @@ fn build_signing_observer() -> Option Option { + use crate::policies::signature::{HttpRekorClient, RekorConfig, RekorLane}; + + let config = RekorConfig::from_env(); + if !config.enabled { + return None; + } + match HttpRekorClient::new(&config) { + Ok(client) => { + tracing::info!( + base_url = %config.base_url, + "rekor transparency-log lane ENABLED (opt-in egress carve-out, ADR-0020 §4)" + ); + Some(RekorLane::new( + std::sync::Arc::new(client), + config.cache_ttl, + )) + } + Err(error) => { + tracing::warn!(%error, "rekor lane unavailable; degrading to local-only (zero egress)"); + None + } + } +} + /// Registry auth for fetching signatures of private images during the running-Pod posture /// sweep — reuses the `PROTECTOR_REGISTRY_*` credentials, mirroring the webhook's /// `registry_auth`. Anonymous unless explicit credentials are supplied. @@ -294,6 +325,13 @@ pub async fn run_watch( // were already running when protector started (no admission event ever replays them). let signing_observer = build_signing_observer(); + // The opt-in Rekor transparency-log lane (ADR-0020 §4, JEF-266): OFF unless + // `PROTECTOR_REKOR_ENABLE` is set, so the default posture stays fully zero-egress. Built once so + // its bounded query cache persists across passes. When enabled, the reconcile pass below + // corroborates repo baselines against the public signing history and surfaces registry↔log + // divergence. + let rekor_lane = build_rekor_lane(); + // The durable per-repo TOFU signing baseline (JEF-263, ADR-0020): learned from the sweep's // observed postures, persisted to (and, here on boot, replayed from) the SAME decision // journal the engine already owns — so a repo's established signed history survives a @@ -449,7 +487,7 @@ pub async fn run_watch( // shared admission-decision log (JEF-261). Bounded by the observer's cache + MAX_IMAGES; // a no-op when no observer is configured. Run before `process` so the inventory reflects // the same snapshot the engine just reasoned over. - super::signing_sweep::sweep( + let signing_map = super::signing_sweep::sweep( signing_observer.as_ref(), &snapshot, &policy_log, @@ -457,6 +495,16 @@ pub async fn run_watch( signing_journal.as_ref(), ) .await; + // Opt-in Rekor reconciliation (JEF-266): corroborate baselines against the public log and + // surface registry↔log divergence. A no-op (zero egress) when the lane is off. + super::signing_rekor::reconcile( + rekor_lane.as_ref(), + &signing_map, + &policy_log, + Some(&mut signing_baselines), + signing_journal.as_ref(), + ) + .await; engine.process(&snapshot).await; } diff --git a/engine/src/engine/signing_baseline_strength.rs b/engine/src/engine/signing_baseline_strength.rs new file mode 100644 index 0000000..cd8de7c --- /dev/null +++ b/engine/src/engine/signing_baseline_strength.rs @@ -0,0 +1,42 @@ +//! The per-repo signing-baseline **strength** row (JEF-266, ADR-0020 §4 render). +//! +//! The inventory (JEF-262) renders per-image posture rows; this adds the per-repo *strength* of the +//! learned baseline behind them: **log-corroborated** (the public transparency log vouches for the +//! repo's signing history — real provenance) vs **local-only** (trust-on-first-local-sight, the +//! weaker default and the ONLY state when the Rekor lane is off). Encoded as a self-describing +//! `SigningStrength/` row on the same admission-decision log the sweep already writes, so the +//! view_model reads it with the existing partition/parse machinery and it works with the lane off. + +use super::policy_log::PolicyDecisionRecord; +use super::state::SigningBaseline; + +/// The subject prefix a per-repo baseline-strength row is keyed under (`SigningStrength/`), +/// one per repo. A signing row (not a webhook decision), so the Admission view_model partitions it +/// out of the admitted/audited/denied tallies exactly like the observation + regression rows. +pub const STRENGTH_SUBJECT_PREFIX: &str = "SigningStrength/"; + +/// The `signature` word marking a log-corroborated baseline (a stronger baseline than local-only). +pub const CORROBORATED_WORD: &str = "log-corroborated"; +/// The `signature` word marking a local-only baseline (weaker TOFU — the lane-off default). +pub const LOCAL_ONLY_WORD: &str = "local-only"; + +/// Encode a repo's baseline strength as a `SigningStrength/` row. The `signature` word is the +/// low-cardinality strength token; `reason` carries `first_seen:` so the render can say "seen +/// signed since …". Decision stays `allow` — this is inventory metadata, never a gate (ADR-0016). +pub fn strength_record(repo: &str, baseline: &SigningBaseline) -> PolicyDecisionRecord { + let word = if baseline.log_corroborated { + CORROBORATED_WORD + } else { + LOCAL_ONLY_WORD + }; + PolicyDecisionRecord::now( + "signing-strength", + "allow", + format!("{STRENGTH_SUBJECT_PREFIX}{repo}"), + repo, + word, + "", + "", + format!("first_seen:{}", baseline.first_seen_ms), + ) +} diff --git a/engine/src/engine/signing_drift_tests.rs b/engine/src/engine/signing_drift_tests.rs index 94a275f..28fcc01 100644 --- a/engine/src/engine/signing_drift_tests.rs +++ b/engine/src/engine/signing_drift_tests.rs @@ -17,6 +17,7 @@ fn baseline(identities: &[&str], established: bool) -> SigningBaseline { issuers: BTreeSet::new(), first_seen_ms: 0, established, + log_corroborated: false, last_updated_ms: 0, } } diff --git a/engine/src/engine/signing_rekor.rs b/engine/src/engine/signing_rekor.rs new file mode 100644 index 0000000..98b20b9 --- /dev/null +++ b/engine/src/engine/signing_rekor.rs @@ -0,0 +1,192 @@ +//! The opt-in Rekor transparency-log reconciliation pass (JEF-266, ADR-0020 §4). +//! +//! Runs AFTER the signing sweep (JEF-261) has observed each running image's posture and learned the +//! local per-repo TOFU baseline (JEF-263). For each observed image it consults the public +//! transparency log (via the bounded, cached [`RekorLane`]) and does two things the local model +//! cannot: +//! +//! 1. **History bootstrap / strength.** A `Signed` image the log already carries an entry for +//! means the repo has *real public provenance*, not just what we happened to observe first +//! locally — so the repo's baseline is marked **log-corroborated** (a stronger baseline than +//! local-only TOFU). This is the direct fix for the cold-start weakness ADR-0020 names. +//! 2. **Registry↔log divergence.** A signature the registry serves but the log has no entry for +//! (`RegistrySignedNotInLog`) — or the reverse, the log holds a signing entry for an image the +//! registry serves unsigned (`LogSignedRegistryUnsigned`) — is tampering neither source +//! reveals alone. It is surfaced as a **divergence finding through JEF-264's regression +//! channel** (a `SigningRegression/` row, distinct reason "registry↔log divergence"), +//! audit-only (still admitted — the shadow invariant, ADR-0016). +//! +//! ## Egress + degrade posture (the critical invariant) +//! +//! The whole pass is a **no-op when the lane is `None`** (the opt-in switch is off) — zero egress, +//! nothing recorded, the inventory/baseline/local-drift all still work. When enabled it makes ONE +//! bounded outbound query per uncached image. A log that is **unreachable/malformed degrades to +//! local-only**: the [`RekorLane`] returns `Err`, this pass skips that image (no corroboration, no +//! divergence) rather than fabricating a clean or a divergence — never a false clean. +//! +//! [`divergence`] is a pure, total function of `(posture, history)` — exhaustively unit-testable. + +use crate::policies::signature::{RekorLane, SigningPosture, repo_key}; + +use super::journal::DecisionJournal; +use super::policy_log::{PolicyDecisionLog, PolicyDecisionRecord}; +use super::signing_baseline_strength::strength_record; +use super::signing_sweep::REGRESSION_SUBJECT_PREFIX; +use super::state::{SigningBaseline, SigningBaselineStore}; +use crate::policies::signature::PostureMap; + +/// A registry↔log disagreement about an image's signature (JEF-266). The two directions are both +/// tampering signals; each is recorded with a distinct drift token so the render can name it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Divergence { + /// The registry serves a verifying signature the public transparency log has NO entry for. + /// (A signature that never made it to the append-only log — or a registry-injected one.) + RegistrySignedNotInLog, + /// The transparency log holds a signing entry for an image the registry now serves UNSIGNED. + /// (A signature dropped/stripped at the registry while the log remembers it.) + LogSignedRegistryUnsigned, +} + +impl Divergence { + /// The direction token embedded in the drift signature (`regression-divergence-- + /// `), parsed back by the inventory render. Low-cardinality, never untrusted text. + fn dir_token(self) -> &'static str { + match self { + Divergence::RegistrySignedNotInLog => "registry", + Divergence::LogSignedRegistryUnsigned => "log", + } + } + + /// The human-facing "after" clause for the finding's reason — the distinct "registry↔log + /// divergence" prose the ticket calls for. + fn after_clause(self) -> &'static str { + match self { + Divergence::RegistrySignedNotInLog => { + "registry\u{2194}log divergence: the registry serves a signature the public \ + transparency log has no entry for" + } + Divergence::LogSignedRegistryUnsigned => { + "registry\u{2194}log divergence: the transparency log records a signature the \ + registry now serves unsigned" + } + } + } +} + +/// Classify a registry↔log disagreement from the local `posture`, the log `history`, and whether +/// the repo is already **log-corroborated** (`repo_corroborated`). PURE + total. +/// +/// * `NotSigned` locally but the log HAS an entry for this artifact ⇒ +/// [`LogSignedRegistryUnsigned`](Divergence::LogSignedRegistryUnsigned) — the log remembers a +/// signature the registry now serves unsigned. Unambiguous: the log entry for this exact +/// artifact is itself the evidence, so no prior corroboration is needed. +/// * `Signed` locally but NO log entry, **and** the repo is already log-corroborated (we KNOW it +/// signs into the log) ⇒ [`RegistrySignedNotInLog`](Divergence::RegistrySignedNotInLog) — a +/// signature that never reached the append-only log for a repo that always logs. +/// * A `Signed` image with no log entry for a repo we have NOT corroborated is NOT divergence — it +/// is the honest **no-history / local-only fallback** (a key-based or never-logged repo), never +/// a false-positive tampering alarm. +/// * agreement, `Invalid` (already its own signal), or the transient `Checking` ⇒ no divergence. +pub fn divergence( + posture: &SigningPosture, + history: &crate::policies::signature::RekorHistory, + repo_corroborated: bool, +) -> Option { + match posture { + SigningPosture::NotSigned if history.signed_in_log => { + Some(Divergence::LogSignedRegistryUnsigned) + } + SigningPosture::Signed(_) if !history.signed_in_log && repo_corroborated => { + Some(Divergence::RegistrySignedNotInLog) + } + _ => None, + } +} + +/// Encode a divergence finding as a `SigningRegression/` row so it rides JEF-264's regression +/// channel (the inventory partitions it out of the decision tallies and renders the loud banner). +/// The signature token is `regression-divergence--` (dir ∈ registry/log, strength ∈ +/// established/cold) — the render parses it back. Decision stays `allow`: audit-only, still +/// admitted (ADR-0016). The baseline signers ("before") are UNTRUSTED Fulcio text, escaped at +/// render. +fn divergence_record( + repo: &str, + image: &str, + div: Divergence, + baseline: Option<&SigningBaseline>, +) -> PolicyDecisionRecord { + let established = baseline.map(|b| b.established).unwrap_or(false); + let strength = if established { "established" } else { "cold" }; + let signature = format!("regression-divergence-{}-{}", div.dir_token(), strength); + let before = baseline + .map(|b| b.identities.iter().cloned().collect::>().join(", ")) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let reason = format!("{} | before: {}", div.after_clause(), before); + PolicyDecisionRecord::now( + "signing-divergence", + "allow", + format!("{REGRESSION_SUBJECT_PREFIX}{repo}"), + image, + signature, + "", + "", + reason, + ) +} + +/// Reconcile this pass's observed postures against the public transparency log (JEF-266). A no-op +/// (zero egress) when `lane` is `None`. Marks corroborated baselines stronger, persists that change, +/// records the (refreshed) strength row, and surfaces divergence findings. A per-image log error +/// degrades that image to local-only (skipped) — never a false clean. +pub async fn reconcile( + lane: Option<&RekorLane>, + map: &PostureMap, + log: &PolicyDecisionLog, + store: Option<&mut SigningBaselineStore>, + journal: &DecisionJournal, +) { + let Some(lane) = lane else { + return; // opt-in switch off ⇒ full zero-egress, nothing consulted. + }; + let Some(store) = store else { + return; // no durable baseline ⇒ nothing to corroborate; divergence still needs a baseline. + }; + + for (image, posture) in map.entries() { + let identity = posture.signer().map(|s| s.identity.as_str()); + let history = match lane.lookup(image, identity).await { + Ok(history) => history, + Err(error) => { + // Unreachable / malformed / unqueryable ⇒ degrade to local-only for this image. + tracing::debug!(%image, %error, "rekor lookup degraded — local-only this pass"); + continue; + } + }; + let repo = repo_key(image); + + // History bootstrap: a signed image the log vouches for makes the repo baseline stronger. + if matches!(posture, SigningPosture::Signed(_)) + && history.signed_in_log + && store.mark_corroborated(&repo) + { + store.persist(journal, &repo); + if let Some(baseline) = store.get(&repo) { + log.record(strength_record(&repo, baseline)); + } + } + + // Divergence: registry and log disagree about this image's signature. The registry-signed + // direction is gated on the repo already being log-corroborated, so a genuinely + // no-history (local-only) signed image is a fallback, never a false-positive alarm. + let baseline = store.get(&repo); + let corroborated = baseline.map(|b| b.log_corroborated).unwrap_or(false); + if let Some(div) = divergence(posture, &history, corroborated) { + log.record(divergence_record(&repo, image, div, baseline)); + } + } +} + +#[cfg(test)] +#[path = "signing_rekor_tests.rs"] +mod tests; diff --git a/engine/src/engine/signing_rekor_tests.rs b/engine/src/engine/signing_rekor_tests.rs new file mode 100644 index 0000000..ca32490 --- /dev/null +++ b/engine/src/engine/signing_rekor_tests.rs @@ -0,0 +1,284 @@ +//! Acceptance tests for the Rekor reconciliation pass (JEF-266): history-seed (corroboration), +//! no-history local-only fallback, divergence in both directions, unreachable degrade, and the +//! off-by-default no-op. The transparency log is a fake [`RekorClient`] keyed by image, so the +//! whole rule table runs with no network. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Result, bail}; +use async_trait::async_trait; + +use super::*; +use crate::engine::journal::DecisionJournal; +use crate::engine::policy_log::PolicyDecisionLog; +use crate::engine::signing_baseline_strength::{CORROBORATED_WORD, STRENGTH_SUBJECT_PREFIX}; +use crate::engine::state::SigningBaselineStore; +use crate::policies::signature::{ + PostureMap, RekorClient, RekorHistory, RekorLane, Signer, SigningPosture, +}; + +const CI: &str = "https://github.com/org/app/.github/workflows/release.yaml@refs/tags/v1"; +const DAY_MS: u64 = 24 * 60 * 60 * 1000; + +/// A fake transparency log keyed by image ref: `Ok(history)` for a scripted entry, or `Err` +/// (unreachable) for an image mapped to `None`. An unmapped image reads as "no log entry". +struct FakeLog { + entries: HashMap>, +} + +#[async_trait] +impl RekorClient for FakeLog { + async fn lookup(&self, image: &str, _identity: Option<&str>) -> Result { + match self.entries.get(image) { + Some(Some(history)) => Ok(history.clone()), + Some(None) => bail!("rekor unreachable"), + None => Ok(RekorHistory::default()), // definitively not in the log + } + } +} + +fn lane(entries: Vec<(&str, Option)>) -> RekorLane { + let fake = FakeLog { + entries: entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + }; + RekorLane::new(Arc::new(fake), Duration::from_secs(3600)) +} + +fn in_log() -> RekorHistory { + RekorHistory { + signed_in_log: true, + identities: vec![], + } +} + +fn not_in_log() -> RekorHistory { + RekorHistory::default() +} + +fn signed(identity: &str) -> SigningPosture { + SigningPosture::Signed(Signer { + identity: identity.to_string(), + issuer: Some("https://token.actions.githubusercontent.com".to_string()), + }) +} + +fn posture_map(pairs: Vec<(&str, SigningPosture)>) -> PostureMap { + let mut map = PostureMap::new(); + for (image, posture) in pairs { + map.record(image, posture); + } + map +} + +/// A store carrying a cold (local-only, not corroborated) baseline for `ghcr.io/org/app`. +fn cold_store() -> SigningBaselineStore { + let mut store = SigningBaselineStore::new(); + store.observe("ghcr.io/org/app@sha256:seed", &signed(CI), 0); + let b = store.get("ghcr.io/org/app").unwrap(); + assert!(!b.log_corroborated && !b.established); + store +} + +fn regression_row( + log: &PolicyDecisionLog, + repo: &str, +) -> Option { + log.snapshot() + .into_iter() + .find(|r| r.subject == format!("{REGRESSION_SUBJECT_PREFIX}{repo}")) +} + +#[tokio::test] +async fn history_seed_marks_the_baseline_log_corroborated() { + // A repo the public log already carries an entry for inherits real provenance: its baseline is + // marked stronger than local-only, and a corroborated strength row surfaces. + let mut store = cold_store(); + let map = posture_map(vec![("ghcr.io/org/app:2", signed(CI))]); + let l = lane(vec![("ghcr.io/org/app:2", Some(in_log()))]); + let log = PolicyDecisionLog::new(); + reconcile( + Some(&l), + &map, + &log, + Some(&mut store), + &DecisionJournal::disabled(), + ) + .await; + assert!( + store.get("ghcr.io/org/app").unwrap().log_corroborated, + "the log-corroborated baseline is stronger than local-only" + ); + let strength = log + .snapshot() + .into_iter() + .find(|r| r.subject == format!("{STRENGTH_SUBJECT_PREFIX}ghcr.io/org/app")) + .expect("a strength row is recorded"); + assert_eq!(strength.signature, CORROBORATED_WORD); + // Corroboration is not a divergence. + assert!(regression_row(&log, "ghcr.io/org/app").is_none()); +} + +#[tokio::test] +async fn no_history_leaves_the_baseline_local_only_without_a_divergence() { + // A signed image the log has no entry for, on a repo we never corroborated, is the honest + // local-only fallback — weaker, but NOT a divergence (no false-positive tampering alarm). + let mut store = cold_store(); + let map = posture_map(vec![("ghcr.io/org/app:2", signed(CI))]); + let l = lane(vec![("ghcr.io/org/app:2", Some(not_in_log()))]); + let log = PolicyDecisionLog::new(); + reconcile( + Some(&l), + &map, + &log, + Some(&mut store), + &DecisionJournal::disabled(), + ) + .await; + assert!( + !store.get("ghcr.io/org/app").unwrap().log_corroborated, + "no log history ⇒ the baseline stays local-only (weaker)" + ); + assert!( + regression_row(&log, "ghcr.io/org/app").is_none(), + "a genuinely no-history signed image is a fallback, never a divergence" + ); +} + +#[tokio::test] +async fn divergence_log_signed_registry_unsigned() { + // The log remembers a signature for an artifact the registry now serves UNSIGNED — tampering. + let mut store = cold_store(); + let map = posture_map(vec![("ghcr.io/org/app:2", SigningPosture::NotSigned)]); + let l = lane(vec![("ghcr.io/org/app:2", Some(in_log()))]); + let log = PolicyDecisionLog::new(); + reconcile( + Some(&l), + &map, + &log, + Some(&mut store), + &DecisionJournal::disabled(), + ) + .await; + let row = regression_row(&log, "ghcr.io/org/app").expect("a divergence finding is recorded"); + assert_eq!(row.signature, "regression-divergence-log-cold"); + assert_eq!(row.decision, "allow", "audit-only — still admitted"); + assert!(row.reason.contains("registry\u{2194}log divergence")); + assert!(row.reason.contains("registry now serves unsigned")); +} + +#[tokio::test] +async fn divergence_registry_signed_not_in_log_on_a_corroborated_repo() { + // A repo we KNOW logs (already corroborated) suddenly serves a signature absent from the log. + let mut store = cold_store(); + assert!(store.mark_corroborated("ghcr.io/org/app")); + let map = posture_map(vec![("ghcr.io/org/app:3", signed(CI))]); + let l = lane(vec![("ghcr.io/org/app:3", Some(not_in_log()))]); + let log = PolicyDecisionLog::new(); + reconcile( + Some(&l), + &map, + &log, + Some(&mut store), + &DecisionJournal::disabled(), + ) + .await; + let row = regression_row(&log, "ghcr.io/org/app").expect("a divergence finding is recorded"); + assert_eq!(row.signature, "regression-divergence-registry-cold"); + assert!(row.reason.contains("no entry for")); +} + +#[tokio::test] +async fn established_baseline_makes_a_divergence_strong() { + // The strength token rides the baseline's established flag, exactly like a regression. + let mut store = SigningBaselineStore::new(); + store.observe("ghcr.io/org/app@sha256:seed", &signed(CI), 0); + store.observe("ghcr.io/org/app@sha256:seed", &signed(CI), 3 * DAY_MS); + assert!(store.get("ghcr.io/org/app").unwrap().established); + let map = posture_map(vec![("ghcr.io/org/app:2", SigningPosture::NotSigned)]); + let l = lane(vec![("ghcr.io/org/app:2", Some(in_log()))]); + let log = PolicyDecisionLog::new(); + reconcile( + Some(&l), + &map, + &log, + Some(&mut store), + &DecisionJournal::disabled(), + ) + .await; + let row = regression_row(&log, "ghcr.io/org/app").unwrap(); + assert_eq!(row.signature, "regression-divergence-log-established"); +} + +#[tokio::test] +async fn an_unreachable_log_degrades_to_local_only_never_a_false_clean() { + // The lane returns Err ⇒ this image is skipped: no corroboration, no divergence, no false clean. + let mut store = cold_store(); + assert!(store.mark_corroborated("ghcr.io/org/app")); + let map = posture_map(vec![("ghcr.io/org/app:2", SigningPosture::NotSigned)]); + let l = lane(vec![("ghcr.io/org/app:2", None)]); // None ⇒ unreachable + let log = PolicyDecisionLog::new(); + reconcile( + Some(&l), + &map, + &log, + Some(&mut store), + &DecisionJournal::disabled(), + ) + .await; + assert!( + regression_row(&log, "ghcr.io/org/app").is_none(), + "an unreachable log never fabricates a divergence" + ); +} + +#[tokio::test] +async fn a_disabled_lane_is_a_no_op_zero_egress() { + let mut store = cold_store(); + let map = posture_map(vec![("ghcr.io/org/app:2", signed(CI))]); + let log = PolicyDecisionLog::new(); + reconcile( + None, + &map, + &log, + Some(&mut store), + &DecisionJournal::disabled(), + ) + .await; + assert!(log.snapshot().is_empty(), "no lane ⇒ nothing recorded"); + assert!(!store.get("ghcr.io/org/app").unwrap().log_corroborated); +} + +#[test] +fn divergence_is_pure_over_posture_history_and_corroboration() { + let signed_posture = signed(CI); + // agreement — both signed + assert_eq!(divergence(&signed_posture, &in_log(), false), None); + // agreement — both unsigned + assert_eq!( + divergence(&SigningPosture::NotSigned, ¬_in_log(), false), + None + ); + // log has it, registry unsigned ⇒ divergence (no corroboration needed) + assert_eq!( + divergence(&SigningPosture::NotSigned, &in_log(), false), + Some(Divergence::LogSignedRegistryUnsigned) + ); + // registry signed, not in log, repo NOT corroborated ⇒ fallback, no divergence + assert_eq!(divergence(&signed_posture, ¬_in_log(), false), None); + // registry signed, not in log, repo corroborated ⇒ divergence + assert_eq!( + divergence(&signed_posture, ¬_in_log(), true), + Some(Divergence::RegistrySignedNotInLog) + ); + // invalid / checking are never divergence + assert_eq!( + divergence(&SigningPosture::InvalidSignature, &in_log(), true), + None + ); + assert_eq!(divergence(&SigningPosture::Checking, &in_log(), true), None); +} diff --git a/engine/src/engine/signing_sweep.rs b/engine/src/engine/signing_sweep.rs index 5df759c..4c50b17 100644 --- a/engine/src/engine/signing_sweep.rs +++ b/engine/src/engine/signing_sweep.rs @@ -31,6 +31,7 @@ use k8s_openapi::api::core::v1::Pod; use super::journal::DecisionJournal; use super::observe::Snapshot; use super::policy_log::{PolicyDecisionLog, PolicyDecisionRecord}; +use super::signing_baseline_strength::strength_record; use super::signing_drift::{RegressionKind, SigningDrift, classify}; use super::state::{SigningBaseline, SigningBaselineStore}; use crate::policies::signature::{PostureMap, SigningObserver, SigningPosture, repo_key}; @@ -210,6 +211,20 @@ fn learn_baselines(store: &mut SigningBaselineStore, journal: &DecisionJournal, store.compact(journal); } +/// Record each observed repo's baseline **strength** (JEF-266) as a `SigningStrength/` row — +/// log-corroborated vs local-only. Written every pass regardless of the Rekor lane, so the +/// inventory shows the honest local-only default when the lane is off; the Rekor reconcile pass +/// refreshes a repo it corroborates. Only repos with a learned baseline (a `Signed` sight) get a +/// row. +fn record_strengths(store: &SigningBaselineStore, log: &PolicyDecisionLog, map: &PostureMap) { + for (image, _) in map.entries() { + let repo = repo_key(image); + if let Some(baseline) = store.get(&repo) { + log.record(strength_record(&repo, baseline)); + } + } +} + /// Run one signing-posture sweep over the snapshot's running pods and record the result. /// A no-op (zero outbound calls, nothing recorded) when no observer is configured — so a /// deploy without signature config behaves exactly as before. Bounded by the observer's @@ -220,19 +235,23 @@ fn learn_baselines(store: &mut SigningBaselineStore, journal: &DecisionJournal, /// `baseline` store + `journal` are wired: a signed image teaches the repo's TOFU baseline, /// which is persisted to (and, on boot, replayed from) the SAME decision journal. This is /// pure learning — never a gate (ADR-0016); drift/enforcement are later stages. +/// +/// Returns the [`PostureMap`] observed this pass so the caller can run the opt-in Rekor +/// reconciliation pass (JEF-266) over the same observations without re-sweeping. An empty map when +/// no observer is configured or there are no running images. pub async fn sweep( observer: Option<&SigningObserver>, snapshot: &Snapshot, log: &Arc, baseline: Option<&mut SigningBaselineStore>, journal: &DecisionJournal, -) { +) -> PostureMap { let Some(observer) = observer else { - return; + return PostureMap::new(); }; let images = snapshot_images(&snapshot.pods); if images.is_empty() { - return; + return PostureMap::new(); } let map = observer.sweep(images).await; record_postures(log, &map); @@ -241,7 +260,11 @@ pub async fn sweep( // a regression / new signer is detected before the observation folds into the baseline. detect_regressions(store, log, &map); learn_baselines(store, journal, &map); + // Surface each repo's baseline strength (JEF-266) — log-corroborated vs local-only. Written + // after learning so the row reflects the freshly-updated baseline. + record_strengths(store, log, &map); } + map } #[cfg(test)] diff --git a/engine/src/engine/state/signing_baseline.rs b/engine/src/engine/state/signing_baseline.rs index b6273ac..df528d1 100644 --- a/engine/src/engine/state/signing_baseline.rs +++ b/engine/src/engine/state/signing_baseline.rs @@ -79,6 +79,12 @@ pub struct SigningBaseline { /// Whether the signed history has matured past the TOFU grace window (see /// [`ESTABLISH_AGE_MS`]). `false` ⇒ a freshly-learned baseline: weaker evidence. pub established: bool, + /// Whether the public Rekor transparency log corroborates this repo's signing history + /// (JEF-266, ADR-0020 §4). `true` ⇒ real provenance read from the append-only log, not just + /// what we happened to observe first locally — a **stronger** baseline than local-only TOFU, + /// which defeats the cold-start weakness. Set only by the opt-in Rekor lane; `false` when the + /// lane is off or the log had no entry, which is the honest "new baseline (local only)" state. + pub log_corroborated: bool, /// When this baseline was last updated (observed or replayed), Unix epoch millis. In-memory /// only (not journaled) — used solely to order eviction. `pub(crate)` so it isn't part of /// the public value shape. @@ -94,6 +100,7 @@ impl SigningBaseline { issuers: self.issuers.iter().cloned().collect(), first_seen_ms: self.first_seen_ms, established: self.established, + log_corroborated: self.log_corroborated, } } } @@ -196,12 +203,30 @@ impl SigningBaselineStore { first_seen_ms: now_ms, // First sight is always cold-start (first_seen == now): weakest evidence. established: false, + // Local observation alone never corroborates against the log — that is the opt-in + // Rekor lane's job (JEF-266). A fresh baseline is local-only until it does. + log_corroborated: false, last_updated_ms: now_ms, }, ); Some(repo) } + /// Mark a repo's baseline as **log-corroborated** by the Rekor transparency log (JEF-266, + /// ADR-0020 §4): the public append-only log carries a signing entry for this repo, so its + /// history is real provenance rather than local trust-on-first-sight. Returns `true` when the + /// flag actually flipped (so the caller persists just that change); `false` if the repo is + /// untracked or already corroborated. Monotonic — corroboration is never un-set by observation. + pub fn mark_corroborated(&mut self, repo: &str) -> bool { + match self.baselines.get_mut(repo) { + Some(baseline) if !baseline.log_corroborated => { + baseline.log_corroborated = true; + true + } + _ => false, + } + } + /// Persist one repo's current baseline to the journal as a full-state line. A no-op if the /// repo isn't tracked. Infallible from the caller's view (a disabled/unwritable journal is /// itself a no-op). @@ -237,6 +262,7 @@ impl SigningBaselineStore { issuers, first_seen_ms, established, + log_corroborated, } = entry.decision { if repo.is_empty() { @@ -250,6 +276,9 @@ impl SigningBaselineStore { issuers: issuers.into_iter().collect(), first_seen_ms, established: matured, + // Corroboration survives a restart — a repo the log vouched for stays + // log-corroborated (monotonic, never re-armed to local-only on replay). + log_corroborated, last_updated_ms: entry.at_ms, }, repo, diff --git a/engine/src/engine/state/signing_baseline_tests.rs b/engine/src/engine/state/signing_baseline_tests.rs index 7868ab7..a511a29 100644 --- a/engine/src/engine/state/signing_baseline_tests.rs +++ b/engine/src/engine/state/signing_baseline_tests.rs @@ -202,6 +202,43 @@ fn baseline_survives_an_engine_restart_round_trip() { cleanup(&path); } +#[test] +fn log_corroboration_is_set_once_and_survives_a_restart() { + // JEF-266: marking a repo log-corroborated flips the flag once, and the stronger baseline + // survives a restart (monotonic — never re-armed to local-only on replay). + let path = temp_path("corroborate"); + { + let journal = DecisionJournal::open(&path); + let mut store = SigningBaselineStore::new(); + let repo = store + .observe( + "ghcr.io/org/app:1", + &signed("id-a", Some("issuer-a")), + 1_000, + ) + .expect("learned"); + assert!( + !store.get(&repo).unwrap().log_corroborated, + "fresh ⇒ local-only" + ); + assert!(store.mark_corroborated(&repo), "first mark flips the flag"); + assert!(!store.mark_corroborated(&repo), "a second mark is a no-op"); + store.persist(&journal, &repo); + } + let reopened = DecisionJournal::open(&path); + let mut restored = SigningBaselineStore::new(); + restored.restore(&reopened); + assert!( + restored.get("ghcr.io/org/app").unwrap().log_corroborated, + "corroboration survives a restart" + ); + assert!( + !restored.mark_corroborated("ghcr.io/nope"), + "marking an untracked repo is a no-op" + ); + cleanup(&path); +} + #[test] fn last_write_wins_on_replay_across_repeated_lines() { // Compaction writes a full-state line each time; replay must keep the LATEST per repo. diff --git a/engine/src/policies/signature/mod.rs b/engine/src/policies/signature/mod.rs index bb2afce..e088c35 100644 --- a/engine/src/policies/signature/mod.rs +++ b/engine/src/policies/signature/mod.rs @@ -11,6 +11,7 @@ mod cosign; pub mod posture; +pub mod rekor; #[cfg(test)] mod tests; @@ -29,6 +30,7 @@ use crate::policy::{Decision, EnforceScope, Policy, ShadowVerdict}; pub use cosign::CosignChecker; pub use posture::{PostureMap, SignatureObserver, Signer, SigningObserver, SigningPosture}; +pub use rekor::{HttpRekorClient, RekorClient, RekorConfig, RekorHistory, RekorLane}; /// Decides whether a single image reference carries a trusted signature. /// diff --git a/engine/src/policies/signature/rekor.rs b/engine/src/policies/signature/rekor.rs new file mode 100644 index 0000000..f4840b5 --- /dev/null +++ b/engine/src/policies/signature/rekor.rs @@ -0,0 +1,266 @@ +//! The Rekor transparency-log lane (JEF-266, ADR-0020 §4 + the ADR-0015 Rekor amendment). +//! +//! This is the ONE outbound call protector adds beyond the registry pull. It is a **deliberate, +//! operator-accepted carve-out of the zero-egress posture** and therefore **opt-in, OFF by +//! default**: with `PROTECTOR_REKOR_ENABLE` unset the lane is never constructed, no query leaves +//! the cluster, and the signature *inventory* (JEF-261/262) + baseline (JEF-263) + local drift +//! (JEF-264) all keep working exactly as before (full zero-egress). Set `PROTECTOR_REKOR_URL` to a +//! self-hosted Rekor mirror to enable the history/divergence checks while still egressing nothing +//! to the public log. +//! +//! What the lane buys (both consumed by [`signing_rekor`](crate::engine::signing_rekor)): +//! +//! * **History bootstrap / strength.** A repo whose signed image the public log already carries +//! an entry for inherits *real provenance* — its TOFU baseline is marked **log-corroborated** +//! (stronger than a purely local first-sight), defeating the cold-start weakness ADR-0020 +//! names. +//! * **Registry↔log divergence.** A signature the registry serves but the log has no entry for +//! (or the reverse — the log holds an entry the registry serves unsigned) is tampering neither +//! source reveals alone → a divergence finding. +//! +//! ## What leaks, and what never does +//! +//! Only image identifiers/digests (already public — pulled from public registries) reach the log +//! operator. The security graph and evidence NEVER leave the cluster. This lane is distinct from — +//! and never routed through — the model-endpoint validator (that is the model's lane). +//! +//! ## Feasibility (recorded per the ticket) +//! +//! `sigstore-rs` 0.14 DOES expose a Rekor search client (`rekor::apis::index_api::search_index`, +//! query by artifact hash / email / public key, behind the `rekor` feature that `cosign` already +//! pulls in). We deliberately query Rekor's REST index (`/api/v1/index/retrieve`) directly with a +//! bounded [`reqwest`] client instead: it gives us tight control over the timeout, the +//! response-size cap, and malformed-response handling the security review requires, without pulling +//! in the heavier entry-body/cert-parsing surface. The client is abstracted behind [`RekorClient`] +//! so the lane's decision logic is exhaustively unit-testable with a fake — the thin HTTP impl is +//! validated against a live/self-hosted Rekor. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Result, bail}; +use async_trait::async_trait; +use tokio::sync::Mutex; + +/// What the transparency log knows about an image's signing, as consulted for one artifact. Every +/// field is derived from UNTRUSTED third-party (the public log) data — bounded here, escaped at +/// render by any consumer. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RekorHistory { + /// The log holds at least one signing entry for this artifact/identity. This is the + /// security-bearing fact: it corroborates a local `Signed` posture, and its ABSENCE against a + /// local `Signed` — or its PRESENCE against a local `NotSigned` — is registry↔log divergence. + pub signed_in_log: bool, + /// Signer identities the log attributes to this artifact, deduped + bounded. May be empty even + /// when [`signed_in_log`](Self::signed_in_log) — identity extraction from entry bodies is + /// best-effort; the presence of an entry is the load-bearing signal. UNTRUSTED — escape at + /// render. + pub identities: Vec, +} + +/// Queries the transparency log for an image's signing history. Abstracted behind a trait — exactly +/// like [`SignatureObserver`](super::SignatureObserver) — so the lane's corroboration/divergence +/// logic is unit-testable with a fake, without reaching the public log. +#[async_trait] +pub trait RekorClient: Send + Sync { + /// Look up `image` (optionally narrowed by the observed signer `identity`) in the log. `Err` + /// only on an infrastructure failure (unreachable / timeout / malformed / no queryable key) — + /// which the lane degrades to local-only (never a false clean, never a false divergence). A + /// definitive "the log has no entry" is `Ok(RekorHistory { signed_in_log: false, .. })`. + async fn lookup(&self, image: &str, identity: Option<&str>) -> Result; +} + +/// Whether an env var reads as truthy (`1` / `true` / `yes` / `on`, case-insensitive). Anything +/// else — including unset — is false, so the lane stays OFF unless explicitly enabled. +fn env_truthy(key: &str) -> bool { + std::env::var(key) + .map(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +/// The Rekor lane's configuration, resolved from the environment. The lane is built ONLY when +/// [`enabled`](Self::enabled) — otherwise no client is constructed and nothing egresses. +#[derive(Debug, Clone)] +pub struct RekorConfig { + /// Opt-in switch (`PROTECTOR_REKOR_ENABLE`). OFF by default — zero egress preserved. + pub enabled: bool, + /// Rekor base URL (`PROTECTOR_REKOR_URL`). Defaults to the public good log; point it at a + /// self-hosted mirror to keep the history/divergence checks while egressing nothing public. + pub base_url: String, + /// Per-query wall-clock budget, so a slow/hung log can't stall the sweep. + pub timeout: Duration, + /// How long a looked-up history stays cached (bounds re-querying an unchanged repo/image each + /// pass — the same TTL discipline the posture observer uses). + pub cache_ttl: Duration, + /// Hard cap on the log response we will read (bytes) — an untrusted third party must not be + /// able to make us allocate unbounded memory. + pub max_response_bytes: usize, +} + +impl RekorConfig { + /// Default public-good Rekor endpoint. + pub const DEFAULT_URL: &'static str = "https://rekor.sigstore.dev"; + + /// Resolve the lane's config from the environment. `PROTECTOR_REKOR_ENABLE` gates the whole + /// lane OFF by default (zero egress); the rest tune it when enabled. + pub fn from_env() -> Self { + let base_url = std::env::var("PROTECTOR_REKOR_URL") + .ok() + .map(|u| u.trim_end_matches('/').to_string()) + .filter(|u| !u.is_empty()) + .unwrap_or_else(|| Self::DEFAULT_URL.to_string()); + Self { + enabled: env_truthy("PROTECTOR_REKOR_ENABLE"), + base_url, + timeout: Duration::from_secs(env_u64("PROTECTOR_REKOR_TIMEOUT", 5)), + cache_ttl: Duration::from_secs(env_u64("PROTECTOR_REKOR_CACHE_TTL", 3600)), + max_response_bytes: env_u64("PROTECTOR_REKOR_MAX_BYTES", 1_048_576) as usize, + } + } +} + +/// Parse a numeric env var, falling back to `default` if unset or unparseable. +fn env_u64(key: &str, default: u64) -> u64 { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +/// The production [`RekorClient`]: a bounded HTTP query against Rekor's REST search index +/// (`/api/v1/index/retrieve`). Times out, caps the response size, and treats a malformed body as an +/// infrastructure error (degrade to local-only) rather than a fabricated "no entry" (which would be +/// a false divergence). +pub struct HttpRekorClient { + client: reqwest::Client, + base_url: String, + timeout: Duration, + max_response_bytes: usize, +} + +impl HttpRekorClient { + /// Build the client from resolved [`RekorConfig`]. The reqwest client carries the per-query + /// timeout as a floor; each call also wraps its own `tokio::time::timeout` so a hung connect + /// can't outlive the budget. + pub fn new(config: &RekorConfig) -> Result { + let client = reqwest::Client::builder().timeout(config.timeout).build()?; + Ok(Self { + client, + base_url: config.base_url.clone(), + timeout: config.timeout, + max_response_bytes: config.max_response_bytes, + }) + } + + /// The query key for an image: prefer the pinned `@sha256:…` digest (the artifact hash Rekor's + /// index is keyed on); fall back to a keyless *email* signer identity. A workflow-URI identity + /// and a tag-only ref have no index key here, so we return `None` and the lane degrades (never + /// a false divergence on an unqueryable ref). + fn query_key(image: &str, identity: Option<&str>) -> Option { + if let Some((_, digest)) = image.split_once('@') + && digest.starts_with("sha256:") + { + return Some(QueryKey::Hash(digest.to_string())); + } + if let Some(id) = identity + && id.contains('@') + && !id.starts_with("http://") + && !id.starts_with("https://") + { + return Some(QueryKey::Email(id.to_string())); + } + None + } +} + +/// What we can key a Rekor index search on for a given image. +enum QueryKey { + /// The pinned artifact digest (`sha256:…`). + Hash(String), + /// A keyless email signer identity. + Email(String), +} + +#[async_trait] +impl RekorClient for HttpRekorClient { + async fn lookup(&self, image: &str, identity: Option<&str>) -> Result { + let Some(key) = Self::query_key(image, identity) else { + bail!("no queryable rekor index key for {image}"); + }; + let body = match &key { + QueryKey::Hash(hash) => serde_json::json!({ "hash": hash }), + QueryKey::Email(email) => serde_json::json!({ "email": email }), + }; + let url = format!("{}/api/v1/index/retrieve", self.base_url); + let resp = + tokio::time::timeout(self.timeout, self.client.post(&url).json(&body).send()).await??; + let status = resp.status(); + if !status.is_success() { + bail!("rekor index query returned {status}"); + } + let text = tokio::time::timeout(self.timeout, resp.text()).await??; + if text.len() > self.max_response_bytes { + bail!("rekor response exceeded {} bytes", self.max_response_bytes); + } + // The index returns a JSON array of entry UUIDs. A malformed body is an infrastructure + // error (degrade to local-only), NEVER an empty result — an empty result would fabricate a + // false "not in log" divergence against a genuinely-signed image. + let uuids: Vec = serde_json::from_str(&text) + .map_err(|e| anyhow::anyhow!("malformed rekor index response: {e}"))?; + Ok(RekorHistory { + signed_in_log: !uuids.is_empty(), + // Identity extraction from entry bodies is out of scope for the thin client; the entry + // presence is the load-bearing signal. + identities: Vec::new(), + }) + } +} + +/// Fronts a [`RekorClient`] with a TTL + image-keyed cache so an unchanged repo/image is NOT +/// re-queried each pass (the ticket's "bounded/cached" requirement). Only definitive `Ok` results +/// are cached; an infrastructure error is deliberately NOT cached, so an unreachable log is retried +/// next pass instead of being frozen into a degraded verdict. Built ONCE (persisting the cache +/// across passes) and only when the lane is enabled — so a disabled lane holds no client and +/// egresses nothing. +pub struct RekorLane { + client: Arc, + cache_ttl: Duration, + cache: Mutex>, +} + +impl RekorLane { + pub fn new(client: Arc, cache_ttl: Duration) -> Self { + Self { + client, + cache_ttl, + cache: Mutex::new(HashMap::new()), + } + } + + /// Look up `image`, serving a fresh cached history without an outbound call. An `Err` + /// (unreachable / malformed / unqueryable) is propagated and NOT cached, so the lane degrades + /// to local-only this pass and retries next pass. + pub async fn lookup(&self, image: &str, identity: Option<&str>) -> Result { + if let Some((history, cached_at)) = self.cache.lock().await.get(image).cloned() + && cached_at.elapsed() < self.cache_ttl + { + return Ok(history); + } + let history = self.client.lookup(image, identity).await?; + self.cache + .lock() + .await + .insert(image.to_string(), (history.clone(), Instant::now())); + Ok(history) + } +} + +#[cfg(test)] +#[path = "rekor_tests.rs"] +mod tests; diff --git a/engine/src/policies/signature/rekor_tests.rs b/engine/src/policies/signature/rekor_tests.rs new file mode 100644 index 0000000..16d71bf --- /dev/null +++ b/engine/src/policies/signature/rekor_tests.rs @@ -0,0 +1,140 @@ +//! Unit tests for the Rekor transparency-log lane (JEF-266): the query-key selection, the +//! TTL cache (don't re-query an unchanged image, retry after an error), and the off-by-default +//! config posture. The corroboration/divergence decision logic is tested where it lives +//! (`engine::signing_rekor`); here we cover the client/cache plumbing. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use anyhow::{Result, bail}; +use async_trait::async_trait; + +use super::{HttpRekorClient, QueryKey, RekorClient, RekorConfig, RekorHistory, RekorLane}; + +/// A fake client that counts calls and returns a scripted result (or an error) — so we can prove +/// the cache serves without an outbound call and that an error is not frozen in. +struct FakeClient { + calls: Arc, + result: Result, +} + +#[async_trait] +impl RekorClient for FakeClient { + async fn lookup(&self, _image: &str, _identity: Option<&str>) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + match &self.result { + Ok(h) => Ok(h.clone()), + Err(()) => bail!("rekor unreachable"), + } + } +} + +fn lane(result: Result, ttl: Duration) -> (RekorLane, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let fake = FakeClient { + calls: calls.clone(), + result, + }; + (RekorLane::new(Arc::new(fake), ttl), calls) +} + +#[tokio::test] +async fn cache_serves_a_repeat_lookup_without_a_second_call() { + let (lane, calls) = lane( + Ok(RekorHistory { + signed_in_log: true, + identities: vec![], + }), + Duration::from_secs(3600), + ); + let first = lane + .lookup("ghcr.io/org/app@sha256:abc", None) + .await + .unwrap(); + assert!(first.signed_in_log); + let second = lane + .lookup("ghcr.io/org/app@sha256:abc", None) + .await + .unwrap(); + assert!(second.signed_in_log); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "the second lookup is served from the cache — no new outbound query" + ); +} + +#[tokio::test] +async fn an_error_is_not_cached_and_is_retried() { + // An unreachable log must degrade AND retry — never freeze a degraded verdict in the cache. + let (lane, calls) = lane(Err(()), Duration::from_secs(3600)); + assert!( + lane.lookup("ghcr.io/org/app@sha256:abc", None) + .await + .is_err() + ); + assert!( + lane.lookup("ghcr.io/org/app@sha256:abc", None) + .await + .is_err() + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "an errored lookup is not cached — the next pass retries the log" + ); +} + +#[tokio::test] +async fn an_expired_cache_entry_is_refetched() { + let (lane, calls) = lane( + Ok(RekorHistory::default()), + Duration::from_millis(0), // every entry is already stale + ); + lane.lookup("ghcr.io/org/app@sha256:abc", None) + .await + .unwrap(); + lane.lookup("ghcr.io/org/app@sha256:abc", None) + .await + .unwrap(); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "a stale cache entry is refetched" + ); +} + +#[test] +fn query_key_prefers_the_pinned_digest() { + let key = HttpRekorClient::query_key("ghcr.io/org/app@sha256:deadbeef", None); + assert!(matches!(key, Some(QueryKey::Hash(h)) if h == "sha256:deadbeef")); +} + +#[test] +fn query_key_falls_back_to_an_email_identity() { + let key = HttpRekorClient::query_key("ghcr.io/org/app:1", Some("dev@example.com")); + assert!(matches!(key, Some(QueryKey::Email(e)) if e == "dev@example.com")); +} + +#[test] +fn query_key_is_none_for_an_unqueryable_ref() { + // A tag-only ref signed by a workflow URI has no index key — the lane must degrade, not + // fabricate a divergence. + let key = HttpRekorClient::query_key( + "ghcr.io/org/app:1", + Some("https://github.com/org/app/.github/workflows/r.yaml@refs/tags/v1"), + ); + assert!(key.is_none()); +} + +#[test] +fn config_is_off_by_default() { + // With PROTECTOR_REKOR_ENABLE unset, the lane is disabled — zero egress preserved. + let config = RekorConfig::from_env(); + assert!( + !config.enabled, + "the Rekor lane is opt-in, OFF by default (zero egress)" + ); + assert_eq!(config.base_url, RekorConfig::DEFAULT_URL); +} diff --git a/engine/web/dist/dashboard.css b/engine/web/dist/dashboard.css index a5529af..b408883 100644 --- a/engine/web/dist/dashboard.css +++ b/engine/web/dist/dashboard.css @@ -884,10 +884,19 @@ summary:focus-visible, enforced" cell is always the binary would-admit / would-block. ---- */ .view > .signing-inventory { margin-bottom: var(--section-gap); } .signing-repo { margin-top: var(--space-4); } +.signing-repo-head { + display: flex; align-items: baseline; gap: var(--space-2); + margin: 0 0 var(--space-1); +} .signing-repo-h { color: var(--ink-1); font-family: var(--font-data); - margin: 0 0 var(--space-1); + margin: 0; } +/* per-repo baseline-strength badge (JEF-266): log-corroborated is the calm cleared channel (the + transparency log vouches for it), local-only is muted (weaker TOFU). Colour + word, never colour + alone. */ +.signing-strength[data-strength="corroborated"] { color: var(--cov-present); } +.signing-strength[data-strength="local"] { color: var(--ink-3); } .signing-row:hover { background: var(--surface-hover); } /* an invalid signature is the loud attention case — a breach keyline (calm not-signed gets none). */ .signing-row-attention td.cell-image { From 22378d36de307bdf9f5d9785faa30794581306f0 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 1 Jul 2026 02:37:50 -0700 Subject: [PATCH 2/2] fix(signature): stream the Rekor response under a running byte cap (no unbounded buffer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review (Medium): the max_response_bytes cap was checked AFTER resp.text() had already buffered the whole body, so a hostile/compromised (but TLS-verified) log endpoint could stream a multi-GB body within the timeout and OOM the engine — a DoS of the security monitor. Replace resp.text() with a chunked accumulate that bails the instant the running size would exceed the cap; memory now stays bounded by max_response_bytes. An outer timeout preserves the original total-read-time bound. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- engine/src/policies/signature/rekor.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/engine/src/policies/signature/rekor.rs b/engine/src/policies/signature/rekor.rs index f4840b5..c3e4e4d 100644 --- a/engine/src/policies/signature/rekor.rs +++ b/engine/src/policies/signature/rekor.rs @@ -198,16 +198,30 @@ impl RekorClient for HttpRekorClient { QueryKey::Email(email) => serde_json::json!({ "email": email }), }; let url = format!("{}/api/v1/index/retrieve", self.base_url); - let resp = + let mut resp = tokio::time::timeout(self.timeout, self.client.post(&url).json(&body).send()).await??; let status = resp.status(); if !status.is_success() { bail!("rekor index query returned {status}"); } - let text = tokio::time::timeout(self.timeout, resp.text()).await??; - if text.len() > self.max_response_bytes { - bail!("rekor response exceeded {} bytes", self.max_response_bytes); - } + // Stream the body under a running byte cap rather than buffering it whole with + // `resp.text()`: the response is untrusted (a hostile or compromised log endpoint), and + // `.text()` would allocate the entire body BEFORE any size check, so a multi-GB body sent + // within the timeout could OOM-kill the engine. Accumulate chunk-by-chunk and bail the + // instant the cap would be exceeded — memory stays bounded by `max_response_bytes`. The + // outer timeout bounds total read time exactly as the previous single-`.text()` timeout did. + let max = self.max_response_bytes; + let text = tokio::time::timeout(self.timeout, async { + let mut buf: Vec = Vec::new(); + while let Some(chunk) = resp.chunk().await? { + if buf.len() + chunk.len() > max { + bail!("rekor response exceeded {max} bytes"); + } + buf.extend_from_slice(&chunk); + } + String::from_utf8(buf).map_err(|e| anyhow::anyhow!("rekor response not utf-8: {e}")) + }) + .await??; // The index returns a JSON array of entry UUIDs. A malformed body is an infrastructure // error (degrade to local-only), NEVER an empty result — an empty result would fabricate a // false "not in log" divergence against a genuinely-signed image.