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..52a39170a --- /dev/null +++ b/demos/evidence.md @@ -0,0 +1,235 @@ +# 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` available. +* A Kubernetes cluster to validate against. +* 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: + + ```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). 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 +├── 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: + +| 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: + +```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. diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index 11d9a0ca0..82317fe0b 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -1892,38 +1892,66 @@ 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. | +| `--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). | +| `--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:** | 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/.*$' + +# CI pipelines: JSON output. +aicr evidence verify recipes/evidence/.yaml -o result.json -t json ``` +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. + --- ### aicr trust update diff --git a/pkg/cli/evidence_verify.go b/pkg/cli/evidence_verify.go index 100ea6404..e0e217936 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.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, + }, + &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, } @@ -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"), + ExpectedIssuer: cmd.String("expected-issuer"), + 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 1ea75aa5d..89b4a124b 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", + "expected-issuer", "expected-identity-regexp", "bundle", + "registry-plain-http", "registry-insecure-tls", "allow-unpinned-tag", + } for _, name := range wanted { found := false for _, f := range cmd.Flags { diff --git a/pkg/evidence/verifier/crosscheck_test.go b/pkg/evidence/verifier/crosscheck_test.go new file mode 100644 index 000000000..ee47b7882 --- /dev/null +++ b/pkg/evidence/verifier/crosscheck_test.go @@ -0,0 +1,170 @@ +// 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" + "strings" + "testing" + "time" + + "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, 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 (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/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..61f5857a8 100644 --- a/pkg/evidence/verifier/fetch.go +++ b/pkg/evidence/verifier/fetch.go @@ -16,23 +16,57 @@ package verifier import ( "context" + "crypto/tls" + "encoding/json" + stderrors "errors" + "io" + "log/slog" + "net/http" "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" + "oras.land/oras-go/v2/registry/remote/credentials" + + "github.com/NVIDIA/aicr/pkg/defaults" "github.com/NVIDIA/aicr/pkg/errors" "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. +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 +75,31 @@ 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: + // 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)) } - return materializeDir(opts.Input) } // materializeDir accepts either the summary-bundle root or a parent @@ -81,3 +127,324 @@ 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. A tag-only ref is allowed only when the +// 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, + 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") + } + // 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 + } + 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 +} + +// 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 { + 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. + // + // "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 { + 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{ + BundleDir: resolved, + 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. +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. +// +// "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 { + 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 + } + // 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) + } + return errors.New(errors.ErrCodeNotFound, "no Sigstore Bundle referrer for artifact") +} + +// 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") +} + +// 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) { + 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 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 { + 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 { + // 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 + } + + 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 c1fe015fb..3b0946268 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,63 @@ 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 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 + 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..5336e57ca 100644 --- a/pkg/evidence/verifier/input.go +++ b/pkg/evidence/verifier/input.go @@ -16,24 +16,61 @@ 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 + } + // Relative-path tokens contain dots but aren't registries. + if first == "." || first == ".." { + 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..85260be79 100644 --- a/pkg/evidence/verifier/input_test.go +++ b/pkg/evidence/verifier/input_test.go @@ -38,10 +38,17 @@ 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}, + {"relative path rejected", "./out/summary-bundle", "", true}, + {"parent path rejected", "../some-bundle", "", true}, + {"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..ed82c72e3 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" @@ -43,19 +44,78 @@ 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) { - b.WriteString("**Signer:** _signature verification not yet implemented in this slice_\n") + 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) + } + if r.Signer.RekorLogIndex != nil { + fmt.Fprintf(b, " • **Rekor:** index %d", *r.Signer.RekorLogIndex) + } + b.WriteString("\n") + 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 { 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") } +// 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 new file mode 100644 index 000000000..c7a1b2045 --- /dev/null +++ b/pkg/evidence/verifier/signature.go @@ -0,0 +1,365 @@ +// 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" + "log/slog" + "os" + "path/filepath" + "strconv" + "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 + } + + 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) + } + + 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.New(errors.ErrCodeUnauthorized, + "sigstore verification failed: "+sanitizeSigstoreError(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 + } + + // 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 +} + +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() +} + +// 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 +} + +// 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 { + 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..3ae47dd89 --- /dev/null +++ b/pkg/evidence/verifier/signature_test.go @@ -0,0 +1,174 @@ +// 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") + } +} + +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) { + 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..ec65ed110 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,47 @@ 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 + + // 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 + + // 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. +// 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 +107,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..f1d763cdc 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,77 @@ 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. +// +// 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: + record(r, stepSignature, StepFailed, sigErr.Error(), nil) + r.Exit = ExitInvalid + 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 { + 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..c72222c03 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") } @@ -249,6 +258,86 @@ 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_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, + 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)