//! A release's trust report (hub spec §9.1): who wrote what the release //! holds and how it is authenticated, what built each asset, in which //! pinned environment, from which inputs, under which signed receipt, and //! an audit of all of it. The page draws it as a graph; the same report is //! JSON for export, carrying the signed receipts so an auditor can check //! them offline with `oxy receipt verify`. use super::{filters, lookup, Error, Page, RepoShell, Shell}; use crate::auth::Viewer; use crate::db::{self, flow as fdb, releases as rdb}; use crate::repos::RepoName; use crate::Shared; use askama::Template; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Response}; use oxy_core::hash::{ChangeHash, ObjectRef}; use oxy_core::identity::{verify, Domain}; use oxy_core::repo::SignatureStatus; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; /// A key as the report names it. #[derive(Clone, Debug, Default, Serialize)] pub struct KeyInfo { pub key: String, pub name: String, pub email: String, /// The account the key is linked to on this hub. pub account: Option, } #[derive(Clone, Debug, Default, Serialize)] pub struct AuthorTrust { pub who: KeyInfo, pub changes: usize, /// Changes authenticated by their own signature. pub signed: usize, /// Changes authenticated by a seal. pub sealed: usize, pub unsigned: usize, /// Authenticated by a key since revoked. pub revoked: usize, pub invalid: usize, } #[derive(Clone, Debug, Serialize)] pub struct BuildTrust { pub run: i64, pub job: String, pub run_url: String, /// The heads the run built, joined with `+`. pub heads: String, /// Whether that is exactly the tagged state. pub same_state_as_tag: bool, pub pipeline_hash: String, pub scope_tree_hash: String, /// Hash of the environment the job ran in (runtime, pinned parts, /// rootfs, argv, env). pub environment: String, pub rootfs: String, pub runtime: String, pub exit_code: Option, pub runner: Option, /// The receipt attestation, when a signed receipt was kept. pub receipt: Option, pub receipt_url: Option, /// Why the kept receipt does not verify now, if it does not. pub receipt_problem: Option, pub runner_revoked: bool, /// The runner's key is one a runner token on this hub signs with. pub runner_known: bool, /// The signed receipt, hex, for `oxy receipt verify`. pub signed_receipt: Option, } #[derive(Clone, Debug, Serialize)] pub struct AssetTrust { pub name: String, pub size: i64, pub blake3: String, pub url: String, pub build: Option, } #[derive(Clone, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Verdict { Pass, Warn, Fail, } #[derive(Clone, Debug, Serialize)] pub struct Check { pub verdict: Verdict, pub title: String, pub detail: String, } impl Check { pub fn glyph(&self) -> &'static str { match self.verdict { Verdict::Pass => "✔", Verdict::Warn => "!", Verdict::Fail => "✘", } } pub fn class(&self) -> &'static str { match self.verdict { Verdict::Pass => "pass", Verdict::Warn => "warn", Verdict::Fail => "fail", } } } #[derive(Clone, Debug, Serialize)] pub struct ReleaseTrust { pub repository: String, pub tag: String, pub title: String, pub release_url: String, pub heads: Vec, pub tag_signer: Option, /// `valid`, `unsigned`, `invalid: …` or `revoked`. pub tag_signature: String, /// The previous release, which bounds the changes counted. pub since: Option, pub changes: usize, pub authors: Vec, pub assets: Vec, pub audit: Vec, pub generated_ms: i64, } impl ReleaseTrust { pub fn verdict_class(&self) -> &'static str { match self.verdict() { Verdict::Pass => "pass", Verdict::Warn => "warn", Verdict::Fail => "fail", } } pub fn verdict(&self) -> Verdict { if self.audit.iter().any(|c| c.verdict == Verdict::Fail) { Verdict::Fail } else if self.audit.iter().any(|c| c.verdict == Verdict::Warn) { Verdict::Warn } else { Verdict::Pass } } } /// What the repository says about the release: read in one go. struct RepoFacts { heads: Vec, tag_signer: Option, tag_signature: String, authors: Vec, changes: usize, /// Kept receipts by attestation: (runner, problem, revoked, signed hex). receipts: HashMap, bool, String)>, } fn key_info(r: &oxy_core::repo::Repo, key: &ObjectRef) -> KeyInfo { match r.store().get_identity(key) { Ok(i) => KeyInfo { key: key.to_hex(), name: i.name, email: i.email, account: None }, Err(_) => KeyInfo { key: key.to_hex(), ..Default::default() }, } } /// An asset's build: the matching artifact's job, with its run and job rows. type Build = (fdb::Provenance, Option, Option); /// Build the report for release `tag`, or `None` when there is no such /// release or its tag is gone. pub async fn report(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow, tag: &str) -> Result, String> { let releases = rdb::list(&app.db, row.id).await.map_err(|e| e.to_string())?; let Some(rel) = releases.iter().find(|r| r.tag == tag).cloned() else { return Ok(None) }; // The previous release: the next older one in the list (newest first). let since = releases.iter().skip_while(|r| r.id != rel.id).nth(1).map(|r| r.tag.clone()); let assets = rdb::assets(&app.db, rel.id).await.map_err(|e| e.to_string())?; let mut builds: Vec> = Vec::new(); for a in &assets { match fdb::provenance(&app.db, row.id, &a.hash).await.map_err(|e| e.to_string())? { Some(p) => { let run = fdb::run(&app.db, row.id, p.run_number).await.map_err(|e| e.to_string())?; let job = match &run { Some(r) => fdb::jobs(&app.db, r.id).await.map_err(|e| e.to_string())?.into_iter().find(|j| j.name == p.job), None => None, }; builds.push(Some((p, run, job))); } None => builds.push(None), } } let attests: Vec = builds.iter().flatten().map(|(p, _, _)| p.receipt_attest.clone()).filter(|a| !a.is_empty()).collect(); let (tag_s, since_s) = (tag.to_string(), since.clone()); let facts = app .repos .read(repo, move |r, cache| { let Some(heads) = r.tag_heads(&tag_s)? else { return Ok(None) }; // The tag's attestation and whether its signature verifies. let (tag_signer, tag_signature) = match r.tag_attest(&tag_s)? { Some((ar, sa)) => match sa.sigs.first() { Some(sig) => { let who = key_info(r, &sig.key); let verdict = match r.store().get_identity(&sig.key) { Ok(id) => match verify(&id, sig, Domain::Attest, &ar) { Ok(()) if r.is_revoked(&sig.key)? => "revoked".to_string(), Ok(()) => "valid".to_string(), Err(e) => format!("invalid: {e}"), }, Err(_) => "invalid: the signer's identity is missing".to_string(), }; (Some(who), verdict) } None => (None, "unsigned".to_string()), }, None => (None, "unsigned".to_string()), }; // The changes the release adds over the previous one, by author. let now = cache.load(r, &heads)?; let before = match &since_s { Some(p) => r.tag_heads(p)?.unwrap_or_default(), None => vec![], }; let then = cache.load(r, &before)?; let mut by_author: BTreeMap = BTreeMap::new(); let mut changes = 0; for (h, meta) in now.state.changes() { if then.state.contains(h) { continue; } changes += 1; let a = by_author .entry(meta.author.to_hex()) .or_insert_with(|| AuthorTrust { who: key_info(r, &meta.author), ..Default::default() }); if meta.author.is_zero() { a.who.name = "(no author: imported or made before an identity)".into(); } a.changes += 1; match r.authenticate_change(h)? { SignatureStatus::Valid { revoked: true, .. } => a.revoked += 1, SignatureStatus::Valid { sealed: true, .. } => a.sealed += 1, SignatureStatus::Valid { .. } => a.signed += 1, SignatureStatus::Unsigned => a.unsigned += 1, SignatureStatus::Invalid(_) => a.invalid += 1, } } let mut authors: Vec = by_author.into_values().collect(); authors.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.who.name.cmp(&b.who.name))); // Kept receipts, verified again from the store. let mut receipts = HashMap::new(); for a in &attests { let Some(ar) = ObjectRef::from_hex(a) else { continue }; if let Ok(i) = r.receipt(&ar) { let runner = KeyInfo { key: i.signed.runner.hash().to_hex(), name: i.signed.runner.name.clone(), email: i.signed.runner.email.clone(), account: None, }; receipts.insert(a.clone(), (runner, i.problem.clone(), i.revoked, hex::encode(i.signed.encode()))); } } Ok(Some(RepoFacts { heads, tag_signer, tag_signature, authors, changes, receipts })) }) .await .map_err(|e| e.to_string())?; let Some(mut facts) = facts else { return Ok(None) }; // Link keys to accounts. let links: HashMap = db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect(); if let Some(s) = facts.tag_signer.as_mut() { s.account = links.get(&s.key).cloned(); } for a in &mut facts.authors { a.who.account = links.get(&a.who.key).cloned(); } let known_runner_keys: Vec = sqlx::query_scalar("SELECT key FROM flow_runner_keys").fetch_all(&app.db).await.unwrap_or_default(); let base = format!("{}/{}", app.config.base_url(), row.slug()); let mut tag_heads: Vec = facts.heads.iter().map(|h| h.to_hex()).collect(); tag_heads.sort(); let tag_state = tag_heads.join("+"); let mut asset_rows = Vec::new(); for (a, b) in assets.iter().zip(builds) { let build = b.map(|(p, run, job)| { let receipt_json: serde_json::Value = job.as_ref().and_then(|j| serde_json::from_str(&j.receipt).ok()).unwrap_or(serde_json::Value::Null); let sandbox: serde_json::Value = job.as_ref().and_then(|j| serde_json::from_str(&j.sandbox).ok()).unwrap_or(serde_json::Value::Null); let heads = run.as_ref().map(|r| r.heads.clone()).unwrap_or_default(); let kept = facts.receipts.get(&p.receipt_attest).cloned(); let s = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string(); BuildTrust { run: p.run_number, job: p.job.clone(), run_url: format!("{base}/flow/{}", p.run_number), same_state_as_tag: heads == tag_state, heads, pipeline_hash: s(&receipt_json["pipeline_hash"]), scope_tree_hash: s(&receipt_json["scope_tree_hash"]), environment: s(&receipt_json["environment"]), rootfs: s(&sandbox["rootfs"]), runtime: s(&sandbox["runtime"]), exit_code: job.as_ref().and_then(|j| j.exit_code), runner_known: kept.as_ref().is_some_and(|(k, ..)| known_runner_keys.contains(&k.key)), runner: kept.as_ref().map(|(k, ..)| k.clone()), receipt: kept.as_ref().map(|_| p.receipt_attest.clone()), receipt_url: kept.as_ref().map(|_| format!("{base}/receipts/{}", p.receipt_attest)), receipt_problem: kept.as_ref().and_then(|(_, pr, ..)| pr.clone()), runner_revoked: kept.as_ref().is_some_and(|(_, _, rv, _)| *rv), signed_receipt: kept.map(|(.., hex)| hex), } }); asset_rows.push(AssetTrust { name: a.name.clone(), size: a.size, blake3: a.hash.clone(), url: format!("{base}/asset/{}/{}", a.id, a.name), build, }); } let mut t = ReleaseTrust { repository: row.slug(), tag: tag.to_string(), title: rel.title.clone(), release_url: format!("{base}/releases/tag/{tag}"), heads: tag_heads, tag_signer: facts.tag_signer, tag_signature: facts.tag_signature, since, changes: facts.changes, authors: facts.authors, assets: asset_rows, audit: vec![], generated_ms: db::now_ms(), }; t.audit = audit(&t); Ok(Some(t)) } /// The audit: every claim the report makes, judged. pub fn audit(t: &ReleaseTrust) -> Vec { let mut out = Vec::new(); let check = |verdict, title: String, detail: String| Check { verdict, title, detail }; let signer = t.tag_signer.as_ref().map(|k| if k.name.is_empty() { k.key[..12].to_string() } else { k.name.clone() }); out.push(match t.tag_signature.as_str() { "valid" => check(Verdict::Pass, format!("Tag {} is signed", t.tag), format!("by {}", signer.unwrap_or_default())), "unsigned" => check( Verdict::Warn, format!("Tag {} is not signed", t.tag), "Create tags with a key (`oxy identity create`) so they carry a signed attestation.".into(), ), "revoked" => check(Verdict::Fail, format!("Tag {} is signed by a revoked key", t.tag), signer.unwrap_or_default()), other => check(Verdict::Fail, format!("Tag {}'s signature does not verify", t.tag), other.to_string()), }); let sum = |f: fn(&AuthorTrust) -> usize| t.authors.iter().map(f).sum::(); let (unsigned, invalid, revoked) = (sum(|a| a.unsigned), sum(|a| a.invalid), sum(|a| a.revoked)); let scope = match &t.since { Some(p) => format!("since {p}"), None => "in the release".to_string(), }; out.push(if invalid > 0 || revoked > 0 { check( Verdict::Fail, format!("{} of {} changes {scope} fail authentication", invalid + revoked, t.changes), format!("{invalid} with an invalid signature, {revoked} by revoked keys"), ) } else if unsigned > 0 { check( Verdict::Warn, format!("{unsigned} of {} changes {scope} are unsigned", t.changes), "Changes are authenticated when their author signs or seals them on push (Oxygen spec §14.4).".into(), ) } else { let what = match t.changes { 1 => format!("The one change {scope} is authenticated"), n => format!("All {n} changes {scope} are authenticated"), }; let by = match t.authors.len() { 1 => "by one author".to_string(), n => format!("by {n} authors"), }; check(Verdict::Pass, what, by) }); if t.assets.is_empty() { out.push(check(Verdict::Warn, "The release has no assets".into(), "Nothing to trace to a build.".into())); } for a in &t.assets { match &a.build { None => out.push(check( Verdict::Warn, format!("{}: no build found", a.name), "No passed OxygenFlow job of this repository produced these bytes; it was uploaded by hand.".into(), )), Some(b) => { out.push(match (&b.receipt, &b.receipt_problem, b.runner_revoked, b.runner_known) { (None, ..) => check( Verdict::Warn, format!("{}: built by run #{} {}, without a signed receipt", a.name, b.run, b.job), "The runner did not sign its receipt, or the hub did not keep it.".into(), ), (Some(_), Some(p), ..) => check(Verdict::Fail, format!("{}: the signed receipt does not verify", a.name), p.clone()), (Some(_), None, true, _) => { check(Verdict::Fail, format!("{}: the receipt's runner key was revoked", a.name), String::new()) } (Some(_), None, false, known) => check( if known { Verdict::Pass } else { Verdict::Warn }, format!("{}: signed receipt from run #{} {}", a.name, b.run, b.job), format!( "signed by {}{}", b.runner.as_ref().map(|k| k.name.as_str()).unwrap_or(""), if known { ", a runner key of this hub" } else { "; that key is not one this hub's runners use" } ), ), }); out.push(if b.same_state_as_tag { check( Verdict::Pass, format!("{}: built from the tagged state", a.name), t.heads.iter().map(|h| &h[..12]).collect::>().join("+"), ) } else { check( Verdict::Warn, format!("{}: built from another state than the tag's", a.name), format!("run #{} built {}", b.run, b.heads.split('+').map(|h| &h[..12.min(h.len())]).collect::>().join("+")), ) }); let pinned = b.rootfs.len() == 64 && b.rootfs.bytes().all(|c| c.is_ascii_hexdigit()) && !b.environment.is_empty(); out.push(if pinned { check(Verdict::Pass, format!("{}: pinned environment", a.name), format!("{} rootfs {}", b.runtime, &b.rootfs[..12])) } else { check(Verdict::Warn, format!("{}: environment not pinned by hash", a.name), b.rootfs.clone()) }); } } } out } /// The report as a graph: authors, the tagged state, the builds and the /// assets, in four columns, as inline SVG. pub fn graph(t: &ReleaseTrust) -> String { const W: f64 = 180.0; const H: f64 = 34.0; const GAP: f64 = 12.0; const COL: f64 = 230.0; let esc = crate::render::escape; let mut nodes: Vec<(f64, f64, String, String, &str)> = Vec::new(); let mut edges: Vec<(usize, usize, bool)> = Vec::new(); let y = |i: usize| 10.0 + i as f64 * (H + GAP); let authors: Vec = t .authors .iter() .take(12) .enumerate() .map(|(i, a)| { let name = if a.who.name.is_empty() { a.who.key[..12].to_string() } else { a.who.name.clone() }; let ok = a.invalid + a.revoked == 0 && a.unsigned == 0; nodes.push(( 0.0, y(i), name, format!("{} changes", a.changes), if ok { "pass" } else if a.invalid + a.revoked > 0 { "fail" } else { "warn" }, )); nodes.len() - 1 }) .collect(); let tag_class = match t.tag_signature.as_str() { "valid" => "pass", "unsigned" => "warn", _ => "fail", }; nodes.push((COL, y(0), format!("tag {}", t.tag), format!("{} change{}", t.changes, if t.changes == 1 { "" } else { "s" }), tag_class)); let tag = nodes.len() - 1; for a in authors { edges.push((a, tag, true)); } let mut builds: HashMap<(i64, String), usize> = HashMap::new(); for (i, a) in t.assets.iter().enumerate() { let build = a.build.as_ref().map(|b| { *builds.entry((b.run, b.job.clone())).or_insert_with(|| { let class = match (&b.receipt, &b.receipt_problem) { (Some(_), None) => "pass", (Some(_), Some(_)) => "fail", _ => "warn", }; let row = nodes.iter().filter(|n| n.0 == 2.0 * COL).count(); nodes.push(( 2.0 * COL, y(row), format!("run #{} {}", b.run, b.job), b.runner.as_ref().map(|r| r.name.clone()).unwrap_or_else(|| "unsigned".into()), class, )); edges.push((tag, nodes.len() - 1, b.same_state_as_tag)); nodes.len() - 1 }) }); let class = if a.build.is_some() { "pass" } else { "warn" }; nodes.push((3.0 * COL, y(i), a.name.clone(), crate::render::bytes(a.size as u64), class)); if let Some(b) = build { edges.push((b, nodes.len() - 1, true)); } } let rows = nodes.iter().map(|n| n.1).fold(0.0, f64::max) + H + 10.0; let mut svg = format!(r#""#, 3.0 * COL + W + 2.0); for (a, b, solid) in &edges { let (x1, y1) = (nodes[*a].0 + W, nodes[*a].1 + H / 2.0); let (x2, y2) = (nodes[*b].0, nodes[*b].1 + H / 2.0); let mx = (x1 + x2) / 2.0; svg.push_str(&format!( r#""#, if *solid { "" } else { " dashed" } )); } for (x, y, title, sub, class) in &nodes { svg.push_str(&format!( r#"{}{}"#, x + 8.0, y + 14.0, esc(&truncate(title, 26)), x + 8.0, y + 27.0, esc(&truncate(sub, 30)), )); } svg.push_str(""); svg } fn truncate(s: &str, n: usize) -> String { if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::()) } } #[derive(Template)] #[template(path = "release_trust.html")] pub struct ReleaseTrustPage { shell: Shell, repo: RepoShell, t: ReleaseTrust, graph: String, } #[derive(Deserialize, Default)] pub struct TrustQuery { #[serde(default)] format: Option, } /// `GET /{owner}/{name}/releases/trust/{*tag}`: the page, or with /// `?format=json` the report as a download. pub async fn page( State(app): State, viewer: Viewer, Path((owner, name, tag)): Path<(String, String, String)>, Query(q): Query, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/trust/{tag}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let t = report(&app, &repo, &row, &tag).await.map_err(|m| Error::bad(shell.clone(), m))?.ok_or_else(|| Error::not_found(shell.clone()))?; if q.format.as_deref() == Some("json") { let file: String = tag.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect(); return Ok(( [ (axum::http::header::CONTENT_TYPE, "application/json".to_string()), (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}-{file}-trust.json\"", row.name)), ], serde_json::to_string_pretty(&t).unwrap_or_default(), ) .into_response()); } let graph = graph(&t); Ok(Page(ReleaseTrustPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), t, graph }).into_response()) }