Skip to content

Commit a32ece7

Browse files
committed
feat(attestation): bundle attestation and verification with security hardening
Add pkg/bundler/attestation (KeylessAttester, NoOpAttester, SLSA statement builder, ambient + browser OIDC), pkg/bundler/verifier (checksum validation, Sigstore verification, identity pinning, trust levels), and pkg/trust (TUF trusted root management for offline verification). Wire attestation into bundler.Make(), add CLI commands (aicr verify, aicr trust update, --skip-attestation, --certificate-identity-regexp), and add cli-e2e action for chainsaw tests with attested binary in CI. Security hardening from review feedback: - Require identity on all attestation verification - Path traversal protection, size limits, context timeouts, nil safety - Validate identity patterns against domain spoofing - Implement --min-trust-level max resolution to highest achievable level
1 parent 5bafb8d commit a32ece7

Some content is hidden

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

58 files changed

+4751
-112
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/build-attested.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
build-and-attest:
4141
name: Build and Attest Binaries
4242
runs-on: ubuntu-latest
43-
timeout-minutes: 15
43+
timeout-minutes: 25
4444
steps:
4545
- name: Checkout Code
4646
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -76,7 +76,7 @@ jobs:
7676
GOFLAGS: -mod=vendor
7777
run: |
7878
set -euo pipefail
79-
goreleaser release --snapshot --clean --skip=publish,ko,sbom --timeout 10m
79+
goreleaser release --snapshot --clean --skip=publish,ko,sbom --timeout 20m --single-target
8080
8181
- name: Verify archive contents
8282
run: |

.github/workflows/on-push.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
permissions:
4848
actions: read
4949
contents: read
50+
id-token: write
5051
security-events: write
5152
with:
5253
coverage_report: true

.github/workflows/on-tag.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040
permissions:
4141
actions: read
4242
contents: read
43+
id-token: write
4344
security-events: write
4445

4546
# =============================================================================

.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
@@ -149,6 +149,11 @@ license: ## Add/verify license headers in source files
149149
@echo "Ensuring license headers..."
150150
@addlicense -f .github/headers/LICENSE $(LICENSE_IGNORES) .
151151

152+
license-check: ## Check license is approved
153+
@echo "Checking license headers..."
154+
go-licenses check ./... \
155+
--allowed_licenses=Apache-2.0,BSD-2-Clause,BSD-3-Clause,ISC,MIT,MPL-2.0
156+
152157
.PHONY: test
153158
test: ## Runs unit tests with race detector and coverage (use -short to skip integration tests)
154159
@set -e; \
@@ -191,7 +196,7 @@ scan: ## Scans for vulnerabilities with grype
191196
grype dir:. --config .grype.yaml --fail-on high --quiet
192197

193198
.PHONY: qualify
194-
qualify: test-coverage lint e2e scan ## Qualifies the codebase (test-coverage, lint, e2e, scan)
199+
qualify: test-coverage lint e2e scan license-check ## Qualifies the codebase (test-coverage, lint, e2e, scan)
195200
@echo "Codebase qualification completed"
196201

197202
.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:

install

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -287,13 +287,21 @@ main() {
287287
sudo mv "${temp_dir}/${BIN_NAME}-attestation.sigstore.json" "${INSTALL_DIR}/${BIN_NAME}-attestation.sigstore.json"
288288
fi
289289

290-
# Summary
291-
msg ""
292-
ok "$BIN_NAME installed successfully!"
293-
msg " $("${BIN_NAME}" --version)"
294-
if [[ -f "${INSTALL_DIR}/${BIN_NAME}-attestation.sigstore.json" ]]; then
295-
ok "Attestation bundle saved for audit/compliance"
296-
info "${INSTALL_DIR}/${BIN_NAME}-attestation.sigstore.json"
290+
# Verify installation
291+
msg "$BIN_NAME installed successfully!"
292+
"${BIN_NAME}" --version
293+
[[ -f "${INSTALL_DIR}/${BIN_NAME}-attestation.sigstore.json" ]] && \
294+
msg "Attestation: ${INSTALL_DIR}/${BIN_NAME}-attestation.sigstore.json"
295+
296+
# Fetch latest Sigstore trusted root for offline verification.
297+
# The trusted root enables 'aicr verify' to check attestation signatures
298+
# without contacting Sigstore infrastructure. If this fails (e.g., no network),
299+
# verification still works using the embedded TUF root compiled into the binary.
300+
msg "Updating Sigstore trusted root..."
301+
if ! "${BIN_NAME}" trust update 2>/dev/null; then
302+
msg "Warning: could not fetch latest trusted root (network may be unavailable)"
303+
msg " Verification will use the embedded root. To update later, run:"
304+
msg " ${BIN_NAME} trust update"
297305
fi
298306
}
299307

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 "context"
18+
19+
// Attester signs bundle content and returns a Sigstore bundle.
20+
type Attester interface {
21+
// Attest creates a DSSE-signed in-toto SLSA provenance statement for the
22+
// given subject, returning a serialized Sigstore bundle (.sigstore.json).
23+
// Returns nil bytes when attestation is not performed (e.g., NoOpAttester).
24+
Attest(ctx context.Context, subject AttestSubject) ([]byte, error)
25+
26+
// Identity returns the attester's identity as it appears in the signing
27+
// certificate or key reference (e.g., OIDC email, KMS key URI).
28+
// Returns empty string when no identity is available.
29+
Identity() string
30+
31+
// HasRekorEntry reports whether produced attestations include a Rekor
32+
// transparency log inclusion proof.
33+
HasRekorEntry() bool
34+
}
35+
36+
// AttestSubject describes what is being attested.
37+
type AttestSubject struct {
38+
// Name is the artifact name (e.g., "checksums.txt").
39+
Name string
40+
41+
// Digest maps algorithm to hex-encoded digest (e.g., {"sha256": "abc123..."}).
42+
Digest map[string]string
43+
44+
// ResolvedDependencies records build inputs in SLSA resolvedDependencies format.
45+
ResolvedDependencies []Dependency
46+
47+
// Metadata provides build context for the SLSA predicate.
48+
Metadata StatementMetadata
49+
}
50+
51+
// Dependency records an input artifact in SLSA resolvedDependencies.
52+
type Dependency struct {
53+
// URI identifies the dependency (e.g., GitHub release URL or file:// URI).
54+
URI string
55+
56+
// Digest maps algorithm to hex-encoded digest.
57+
Digest map[string]string
58+
}

0 commit comments

Comments
 (0)