diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index f584a5fcdbd..51d48e8279b 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -370,6 +370,9 @@ release-publish.yml │ └──────────▶ security-trivy-scan-callable.yml └──────────────────────────▶ sbom-generation-callable.yml +test-sbom-nightly.yml + └──────────────────────────▶ sbom-validation-callable.yml + test-workflows-nightly.yml (manual dispatch only — nightly schedule disabled, DEVP-544) └──────────────────────────▶ test-workflows-callable.yml @@ -522,6 +525,7 @@ Push to master/1.x | Daily 01:30, 02:30, 03:30 | `test-benchmark-nightly.yml` | Performance benchmarks | | Daily 02:00 | `test-get-n8n.yml` | get.n8n.io installer health | | Daily 02:00 | `test-e2e-pc-nightly.yml` | E2E on the `-pc` image | +| Daily 04:00 | `test-sbom-nightly.yml` | Release and image SBOM license validation | | Daily 05:00 | `test-benchmark-destroy-nightly.yml`| Cleanup benchmark env | | Daily 06:00 | `util-sync-master-to-3x.yml` | Replay 3.x onto master (v3) | | Daily 08:00 | `build-v3-nightly.yml` | Nightly v3 Docker images | @@ -643,6 +647,7 @@ Workflows with `workflow_call` trigger: | `sec-sync-retarget-prs.yml` | none | Move bundle PRs back onto `bundle/*` | | `security-trivy-scan-callable.yml` | `image_ref` | Trivy scan | | `sbom-generation-callable.yml` | `n8n_version`, `release_tag_ref` | SBOM generation | +| `sbom-validation-callable.yml` | `sha` | Read-only SBOM validation | | `test-single-instance-npm.yml` | `scope`, `base-ref`, `base-branch`, `blocking`, `timeout-minutes` | Dependency duplication | --- @@ -670,6 +675,7 @@ Scripts in `.github/scripts/`: | `docker/kafka-native-smoke-check.mjs`| Verify librdkafka binary loads in built image | `docker-build-smoke.yml`| | `docker/assert-manifest-format.mjs`| Assert a merged manifest is an OCI image index with the expected platforms | `docker-build-push.yml`| | `docker/should-smoke-build.mjs`| Narrow the `pnpm-workspace.yaml` smoke trigger to native dependency pins | `docker-build-smoke.yml`| +| `attest-image-sbom.mjs` | Generate, validate, and optionally attest image SBOMs | `docker-build-push.yml`, `test-sbom-nightly.yml` | ### Validation Scripts @@ -678,6 +684,7 @@ Scripts in `.github/scripts/`: | `validate-docs-links.js`| Check doc URLs | `util-check-docs-urls.yml`| | `send-build-stats.mjs` | Build telemetry | `setup-nodejs` action | | `resolve-pnpm-version.mjs` | Publish the pinned pnpm version and its executable cache key | `setup-nodejs` action | +| `nightly-sbom-context.mjs` | Resolve the source SHA and image tag for nightly SBOM validation | `test-sbom-nightly.yml` | | `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` | | `quality/check-cubic-config.mjs` | Validate `cubic.yaml` against the vendored cubic schema; enforce its silent agent/character limits. `--refresh` re-pulls the schema | `test-workflow-scripts-reusable.yml`, `util-refresh-cubic-schema.yml` | | `probe-registry.mjs` | Registry path throughput probe (temporary) | `util-probe-registry.yml` | @@ -910,6 +917,13 @@ Packages whose license cannot be resolved from disk go in `scripts/licenses/license-overrides.json` with a verified `source` citation — the upstream LICENSE file, not registry metadata. +`test-sbom-nightly.yml` runs at 04:00 UTC. It waits up to two hours for the current scheduled +Docker build to complete. It builds the production deployment closure at that run's SHA and +validates the release SBOM. It also resolves the four immutable SHA image tags from that +build and validates each image SBOM. The validation uses the same enrichment and SPDX gates +as a release. It does not publish, attest, or upload an artifact. A failure reports to the +Developer Platform Slack channel. + ### SLSA L3 Provenance SLSA (Supply-chain Levels for Software Artifacts) Level 3 provides cryptographic proof of build integrity. diff --git a/.github/scripts/attest-image-sbom.mjs b/.github/scripts/attest-image-sbom.mjs index 538ef2c3700..cbdfb9c790a 100644 --- a/.github/scripts/attest-image-sbom.mjs +++ b/.github/scripts/attest-image-sbom.mjs @@ -8,7 +8,8 @@ * Image refs + digests come from the environment (set by docker-build-push.yml). * An image with no digest (not built for this release type) is skipped. * - * Usage: node .github/scripts/attest-image-sbom.mjs (run from the repo root) + * Usage: node .github/scripts/attest-image-sbom.mjs [--validate-only] + * (run from the repo root) */ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; @@ -62,7 +63,10 @@ export function assertSbomIsUsable(sbomPath, label) { } } -function attest({ label, image, digest }) { +export function processTarget( + { label, image, digest }, + { shouldAttest = true, runCommand = run, assertUsable = assertSbomIsUsable } = {}, +) { const ref = `${image}@${digest}`; const out = path.join(REPO_ROOT, `sbom-${label}.cdx.json`); console.log(`::group::SBOM for ${label} (${ref})`); @@ -71,14 +75,14 @@ function attest({ label, image, digest }) { // names the failing image renders inside a collapsed section. try { // Pull the (host-arch) image and scan its filesystem: OS packages + npm. - run('docker', ['pull', ref]); + runCommand('docker', ['pull', ref]); // `docker:` pins the scan to the image just pulled. A bare ref lets syft's // own provider order decide, and it may resolve the multi-arch index from // the registry instead — describing a different manifest than the one // cosign then attests to. // syft reads licenses from the LICENSE files on disk, so this scan makes no // registry requests. `-file` excludes its per-file catalogue, ~4000 entries. - run('syft', [ + runCommand('syft', [ `docker:${ref}`, '-o', `cyclonedx-json@1.6=${out}`, @@ -90,52 +94,67 @@ function attest({ label, image, digest }) { // Resolve first-party + override licenses (lenient: this image holds only a // subset of the npm closure, so absent overrides are not stale pins) and drop // scanner filesystem phantoms. - run(process.execPath, [ENRICH, out, '--lenient-config', '--drop-phantom-npm']); + runCommand(process.execPath, [ENRICH, out, '--lenient-config', '--drop-phantom-npm']); // Release-blocking gate, scoped to npm — OS packages carry upstream-distro // license strings we don't control, so they're inventoried but not gated. - run(process.execPath, [CHECK, out, ...ALLOW_REFS, '--enforce-prefix=pkg:npm/']); - assertSbomIsUsable(out, label); + runCommand(process.execPath, [CHECK, out, ...ALLOW_REFS, '--enforce-prefix=pkg:npm/']); + assertUsable(out, label); // --replace, so re-running after a mid-loop failure does not leave the // digest carrying two CycloneDX attestations. - run('cosign', [ - 'attest', - '--yes', - '--replace', - '--type', - 'cyclonedx', - '--predicate', - out, - ref, - ]); + if (shouldAttest) { + runCommand('cosign', [ + 'attest', + '--yes', + '--replace', + '--type', + 'cyclonedx', + '--predicate', + out, + ref, + ]); + } } finally { console.log('::endgroup::'); } } -function main() { - const targets = parseTargets(process.env); - if (targets.length === 0) { - console.log('No images with digests to attest — skipping.'); - return; - } +export function processTargets(targets, options = {}) { + const action = options.shouldAttest === false ? 'validation' : 'attestation'; + const process = options.processTarget ?? processTarget; // Attempt every image, then report. Aborting on the first failure leaves the - // later images silently unattested and hides whether they would have passed. + // later images silently unvalidated and hides whether they would have passed. const failed = []; for (const target of targets) { try { - attest(target); + process(target, options); } catch (err) { failed.push(`${target.label}: ${err.message}`); - console.log(`::error title=SBOM attestation::${target.label}: ${err.message}`); + console.log(`::error title=SBOM ${action}::${target.label}: ${err.message}`); } } if (failed.length > 0) { - throw new Error(`${failed.length} of ${targets.length} image(s) failed:\n ${failed.join('\n ')}`); + throw new Error( + `${failed.length} of ${targets.length} image(s) failed:\n ${failed.join('\n ')}`, + ); } } +export function main({ + env = process.env, + args = process.argv.slice(2), + processAll = processTargets, +} = {}) { + const shouldAttest = !args.includes('--validate-only'); + const targets = parseTargets(env); + if (targets.length === 0) { + console.log(`No images with digests to ${shouldAttest ? 'attest' : 'validate'} - skipping.`); + return; + } + processAll(targets, { shouldAttest }); +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { main(); diff --git a/.github/scripts/attest-image-sbom.test.mjs b/.github/scripts/attest-image-sbom.test.mjs index ba734990b09..650cb4b5f63 100644 --- a/.github/scripts/attest-image-sbom.test.mjs +++ b/.github/scripts/attest-image-sbom.test.mjs @@ -3,7 +3,13 @@ import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { assertSbomIsUsable, parseTargets } from './attest-image-sbom.mjs'; +import { + assertSbomIsUsable, + main, + parseTargets, + processTarget, + processTargets, +} from './attest-image-sbom.mjs'; describe('parseTargets', () => { it('builds a target per image when both ref and digest are present', () => { @@ -82,3 +88,89 @@ describe('assertSbomIsUsable', () => { }); }); }); + +describe('processTarget', () => { + const target = { + label: 'n8n-pc', + image: 'ghcr.io/n8n-io/n8n', + digest: 'sha256:aaa', + }; + + it('runs the complete validation chain without attesting in validation-only mode', () => { + const calls = []; + let asserted = false; + processTarget(target, { + shouldAttest: false, + runCommand: (command, args) => calls.push([command, args]), + assertUsable: (_sbomPath, label) => { + assert.equal(label, 'n8n-pc'); + asserted = true; + }, + }); + + assert.deepEqual( + calls.map(([command]) => path.basename(command)), + ['docker', 'syft', 'node', 'node'], + ); + assert.equal(calls[0][1][0], 'pull'); + assert.equal(calls[1][1][0], 'docker:ghcr.io/n8n-io/n8n@sha256:aaa'); + assert.ok(calls[2][1].includes('--drop-phantom-npm')); + assert.ok(calls[3][1].includes('--enforce-prefix=pkg:npm/')); + assert.equal(asserted, true); + }); + + it('attests after validation by default', () => { + const calls = []; + processTarget(target, { + runCommand: (command, args) => calls.push([command, args]), + assertUsable: () => {}, + }); + + assert.equal(calls.at(-1)[0], 'cosign'); + assert.deepEqual(calls.at(-1)[1].slice(0, 6), [ + 'attest', + '--yes', + '--replace', + '--type', + 'cyclonedx', + '--predicate', + ]); + }); +}); + +describe('processTargets', () => { + it('validates every image before reporting aggregated failures', () => { + const attempted = []; + assert.throws( + () => + processTargets([{ label: 'n8n' }, { label: 'n8n-pc' }, { label: 'runners' }], { + shouldAttest: false, + processTarget: (target) => { + attempted.push(target.label); + if (target.label !== 'n8n-pc') throw new Error('missing license'); + }, + }), + /2 of 3 image\(s\) failed/, + ); + assert.deepEqual(attempted, ['n8n', 'n8n-pc', 'runners']); + }); +}); + +describe('main', () => { + it('passes validation-only mode to image processing', () => { + let received; + main({ + env: { + N8N_IMAGE: 'ghcr.io/n8n-io/n8n', + N8N_DIGEST: 'sha256:aaa', + }, + args: ['--validate-only'], + processAll: (targets, options) => { + received = { targets, options }; + }, + }); + + assert.equal(received.targets[0].label, 'n8n'); + assert.equal(received.options.shouldAttest, false); + }); +}); diff --git a/.github/scripts/nightly-sbom-context.mjs b/.github/scripts/nightly-sbom-context.mjs new file mode 100644 index 00000000000..8775612c723 --- /dev/null +++ b/.github/scripts/nightly-sbom-context.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; + +const MAX_AGE_MS = 6 * 60 * 60 * 1000; +const POLL_INTERVAL_MS = 5 * 60 * 1000; +const POLL_ATTEMPTS = 25; + +function writeOutput(name, value) { + if (!process.env.GITHUB_OUTPUT) throw new Error('GITHUB_OUTPUT is not set.'); + appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); +} + +function getLatestScheduledRun() { + const output = execFileSync( + 'gh', + [ + 'run', + 'list', + '--repo', + process.env.GITHUB_REPOSITORY, + '--workflow', + 'docker-build-push.yml', + '--event', + 'schedule', + '--limit', + '1', + '--json', + 'conclusion,createdAt,headSha,status', + ], + { encoding: 'utf8' }, + ); + return JSON.parse(output)[0]; +} + +function isFullSha(value) { + return /^[0-9a-f]{40}$/.test(value ?? ''); +} + +async function resolveSource() { + if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') { + if (!isFullSha(process.env.GITHUB_SHA)) throw new Error('GITHUB_SHA is not a full commit SHA.'); + writeOutput('source_sha', process.env.GITHUB_SHA); + return; + } + + for (let attempt = 1; attempt <= POLL_ATTEMPTS; attempt++) { + const run = getLatestScheduledRun(); + const age = run?.createdAt ? Date.now() - Date.parse(run.createdAt) : MAX_AGE_MS + 1; + const isCurrent = age >= 0 && age <= MAX_AGE_MS; + + if ( + run?.status === 'completed' && + run.conclusion === 'success' && + isFullSha(run.headSha) && + isCurrent + ) { + writeOutput('source_sha', run.headSha); + return; + } + + if (run?.status === 'completed' && run.createdAt && isCurrent) { + throw new Error( + `The current scheduled Docker build completed with conclusion '${run.conclusion}'.`, + ); + } + + if (attempt < POLL_ATTEMPTS) { + console.log( + `Waiting for the current scheduled Docker build (attempt ${attempt} of ${POLL_ATTEMPTS}).`, + ); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + } + + throw new Error('No successful scheduled Docker build appeared within two hours.'); +} + +function resolveImageTag() { + if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') { + writeOutput('image_tag', 'nightly'); + return; + } + + const shortSha = execFileSync('git', ['rev-parse', '--short=7', 'HEAD'], { + encoding: 'utf8', + }).trim(); + writeOutput('image_tag', `nightly-${shortSha}`); +} + +const command = process.argv[2]; + +try { + if (command === 'resolve-source') { + await resolveSource(); + } else if (command === 'resolve-image-tag') { + resolveImageTag(); + } else { + throw new Error('Expected resolve-source or resolve-image-tag.'); + } +} catch (error) { + console.error(`::error::${error.message}`); + process.exit(1); +} diff --git a/.github/workflows/sbom-validation-callable.yml b/.github/workflows/sbom-validation-callable.yml new file mode 100644 index 00000000000..57804bbdc1d --- /dev/null +++ b/.github/workflows/sbom-validation-callable.yml @@ -0,0 +1,53 @@ +name: 'Test: Validate Release SBOM' + +on: + workflow_call: + inputs: + sha: + description: 'Trusted full commit SHA to validate' + required: true + type: string + +permissions: + contents: read + +jobs: + validate-sbom: + name: Validate release SBOM + runs-on: blacksmith-4vcpu-ubuntu-2204 + timeout-minutes: 15 + steps: + - name: Checkout workflow source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # Poutine flags all runtime-selected checkouts. The only caller gets this + # value from GitHub's scheduled-run response or GITHUB_SHA, and this step + # independently rejects non-SHA values before fetch. + # poutine: untrusted_checkout_exec + - name: Checkout SBOM source + env: + SOURCE_SHA: ${{ inputs.sha }} + run: | + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo '::error::The SBOM source must be a full commit SHA.' + exit 1 + fi + git fetch --depth=1 origin "$SOURCE_SHA" + git checkout --detach "$SOURCE_SHA" + + - name: Build production deployment artifact + uses: ./.github/actions/setup-nodejs + with: + build-command: 'pnpm build:deploy' + env: + N8N_GENERATE_LICENSES: 'true' + + # build:deploy already runs this gate. Run it again against the final file + # so validation cannot report success without checking that artifact. + - name: Gate SBOM on resolved SPDX licenses + run: | + node scripts/licenses/check-sbom-licenses.mjs \ + sbom-source.cdx.json \ + --allow-ref=LicenseRef-n8n-sustainable-use --allow-ref=LicenseRef-n8n-enterprise diff --git a/.github/workflows/test-sbom-nightly.yml b/.github/workflows/test-sbom-nightly.yml new file mode 100644 index 00000000000..bddaa0879cb --- /dev/null +++ b/.github/workflows/test-sbom-nightly.yml @@ -0,0 +1,142 @@ +name: 'Test: Nightly SBOM Validation' + +on: + schedule: + # Run after the 00:00 UTC nightly Docker build. + - cron: '0 4 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nightly-sbom-validation + cancel-in-progress: false + +jobs: + resolve-nightly-build: + name: Resolve nightly Docker build + if: github.repository == 'n8n-io/n8n' + runs-on: ubuntu-slim + timeout-minutes: 130 + permissions: + actions: read + contents: read + outputs: + image_tag: ${{ steps.image-tag.outputs.image_tag }} + source_sha: ${{ steps.source.outputs.source_sha }} + steps: + - name: Checkout workflow source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Resolve source SHA + id: source + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/nightly-sbom-context.mjs resolve-source + + # Poutine flags all runtime-selected checkouts. This SHA comes only from + # GitHub's scheduled-run response or GITHUB_SHA, and the step rejects + # non-SHA values before fetch. This workflow has no pull_request trigger. + # poutine: untrusted_checkout_exec + - name: Checkout nightly source + env: + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + run: | + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo '::error::The nightly source must be a full commit SHA.' + exit 1 + fi + git fetch --depth=1 origin "$SOURCE_SHA" + git checkout --detach "$SOURCE_SHA" + + - name: Select nightly image tag + id: image-tag + run: node .github/scripts/nightly-sbom-context.mjs resolve-image-tag + + validate-release-sbom: + name: Validate release SBOM + needs: resolve-nightly-build + permissions: + contents: read + uses: ./.github/workflows/sbom-validation-callable.yml + with: + sha: ${{ needs.resolve-nightly-build.outputs.source_sha }} + + validate-image-sboms: + name: Validate nightly image SBOMs + needs: resolve-nightly-build + runs-on: blacksmith-4vcpu-ubuntu-2204 + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Setup Node.js + uses: ./.github/actions/setup-nodejs + with: + build-command: '' + install-command: '' + cache-dependency-path: .github/scripts/pnpm-lock.yaml + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Install Syft + uses: anchore/sbom-action/download-syft@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 + with: + syft-version: v1.38.2 + + - name: Resolve nightly image digests + id: digests + env: + N8N_TAG: ghcr.io/n8n-io/n8n:${{ needs.resolve-nightly-build.outputs.image_tag }} + N8N_PC_TAG: ghcr.io/n8n-io/n8n:${{ needs.resolve-nightly-build.outputs.image_tag }}-pc + RUNNERS_TAG: ghcr.io/n8n-io/runners:${{ needs.resolve-nightly-build.outputs.image_tag }} + DISTROLESS_TAG: ghcr.io/n8n-io/runners:${{ needs.resolve-nightly-build.outputs.image_tag }}-distroless + run: node .github/scripts/docker/get-manifest-digests.mjs + + - name: Generate and validate image SBOMs + env: + N8N_IMAGE: ${{ steps.digests.outputs.n8n_image }} + N8N_DIGEST: ${{ steps.digests.outputs.n8n_digest }} + N8N_PC_IMAGE: ${{ steps.digests.outputs.n8n_pc_image }} + N8N_PC_DIGEST: ${{ steps.digests.outputs.n8n_pc_digest }} + RUNNERS_IMAGE: ${{ steps.digests.outputs.runners_image }} + RUNNERS_DIGEST: ${{ steps.digests.outputs.runners_digest }} + DISTROLESS_IMAGE: ${{ steps.digests.outputs.runners_distroless_image }} + DISTROLESS_DIGEST: ${{ steps.digests.outputs.runners_distroless_digest }} + run: node .github/scripts/attest-image-sbom.mjs --validate-only + + notify-on-failure: + name: Notify on nightly SBOM validation failure + needs: [resolve-nightly-build, validate-release-sbom, validate-image-sboms] + if: | + always() && + github.event_name == 'schedule' && + (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) + runs-on: ubuntu-slim + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .github/scripts/slack + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Notify Slack + env: + SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + node .github/scripts/slack/notify.mjs \ + --channel C0B4GC7MNF3 \ + --text "<${RUN_URL}|Nightly SBOM license validation failed>" diff --git a/scripts/build-n8n.mjs b/scripts/build-n8n.mjs index ae7fced4cb5..6a730e393f3 100755 --- a/scripts/build-n8n.mjs +++ b/scripts/build-n8n.mjs @@ -563,8 +563,8 @@ try { // scripts, so we don't carry a second isolated install. // // Default: skip. cdxgen + license rendering adds ~minutes to every build:deploy and - // is only needed for the release SBOM job. The release-publish workflow opts in by - // setting N8N_GENERATE_LICENSES=true; regular CI Docker prepare runs skip it. + // is only needed for release and nightly SBOM validation. Those workflows opt in + // with N8N_GENERATE_LICENSES=true; regular CI Docker prepare runs skip it. if (generateLicenses) { echo(chalk.yellow('INFO: Generating SBOM and rendering THIRD_PARTY_LICENSES.md...')); try { diff --git a/security/sca/README.md b/security/sca/README.md index 17f13a60472..e6be8204769 100644 --- a/security/sca/README.md +++ b/security/sca/README.md @@ -39,10 +39,14 @@ pnpm build:deploy (N8N_GENERATE_LICENSES=true) └─ gh release upload ``` +The nightly validation builds the same production deployment closure and runs the same +enrichment and SPDX gate through `sbom-validation-callable.yml`. It has read-only +permissions and does not contain attestation or release upload steps. + ### Docker image SBOM -Produced by the `sbom-attestation` job in `docker-build-push.yml` on -`stable`/`rc`/`nightly` builds. +Produced by the `sbom-attestation` job in `docker-build-push.yml` for release builds that +enable attestations. ``` docker push @@ -52,6 +56,11 @@ docker push └─ cosign attest → attested to image digest ``` +The daily validation waits up to two hours for the current scheduled Docker build to +complete. It resolves that build's immutable SHA tags to digests. It runs the same Syft +scan, enrichment, and npm SPDX gate for all four images. It uses +`attest-image-sbom.mjs --validate-only`, so it does not run Cosign or write to the registry. + The two pipelines use different scanners deliberately. The release SBOM scans a pnpm lockfile, which has no package files to read licenses from, so it queries the npm registry (`FETCH_LICENSE=true`). The image SBOM scans a filesystem, so