From 5163631ba0142fd0e9357eba56bd3720c260e501 Mon Sep 17 00:00:00 2001 From: Nathan Hensley Date: Thu, 14 May 2026 10:02:56 -0700 Subject: [PATCH 1/7] feat(verifier): pointer-file input, OCI pull, and Sigstore signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of the evidence verifier. Layers three input forms and the signature step on top of the directory-input verifier that shipped in #879. Input forms (InputFormPointer, InputFormOCI added): - Pointer file (recipes/evidence/.yaml) — declares the OCI ref, expected digest, predicateType, signer claims, and Rekor index. The verifier loads it, pulls the artifact at the declared digest, and verifies the pulled bundle's content matches. - OCI ref (registry/repo@sha256:...) — direct OCI pull when callers don't have a pointer file. - Directory — unchanged from the first slice. Signature verification (new step 2): - For directory input, reads attestation.intoto.jsonl from the bundle. - For pointer/OCI input, discovers the Sigstore Bundle as a sibling Referrer artifact on the registry (mediaType application/vnd.dev.sigstore.bundle.v0.3+json) and verifies it without writing the signature to disk — matches cosign's mental model. One fewer round trip, no stray file in the materialized bundle. - Uses sigstore-go for DSSE envelope verification, Fulcio cert chain validation, and Rekor inclusion proof. Extracts the signer's SAN identity and OIDC issuer; surfaces them in the report header. - Unsigned bundles record signature-verify as "skipped" rather than failing — the manifest-hash chain still binds the bundle, the rendered report just marks the signer as unsigned. - Bundle size capped via defaults.MaxSigstoreBundleSize before parse; hostile inputs can't OOM the verifier. Report changes: - Header now surfaces signer identity / issuer / Rekor index when the signature verified, and the bundle digest when known (pointer or OCI input). - Predicate-parse step distinguishes "from verified DSSE payload" (signed) vs "from unsigned statement.intoto.json" (skipped sig). Schema: - Pointer 1.0.x — exactly one entry in attestations; multi-instance (schema 2.0) is reserved. predicateType must be PredicateTypeV1. Signer requires both identity and issuer when present. - Pointer files are capped at 1 MiB (matches MaxRecipePOSTBytes). Docs + demos updated for the new flag, the five-step pipeline, and end-to-end pointer/OCI flows. --- demos/README.md | 1 + demos/evidence.md | 212 +++++++++++++++++ docs/user/cli-reference.md | 49 +++- pkg/cli/evidence_verify.go | 68 ++++-- pkg/cli/evidence_verify_test.go | 6 +- pkg/evidence/verifier/doc.go | 47 +++- pkg/evidence/verifier/fetch.go | 296 +++++++++++++++++++++++- pkg/evidence/verifier/fetch_test.go | 41 +++- pkg/evidence/verifier/input.go | 49 +++- pkg/evidence/verifier/input_test.go | 11 +- pkg/evidence/verifier/pointer.go | 102 ++++++++ pkg/evidence/verifier/pointer_test.go | 104 +++++++++ pkg/evidence/verifier/referrer_test.go | 186 +++++++++++++++ pkg/evidence/verifier/report.go | 21 +- pkg/evidence/verifier/signature.go | 285 +++++++++++++++++++++++ pkg/evidence/verifier/signature_test.go | 136 +++++++++++ pkg/evidence/verifier/types.go | 61 ++++- pkg/evidence/verifier/verify.go | 105 +++++++-- pkg/evidence/verifier/verify_test.go | 9 + 19 files changed, 1695 insertions(+), 94 deletions(-) create mode 100644 demos/evidence.md create mode 100644 pkg/evidence/verifier/pointer.go create mode 100644 pkg/evidence/verifier/pointer_test.go create mode 100644 pkg/evidence/verifier/referrer_test.go create mode 100644 pkg/evidence/verifier/signature.go create mode 100644 pkg/evidence/verifier/signature_test.go diff --git a/demos/README.md b/demos/README.md index 1a3b6c083..29e51663b 100644 --- a/demos/README.md +++ b/demos/README.md @@ -16,6 +16,7 @@ Runbooks for testing and demonstrating AICR end-to-end workflows on live cluster | [ext.md](ext.md) | Extension demo | | [query.md](query.md) | Querying hydrated recipes with dot-path selectors | | [attestation.md](attestation.md) | Bundle attestation demo | +| [evidence.md](evidence.md) | Recipe evidence demo (validate emit + verify) | | [s3c.md](s3c.md) | Supply chain security demo | ## Recording Test Runs diff --git a/demos/evidence.md b/demos/evidence.md new file mode 100644 index 000000000..462123386 --- /dev/null +++ b/demos/evidence.md @@ -0,0 +1,212 @@ +# Recipe Evidence Demo + +Recipe evidence is a signed, OCI-distributed bundle that proves a particular +recipe passed `aicr validate` against a specific cluster. Contributors emit +the bundle with `aicr validate --emit-attestation`; maintainers verify it +offline with `aicr evidence verify`. This is the trust handoff for recipes +on hardware AICR maintainers can't reach — see +[ADR-007](../docs/design/007-recipe-evidence.md) for the design. + +This demo walks through the full producer-and-consumer loop: + +1. Run `aicr validate --emit-attestation` to produce a bundle and pointer. +2. Push the bundle to an OCI registry (signs via cosign keyless OIDC). +3. Commit the pointer file to the repo. +4. Verify from the pointer (the maintainer's path). +5. Verify directly from the OCI artifact. +6. Verify locally without push (contributor self-debug, no signature). +7. Tamper a file and re-verify. + +## Prerequisites + +* `aicr` with `aicr evidence verify` (PR-B onward). +* A Kubernetes cluster to validate against. +* OCI registry write access for the producer (GHCR, GitLab Container + Registry, Harbor, ECR, Artifactory, ACR — any OCI-1.1 registry works). +* For signing: a working OIDC source. GitHub Actions OIDC is detected + automatically; otherwise the CLI opens a browser for keyless signing. +* Bootstrap the Sigstore trusted root once on the verifier's machine: + + ```shell + aicr trust update + ``` + +## 1. Validate and emit evidence + +```shell +aicr recipe \ + --service eks \ + --accelerator h100 \ + --os ubuntu \ + --intent training \ + --output recipe.yaml + +aicr snapshot --output snapshot.yaml + +aicr validate \ + --recipe recipe.yaml \ + --snapshot snapshot.yaml \ + --emit-attestation ./out \ + --push ghcr.io//aicr-evidence +``` + +`--push` opens a browser for OIDC sign-in (or uses ambient GitHub Actions +OIDC if `ACTIONS_ID_TOKEN_REQUEST_URL` is set). After it finishes: + +```text +./out +├── pointer.yaml # copy this into the repo +└── summary-bundle/ + ├── recipe.yaml # canonical post-resolution recipe + ├── snapshot.yaml # cluster snapshot at validate-time + ├── bom.cdx.json # CycloneDX SBOM + ├── ctrf/ # per-phase test results + ├── manifest.json # per-file sha256 inventory + ├── statement.intoto.json # unsigned in-toto Statement (recipe-subject) + └── attestation.intoto.jsonl # SIGNED Sigstore Bundle (DSSE + Fulcio + Rekor) +``` + +## 2. Commit the pointer + +```shell +mkdir -p recipes/evidence +cp ./out/pointer.yaml recipes/evidence/h100-eks-ubuntu-training.yaml +git add recipes/evidence/h100-eks-ubuntu-training.yaml +git commit -S -m "evidence: attest h100-eks-ubuntu-training" +``` + +`git log recipes/evidence/.yaml` is the audit trail of who signed +what, when. The pointer is small: + +```yaml +schemaVersion: 1.0.0 +recipe: h100-eks-ubuntu-training +attestations: +- bundle: + oci: ghcr.io//aicr-evidence:v1 + digest: sha256:f0c1... + predicateType: https://aicr.nvidia.com/recipe-evidence/v1 + signer: + identity: https://github.com///.github/workflows/validate.yaml@refs/heads/main + issuer: https://token.actions.githubusercontent.com + rekorLogIndex: 91234567 + attestedAt: 2026-05-14T10:23:11Z +``` + +It's a locator, not a cache — every other field (fingerprint, phase counts, +BOM info) lives in the predicate inside the pulled artifact. + +## 3. Verify from the pointer (maintainer path) + +```shell +aicr evidence verify recipes/evidence/h100-eks-ubuntu-training.yaml +``` + +The verifier pulls the OCI artifact and runs five checks: + +1. **Materialize** the bundle (OCI pull). +2. **Signature verify** — cosign keyless via sigstore-go; predicate extracted from the verified DSSE payload. +3. **Predicate parse** — uses the signature-anchored predicate. +4. **Manifest hash check** — every bundled file recomputed against `manifest.json`, which is bound to `predicate.Manifest.Digest` (now cryptographically anchored). +5. **Render** a Markdown summary with signer, fingerprint table, phase counts, and BOM info. + +Exit codes: + +* `0` — bundle valid, all checks passed. +* `1` — bundle valid, but recorded validator results show failures + (informational; cryptographic integrity intact). Surfaced in + `VerifyResult.Exit` for JSON consumers; OS exit collapses to `2`. +* `2` — bundle invalid (signature, integrity, or constraint failure). + +Pin the expected signer when only one identity should be accepted: + +```shell +aicr evidence verify recipes/evidence/h100-eks-ubuntu-training.yaml \ + --expected-issuer https://token.actions.githubusercontent.com \ + --expected-identity-regexp '^https://github\.com//.*$' +``` + +## 4. Verify directly from OCI + +```shell +aicr evidence verify ghcr.io//aicr-evidence@sha256:f0c1... +``` + +Same five checks, no repo checkout required. Useful for auditing a +contribution before merge. + +## 5. Verify locally without push (contributor self-debug, no signature) + +Skip `--push` and the verifier runs every check except signature: + +```shell +aicr validate --recipe recipe.yaml --snapshot snapshot.yaml \ + --emit-attestation ./out + +aicr evidence verify ./out/summary-bundle +``` + +The Signer line in the rendered report reads `_unsigned bundle_`. The +predicate comes from `statement.intoto.json` rather than the verified +signed payload, so the manifest-hash chain becomes self-consistency +only — useful for catching accidental corruption, not deliberate +tampering. See ADR-007 §"Trust model" for details. + +## 6. Tamper demo + +The signed manifest hash pins every file. One example: + +```shell +# Pull a signed bundle locally and mutate a CTRF result. +mkdir tmp && cd tmp +oras pull ghcr.io//aicr-evidence@sha256:f0c1... +sed -i 's/"passed"/"failed"/' summary-bundle/ctrf/deployment.json + +aicr evidence verify ./summary-bundle +# Expected: manifest-hash-check status = failed; exit 2. +# The CTRF file's sha256 no longer matches manifest.json, and +# manifest.json's digest is anchored to the verified predicate. +``` + +## 7. PR-comment Markdown + +```shell +aicr evidence verify recipes/evidence/h100-eks-ubuntu-training.yaml \ + -o ./evidence-summary.md +``` + +Paste the rendered Markdown into the PR comment for maintainer review. + +## 8. JSON output (CI path) + +```shell +aicr evidence verify recipes/evidence/h100-eks-ubuntu-training.yaml \ + -o evidence-result.json -t json + +jq '.exit' evidence-result.json # 0 / 1 / 2 (library code) +jq '.signer' evidence-result.json # signer claims +jq '.predicate.phases' evidence-result.json +``` + +## Troubleshooting + +**"sigstore verification failed — trusted root may be stale"** — Sigstore +rotates its TUF roots periodically. Run `aicr trust update`. + +**"pointer digest does not match pulled digest"** — the OCI artifact at the +pointer's reference is not the one the pointer was attested against. Either +the registry rewrote the tag (use a digest-bound reference) or the bundle +was re-pushed. Re-emit and re-commit the pointer. + +**"signed subject digest does not match pulled artifact digest"** — the +Sigstore signature was made for a different OCI artifact than the one we +pulled. Someone substituted the bundle and re-pointed at a stale signature. + +**"OCI pull failed"** — registry auth. The verifier uses ambient Docker +credentials (`docker login` / `DOCKER_CONFIG`); confirm `docker pull +` works from the same shell. + +**`--no-rekor`** — when the verifier cannot reach Rekor +(`rekor.sigstore.dev`), this flag falls back to the cert and signature in +the Sigstore Bundle alone. The attestation is still cryptographically +verified; only the transparency-log cross-check is skipped. diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index 11d9a0ca0..53b6d5f87 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -1892,38 +1892,69 @@ aicr verify ./my-bundle --format json ### aicr evidence verify -Verify a recipe-evidence v1 bundle produced by `aicr validate --emit-attestation`. Recomputes every file's sha256 against the bundle's `manifest.json` (which is bound to the predicate's `manifest.digest`) and surfaces the predicate's fingerprint, phase counts, and BOM info. +Verify a recipe-evidence v1 bundle produced by `aicr validate --emit-attestation`. When the bundle carries a signature, verifies it against the Sigstore trusted root and extracts the cryptographically anchored predicate. Recomputes every file's sha256 against `manifest.json` (which the predicate's `manifest.digest` field anchors), and surfaces the predicate's fingerprint, phase counts, and BOM info. -Only directory input is supported today. Cryptographic signature verification, inline constraint replay, OCI pull, and pointer-file support are not yet implemented. +Inline constraint replay is reserved for a follow-up PR. **Synopsis:** ```shell -aicr evidence verify [flags] +aicr evidence verify [flags] ``` +The positional argument is auto-detected as one of: + +* `recipes/evidence/.yaml` — pointer file (verifier fetches the OCI artifact named inside). +* `ghcr.io//aicr-evidence@sha256:...` or `oci://...` — OCI reference. +* `./out/summary-bundle/` (or a parent containing it) — unpacked directory. + **Flags:** | Flag | Alias | Type | Default | Description | |------|-------|------|---------|-------------| | `--output` | `-o` | string | | Write output to this file. When empty, output goes to stdout. | | `--format` | `-t` | string | `text` | Output format: `text` (Markdown) or `json`. Applies regardless of destination. | +| `--no-rekor` | | bool | `false` | Skip Rekor transparency-log cross-check; rely on the cert + signature in the Sigstore Bundle alone. Useful in air-gapped environments. | +| `--expected-issuer` | | string | | Pin the OIDC issuer URL on the signing certificate. Empty allows any issuer. | +| `--expected-identity-regexp` | | string | | Pin the signer's `SubjectAlternativeName` via regex. Empty allows any identity. | +| `--bundle` | | string | | OCI reference override when the pointer carries no `bundle.oci`. | +| `--registry-plain-http` | | bool | `false` | Use HTTP for registry traffic (local-registry tests only). | +| `--registry-insecure-tls` | | bool | `false` | Skip TLS verification for the registry (self-signed certificates). | **Exit codes:** | Code | Meaning | |------|---------| | 0 | Bundle valid; every check passed. | -| 2 | Bundle invalid (manifest hash mismatch or predicate malformed), OR recorded validator results show failures. | +| 2 | Bundle invalid (signature, integrity, or predicate failure), OR recorded validator results show failures. | -The JSON/Markdown output's `exit` field (and `VerifyResult.Exit` from the library API) still distinguishes the two non-zero cases as `1` (recorded phase failures) vs `2` (bundle invalid). +The JSON/Markdown output's `exit` field (and `VerifyResult.Exit` from the library API) still distinguishes the two non-zero cases as `1` (recorded phase failures) vs `2` (bundle invalid). Shell consumers can branch via `jq '.exit'` on `--format json` output. **Examples:** ```shell -aicr evidence verify ./out/summary-bundle # Markdown to stdout -aicr evidence verify ./out/summary-bundle -o summary.md # Markdown to file -aicr evidence verify ./out/summary-bundle -t json # JSON to stdout -aicr evidence verify ./out/summary-bundle -o r.json -t json # JSON to file +# Verify the pointer that a contributor committed alongside their recipe change. +aicr evidence verify recipes/evidence/h100-eks-ubuntu-training.yaml + +# Verify a pushed OCI bundle directly (no repo checkout required). +aicr evidence verify ghcr.io/myorg/aicr-evidence@sha256:abc... + +# Verify a local bundle directory (contributor self-debug before push). +aicr evidence verify ./out/summary-bundle + +# Pin the expected OIDC signer. +aicr evidence verify recipes/evidence/.yaml \ + --expected-issuer https://token.actions.githubusercontent.com \ + --expected-identity-regexp '^https://github\.com/myorg/.*$' + +# Air-gapped: skip Rekor cross-check, rely on the Sigstore Bundle alone. +aicr evidence verify recipes/evidence/.yaml --no-rekor + +# CI pipelines: JSON output. +aicr evidence verify recipes/evidence/.yaml -o result.json -t json ``` +See [demos/evidence.md](../../demos/evidence.md) for a full producer-and-consumer walkthrough. + +> **Stale root:** If verification fails with certificate chain errors, run `aicr trust update` to refresh the Sigstore trusted root. + --- ### aicr trust update diff --git a/pkg/cli/evidence_verify.go b/pkg/cli/evidence_verify.go index 100ea6404..7ea3c7fb9 100644 --- a/pkg/cli/evidence_verify.go +++ b/pkg/cli/evidence_verify.go @@ -31,30 +31,34 @@ const ( evidenceVerifyFormatJSON = "json" ) -// evidenceVerifyCmd implements `aicr evidence verify `. Only -// directory input is supported today. +// evidenceVerifyCmd implements `aicr evidence verify `. +// Accepts three input forms (auto-detected): directory, pointer file, +// or OCI reference. When the bundle is signed (attestation.intoto.jsonl +// present), the signature is verified against the Sigstore trusted +// root and the predicate body is extracted from the verified payload. func evidenceVerifyCmd() *cli.Command { return &cli.Command{ Name: "verify", Category: functionalCategoryName, Usage: "Verify a recipe evidence bundle (offline).", - Description: `Verifies a recipe-evidence v1 bundle's manifest hash chain and -surfaces the signed predicate's fingerprint, phase counts, and BOM info. + Description: `Verifies a recipe-evidence v1 bundle's signature (when present) +and manifest hash chain, then surfaces the predicate's fingerprint, +phase counts, and BOM info. -Only directory input is supported today: +Input is auto-detected as one of: - aicr evidence verify ./out/summary-bundle - -Pointer files (recipes/evidence/.yaml), OCI references, and -cryptographic signature verification are not yet implemented. + pointer recipes/evidence/.yaml — pulls the OCI artifact named inside. + oci ghcr.io/owner/aicr-evidence@sha256:abc... or oci://... + directory ./out/summary-bundle/ (or a parent containing it). The rendered output goes to stdout by default; -o writes it to a file instead. -t selects the format (text = Markdown, json = structured). -Exit codes (see Exit Codes section in cli-reference.md): +Exit codes: 0 bundle valid; every check passed. - 2 bundle invalid, OR recorded validator results show failures. + 1 bundle valid; recorded validator results show failures (informational). + 2 bundle invalid (signature, schema, or integrity failure). `, Flags: []cli.Flag{ &cli.StringFlag{ @@ -70,6 +74,36 @@ Exit codes (see Exit Codes section in cli-reference.md): Usage: "Output format: text (Markdown summary), json.", Category: catOutput, }, func() []string { return []string{evidenceVerifyFormatText, evidenceVerifyFormatJSON} }), + &cli.BoolFlag{ + Name: "no-rekor", + Usage: "Skip Rekor transparency-log cross-check; use the cert and signature in the Sigstore Bundle alone.", + Category: catEvidence, + }, + &cli.StringFlag{ + Name: "expected-issuer", + Usage: "Pin the OIDC issuer URL on the signing certificate (empty = any issuer).", + Category: catEvidence, + }, + &cli.StringFlag{ + Name: "expected-identity-regexp", + Usage: "Pin the signer's SubjectAlternativeName via regex (empty = any identity).", + Category: catEvidence, + }, + &cli.StringFlag{ + Name: "bundle", + Usage: "OCI reference override when the pointer carries no bundle.oci.", + Category: catEvidence, + }, + &cli.BoolFlag{ + Name: "registry-plain-http", + Usage: "Use HTTP for registry traffic (local-registry tests only).", + Category: catEvidence, + }, + &cli.BoolFlag{ + Name: "registry-insecure-tls", + Usage: "Skip TLS verification for the registry (self-signed certificates).", + Category: catEvidence, + }, }, Action: runEvidenceVerifyCmd, } @@ -79,14 +113,22 @@ func runEvidenceVerifyCmd(ctx context.Context, cmd *cli.Command) error { input := cmd.Args().First() if input == "" { return errors.New(errors.ErrCodeInvalidRequest, - "input is required: aicr evidence verify ") + "input is required: aicr evidence verify ") } format := cmd.String(flagFormat) if format != evidenceVerifyFormatText && format != evidenceVerifyFormatJSON { return errors.New(errors.ErrCodeInvalidRequest, "invalid --format: must be text or json") } - result, err := verifier.Verify(ctx, verifier.VerifyOptions{Input: input}) + result, err := verifier.Verify(ctx, verifier.VerifyOptions{ + Input: input, + BundleRef: cmd.String("bundle"), + NoRekor: cmd.Bool("no-rekor"), + ExpectedIssuer: cmd.String("expected-issuer"), + ExpectedIdentityRegexp: cmd.String("expected-identity-regexp"), + PlainHTTP: cmd.Bool("registry-plain-http"), + InsecureTLS: cmd.Bool("registry-insecure-tls"), + }) if err != nil { return err } diff --git a/pkg/cli/evidence_verify_test.go b/pkg/cli/evidence_verify_test.go index 1ea75aa5d..7b6e6027e 100644 --- a/pkg/cli/evidence_verify_test.go +++ b/pkg/cli/evidence_verify_test.go @@ -48,7 +48,11 @@ func TestEvidenceCmd_RegistersVerifySubcommand(t *testing.T) { func TestEvidenceVerifyCmd_HasExpectedFlags(t *testing.T) { cmd := evidenceVerifyCmd() - wanted := []string{"output", "format"} + wanted := []string{ + "output", "format", + "no-rekor", "expected-issuer", "expected-identity-regexp", "bundle", + "registry-plain-http", "registry-insecure-tls", + } for _, name := range wanted { found := false for _, f := range cmd.Flags { diff --git a/pkg/evidence/verifier/doc.go b/pkg/evidence/verifier/doc.go index 75baad338..8ca1115aa 100644 --- a/pkg/evidence/verifier/doc.go +++ b/pkg/evidence/verifier/doc.go @@ -13,20 +13,43 @@ // limitations under the License. // Package verifier implements `aicr evidence verify`: offline -// verification of a recipe-evidence v1 bundle directory produced by -// `aicr validate --emit-attestation`. Four steps run: +// verification of a recipe-evidence v1 bundle produced by +// `aicr validate --emit-attestation`. Five steps run: // -// 1. Materialize — resolve the user-supplied directory to a bundle root. -// 2. Predicate parse — read the in-toto Statement; reject unknown -// predicate types. -// 3. Manifest hash check — sha256(manifest.json) must match +// 1. Materialize — resolve the input (directory / pointer file / OCI +// reference) to a bundle root on disk. Pointer and OCI forms pull +// the artifact via ORAS, then discover the Sigstore Bundle attached +// as an OCI Referrer and stage it as attestation.intoto.jsonl so +// the signature step finds it on disk the same way it does for +// directory input. +// 2. Signature verify — when attestation.intoto.jsonl is present, +// sigstore-go verifies the DSSE-wrapped in-toto Statement against +// the Sigstore trusted root (Fulcio cert chain, optional Rekor +// entry). The cryptographically anchored predicate body is +// extracted from the verified payload. +// 3. Predicate parse — use the verified predicate when the signature +// step produced one; otherwise fall back to the unsigned +// statement.intoto.json (self-consistency only). +// 4. Manifest hash check — sha256(manifest.json) must match // predicate.Manifest.Digest, and every file the manifest names // must match its recorded sha256. Together these transitively -// bind every bundled file to the predicate. -// 4. Render — Markdown / JSON; surfaces fingerprint, phase counts, -// and BOM info from the bundled predicate. +// bind every bundled file to the (now signature-anchored) +// predicate. +// 5. Render — Markdown / JSON; surfaces signer identity, fingerprint, +// phase counts, and BOM info. // -// The predicate body is read but not yet cryptographically verified — -// the rendered report surfaces this via an empty Signer line. See -// docs/design/007-recipe-evidence.md for the trust model. +// The trust chain when a signature is present: +// +// Sigstore trusted root → Fulcio cert (Rekor-logged) +// → DSSE-signed Statement +// → predicate.Manifest.Digest +// → manifest.json +// → every bundled file's sha256 +// +// Tampering anywhere below the signature breaks the chain. The OCI +// input form adds a freshness check: the signed Statement's subject +// digest is locked to the pulled artifact's OCI manifest digest, so a +// substituted artifact paired with a stale signature fails too. +// +// See docs/design/007-recipe-evidence.md for the full trust model. package verifier diff --git a/pkg/evidence/verifier/fetch.go b/pkg/evidence/verifier/fetch.go index 56fda8123..36b26118a 100644 --- a/pkg/evidence/verifier/fetch.go +++ b/pkg/evidence/verifier/fetch.go @@ -16,23 +16,46 @@ package verifier import ( "context" + "encoding/json" + "io" + "log/slog" "os" "path/filepath" + "strings" + "github.com/distribution/reference" + ociv1 "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/file" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + + "github.com/NVIDIA/aicr/pkg/defaults" "github.com/NVIDIA/aicr/pkg/errors" "github.com/NVIDIA/aicr/pkg/evidence/attestation" ) +// maxReferrerManifestBytes caps the in-memory read of a referrer +// manifest JSON. Manifests are small (KiB); anything past this is a +// bug or hostile. +const maxReferrerManifestBytes = 1 << 20 // 1 MiB + // MaterializedBundle is the verifier's view of a bundle on local disk. -// Only directory input is sourced today; OCI fetch and pointer-driven -// pull would populate additional fields here. type MaterializedBundle struct { + // BundleDir is the local directory containing recipe.yaml, + // manifest.json, ctrf/*, etc. Always populated. BundleDir string + // Reference and Digest are populated when the bundle came from an + // OCI source. Reference is the canonical registry/repo:tag string; + // Digest is the resolved OCI manifest digest ("sha256:..."). + Reference string + Digest string + cleanup func() } -// Cleanup releases temp resources. No-op for directory input. +// Cleanup releases any temporary directories the verifier created. func (m *MaterializedBundle) Cleanup() { if m == nil || m.cleanup == nil { return @@ -41,19 +64,28 @@ func (m *MaterializedBundle) Cleanup() { m.cleanup = nil } -// MaterializeBundle dispatches on InputForm. Only InputFormDir is -// handled today; OCI fetch and pointer-driven pull land in follow-up -// slices. ctx is checked once up front so cancellation behaves the -// same as the rest of the pipeline, even though directory resolution -// itself is cheap. -func MaterializeBundle(ctx context.Context, opts VerifyOptions, form InputForm) (*MaterializedBundle, error) { +// MaterializeBundle dispatches on InputForm. Returns a directory the +// rest of the verifier reads from, plus optional OCI provenance. +func MaterializeBundle( + ctx context.Context, + opts VerifyOptions, + form InputForm, + pointer *attestation.Pointer, +) (*MaterializedBundle, error) { + if err := ctx.Err(); err != nil { return nil, errors.Wrap(errors.ErrCodeUnavailable, "materialize canceled", err) } - if form != InputFormDir { - return nil, errors.New(errors.ErrCodeInvalidRequest, "unsupported input form: "+string(form)) + switch form { + case InputFormDir: + return materializeDir(opts.Input) + case InputFormPointer: + return materializeFromPointer(ctx, pointer, opts) + case InputFormOCI: + return materializeOCIRef(ctx, opts.Input, opts) + default: + return nil, errors.New(errors.ErrCodeInvalidRequest, "unknown input form "+string(form)) } - return materializeDir(opts.Input) } // materializeDir accepts either the summary-bundle root or a parent @@ -81,3 +113,243 @@ func hasBundleMarkers(dir string) bool { } return true } + +// materializeFromPointer pulls the OCI artifact named in the pointer's +// first attestation, or falls back to opts.BundleRef when the pointer +// has no OCI ref. The pointer's digest claim is cross-checked against +// the actual pulled digest. +func materializeFromPointer( + ctx context.Context, + pointer *attestation.Pointer, + opts VerifyOptions, +) (*MaterializedBundle, error) { + + if pointer == nil || len(pointer.Attestations) == 0 { + return nil, errors.New(errors.ErrCodeInvalidRequest, "pointer has no attestations") + } + att := pointer.Attestations[0] + ref := att.Bundle.OCI + if ref == "" { + ref = opts.BundleRef + } + if ref == "" { + return nil, errors.New(errors.ErrCodeInvalidRequest, + "pointer carries no bundle.oci — re-run with --bundle or point at the unpacked directory") + } + mat, err := materializeOCIRef(ctx, ref, opts) + if err != nil { + return nil, err + } + if att.Bundle.Digest != "" && mat.Digest != "" && att.Bundle.Digest != mat.Digest { + mat.Cleanup() + return nil, errors.New(errors.ErrCodeInvalidRequest, + "pointer digest "+att.Bundle.Digest+" does not match pulled digest "+mat.Digest) + } + return mat, nil +} + +// materializeOCIRef pulls an OCI artifact into a temp directory using +// oras.Copy from a remote repository to a local file store. The file +// store unpacks the gzip-tar layer the emitter writes, so the result +// is the bundle tree on disk. +func materializeOCIRef(ctx context.Context, ref string, opts VerifyOptions) (*MaterializedBundle, error) { + registry, repo, refTarget, err := parseOCIReference(ref) + if err != nil { + return nil, err + } + + tmp, err := os.MkdirTemp("", "aicr-evidence-pull-") + if err != nil { + return nil, errors.Wrap(errors.ErrCodeInternal, "failed to create temp dir for OCI pull", err) + } + cleanup := func() { _ = os.RemoveAll(tmp) } + + fs, fsErr := file.New(tmp) + if fsErr != nil { + cleanup() + return nil, errors.Wrap(errors.ErrCodeInternal, "failed to create file store for OCI pull", fsErr) + } + defer func() { _ = fs.Close() }() + + remoteRepo, rErr := remote.NewRepository(registry + "/" + repo) + if rErr != nil { + cleanup() + return nil, errors.Wrap(errors.ErrCodeInternal, "failed to initialize remote repository", rErr) + } + remoteRepo.PlainHTTP = opts.PlainHTTP + remoteRepo.Client = newAuthClient(opts.PlainHTTP, opts.InsecureTLS) + + pullCtx, pullCancel := context.WithTimeout(ctx, defaults.EvidenceBundlePushTimeout) + defer pullCancel() + desc, copyErr := oras.Copy(pullCtx, remoteRepo, refTarget, fs, refTarget, oras.DefaultCopyOptions) + if copyErr != nil { + cleanup() + return nil, errors.Wrap(errors.ErrCodeUnavailable, "OCI pull failed", copyErr) + } + + resolved, dErr := resolveBundleDir(tmp) + if dErr != nil { + cleanup() + return nil, dErr + } + + // The Sigstore Bundle is attached as an OCI Referrer of the main + // artifact, not part of the artifact's own layers. Discover and + // stage it as attestation.intoto.jsonl so signature verification + // can read it from disk the same way it does for directory input. + // Best-effort: an unsigned bundle has no referrer and the signature + // step records Skipped (matches the unsigned-on-disk behavior). + if err := discoverAndWriteReferrer(pullCtx, remoteRepo, desc, resolved); err != nil { + slog.Debug("no Sigstore Bundle referrer discovered", + "reference", registry+"/"+repo, "error", err.Error()) + } + + return &MaterializedBundle{ + BundleDir: resolved, + Reference: registry + "/" + repo + ":" + refTarget, + Digest: desc.Digest.String(), + cleanup: cleanup, + }, nil +} + +// referrerFetcher is the minimal subset of *remote.Repository that +// fetchAndWriteReferrerLayer uses. Exists so tests can substitute an +// in-memory fake without spinning up a real registry. +type referrerFetcher interface { + Fetch(ctx context.Context, target ociv1.Descriptor) (io.ReadCloser, error) +} + +// discoverAndWriteReferrer queries the Referrers API for a Sigstore +// Bundle attached to the subject artifact, fetches its single layer, +// and writes it to bundleDir as attestation.intoto.jsonl. +// +// Returns ErrCodeNotFound when no Sigstore Bundle referrer is present; +// callers treat that as "unsigned bundle." Other errors propagate. +func discoverAndWriteReferrer(ctx context.Context, repo *remote.Repository, subject ociv1.Descriptor, bundleDir string) error { + found := false + cbErr := repo.Referrers(ctx, subject, attestation.SigstoreBundleMediaType, + func(refs []ociv1.Descriptor) error { + for _, r := range refs { + if r.ArtifactType != attestation.SigstoreBundleMediaType { + continue + } + if err := fetchAndWriteReferrerLayer(ctx, repo, r, bundleDir); err != nil { + return err + } + found = true + // Take the first matching referrer. Multi-signature + // bundles aren't a V1 case; if one ever lands, we'd + // need a selection policy. + return nil + } + return nil + }) + if cbErr != nil { + return errors.Wrap(errors.ErrCodeUnavailable, "referrers query failed", cbErr) + } + if !found { + return errors.New(errors.ErrCodeNotFound, "no Sigstore Bundle referrer for artifact") + } + return nil +} + +// fetchAndWriteReferrerLayer pulls the referrer's manifest, extracts +// its single layer descriptor, fetches the layer blob (the Sigstore +// Bundle bytes), and writes them to attestation.intoto.jsonl. +func fetchAndWriteReferrerLayer(ctx context.Context, repo referrerFetcher, referrerDesc ociv1.Descriptor, bundleDir string) error { + manifestRdr, err := repo.Fetch(ctx, referrerDesc) + if err != nil { + return errors.Wrap(errors.ErrCodeUnavailable, "failed to fetch referrer manifest", err) + } + defer func() { _ = manifestRdr.Close() }() + + manifestBytes, err := io.ReadAll(io.LimitReader(manifestRdr, maxReferrerManifestBytes+1)) + if err != nil { + return errors.Wrap(errors.ErrCodeInternal, "failed to read referrer manifest", err) + } + if int64(len(manifestBytes)) > maxReferrerManifestBytes { + return errors.New(errors.ErrCodeInvalidRequest, + "referrer manifest exceeds size limit") + } + + var manifest ociv1.Manifest + if uErr := json.Unmarshal(manifestBytes, &manifest); uErr != nil { + return errors.Wrap(errors.ErrCodeInvalidRequest, + "referrer manifest is not valid JSON", uErr) + } + if len(manifest.Layers) != 1 { + return errors.New(errors.ErrCodeInvalidRequest, + "expected single-layer Sigstore Bundle referrer manifest") + } + layerDesc := manifest.Layers[0] + if layerDesc.Size > defaults.MaxSigstoreBundleSize { + return errors.New(errors.ErrCodeInvalidRequest, + "referrer layer exceeds Sigstore Bundle size limit") + } + + layerRdr, err := repo.Fetch(ctx, layerDesc) + if err != nil { + return errors.Wrap(errors.ErrCodeUnavailable, "failed to fetch referrer layer", err) + } + defer func() { _ = layerRdr.Close() }() + + outPath := filepath.Join(bundleDir, attestation.AttestationFilename) + out, err := os.Create(outPath) //nolint:gosec // verifier-controlled temp dir + if err != nil { + return errors.Wrap(errors.ErrCodeInternal, "failed to create attestation file", err) + } + if _, copyErr := io.Copy(out, io.LimitReader(layerRdr, defaults.MaxSigstoreBundleSize)); copyErr != nil { + _ = out.Close() + return errors.Wrap(errors.ErrCodeInternal, "failed to write attestation file", copyErr) + } + if closeErr := out.Close(); closeErr != nil { + return errors.Wrap(errors.ErrCodeInternal, "failed to close attestation file", closeErr) + } + return nil +} + +// resolveBundleDir picks the bundle root from a temp dir holding the +// pulled or extracted layer contents. +func resolveBundleDir(dir string) (string, error) { + if hasBundleMarkers(dir) { + return dir, nil + } + candidate := filepath.Join(dir, attestation.SummaryBundleDirName) + if hasBundleMarkers(candidate) { + return candidate, nil + } + return "", errors.New(errors.ErrCodeInvalidRequest, + "pulled artifact does not contain a recognizable summary bundle") +} + +// parseOCIReference splits a reference into (registry, repository, target). +// target is the tag or digest portion ORAS resolves against the remote. +func parseOCIReference(ref string) (registry, repo, target string, err error) { + clean := strings.TrimPrefix(ref, "oci://") + named, parseErr := reference.ParseNormalizedNamed(clean) + if parseErr != nil { + return "", "", "", errors.Wrap(errors.ErrCodeInvalidRequest, "invalid OCI reference", parseErr) + } + registry = reference.Domain(named) + repo = reference.Path(named) + if digested, ok := named.(reference.Digested); ok { + target = digested.Digest().String() + } else if tagged, ok := named.(reference.Tagged); ok { + target = tagged.Tag() + } + if target == "" { + return "", "", "", errors.New(errors.ErrCodeInvalidRequest, + "OCI reference "+ref+" must include a tag or digest") + } + return registry, repo, target, nil +} + +// newAuthClient returns nil for plainHTTP/insecureTLS so ORAS falls +// back to its built-in default; otherwise returns the package-level +// auth.DefaultClient which honors ambient docker credentials. +func newAuthClient(plainHTTP, insecureTLS bool) *auth.Client { + if plainHTTP || insecureTLS { + return nil + } + return auth.DefaultClient +} diff --git a/pkg/evidence/verifier/fetch_test.go b/pkg/evidence/verifier/fetch_test.go index c1fe015fb..f70cc8a1f 100644 --- a/pkg/evidence/verifier/fetch_test.go +++ b/pkg/evidence/verifier/fetch_test.go @@ -16,6 +16,7 @@ package verifier import ( "context" + "strings" "testing" ) @@ -23,14 +24,14 @@ func TestMaterializeBundle_DirAcceptsParentOrSummary(t *testing.T) { bundleDir := buildTestBundle(t) mat, err := MaterializeBundle(context.Background(), - VerifyOptions{Input: bundleDir}, InputFormDir) + VerifyOptions{Input: bundleDir}, InputFormDir, nil) if err != nil { t.Fatalf("MaterializeBundle(parent): %v", err) } mat.Cleanup() mat2, err := MaterializeBundle(context.Background(), - VerifyOptions{Input: summaryDirOf(t, bundleDir)}, InputFormDir) + VerifyOptions{Input: summaryDirOf(t, bundleDir)}, InputFormDir, nil) if err != nil { t.Fatalf("MaterializeBundle(summary): %v", err) } @@ -39,8 +40,42 @@ func TestMaterializeBundle_DirAcceptsParentOrSummary(t *testing.T) { func TestMaterializeBundle_DirRejectsNonBundle(t *testing.T) { _, err := MaterializeBundle(context.Background(), - VerifyOptions{Input: t.TempDir()}, InputFormDir) + VerifyOptions{Input: t.TempDir()}, InputFormDir, nil) if err == nil { t.Errorf("expected error for empty directory") } } + +func TestParseOCIReference(t *testing.T) { + tests := []struct { + name string + in string + wantReg string + wantRepo string + wantTarget string + wantErr bool + }{ + {"with tag", "ghcr.io/owner/aicr-evidence:v1", "ghcr.io", "owner/aicr-evidence", "v1", false}, + {"with digest", "ghcr.io/owner/aicr-evidence@sha256:" + strings.Repeat("a", 64), + "ghcr.io", "owner/aicr-evidence", "sha256:" + strings.Repeat("a", 64), false}, + {"oci scheme prefix", "oci://ghcr.io/owner/aicr-evidence:v1", + "ghcr.io", "owner/aicr-evidence", "v1", false}, + {"missing target", "ghcr.io/owner/aicr-evidence", "", "", "", true}, + {"invalid", "::not-a-ref", "", "", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg, repo, target, err := parseOCIReference(tt.in) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if reg != tt.wantReg || repo != tt.wantRepo || target != tt.wantTarget { + t.Errorf("got (%q, %q, %q), want (%q, %q, %q)", + reg, repo, target, tt.wantReg, tt.wantRepo, tt.wantTarget) + } + }) + } +} diff --git a/pkg/evidence/verifier/input.go b/pkg/evidence/verifier/input.go index 7e2efeadf..7179c3292 100644 --- a/pkg/evidence/verifier/input.go +++ b/pkg/evidence/verifier/input.go @@ -16,24 +16,57 @@ package verifier import ( "os" + "strings" "github.com/NVIDIA/aicr/pkg/errors" ) -// DetectInputForm classifies a user-supplied input. Only directory -// input is supported; pointer and OCI forms are rejected. +// DetectInputForm classifies a user-supplied input string into one of +// the three supported transport forms. Detection precedence: +// +// 1. URL prefix: oci:// → OCI; http(s):// is rejected. +// 2. Filesystem: directory → dir; .yaml/.yml file → pointer. +// 3. Bare OCI ref shape ("registry/repo[:tag][@digest]") → OCI. func DetectInputForm(input string) (InputForm, error) { if input == "" { return "", errors.New(errors.ErrCodeInvalidRequest, "input is empty") } - info, err := os.Stat(input) - if err != nil { + if strings.HasPrefix(input, "oci://") { + return InputFormOCI, nil + } + if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { return "", errors.New(errors.ErrCodeInvalidRequest, - "input "+input+" is not a directory (pointer and OCI forms not yet supported)") + "http(s):// inputs are not supported — use oci://, a pointer file, or a local directory") } - if !info.IsDir() { + if info, err := os.Stat(input); err == nil { + if info.IsDir() { + return InputFormDir, nil + } + if strings.HasSuffix(input, ".yaml") || strings.HasSuffix(input, ".yml") { + return InputFormPointer, nil + } return "", errors.New(errors.ErrCodeInvalidRequest, - "input "+input+" is not a directory (pointer and OCI forms not yet supported)") + "input "+input+" is a file with an unrecognized extension (expected .yaml/.yml pointer)") + } + if looksLikeOCIRef(input) { + return InputFormOCI, nil + } + return "", errors.New(errors.ErrCodeInvalidRequest, + "input "+input+" is not a recognizable pointer / OCI ref / directory") +} + +// looksLikeOCIRef is a cheap shape check for a bare reference. The +// full parse happens in parseOCIReference when materialization runs. +func looksLikeOCIRef(s string) bool { + first, _, ok := strings.Cut(s, "/") + if !ok { + return false + } + if !strings.ContainsAny(first, ".:") && first != "localhost" { + return false + } + if strings.ContainsAny(s, " \t") { + return false } - return InputFormDir, nil + return true } diff --git a/pkg/evidence/verifier/input_test.go b/pkg/evidence/verifier/input_test.go index b304ea4a0..3152a914a 100644 --- a/pkg/evidence/verifier/input_test.go +++ b/pkg/evidence/verifier/input_test.go @@ -38,10 +38,15 @@ func TestDetectInputForm(t *testing.T) { wantErr bool }{ {"empty rejected", "", "", true}, + {"https rejected", "https://example.com/x", "", true}, + {"http rejected", "http://example.com/x", "", true}, {"directory accepted", dir, InputFormDir, false}, - {"yaml rejected (pointer not yet supported)", yamlPath, "", true}, - {"oci-scheme rejected (not yet supported)", "oci://ghcr.io/x/y:v1", "", true}, - {"nonexistent path rejected", filepath.Join(tmp, "nope"), "", true}, + {"yaml pointer accepted", yamlPath, InputFormPointer, false}, + {"oci-scheme accepted", "oci://ghcr.io/x/y:v1", InputFormOCI, false}, + {"bare oci with digest", "ghcr.io/owner/repo@sha256:abc", InputFormOCI, false}, + {"bare oci with tag", "ghcr.io/owner/repo:v1", InputFormOCI, false}, + {"localhost registry", "localhost:5000/repo:v1", InputFormOCI, false}, + {"nonsense rejected", "not-an-input", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/evidence/verifier/pointer.go b/pkg/evidence/verifier/pointer.go new file mode 100644 index 000000000..d8d026536 --- /dev/null +++ b/pkg/evidence/verifier/pointer.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package verifier + +import ( + "io" + "os" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/NVIDIA/aicr/pkg/defaults" + "github.com/NVIDIA/aicr/pkg/errors" + "github.com/NVIDIA/aicr/pkg/evidence/attestation" +) + +// pointerSizeCeiling caps the bytes the verifier will read from a +// pointer file. 1 MiB matches defaults.MaxRecipePOSTBytes. Pointers +// are tiny; anything past this is either a bug or hostile input. +var pointerSizeCeiling = defaults.MaxRecipePOSTBytes + +// LoadAndValidatePointer reads and validates the pointer file at path. +// V1 enforces schema 1.0.x with exactly one attestation entry — schema +// 2.0 (multi-instance pointers) is reserved. +func LoadAndValidatePointer(path string) (*attestation.Pointer, error) { + f, err := os.Open(path) //nolint:gosec // operator-supplied path + if err != nil { + return nil, errors.Wrap(errors.ErrCodeNotFound, "failed to open pointer file", err) + } + defer func() { _ = f.Close() }() + + body, readErr := io.ReadAll(io.LimitReader(f, pointerSizeCeiling+1)) + if readErr != nil { + return nil, errors.Wrap(errors.ErrCodeInternal, "failed to read pointer file", readErr) + } + if int64(len(body)) > pointerSizeCeiling { + return nil, errors.New(errors.ErrCodeInvalidRequest, + "pointer file exceeds size limit (1 MiB)") + } + + var ptr attestation.Pointer + if uErr := yaml.Unmarshal(body, &ptr); uErr != nil { + return nil, errors.Wrap(errors.ErrCodeInvalidRequest, "pointer file is not valid YAML", uErr) + } + if err := validatePointer(&ptr); err != nil { + return nil, err + } + return &ptr, nil +} + +func validatePointer(p *attestation.Pointer) error { + if p == nil { + return errors.New(errors.ErrCodeInvalidRequest, "pointer is nil") + } + if !isSupportedPointerSchema(p.SchemaVersion) { + return errors.New(errors.ErrCodeInvalidRequest, + "unsupported pointer schemaVersion "+p.SchemaVersion+" (verifier supports 1.0.x)") + } + if p.Recipe == "" { + return errors.New(errors.ErrCodeInvalidRequest, "pointer.recipe is required") + } + switch len(p.Attestations) { + case 0: + return errors.New(errors.ErrCodeInvalidRequest, + "pointer.attestations must have at least one entry") + case 1: + // expected + default: + return errors.New(errors.ErrCodeInvalidRequest, + "pointer.attestations has multiple entries — schema 2.0 not yet supported") + } + att := p.Attestations[0] + if att.Bundle.PredicateType != attestation.PredicateTypeV1 { + return errors.New(errors.ErrCodeInvalidRequest, + "unsupported predicateType "+att.Bundle.PredicateType) + } + if att.Bundle.OCI != "" && !strings.HasPrefix(att.Bundle.Digest, "sha256:") { + return errors.New(errors.ErrCodeInvalidRequest, + "pointer.attestations[0].bundle.digest must be sha256: when OCI is set") + } + if att.Signer != nil && (att.Signer.Identity == "" || att.Signer.Issuer == "") { + return errors.New(errors.ErrCodeInvalidRequest, + "pointer.attestations[0].signer requires identity and issuer when present") + } + return nil +} + +func isSupportedPointerSchema(v string) bool { + return strings.HasPrefix(v, "1.0.") || v == "1.0" +} diff --git a/pkg/evidence/verifier/pointer_test.go b/pkg/evidence/verifier/pointer_test.go new file mode 100644 index 000000000..956ffa593 --- /dev/null +++ b/pkg/evidence/verifier/pointer_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package verifier + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadAndValidatePointer_HappyPath(t *testing.T) { + body := `schemaVersion: 1.0.0 +recipe: h100-eks-ubuntu-training +attestations: +- bundle: + oci: ghcr.io/owner/aicr-evidence:v1 + digest: sha256:abc + predicateType: https://aicr.nvidia.com/recipe-evidence/v1 + attestedAt: 2026-05-08T10:23:11Z +` + p := filepath.Join(t.TempDir(), "pointer.yaml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + got, err := LoadAndValidatePointer(p) + if err != nil { + t.Fatalf("LoadAndValidatePointer: %v", err) + } + if got.Recipe != "h100-eks-ubuntu-training" { + t.Errorf("Recipe = %q", got.Recipe) + } +} + +func TestLoadAndValidatePointer_Rejects(t *testing.T) { + tests := []struct { + name string + body string + }{ + {"unsupported schema", `schemaVersion: 2.0.0 +recipe: x +attestations: [{bundle: {predicateType: https://aicr.nvidia.com/recipe-evidence/v1}, attestedAt: 2026-05-08T10:23:11Z}] +`}, + {"missing recipe", `schemaVersion: 1.0.0 +attestations: [{bundle: {predicateType: https://aicr.nvidia.com/recipe-evidence/v1}, attestedAt: 2026-05-08T10:23:11Z}] +`}, + {"no attestations", `schemaVersion: 1.0.0 +recipe: x +attestations: [] +`}, + {"multiple attestations", `schemaVersion: 1.0.0 +recipe: x +attestations: +- {bundle: {predicateType: https://aicr.nvidia.com/recipe-evidence/v1}, attestedAt: 2026-05-08T10:23:11Z} +- {bundle: {predicateType: https://aicr.nvidia.com/recipe-evidence/v1}, attestedAt: 2026-05-08T10:23:11Z} +`}, + {"wrong predicate type", `schemaVersion: 1.0.0 +recipe: x +attestations: [{bundle: {predicateType: wrong}, attestedAt: 2026-05-08T10:23:11Z}] +`}, + {"bad digest format", `schemaVersion: 1.0.0 +recipe: x +attestations: +- bundle: {oci: ghcr.io/x/y:v1, digest: no-prefix, predicateType: https://aicr.nvidia.com/recipe-evidence/v1} + attestedAt: 2026-05-08T10:23:11Z +`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := filepath.Join(t.TempDir(), "pointer.yaml") + if err := os.WriteFile(p, []byte(tt.body), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := LoadAndValidatePointer(p); err == nil { + t.Errorf("expected error") + } + }) + } +} + +func TestLoadAndValidatePointer_RejectsHuge(t *testing.T) { + big := make([]byte, pointerSizeCeiling+1) + for i := range big { + big[i] = 'a' + } + p := filepath.Join(t.TempDir(), "huge.yaml") + if err := os.WriteFile(p, big, 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := LoadAndValidatePointer(p); err == nil { + t.Errorf("expected error for oversize pointer") + } +} diff --git a/pkg/evidence/verifier/referrer_test.go b/pkg/evidence/verifier/referrer_test.go new file mode 100644 index 000000000..f0d33285d --- /dev/null +++ b/pkg/evidence/verifier/referrer_test.go @@ -0,0 +1,186 @@ +// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package verifier + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/opencontainers/go-digest" + ociv1 "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/NVIDIA/aicr/pkg/evidence/attestation" +) + +// fakeFetcher is an in-memory referrerFetcher keyed by sha256 digest. +type fakeFetcher struct { + blobs map[digest.Digest][]byte + err error // forces every Fetch to fail +} + +func (f *fakeFetcher) Fetch(_ context.Context, target ociv1.Descriptor) (io.ReadCloser, error) { + if f.err != nil { + return nil, f.err + } + body, ok := f.blobs[target.Digest] + if !ok { + return nil, os.ErrNotExist + } + return io.NopCloser(bytes.NewReader(body)), nil +} + +func sha256Digest(b []byte) digest.Digest { + sum := sha256.Sum256(b) + return digest.NewDigestFromHex("sha256", hex.EncodeToString(sum[:])) +} + +// buildReferrerFixture stages a single-layer referrer manifest plus +// its layer blob in a fakeFetcher and returns the manifest descriptor +// that fetchAndWriteReferrerLayer would receive from the Referrers API. +func buildReferrerFixture(t *testing.T, bundleBody []byte) (*fakeFetcher, ociv1.Descriptor) { + t.Helper() + layerDigest := sha256Digest(bundleBody) + layerDesc := ociv1.Descriptor{ + MediaType: attestation.SigstoreBundleMediaType, + Digest: layerDigest, + Size: int64(len(bundleBody)), + } + manifest := ociv1.Manifest{ + MediaType: ociv1.MediaTypeImageManifest, + ArtifactType: attestation.SigstoreBundleMediaType, + Layers: []ociv1.Descriptor{layerDesc}, + } + manifestBytes, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + manifestDigest := sha256Digest(manifestBytes) + manifestDesc := ociv1.Descriptor{ + MediaType: ociv1.MediaTypeImageManifest, + Digest: manifestDigest, + Size: int64(len(manifestBytes)), + ArtifactType: attestation.SigstoreBundleMediaType, + } + return &fakeFetcher{ + blobs: map[digest.Digest][]byte{ + manifestDigest: manifestBytes, + layerDigest: bundleBody, + }, + }, manifestDesc +} + +func TestFetchAndWriteReferrerLayer_HappyPath(t *testing.T) { + bundleBody := []byte(`{"fake":"sigstore bundle"}`) + fetcher, manifestDesc := buildReferrerFixture(t, bundleBody) + bundleDir := t.TempDir() + + if err := fetchAndWriteReferrerLayer(context.Background(), fetcher, manifestDesc, bundleDir); err != nil { + t.Fatalf("fetchAndWriteReferrerLayer: %v", err) + } + got, err := os.ReadFile(filepath.Join(bundleDir, attestation.AttestationFilename)) + if err != nil { + t.Fatalf("read written attestation: %v", err) + } + if !bytes.Equal(got, bundleBody) { + t.Errorf("written attestation = %q, want %q", got, bundleBody) + } +} + +func TestFetchAndWriteReferrerLayer_RejectsMultiLayerManifest(t *testing.T) { + // Build a manifest with two layers — single-layer Sigstore Bundle + // referrers are the V1 contract; anything else is malformed. + layerA := []byte("a") + layerB := []byte("b") + manifest := ociv1.Manifest{ + MediaType: ociv1.MediaTypeImageManifest, + ArtifactType: attestation.SigstoreBundleMediaType, + Layers: []ociv1.Descriptor{ + {Digest: sha256Digest(layerA), Size: 1}, + {Digest: sha256Digest(layerB), Size: 1}, + }, + } + manifestBytes, _ := json.Marshal(manifest) + manifestDigest := sha256Digest(manifestBytes) + fetcher := &fakeFetcher{blobs: map[digest.Digest][]byte{manifestDigest: manifestBytes}} + manifestDesc := ociv1.Descriptor{Digest: manifestDigest, Size: int64(len(manifestBytes))} + + err := fetchAndWriteReferrerLayer(context.Background(), fetcher, manifestDesc, t.TempDir()) + if err == nil { + t.Fatalf("expected error for multi-layer manifest") + } + if !strings.Contains(err.Error(), "single-layer") { + t.Errorf("error should mention single-layer; got %v", err) + } +} + +func TestFetchAndWriteReferrerLayer_RejectsOversizedManifest(t *testing.T) { + // A manifest body larger than the limit triggers the bound check. + body := make([]byte, maxReferrerManifestBytes+1024) + for i := range body { + body[i] = '{' + } + manifestDigest := sha256Digest(body) + fetcher := &fakeFetcher{blobs: map[digest.Digest][]byte{manifestDigest: body}} + desc := ociv1.Descriptor{Digest: manifestDigest, Size: int64(len(body))} + + err := fetchAndWriteReferrerLayer(context.Background(), fetcher, desc, t.TempDir()) + if err == nil { + t.Fatalf("expected error for oversize manifest") + } +} + +func TestFetchAndWriteReferrerLayer_RejectsOversizedLayer(t *testing.T) { + // Manifest is small, but layer descriptor claims a size that + // exceeds the Sigstore Bundle limit — refuse before fetching. + layerDigest := digest.NewDigestFromHex("sha256", strings.Repeat("a", 64)) + manifest := ociv1.Manifest{ + MediaType: ociv1.MediaTypeImageManifest, + ArtifactType: attestation.SigstoreBundleMediaType, + Layers: []ociv1.Descriptor{ + {Digest: layerDigest, Size: 1 << 40}, // 1 TiB + }, + } + manifestBytes, _ := json.Marshal(manifest) + manifestDigest := sha256Digest(manifestBytes) + fetcher := &fakeFetcher{blobs: map[digest.Digest][]byte{manifestDigest: manifestBytes}} + desc := ociv1.Descriptor{Digest: manifestDigest, Size: int64(len(manifestBytes))} + + err := fetchAndWriteReferrerLayer(context.Background(), fetcher, desc, t.TempDir()) + if err == nil { + t.Fatalf("expected error for oversize layer claim") + } + if !strings.Contains(err.Error(), "size limit") { + t.Errorf("error should mention size limit; got %v", err) + } +} + +func TestFetchAndWriteReferrerLayer_RejectsBadManifestJSON(t *testing.T) { + bogus := []byte("not json") + manifestDigest := sha256Digest(bogus) + fetcher := &fakeFetcher{blobs: map[digest.Digest][]byte{manifestDigest: bogus}} + desc := ociv1.Descriptor{Digest: manifestDigest, Size: int64(len(bogus))} + + if err := fetchAndWriteReferrerLayer(context.Background(), fetcher, desc, t.TempDir()); err == nil { + t.Fatalf("expected error for malformed manifest JSON") + } +} diff --git a/pkg/evidence/verifier/report.go b/pkg/evidence/verifier/report.go index 67e2e35ce..b205cc47c 100644 --- a/pkg/evidence/verifier/report.go +++ b/pkg/evidence/verifier/report.go @@ -25,8 +25,9 @@ import ( // RenderMarkdown produces the PR-comment-shaped summary. Signed // predicate fields (fingerprint, phase counts, BOM info) are surfaced -// here. The Signer line marks the bundle as unsigned until -// cryptographic signature verification lands. +// directly — when the signature step passed, the predicate body is +// cryptographically anchored to the Fulcio cert claims shown on the +// Signer line. func RenderMarkdown(r *VerifyResult) string { if r == nil { return "## Evidence verification — (no result)\n" @@ -48,11 +49,25 @@ func RenderMarkdown(r *VerifyResult) string { } func writeHeader(b *strings.Builder, r *VerifyResult) { - b.WriteString("**Signer:** _signature verification not yet implemented in this slice_\n") + if r.Signer != nil && r.Signer.Identity != "" { + fmt.Fprintf(b, "**Signer:** %s", r.Signer.Identity) + if r.Signer.Issuer != "" { + fmt.Fprintf(b, " (issuer %s)", r.Signer.Issuer) + } + if r.Signer.RekorLogIndex != nil { + fmt.Fprintf(b, " • **Rekor:** index %d", *r.Signer.RekorLogIndex) + } + b.WriteString("\n") + } else { + b.WriteString("**Signer:** _unsigned bundle_\n") + } if r.Predicate != nil { fmt.Fprintf(b, "**AICR:** %s • **Schema:** %s", r.Predicate.AICRVersion, r.Predicate.SchemaVersion) } + if r.BundleDigest != "" { + fmt.Fprintf(b, " • **Bundle digest:** %s", r.BundleDigest) + } b.WriteString("\n\n") } diff --git a/pkg/evidence/verifier/signature.go b/pkg/evidence/verifier/signature.go new file mode 100644 index 000000000..2fad63d5d --- /dev/null +++ b/pkg/evidence/verifier/signature.go @@ -0,0 +1,285 @@ +// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package verifier + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + + protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/verify" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/NVIDIA/aicr/pkg/defaults" + "github.com/NVIDIA/aicr/pkg/errors" + "github.com/NVIDIA/aicr/pkg/evidence/attestation" + "github.com/NVIDIA/aicr/pkg/trust" +) + +// ErrUnsignedBundle is returned by VerifySignature when no Sigstore +// Bundle file is present in the bundle directory. Callers translate +// this to a Skipped step row. +var ErrUnsignedBundle = errors.New(errors.ErrCodeNotFound, "no signature attached (unsigned bundle)") + +// SignatureResult is what a successful VerifySignature returns. +type SignatureResult struct { + // Signer holds OIDC claims extracted from the verifying cert. + Signer *SignerClaims + + // Predicate is the cryptographically anchored predicate body + // extracted from the verified DSSE payload. Callers should prefer + // this over the unsigned statement.intoto.json when present — + // THIS is the value the signer attested to. + Predicate *attestation.Predicate +} + +// VerifySignature performs sigstore-go verification of the bundle's +// in-toto Statement signature. Returns ErrUnsignedBundle when no +// attestation.intoto.jsonl is present. +// +// For OCI inputs the subject digest in the signed Statement is locked +// to the actual pulled artifact digest — a mismatch means someone +// substituted the bundle and re-pointed at a different signature. +func VerifySignature(ctx context.Context, mat *MaterializedBundle, opts VerifyOptions) (*SignatureResult, error) { + if mat == nil || mat.BundleDir == "" { + return nil, errors.New(errors.ErrCodeInvalidRequest, "materialized bundle is required") + } + if err := ctx.Err(); err != nil { + return nil, errors.Wrap(errors.ErrCodeTimeout, "context canceled before signature verify", err) + } + + sigPath := filepath.Join(mat.BundleDir, attestation.AttestationFilename) + info, statErr := os.Stat(sigPath) + if statErr != nil && os.IsNotExist(statErr) { + return nil, ErrUnsignedBundle + } + if statErr != nil { + return nil, errors.Wrap(errors.ErrCodeInternal, "failed to stat signature file", statErr) + } + if info.Size() > defaults.MaxSigstoreBundleSize { + return nil, errors.New(errors.ErrCodeInvalidRequest, + "signature file exceeds maximum size — refusing to parse") + } + + sigBundle, parseErr := loadSigstoreBundle(sigPath) + if parseErr != nil { + return nil, parseErr + } + + stmtBytes, payloadErr := extractStatementBytes(sigBundle) + if payloadErr != nil { + return nil, payloadErr + } + + subjectHex, predicate, parseStmtErr := parseStatement(stmtBytes) + if parseStmtErr != nil { + return nil, parseStmtErr + } + digestBytes, decodeErr := hex.DecodeString(subjectHex) + if decodeErr != nil { + return nil, errors.Wrap(errors.ErrCodeInvalidRequest, + "signed statement subject digest is not valid hex", decodeErr) + } + if mat.Digest != "" { + want := strings.TrimPrefix(mat.Digest, "sha256:") + if want != subjectHex { + return nil, errors.New(errors.ErrCodeInvalidRequest, + "signed subject digest ("+subjectHex+ + ") does not match pulled artifact digest ("+want+")") + } + } + + trustedMaterial, trustErr := trust.GetTrustedMaterial() + if trustErr != nil { + return nil, errors.Wrap(errors.ErrCodeInternal, "failed to load trusted root", trustErr) + } + + identity, idErr := buildIdentityMatcher(opts) + if idErr != nil { + return nil, idErr + } + + verifierOpts := []verify.VerifierOption{verify.WithObserverTimestamps(1)} + if !opts.NoRekor { + verifierOpts = append(verifierOpts, verify.WithTransparencyLog(1)) + } + v, vErr := verify.NewVerifier(trustedMaterial, verifierOpts...) + if vErr != nil { + return nil, errors.Wrap(errors.ErrCodeInternal, "failed to create sigstore verifier", vErr) + } + + result, verifyErr := v.Verify(sigBundle, verify.NewPolicy( + verify.WithArtifactDigest("sha256", digestBytes), + verify.WithCertificateIdentity(identity), + )) + if verifyErr != nil { + if isCertChainError(verifyErr.Error()) { + return nil, errors.New(errors.ErrCodeUnauthorized, + "sigstore verification failed — trusted root may be stale.\n\n To fix: aicr trust update") + } + return nil, errors.Wrap(errors.ErrCodeUnauthorized, "sigstore verification failed", verifyErr) + } + + claims := &SignerClaims{} + if result != nil && result.Signature != nil && result.Signature.Certificate != nil { + claims.Identity = result.Signature.Certificate.SubjectAlternativeName + claims.Issuer = result.Signature.Certificate.Issuer + } + if idx := rekorLogIndex(sigBundle); idx > 0 { + i := idx + claims.RekorLogIndex = &i + } + return &SignatureResult{Signer: claims, Predicate: predicate}, nil +} + +func buildIdentityMatcher(opts VerifyOptions) (verify.CertificateIdentity, error) { + issuerLit, issuerRe := "", ".+" + if opts.ExpectedIssuer != "" { + issuerLit, issuerRe = opts.ExpectedIssuer, "" + } + idLit, idRe := "", ".+" + if opts.ExpectedIdentityRegexp != "" { + idLit, idRe = "", opts.ExpectedIdentityRegexp + } + identity, err := verify.NewShortCertificateIdentity(issuerLit, issuerRe, idLit, idRe) + if err != nil { + return verify.CertificateIdentity{}, errors.Wrap(errors.ErrCodeInternal, + "failed to build certificate identity matcher", err) + } + return identity, nil +} + +func loadSigstoreBundle(path string) (*bundle.Bundle, error) { + data, err := os.ReadFile(path) //nolint:gosec // bundle-local path + if err != nil { + return nil, errors.Wrap(errors.ErrCodeInternal, "failed to read sigstore bundle", err) + } + var pb protobundle.Bundle + if uErr := protojson.Unmarshal(data, &pb); uErr != nil { + return nil, errors.Wrap(errors.ErrCodeInvalidRequest, "invalid sigstore bundle JSON", uErr) + } + b, ctorErr := bundle.NewBundle(&pb) + if ctorErr != nil { + return nil, errors.Wrap(errors.ErrCodeInvalidRequest, "invalid sigstore bundle", ctorErr) + } + return b, nil +} + +// extractStatementBytes pulls the DSSE payload bytes (the in-toto +// Statement JSON) out of a Sigstore Bundle. Handles both raw-JSON and +// base64-encoded payload shapes that upstream parsers may produce. +func extractStatementBytes(b *bundle.Bundle) ([]byte, error) { + if b == nil || b.Bundle == nil { + return nil, errors.New(errors.ErrCodeInvalidRequest, "nil bundle") + } + env := b.GetDsseEnvelope() + if env == nil { + return nil, errors.New(errors.ErrCodeInvalidRequest, "bundle has no DSSE envelope") + } + payload := env.GetPayload() + if len(payload) == 0 { + return nil, errors.New(errors.ErrCodeInvalidRequest, "DSSE envelope has empty payload") + } + if looksLikeJSON(payload) { + return payload, nil + } + decoded, err := base64.StdEncoding.DecodeString(string(payload)) + if err != nil { + return nil, errors.Wrap(errors.ErrCodeInvalidRequest, "failed to decode DSSE payload", err) + } + return decoded, nil +} + +// parseStatement extracts the subject sha256 (hex, no prefix) plus the +// predicate body from an in-toto Statement JSON. +func parseStatement(stmtBytes []byte) (subjectHex string, predicate *attestation.Predicate, err error) { + var stmt struct { + Subject []struct { + Digest map[string]string `json:"digest"` + } `json:"subject"` + PredicateType string `json:"predicateType"` + Predicate attestation.Predicate `json:"predicate"` + } + if uErr := json.Unmarshal(stmtBytes, &stmt); uErr != nil { + return "", nil, errors.Wrap(errors.ErrCodeInvalidRequest, + "DSSE payload is not a valid in-toto Statement", uErr) + } + if len(stmt.Subject) == 0 { + return "", nil, errors.New(errors.ErrCodeInvalidRequest, "Statement has no subject") + } + subjectHex = stmt.Subject[0].Digest["sha256"] + if subjectHex == "" { + return "", nil, errors.New(errors.ErrCodeInvalidRequest, "Statement subject has no sha256 digest") + } + if stmt.PredicateType != attestation.PredicateTypeV1 { + return "", nil, errors.New(errors.ErrCodeInvalidRequest, + "unexpected predicateType "+stmt.PredicateType) + } + return subjectHex, &stmt.Predicate, nil +} + +func looksLikeJSON(b []byte) bool { + for _, c := range b { + switch c { + case ' ', '\t', '\n', '\r': + continue + case '{', '[': + return true + default: + return false + } + } + return false +} + +func rekorLogIndex(b *bundle.Bundle) int64 { + if b == nil || b.Bundle == nil { + return 0 + } + vm := b.GetVerificationMaterial() + if vm == nil { + return 0 + } + entries := vm.GetTlogEntries() + if len(entries) == 0 { + return 0 + } + return entries[0].GetLogIndex() +} + +// isCertChainError reports whether the sigstore error string signals +// a stale trusted-root condition. Used to suggest `aicr trust update`. +func isCertChainError(msg string) bool { + stale := []string{ + "certificate signed by unknown authority", + "certificate chain", + "x509", + "unable to verify certificate", + "root certificate", + } + lower := strings.ToLower(msg) + for _, s := range stale { + if strings.Contains(lower, s) { + return true + } + } + return false +} diff --git a/pkg/evidence/verifier/signature_test.go b/pkg/evidence/verifier/signature_test.go new file mode 100644 index 000000000..a98275c79 --- /dev/null +++ b/pkg/evidence/verifier/signature_test.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package verifier + +import ( + "encoding/base64" + stderrors "errors" + "testing" +) + +func TestLooksLikeJSON(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {`{"a": 1}`, true}, + {` {"a": 1}`, true}, + {`[1,2,3]`, true}, + {`hello`, false}, + {``, false}, + } + for _, tt := range tests { + if got := looksLikeJSON([]byte(tt.in)); got != tt.want { + t.Errorf("looksLikeJSON(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestIsCertChainError(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"x509: certificate signed by unknown authority", true}, + {"failed to validate certificate chain", true}, + {"some other error", false}, + {"", false}, + } + for _, tt := range tests { + if got := isCertChainError(tt.in); got != tt.want { + t.Errorf("isCertChainError(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestBuildIdentityMatcher(t *testing.T) { + if _, err := buildIdentityMatcher(VerifyOptions{}); err != nil { + t.Errorf("default matcher: %v", err) + } + if _, err := buildIdentityMatcher(VerifyOptions{ + ExpectedIssuer: "https://token.actions.githubusercontent.com", + ExpectedIdentityRegexp: `^https://github\.com/myorg/.*$`, + }); err != nil { + t.Errorf("pinned matcher: %v", err) + } +} + +func TestVerifySignature_AbsentFileIsUnsigned(t *testing.T) { + bundleDir := buildTestBundle(t) + mat := &MaterializedBundle{BundleDir: summaryDirOf(t, bundleDir)} + if _, err := VerifySignature(t.Context(), mat, VerifyOptions{}); !stderrors.Is(err, ErrUnsignedBundle) { + t.Fatalf("err = %v, want ErrUnsignedBundle", err) + } +} + +func TestVerifySignature_NilBundleErrors(t *testing.T) { + if _, err := VerifySignature(t.Context(), nil, VerifyOptions{}); err == nil { + t.Errorf("expected error for nil bundle") + } +} + +// TestParseStatement covers the JSON parsing path that runs after DSSE +// decode — pure data-plumbing, no sigstore involvement. +func TestParseStatement(t *testing.T) { + good := []byte(`{ + "subject": [{"digest": {"sha256": "abc123"}}], + "predicateType": "https://aicr.nvidia.com/recipe-evidence/v1", + "predicate": {"schemaVersion": "1.0.0", "aicrVersion": "v0.13.0"} +}`) + hex, pred, err := parseStatement(good) + if err != nil { + t.Fatalf("parseStatement: %v", err) + } + if hex != "abc123" { + t.Errorf("subject hex = %q, want abc123", hex) + } + if pred == nil || pred.SchemaVersion != "1.0.0" { + t.Errorf("predicate parse missing or wrong; got %+v", pred) + } +} + +func TestParseStatement_Rejects(t *testing.T) { + tests := []struct { + name string + in string + }{ + {"no subject", `{"predicateType": "https://aicr.nvidia.com/recipe-evidence/v1", "predicate": {}}`}, + {"empty digest", `{"subject": [{"digest": {}}], "predicateType": "https://aicr.nvidia.com/recipe-evidence/v1", "predicate": {}}`}, + {"wrong predicateType", `{"subject": [{"digest": {"sha256": "abc"}}], "predicateType": "wrong", "predicate": {}}`}, + {"invalid JSON", `not json`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, _, err := parseStatement([]byte(tt.in)); err == nil { + t.Errorf("expected error") + } + }) + } +} + +func TestDSSEPayload_RoundTrip(t *testing.T) { + raw := []byte(`{"subject":[]}`) + if !looksLikeJSON(raw) { + t.Fatal("raw JSON should be detected") + } + enc := base64.StdEncoding.EncodeToString(raw) + dec, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + t.Fatalf("decode: %v", err) + } + if string(dec) != string(raw) { + t.Fatalf("got %q, want %q", dec, raw) + } +} diff --git a/pkg/evidence/verifier/types.go b/pkg/evidence/verifier/types.go index 7bad79fe1..41bff6d56 100644 --- a/pkg/evidence/verifier/types.go +++ b/pkg/evidence/verifier/types.go @@ -18,12 +18,13 @@ import ( "github.com/NVIDIA/aicr/pkg/evidence/attestation" ) -// InputForm enumerates supported bundle transport shapes. Only -// InputFormDir is implemented; pointer and OCI forms are reserved. +// InputForm enumerates supported bundle transport shapes. type InputForm string const ( - InputFormDir InputForm = "dir" + InputFormDir InputForm = "dir" + InputFormPointer InputForm = "pointer" + InputFormOCI InputForm = "oci" ) // StepStatus is the per-step verdict. @@ -36,9 +37,8 @@ const ( StepInformational StepStatus = "informational" ) -// Exit codes returned by Verify in VerifyResult.Exit. These are the -// library-API codes from ADR-007; the CLI maps them to OS exit codes -// via pkg/errors error codes. +// Exit codes returned by Verify in VerifyResult.Exit. The CLI maps +// these to OS exit codes via pkg/errors error codes. const ( ExitValidPassed = 0 ExitValidPhaseFailures = 1 @@ -47,7 +47,43 @@ const ( // VerifyOptions configures one Verify run. type VerifyOptions struct { + // Input is the user-supplied positional argument: pointer path, + // OCI reference (with or without oci:// prefix), or unpacked + // bundle directory. Required. Input string + + // BundleRef overrides the OCI reference when the input does not + // embed one — e.g., a pointer file whose bundle.oci is empty. + BundleRef string + + // NoRekor skips the Rekor transparency-log cross-check and uses + // the cert + signature carried in the Sigstore Bundle alone. + // Useful in air-gapped environments. + NoRekor bool + + // ExpectedIssuer pins the OIDC issuer URL recorded on the signing + // certificate. Empty allows any issuer. + ExpectedIssuer string + + // ExpectedIdentityRegexp pins the signer's SubjectAlternativeName + // via regex. Empty allows any identity. + ExpectedIdentityRegexp string + + // PlainHTTP forces HTTP for registry traffic (local-registry tests + // only). + PlainHTTP bool + + // InsecureTLS disables TLS verification for the registry + // (self-signed certificates). + InsecureTLS bool +} + +// SignerClaims records the OIDC identity from the signing certificate. +// nil on unsigned bundles. +type SignerClaims struct { + Identity string `json:"identity" yaml:"identity"` + Issuer string `json:"issuer" yaml:"issuer"` + RekorLogIndex *int64 `json:"rekorLogIndex,omitempty" yaml:"rekorLogIndex,omitempty"` } // StepResult is the recorded outcome of one verification step. @@ -67,9 +103,12 @@ type KV struct { // VerifyResult is what Verify returns to its caller. type VerifyResult struct { - Input InputForm `json:"input" yaml:"input"` - Predicate *attestation.Predicate `json:"predicate,omitempty" yaml:"predicate,omitempty"` - RecipeName string `json:"recipeName,omitempty" yaml:"recipeName,omitempty"` - Steps []StepResult `json:"steps" yaml:"steps"` - Exit int `json:"exit" yaml:"exit"` + Input InputForm `json:"input" yaml:"input"` + Pointer *attestation.Pointer `json:"pointer,omitempty" yaml:"pointer,omitempty"` + Predicate *attestation.Predicate `json:"predicate,omitempty" yaml:"predicate,omitempty"` + Signer *SignerClaims `json:"signer,omitempty" yaml:"signer,omitempty"` + RecipeName string `json:"recipeName,omitempty" yaml:"recipeName,omitempty"` + BundleDigest string `json:"bundleDigest,omitempty" yaml:"bundleDigest,omitempty"` + Steps []StepResult `json:"steps" yaml:"steps"` + Exit int `json:"exit" yaml:"exit"` } diff --git a/pkg/evidence/verifier/verify.go b/pkg/evidence/verifier/verify.go index 277ac6fcc..c4eeeb8a2 100644 --- a/pkg/evidence/verifier/verify.go +++ b/pkg/evidence/verifier/verify.go @@ -17,25 +17,30 @@ package verifier import ( "context" "encoding/json" + stderrors "errors" "os" "path/filepath" + "strconv" "github.com/NVIDIA/aicr/pkg/errors" "github.com/NVIDIA/aicr/pkg/evidence/attestation" ) // Step numbers recorded in StepResult.Step. Signature verification -// and constraint replay are reserved for future inserts between -// predicate-parse and inventory; the render step stays last. +// runs between materialize and predicate-parse so the predicate body +// downstream steps consume is the cryptographically anchored one when +// available. Constraint replay is reserved for a follow-up slice. const ( stepMaterialize = 1 - stepPredicate = 2 - stepInventory = 3 - stepRender = 4 + stepSignature = 2 + stepPredicate = 3 + stepInventory = 4 + stepRender = 5 ) var stepNames = map[int]string{ stepMaterialize: "materialize-bundle", + stepSignature: "signature-verify", stepPredicate: "predicate-parse", stepInventory: "manifest-hash-check", stepRender: "render-summary", @@ -55,21 +60,42 @@ func Verify(ctx context.Context, opts VerifyOptions) (*VerifyResult, error) { r := &VerifyResult{Input: form, Steps: make([]StepResult, 0, len(stepNames))} + // Pointer file is loaded up front for the pointer input form so + // materialization knows the OCI ref to pull from. + var pointer *attestation.Pointer + if form == InputFormPointer { + p, perr := LoadAndValidatePointer(opts.Input) + if perr != nil { + return nil, perr + } + pointer = p + r.Pointer = p + } + // Step 1 — materialize. - mat, err := MaterializeBundle(ctx, opts, form) + mat, err := MaterializeBundle(ctx, opts, form, pointer) if err != nil { record(r, stepMaterialize, StepFailed, err.Error(), nil) r.Exit = ExitInvalid return r, nil } defer mat.Cleanup() + if mat.Digest != "" { + r.BundleDigest = mat.Digest + } record(r, stepMaterialize, StepPassed, "bundle at "+mat.BundleDir, nil) - // Step 2 — parse the predicate. Display steps need its content, - // and step 3 needs predicate.Manifest.Digest to bind the manifest - // to the (currently unsigned) statement. Signature verification - // lands in a follow-up slice. - pred, perr := loadPredicate(mat) + // Step 2 — signature verify. When attestation.intoto.jsonl is + // present, sigstore-go anchors the DSSE-wrapped Statement to a + // Fulcio cert + optional Rekor entry. The predicate inside that + // verified Statement is the cryptographically authoritative value. + verifiedPredicate := stepSignatureCheck(ctx, r, mat, opts) + + // Step 3 — predicate parse. Prefer the predicate the signature + // step produced (cryptographically anchored); fall back to the + // bundle's unsigned statement.intoto.json when no signature was + // attached. Either way the manifest digest comes from this value. + pred, perr := resolvePredicate(verifiedPredicate, mat) if perr != nil { record(r, stepPredicate, StepFailed, perr.Error(), nil) r.Exit = ExitInvalid @@ -77,13 +103,17 @@ func Verify(ctx context.Context, opts VerifyOptions) (*VerifyResult, error) { } r.Predicate = pred r.RecipeName = pred.Recipe.Name + source := "unsigned statement.intoto.json" + if verifiedPredicate != nil { + source = "verified DSSE payload" + } record(r, stepPredicate, StepPassed, - "predicate "+pred.SchemaVersion+" for recipe "+pred.Recipe.Name, nil) + "predicate "+pred.SchemaVersion+" for recipe "+pred.Recipe.Name+ + " (from "+source+")", nil) - // Step 3 — manifest hash check. Binds manifest.json to + // Step 4 — manifest hash check. Binds manifest.json to // predicate.Manifest.Digest, then every file in the manifest to its - // recorded sha256. Together these transitively bind every bundled - // file to the predicate. + // recorded sha256. mismatches, invErr := CheckInventory(ctx, mat, pred.Manifest.Digest) if invErr != nil { record(r, stepInventory, StepFailed, invErr.Error(), mismatches) @@ -102,16 +132,53 @@ func Verify(ctx context.Context, opts VerifyOptions) (*VerifyResult, error) { return r, nil } +// stepSignatureCheck runs step 2 and returns the cryptographically +// anchored predicate when the bundle is signed (nil otherwise). Side +// effects: records the step row, sets r.Signer, may update r.Exit. +func stepSignatureCheck(ctx context.Context, r *VerifyResult, mat *MaterializedBundle, opts VerifyOptions) *attestation.Predicate { + sig, sigErr := VerifySignature(ctx, mat, opts) + switch { + case stderrors.Is(sigErr, ErrUnsignedBundle): + record(r, stepSignature, StepSkipped, "no signature attached (unsigned bundle)", nil) + return nil + case sigErr != nil: + record(r, stepSignature, StepFailed, sigErr.Error(), nil) + r.Exit = ExitInvalid + return nil + default: + r.Signer = sig.Signer + detail := "signer " + sig.Signer.Identity + " (issuer " + sig.Signer.Issuer + ")" + var sub []KV + if sig.Signer.RekorLogIndex != nil { + sub = []KV{{Key: "rekorLogIndex", + Value: strconv.FormatInt(*sig.Signer.RekorLogIndex, 10)}} + } + record(r, stepSignature, StepPassed, detail, sub) + return sig.Predicate + } +} + +// resolvePredicate picks the predicate to use for downstream steps. +// Verified payload takes precedence; otherwise we fall back to the +// unsigned statement.intoto.json. Both shapes go through the same +// PredicateTypeV1 check. +func resolvePredicate(verified *attestation.Predicate, mat *MaterializedBundle) (*attestation.Predicate, error) { + if verified != nil { + return verified, nil + } + return loadUnsignedPredicate(mat) +} + func record(r *VerifyResult, step int, status StepStatus, detail string, sub []KV) { r.Steps = append(r.Steps, StepResult{ Step: step, Name: stepNames[step], Status: status, Detail: detail, SubRows: sub, }) } -// loadPredicate reads the bundle's unsigned in-toto Statement and -// returns the predicate body. Signature binding is not yet enforced — -// the file is trusted as-is. -func loadPredicate(mat *MaterializedBundle) (*attestation.Predicate, error) { +// loadUnsignedPredicate reads the bundle's unsigned in-toto Statement +// and returns the predicate body. Used when no Sigstore Bundle was +// emitted; the predicate is trusted as-is (self-consistency only). +func loadUnsignedPredicate(mat *MaterializedBundle) (*attestation.Predicate, error) { path := filepath.Join(mat.BundleDir, attestation.StatementFilename) body, err := os.ReadFile(path) //nolint:gosec // bundle-local path if err != nil { diff --git a/pkg/evidence/verifier/verify_test.go b/pkg/evidence/verifier/verify_test.go index c6709baef..418385cd7 100644 --- a/pkg/evidence/verifier/verify_test.go +++ b/pkg/evidence/verifier/verify_test.go @@ -49,12 +49,21 @@ func TestVerify_DirectoryHappyPath(t *testing.T) { if got := stepByNumber(t, res, stepMaterialize).Status; got != StepPassed { t.Errorf("materialize = %v, want passed", got) } + if got := stepByNumber(t, res, stepSignature).Status; got != StepSkipped { + t.Errorf("signature = %v, want skipped (unsigned bundle)", got) + } + if got := stepByNumber(t, res, stepPredicate).Status; got != StepPassed { + t.Errorf("predicate = %v, want passed", got) + } if got := stepByNumber(t, res, stepInventory).Status; got != StepPassed { t.Errorf("inventory = %v, want passed", got) } if res.Exit != ExitValidPassed { t.Errorf("Exit = %d, want %d", res.Exit, ExitValidPassed) } + if res.Signer != nil { + t.Errorf("Signer should be nil for unsigned bundle; got %+v", res.Signer) + } if res.Predicate == nil { t.Errorf("Predicate is nil; expected parsed predicate") } From a4f12d2dc61503949f3452f9c9f05c3ea0083f27 Mon Sep 17 00:00:00 2001 From: Nathan Hensley Date: Thu, 14 May 2026 10:17:40 -0700 Subject: [PATCH 2/7] fix(verifier): enforce pointer signer claims and require digest-pinned OCI refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three findings from the verify test sweep: 1. Pointer signer claims are now load-bearing (was: decorative). When a pointer file declares signer.identity / signer.issuer / signer.rekorLogIndex, the verifier cross-checks the actual cert SAN, OIDC issuer, and Rekor log index after signature verification. A pointer that names a different identity than the bundle's cert fails at signature-verify with a "pointer claimed X, cert says Y" error. A pointer that claims a signer for an unsigned bundle also fails — the operator's claim and the bundle's reality must agree. 2. Refuse tag-only OCI references by default. Tags are not content-addressable; a registry rewrite (or a push from anyone with write access) could substitute the artifact at the same tag and the verifier would happily verify whatever now sits there. The new --allow-unpinned-tag flag opts back in for one-off debugging. Pointer-driven pulls remain accepted with a tag-only OCI URI as long as pointer.bundle.digest is set — the pointer's digest claim is the pin (already cross-checked against the actual pulled digest). 3. Markdown report now enumerates sub-rows of failed steps. The bundle-tamper test surfaced "manifest inventory check failed for 1 file(s)" with no indication of which file. Add a "Failed check details" section that breaks out each failed step's sub-rows as bullets, naming the offending path / dimension / constraint. Tests cover the new failure modes: pointer-vs-cert identity mismatch, issuer mismatch, Rekor index mismatch, claimed-signer-on-unsigned- bundle, and the tag-only refusal with and without the opt-in flag. --- pkg/cli/evidence_verify.go | 6 + pkg/cli/evidence_verify_test.go | 2 +- pkg/evidence/verifier/crosscheck_test.go | 159 +++++++++++++++++++++++ pkg/evidence/verifier/fetch.go | 41 ++++-- pkg/evidence/verifier/report.go | 28 ++++ pkg/evidence/verifier/signature.go | 43 ++++++ pkg/evidence/verifier/types.go | 9 ++ pkg/evidence/verifier/verify.go | 24 ++++ pkg/evidence/verifier/verify_test.go | 40 ++++++ 9 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 pkg/evidence/verifier/crosscheck_test.go diff --git a/pkg/cli/evidence_verify.go b/pkg/cli/evidence_verify.go index 7ea3c7fb9..ea9e4ada3 100644 --- a/pkg/cli/evidence_verify.go +++ b/pkg/cli/evidence_verify.go @@ -104,6 +104,11 @@ Exit codes: Usage: "Skip TLS verification for the registry (self-signed certificates).", Category: catEvidence, }, + &cli.BoolFlag{ + Name: "allow-unpinned-tag", + Usage: "Accept tag-only OCI references (default: refuse). Tags are not content-addressable; opt in only for one-off debugging.", + Category: catEvidence, + }, }, Action: runEvidenceVerifyCmd, } @@ -128,6 +133,7 @@ func runEvidenceVerifyCmd(ctx context.Context, cmd *cli.Command) error { ExpectedIdentityRegexp: cmd.String("expected-identity-regexp"), PlainHTTP: cmd.Bool("registry-plain-http"), InsecureTLS: cmd.Bool("registry-insecure-tls"), + AllowUnpinnedTag: cmd.Bool("allow-unpinned-tag"), }) if err != nil { return err diff --git a/pkg/cli/evidence_verify_test.go b/pkg/cli/evidence_verify_test.go index 7b6e6027e..421f1d2c1 100644 --- a/pkg/cli/evidence_verify_test.go +++ b/pkg/cli/evidence_verify_test.go @@ -51,7 +51,7 @@ func TestEvidenceVerifyCmd_HasExpectedFlags(t *testing.T) { wanted := []string{ "output", "format", "no-rekor", "expected-issuer", "expected-identity-regexp", "bundle", - "registry-plain-http", "registry-insecure-tls", + "registry-plain-http", "registry-insecure-tls", "allow-unpinned-tag", } for _, name := range wanted { found := false diff --git a/pkg/evidence/verifier/crosscheck_test.go b/pkg/evidence/verifier/crosscheck_test.go new file mode 100644 index 000000000..195309ddf --- /dev/null +++ b/pkg/evidence/verifier/crosscheck_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package verifier + +import ( + "strings" + "testing" + + "github.com/NVIDIA/aicr/pkg/evidence/attestation" +) + +func i64ptr(v int64) *int64 { return &v } + +func TestCrossCheckPointerSigner(t *testing.T) { + tests := []struct { + name string + claimed *attestation.PointerSigner + actual *SignerClaims + wantErr bool + errSubstr string + }{ + { + name: "no claim, no actual", + claimed: nil, + actual: nil, + }, + { + name: "no claim, signed bundle (claim-agnostic pointer)", + claimed: nil, + actual: &SignerClaims{Identity: "x", Issuer: "y"}, + }, + { + name: "claim but no actual (pointer says signed, bundle is unsigned)", + claimed: &attestation.PointerSigner{Identity: "x", Issuer: "y"}, + actual: nil, + wantErr: true, + errSubstr: "no signature", + }, + { + name: "identity matches", + claimed: &attestation.PointerSigner{Identity: "alice@x", Issuer: "https://ghap"}, + actual: &SignerClaims{Identity: "alice@x", Issuer: "https://ghap"}, + }, + { + name: "identity mismatch", + claimed: &attestation.PointerSigner{Identity: "alice@x", Issuer: "https://ghap"}, + actual: &SignerClaims{Identity: "mallory@x", Issuer: "https://ghap"}, + wantErr: true, + errSubstr: "identity mismatch", + }, + { + name: "issuer mismatch", + claimed: &attestation.PointerSigner{Identity: "alice@x", Issuer: "https://good"}, + actual: &SignerClaims{Identity: "alice@x", Issuer: "https://evil"}, + wantErr: true, + errSubstr: "issuer mismatch", + }, + { + name: "rekor index matches", + claimed: &attestation.PointerSigner{Identity: "x", Issuer: "y", RekorLogIndex: i64ptr(42)}, + actual: &SignerClaims{Identity: "x", Issuer: "y", RekorLogIndex: i64ptr(42)}, + }, + { + name: "rekor index mismatch", + claimed: &attestation.PointerSigner{Identity: "x", Issuer: "y", RekorLogIndex: i64ptr(42)}, + actual: &SignerClaims{Identity: "x", Issuer: "y", RekorLogIndex: i64ptr(99)}, + wantErr: true, + errSubstr: "Rekor log index mismatch", + }, + { + name: "pointer claims rekor, actual has none", + claimed: &attestation.PointerSigner{Identity: "x", Issuer: "y", RekorLogIndex: i64ptr(42)}, + actual: &SignerClaims{Identity: "x", Issuer: "y"}, + wantErr: true, + errSubstr: "no Rekor entry", + }, + { + name: "pointer issuer empty, no comparison", + claimed: &attestation.PointerSigner{Identity: "x"}, + actual: &SignerClaims{Identity: "x", Issuer: "anything"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CrossCheckPointerSigner(tt.claimed, tt.actual) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { + t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr) + } + }) + } +} + +func TestIsDigestPinned(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"sha256:abc123", true}, + {"sha256:" + strings.Repeat("a", 64), true}, + {"v1", false}, + {"latest", false}, + {"", false}, + {"sha512:abc", false}, // we only pin on sha256 today + } + for _, tt := range tests { + if got := isDigestPinned(tt.in); got != tt.want { + t.Errorf("isDigestPinned(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestMaterializeBundle_RejectsTagOnlyOCI(t *testing.T) { + // Direct OCI input with a tag-only ref should fail at the pin + // check before any network call. parseOCIReference accepts both + // forms; the digest-pin enforcement is in materializeOCIRefRequireDigest. + form, err := DetectInputForm("ghcr.io/owner/repo:v1") + if err != nil { + t.Fatalf("DetectInputForm: %v", err) + } + _, err = MaterializeBundle(t.Context(), + VerifyOptions{Input: "ghcr.io/owner/repo:v1"}, form, nil) + if err == nil { + t.Fatalf("expected error for tag-only OCI reference") + } + if !strings.Contains(err.Error(), "tag-only") { + t.Errorf("error should mention tag-only; got %v", err) + } +} + +func TestMaterializeBundle_TagOnlyWithAllowFlag(t *testing.T) { + // With AllowUnpinnedTag, we get past the pin check and fail at + // the OCI pull (no actual registry available in the test). The + // pin-error message must NOT appear. + form, _ := DetectInputForm("ghcr.io/owner/repo:v1") + _, err := MaterializeBundle(t.Context(), + VerifyOptions{Input: "ghcr.io/owner/repo:v1", AllowUnpinnedTag: true}, + form, nil) + if err == nil { + t.Fatalf("expected pull error (no registry), got nil") + } + if strings.Contains(err.Error(), "tag-only") { + t.Errorf("AllowUnpinnedTag should bypass tag-pin check; got %v", err) + } +} diff --git a/pkg/evidence/verifier/fetch.go b/pkg/evidence/verifier/fetch.go index 36b26118a..495b0e8e5 100644 --- a/pkg/evidence/verifier/fetch.go +++ b/pkg/evidence/verifier/fetch.go @@ -82,7 +82,10 @@ func MaterializeBundle( case InputFormPointer: return materializeFromPointer(ctx, pointer, opts) case InputFormOCI: - return materializeOCIRef(ctx, opts.Input, opts) + // Direct OCI input has no external digest pin; refuse tag-only + // refs unless the operator explicitly opted in. Pointer-driven + // pulls have their own check below using pointer.bundle.digest. + return materializeOCIRefRequireDigest(ctx, opts.Input, opts, !opts.AllowUnpinnedTag) default: return nil, errors.New(errors.ErrCodeInvalidRequest, "unknown input form "+string(form)) } @@ -117,7 +120,8 @@ func hasBundleMarkers(dir string) bool { // materializeFromPointer pulls the OCI artifact named in the pointer's // first attestation, or falls back to opts.BundleRef when the pointer // has no OCI ref. The pointer's digest claim is cross-checked against -// the actual pulled digest. +// the actual pulled digest. A tag-only ref is allowed only when the +// pointer carries a non-empty bundle.digest — that digest is the pin. func materializeFromPointer( ctx context.Context, pointer *attestation.Pointer, @@ -136,7 +140,11 @@ func materializeFromPointer( return nil, errors.New(errors.ErrCodeInvalidRequest, "pointer carries no bundle.oci — re-run with --bundle or point at the unpacked directory") } - mat, err := materializeOCIRef(ctx, ref, opts) + // Pointer-driven path: pointer.bundle.digest is the pin. When it + // is empty (e.g., a local-only pointer plus --bundle override), + // fall through to the direct-OCI digest-pinning rule. + requirePin := !opts.AllowUnpinnedTag && att.Bundle.Digest == "" + mat, err := materializeOCIRefRequireDigest(ctx, ref, opts, requirePin) if err != nil { return nil, err } @@ -148,15 +156,25 @@ func materializeFromPointer( return mat, nil } -// materializeOCIRef pulls an OCI artifact into a temp directory using -// oras.Copy from a remote repository to a local file store. The file -// store unpacks the gzip-tar layer the emitter writes, so the result -// is the bundle tree on disk. -func materializeOCIRef(ctx context.Context, ref string, opts VerifyOptions) (*MaterializedBundle, error) { +// materializeOCIRefRequireDigest pulls an OCI artifact into a temp +// directory using oras.Copy. When requirePin is true the reference +// must resolve to a digest (not a bare tag) — registry-rewritable +// tags are not content-addressable and would let a registry compromise +// substitute the artifact. +// +// The file store unpacks the gzip-tar layer the emitter writes, so the +// result is the bundle tree on disk. +func materializeOCIRefRequireDigest(ctx context.Context, ref string, opts VerifyOptions, requirePin bool) (*MaterializedBundle, error) { registry, repo, refTarget, err := parseOCIReference(ref) if err != nil { return nil, err } + if requirePin && !isDigestPinned(refTarget) { + return nil, errors.New(errors.ErrCodeInvalidRequest, + "OCI reference "+ref+" is tag-only — refusing to pull an unpinned reference. "+ + "Use a digest-bound reference (registry/repo@sha256:), supply a pointer with "+ + "bundle.digest set, or pass --allow-unpinned-tag for one-off debugging.") + } tmp, err := os.MkdirTemp("", "aicr-evidence-pull-") if err != nil { @@ -322,6 +340,13 @@ func resolveBundleDir(dir string) (string, error) { "pulled artifact does not contain a recognizable summary bundle") } +// isDigestPinned reports whether an OCI reference target (the tag-or- +// digest portion ORAS uses) is content-addressed. Digest targets are +// "sha256:"; tag targets are anything else. +func isDigestPinned(target string) bool { + return strings.HasPrefix(target, "sha256:") +} + // parseOCIReference splits a reference into (registry, repository, target). // target is the tag or digest portion ORAS resolves against the remote. func parseOCIReference(ref string) (registry, repo, target string, err error) { diff --git a/pkg/evidence/verifier/report.go b/pkg/evidence/verifier/report.go index b205cc47c..bfcd86c0f 100644 --- a/pkg/evidence/verifier/report.go +++ b/pkg/evidence/verifier/report.go @@ -44,10 +44,38 @@ func RenderMarkdown(r *VerifyResult) string { writePhases(&b, r.Predicate) writeBOM(&b, r.Predicate) writeSteps(&b, r) + writeFailedStepDetails(&b, r) writeVerdict(&b, r) return b.String() } +// writeFailedStepDetails enumerates the sub-rows of any failed step so +// the maintainer sees exactly which files / dimensions / constraints +// caused the failure. Markdown tables can't render nested lists; this +// section follows the steps table and breaks failures out as bullets. +func writeFailedStepDetails(b *strings.Builder, r *VerifyResult) { + failedWithRows := 0 + for _, s := range r.Steps { + if s.Status == StepFailed && len(s.SubRows) > 0 { + failedWithRows++ + } + } + if failedWithRows == 0 { + return + } + b.WriteString("### Failed check details\n") + for _, s := range r.Steps { + if s.Status != StepFailed || len(s.SubRows) == 0 { + continue + } + fmt.Fprintf(b, "- **%s**\n", s.Name) + for _, row := range s.SubRows { + fmt.Fprintf(b, " - `%s` — %s\n", row.Key, row.Value) + } + } + b.WriteString("\n") +} + func writeHeader(b *strings.Builder, r *VerifyResult) { if r.Signer != nil && r.Signer.Identity != "" { fmt.Fprintf(b, "**Signer:** %s", r.Signer.Identity) diff --git a/pkg/evidence/verifier/signature.go b/pkg/evidence/verifier/signature.go index 2fad63d5d..0343323c6 100644 --- a/pkg/evidence/verifier/signature.go +++ b/pkg/evidence/verifier/signature.go @@ -21,6 +21,7 @@ import ( "encoding/json" "os" "path/filepath" + "strconv" "strings" protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" @@ -265,6 +266,48 @@ func rekorLogIndex(b *bundle.Bundle) int64 { return entries[0].GetLogIndex() } +// CrossCheckPointerSigner compares the verified signer claims against +// what the pointer file claimed. Returns nil when the pointer makes no +// signer claim, when there's no actual signer to compare against, or +// when every claimed field matches. A mismatch produces an error that +// names the specific field and both sides — the verifier surfaces it +// so a malicious pointer that names a different signer than the +// actual bundle fails loudly. +func CrossCheckPointerSigner(claimed *attestation.PointerSigner, actual *SignerClaims) error { + if claimed == nil { + return nil + } + if actual == nil { + return errors.New(errors.ErrCodeInvalidRequest, + "pointer claims a signer ("+claimed.Identity+ + ", issuer "+claimed.Issuer+") but the bundle carries no signature") + } + if claimed.Identity != "" && claimed.Identity != actual.Identity { + return errors.New(errors.ErrCodeInvalidRequest, + "signer identity mismatch: pointer claims "+claimed.Identity+ + ", cert says "+actual.Identity) + } + if claimed.Issuer != "" && claimed.Issuer != actual.Issuer { + return errors.New(errors.ErrCodeInvalidRequest, + "signer issuer mismatch: pointer claims "+claimed.Issuer+ + ", cert says "+actual.Issuer) + } + if claimed.RekorLogIndex != nil { + if actual.RekorLogIndex == nil { + return errors.New(errors.ErrCodeInvalidRequest, + "pointer claims a Rekor log index but the bundle has no Rekor entry "+ + "(was the bundle signed with --no-rekor, or is the pointer stale?)") + } + if *claimed.RekorLogIndex != *actual.RekorLogIndex { + return errors.New(errors.ErrCodeInvalidRequest, + "Rekor log index mismatch: pointer claims "+ + strconv.FormatInt(*claimed.RekorLogIndex, 10)+ + ", actual entry "+strconv.FormatInt(*actual.RekorLogIndex, 10)) + } + } + return nil +} + // isCertChainError reports whether the sigstore error string signals // a stale trusted-root condition. Used to suggest `aicr trust update`. func isCertChainError(msg string) bool { diff --git a/pkg/evidence/verifier/types.go b/pkg/evidence/verifier/types.go index 41bff6d56..f1a972b5e 100644 --- a/pkg/evidence/verifier/types.go +++ b/pkg/evidence/verifier/types.go @@ -76,6 +76,15 @@ type VerifyOptions struct { // InsecureTLS disables TLS verification for the registry // (self-signed certificates). InsecureTLS bool + + // AllowUnpinnedTag opts into accepting OCI references that resolve + // to a tag rather than a digest. By default the verifier refuses + // unpinned refs because tags can be rewritten by the registry, so + // "verify this artifact at this tag" is not content-addressable. + // Pointer-driven flows ignore this flag when the pointer carries a + // non-empty bundle.digest (the pointer's digest claim becomes the + // pin and is cross-checked against the actual pulled digest). + AllowUnpinnedTag bool } // SignerClaims records the OIDC identity from the signing certificate. diff --git a/pkg/evidence/verifier/verify.go b/pkg/evidence/verifier/verify.go index c4eeeb8a2..f1d763cdc 100644 --- a/pkg/evidence/verifier/verify.go +++ b/pkg/evidence/verifier/verify.go @@ -135,10 +135,29 @@ func Verify(ctx context.Context, opts VerifyOptions) (*VerifyResult, error) { // stepSignatureCheck runs step 2 and returns the cryptographically // anchored predicate when the bundle is signed (nil otherwise). Side // effects: records the step row, sets r.Signer, may update r.Exit. +// +// When the input is a pointer file with a signer claim, this step also +// cross-checks the pointer's claim against the actual cert. A +// malicious pointer that names a different signer than the bundle +// fails here. func stepSignatureCheck(ctx context.Context, r *VerifyResult, mat *MaterializedBundle, opts VerifyOptions) *attestation.Predicate { sig, sigErr := VerifySignature(ctx, mat, opts) + + var claimedSigner *attestation.PointerSigner + if r.Pointer != nil && len(r.Pointer.Attestations) > 0 { + claimedSigner = r.Pointer.Attestations[0].Signer + } + switch { case stderrors.Is(sigErr, ErrUnsignedBundle): + // Pointer claims a signer but the bundle is unsigned → fail. + if claimedSigner != nil { + if ccErr := CrossCheckPointerSigner(claimedSigner, nil); ccErr != nil { + record(r, stepSignature, StepFailed, ccErr.Error(), nil) + r.Exit = ExitInvalid + return nil + } + } record(r, stepSignature, StepSkipped, "no signature attached (unsigned bundle)", nil) return nil case sigErr != nil: @@ -147,6 +166,11 @@ func stepSignatureCheck(ctx context.Context, r *VerifyResult, mat *MaterializedB return nil default: r.Signer = sig.Signer + if ccErr := CrossCheckPointerSigner(claimedSigner, sig.Signer); ccErr != nil { + record(r, stepSignature, StepFailed, ccErr.Error(), nil) + r.Exit = ExitInvalid + return nil + } detail := "signer " + sig.Signer.Identity + " (issuer " + sig.Signer.Issuer + ")" var sub []KV if sig.Signer.RekorLogIndex != nil { diff --git a/pkg/evidence/verifier/verify_test.go b/pkg/evidence/verifier/verify_test.go index 418385cd7..894fa9628 100644 --- a/pkg/evidence/verifier/verify_test.go +++ b/pkg/evidence/verifier/verify_test.go @@ -258,6 +258,46 @@ func TestCheckInventory_RespectsCancellation(t *testing.T) { } } +func TestRenderMarkdown_FailedStepDetailsListsFiles(t *testing.T) { + // Build a result with a failed inventory step that carries per-file + // sub-rows. The rendered Markdown must list the file names — not + // just count them — so the maintainer can see what failed. + r := &VerifyResult{ + Exit: ExitInvalid, + Steps: []StepResult{ + {Step: 4, Name: "manifest-hash-check", Status: StepFailed, + Detail: "manifest inventory check failed for 2 file(s)", + SubRows: []KV{ + {Key: "ctrf/deployment.json", Value: "sha256 mismatch"}, + {Key: "stray.txt", Value: "file not in manifest.json (unsigned)"}, + }}, + }, + } + md := RenderMarkdown(r) + if !strings.Contains(md, "Failed check details") { + t.Errorf("missing Failed check details section; got %q", md) + } + if !strings.Contains(md, "ctrf/deployment.json") { + t.Errorf("rendered Markdown should name ctrf/deployment.json; got %q", md) + } + if !strings.Contains(md, "stray.txt") { + t.Errorf("rendered Markdown should name stray.txt; got %q", md) + } +} + +func TestRenderMarkdown_NoFailedStepDetailsSectionWhenAllPass(t *testing.T) { + r := &VerifyResult{ + Exit: ExitValidPassed, + Steps: []StepResult{ + {Step: 1, Name: "materialize-bundle", Status: StepPassed}, + }, + } + md := RenderMarkdown(r) + if strings.Contains(md, "Failed check details") { + t.Errorf("should not render Failed check details when nothing failed; got %q", md) + } +} + func TestVerify_PredicateParseFailureRecordedAsPredicateStep(t *testing.T) { bundleDir := buildTestBundle(t) summary := summaryDirOf(t, bundleDir) From 3d1bf96539a9a299e1413c5d49364b42cfaea7b3 Mon Sep 17 00:00:00 2001 From: Nathan Hensley Date: Thu, 14 May 2026 11:29:17 -0700 Subject: [PATCH 3/7] fix(verifier): remove --no-rekor; clarify signature failure messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cleanups exposed by the verify test sweep: - Remove --no-rekor. The flag only ever works for bundles carrying a separate TSA-signed timestamp, but the emitter signs keyless via Fulcio + Rekor where the Rekor entry IS the timestamp. With --no-rekor on a keyless bundle the verifier had zero timestamp sources and sigstore-go failed the "threshold not met" check — the flag's documented behavior could never succeed against bundles this project produces. Re-introduce if/when the emitter dual-anchors with a TSA. - Distinguish "signature verification failed" from "unsigned bundle" in the report header. The previous code printed "_unsigned bundle_" whenever r.Signer was nil, which was misleading on bundles where the signature step actually ran and failed. Now reads "_signature verification failed (see Verification steps)_" when the signature step status is failed, reserving "_unsigned bundle_" for the no-signature-attached case. - Sanitize sigstore-go's "%!w()" format-string leak in sigstore verification error messages. The library's threshold-not-met paths wrap an empty errors.Join with %w, producing user-visible "...: 0 < 1; error: %!w()" surface. Strip the artifact so the error reads cleanly. Safe to remove once fixed upstream. --- demos/evidence.md | 7 +---- docs/user/cli-reference.md | 4 --- pkg/cli/evidence_verify.go | 6 ---- pkg/cli/evidence_verify_test.go | 2 +- pkg/evidence/verifier/report.go | 21 +++++++++++-- pkg/evidence/verifier/signature.go | 34 +++++++++++++++++---- pkg/evidence/verifier/signature_test.go | 38 +++++++++++++++++++++++ pkg/evidence/verifier/types.go | 5 ---- pkg/evidence/verifier/verify_test.go | 40 +++++++++++++++++++++++++ 9 files changed, 127 insertions(+), 30 deletions(-) diff --git a/demos/evidence.md b/demos/evidence.md index 462123386..2c1d90f3c 100644 --- a/demos/evidence.md +++ b/demos/evidence.md @@ -19,7 +19,7 @@ This demo walks through the full producer-and-consumer loop: ## Prerequisites -* `aicr` with `aicr evidence verify` (PR-B onward). +* `aicr` with `aicr evidence verify` available. * A Kubernetes cluster to validate against. * OCI registry write access for the producer (GHCR, GitLab Container Registry, Harbor, ECR, Artifactory, ACR — any OCI-1.1 registry works). @@ -205,8 +205,3 @@ pulled. Someone substituted the bundle and re-pointed at a stale signature. **"OCI pull failed"** — registry auth. The verifier uses ambient Docker credentials (`docker login` / `DOCKER_CONFIG`); confirm `docker pull ` works from the same shell. - -**`--no-rekor`** — when the verifier cannot reach Rekor -(`rekor.sigstore.dev`), this flag falls back to the cert and signature in -the Sigstore Bundle alone. The attestation is still cryptographically -verified; only the transparency-log cross-check is skipped. diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index 53b6d5f87..d43cb6f5e 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -1912,7 +1912,6 @@ The positional argument is auto-detected as one of: |------|-------|------|---------|-------------| | `--output` | `-o` | string | | Write output to this file. When empty, output goes to stdout. | | `--format` | `-t` | string | `text` | Output format: `text` (Markdown) or `json`. Applies regardless of destination. | -| `--no-rekor` | | bool | `false` | Skip Rekor transparency-log cross-check; rely on the cert + signature in the Sigstore Bundle alone. Useful in air-gapped environments. | | `--expected-issuer` | | string | | Pin the OIDC issuer URL on the signing certificate. Empty allows any issuer. | | `--expected-identity-regexp` | | string | | Pin the signer's `SubjectAlternativeName` via regex. Empty allows any identity. | | `--bundle` | | string | | OCI reference override when the pointer carries no `bundle.oci`. | @@ -1944,9 +1943,6 @@ aicr evidence verify recipes/evidence/.yaml \ --expected-issuer https://token.actions.githubusercontent.com \ --expected-identity-regexp '^https://github\.com/myorg/.*$' -# Air-gapped: skip Rekor cross-check, rely on the Sigstore Bundle alone. -aicr evidence verify recipes/evidence/.yaml --no-rekor - # CI pipelines: JSON output. aicr evidence verify recipes/evidence/.yaml -o result.json -t json ``` diff --git a/pkg/cli/evidence_verify.go b/pkg/cli/evidence_verify.go index ea9e4ada3..e0e217936 100644 --- a/pkg/cli/evidence_verify.go +++ b/pkg/cli/evidence_verify.go @@ -74,11 +74,6 @@ Exit codes: Usage: "Output format: text (Markdown summary), json.", Category: catOutput, }, func() []string { return []string{evidenceVerifyFormatText, evidenceVerifyFormatJSON} }), - &cli.BoolFlag{ - Name: "no-rekor", - Usage: "Skip Rekor transparency-log cross-check; use the cert and signature in the Sigstore Bundle alone.", - Category: catEvidence, - }, &cli.StringFlag{ Name: "expected-issuer", Usage: "Pin the OIDC issuer URL on the signing certificate (empty = any issuer).", @@ -128,7 +123,6 @@ func runEvidenceVerifyCmd(ctx context.Context, cmd *cli.Command) error { result, err := verifier.Verify(ctx, verifier.VerifyOptions{ Input: input, BundleRef: cmd.String("bundle"), - NoRekor: cmd.Bool("no-rekor"), ExpectedIssuer: cmd.String("expected-issuer"), ExpectedIdentityRegexp: cmd.String("expected-identity-regexp"), PlainHTTP: cmd.Bool("registry-plain-http"), diff --git a/pkg/cli/evidence_verify_test.go b/pkg/cli/evidence_verify_test.go index 421f1d2c1..89b4a124b 100644 --- a/pkg/cli/evidence_verify_test.go +++ b/pkg/cli/evidence_verify_test.go @@ -50,7 +50,7 @@ func TestEvidenceVerifyCmd_HasExpectedFlags(t *testing.T) { cmd := evidenceVerifyCmd() wanted := []string{ "output", "format", - "no-rekor", "expected-issuer", "expected-identity-regexp", "bundle", + "expected-issuer", "expected-identity-regexp", "bundle", "registry-plain-http", "registry-insecure-tls", "allow-unpinned-tag", } for _, name := range wanted { diff --git a/pkg/evidence/verifier/report.go b/pkg/evidence/verifier/report.go index bfcd86c0f..ed82c72e3 100644 --- a/pkg/evidence/verifier/report.go +++ b/pkg/evidence/verifier/report.go @@ -77,7 +77,8 @@ func writeFailedStepDetails(b *strings.Builder, r *VerifyResult) { } func writeHeader(b *strings.Builder, r *VerifyResult) { - if r.Signer != nil && r.Signer.Identity != "" { + switch { + case r.Signer != nil && r.Signer.Identity != "": fmt.Fprintf(b, "**Signer:** %s", r.Signer.Identity) if r.Signer.Issuer != "" { fmt.Fprintf(b, " (issuer %s)", r.Signer.Issuer) @@ -86,7 +87,12 @@ func writeHeader(b *strings.Builder, r *VerifyResult) { fmt.Fprintf(b, " • **Rekor:** index %d", *r.Signer.RekorLogIndex) } b.WriteString("\n") - } else { + case signatureStepStatus(r) == StepFailed: + // A signed bundle whose signature didn't verify is meaningfully + // different from a bundle that carries no signature at all — + // don't claim "unsigned" when verification actually failed. + b.WriteString("**Signer:** _signature verification failed (see Verification steps)_\n") + default: b.WriteString("**Signer:** _unsigned bundle_\n") } if r.Predicate != nil { @@ -99,6 +105,17 @@ func writeHeader(b *strings.Builder, r *VerifyResult) { b.WriteString("\n\n") } +// signatureStepStatus returns the recorded status of the signature +// step, or "" if no signature step was recorded. +func signatureStepStatus(r *VerifyResult) StepStatus { + for _, s := range r.Steps { + if s.Step == stepSignature { + return s.Status + } + } + return "" +} + func writeFingerprint(b *strings.Builder, p *attestation.Predicate) { if p == nil || len(p.CriteriaMatch.PerDimension) == 0 { return diff --git a/pkg/evidence/verifier/signature.go b/pkg/evidence/verifier/signature.go index 0343323c6..df6542984 100644 --- a/pkg/evidence/verifier/signature.go +++ b/pkg/evidence/verifier/signature.go @@ -118,11 +118,10 @@ func VerifySignature(ctx context.Context, mat *MaterializedBundle, opts VerifyOp return nil, idErr } - verifierOpts := []verify.VerifierOption{verify.WithObserverTimestamps(1)} - if !opts.NoRekor { - verifierOpts = append(verifierOpts, verify.WithTransparencyLog(1)) - } - v, vErr := verify.NewVerifier(trustedMaterial, verifierOpts...) + v, vErr := verify.NewVerifier(trustedMaterial, + verify.WithObserverTimestamps(1), + verify.WithTransparencyLog(1), + ) if vErr != nil { return nil, errors.Wrap(errors.ErrCodeInternal, "failed to create sigstore verifier", vErr) } @@ -136,7 +135,8 @@ func VerifySignature(ctx context.Context, mat *MaterializedBundle, opts VerifyOp return nil, errors.New(errors.ErrCodeUnauthorized, "sigstore verification failed — trusted root may be stale.\n\n To fix: aicr trust update") } - return nil, errors.Wrap(errors.ErrCodeUnauthorized, "sigstore verification failed", verifyErr) + return nil, errors.New(errors.ErrCodeUnauthorized, + "sigstore verification failed: "+sanitizeSigstoreError(verifyErr)) } claims := &SignerClaims{} @@ -308,6 +308,28 @@ func CrossCheckPointerSigner(claimed *attestation.PointerSigner, actual *SignerC return nil } +// sanitizeSigstoreError strips Go format-string artifacts that +// sigstore-go produces when its threshold-not-met paths wrap an empty +// errors.Join(...) chain with %w. The literal "%!w()" leaks into +// user-visible error messages; this helper removes it so the surface +// reads as "threshold not met for verified signed timestamps: 0 < 1" +// instead of "...: 0 < 1; error: %!w()". +// +// Tracked upstream; safe to remove when sigstore-go's tsa.go and +// similar wraps guard against nil joins. +func sanitizeSigstoreError(err error) string { + msg := err.Error() + for _, suffix := range []string{ + "; error: %!w()", + ": %!w()", + " %!w()", + "%!w()", + } { + msg = strings.ReplaceAll(msg, suffix, "") + } + return msg +} + // isCertChainError reports whether the sigstore error string signals // a stale trusted-root condition. Used to suggest `aicr trust update`. func isCertChainError(msg string) bool { diff --git a/pkg/evidence/verifier/signature_test.go b/pkg/evidence/verifier/signature_test.go index a98275c79..3ae47dd89 100644 --- a/pkg/evidence/verifier/signature_test.go +++ b/pkg/evidence/verifier/signature_test.go @@ -81,6 +81,44 @@ func TestVerifySignature_NilBundleErrors(t *testing.T) { } } +func TestSanitizeSigstoreError(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "tsa threshold artifact", + in: "threshold not met for verified signed timestamps: 0 < 1; error: %!w()", + want: "threshold not met for verified signed timestamps: 0 < 1", + }, + { + name: "bare artifact", + in: "some error: %!w()", + want: "some error", + }, + { + name: "no artifact", + in: "ordinary error", + want: "ordinary error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeSigstoreError(errPlain{msg: tt.in}) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +// errPlain is a tiny error wrapper that returns msg verbatim from +// Error(). Used to feed exact strings to sanitizeSigstoreError. +type errPlain struct{ msg string } + +func (e errPlain) Error() string { return e.msg } + // TestParseStatement covers the JSON parsing path that runs after DSSE // decode — pure data-plumbing, no sigstore involvement. func TestParseStatement(t *testing.T) { diff --git a/pkg/evidence/verifier/types.go b/pkg/evidence/verifier/types.go index f1a972b5e..ec65ed110 100644 --- a/pkg/evidence/verifier/types.go +++ b/pkg/evidence/verifier/types.go @@ -56,11 +56,6 @@ type VerifyOptions struct { // embed one — e.g., a pointer file whose bundle.oci is empty. BundleRef string - // NoRekor skips the Rekor transparency-log cross-check and uses - // the cert + signature carried in the Sigstore Bundle alone. - // Useful in air-gapped environments. - NoRekor bool - // ExpectedIssuer pins the OIDC issuer URL recorded on the signing // certificate. Empty allows any issuer. ExpectedIssuer string diff --git a/pkg/evidence/verifier/verify_test.go b/pkg/evidence/verifier/verify_test.go index 894fa9628..c72222c03 100644 --- a/pkg/evidence/verifier/verify_test.go +++ b/pkg/evidence/verifier/verify_test.go @@ -285,6 +285,46 @@ func TestRenderMarkdown_FailedStepDetailsListsFiles(t *testing.T) { } } +func TestRenderMarkdown_HeaderDistinguishesFailedFromUnsigned(t *testing.T) { + tests := []struct { + name string + steps []StepResult + signer *SignerClaims + wantSub string + wantNotSub string + }{ + { + name: "passed signature shows identity", + steps: []StepResult{{Step: stepSignature, Name: "signature-verify", Status: StepPassed}}, + signer: &SignerClaims{Identity: "alice@example.com", Issuer: "https://issuer"}, + wantSub: "alice@example.com", + }, + { + name: "skipped signature shows unsigned bundle", + steps: []StepResult{{Step: stepSignature, Name: "signature-verify", Status: StepSkipped}}, + wantSub: "unsigned bundle", + }, + { + name: "failed signature does NOT claim unsigned", + steps: []StepResult{{Step: stepSignature, Name: "signature-verify", Status: StepFailed, Detail: "x"}}, + wantSub: "signature verification failed", + wantNotSub: "unsigned", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &VerifyResult{Steps: tt.steps, Signer: tt.signer} + md := RenderMarkdown(r) + if !strings.Contains(md, tt.wantSub) { + t.Errorf("output should contain %q; got %q", tt.wantSub, md) + } + if tt.wantNotSub != "" && strings.Contains(md, tt.wantNotSub) { + t.Errorf("output should NOT contain %q; got %q", tt.wantNotSub, md) + } + }) + } +} + func TestRenderMarkdown_NoFailedStepDetailsSectionWhenAllPass(t *testing.T) { r := &VerifyResult{ Exit: ExitValidPassed, From b44e3c376441d354fe8364d71c5a697c1370ce1a Mon Sep 17 00:00:00 2001 From: Nathan Hensley Date: Thu, 14 May 2026 11:36:22 -0700 Subject: [PATCH 4/7] docs(evidence): note the :v1 placeholder tag for --push references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators reading the demo were confused why `--push ttl.sh/aicr-demo` worked when ttl.sh requires a tag. Explain that the emitter substitutes `:v1` as a placeholder when the caller omits one — the OCI digest is the canonical address, and the pointer file the emitter writes records both the (tagged) ref and the digest. --- demos/evidence.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/demos/evidence.md b/demos/evidence.md index 2c1d90f3c..3e1745a37 100644 --- a/demos/evidence.md +++ b/demos/evidence.md @@ -51,7 +51,10 @@ aicr validate \ ``` `--push` opens a browser for OIDC sign-in (or uses ambient GitHub Actions -OIDC if `ACTIONS_ID_TOKEN_REQUEST_URL` is set). After it finishes: +OIDC if `ACTIONS_ID_TOKEN_REQUEST_URL` is set). The tag may be omitted as +shown — the emitter applies `:v1` as a placeholder since the OCI digest +is the canonical address; the pointer file (below) records both. After +it finishes: ```text ./out From ce8b23ea51bcf0b99514f746a7099f1d0990d32a Mon Sep 17 00:00:00 2001 From: Nathan Hensley Date: Thu, 14 May 2026 11:59:49 -0700 Subject: [PATCH 5/7] =?UTF-8?q?fix(verifier):=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20OCI=20ref=20format,=20auth=20client,=20input=20dete?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings from CodeRabbit on PR #890: - materializeOCIRefRequireDigest was joining (registry, repo, target) with ":" unconditionally, producing invalid refs like "ghcr.io/owner/repo:sha256:..." when the target was a digest. The OCI spec uses "@" for digest separators and ":" for tags. Extract formatOCIReference so the rule lives in one place; add a table test covering tag, digest, and localhost-with-port cases. - newAuthClient returned nil for plainHTTP/insecureTLS, throwing away any ambient docker credentials the operator might have configured. Build a proper *auth.Client that loads the docker credential store (best-effort: anonymous fallback when missing) and honors the TLS preferences explicitly. Clone any existing TLS config when flipping InsecureSkipVerify so hardening defaults from defaults.NewHTTPTransport (MinVersion, ciphers) survive. Emits a Warn when TLS verification is disabled — a deliberate operator opt-in should be visible in logs. - DetectInputForm/looksLikeOCIRef classified "./out/bundle" and "../bundle" as OCI references because the leading "." satisfied the "contains '.' or ':'" registry-host heuristic. Reject relative- path tokens explicitly before the registry check; add test cases for both forms. - demos/evidence.md: clarify the registry-referrer requirement (the registry must support OCI 1.1 Referrers API or fallback tag-schema referrers; without either, the Sigstore Bundle can't be attached and the verifier will record signature-verify as skipped even for push-time-signed bundles). Also expand the exit-code section: the CLI collapses library exit codes 1 and 2 to OS exit 2 today; JSON consumers reading the .exit field still see the three-way split. --- demos/evidence.md | 39 +++++++++++++++---- pkg/evidence/verifier/fetch.go | 58 +++++++++++++++++++++++++---- pkg/evidence/verifier/fetch_test.go | 21 +++++++++++ pkg/evidence/verifier/input.go | 4 ++ pkg/evidence/verifier/input_test.go | 2 + 5 files changed, 110 insertions(+), 14 deletions(-) diff --git a/demos/evidence.md b/demos/evidence.md index 3e1745a37..52a39170a 100644 --- a/demos/evidence.md +++ b/demos/evidence.md @@ -21,8 +21,16 @@ This demo walks through the full producer-and-consumer loop: * `aicr` with `aicr evidence verify` available. * A Kubernetes cluster to validate against. -* OCI registry write access for the producer (GHCR, GitLab Container - Registry, Harbor, ECR, Artifactory, ACR — any OCI-1.1 registry works). +* OCI registry write access for the producer. The registry must + support storing image manifests AND referrers — either via the + OCI 1.1 Referrers API (`/v2//referrers/`) or via + fallback tag-schema referrers. ORAS handles either transparently. + Known-good: GHCR, GitLab Container Registry, Harbor (≥ 2.8), + AWS ECR, Google Artifact Registry, Azure Container Registry, + JFrog Artifactory. Registries without referrer support cannot + carry the Sigstore Bundle attached to the artifact; without that + the verifier records signature-verify as "skipped (unsigned)" + even though the bundle was signed at push-time. * For signing: a working OIDC source. GitHub Actions OIDC is detected automatically; otherwise the CLI opens a browser for keyless signing. * Bootstrap the Sigstore trusted root once on the verifier's machine: @@ -115,11 +123,28 @@ The verifier pulls the OCI artifact and runs five checks: Exit codes: -* `0` — bundle valid, all checks passed. -* `1` — bundle valid, but recorded validator results show failures - (informational; cryptographic integrity intact). Surfaced in - `VerifyResult.Exit` for JSON consumers; OS exit collapses to `2`. -* `2` — bundle invalid (signature, integrity, or constraint failure). +| Surface | `0` | `2` | +|---|---|---| +| OS exit code | Bundle valid; every check passed. | Anything else — bundle invalid OR recorded validator results show failures. | + +The structured output's `exit` field (`VerifyResult.Exit` in the +library, `.exit` in JSON output) carries a three-valued code so JSON +consumers can distinguish the two non-zero cases: + +* `0` — bundle valid; every check passed. +* `1` — bundle valid; recorded validator results show failures + (cryptographic integrity intact, informational). +* `2` — bundle invalid (signature, integrity, or predicate failure). + +Today the CLI collapses `1` and `2` to OS exit `2` because +`pkg/errors/exitcode.go` maps both `ErrCodeConflict` and +`ErrCodeInvalidRequest` to the same OS code. Shell scripts that want +to branch on the informational case should consume `--format json` +and read `.exit` via `jq`: + +```shell +aicr evidence verify recipes/evidence/.yaml --format json | jq '.exit' +``` Pin the expected signer when only one identity should be accepted: diff --git a/pkg/evidence/verifier/fetch.go b/pkg/evidence/verifier/fetch.go index 495b0e8e5..bd8d91698 100644 --- a/pkg/evidence/verifier/fetch.go +++ b/pkg/evidence/verifier/fetch.go @@ -16,9 +16,11 @@ package verifier import ( "context" + "crypto/tls" "encoding/json" "io" "log/slog" + "net/http" "os" "path/filepath" "strings" @@ -29,6 +31,7 @@ import ( "oras.land/oras-go/v2/content/file" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" "github.com/NVIDIA/aicr/pkg/defaults" "github.com/NVIDIA/aicr/pkg/errors" @@ -224,12 +227,24 @@ func materializeOCIRefRequireDigest(ctx context.Context, ref string, opts Verify return &MaterializedBundle{ BundleDir: resolved, - Reference: registry + "/" + repo + ":" + refTarget, + Reference: formatOCIReference(registry, repo, refTarget), Digest: desc.Digest.String(), cleanup: cleanup, }, nil } +// formatOCIReference assembles a canonical OCI reference from its +// parts. Digest targets get separated by "@" per the OCI spec; tag +// targets use ":". Joining with ":" unconditionally produces invalid +// refs like "registry/repo:sha256:..." for digest pulls. +func formatOCIReference(registry, repo, target string) string { + sep := ":" + if isDigestPinned(target) { + sep = "@" + } + return registry + "/" + repo + sep + target +} + // referrerFetcher is the minimal subset of *remote.Repository that // fetchAndWriteReferrerLayer uses. Exists so tests can substitute an // in-memory fake without spinning up a real registry. @@ -369,12 +384,41 @@ func parseOCIReference(ref string) (registry, repo, target string, err error) { return registry, repo, target, nil } -// newAuthClient returns nil for plainHTTP/insecureTLS so ORAS falls -// back to its built-in default; otherwise returns the package-level -// auth.DefaultClient which honors ambient docker credentials. +// newAuthClient builds an oras-go auth.Client that honors ambient +// docker credentials and the operator's TLS preferences. Mirrors the +// producer-side pattern in pkg/oci.createAuthClientForHost so both +// sides have consistent registry behavior. +// +// Docker credential store load is best-effort: if a developer has no +// docker config, public-registry pulls still work (the client just +// goes anonymous). func newAuthClient(plainHTTP, insecureTLS bool) *auth.Client { - if plainHTTP || insecureTLS { - return nil + transport := defaults.NewHTTPTransport() + if !plainHTTP && insecureTLS { + slog.Warn("TLS verification disabled for OCI registry") + // Clone any existing TLS config so hardening defaults from + // defaults.NewHTTPTransport (MinVersion, ciphers) survive. + var cfg *tls.Config + if transport.TLSClientConfig != nil { + cfg = transport.TLSClientConfig.Clone() + } else { + cfg = &tls.Config{} //nolint:gosec // InsecureSkipVerify set on next line + } + cfg.InsecureSkipVerify = true //nolint:gosec // explicit operator opt-in via --registry-insecure-tls + transport.TLSClientConfig = cfg } - return auth.DefaultClient + + client := &auth.Client{ + Client: &http.Client{Timeout: defaults.HTTPClientTimeout, Transport: transport}, + Cache: auth.NewCache(), + } + + if credStore, err := credentials.NewStoreFromDocker(credentials.StoreOptions{}); err == nil && credStore != nil { + client.Credential = credentials.Credential(credStore) + } else if err != nil { + slog.Debug("docker credential store unavailable; continuing anonymously", + "error", err.Error()) + } + + return client } diff --git a/pkg/evidence/verifier/fetch_test.go b/pkg/evidence/verifier/fetch_test.go index f70cc8a1f..3b0946268 100644 --- a/pkg/evidence/verifier/fetch_test.go +++ b/pkg/evidence/verifier/fetch_test.go @@ -46,6 +46,27 @@ func TestMaterializeBundle_DirRejectsNonBundle(t *testing.T) { } } +func TestFormatOCIReference(t *testing.T) { + tests := []struct { + name string + registry, repo, t string + want string + }{ + {"tag", "ghcr.io", "owner/repo", "v1", "ghcr.io/owner/repo:v1"}, + {"digest", "ghcr.io", "owner/repo", "sha256:" + strings.Repeat("a", 64), + "ghcr.io/owner/repo@sha256:" + strings.Repeat("a", 64)}, + {"localhost tag", "localhost:5000", "repo", "latest", "localhost:5000/repo:latest"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatOCIReference(tt.registry, tt.repo, tt.t) + if got != tt.want { + t.Errorf("formatOCIReference = %q, want %q", got, tt.want) + } + }) + } +} + func TestParseOCIReference(t *testing.T) { tests := []struct { name string diff --git a/pkg/evidence/verifier/input.go b/pkg/evidence/verifier/input.go index 7179c3292..5336e57ca 100644 --- a/pkg/evidence/verifier/input.go +++ b/pkg/evidence/verifier/input.go @@ -62,6 +62,10 @@ func looksLikeOCIRef(s string) bool { if !ok { return false } + // Relative-path tokens contain dots but aren't registries. + if first == "." || first == ".." { + return false + } if !strings.ContainsAny(first, ".:") && first != "localhost" { return false } diff --git a/pkg/evidence/verifier/input_test.go b/pkg/evidence/verifier/input_test.go index 3152a914a..85260be79 100644 --- a/pkg/evidence/verifier/input_test.go +++ b/pkg/evidence/verifier/input_test.go @@ -46,6 +46,8 @@ func TestDetectInputForm(t *testing.T) { {"bare oci with digest", "ghcr.io/owner/repo@sha256:abc", InputFormOCI, false}, {"bare oci with tag", "ghcr.io/owner/repo:v1", InputFormOCI, false}, {"localhost registry", "localhost:5000/repo:v1", InputFormOCI, false}, + {"relative path rejected", "./out/summary-bundle", "", true}, + {"parent path rejected", "../some-bundle", "", true}, {"nonsense rejected", "not-an-input", "", true}, } for _, tt := range tests { From 2ff91dce46def631ff75f942839063d9978fbbd1 Mon Sep 17 00:00:00 2001 From: Nathan Hensley Date: Thu, 14 May 2026 12:15:40 -0700 Subject: [PATCH 6/7] fix(verifier): fail closed on malformed referrer; stop pagination on first match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three correctness fixes plus an operator-visible warning: - discoverAndWriteReferrer used `return nil` to break out of the Referrers pagination callback after writing the first matching Sigstore Bundle. That only stops the *current* page; subsequent pages would re-enter the loop and overwrite attestation.intoto.jsonl with a later match. Introduce an unexported errReferrerFound sentinel as the stop signal and translate it back to a clean nil at the top. - materializeOCIRefRequireDigest treated *any* referrer-discovery error as "unsigned bundle" and let signature-verify record Skipped. A registry that returns a malformed Referrers response (or a MITM rewriting one) could silently downgrade a signed bundle to "unsigned." Discriminate: only ErrCodeNotFound is the unsigned-bundle path; everything else (malformed manifest, oversized layer, bad JSON) propagates as ErrCodeInvalidRequest so the verifier fails closed. - VerifySignature now slog.Warn-s when the bundle verifies but no signer was pinned via --expected-issuer or --expected-identity- regexp. Without a pin, ANY Fulcio-issued cert from ANY OIDC provider passes the policy — the signature is cryptographically valid but the verifier hasn't said anything about *who* signed. Surfaces the default-trust footgun in the logs. - TestMaterializeBundle_TagOnlyWithAllowFlag previously relied on "no registry available in the test" to fail the OCI pull. That's not portable — DNS for ghcr.io resolves and oras-go could pay the full 2-minute pull timeout. Point the test at 127.0.0.1:1 (no listener) with a 250ms context so connection fails fast and the test stays local-only. - cli-reference.md: document --allow-unpinned-tag and replace the relative demos/evidence.md link with a global GitHub URL (matches the project's Review Output Links convention). --- docs/user/cli-reference.md | 3 +- pkg/evidence/verifier/crosscheck_test.go | 25 +++++++---- pkg/evidence/verifier/fetch.go | 53 +++++++++++++++++------- pkg/evidence/verifier/signature.go | 15 +++++++ 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index d43cb6f5e..82317fe0b 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -1917,6 +1917,7 @@ The positional argument is auto-detected as one of: | `--bundle` | | string | | OCI reference override when the pointer carries no `bundle.oci`. | | `--registry-plain-http` | | bool | `false` | Use HTTP for registry traffic (local-registry tests only). | | `--registry-insecure-tls` | | bool | `false` | Skip TLS verification for the registry (self-signed certificates). | +| `--allow-unpinned-tag` | | bool | `false` | Accept tag-only OCI references. By default the verifier refuses unpinned refs because tags are registry-rewritable; opt in only for one-off debugging. Pointer-driven flows ignore this flag when the pointer carries a `sha256:` digest. | **Exit codes:** @@ -1947,7 +1948,7 @@ aicr evidence verify recipes/evidence/.yaml \ aicr evidence verify recipes/evidence/.yaml -o result.json -t json ``` -See [demos/evidence.md](../../demos/evidence.md) for a full producer-and-consumer walkthrough. +See [`demos/evidence.md`](https://github.com/NVIDIA/aicr/blob/main/demos/evidence.md) for a full producer-and-consumer walkthrough. > **Stale root:** If verification fails with certificate chain errors, run `aicr trust update` to refresh the Sigstore trusted root. diff --git a/pkg/evidence/verifier/crosscheck_test.go b/pkg/evidence/verifier/crosscheck_test.go index 195309ddf..ee47b7882 100644 --- a/pkg/evidence/verifier/crosscheck_test.go +++ b/pkg/evidence/verifier/crosscheck_test.go @@ -15,8 +15,10 @@ package verifier import ( + "context" "strings" "testing" + "time" "github.com/NVIDIA/aicr/pkg/evidence/attestation" ) @@ -143,15 +145,24 @@ func TestMaterializeBundle_RejectsTagOnlyOCI(t *testing.T) { } func TestMaterializeBundle_TagOnlyWithAllowFlag(t *testing.T) { - // With AllowUnpinnedTag, we get past the pin check and fail at - // the OCI pull (no actual registry available in the test). The - // pin-error message must NOT appear. - form, _ := DetectInputForm("ghcr.io/owner/repo:v1") - _, err := MaterializeBundle(t.Context(), - VerifyOptions{Input: "ghcr.io/owner/repo:v1", AllowUnpinnedTag: true}, + // With AllowUnpinnedTag, the pin check is bypassed and execution + // proceeds into oras.Copy. Use 127.0.0.1:1 (a port nothing should + // be listening on) so connection fails fast — never reaching the + // real internet — and a 250ms per-test context so flaky DNS or a + // slow stack can't pay the 2-minute pull timeout. The assertion + // is only that the pin-error message is NOT in the result. + const unreachable = "127.0.0.1:1/repo:v1" + form, err := DetectInputForm(unreachable) + if err != nil { + t.Fatalf("DetectInputForm: %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond) + defer cancel() + _, err = MaterializeBundle(ctx, + VerifyOptions{Input: unreachable, AllowUnpinnedTag: true}, form, nil) if err == nil { - t.Fatalf("expected pull error (no registry), got nil") + t.Fatalf("expected pull error (unreachable registry), got nil") } if strings.Contains(err.Error(), "tag-only") { t.Errorf("AllowUnpinnedTag should bypass tag-pin check; got %v", err) diff --git a/pkg/evidence/verifier/fetch.go b/pkg/evidence/verifier/fetch.go index bd8d91698..bd248224e 100644 --- a/pkg/evidence/verifier/fetch.go +++ b/pkg/evidence/verifier/fetch.go @@ -18,6 +18,7 @@ import ( "context" "crypto/tls" "encoding/json" + stderrors "errors" "io" "log/slog" "net/http" @@ -38,6 +39,13 @@ import ( "github.com/NVIDIA/aicr/pkg/evidence/attestation" ) +// errReferrerFound is an unexported sentinel used to unwind the +// pagination callback in repo.Referrers after the first matching +// Sigstore Bundle referrer has been consumed. Without this sentinel, +// `return nil` from the callback only stops the current page; the +// next page would re-enter and overwrite attestation.intoto.jsonl. +var errReferrerFound = stderrors.New("referrer found") + // maxReferrerManifestBytes caps the in-memory read of a referrer // manifest JSON. Manifests are small (KiB); anything past this is a // bug or hostile. @@ -124,7 +132,9 @@ func hasBundleMarkers(dir string) bool { // first attestation, or falls back to opts.BundleRef when the pointer // has no OCI ref. The pointer's digest claim is cross-checked against // the actual pulled digest. A tag-only ref is allowed only when the -// pointer carries a non-empty bundle.digest — that digest is the pin. +// pointer carries a sha256-prefixed bundle.digest (the validator in +// pointer.go rejects any other shape when bundle.oci is set) — that +// digest is the pin. func materializeFromPointer( ctx context.Context, pointer *attestation.Pointer, @@ -218,11 +228,22 @@ func materializeOCIRefRequireDigest(ctx context.Context, ref string, opts Verify // artifact, not part of the artifact's own layers. Discover and // stage it as attestation.intoto.jsonl so signature verification // can read it from disk the same way it does for directory input. - // Best-effort: an unsigned bundle has no referrer and the signature - // step records Skipped (matches the unsigned-on-disk behavior). + // + // "No referrer at all" is a legitimate unsigned-bundle state → + // debug-log and let the signature step record Skipped. ANY other + // error (malformed manifest, oversized layer, registry returning + // junk) is fail-closed: a registry that mid-MITMs the Referrers + // response could otherwise silently downgrade a signed bundle to + // "unsigned." if err := discoverAndWriteReferrer(pullCtx, remoteRepo, desc, resolved); err != nil { - slog.Debug("no Sigstore Bundle referrer discovered", - "reference", registry+"/"+repo, "error", err.Error()) + if stderrors.Is(err, errors.New(errors.ErrCodeNotFound, "")) { + slog.Debug("no Sigstore Bundle referrer discovered", + "reference", registry+"/"+repo) + } else { + cleanup() + return nil, errors.Wrap(errors.ErrCodeInvalidRequest, + "registry returned a malformed Sigstore Bundle referrer", err) + } } return &MaterializedBundle{ @@ -258,8 +279,11 @@ type referrerFetcher interface { // // Returns ErrCodeNotFound when no Sigstore Bundle referrer is present; // callers treat that as "unsigned bundle." Other errors propagate. +// +// "Take the first matching referrer" is enforced via an unexported +// errReferrerFound sentinel — `return nil` from the callback would +// only stop the current page, letting a later page overwrite the file. func discoverAndWriteReferrer(ctx context.Context, repo *remote.Repository, subject ociv1.Descriptor, bundleDir string) error { - found := false cbErr := repo.Referrers(ctx, subject, attestation.SigstoreBundleMediaType, func(refs []ociv1.Descriptor) error { for _, r := range refs { @@ -269,21 +293,20 @@ func discoverAndWriteReferrer(ctx context.Context, repo *remote.Repository, subj if err := fetchAndWriteReferrerLayer(ctx, repo, r, bundleDir); err != nil { return err } - found = true - // Take the first matching referrer. Multi-signature - // bundles aren't a V1 case; if one ever lands, we'd - // need a selection policy. - return nil + // Multi-signature bundles aren't a V1 case; if one ever + // lands, we'd need a selection policy. For now: first + // match wins and stops pagination. + return errReferrerFound } return nil }) + if stderrors.Is(cbErr, errReferrerFound) { + return nil + } if cbErr != nil { return errors.Wrap(errors.ErrCodeUnavailable, "referrers query failed", cbErr) } - if !found { - return errors.New(errors.ErrCodeNotFound, "no Sigstore Bundle referrer for artifact") - } - return nil + return errors.New(errors.ErrCodeNotFound, "no Sigstore Bundle referrer for artifact") } // fetchAndWriteReferrerLayer pulls the referrer's manifest, extracts diff --git a/pkg/evidence/verifier/signature.go b/pkg/evidence/verifier/signature.go index df6542984..c7a1b2045 100644 --- a/pkg/evidence/verifier/signature.go +++ b/pkg/evidence/verifier/signature.go @@ -19,6 +19,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "log/slog" "os" "path/filepath" "strconv" @@ -148,6 +149,20 @@ func VerifySignature(ctx context.Context, mat *MaterializedBundle, opts VerifyOp i := idx claims.RekorLogIndex = &i } + + // Surface the no-pin footgun: without --expected-issuer or + // --expected-identity-regexp, ANY Fulcio-issued cert from ANY OIDC + // provider passes the identity policy. The signature is still + // cryptographically valid, but the verifier hasn't said anything + // about *who* signed. Operators reviewing the report need to know + // that default verification accepts every signer. + if opts.ExpectedIssuer == "" && opts.ExpectedIdentityRegexp == "" { + slog.Warn("signature verified but no signer pinned — any Fulcio identity will pass", + "identity", claims.Identity, + "issuer", claims.Issuer, + "hint", "consider --expected-issuer / --expected-identity-regexp to fail on unexpected signers") + } + return &SignatureResult{Signer: claims, Predicate: predicate}, nil } From 2e8be7a34e38b1e7704404f4d67b3515c25e68a8 Mon Sep 17 00:00:00 2001 From: Nathan Hensley Date: Thu, 14 May 2026 12:36:06 -0700 Subject: [PATCH 7/7] fix(verifier): pin TLS 1.2 floor when synthesizing TLS config for --registry-insecure-tls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit newAuthClient clones any existing transport TLSClientConfig before flipping InsecureSkipVerify so hardening defaults survive. But defaults.NewHTTPTransport currently leaves TLSClientConfig nil, so we fall through to the synthesized-config branch and would inherit Go's historical client default for MinVersion. Set tls.VersionTLS12 explicitly — it's the project floor everywhere else. --- pkg/evidence/verifier/fetch.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/evidence/verifier/fetch.go b/pkg/evidence/verifier/fetch.go index bd248224e..61f5857a8 100644 --- a/pkg/evidence/verifier/fetch.go +++ b/pkg/evidence/verifier/fetch.go @@ -425,7 +425,10 @@ func newAuthClient(plainHTTP, insecureTLS bool) *auth.Client { if transport.TLSClientConfig != nil { cfg = transport.TLSClientConfig.Clone() } else { - cfg = &tls.Config{} //nolint:gosec // InsecureSkipVerify set on next line + // defaults.NewHTTPTransport currently leaves TLSClientConfig + // nil; set MinVersion explicitly so we don't fall through to + // Go's historical client default. TLS 1.2 is the project floor. + cfg = &tls.Config{MinVersion: tls.VersionTLS12} //nolint:gosec // InsecureSkipVerify set on next line } cfg.InsecureSkipVerify = true //nolint:gosec // explicit operator opt-in via --registry-insecure-tls transport.TLSClientConfig = cfg