From 150f47e70cd93cd8550478436a167ba0b2a07717 Mon Sep 17 00:00:00 2001 From: mmou Date: Sat, 24 Oct 2026 14:03:47 +0800 Subject: [PATCH 1/2] fix(containers): preserve complete SPDX evidence within signing limits --- .github/actions/docker-build/README.md | 8 + .github/actions/docker-build/action.yml | 1 + .../docker-build/scripts/compact-spdx.jq | 51 +++++++ .../docker-build/scripts/limit-spdx.sh | 31 ++++ .../actions/docker-build/scripts/sbom-oci.sh | 4 + .github/scripts/prepare-image-attestation.sh | 2 + .../scripts/tests/image-attestation.test.cjs | 139 +++++++++++++++++- cds-containers/CHANGELOG.md | 7 + cds-containers/README.md | 20 ++- cds-containers/VERSION | 2 +- cds-containers/tests/test_security_runtime.py | 2 +- 11 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 .github/actions/docker-build/scripts/compact-spdx.jq create mode 100644 .github/actions/docker-build/scripts/limit-spdx.sh diff --git a/.github/actions/docker-build/README.md b/.github/actions/docker-build/README.md index 9a018d7..b6f9d7d 100644 --- a/.github/actions/docker-build/README.md +++ b/.github/actions/docker-build/README.md @@ -181,6 +181,14 @@ Callers remain responsible for release-event restrictions and concurrency orderi of mutable tags such as `latest`. Signing does not imply vulnerability scanning or application/runtime testing; those gates must be explicitly enabled or supplied. +GitHub's signing action limits each SPDX predicate to 16 MiB. When preparing +attestations, oversized reports get shorter document-local file IDs and matching +references in a distinct document namespace. No packages, files, checksums, +licenses, or relationships are removed. The evidence artifact also retains +`*.spdx.original.json` and `*.spdx-id-map.json` for comparison and reversal. +Smaller reports stay byte-for-byte unchanged. A report that still exceeds the +limit fails before candidate publication; scan-only callers are unchanged. + The signed path requires GitHub artifact-attestation access for the caller repo, registry support for the OCI attestations written by `actions/attest`, and a Linux runner with Docker, Bash, jq, and a `gh` CLI supporting `--bundle-from-oci`. diff --git a/.github/actions/docker-build/action.yml b/.github/actions/docker-build/action.yml index ff1e2df..f78f610 100644 --- a/.github/actions/docker-build/action.yml +++ b/.github/actions/docker-build/action.yml @@ -241,6 +241,7 @@ runs: EXPECTED_DIGEST: ${{ steps.build.outputs.digest }} PLATFORMS: ${{ inputs.platforms }} REPORTS: ${{ steps.scan_vars.outputs.reports }} + PREPARE_ATTESTATION: ${{ inputs.prepare-attestation }} # Tool image pins live here, alongside the action version pins. SYFT_IMAGE: anchore/syft:v1.52.0@sha256:500e2d872ac019436926e8322b4fc1f39441d94d21f6f4046c6ff29b30e8cb02 run: | diff --git a/.github/actions/docker-build/scripts/compact-spdx.jq b/.github/actions/docker-build/scripts/compact-spdx.jq new file mode 100644 index 0000000..815c3fd --- /dev/null +++ b/.github/actions/docker-build/scripts/compact-spdx.jq @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Only document-local file identifiers change, never file or package metadata. +def reference($ids): if type == "string" then $ids[.] // . else . end; +def references($ids): map(reference($ids)); + +if .spdxVersion != "SPDX-2.3" or .SPDXID != "SPDXRef-DOCUMENT" then + error("Expected an SPDX 2.3 document") +else . end +| . as $original +| [.files[]?.SPDXID] as $files +| [.SPDXID, .packages[]?.SPDXID, .snippets[]?.SPDXID] as $other_ids +| if ($files | length) != ($files | unique | length) then + error("Duplicate file SPDX identifiers") + else . end +| if any($files[]; . as $id | $other_ids | index($id)) then + error("File identifier duplicates another SPDX element") + else . end +| ($files | to_entries | map({key: .value, value: ("SPDXRef-f" + (.key | tostring))}) | from_entries) as $ids +| if any($ids[]; . as $id | $other_ids | index($id)) then + error("Compact file identifier collides with another SPDX element") + else . end +| if has("files") then .files |= map( + .SPDXID |= reference($ids) + | if has("fileDependencies") then .fileDependencies |= references($ids) else . end + ) else . end +| if has("packages") then .packages |= map( + if has("hasFiles") then .hasFiles |= references($ids) else . end + ) else . end +| if has("documentDescribes") then .documentDescribes |= references($ids) else . end +| if has("relationships") then .relationships |= map( + .spdxElementId |= reference($ids) + | .relatedSpdxElement |= reference($ids) + ) else . end +| if has("snippets") then .snippets |= map( + .snippetFromFile |= reference($ids) + | .ranges |= map( + .startPointer.reference |= reference($ids) + | .endPointer.reference |= reference($ids) + ) + ) else . end +| .documentNamespace += "/dsx-file-ids-v1" +| { + document: ., + idMap: { + originalNamespace: $original.documentNamespace, + compactNamespace: .documentNamespace, + fileIds: ($ids | to_entries | map({key: .value, value: .key}) | from_entries) + } + } diff --git a/.github/actions/docker-build/scripts/limit-spdx.sh b/.github/actions/docker-build/scripts/limit-spdx.sh new file mode 100644 index 0000000..a3f1ff3 --- /dev/null +++ b/.github/actions/docker-build/scripts/limit-spdx.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +fail() { printf '::error::%s\n' "$*" >&2; exit 1; } +report="${1:?Expected an SPDX report path}" +[[ "$report" == *.spdx.json && -s "$report" ]] || fail 'Expected a nonempty .spdx.json report' +# actions/attest limits the complete predicate, including when registry-only. +maximum=16777216 +bytes="$(wc -c < "$report")" +(( bytes > maximum )) || exit 0 + +original="${report%.spdx.json}.spdx.original.json" +mapping="${report%.spdx.json}.spdx-id-map.json" +[[ ! -e "$original" && ! -e "$mapping" ]] || fail 'Refusing to replace existing SPDX source evidence' +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +temporary="$(mktemp -d "$(dirname -- "$report")/.spdx-compact.XXXXXX")" +trap 'rm -rf "$temporary"' EXIT + +jq -ce -f "$script_dir/compact-spdx.jq" "$report" > "$temporary/result.json" +jq -c '.document' "$temporary/result.json" > "$temporary/report.json" +compact_bytes="$(wc -c < "$temporary/report.json")" +(( compact_bytes <= maximum )) || fail 'Complete SPDX report still exceeds the 16 MiB signing limit; no evidence was removed' +jq -c '.idMap' "$temporary/result.json" > "$temporary/id-map.json" + +# Keep the exact Syft document and inverse map in the same evidence artifact. +cp -- "$report" "$original" +mv -- "$temporary/id-map.json" "$mapping" +mv -- "$temporary/report.json" "$report" +printf 'SPDX file identifiers compacted: %s -> %s bytes; original evidence retained\n' "$bytes" "$compact_bytes" diff --git a/.github/actions/docker-build/scripts/sbom-oci.sh b/.github/actions/docker-build/scripts/sbom-oci.sh index 3483b4a..5919936 100644 --- a/.github/actions/docker-build/scripts/sbom-oci.sh +++ b/.github/actions/docker-build/scripts/sbom-oci.sh @@ -47,6 +47,10 @@ while IFS=$'\t' read -r platform digest; do "$REPORTS/$key.syft.json" >/dev/null || fail "Syft scanned the wrong manifest for $platform" jq -e '.spdxVersion and .SPDXID == "SPDXRef-DOCUMENT"' \ "$REPORTS/$key.spdx.json" >/dev/null || fail "Invalid SPDX report for $platform" + if [[ "${PREPARE_ATTESTATION:-false}" == 'true' ]]; then + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + bash "$script_dir/limit-spdx.sh" "$REPORTS/$key.spdx.json" + fi done < "$REPORTS/platforms.tsv" jq -Rn --arg digest "$EXPECTED_DIGEST" \ '{digest: $digest, platforms: [inputs | split("\t") | {platform: .[0], digest: .[1]}]}' \ diff --git a/.github/scripts/prepare-image-attestation.sh b/.github/scripts/prepare-image-attestation.sh index 445c024..e49f368 100644 --- a/.github/scripts/prepare-image-attestation.sh +++ b/.github/scripts/prepare-image-attestation.sh @@ -44,6 +44,8 @@ while IFS=$'\t' read -r platform digest; do "$REPORTS/$key.syft.json" >/dev/null || fail "SBOM subject mismatch for $platform" jq -e '.spdxVersion and .SPDXID == "SPDXRef-DOCUMENT"' \ "$REPORTS/$key.spdx.json" >/dev/null || fail "Invalid SBOM for $platform" + (( $(wc -c < "$REPORTS/$key.spdx.json") <= 16777216 )) \ + || fail "SPDX report exceeds the 16 MiB signing limit for $platform" done < <(jq -r '.platforms[] | [.platform, .digest] | @tsv' "$REPORTS/image.json") # A plain single-platform manifest is already the root: attest it only once. diff --git a/.github/scripts/tests/image-attestation.test.cjs b/.github/scripts/tests/image-attestation.test.cjs index 12ea03e..7bf7aa0 100644 --- a/.github/scripts/tests/image-attestation.test.cjs +++ b/.github/scripts/tests/image-attestation.test.cjs @@ -68,7 +68,9 @@ if (args.includes('syft-test')) { const key = platform.replaceAll('/', '-'); const digest = process.env.WRONG_SYFT_DIGEST || metadata.platforms.find(p => p.platform === platform).digest; fs.writeFileSync(path.join(reportDir, key + '.syft.json'), JSON.stringify({source: {metadata: {manifestDigest: digest}}})); - fs.writeFileSync(path.join(reportDir, key + '.spdx.json'), JSON.stringify({spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT'})); + fs.writeFileSync(path.join(reportDir, key + '.spdx.json'), process.env.SPDX_FIXTURE + ? fs.readFileSync(process.env.SPDX_FIXTURE) + : JSON.stringify({spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT'})); } else if (args.includes('grype-test')) { const source = args.find(a => a.startsWith('sbom:/reports/')); const key = path.basename(source).replace('.syft.json', ''); @@ -223,3 +225,138 @@ test('promotion fails if a release tag resolves to another digest', t => { const f = fixture(t); assert.notEqual(f.run(path.join(scripts, 'promote-attested-image.sh'), { PROMOTED_DIGEST: fakeDigest }).status, 0); }); + +function spdxFixture(t, overrides = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spdx-limit-test-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const document = { + spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT', + documentNamespace: 'https://example.invalid/spdx/fixture', + name: 'fixture', dataLicense: 'CC0-1.0', + creationInfo: { creators: ['Tool: syft-test'], created: '2026-10-24T00:00:00Z' }, + files: [{ SPDXID: 'SPDXRef-File-original', fileName: 'app', checksums: [] }], + ...overrides, + }; + const file = path.join(dir, 'linux-amd64.spdx.json'); + fs.writeFileSync(file, JSON.stringify(document)); + const compact = () => spawnSync('jq', ['-cf', path.join(buildScripts, 'compact-spdx.jq'), file], { encoding: 'utf8' }); + const limit = () => spawnSync('bash', [path.join(buildScripts, 'limit-spdx.sh'), file], { encoding: 'utf8' }); + return { dir, file, document, compact, limit }; +} + +test('SPDX compaction changes only file IDs and their typed references', t => { + const a = 'SPDXRef-File-original', b = 'SPDXRef-File-second'; + const external = `DocumentRef-other:${a}`; + const f = spdxFixture(t, { + documentDescribes: ['SPDXRef-Package-app', a], + packages: [{ SPDXID: 'SPDXRef-Package-app', name: a, hasFiles: [a, b] }], + files: [ + { SPDXID: a, fileName: a, checksums: [], fileDependencies: [b, external] }, + { SPDXID: b, fileName: 'second', checksums: [], comment: a }, + ], + relationships: [{ spdxElementId: a, relatedSpdxElement: b, relationshipType: 'CONTAINS' }, + { spdxElementId: a, relatedSpdxElement: external, relationshipType: 'OTHER' }], + snippets: [{ SPDXID: 'SPDXRef-Snippet-1', name: a, snippetFromFile: b, + ranges: [{ startPointer: { reference: a, offset: 0 }, endPointer: { reference: b, offset: 1 } }] }], + }); + const result = f.compact(); + assert.equal(result.status, 0, result.stderr); + const { document, idMap } = JSON.parse(result.stdout); + const expected = structuredClone(f.document); + expected.documentNamespace += '/dsx-file-ids-v1'; + expected.documentDescribes[1] = 'SPDXRef-f0'; + expected.packages[0].hasFiles = ['SPDXRef-f0', 'SPDXRef-f1']; + expected.files[0].SPDXID = 'SPDXRef-f0'; + expected.files[0].fileDependencies = ['SPDXRef-f1', external]; + expected.files[1].SPDXID = 'SPDXRef-f1'; + expected.relationships[0].spdxElementId = 'SPDXRef-f0'; + expected.relationships[0].relatedSpdxElement = 'SPDXRef-f1'; + expected.relationships[1].spdxElementId = 'SPDXRef-f0'; + expected.snippets[0].snippetFromFile = 'SPDXRef-f1'; + expected.snippets[0].ranges[0].startPointer.reference = 'SPDXRef-f0'; + expected.snippets[0].ranges[0].endPointer.reference = 'SPDXRef-f1'; + assert.deepEqual(document, expected); + assert.deepEqual(idMap.fileIds, { 'SPDXRef-f0': a, 'SPDXRef-f1': b }); + assert.equal(idMap.originalNamespace, f.document.documentNamespace); +}); + +for (const [name, overrides] of [ + ['duplicate file IDs', { files: [{ SPDXID: 'SPDXRef-File-1' }, { SPDXID: 'SPDXRef-File-1' }] }], + ['collision with a package', { packages: [{ SPDXID: 'SPDXRef-f0' }] }], + ['collision with a snippet', { snippets: [{ SPDXID: 'SPDXRef-f0' }] }], + ['file duplicating the document ID', { files: [{ SPDXID: 'SPDXRef-DOCUMENT' }] }], + ['unsupported SPDX version', { spdxVersion: 'SPDX-2.2' }], +]) { + test(`SPDX compaction rejects ${name}`, t => { + const f = spdxFixture(t, overrides); + assert.notEqual(f.compact().status, 0); + }); +} + +test('small SPDX reports stay byte-for-byte unchanged with no sidecars', t => { + const f = spdxFixture(t); + const before = fs.readFileSync(f.file); + const result = f.limit(); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(fs.readFileSync(f.file), before); + assert.deepEqual(fs.readdirSync(f.dir), [path.basename(f.file)]); +}); + +test('oversized reports retain every record, exact source bytes, and an inverse ID map', t => { + const files = Array.from({ length: 1024 }, (_, n) => ({ + SPDXID: `SPDXRef-File-${n}-${'a'.repeat(8300)}`, fileName: `app/${n}`, checksums: [], + })); + const f = spdxFixture(t, { files, relationships: files.map(file => ({ + spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElement: file.SPDXID, relationshipType: 'DESCRIBES', + })) }); + const before = fs.readFileSync(f.file); + assert(before.length > 16777216); + const result = f.limit(); + assert.equal(result.status, 0, result.stderr); + const compacted = JSON.parse(fs.readFileSync(f.file)); + const map = JSON.parse(fs.readFileSync(f.file.replace('.spdx.json', '.spdx-id-map.json'))); + assert(fs.statSync(f.file).size <= 16777216); + assert.deepEqual(fs.readFileSync(f.file.replace('.spdx.json', '.spdx.original.json')), before); + assert.equal(compacted.files.length, files.length); + assert.equal(compacted.relationships.length, files.length); + compacted.documentNamespace = map.originalNamespace; + for (const file of compacted.files) file.SPDXID = map.fileIds[file.SPDXID]; + for (const relation of compacted.relationships) relation.relatedSpdxElement = map.fileIds[relation.relatedSpdxElement]; + assert.deepEqual(compacted, f.document); +}); + +test('reports still over the limit fail without dropping or replacing evidence', t => { + const f = spdxFixture(t, { comment: 'x'.repeat(16777216) }); + const before = fs.readFileSync(f.file); + const result = f.limit(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no evidence was removed/); + assert.deepEqual(fs.readFileSync(f.file), before); + assert.deepEqual(fs.readdirSync(f.dir), [path.basename(f.file)]); +}); + +test('pre-signing validation rejects an oversized SPDX report before returning subjects', t => { + const f = fixture(t, ['amd64']); + f.generate(); + fs.writeFileSync(path.join(f.reports, 'linux-amd64.spdx.json'), JSON.stringify({ + spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT', comment: 'x'.repeat(16777216), + })); + const result = f.prepare(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /16 MiB signing limit/); + assert.ok(!fs.existsSync(f.env.GITHUB_OUTPUT)); +}); + +for (const prepare of ['false', 'true']) { + test(`large SBOM generation enforces the signing limit only when prepare-attestation=${prepare}`, t => { + const f = fixture(t, ['amd64']); + const source = spdxFixture(t, { comment: 'x'.repeat(16777216) }); + f.env.SPDX_FIXTURE = source.file; + f.env.PREPARE_ATTESTATION = prepare; + const result = f.run(path.join(buildScripts, 'sbom-oci.sh')); + assert.equal(result.status === 0, prepare === 'false', result.stderr); + if (prepare === 'false') { + assert.deepEqual(fs.readFileSync(path.join(f.reports, 'linux-amd64.spdx.json')), fs.readFileSync(source.file)); + } + }); +} diff --git a/cds-containers/CHANGELOG.md b/cds-containers/CHANGELOG.md index 442828b..9df5449 100644 --- a/cds-containers/CHANGELOG.md +++ b/cds-containers/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to CDS Containers will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.1] - 2026-10-24 + +### Fixed +- Fit complete SPDX evidence within GitHub's 16 MiB signing limit by shortening document-local file identifiers and preserving all records and references. +- Retain the original Syft report and inverse ID map; do not bypass signing or change container tool versions. +- Retry the interrupted CA-patched image publication under a new immutable version. + ## [0.1.0] - 2026-10-23 ### Changed diff --git a/cds-containers/README.md b/cds-containers/README.md index 4e569a5..bfa3408 100644 --- a/cds-containers/README.md +++ b/cds-containers/README.md @@ -11,7 +11,7 @@ Container images for CDS tooling, optimized for GitHub Actions workflows. These ### 1. `cds-tools` - CDS Tools Container Comprehensive tooling for infrastructure automation, CI/CD, and Kubernetes operations. -**Image**: `ghcr.io/dsx-ai-factory/dsx-cds-tools:0.1.0` +**Image**: `ghcr.io/dsx-ai-factory/dsx-cds-tools:0.1.1` **Includes**: - **Bazel** (multiple versions): @@ -30,7 +30,7 @@ Comprehensive tooling for infrastructure automation, CI/CD, and Kubernetes opera Lightweight Go 1.26.10 development environment with essential tooling. The `1.24` image name is retained for compatibility; it no longer identifies the toolchain version. -**Image**: `ghcr.io/dsx-ai-factory/dsx-cds-go-dev-1.24-alpine:0.1.0` +**Image**: `ghcr.io/dsx-ai-factory/dsx-cds-go-dev-1.24-alpine:0.1.1` **Includes**: - Go 1.26.10 (Alpine-based) @@ -48,7 +48,7 @@ image name is retained for compatibility; it no longer identifies the toolchain Full-featured Go 1.26.10 environment with better C library compatibility. The public image name is unchanged. -**Image**: `ghcr.io/dsx-ai-factory/dsx-cds-go-dev-1.24-debian:0.1.0` +**Image**: `ghcr.io/dsx-ai-factory/dsx-cds-go-dev-1.24-debian:0.1.1` **Size**: ~300MB+ @@ -71,7 +71,7 @@ jobs: packages: read # Required to pull from GHCR container: - image: ghcr.io/dsx-ai-factory/dsx-cds-tools:0.1.0 + image: ghcr.io/dsx-ai-factory/dsx-cds-tools:0.1.1 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -111,14 +111,14 @@ jobs: docker run --rm \ -v $PWD:/workspace \ -w /workspace \ - ghcr.io/dsx-ai-factory/dsx-cds-go-dev-1.24-alpine:0.1.0 \ + ghcr.io/dsx-ai-factory/dsx-cds-go-dev-1.24-alpine:0.1.1 \ go build ./... ``` ### Method 3: Building Custom Image Based on CDS Containers ```dockerfile -FROM ghcr.io/dsx-ai-factory/dsx-cds-tools:0.1.0 +FROM ghcr.io/dsx-ai-factory/dsx-cds-tools:0.1.1 # Add your custom tools RUN apt-get update && apt-get install -y \ @@ -151,7 +151,7 @@ WORKDIR /app ## 🏗️ Versioning and Updates ### Current Version -The current version is defined in [`VERSION`](./VERSION): **0.1.0** +The current version is defined in [`VERSION`](./VERSION): **0.1.1** This file contains only the semantic version number (e.g., `0.0.1`). @@ -182,6 +182,12 @@ All images refresh distro-managed CA stores with patched package minimums: `20250419+deb13u1`, and [Ubuntu](https://ubuntu.com/security/notices/USN-8944-1) `20260816ubuntu0.22.04.1`. No custom root blocklist or TLS bypass is used. +The first `0.1.0` publication stopped before release tags because the tools +SPDX report exceeded GitHub's 16 MiB attestation limit. `0.1.1` retries with +shorter document-local file IDs while retaining every package, file, checksum, +license, and relationship. The original report and inverse ID map remain in the +evidence artifact. Container contents and tool versions are unchanged by this fix. + ### Updating Containers Pushes to `main` and copy-pr-bot branches (`pull-request/**`) build and smoke-test diff --git a/cds-containers/VERSION b/cds-containers/VERSION index 6e8bf73..17e51c3 100644 --- a/cds-containers/VERSION +++ b/cds-containers/VERSION @@ -1 +1 @@ -0.1.0 +0.1.1 diff --git a/cds-containers/tests/test_security_runtime.py b/cds-containers/tests/test_security_runtime.py index 879a6f4..a8cf768 100644 --- a/cds-containers/tests/test_security_runtime.py +++ b/cds-containers/tests/test_security_runtime.py @@ -73,7 +73,7 @@ def test_alpine_uses_the_installed_package_version_constraint(self): self.assertNotIn("apk version -t", script) def test_immutable_release_version(self): - self.assertEqual((CONTAINERS / "VERSION").read_text(), "0.1.0\n") + self.assertEqual((CONTAINERS / "VERSION").read_text(), "0.1.1\n") def test_smoke_script_syntax_and_usage(self): subprocess.run(["sh", "-n", str(SMOKE)], check=True) From ad489776e6a9a46b9b8638348bfe66becb7de5be Mon Sep 17 00:00:00 2001 From: mmou Date: Sat, 24 Oct 2026 14:11:33 +0800 Subject: [PATCH 2/2] test(containers): cover SPDX limits and interrupted writes --- .../scripts/tests/image-attestation.test.cjs | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/.github/scripts/tests/image-attestation.test.cjs b/.github/scripts/tests/image-attestation.test.cjs index 7bf7aa0..aea52ca 100644 --- a/.github/scripts/tests/image-attestation.test.cjs +++ b/.github/scripts/tests/image-attestation.test.cjs @@ -240,7 +240,9 @@ function spdxFixture(t, overrides = {}) { const file = path.join(dir, 'linux-amd64.spdx.json'); fs.writeFileSync(file, JSON.stringify(document)); const compact = () => spawnSync('jq', ['-cf', path.join(buildScripts, 'compact-spdx.jq'), file], { encoding: 'utf8' }); - const limit = () => spawnSync('bash', [path.join(buildScripts, 'limit-spdx.sh'), file], { encoding: 'utf8' }); + const limit = (overrides = {}) => spawnSync('bash', [path.join(buildScripts, 'limit-spdx.sh'), file], { + env: { ...process.env, ...overrides }, encoding: 'utf8', + }); return { dir, file, document, compact, limit }; } @@ -360,3 +362,65 @@ for (const prepare of ['false', 'true']) { } }); } + +for (const [bytes, success] of [[16777216, true], [16777217, false]]) { + test(`SPDX signing limit at ${bytes} bytes ${success ? 'keeps the source unchanged' : 'fails closed'}`, t => { + const f = spdxFixture(t, { files: [], comment: '' }); + f.document.comment = 'x'.repeat(bytes - fs.statSync(f.file).size); + fs.writeFileSync(f.file, JSON.stringify(f.document)); + const before = fs.readFileSync(f.file); + assert.equal(before.length, bytes); + const result = f.limit(); + assert.equal(result.status === 0, success, result.stderr); + if (!success) assert.match(result.stderr, /no evidence was removed/); + assert.deepEqual(fs.readFileSync(f.file), before); + assert.deepEqual(fs.readdirSync(f.dir), [path.basename(f.file)]); + }); +} + +for (const [name, suffix] of [['original', '.spdx.original.json'], ['map', '.spdx-id-map.json']]) { + test(`SPDX normalization refuses an existing ${name} sidecar`, t => { + const f = spdxFixture(t); + fs.appendFileSync(f.file, ' '.repeat(16777216)); + const before = fs.readFileSync(f.file); + const sidecar = f.file.replace('.spdx.json', suffix); + const existing = Buffer.from(`existing ${name} evidence\n`); + fs.writeFileSync(sidecar, existing); + const result = f.limit(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Refusing to replace existing SPDX source evidence/); + assert.deepEqual(fs.readFileSync(f.file), before); + assert.deepEqual(fs.readFileSync(sidecar), existing); + assert.deepEqual(fs.readdirSync(f.dir).sort(), [path.basename(f.file), path.basename(sidecar)].sort()); + }); +} + +test('SPDX normalization preserves source when final report rename fails', t => { + const f = spdxFixture(t); + fs.appendFileSync(f.file, ' '.repeat(16777216)); + const before = fs.readFileSync(f.file); + const bin = path.join(f.dir, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, 'mv'), `#!/usr/bin/env node +const fs = require('node:fs'); +const [source, destination] = process.argv.slice(2).filter(arg => arg !== '--'); +if (destination === process.env.FAIL_REPORT_RENAME) { + process.stderr.write('injected final SPDX report rename failure\\n'); + process.exit(73); +} +fs.renameSync(source, destination); +`, { mode: 0o755 }); + const result = f.limit({ + PATH: `${bin}${path.delimiter}${process.env.PATH}`, FAIL_REPORT_RENAME: f.file, + }); + assert.equal(result.status, 73, result.stderr); + assert.match(result.stderr, /injected final SPDX report rename failure/); + assert.deepEqual(fs.readFileSync(f.file), before); + const original = f.file.replace('.spdx.json', '.spdx.original.json'); + const mapping = f.file.replace('.spdx.json', '.spdx-id-map.json'); + assert.deepEqual(fs.readFileSync(original), before); + const map = JSON.parse(fs.readFileSync(mapping)); + assert.deepEqual(map.fileIds, { 'SPDXRef-f0': f.document.files[0].SPDXID }); + assert.deepEqual(fs.readdirSync(f.dir).sort(), + ['bin', path.basename(f.file), path.basename(original), path.basename(mapping)].sort()); +});