Skip to content

Commit be0a643

Browse files
committed
feat(cli): add attestation bundle and verify
1 parent 7efaf5c commit be0a643

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+3173
-117
lines changed

.github/actions/cli-e2e/action.yml

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
name: 'CLI E2E Tests'
16+
description: 'Build attested aicr binary and run CLI chainsaw tests'
17+
18+
inputs:
19+
go_version:
20+
description: 'Go version (e.g., 1.25)'
21+
required: true
22+
chainsaw_version:
23+
description: 'Chainsaw version (e.g., v0.2.14)'
24+
required: true
25+
26+
runs:
27+
using: 'composite'
28+
steps:
29+
- name: Setup Go
30+
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
31+
with:
32+
go-version: '${{ inputs.go_version }}'
33+
cache: true
34+
35+
- name: Install Cosign
36+
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
37+
38+
- name: Install GoReleaser
39+
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
40+
with:
41+
install-only: true
42+
43+
- name: Install Chainsaw
44+
shell: bash
45+
run: |
46+
set -euo pipefail
47+
VERSION="${{ inputs.chainsaw_version }}"
48+
VERSION="${VERSION#v}"
49+
TAR="chainsaw_linux_amd64.tar.gz"
50+
URL="https://github.com/kyverno/chainsaw/releases/download/v${VERSION}/${TAR}"
51+
TMP="$(mktemp -d)"
52+
curl -fsSL -o "${TMP}/${TAR}" "${URL}"
53+
tar -xzf "${TMP}/${TAR}" -C "${TMP}"
54+
sudo mv "${TMP}/chainsaw" /usr/local/bin/chainsaw
55+
sudo chmod +x /usr/local/bin/chainsaw
56+
rm -rf "${TMP}"
57+
58+
- name: Generate SLSA predicate
59+
uses: ./.github/actions/generate-slsa-predicate
60+
with:
61+
workflow_file: qualification.yaml
62+
63+
- name: Build attested binary
64+
shell: bash
65+
env:
66+
GOFLAGS: -mod=vendor
67+
run: |
68+
set -euo pipefail
69+
goreleaser build --clean --single-target --snapshot --timeout 10m
70+
71+
- name: Locate binary and detect attestation
72+
id: binary
73+
shell: bash
74+
run: |
75+
set -euo pipefail
76+
# Find binary (linux/amd64 in CI)
77+
AICR_BIN=""
78+
for pattern in dist/aicr_linux_amd64_v1/aicr dist/aicr_linux_amd64/aicr; do
79+
if [ -x "$pattern" ]; then
80+
AICR_BIN="$(pwd)/$pattern"
81+
break
82+
fi
83+
done
84+
if [ -z "$AICR_BIN" ]; then
85+
echo "::error::Built binary not found in dist/"
86+
exit 1
87+
fi
88+
echo "AICR_BIN=$AICR_BIN" >> "$GITHUB_ENV"
89+
90+
# Detect attestation
91+
ATTEST_FILE="$(dirname "$AICR_BIN")/aicr-attestation.sigstore.json"
92+
if [ -f "$ATTEST_FILE" ]; then
93+
echo "AICR_ATTESTED=true" >> "$GITHUB_ENV"
94+
echo "Binary attestation found: $ATTEST_FILE"
95+
else
96+
echo "AICR_ATTESTED=false" >> "$GITHUB_ENV"
97+
echo "::warning::No binary attestation found (attestation tests will skip)"
98+
fi
99+
100+
- name: Run CLI chainsaw tests
101+
shell: bash
102+
run: |
103+
set -euo pipefail
104+
chainsaw test \
105+
--no-cluster \
106+
--config tests/chainsaw/chainsaw-config.yaml \
107+
--test-dir tests/chainsaw/cli/

.github/workflows/qualification.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,30 @@ jobs:
103103
go_version: ${{ steps.versions.outputs.go }}
104104
chainsaw_version: ${{ steps.versions.outputs.chainsaw }}
105105

106+
cli-e2e:
107+
name: CLI E2E
108+
runs-on: ubuntu-latest
109+
timeout-minutes: 10
110+
permissions:
111+
contents: read
112+
id-token: write
113+
steps:
114+
115+
- name: Checkout Code
116+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
117+
with:
118+
persist-credentials: false
119+
120+
- name: Load versions
121+
id: versions
122+
uses: ./.github/actions/load-versions
123+
124+
- name: Run CLI E2E Tests
125+
uses: ./.github/actions/cli-e2e
126+
with:
127+
go_version: ${{ steps.versions.outputs.go }}
128+
chainsaw_version: ${{ steps.versions.outputs.chainsaw }}
129+
106130
e2e:
107131
name: E2E
108132
runs-on: ubuntu-latest

DEVELOPMENT.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,17 @@ To produce attested binaries without a release tag, use the **Build Attested Bin
560560
workflow (`.github/workflows/build-attested.yaml`) from the Actions tab. It runs
561561
`goreleaser release --snapshot` with cosign and uploads tar.gz archives as artifacts.
562562

563+
#### Bundle Attestation
564+
565+
`aicr bundle` attests bundles by default using Sigstore keyless OIDC signing:
566+
567+
- **GitHub Actions**: Uses the ambient OIDC token automatically (requires `id-token: write`)
568+
- **Local**: Opens a browser for Sigstore OIDC authentication (GitHub, Google, or Microsoft)
569+
- **Skip**: Use `--skip-attestation` to disable signing for local development
570+
571+
Verify a bundle with `aicr verify <dir>`. Update the trusted root cache with
572+
`aicr trust update` (run automatically by the install script).
573+
563574
### Local Development
564575

565576
| Target | Description |

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ license: ## Add/verify license headers in source files
146146
@echo "Ensuring license headers..."
147147
@addlicense -f .github/headers/LICENSE $(LICENSE_IGNORES) .
148148

149+
license-check: ## Check license is approved
150+
@echo "Checking license headers..."
151+
go-licenses check ./... \
152+
--allowed_licenses=Apache-2.0,BSD-2-Clause,BSD-3-Clause,ISC,MIT,MPL-2.0
153+
149154
.PHONY: test
150155
test: ## Runs unit tests with race detector and coverage (use -short to skip integration tests)
151156
@set -e; \
@@ -188,7 +193,7 @@ scan: ## Scans for vulnerabilities with grype
188193
grype dir:. --config .grype.yaml --fail-on high --quiet
189194

190195
.PHONY: qualify
191-
qualify: test-coverage lint e2e scan ## Qualifies the codebase (test-coverage, lint, e2e, scan)
196+
qualify: test-coverage lint e2e scan license-check ## Qualifies the codebase (test-coverage, lint, e2e, scan)
192197
@echo "Codebase qualification completed"
193198

194199
.PHONY: server

SECURITY.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,67 @@ The install script (`./install`) performs this verification automatically when
169169
(`.github/workflows/build-attested.yaml`) can be triggered manually from the
170170
Actions tab to produce attested binaries from any branch without cutting a release.
171171

172+
### Bundle Attestation
173+
174+
When `aicr bundle` runs, it attests the bundle using Sigstore keyless OIDC signing.
175+
The attestation binds the bundle creator's identity to the bundle content (via
176+
`checksums.txt`) and the binary that produced it (via `resolvedDependencies`).
177+
178+
The bundle output includes:
179+
- `bundle-attestation.sigstore.json` — SLSA Build Provenance v1 for the bundle
180+
- `aicr-attestation.sigstore.json` — copy of the binary's attestation (provenance chain)
181+
182+
Use `--skip-attestation` to skip signing (e.g., for local development).
183+
184+
**Verify a bundle:**
185+
186+
```shell
187+
aicr verify ./my-bundle
188+
```
189+
190+
This verifies:
191+
1. Checksums — all content files match `checksums.txt`
192+
2. Bundle attestation — cryptographic signature verified against Sigstore trusted root
193+
3. Binary attestation — provenance chain verified with identity pinned to NVIDIA CI
194+
195+
**Trust levels:**
196+
197+
| Level | Name | Criteria |
198+
|-------|------|----------|
199+
| 4 | `verified` | Full chain verified, binary identity pinned to NVIDIA CI |
200+
| 3 | `attested` | Chain verified but binary attestation missing or external data used |
201+
| 2 | `unverified` | Checksums valid, `--skip-attestation` was used |
202+
| 1 | `unknown` | Missing checksums or attestation files |
203+
204+
**Enforce a minimum trust level:**
205+
206+
```shell
207+
aicr verify ./my-bundle --min-trust-level verified
208+
```
209+
210+
### Trusted Root Management
211+
212+
Bundle verification uses a Sigstore trusted root to validate attestation signatures
213+
offline. The trusted root contains CA certificates and Rekor public keys.
214+
215+
**Three layers of trust resolution (in priority order):**
216+
217+
1. **TUF cache** (`~/.sigstore/root/`) — updated by `aicr trust update`
218+
2. **Embedded TUF root** — compiled into the binary, used to bootstrap
219+
3. **TUF update**`aicr trust update` contacts the Sigstore TUF CDN
220+
221+
Verification never contacts the network — it uses the cache or embedded root.
222+
The install script runs `aicr trust update` automatically after installation.
223+
224+
**Update the trusted root:**
225+
226+
```shell
227+
aicr trust update
228+
```
229+
230+
Run this when Sigstore rotates their keys (a few times per year) or if
231+
verification reports a stale root.
232+
172233
### Setup
173234

174235
Export variables for the image you want to verify:

go.mod

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,13 @@ require (
109109
github.com/jmoiron/sqlx v1.4.0 // indirect
110110
github.com/json-iterator/go v1.1.12 // indirect
111111
github.com/klauspost/compress v1.18.4 // indirect
112-
github.com/klauspost/compress v1.18.1 // indirect
113112
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
114113
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
115114
github.com/lib/pq v1.11.2 // indirect
116115
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
117116
github.com/mattn/go-colorable v0.1.14 // indirect
118117
github.com/mattn/go-isatty v0.0.20 // indirect
119118
github.com/mattn/go-runewidth v0.0.20 // indirect
120-
github.com/mattn/go-colorable v0.1.14 // indirect
121-
github.com/mattn/go-isatty v0.0.20 // indirect
122-
github.com/mattn/go-runewidth v0.0.16 // indirect
123119
github.com/mitchellh/copystructure v1.2.0 // indirect
124120
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
125121
github.com/mitchellh/reflectwalk v1.0.2 // indirect

go.sum

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -341,8 +341,6 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
341341
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
342342
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
343343
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
344-
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
345-
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
346344
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
347345
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
348346
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -576,20 +574,12 @@ go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF
576574
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
577575
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
578576
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
579-
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
580-
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
581-
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
582-
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
583577
go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs=
584578
go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo=
585579
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
586580
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
587581
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
588582
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
589-
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
590-
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
591-
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
592-
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
593583
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
594584
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
595585
go.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA=

install

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,17 @@ main() {
263263
"${BIN_NAME}" --version
264264
[[ -f "${INSTALL_DIR}/${BIN_NAME}-attestation.sigstore.json" ]] && \
265265
msg "Attestation: ${INSTALL_DIR}/${BIN_NAME}-attestation.sigstore.json"
266+
267+
# Fetch latest Sigstore trusted root for offline verification.
268+
# The trusted root enables 'aicr verify' to check attestation signatures
269+
# without contacting Sigstore infrastructure. If this fails (e.g., no network),
270+
# verification still works using the embedded TUF root compiled into the binary.
271+
msg "Updating Sigstore trusted root..."
272+
if ! "${BIN_NAME}" trust update 2>/dev/null; then
273+
msg "Warning: could not fetch latest trusted root (network may be unavailable)"
274+
msg " Verification will use the embedded root. To update later, run:"
275+
msg " ${BIN_NAME} trust update"
276+
fi
266277
}
267278

268279
# Run main function

pkg/bundler/attestation/binary.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package attestation
16+
17+
import (
18+
"crypto/sha256"
19+
"encoding/hex"
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
24+
"github.com/NVIDIA/aicr/pkg/errors"
25+
)
26+
27+
// AttestationFileSuffix is the conventional suffix for attestation files.
28+
const AttestationFileSuffix = "-attestation.sigstore.json"
29+
30+
// BundleAttestationFile is the filename for the bundle attestation in the output directory.
31+
const BundleAttestationFile = "bundle-attestation.sigstore.json"
32+
33+
// BinaryAttestationFile is the filename for the binary attestation copied into the bundle.
34+
const BinaryAttestationFile = "aicr-attestation.sigstore.json"
35+
36+
// FindBinaryAttestation locates the attestation file for a binary at the
37+
// conventional path: <binary-path>-attestation.sigstore.json.
38+
// Returns the attestation file path.
39+
func FindBinaryAttestation(binaryPath string) (string, error) {
40+
// Convention: attestation file is named <binary-name>-attestation.sigstore.json
41+
// in the same directory as the binary.
42+
dir := filepath.Dir(binaryPath)
43+
base := filepath.Base(binaryPath)
44+
attestPath := filepath.Join(dir, base+AttestationFileSuffix)
45+
46+
if _, err := os.Stat(attestPath); err != nil {
47+
if os.IsNotExist(err) {
48+
return "", errors.New(errors.ErrCodeNotFound,
49+
fmt.Sprintf("binary attestation not found: %s", attestPath))
50+
}
51+
return "", errors.Wrap(errors.ErrCodeInternal,
52+
fmt.Sprintf("cannot access binary attestation: %s", attestPath), err)
53+
}
54+
55+
return attestPath, nil
56+
}
57+
58+
// ComputeFileDigest reads a file and returns its SHA256 hex digest.
59+
func ComputeFileDigest(path string) (string, error) {
60+
data, err := os.ReadFile(path)
61+
if err != nil {
62+
return "", errors.Wrap(errors.ErrCodeInternal,
63+
fmt.Sprintf("failed to read file for digest: %s", path), err)
64+
}
65+
66+
hash := sha256.Sum256(data)
67+
return hex.EncodeToString(hash[:]), nil
68+
}

0 commit comments

Comments
 (0)