diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index f0a1512..0d738e8 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -68,6 +68,18 @@ rails make "not decided" texturally distinct even in greyscale. | `--delta-restored` | `#6B7280` | | `--mode-shadow` | `#667085` | | | | | `--mode-enforce` | `#1570EF` | +### Colour — signing posture (ADR-0020; the Admission signing inventory) +Every image's observed signing posture, always one of these — **never n/a** — each carrying +colour **+ glyph + word** (meaning never by colour alone). `invalid signature` is the loud channel; +plain `not signed` is calm (no baseline yet), never green. The "if enforced" cell is always the +binary would-admit / would-block. +| Token | Value | Glyph | Word | +|---|---|---|---| +| `--sign-signed` (`--cov-present`) | `#067647` | ✓ | signed | +| `--sign-invalid` (`--posture-breach`) | `#D92D20` | ✕ | invalid signature | +| `--sign-notsigned` (`--ink-3`) | `#6B7280` | ○ open | not signed | +| `--sign-checking` (`--posture-awaiting`) | `#9A6B2E` | ◌ dotted | checking… | + ### Space (4px base) `--space-1:4 · --space-2:8 · --space-3:12 · --space-4:16 · --space-6:24 · --space-8:32` @@ -97,12 +109,15 @@ Two weights only (400/600). Emphasis via weight + ink value, not a third weight. | CVE table | `--font-data` `--text-micro`, right-aligned numerics | | Coverage row | `--cov-*` dot + glyph + label; `weakens_decisions` + absent → amber keyline | | Reversion log | `--posture-cleared` toned (a self-revert is the system working) | +| Signing inventory | `--sign-*` chip (glyph + word); `invalid` → `--posture-breach` keyline (loud), `not signed` calm; ref/signer single-line ellipsis (never `break-all`), full value in the `
` panel + `title=`; "if enforced" → `--cov-present` would-admit / `--posture-breach` would-block | | Empty states | `--ink-2`; posture-coloured only when honestly earned (model judging) | ## Accessibility gate (test-enforced) 1. **Contrast:** body/status text ≥ **4.5:1** on its surface; chips/rails/glyphs ≥ **3:1**. -2. **Meaning not by colour alone:** every posture / severity / Δ / coverage state renders a - non-empty **glyph + text label** in addition to colour. (Assert each enum→(glyph,label).) +2. **Meaning not by colour alone:** every posture / severity / Δ / coverage / **signing posture** + state renders a non-empty **glyph + text label** in addition to colour. (Assert each + enum→(glyph,label).) The signing posture is always one of signed / invalid signature / not + signed / checking — **never n/a** — and its "if enforced" cell is always would-admit / would-block. 3. **Honest-calm invariant:** the overall green/all-clear resolves to `--posture-cleared`/green ONLY when the model has affirmatively cleared everything — `model_judging == true` AND not `warming_up` AND **covered** AND **zero breaches AND zero awaiting AND zero uncertain**. If diff --git a/engine/examples/dashboard_preview.rs b/engine/examples/dashboard_preview.rs index 61db290..f7967b5 100644 --- a/engine/examples/dashboard_preview.rs +++ b/engine/examples/dashboard_preview.rs @@ -334,9 +334,52 @@ fn sample_policy_log() -> Arc { ) .with_would_admit(false), ); + record_signing_inventory(&log); Arc::new(log) } +/// Seed the signing sweep's per-image observation rows (JEF-261 shape) so the Admission tab's +/// signing inventory (JEF-262) renders every posture: a GitHub Actions keyless signature, a +/// human/Google-issued signature, an invalid signature (loud), a plain not-signed (calm), and a +/// transient checking. Keyed `Image/` with the posture in the `signature` word + `reason` +/// prose, exactly as `engine::signing_sweep` records them. +fn record_signing_inventory(log: &PolicyDecisionLog) { + let sweep = |image: &str, status: &str, reason: &str| { + log.record(PolicyDecisionRecord::now( + "image-signature", + "allow", + format!("Image/{image}"), + image, + status, + "", + "", + reason, + )); + }; + sweep( + "ghcr.io/acme/api-gateway@sha256:1a2b3c4d5e6f70819293a4b5c6d7e8f90112233445566778899aabbccddeeff0", + "signed", + "signed by https://github.com/acme/api-gateway/.github/workflows/release.yaml@refs/tags/v1.8.2 \ + via https://token.actions.githubusercontent.com", + ); + sweep( + "ghcr.io/acme/export:3.0.0", + "signed", + "signed by releng@acme.example via https://accounts.google.com", + ); + sweep( + "docker.io/library/storefront:latest", + "invalid-signature", + "signature present but does not verify (untrusted/tampered chain)", + ); + sweep("docker.io/library/postgres:16", "not-signed", ""); + sweep( + "registry.k8s.io/pause:3.9", + "checking", + "signing posture not yet known (registry/log unreachable)", + ); +} + /// A representative bake/coverage summary used by the covered scenarios. fn covered_bake() -> BakeStats { let mut bake = BakeStats::default(); @@ -826,11 +869,7 @@ fn preview_readiness(state: &DashboardState) -> view_model::props::ReadinessView /// Build the Admission view props through the public render path. fn preview_admission(state: &DashboardState) -> view_model::props::AdmissionViewProps { - view_model::build_admission_view( - preview_strip(state), - state.policy_log.tallies(), - &state.policy_log.snapshot(), - ) + view_model::build_admission_view(preview_strip(state), &state.policy_log.snapshot()) } /// Render the full page for a tab through the dashboard's PUBLIC render path (all four real). diff --git a/engine/src/engine/dashboard/admission_tests.rs b/engine/src/engine/dashboard/admission_tests.rs index 91d6fe6..78c47c8 100644 --- a/engine/src/engine/dashboard/admission_tests.rs +++ b/engine/src/engine/dashboard/admission_tests.rs @@ -1,12 +1,13 @@ //! Render-level tests for the Admission/policy view (the webhook floor, brief §6): the tallies -//! header (never blank, honest at zero), the deduped decision rows + the "if enforced" what-if, the -//! honest-empty case, the real fourth nav tab, and escaping of the untrusted image/subject/reason -//! text. These drive the view_model + component directly (no HTTP, no engine), so they are fast and -//! pure. Kept in their own file so `tests.rs` stays under the 1,000-line cap (CLAUDE.md). +//! header (never blank, honest at zero), the per-image signing inventory (JEF-262 — every posture, +//! the binary if-enforced, honest empty), the deduped decision rows + the "if enforced" what-if, the +//! real fourth nav tab, and escaping of the untrusted image/subject/reason/identity text. These +//! drive the view_model + component directly (no HTTP, no engine), so they are fast and pure. Kept +//! in their own file so `tests.rs` stays under the 1,000-line cap (CLAUDE.md). use std::time::SystemTime; -use crate::engine::policy_log::{DecisionTallies, PolicyDecisionRecord}; +use crate::engine::policy_log::PolicyDecisionRecord; use crate::engine::state::{BakeStats, Finding, ModelHealth, ReadinessConfig, derive_readiness}; use super::page; @@ -61,12 +62,29 @@ fn admission_rec( .with_would_admit(would_admit) } +/// A signing-sweep observation row (JEF-261 shape): `Image/` subject, posture in `signature`. +fn signing_rec(image: &str, status: &str, reason: &str) -> PolicyDecisionRecord { + PolicyDecisionRecord::now( + "image-signature", + "allow", + format!("Image/{image}"), + image, + status, + "", + "", + reason, + ) +} + +fn render(rows: &[PolicyDecisionRecord]) -> String { + page::admission_page(&build_admission_view(strip_from(&[]), rows)).into_string() +} + #[test] fn admission_nav_tab_is_a_real_fourth_surface() { // The four tabs are all reachable; the Admission tab links to its real route. The merged // Action tab replaces the former Trust + Activity pair. - let v = build_admission_view(strip_from(&[]), DecisionTallies::default(), &[]); - let html = page::admission_page(&v).into_string(); + let html = render(&[]); for tab in ["Findings", "Action", "Readiness", "Admission"] { assert!(html.contains(tab), "the nav offers the {tab} tab"); } @@ -85,64 +103,69 @@ fn admission_nav_tab_is_a_real_fourth_surface() { #[test] fn admission_tallies_header_is_never_blank_even_at_zero() { // The webhook floor's headline: counts honest at zero, so a healthy cluster is never blank. - let v = build_admission_view(strip_from(&[]), DecisionTallies::default(), &[]); - let html = page::admission_page(&v).into_string(); + let html = render(&[]); assert!(html.contains("admitted"), "the admitted tally is rendered"); assert!(html.contains("audited"), "the audited tally is rendered"); assert!(html.contains("denied"), "the denied tally is rendered"); - // And the honest-empty body, never read as all-clear. + // And the honest-empty bodies, never read as all-clear. assert!( html.contains("no admission decisions recorded yet"), "an empty log reads as no-decisions, not all-clear" ); + assert!( + html.contains("no images observed yet"), + "an empty inventory reads as nothing-inspected, not all-clear" + ); assert!(html.contains("not an all-clear")); } #[test] fn admission_renders_deduped_rows_with_the_if_enforced_what_if() { - let tallies = DecisionTallies { - admitted: 42, - audited: 2, - denied: 1, - }; + let mut clean = admission_rec( + "allow", + "Deployment/web", + "ghcr.io/org/web:1", + "verified", + "verified", + "default", + "", + true, + ); + clean.count = 42; let rows = vec![ - // A clean admit — verified on both gates, would admit. - admission_rec( - "allow", - "Deployment/web", - "ghcr.io/org/web:1", - "verified", - "verified", - "default", - "", - true, - ), - // A would-fail signature gate → the "if enforced" what-if is would-deny. + clean, + // A would-fail MESH gate → the "if enforced" what-if is would-deny. admission_rec( "audit", "Deployment/legacy", "docker.io/legacy:old", + "verified", "would-fail", - "would-pass", "payments", - "unsigned or untrusted image", + "not mesh-injected", false, ), ]; - let html = - page::admission_page(&build_admission_view(strip_from(&[]), tallies, &rows)).into_string(); - // The counts surface. + let html = render(&rows); + // The derived admitted count surfaces. assert!(html.contains("42"), "the admitted count"); - // The per-gate shadow status words ride alongside their glyphs (meaning never by colour alone). + // The mesh shadow status words ride alongside their glyphs (meaning never by colour alone). assert!(html.contains("verified"), "a verified gate"); assert!(html.contains("would-fail"), "a would-fail gate"); - assert!(html.contains("would-pass"), "a would-pass gate"); // The "if enforced" what-if for both directions. assert!(html.contains("would admit"), "the admit what-if"); assert!(html.contains("would deny"), "the would-deny what-if"); // The subject + image surface (untrusted, escaped by maud). assert!(html.contains("Deployment/web")); assert!(html.contains("ghcr.io/org/web:1")); + // The decision log no longer carries a signature gate column — its thead is decision / workload + // / mesh / if enforced (posture now lives in the inventory). + let decisions = &html[html.find("admission-rows").unwrap()..]; + assert!( + !decisions.contains(">signature<"), + "no signature column header in the decision log" + ); + assert!(decisions.contains(">mesh<"), "the mesh column remains"); } #[test] @@ -151,12 +174,7 @@ fn admission_dedup_count_shows_when_above_one() { "allow", "Pod/web", "img:1", "verified", "verified", "ns", "", true, ); r.count = 50; - let html = page::admission_page(&build_admission_view( - strip_from(&[]), - DecisionTallies::default(), - &[r], - )) - .into_string(); + let html = render(&[r]); assert!( html.contains("\u{00D7}50"), "the replica-churn dedup count (×50) is shown" @@ -170,18 +188,13 @@ fn admission_untrusted_image_and_reason_are_escaped() { "deny", format!("Pod/{evil}").as_str(), format!("img/{evil}").as_str(), - "would-fail", "verified", + "would-fail", evil, format!("unsigned {evil}").as_str(), false, )]; - let html = page::admission_page(&build_admission_view( - strip_from(&[]), - DecisionTallies::default(), - &rows, - )) - .into_string(); + let html = render(&rows); assert!( !html.contains(""; + let rows = vec![signing_rec( + "ghcr.io/acme/app@sha256:aa", + "signed", + &format!("signed by {evil} via https://token.actions.githubusercontent.com"), + )]; + let html = render(&rows); + assert!( + !html.contains("