diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 0d6090b8b58..2eb7a52895f 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -706,8 +706,11 @@ jobs: notify-on-failure: name: Notify Cats on nightly build failure runs-on: ubuntu-latest - needs: [build-and-push-docker] - if: always() && needs.build-and-push-docker.result == 'failure' && github.event_name == 'schedule' + needs: [build-and-push-docker, sbom-attestation] + if: | + always() && github.event_name == 'schedule' && + (needs.build-and-push-docker.result == 'failure' || + needs.sbom-attestation.result == 'failure') steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -719,4 +722,4 @@ jobs: run: | node .github/scripts/slack/notify.mjs \ --channel '#team-catalysts' \ - --text 'Nightly Docker build failed - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + --text 'Nightly Docker build failed (build=${{ needs.build-and-push-docker.result }}, sbom=${{ needs.sbom-attestation.result }}) - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' diff --git a/scripts/licenses/enrich-sbom.mjs b/scripts/licenses/enrich-sbom.mjs index 8d4837e4732..c43470c0a95 100644 --- a/scripts/licenses/enrich-sbom.mjs +++ b/scripts/licenses/enrich-sbom.mjs @@ -75,6 +75,16 @@ function srcFileOf(component) { return null; } +/** + * An npm name is `name` or `@scope/name` — neither part may contain a slash. A + * further slash means the scanner named a directory inside a package, i.e. an + * `exports` subpath (@google/genai/node, @linear/sdk/webhooks). + */ +function isExportsSubpath(component) { + const qualified = qualifiedName(component) ?? ''; + return qualified.replace(/^@[^/]+\//, '').includes('/'); +} + /** * A scan of a container image filesystem (unlike the pnpm-lockfile scan of the * release closure) deep-walks node_modules and mis-catalogs nested package.json @@ -91,6 +101,11 @@ export function isPhantomNpm(component) { const purl = component.purl ?? ''; if (!purl.startsWith('pkg:npm/')) return false; + // Checked before the path rules: a subpath stub lives at + // node_modules/@google/genai/node/package.json, which *does* end with its own + // qualified name, so the canonical-path check below would call it real. + if (isExportsSubpath(component)) return true; + const src = srcFileOf(component); // syft writes "UNKNOWN" where cdxgen omits the field. Neither is a real // version. cdxgen emits `exports`-subpath phantoms with no path either, so diff --git a/scripts/licenses/enrich-sbom.test.mjs b/scripts/licenses/enrich-sbom.test.mjs index 49738f12282..5376f7a3b3b 100644 --- a/scripts/licenses/enrich-sbom.test.mjs +++ b/scripts/licenses/enrich-sbom.test.mjs @@ -204,6 +204,42 @@ describe('isPhantomNpm (cdxgen image-scan noise)', () => { const syftSrc = (p) => ({ properties: [{ name: 'syft:location:0:path', value: p }] }); + it('flags an exports subpath whose syft path matches its own qualified name', () => { + assert.equal( + isPhantomNpm({ + name: 'genai/node', + group: '@google', + version: 'UNKNOWN', + purl: 'pkg:npm/%40google/genai%2Fnode', + ...syftSrc('/usr/local/lib/node_modules/n8n/node_modules/@google/genai/node/package.json'), + }), + true, + ); + assert.equal( + isPhantomNpm({ + name: 'sdk/webhooks', + group: '@linear', + version: 'UNKNOWN', + purl: 'pkg:npm/%40linear/sdk%2Fwebhooks', + ...syftSrc('/usr/local/lib/node_modules/n8n/node_modules/@linear/sdk/webhooks/package.json'), + }), + true, + ); + }); + + it('keeps an unscoped package whose name is a prefix of a subpath', () => { + assert.equal( + isPhantomNpm({ + name: 'genai', + group: '@google', + version: '1.19.0', + purl: 'pkg:npm/%40google/genai@1.19.0', + ...syftSrc('/usr/local/lib/node_modules/n8n/node_modules/@google/genai/package.json'), + }), + false, + ); + }); + it('keeps an application root outside node_modules (runners ship the task runner there)', () => { assert.equal( isPhantomNpm({ diff --git a/scripts/licenses/properties.test.mjs b/scripts/licenses/properties.test.mjs index 2304c5ef46a..a79996e946a 100644 --- a/scripts/licenses/properties.test.mjs +++ b/scripts/licenses/properties.test.mjs @@ -238,7 +238,7 @@ describe('isPhantomNpm — properties', () => { } }); - it('CANONICAL-PATH-WITH-VERSION-NEVER-PHANTOM', () => { + it('CANONICAL-PATH-WITH-VERSION-NEVER-PHANTOM (for a well-formed npm name)', () => { const node = (group, name) => ({ group, name, @@ -255,6 +255,27 @@ describe('isPhantomNpm — properties', () => { assert.equal(isPhantomNpm(node('@n8n', 'db')), false); }); + it('SUBPATH-NAME-ALWAYS-PHANTOM: a slash past the scope is never a real npm name', () => { + const atCanonicalPath = (group, name) => ({ + group, + name, + version: '1.0.0', + purl: `pkg:npm/${group ? group + '/' : ''}${name}@1.0.0`, + properties: [ + { + name: 'syft:location:0:path', + value: `/x/node_modules/${group ? group + '/' : ''}${name}/package.json`, + }, + ], + }); + // Canonical path AND a resolved version — every other signal says "real". + assert.equal(isPhantomNpm(atCanonicalPath('@google', 'genai/node')), true); + assert.equal(isPhantomNpm(atCanonicalPath('@linear', 'sdk/webhooks')), true); + assert.equal(isPhantomNpm(atCanonicalPath('', 'foo/bar')), true); + // The scope slash alone must not trip it. + assert.equal(isPhantomNpm(atCanonicalPath('@google', 'genai')), false); + }); + it('NON-NPM-NEVER-PHANTOM: OS and other ecosystems are out of scope', () => { for (const purl of ['pkg:apk/alpine/busybox@1', 'pkg:deb/debian/libc@2', 'pkg:cargo/x@1']) { assert.equal(isPhantomNpm({ purl, name: 'x' }), false, purl);