From 5951f400e1b22e81ab59c1f23df75c1096f76d01 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:15:38 -0400 Subject: [PATCH] Fail the published image scan when its Node.js misses a security release --- .github/workflows/scan-published.yml | 19 ++++++++++ scripts/node-security.js | 53 ++++++++++++++++++++++++++++ test/repo/node-security.test.js | 44 +++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 scripts/node-security.js create mode 100644 test/repo/node-security.test.js diff --git a/.github/workflows/scan-published.yml b/.github/workflows/scan-published.yml index d722ae9d..7f087451 100644 --- a/.github/workflows/scan-published.yml +++ b/.github/workflows/scan-published.yml @@ -34,3 +34,22 @@ jobs: severity: HIGH,CRITICAL ignore-unfixed: true exit-code: '1' + + - name: Check out the repository + if: ${{ !cancelled() }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + if: ${{ !cancelled() }} + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + + - name: Check the image's Node.js for missed security releases + if: ${{ !cancelled() }} + run: | + version=$(docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \ + ghcr.io/sandobserver/stackyard:latest | sed -n 's/^NODE_VERSION=//p') + node scripts/node-security.js "$version" diff --git a/scripts/node-security.js b/scripts/node-security.js new file mode 100644 index 00000000..3e6280d1 --- /dev/null +++ b/scripts/node-security.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/* Fails when a newer Node.js security release exists on the image's major line. + Trivy does not report the Node runtime in the image. */ + +const INDEX_URL = 'https://nodejs.org/dist/index.json'; + +/** @param {unknown} v @returns {[number, number, number]|null} */ +function parseVersion(v) { + const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? '').trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +/** @param {[number, number, number]} a @param {[number, number, number]} b */ +function compare(a, b) { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +/** The newest security release on the same major line that is newer than + `installed`, or null when there is none. + + @param {string} installed + @param {Array<{ version: string, security?: boolean }>} releases + @returns {string|null} */ +function missingSecurityRelease(installed, releases) { + const have = parseVersion(installed); + if (!have) throw new Error(`not a Node.js version: ${installed}`); + let newest = null; + for (const r of releases) { + const v = parseVersion(r.version); + if (!r.security || !v || v[0] !== have[0] || compare(v, have) <= 0) continue; + if (!newest || compare(v, newest.v) > 0) newest = { v, version: r.version }; + } + return newest ? newest.version : null; +} + +module.exports = { parseVersion, missingSecurityRelease }; + +if (require.main === module) { + const installed = process.argv[2] ?? ''; + (async () => { + const res = await fetch(INDEX_URL); + if (!res.ok) throw new Error(`${INDEX_URL}: HTTP ${res.status}`); + const missing = missingSecurityRelease(installed, await res.json()); + if (missing) { + console.error(`node-security: image has Node.js ${installed}, security release ${missing} is out`); + process.exit(1); + } + console.error(`node-security: Node.js ${installed} has every security release on its line`); + })().catch(err => { + console.error(`node-security: ${err.message}`); + process.exit(1); + }); +} diff --git a/test/repo/node-security.test.js b/test/repo/node-security.test.js new file mode 100644 index 00000000..c963959a --- /dev/null +++ b/test/repo/node-security.test.js @@ -0,0 +1,44 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { parseVersion, missingSecurityRelease } = require('../../scripts/node-security'); + +const RELEASES = [ + { version: 'v26.12.1', security: true }, + { version: 'v24.23.0', security: false }, + { version: 'v24.22.1', security: true }, + { version: 'v24.22.0', security: false }, + { version: 'v24.21.3', security: true }, + { version: 'v22.23.4', security: true }, +]; + +test('an image behind a security release on its line is reported', () => { + assert.equal(missingSecurityRelease('24.22.0', RELEASES), 'v24.22.1'); + assert.equal(missingSecurityRelease('v24.21.0', RELEASES), 'v24.22.1'); +}); + +test('an image on or past the newest security release passes', () => { + assert.equal(missingSecurityRelease('24.22.1', RELEASES), null); + assert.equal(missingSecurityRelease('24.23.0', RELEASES), null); +}); + +test('releases on other major lines are ignored', () => { + assert.equal(missingSecurityRelease('22.23.4', RELEASES), null); + assert.equal(missingSecurityRelease('25.0.0', RELEASES), null); +}); + +test('a newer release without security fixes is not reported', () => { + assert.equal(missingSecurityRelease('24.22.1', [{ version: 'v24.23.0', security: false }]), null); +}); + +test('an unreadable installed version throws rather than passing', () => { + for (const v of ['', 'latest', '24.22', undefined]) { + assert.throws(() => missingSecurityRelease(v, RELEASES), /not a Node.js version/); + } +}); + +test('the parse accepts both spellings', () => { + assert.deepEqual(parseVersion('v24.22.1'), [24, 22, 1]); + assert.deepEqual(parseVersion('24.22.1'), [24, 22, 1]); + assert.equal(parseVersion('v24.22.1-rc.1'), null); +});