diff --git a/README.md b/README.md index 9eaae0c9..328a40a4 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,18 @@ This action runs the [Microsoft Security DevOps CLI](https://aka.ms/msdo-nuget) See [action.yml](action.yml) +## Node.js runtime security + +The action uses the GitHub Actions runner's `node24` interpreter, not the Node.js selected by `actions/setup-node` or the shell `PATH`. + +The CLI installer can decode Brotli-compressed NuGet metadata through its dependencies. [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases) affects the native Brotli decoder in Node.js, so updating only JavaScript dependencies does not patch the interpreter. + +Before CLI installation, the action logs its actual Node.js version, executable path, and Brotli version. It then rejects unpatched or unverified runtimes. The accepted stable upstream release lines start at Node.js **22.23.4**, **24.22.1**, and **26.12.1**, respectively. Older, end-of-life, prerelease, and unrecognized release lines are rejected. Vendor backports with older version numbers require a reviewed update to this policy; they are not accepted automatically. + +For GitHub-hosted runners, the hosted provider must supply the patched action interpreter. For self-hosted runners, the runner operator must update the runner and its bundled `node24` interpreter. Installing a patched Node.js elsewhere on the host is insufficient. + +After rollout, verify the executable and version reported by a subsequent legitimate action run, and confirm that the action completes successfully. Record the action ref and runner identity with that evidence. A local test, a `node --version` shell step, or the `node24` metadata declaration alone does not verify the deployed action interpreter. + ## Basic Run **Microsoft Security DevOps (MSDO)** with the default policy and recommended tools. diff --git a/lib/v1/msdo.js b/lib/v1/msdo.js index 039c3c00..570e6fe5 100644 --- a/lib/v1/msdo.js +++ b/lib/v1/msdo.js @@ -37,6 +37,7 @@ const core = __importStar(require("@actions/core")); const msdo_helpers_1 = require("./msdo-helpers"); const client = __importStar(require("@microsoft/security-devops-actions-toolkit/msdo-client")); const common = __importStar(require("@microsoft/security-devops-actions-toolkit/msdo-common")); +const node_runtime_1 = require("./node-runtime"); class MicrosoftSecurityDevOps { constructor() { this.succeedOnError = false; @@ -51,6 +52,8 @@ class MicrosoftSecurityDevOps { } runMain() { return __awaiter(this, void 0, void 0, function* () { + core.info(`Node.js action runtime: ${process.versions.node}; executable: ${process.execPath}; Brotli: ${process.versions.brotli}`); + (0, node_runtime_1.assertPatchedNodeRuntime)(process.versions.node); core.debug('MicrosoftSecurityDevOps.runMain - Running MSDO...'); let args = undefined; let existingFilename = core.getInput('existingFilename'); diff --git a/lib/v1/node-runtime.js b/lib/v1/node-runtime.js new file mode 100644 index 00000000..9eda305d --- /dev/null +++ b/lib/v1/node-runtime.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.assertPatchedNodeRuntime = assertPatchedNodeRuntime; +const minimumNodeVersions = { + 22: [23, 4], + 24: [22, 1], + 26: [12, 1] +}; +function assertPatchedNodeRuntime(version) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (match) { + const [major, minor, patch] = match.slice(1).map(Number); + const minimum = minimumNodeVersions[major]; + if (minimum && (minor > minimum[0] || (minor === minimum[0] && patch >= minimum[1]))) { + return; + } + } + throw new Error(`CVE-2026-111497: Node.js ${version} is not a verified patched release. ` + + 'Use Node.js 22.23.4+, 24.22.1+, or 26.12.1+ within those supported release lines. ' + + 'Update the runner-managed action interpreter through the hosted provider or self-hosted runner operator; ' + + 'actions/setup-node and PATH changes do not update this executable.'); +} diff --git a/src/v1/msdo.ts b/src/v1/msdo.ts index 8d9680f1..822ddf90 100644 --- a/src/v1/msdo.ts +++ b/src/v1/msdo.ts @@ -3,6 +3,7 @@ import { IMicrosoftSecurityDevOps } from './msdo-interface'; import { Tools } from './msdo-helpers'; import * as client from '@microsoft/security-devops-actions-toolkit/msdo-client'; import * as common from '@microsoft/security-devops-actions-toolkit/msdo-common'; +import { assertPatchedNodeRuntime } from './node-runtime'; /* * Microsoft Security DevOps analyzers runner. @@ -23,6 +24,9 @@ export class MicrosoftSecurityDevOps implements IMicrosoftSecurityDevOps { } public async runMain() { + core.info(`Node.js action runtime: ${process.versions.node}; executable: ${process.execPath}; Brotli: ${process.versions.brotli}`); + assertPatchedNodeRuntime(process.versions.node); + core.debug('MicrosoftSecurityDevOps.runMain - Running MSDO...'); let args: string[] = undefined; diff --git a/src/v1/node-runtime.ts b/src/v1/node-runtime.ts new file mode 100644 index 00000000..62ddbe28 --- /dev/null +++ b/src/v1/node-runtime.ts @@ -0,0 +1,25 @@ +const minimumNodeVersions: Record = { + 22: [23, 4], + 24: [22, 1], + 26: [12, 1] +}; + +export function assertPatchedNodeRuntime(version: string): void { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + + if (match) { + const [major, minor, patch] = match.slice(1).map(Number); + const minimum = minimumNodeVersions[major]; + + if (minimum && (minor > minimum[0] || (minor === minimum[0] && patch >= minimum[1]))) { + return; + } + } + + throw new Error( + `CVE-2026-111497: Node.js ${version} is not a verified patched release. ` + + 'Use Node.js 22.23.4+, 24.22.1+, or 26.12.1+ within those supported release lines. ' + + 'Update the runner-managed action interpreter through the hosted provider or self-hosted runner operator; ' + + 'actions/setup-node and PATH changes do not update this executable.' + ); +} diff --git a/test/node-runtime.tests.ts b/test/node-runtime.tests.ts new file mode 100644 index 00000000..0ea61380 --- /dev/null +++ b/test/node-runtime.tests.ts @@ -0,0 +1,84 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import * as core from '@actions/core'; +import * as client from '@microsoft/security-devops-actions-toolkit/msdo-client'; +import { MicrosoftSecurityDevOps } from '../lib/v1/msdo'; + +describe('MSDO action Node.js runtime', () => { + let sandbox: sinon.SinonSandbox; + let runStub: sinon.SinonStub; + let infoStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(core, 'getInput').returns(''); + sandbox.stub(core, 'debug'); + infoStub = sandbox.stub(core, 'info'); + runStub = sandbox.stub(client, 'run').resolves(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + for (const version of [ + '22.23.4', '22.23.5', '22.24.0', + '24.22.1', '24.22.2', '24.23.0', + '26.12.1', '26.12.2', '26.13.0' + ]) { + it(`preserves CLI execution on patched Node.js ${version}`, async () => { + sandbox.stub(process.versions, 'node').value(version); + + await new MicrosoftSecurityDevOps().runMain(); + + sinon.assert.calledOnceWithExactly( + runStub, + ['run', '-p', 'GitHub', '--github'], + 'microsoft/security-devops-action' + ); + }); + } + + for (const version of [ + '22.23.3', '22.22.99', + '24.22.0', '24.21.99', + '26.12.0', '26.11.99', + '18.20.8', '20.20.0', '23.11.0', '25.9.0', '27.0.0', + '24.22.1-rc.1', '24.22', '24.22.1.0', '' + ]) { + it(`rejects unpatched or unverified Node.js "${version}" before CLI installation`, async () => { + sandbox.stub(process.versions, 'node').value(version); + + await assert.rejects( + new MicrosoftSecurityDevOps().runMain(), + /CVE-2026-111497/ + ); + sinon.assert.notCalled(runStub); + }); + } + + it('logs the actual action interpreter and Brotli version', async () => { + sandbox.stub(process.versions, 'node').value('24.22.1'); + + await new MicrosoftSecurityDevOps().runMain(); + + sinon.assert.calledWithExactly( + infoStub, + `Node.js action runtime: ${process.versions.node}; executable: ${process.execPath}; Brotli: ${process.versions.brotli}` + ); + }); + + it('reports the rejected runtime and explains runner remediation', async () => { + sandbox.stub(process.versions, 'node').value('24.22.0'); + + await assert.rejects( + new MicrosoftSecurityDevOps().runMain(), + /runner-managed.*actions\/setup-node/ + ); + sinon.assert.calledWithExactly( + infoStub, + `Node.js action runtime: ${process.versions.node}; executable: ${process.execPath}; Brotli: ${process.versions.brotli}` + ); + sinon.assert.notCalled(runStub); + }); +});