From 0fe53ae0d3957913cdff0544981491b61bb3b962 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Tue, 25 Aug 2026 00:39:31 -0700 Subject: [PATCH 1/2] fix(engine): verify SLSA build provenance via sigstore-verify (ADR-0020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard provenance column was blank on every row: the observer resolved Absent for every image, including protector's own signed+attested images. The feature had been inert since it shipped. Root cause is two independent dead ends in the sigstore crate (0.14, newest): its trusted_signature_layers hardcodes the cosign sign/v1 predicate and drops every SLSA attestation, and its bundle::verify::Verifier recomputes the Rekor DSSE envelopeHash from a proto round-trip that never matches what Rekor stored (offline always fails; online is unimplemented). Both confirmed against the live agent image; the primitives to hand-roll around them are pub(crate). Add the sigstore-verify crate (prefix-dev) for the provenance verify step only — it handles GitHub artifact attestation correctly and ships a built-in trust root. A new provenance_observer module fetches the image's OCI referrers directly, selects the slsa.dev/provenance bundle, verifies it offline against the same registry (no new egress), and feeds the unchanged classify_provenance pipeline. A registry without the referrers API resolves to Absent, not perpetual Checking. Validated live: protector-agent -> Verified (correct source + builder); redis / ollama / argocd -> Absent. Adds unit tests plus an ignored live integration test guarding the exact green-on-fixtures / dead-in-prod failure mode. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 248 ++++++++++++++++++ docs/adr/0020-signature-continuity.md | 68 ++++- engine/Cargo.toml | 2 + engine/src/policies/signature/auth.rs | 11 + engine/src/policies/signature/cosign.rs | 143 ++-------- engine/src/policies/signature/mod.rs | 1 + .../policies/signature/provenance_observer.rs | 229 ++++++++++++++++ .../signature/provenance_observer_tests.rs | 82 ++++++ engine/tests/provenance_live.rs | 66 +++++ 9 files changed, 727 insertions(+), 123 deletions(-) create mode 100644 engine/src/policies/signature/provenance_observer.rs create mode 100644 engine/src/policies/signature/provenance_observer_tests.rs create mode 100644 engine/tests/provenance_live.rs diff --git a/Cargo.lock b/Cargo.lock index b99eb5cb..7fe9a420 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,6 +513,30 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "x509-cert", +] + [[package]] name = "combine" version = "4.6.7" @@ -599,6 +623,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1783,10 +1819,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-link", ] [[package]] @@ -1800,6 +1838,21 @@ dependencies = [ "syn", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2981,6 +3034,7 @@ dependencies = [ "k8s-openapi", "kube", "maud", + "oci-client", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", @@ -2994,6 +3048,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "sigstore", + "sigstore-verify", "subtle", "thiserror 2.0.18", "tokio", @@ -3923,6 +3978,58 @@ dependencies = [ "zeroize", ] +[[package]] +name = "sigstore-bundle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5948e95f63e900fa92936ecf72c7f2251f83d27560a35a4aae560cc745f8b687" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-crypto" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f27364b37bec104a904f10a675c8cdf05d5e48a980569368f0cd0c119b2652" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid 0.9.6", + "der", + "digest 0.10.7", + "pem", + "rand_core 0.9.5", + "sha2 0.10.9", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.18", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee85fd9fb550efd7c8a59447cb0ef4eb02f1258f3ffa80c88d38427c3930af3" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", +] + [[package]] name = "sigstore-protobuf-specs-derive" version = "0.0.1" @@ -3933,6 +4040,135 @@ dependencies = [ "syn", ] +[[package]] +name = "sigstore-rekor" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b97c5a866f849a9445ae657bef0caa2db053a82827e6dae3a2b3de9c15a6a1a" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389b54d1b8ace20ba86fdf90e5e655495f65f1abbc7d5d0eb7494defa9ad32f0" +dependencies = [ + "base64 0.22.1", + "hex", + "jiff", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-tuf", + "sigstore-types", + "thiserror 2.0.18", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58fe92d4d8cf4b7215b927b70bc1245b6e0d70d801febdfe0cd265ad97ebf8b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "cmpv2", + "cms", + "const-oid 0.9.6", + "der", + "hex", + "jiff", + "rand 0.9.4", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-tuf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eedac50883a917b7b434db22e2e6e853ace8c00f4a9c27f53e1e9c87e6d89fe4" +dependencies = [ + "globset", + "hex", + "jiff", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "sigstore-crypto", + "sigstore-types", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "sigstore-types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236474c535a3157839926a0ae18f9c0c22b151e49634da6e5b18ad7cac8a3b69" +dependencies = [ + "base64 0.22.1", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-verify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f71aad0e1c5925d29ae2024f55f0f8898a7ad450c93668f99086624c421e0" +dependencies = [ + "base64 0.22.1", + "cms", + "const-oid 0.9.6", + "hex", + "jiff", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "sigstore_protobuf_specs" version = "0.5.1" @@ -4269,6 +4505,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4955,6 +5192,17 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/docs/adr/0020-signature-continuity.md b/docs/adr/0020-signature-continuity.md index 986f7de2..a1f7abe8 100644 --- a/docs/adr/0020-signature-continuity.md +++ b/docs/adr/0020-signature-continuity.md @@ -288,7 +288,8 @@ new egress path. default-on, not opt-in — it was never a new egress destination, so gating it behind a flag was detection proliferation, not an egress control.)* -**Known limitation (DECISION NEEDED — recorded for the architect).** The pinned +**Known limitation (DECISION NEEDED — recorded for the architect).** *(RESOLVED — see the +"provenance is verified via `sigstore-verify`" addendum at the end of this file.)* The pinned `sigstore` crate (0.14) verifies DSSE bundle referrers only for the cosign `sign/v1` predicate — `from_sigstore_bundle` rejects any other predicate type, so a **SLSA provenance** attestation is currently *not surfaced* by `trusted_signature_layers` end to @@ -298,7 +299,10 @@ the `Verified` path is real, correct code that activates the moment the verifier SLSA layer; until then the production observer yields `Absent`/`Checking` — the safe, honest degradation. Closing the gap (compose sigstore's lower-level DSSE + Fulcio + Rekor primitives, or an upgraded `sigstore` release) is a follow-up that does not change this -addendum's contract. +addendum's contract. *(The optimism above was half-right: the pipeline/parser were correct, +but the `Verified` path did NOT just activate — the resolving addendum documents why both of +the `sigstore` crate's verify paths are dead ends for GitHub attestations, and what replaced +them.)* ## Addenda (— the rendered "if enforced" is CONTINUITY, not keyless-identity) @@ -422,6 +426,10 @@ not egress, and is retired. contacted; the only change is that the SLSA in-toto/DSSE layer `trusted_signature_layers` already fetched is now also classified. So flipping the default costs nothing egress-wise: any image the signing sweep already observes is now also read for provenance off the identical bytes. + *(Superseded in mechanism by the "provenance is verified via `sigstore-verify`" addendum below: + provenance no longer rides `fetch_layers` — it makes its OWN referrer fetch to the same registry. + The zero-egress conclusion still holds — same registry host, no new destination, offline + verification — but it is now a **separate** round trip, not the shared one described here.)* 2. **The provenance sweep is now built unconditionally** (`build_provenance_scanner`, mirroring `build_signing_observer` exactly), bounded by the same `PROTECTOR_MAX_IMAGES` cap and TTL cache as before. `PROTECTOR_PROVENANCE_ENABLE` is removed; there is no replacement flag and no migration @@ -432,3 +440,59 @@ not egress, and is retired. admission. The genuinely opt-in Rekor lane (`PROTECTOR_REKOR_ENABLE`) is UNCHANGED by this addendum — it remains gated because it is a real second egress destination, the case this principle's "egress" carve-out exists for. + +## Addenda (— provenance is verified via `sigstore-verify`; the `sigstore` crate cannot) + +The "Known limitation" note above assumed the `Verified` path would light up the moment the +verifier surfaced a SLSA layer. It did not, and observation confirmed it in production: **every** +image — including protector's own, which ships a real `actions/attest-build-provenance` attestation +— read `Absent`, so the inventory's provenance column was uniformly blank. Root-causing it turned up +**two** independent dead ends in the pinned `sigstore` crate (0.14, the newest release), not one: + +1. **The predicate guard (already known).** `trusted_signature_layers` fetches the SLSA bundle as an + OCI referrer, then `from_sigstore_bundle` rejects it — it hardcodes the cosign `sign/v1` predicate + and errors on `https://slsa.dev/provenance/v1`. So the shared signing fetch can never yield a SLSA + layer; the `Verified` path was unreachable, not merely dormant. +2. **The DSSE verifier is also broken (newly found).** The crate's OTHER public path, + `bundle::verify::Verifier`, verifies a bundle directly — but its **offline** DSSE check recomputes + the Rekor `envelopeHash` from a proto round-trip of the envelope, which never equals the hash Rekor + stored over the originally-submitted bytes (a canonicalization bug), so it always fails; its + **online** DSSE path is an unimplemented stub. Both were confirmed against protector's live agent + image (`envelopeHash mismatch`). No published `sigstore` version fixes either, and the lower-level + Fulcio-chain / SCT primitives needed to hand-roll a correct verifier are `pub(crate)`. + +**Decision — add the `sigstore-verify` crate (prefix-dev) for the provenance verify step only.** It +is a maintained, published verifier purpose-built for GitHub artifact attestation: it handles the +DSSE Rekor-entry consistency correctly (matches the payload hash + signature, not the broken envelope +recompute) and ships a built-in `SIGSTORE_PRODUCTION_TRUSTED_ROOT`. The signing axis stays on the +`sigstore` crate untouched; only provenance uses the new stack. This carries a second sigstore +dependency stack, accepted deliberately as the alternative to vendoring a patched `sigstore` fork of +security-critical verification code. + +Mechanics ([`policies::signature::provenance_observer`]): + +1. **Its own referrer fetch, same registry.** Because the shared `fetch_layers` drops the SLSA layer + (dead end 1), provenance now fetches the image's OCI referrers directly via `oci-client`, selects + the `slsa.dev/provenance/*` bundle(s), and verifies each with `sigstore-verify`. This is a + **separate** round trip from the signing fetch (superseding the "one round trip, two postures" + claim in the default-ON addendum above), but to the **same registry host** — no new egress + destination, and the ADR-0015 zero-egress default holds. Verification is fully **offline** + (built-in trust root; the bundle's own embedded inclusion proof + checkpoint), so it adds no + transparency-log call. +2. **The verified facts feed the unchanged classifier.** A verified bundle's `(predicate, keyless?)` + is turned into the same `ProvenanceFacts` the pure `classify_provenance` + `parse_slsa_predicate` + already consume, so the four-state precedence (verified / unverifiable / absent / checking), the + TOFU baseline, the drift finding, and the render are all unchanged — only the *source* of the + facts moved. Observation stays permissive on identity (no configured signer — the Fulcio/Rekor + chain is the anchor; the builder is learned, not gated), matching the signing sweep. +3. **Referrers-unsupported is `Absent`, not `Checking`.** The manifest fetch runs first and proves + the image is reachable + authorized; a subsequent failure to LIST referrers therefore means the + registry does not support the OCI referrers API (common on mirrors, which return an + unparseable/empty referrers tag), which is the calm `Absent` — never a perpetual `Checking` that + would leave every mirrored base image stuck showing the transient glyph. Only an unreachable + *image* is `Checking`. +4. **Guarded by a live end-to-end test.** The failure that hid here for so long was invisible to unit + tests — the pipeline was green on synthetic fixtures while dead in production. An `#[ignore]`d + integration test (`engine/tests/provenance_live.rs`) now verifies the whole chain against a real + attested image (`Verified`, right source + builder) and a real unattested mirror image (`Absent`), + so a future regression to the silent-blank state is catchable with one command. diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 0529add6..a5d78d16 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -15,6 +15,7 @@ jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] } k8s-openapi = { version = "0.28.0", features = ["v1_33"] } kube = { version = "4.0.0", features = ["runtime", "client", "config", "derive", "admission"] } maud = "0.27.0" +oci-client = { version = "0.17.0", default-features = false, features = ["rustls-tls"] } opentelemetry = "0.32.0" opentelemetry-otlp = { version = "0.32.0", default-features = false, features = ["http-proto", "reqwest-blocking-client", "reqwest-rustls", "trace", "metrics"] } opentelemetry_sdk = { version = "0.32.1", features = ["rt-tokio"] } @@ -28,6 +29,7 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.11.0" sigstore = { version = "0.14.0", default-features = false, features = ["cosign", "sigstore-trust-root", "rustls-tls"] } +sigstore-verify = "0.11.0" subtle = "2.6.1" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "time", "signal", "sync", "process"] } diff --git a/engine/src/policies/signature/auth.rs b/engine/src/policies/signature/auth.rs index e83edd3b..6f7fb5a1 100644 --- a/engine/src/policies/signature/auth.rs +++ b/engine/src/policies/signature/auth.rs @@ -88,6 +88,17 @@ impl RegistryAuth { None => Auth::Anonymous, } } + + /// The resolved `(username, password)` for `image`, or `None` for `Anonymous` — the same + /// precedence as [`for_image`](Self::for_image), but auth-library-agnostic so the provenance + /// referrer fetch (which drives oci-client directly, not sigstore's wrapper) can authenticate + /// private images identically without this module depending on either client's auth enum. + pub(super) fn basic_for_image(&self, image: &str) -> Option<(String, String)> { + if let Some((user, pass)) = &self.env_override { + return Some((user.clone(), pass.clone())); + } + self.entries.get(&image_registry_key(image)).cloned() + } } /// Parse the whole dockerconfigjson `auths` map into a `host key → (user, pass)` table. Each entry diff --git a/engine/src/policies/signature/cosign.rs b/engine/src/policies/signature/cosign.rs index 65dcfac3..36c3a72b 100644 --- a/engine/src/policies/signature/cosign.rs +++ b/engine/src/policies/signature/cosign.rs @@ -31,10 +31,7 @@ use tokio::sync::OnceCell; use super::SignatureChecker; use super::auth::RegistryAuth; use super::posture::{SignatureObserver, Signer, SigningPosture}; -use super::provenance::{ - ProvenanceFacts, ProvenanceObserver, ProvenancePosture, classify_provenance, - is_slsa_predicate_type, -}; +use super::provenance::{ProvenanceObserver, ProvenancePosture}; /// The production [`SignatureChecker`] / [`SignatureObserver`]: verifies keyless cosign /// signatures with sigstore-rs against the public-good sigstore TUF root. @@ -244,13 +241,27 @@ impl SignatureObserver for CosignChecker { #[async_trait] impl ProvenanceObserver for CosignChecker { async fn observe_provenance(&self, image: &str) -> ProvenancePosture { - // Reuse the SAME sanctioned registry/Rekor round trip as signature verification (ADR-0015): - // `trusted_signature_layers` already returns any attached in-toto/DSSE attestation layer. - // No second verifier, no new egress path. An infra error is the transient "checking" state. - match self.fetch_layers(image).await { - Ok(layers) => classify_provenance(&provenance_facts(&layers)), - Err(err) => { - tracing::debug!(%image, error = %err, "build-provenance: registry/Rekor unreachable — checking"); + // NOT `trusted_signature_layers`: the `sigstore` crate can't observe SLSA provenance at all + // (it drops non-cosign predicates on the referrer path, and its DSSE bundle verifier has a + // Rekor envelope-hash bug), so that call can only ever yield `Absent`. Fetch + verify the + // provenance referrer directly instead (super::provenance_observer, via `sigstore-verify`). + // Same sanctioned registry egress (ADR-0015), offline transparency-log check. + // + // Bound the whole registry round trip so a slow/hung registry can't stall the sweep — the + // same budget the signing fetch uses. A timeout or infra error is the transient "checking". + match tokio::time::timeout( + self.verify_timeout, + super::provenance_observer::observe_provenance(&self.auth, image), + ) + .await + { + Ok(Ok(posture)) => posture, + Ok(Err(err)) => { + tracing::debug!(%image, error = %err, "build-provenance: registry unreachable — checking"); + ProvenancePosture::Checking + } + Err(_) => { + tracing::debug!(%image, "build-provenance: fetch timed out — checking"); ProvenancePosture::Checking } } @@ -358,63 +369,6 @@ pub(super) fn classify_facts(facts: &[LayerFacts]) -> SigningPosture { } } -/// Project the SLSA build-provenance facts off fetched layers: one [`ProvenanceFacts`] -/// per layer whose in-toto predicate type is a SLSA provenance type (a plain signature layer never -/// produces one). `keyless_verified` mirrors the signing axis — sigstore populates -/// `certificate_signature` ONLY when the attestation's cert chained to the trusted Fulcio root AND -/// its Rekor bundle verified, so an attacker-attached, unverifiable attestation comes back with -/// `keyless_verified: false` (which classifies as [`Unverifiable`](ProvenancePosture::Unverifiable), -/// never trusted). The predicate is decoded from the layer's DSSE PAE payload (the in-toto -/// statement), which the classifier reads for the source repo + builder identity. -pub(super) fn provenance_facts(layers: &[SignatureLayer]) -> Vec { - layers - .iter() - .filter(|layer| is_slsa_predicate_type(&layer.simple_signing.critical.type_name)) - .map(|layer| ProvenanceFacts { - predicate_type: layer.simple_signing.critical.type_name.clone(), - predicate: predicate_from_pae(&layer.raw_data), - keyless_verified: layer.certificate_signature.is_some(), - }) - .collect() -} - -/// Decode the SLSA `predicate` object out of a layer's DSSE PAE-encoded `raw_data`. sigstore stores -/// the DSSE Pre-Authentication-Encoding (`DSSEv1 `) in -/// `raw_data`; the `` is the in-toto Statement JSON, whose `predicate` field carries the -/// SLSA provenance. Returns `None` when the PAE is malformed or the payload isn't the expected -/// in-toto shape — a present-but-opaque attestation (never a fabricated predicate). -fn predicate_from_pae(raw_data: &[u8]) -> Option { - let payload = pae_payload(raw_data)?; - let statement: serde_json::Value = serde_json::from_slice(payload).ok()?; - statement.get("predicate").cloned() -} - -/// Extract the `` bytes from a DSSE PAE header -/// (`DSSEv1 `, all lengths ASCII decimal). The type and -/// payload can themselves contain spaces, so this reads by the declared lengths rather than -/// splitting on whitespace. Returns `None` on any malformed field. -fn pae_payload(raw: &[u8]) -> Option<&[u8]> { - let rest = raw.strip_prefix(b"DSSEv1 ")?; - // up to the next space. - let sp = rest.iter().position(|&b| b == b' ')?; - let type_len: usize = std::str::from_utf8(&rest[..sp]).ok()?.parse().ok()?; - let rest = &rest[sp + 1..]; - // Skip the type itself (type_len bytes) then a single space. - let rest = rest.get(type_len..)?; - let rest = rest.strip_prefix(b" ")?; - // up to the next space. - let sp = rest.iter().position(|&b| b == b' ')?; - let payload_len: usize = std::str::from_utf8(&rest[..sp]).ok()?.parse().ok()?; - let payload = rest.get(sp + 1..)?; - // The declared length must match exactly what remains — a defensive check against a truncated - // or over-long PAE. - if payload.len() == payload_len { - Some(payload) - } else { - None - } -} - /// The per-image verification budget was exhausted before the registry/Rekor round trip returned /// . A typed error (rather than a formatted string) so [`classify_checking_reason`] can /// tell a spent timeout apart from a reachability failure via `downcast_ref`, robust to message @@ -452,59 +406,6 @@ impl VerificationConstraint for IdentityVerifier { #[path = "cosign_tests.rs"] mod cosign_tests; -#[cfg(test)] -mod provenance_pae_tests { - use super::*; - - /// Build a DSSE PAE the way sigstore's `compute_pae` does, so the extractor is tested against - /// the exact on-the-wire shape (`DSSEv1 `). - fn pae(payload_type: &str, payload: &[u8]) -> Vec { - let mut out = format!( - "DSSEv1 {} {} {} ", - payload_type.len(), - payload_type, - payload.len() - ) - .into_bytes(); - out.extend_from_slice(payload); - out - } - - #[test] - fn extracts_predicate_from_a_well_formed_pae() { - let statement = br#"{"_type":"https://in-toto.io/Statement/v1","predicateType":"https://slsa.dev/provenance/v1","predicate":{"runDetails":{"builder":{"id":"https://github.com/org/app/.github/workflows/x.yml@refs/heads/main"}}}}"#; - let raw = pae("application/vnd.in-toto+json", statement); - let predicate = predicate_from_pae(&raw).expect("predicate decoded"); - assert_eq!( - predicate.pointer("/runDetails/builder/id").unwrap(), - "https://github.com/org/app/.github/workflows/x.yml@refs/heads/main" - ); - } - - #[test] - fn payload_with_embedded_spaces_is_read_by_length() { - // The in-toto statement JSON can contain spaces; the extractor must read by the declared - // length, not split on whitespace. - let statement = br#"{ "predicate": { "buildType": "a b c" } }"#; - let raw = pae("application/vnd.in-toto+json", statement); - let predicate = predicate_from_pae(&raw).expect("predicate decoded"); - assert_eq!(predicate.pointer("/buildType").unwrap(), "a b c"); - } - - #[test] - fn malformed_pae_yields_none() { - assert!(pae_payload(b"not a dsse pae").is_none()); - assert!(predicate_from_pae(b"garbage").is_none()); - } - - #[test] - fn a_truncated_payload_is_rejected() { - // Declared length longer than the actual bytes must not silently succeed. - let raw = b"DSSEv1 4 json 999 {}".to_vec(); - assert!(pae_payload(&raw).is_none()); - } -} - #[cfg(test)] mod checking_reason_tests { use super::*; diff --git a/engine/src/policies/signature/mod.rs b/engine/src/policies/signature/mod.rs index 3e426cdb..40bb8495 100644 --- a/engine/src/policies/signature/mod.rs +++ b/engine/src/policies/signature/mod.rs @@ -14,6 +14,7 @@ pub mod continuity; mod cosign; pub mod posture; pub mod provenance; +mod provenance_observer; pub mod rekor; pub mod tuf_tmpdir; diff --git a/engine/src/policies/signature/provenance_observer.rs b/engine/src/policies/signature/provenance_observer.rs new file mode 100644 index 00000000..611329a7 --- /dev/null +++ b/engine/src/policies/signature/provenance_observer.rs @@ -0,0 +1,229 @@ +//! Real SLSA build-provenance observation over OCI referrers (ADR-0020 §5). +//! +//! ## Why this is a separate path (not `sigstore`'s cosign layer fetch) +//! +//! The `sigstore` crate (0.14, chainguard) — which the signing axis uses — **cannot observe SLSA +//! build provenance at all**, via either of its public verify paths: +//! * `trusted_signature_layers` hardcodes the cosign-signature in-toto predicate on its OCI- +//! referrer path and REJECTS every other predicate, including `https://slsa.dev/provenance/v1` +//! (what `actions/attest-build-provenance` publishes) — so the attestation is fetched then +//! dropped; and +//! * its `bundle::verify::Verifier` recomputes the DSSE envelope hash from a proto round-trip, +//! which never matches the hash Rekor actually stored (a canonicalization bug), so offline DSSE +//! verification always fails; its online DSSE path is unimplemented. +//! +//! The net effect was that every image observed [`Absent`](ProvenancePosture::Absent) and the +//! inventory's provenance column was uniformly blank. (These images carry no legacy `.sig` tag +//! either — signature and attestation are BOTH OCI referrers now.) +//! +//! ## What this does +//! +//! Fetch the image's OCI referrers directly, select the SLSA build-provenance bundle(s), and verify +//! each with the `sigstore-verify` crate (prefix-dev) — a maintained verifier that is purpose-built +//! for GitHub artifact attestation and handles the DSSE Rekor-entry consistency **correctly** +//! (payload-hash + signature match, not the broken envelope-hash recompute). Trust material is the +//! crate's built-in `SIGSTORE_PRODUCTION_TRUSTED_ROOT` — no TUF fetch, and verification runs against +//! the bundle's OWN embedded inclusion proof + checkpoint, so it is fully OFFLINE. The verified +//! facts feed the SAME pure [`classify_provenance`] + `parse_slsa_predicate` the signing axis' +//! unit tests already cover, so the four-state precedence (verified / unverifiable / absent / +//! checking) is unchanged; only the *source* of the facts moves to a real referrer fetch. +//! +//! Egress: the SAME sanctioned registry round trip signature verification already makes (ADR-0015), +//! no new destination and no online transparency-log call. + +use std::sync::OnceLock; + +use anyhow::{Context, Result}; +use oci_client::Client; +use oci_client::Reference; +use oci_client::client::ClientConfig; +use oci_client::manifest::{ + IMAGE_MANIFEST_LIST_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE, OCI_IMAGE_INDEX_MEDIA_TYPE, + OCI_IMAGE_MEDIA_TYPE, +}; +use oci_client::secrets::RegistryAuth as OciAuth; +use sigstore_verify::trust_root::{SIGSTORE_PRODUCTION_TRUSTED_ROOT, TrustedRoot}; +use sigstore_verify::types::{Bundle, Sha256Hash}; +use sigstore_verify::{VerificationPolicy, Verifier}; + +use super::RegistryAuth; +use super::provenance::{ + ProvenanceFacts, ProvenancePosture, classify_provenance, is_slsa_predicate_type, +}; + +/// The OCI media type of a Sigstore bundle v0.3 blob — how `actions/attest-build-provenance` (and +/// modern cosign) write both attestations and signatures as OCI referrers. +const SIGSTORE_BUNDLE_V03_MEDIA_TYPE: &str = "application/vnd.dev.sigstore.bundle.v0.3+json"; + +/// The manifest media types the top-level fetch must accept to resolve the image's tag object — +/// single-arch manifest OR multi-arch index, whichever the tag points at. Its digest is the SLSA +/// attestation's `subject`, so we hand it to the verifier as the artifact to bind against. +const MANIFEST_MEDIA_TYPES: &[&str] = &[ + OCI_IMAGE_MEDIA_TYPE, + OCI_IMAGE_INDEX_MEDIA_TYPE, + IMAGE_MANIFEST_MEDIA_TYPE, + IMAGE_MANIFEST_LIST_MEDIA_TYPE, +]; + +/// The built-in sigstore public-good trust root, parsed once. It carries the Fulcio CA, Rekor keys, +/// and CT-log keys `sigstore-verify` needs — so provenance verification does ZERO TUF/network fetch +/// for trust material (the signing axis fetches TUF for its own cosign path; this path does not). +fn trusted_root() -> &'static TrustedRoot { + static TRUSTED_ROOT: OnceLock = OnceLock::new(); + TRUSTED_ROOT.get_or_init(|| { + TrustedRoot::from_json(SIGSTORE_PRODUCTION_TRUSTED_ROOT) + .expect("built-in sigstore production trusted root must parse") + }) +} + +/// Observe `image`'s SLSA build-provenance posture by fetching + verifying its OCI referrers. +/// +/// Returns `Err` ONLY on an infrastructure failure (registry unreachable / manifest fetch failed) — +/// the caller maps that to the transient [`Checking`](ProvenancePosture::Checking). A reachable +/// registry with no provenance referrer is [`Absent`](ProvenancePosture::Absent) (an `Ok`), never an +/// error. Verified provenance is [`Verified`](ProvenancePosture::Verified); a present-but- +/// unverifiable attestation is [`Unverifiable`](ProvenancePosture::Unverifiable) — the exact +/// precedence of the shared [`classify_provenance`]. +pub(super) async fn observe_provenance( + auth: &RegistryAuth, + image: &str, +) -> Result { + let image_ref: Reference = image + .parse() + .with_context(|| format!("parsing image reference {image}"))?; + let oci_auth = oci_auth_for(auth, image); + let client = Client::new(ClientConfig::default()); + + // One manifest fetch does double duty: it authenticates the client for the subsequent + // referrer/blob pulls (token cached), AND yields the tag's manifest digest — the attestation + // `subject` the verifier binds each DSSE statement against. + let (_, digest) = client + .pull_manifest_raw(&image_ref, &oci_auth, MANIFEST_MEDIA_TYPES) + .await + .with_context(|| format!("fetching manifest for {image}"))?; + let subject_ref = Reference::with_digest( + image_ref.registry().to_string(), + image_ref.repository().to_string(), + digest.clone(), + ); + + // The manifest fetch above already proved the registry is reachable + authorized, so a failure + // to LIST referrers here is not a reachability problem — it means the registry doesn't support + // the OCI referrers API (many mirrors return an unparseable/empty referrers tag). That is the + // calm `Absent` (no provenance to show), NOT the transient `Checking` (which would leave every + // such image stuck showing "checking" forever). Only an unreachable IMAGE is `Checking`. + let referrers = match client.pull_referrers(&subject_ref, None).await { + Ok(r) => r, + Err(err) => { + tracing::debug!(%image, error = %err, "build-provenance: referrers API unavailable — absent"); + return Ok(ProvenancePosture::Absent); + } + }; + + let verifier = Verifier::new(trusted_root()); + let mut facts: Vec = Vec::new(); + for entry in &referrers.manifests { + let referrer_ref = Reference::with_digest( + image_ref.registry().to_string(), + image_ref.repository().to_string(), + entry.digest.clone(), + ); + // A single referrer we can't pull is skipped, not fatal: a hard registry failure already + // surfaced at the manifest/referrer-list step above (→ Checking). One odd referrer must not + // mask a real provenance attestation sitting beside it. + let Ok(data) = client + .pull( + &referrer_ref, + &oci_auth, + vec![SIGSTORE_BUNDLE_V03_MEDIA_TYPE], + ) + .await + else { + continue; + }; + for layer in &data.layers { + if layer.media_type != SIGSTORE_BUNDLE_V03_MEDIA_TYPE { + continue; + } + if let Some(fact) = provenance_fact_from_bundle(&verifier, &layer.data, &digest) { + facts.push(fact); + } + } + } + Ok(classify_provenance(&facts)) +} + +/// Turn one Sigstore-bundle blob into a [`ProvenanceFacts`] IF it is a SLSA build-provenance +/// attestation, else `None` (a signature bundle or any non-SLSA predicate is not provenance). +/// `keyless_verified` reflects whether `sigstore-verify` confirmed the bundle's Fulcio cert chain, +/// SCT, Rekor entry consistency, AND that its in-toto `subject` digest matches this image — the one +/// bit separating a trusted build from a present-but-unverifiable one. +fn provenance_fact_from_bundle( + verifier: &Verifier, + bundle_json: &[u8], + subject_digest: &str, +) -> Option { + let (predicate_type, predicate) = extract_slsa_predicate(bundle_json)?; + let keyless_verified = verify_bundle(verifier, bundle_json, subject_digest); + Some(ProvenanceFacts { + predicate_type, + predicate: Some(predicate), + keyless_verified, + }) +} + +/// Verify one SLSA bundle blob against the image's `subject_digest` (`sha256:`) with the +/// permissive observation policy (no configured identity — the Fulcio/Rekor chain is the anchor, +/// ADR-0020 §5; identity is learned, not gated). Any parse/verify failure is a plain `false` +/// (→ `Unverifiable`), never a trusted build. +fn verify_bundle(verifier: &Verifier, bundle_json: &[u8], subject_digest: &str) -> bool { + let Ok(json) = std::str::from_utf8(bundle_json) else { + return false; + }; + let Ok(bundle) = Bundle::from_json(json) else { + return false; + }; + let Some(hex) = subject_digest.strip_prefix("sha256:") else { + return false; + }; + let Ok(hash) = Sha256Hash::from_hex(hex) else { + return false; + }; + // Default policy: verify cert chain + SCT + transparency log, but require NO specific signing + // identity — observation learns the builder, it does not gate on it. + verifier + .verify(hash, &bundle, &VerificationPolicy::default()) + .is_ok() +} + +/// Pull `(predicateType, predicate)` out of a Sigstore-bundle v0.3 JSON blob whose content is a DSSE +/// envelope over an in-toto Statement, returning `None` unless the predicate type is a SLSA +/// build-provenance type. Reads the raw JSON (`dsseEnvelope.payload`, base64) rather than the proto +/// types, so it needs no extra proto dependency. Every extracted value is UNTRUSTED third-party text +/// — escaped wherever rendered. +fn extract_slsa_predicate(bundle_json: &[u8]) -> Option<(String, serde_json::Value)> { + use base64::{Engine as _, engine::general_purpose::STANDARD as b64}; + let bundle: serde_json::Value = serde_json::from_slice(bundle_json).ok()?; + let payload_b64 = bundle.pointer("/dsseEnvelope/payload")?.as_str()?; + let payload = b64.decode(payload_b64).ok()?; + let statement: serde_json::Value = serde_json::from_slice(&payload).ok()?; + let predicate_type = statement.get("predicateType")?.as_str()?; + if !is_slsa_predicate_type(predicate_type) { + return None; + } + let predicate = statement.get("predicate")?.clone(); + Some((predicate_type.to_string(), predicate)) +} + +/// Map protector's per-image [`RegistryAuth`] resolution onto oci-client's auth enum, so the +/// provenance fetch authenticates private images exactly as the signing sweep does. +fn oci_auth_for(auth: &RegistryAuth, image: &str) -> OciAuth { + match auth.basic_for_image(image) { + Some((user, pass)) => OciAuth::Basic(user, pass), + None => OciAuth::Anonymous, + } +} + +#[cfg(test)] +#[path = "provenance_observer_tests.rs"] +mod tests; diff --git a/engine/src/policies/signature/provenance_observer_tests.rs b/engine/src/policies/signature/provenance_observer_tests.rs new file mode 100644 index 00000000..386f57e3 --- /dev/null +++ b/engine/src/policies/signature/provenance_observer_tests.rs @@ -0,0 +1,82 @@ +//! Unit tests for the pure parts of the provenance referrer observer: SLSA predicate extraction +//! out of a Sigstore-bundle v0.3 JSON blob, and the auth mapping. The fetch + cryptographic +//! verification are exercised end-to-end against a live image by the ignored integration test in +//! `engine/tests/` (they need a registry + the sigstore TUF root); here we cover the parsing that +//! decides whether a referrer even IS build provenance. + +use super::*; +use base64::{Engine as _, engine::general_purpose::STANDARD as b64}; + +/// Wrap an in-toto statement JSON in the DSSE-envelope Sigstore-bundle v0.3 shape the extractor +/// reads (`dsseEnvelope.payload` is base64 of the statement). +fn bundle_with_statement(statement: &str) -> Vec { + let payload = b64.encode(statement); + format!( + r#"{{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json", + "dsseEnvelope":{{"payloadType":"application/vnd.in-toto+json", + "payload":"{payload}","signatures":[{{"sig":"AA=="}}]}}}}"# + ) + .into_bytes() +} + +#[test] +fn extracts_a_slsa_v1_predicate() { + let statement = r#"{"_type":"https://in-toto.io/Statement/v1", + "predicateType":"https://slsa.dev/provenance/v1", + "predicate":{"runDetails":{"builder":{"id":"https://github.com/org/app/.github/workflows/x.yml@refs/tags/v1"}}}}"#; + let (ptype, predicate) = + extract_slsa_predicate(&bundle_with_statement(statement)).expect("slsa predicate"); + assert_eq!(ptype, "https://slsa.dev/provenance/v1"); + assert_eq!( + predicate.pointer("/runDetails/builder/id").unwrap(), + "https://github.com/org/app/.github/workflows/x.yml@refs/tags/v1" + ); +} + +#[test] +fn extracts_a_slsa_v02_predicate() { + let statement = r#"{"predicateType":"https://slsa.dev/provenance/v0.2", + "predicate":{"builder":{"id":"https://example.com/builder"}}}"#; + let (ptype, _) = + extract_slsa_predicate(&bundle_with_statement(statement)).expect("slsa predicate"); + assert_eq!(ptype, "https://slsa.dev/provenance/v0.2"); +} + +#[test] +fn a_cosign_signature_predicate_is_not_provenance() { + // The exact predicate sigstore-rs's referrer path accepts and protector's provenance axis must + // ignore — this is why the axis can't just read every referrer as provenance. + let statement = r#"{"predicateType":"https://sigstore.dev/cosign/sign/v1","predicate":{}}"#; + assert!(extract_slsa_predicate(&bundle_with_statement(statement)).is_none()); +} + +#[test] +fn a_non_dsse_bundle_yields_none() { + // A message-signature bundle (cosign signature) has no dsseEnvelope — not an attestation. + let bundle = br#"{"messageSignature":{"signature":"AA=="}}"#; + assert!(extract_slsa_predicate(bundle).is_none()); +} + +#[test] +fn garbage_and_bad_base64_yield_none() { + assert!(extract_slsa_predicate(b"not json").is_none()); + let bad = br#"{"dsseEnvelope":{"payload":"!!!not-base64!!!"}}"#; + assert!(extract_slsa_predicate(bad).is_none()); +} + +#[test] +fn a_statement_missing_the_predicate_field_yields_none() { + // A SLSA predicateType with no predicate object is not usable — never fabricate an empty one. + let statement = r#"{"predicateType":"https://slsa.dev/provenance/v1"}"#; + assert!(extract_slsa_predicate(&bundle_with_statement(statement)).is_none()); +} + +#[test] +fn anonymous_auth_maps_to_oci_anonymous() { + // The default resolver (no creds) must produce oci-client Anonymous, the safe per-image default. + let auth = RegistryAuth::default(); + assert!(matches!( + oci_auth_for(&auth, "ghcr.io/org/app:1"), + OciAuth::Anonymous + )); +} diff --git a/engine/tests/provenance_live.rs b/engine/tests/provenance_live.rs new file mode 100644 index 00000000..20208459 --- /dev/null +++ b/engine/tests/provenance_live.rs @@ -0,0 +1,66 @@ +//! Live end-to-end check of SLSA build-provenance observation (ADR-0020 §5). +//! +//! `#[ignore]`d because it needs network (a real registry + the sigstore trust material) and +//! reaches out to specific public images — so it never runs in CI, but a maintainer can run it to +//! confirm the whole fetch → referrer → `sigstore-verify` chain against real attestations: +//! +//! cargo test -p protector --test provenance_live -- --ignored --nocapture +//! +//! This test exists because the bug it guards was invisible to unit tests: the provenance path was +//! fully green against synthetic fixtures while returning `Absent` for every real image in +//! production (the `sigstore` crate silently drops SLSA attestations). Only an end-to-end check +//! against a genuinely-attested image catches that class of regression. + +use std::time::Duration; + +use protector::policies::signature::{ + CosignChecker, ProvenanceObserver, ProvenancePosture, RegistryAuth, +}; + +fn checker() -> CosignChecker { + CosignChecker::new( + ".*", + "https://token.actions.githubusercontent.com".to_string(), + RegistryAuth::from_env(), + std::env::temp_dir().join("protector-tuf-provenance-live"), + Duration::from_secs(90), + ) + .expect("build checker") +} + +/// protector's own agent image is built with `actions/attest-build-provenance`, so it MUST verify to +/// `Verified` with this repo as the source and its release workflow as the builder — the posture the +/// inventory's provenance column renders. (Override the image via `IMG`.) +#[tokio::test] +#[ignore] +async fn own_image_provenance_verifies() { + let image = std::env::var("IMG") + .unwrap_or_else(|_| "ghcr.io/thejefflarson/protector-agent:0.14.1".to_string()); + match checker().observe_provenance(&image).await { + ProvenancePosture::Verified(p) => { + assert!( + p.source_repo.contains("thejefflarson/protector"), + "unexpected source repo: {}", + p.source_repo + ); + assert!( + p.builder.contains(".github/workflows/"), + "unexpected builder: {}", + p.builder + ); + } + other => panic!("expected Verified provenance for {image}, got {other:?}"), + } +} + +/// An image with no SLSA attestation on a registry that doesn't support the OCI referrers API must +/// resolve to the calm `Absent`, NOT a perpetual `Checking` — the whole cluster's mirrored base +/// images depend on this staying calm. +#[tokio::test] +#[ignore] +async fn unattested_mirror_image_is_absent() { + let posture = checker() + .observe_provenance("mirror.gcr.io/library/redis:7-alpine") + .await; + assert_eq!(posture, ProvenancePosture::Absent, "got {posture:?}"); +} From d4c19a8f1d4d43ade29763a9eb0f0667177648f9 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Tue, 25 Aug 2026 19:53:06 -0700 Subject: [PATCH 2/2] chore(deps): bump h2 0.4.16, webbrowser 1.2.2 to clear RUSTSEC advisories The audit gate flagged two newly-published advisories in pre-existing transitive deps: h2 (RUSTSEC-2026-0258, unbounded empty DATA frames) and webbrowser (RUSTSEC-2026-0257, Unix BROWSER argument injection). Both are lock-only, semver-compatible bumps. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7fe9a420..0a844107 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,16 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -1364,9 +1374,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -2378,6 +2388,28 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2392,6 +2424,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags", "objc2", + "objc2-core-foundation", ] [[package]] @@ -4984,15 +5017,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f" dependencies = [ - "core-foundation", "jni", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys",