Skip to content

Commit 15cd986

Browse files
authored
Merge branch 'main' into refactor/unify-topo-sort
2 parents a2021b7 + 4fe9dc8 commit 15cd986

13 files changed

Lines changed: 261 additions & 36 deletions

File tree

.github/workflows/rekor-monitor.yaml

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,39 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
# Transparency-log monitoring for AICR's release supply chain.
15+
# Transparency-log monitoring for AICR's release supply chain, on Rekor v2.
1616
#
17-
# Hourly, this calls the upstream sigstore/rekor-monitor reusable workflow to:
18-
# - Consistency: verify the public-good Rekor log stays append-only between
19-
# runs (checkpoint persisted as the `checkpoint` artifact).
20-
# - Identity: scan entries added since the last run for AICR's release signing
21-
# identity. An entry under that identity that a release did not produce
22-
# signals OIDC/key compromise.
17+
# Hourly, this calls the upstream sigstore/rekor-monitor reusable workflow to do
18+
# both checks in a single job (see internal/cmd MonitorLoop upstream):
19+
# - Consistency: prove the Rekor v2 log stays append-only between runs (Merkle
20+
# consistency from the last checkpoint to the current tree head). O(log n),
21+
# always finishes in seconds. Checkpoint persisted as the `checkpoint`
22+
# artifact.
23+
# - Identity: scan entries added since the last checkpoint for AICR's release
24+
# signing identity. An entry under that identity that a release did not
25+
# produce signals OIDC/key compromise.
2326
# On any failure (consistency break or matched identity), it files an issue
2427
# (`file_issue: true`).
2528
#
29+
# Why v2 (NVIDIA/aicr#1623). Identity monitoring is a linear scan of every entry
30+
# added since the last checkpoint (Rekor's index cannot be queried by
31+
# certificate SAN, and our keyless release identity has no email or fixed key).
32+
# On the Rekor **v1** firehose that scan runs ~50x slower than the log grows, so
33+
# it can never keep up in a bounded CI job: the earlier v1 config timed out
34+
# every run and never completed a single scan. Rekor **v2** is tile-based: bulk
35+
# 256-entry reads make a single-worker scan outpace the log, so identity
36+
# monitoring becomes one cheap job. This rides on release signing having moved to
37+
# v2 in NVIDIA/aicr#1650 (only entries actually in v2 can be watched there).
38+
#
39+
# Selecting v2. The monitor picks its Rekor API version by matching its `url`
40+
# against the services in the TUF-distributed Sigstore SigningConfig; a match on
41+
# a v2 service switches it to v2. The resolve-v2-shard job computes that URL at
42+
# run time from the same signing config release signing uses (via `aicr trust
43+
# update --emit-signing-config`), so we never hardcode a shard and yearly shard
44+
# rotation (log2025-1 -> log2026-1 -> ...) needs no change here. Once v2 is
45+
# selected the *full* shard set is auto-discovered from the SigningConfig and
46+
# refreshed each run.
47+
#
2648
# The monitored identity is AICR's release signer (see .goreleaser.yaml and
2749
# .github/workflows/on-tag.yaml): the GitHub Actions OIDC SAN for on-tag.yaml,
2850
# issued by token.actions.githubusercontent.com.
@@ -52,8 +74,38 @@ concurrency:
5274
cancel-in-progress: false
5375

5476
jobs:
77+
# Resolve the current Rekor v2 shard from the Sigstore signing config, so the
78+
# monitor always selects v2 against a live shard without a hardcoded URL. We
79+
# read the same TUF-distributed signing config that release signing resolves
80+
# (`aicr trust update --emit-signing-config`, see pkg/trust), so the monitor
81+
# provably watches where releases actually write, and yearly shard rotation
82+
# (log2025-1 -> log2026-1 -> ...) needs no change here.
83+
resolve-v2-shard:
84+
name: Resolve current Rekor v2 shard
85+
runs-on: ubuntu-latest
86+
permissions:
87+
contents: read # checkout to build the aicr CLI
88+
outputs:
89+
url: ${{ steps.resolve.outputs.url }}
90+
steps:
91+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
92+
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
93+
with:
94+
go-version-file: go.mod
95+
- id: resolve
96+
name: Resolve v2 shard URL from the signing config
97+
run: |
98+
set -euo pipefail
99+
go run ./cmd/aicr trust update --emit-signing-config signing-config.json
100+
# Newest currently-listed v2 shard (majorApiVersion 2). Any live v2
101+
# shard selects v2; the monitor then discovers the full set from TUF.
102+
url="$(jq -er '[.rekorTlogUrls[] | select(.majorApiVersion == 2)] | sort_by(.validFor.start) | last | .url' signing-config.json)"
103+
echo "Resolved Rekor v2 shard: $url"
104+
echo "url=$url" >> "$GITHUB_OUTPUT"
105+
55106
monitor:
56-
name: AICR release identity + log consistency
107+
name: AICR release identity + log consistency (Rekor v2)
108+
needs: resolve-v2-shard
57109
permissions:
58110
contents: read # checkout performed by the reusable workflow
59111
issues: write # file an issue on monitoring failure
@@ -64,6 +116,10 @@ jobs:
64116
# Must exceed the hourly cron so the consistency checkpoint never expires
65117
# between runs; wide enough to survive a brief scheduling pause.
66118
artifact_retention_days: 30
119+
# Rekor v2 shard resolved at run time from the signing config (see the
120+
# resolve-v2-shard job). Selecting a v2 service switches the monitor to v2;
121+
# it then auto-discovers the full shard set from TUF.
122+
url: ${{ needs.resolve-v2-shard.outputs.url }}
67123
config: |
68124
monitoredValues:
69125
certIdentities:

.github/workflows/renovate.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ jobs:
105105
# Keep this digest in lockstep with RENOVATE_VALIDATOR_IMAGE in
106106
# the Makefile.
107107
- name: Run Renovate
108-
uses: renovatebot/github-action@b50d2ba2bd928235abdcc14d06dfafc217f1c565 # v46.1.18
108+
uses: renovatebot/github-action@22e0a16091fc706b04affe6ae53d5e3358ac4023 # v46.1.19
109109
with:
110110
renovate-version: '43@sha256:00185c0d63462acec8331cc9a94dcd74a763f2765fca0edcc3ff568af1dc8104'
111111
env:

.openvex.json

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"@id": "https://github.com/NVIDIA/aicr/.openvex.json",
44
"author": "NVIDIA AICR maintainers",
55
"role": "document creator",
6-
"timestamp": "2026-07-09T00:00:00Z",
7-
"version": 6,
6+
"timestamp": "2026-07-13T00:00:00Z",
7+
"version": 7,
88
"tooling": "manual; aiperf-bench statements verified against aiperf v0.7.0 source; aicr statement reachability verified by source inspection (CGO-free ko build, no libssl linkage); aicr-gate statements suppress CVEs in the embedded upstream kyverno/chainsaw binary (affected packages identified via vuln.go.dev), justified by chainsaw's ephemeral cluster-internal readiness-gate usage",
99
"statements": [
1010
{
@@ -306,6 +306,29 @@
306306
"justification": "vulnerable_code_not_in_execute_path",
307307
"impact_statement": "The attack requires parsing a crafted (attacker-controlled) XML document through xml.parsers.expat or xml.etree.ElementTree to trigger hash flooding. AICR invokes aiperf-bench exclusively as `aiperf profile <text-LLM> --url <endpoint>` (validators/performance/inference_perf_constraint.go). That subcommand communicates with the inference endpoint via JSON over HTTP only; all data transport uses aiohttp + orjson/msgspec. aiperf v0.7.0's declared dependencies contain no XML parsing libraries \u2014 the runtime stack (aiohttp, msgspec, orjson, numpy, transformers, uvicorn, fastapi) is entirely JSON/binary-based. While xml.parsers.expat is present in the Python 3.13 stdlib image, no attacker-controlled XML document can enter the aiperf profile execution path, making the hash-flooding trigger unreachable."
308308
},
309+
{
310+
"vulnerability": {
311+
"name": "CVE-2026-15308",
312+
"description": "cpython CPU-exhaustion DoS in html.parser.HTMLParser incremental feed() via repeated unterminated markup declarations (PSF-2026-33, CWE-407, High CVSS 7.5/8.7). Fix merged to the cpython 3.13 branch on 2026-07-04 (PR #153040) but as of 2026-07-13 no released CPython contains it — v3.13.14 (2026-06-10), v3.14.6, and v3.15.0b3 all predate the fix, so no base-image bump can remediate yet."
313+
},
314+
"products": [
315+
{
316+
"@id": "pkg:oci/aicr-aiperf-bench",
317+
"identifiers": {
318+
"purl": "pkg:oci/aicr-aiperf-bench"
319+
}
320+
},
321+
{
322+
"@id": "pkg:oci/aiperf-bench",
323+
"identifiers": {
324+
"purl": "pkg:oci/aiperf-bench"
325+
}
326+
}
327+
],
328+
"status": "not_affected",
329+
"justification": "vulnerable_code_not_in_execute_path",
330+
"impact_statement": "The trigger requires incrementally feeding attacker-controlled HTML containing repeated unterminated markup declarations into html.parser.HTMLParser.feed(). aiperf v0.7.0 source contains zero references to html.parser, HTMLParser, or any html stdlib import (verified with `grep -rn -E 'html\\.parser|HTMLParser|^(import|from) html'` against the PyPI sdist — no hits). AICR invokes aiperf-bench exclusively as `aiperf profile <text-LLM> --url <endpoint>` (validators/performance/inference_perf_constraint.go); the workload exchanges only JSON over HTTP via aiohttp + msgspec/orjson and never parses HTML documents from the inference endpoint or any other source. No attacker-controlled markup can reach the vulnerable parser, so the quadratic-complexity code path is unreachable."
331+
},
309332
{
310333
"vulnerability": {
311334
"name": "CVE-2026-45447",

.settings.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ testing_tools:
6464
# renovate: datasource=github-releases depName=tilt-dev/tilt depType=testing_tools
6565
tilt: '0.37.5'
6666
# renovate: datasource=github-releases depName=helm/helm depType=testing_tools
67-
helm: 'v4.2.2'
67+
helm: 'v4.2.3'
6868
# renovate: datasource=github-releases depName=databus23/helm-diff depType=testing_tools
6969
helm_diff: 'v3.15.10'
7070
# renovate: datasource=github-releases depName=helmfile/helmfile depType=testing_tools
@@ -91,14 +91,14 @@ testing_tools:
9191
yq: 'v4.53.3'
9292
# AWS CLI used by the KMS e2e to provision keys against the MiniStack emulator.
9393
# renovate: datasource=pypi depName=awscli depType=testing_tools
94-
awscli: '1.45.41'
94+
awscli: '1.45.45'
9595
# MiniStack: MIT-licensed, token-free AWS emulator (https://ministack.org) used
9696
# by the KMS e2e in place of LocalStack, whose latest/stable images now refuse
9797
# to boot without a paid license token. Pinned (never :latest) for reproducibility.
9898
# Run with USE_SSL=1: sigstore's awskms signer hardcodes https://, so the e2e
9999
# serves TLS with a mkcert cert (see mkcert below).
100100
# renovate: datasource=docker depName=ministackorg/ministack depType=testing_tools
101-
ministack_image: 'ministackorg/ministack:1.3.72'
101+
ministack_image: 'ministackorg/ministack:1.4.1'
102102
# mkcert issues a trusted localhost cert for MiniStack's TLS and installs its CA
103103
# into the system trust store, so the Go AWS SDK accepts the awskms:// endpoint.
104104
# renovate: datasource=github-releases depName=FiloSottile/mkcert depType=testing_tools
@@ -127,7 +127,7 @@ testing_tools:
127127
# kustomize-controller and helm-controller are the only controllers the
128128
# bundle consumes (OCIRepository -> Kustomization -> HelmRelease).
129129
# renovate: datasource=github-releases depName=fluxcd/flux2 depType=testing_tools
130-
flux_version: 'v2.9.0'
130+
flux_version: 'v2.9.1'
131131
# In-cluster Git server for the KWOK flux-git deployer lane (issue #963).
132132
# Rootless variant so the admin-user bootstrap can `kubectl exec gitea
133133
# admin user create` directly without su gymnastics.
@@ -136,7 +136,7 @@ testing_tools:
136136
# renovate: datasource=github-releases depName=hauler-dev/hauler depType=testing_tools
137137
hauler: 'v2.0.1'
138138
# renovate: datasource=github-releases depName=zarf-dev/zarf depType=testing_tools
139-
zarf: 'v0.80.0'
139+
zarf: 'v0.81.0'
140140
# Quality Thresholds
141141
quality:
142142
coverage_threshold: '75'

docs/contributor/maintaining.md

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,71 @@ failure during the first 60s after tag publish. Re-run the workflow.
5353

5454
The `Rekor Monitor` workflow (`.github/workflows/rekor-monitor.yaml`) runs
5555
hourly and calls the upstream `sigstore/rekor-monitor` reusable workflow. It
56-
watches the public-good Rekor transparency log for two things: that the log
57-
stays append-only (consistency), and that no entry appears under AICR's release
58-
signing identity that a release did not produce (identity). On either failure it
59-
opens an issue.
56+
watches the **Rekor v2** transparency log (where AICR release signing writes
57+
since [#1650](https://github.com/NVIDIA/aicr/issues/1650)) for two things, both
58+
in one job: that the log stays append-only (consistency), and that no entry
59+
appears under AICR's release signing identity that a release did not produce
60+
(identity). On either failure it opens an issue.
6061

6162
This protects the trust root every AICR consumer depends on: the release
6263
binaries, the signed recipe catalog, and the container images all chain to that
6364
one identity. When the workflow files an issue, follow the triage steps in the
6465
workflow file's header comment; an unrecognized identity hit should be treated
6566
as potential OIDC/key compromise.
6667

68+
### Why v2, and why identity monitoring is feasible now
69+
70+
Identity monitoring is a linear scan of every entry added to the log since the
71+
last checkpoint, because Rekor's index cannot be queried by certificate SAN and
72+
AICR's keyless release identity has no email or fixed public key to search on.
73+
On the Rekor **v1** firehose that scan runs roughly 50x slower than the log
74+
grows, so it can never keep up inside a bounded CI job: the earlier v1
75+
identity config timed out on every run and never completed a single scan
76+
([#1623](https://github.com/NVIDIA/aicr/issues/1623)). Rekor **v2** is
77+
tile-based: bulk 256-entry reads let a single worker outpace the log, so the
78+
identity scan is a cheap job that always finishes. This is why the whole design
79+
is a single unbounded scan again rather than sharded paging.
80+
81+
### Shard selection (automatic, no manual re-tune)
82+
83+
The monitor selects Rekor v2 by pointing its `url` at a v2 shard listed in the
84+
Sigstore `SigningConfig`; a match on a v2 service switches it to v2, after which
85+
it auto-discovers the **full** shard set from TUF and refreshes it every run.
86+
87+
The shard URL is **not hardcoded**. The `resolve-v2-shard` job computes it at run
88+
time from the same TUF-distributed signing config that release signing resolves,
89+
then feeds it to the monitor:
90+
91+
```bash
92+
# Same command the resolve-v2-shard job runs (works from a fresh checkout).
93+
go run ./cmd/aicr trust update --emit-signing-config signing-config.json
94+
jq -er '[.rekorTlogUrls[] | select(.majorApiVersion == 2)] | sort_by(.validFor.start) | last | .url' signing-config.json
95+
```
96+
97+
Because it reads the signing config directly, the monitor provably watches where
98+
releases actually write, and yearly shard rotation (`log2025-1` -> `log2026-1`
99+
-> ...) needs no change to this workflow. If the resolve job ever fails (for
100+
example the TUF CDN is unreachable), the monitor job is skipped for that run and
101+
retries on the next hourly tick.
102+
103+
### First run after switching from v1: reset the checkpoint
104+
105+
The `checkpoint` artifact persists a **v1** checkpoint from the prior config; a
106+
v2 run cannot parse it and will fail. After merging a change that moves this
107+
workflow to v2, delete the stale artifact once so the first v2 run establishes a
108+
fresh v2 baseline (it saves the current v2 tree head and scans forward from
109+
there):
110+
111+
```bash
112+
gh api "repos/NVIDIA/aicr/actions/artifacts?name=checkpoint" \
113+
--jq '.artifacts[].id' \
114+
| xargs -I{} gh api -X DELETE "repos/NVIDIA/aicr/actions/artifacts/{}"
115+
```
116+
117+
The first v2 run then watches forward from the current head; historical entries
118+
predating the baseline are covered by release-time verification (the `aicr
119+
verify` path), not by this monitor.
120+
67121
## Reviewing Recipe Contributions
68122

69123
A recipe PR touches `recipes/overlays/`, `recipes/mixins/`,

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ require (
3535
k8s.io/apimachinery v0.36.2
3636
k8s.io/client-go v0.36.2
3737
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
38-
oras.land/oras-go/v2 v2.6.1
38+
oras.land/oras-go/v2 v2.6.2
3939
sigs.k8s.io/controller-runtime v0.24.1
4040
sigs.k8s.io/kustomize/api v0.21.1
4141
sigs.k8s.io/kustomize/kyaml v0.21.1

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -603,8 +603,8 @@ k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98=
603603
k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
604604
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
605605
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
606-
oras.land/oras-go/v2 v2.6.1 h1:bonOEkjLfp8tt6qXWRRWP6p1F+9octchOf2EqnWB4Zs=
607-
oras.land/oras-go/v2 v2.6.1/go.mod h1:dhtFrFOuZuDtAVeZ9FUnaa5zfzplG3ZnFX9/uH1J/Yk=
606+
oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo=
607+
oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4=
608608
sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
609609
sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
610610
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=

vendor/modules.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1694,7 +1694,7 @@ k8s.io/utils/internal/third_party/forked/golang/net
16941694
k8s.io/utils/net
16951695
k8s.io/utils/ptr
16961696
k8s.io/utils/trace
1697-
# oras.land/oras-go/v2 v2.6.1
1697+
# oras.land/oras-go/v2 v2.6.2
16981698
## explicit; go 1.25.0
16991699
oras.land/oras-go/v2
17001700
oras.land/oras-go/v2/content

vendor/oras.land/oras-go/v2/content/file/utils.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,15 @@ func extractTarDirectory(dirPath, dirName string, r io.Reader, buf []byte, prese
175175
}
176176
filePath := filepath.Join(dirPath, filePathRel)
177177

178+
// resolveRelToBase only performs lexical and per-component Lstat checks,
179+
// which a chain of previously-extracted symlinks can bypass. Re-verify
180+
// containment with symlinks fully resolved before mutating the
181+
// filesystem, matching the check on the pushFile path.
182+
// (GHSA-m37j-52j7-pjw7)
183+
if err := checkSymlinkEscape(dirPath, filePath); err != nil {
184+
return err
185+
}
186+
178187
// Create content
179188
switch header.Typeflag {
180189
case tar.TypeReg:
@@ -188,6 +197,11 @@ func extractTarDirectory(dirPath, dirName string, r io.Reader, buf []byte, prese
188197
// This is a known limitation and will not be addressed.
189198
var target string
190199
if target, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname); err == nil {
200+
if !filepath.IsAbs(target) {
201+
// link(2) resolves relative paths against the process CWD, not
202+
// the link file's directory. Resolve explicitly to prevent escape.
203+
target = filepath.Join(filepath.Dir(filePath), target)
204+
}
191205
err = os.Link(target, filePath)
192206
}
193207
case tar.TypeSymlink:
@@ -276,6 +290,16 @@ func ensureLinkPath(baseAbs, baseRel, link, target string) (string, error) {
276290

277291
// writeFile writes content to the file specified by the `path` parameter.
278292
func writeFile(path string, r io.Reader, perm os.FileMode, buf []byte) (err error) {
293+
// os.OpenFile follows a terminal symlink, so a regular-file entry whose
294+
// path was already created as a symlink by an earlier archive entry would
295+
// be written through that link, landing outside the extraction root
296+
// (GHSA-m37j-52j7-pjw7). Remove any such symlink first so the content is
297+
// written to a regular file at path itself.
298+
if fi, err := os.Lstat(path); err == nil && fi.Mode()&os.ModeSymlink != 0 {
299+
if err := os.Remove(path); err != nil {
300+
return err
301+
}
302+
}
279303
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
280304
if err != nil {
281305
return err

0 commit comments

Comments
 (0)