Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/actions/docker-build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions .github/actions/docker-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
51 changes: 51 additions & 0 deletions .github/actions/docker-build/scripts/compact-spdx.jq
Original file line number Diff line number Diff line change
@@ -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)
}
}
31 changes: 31 additions & 0 deletions .github/actions/docker-build/scripts/limit-spdx.sh
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions .github/actions/docker-build/scripts/sbom-oci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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]}]}' \
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/prepare-image-attestation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
203 changes: 202 additions & 1 deletion .github/scripts/tests/image-attestation.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', '');
Expand Down Expand Up @@ -223,3 +225,202 @@ 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 = (overrides = {}) => spawnSync('bash', [path.join(buildScripts, 'limit-spdx.sh'), file], {
env: { ...process.env, ...overrides }, 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));
}
});
}

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());
});
7 changes: 7 additions & 0 deletions cds-containers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading