Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .github/workflows/scan-published.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
53 changes: 53 additions & 0 deletions scripts/node-security.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
44 changes: 44 additions & 0 deletions test/repo/node-security.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading