Skip to content

Commit 480ef02

Browse files
notgitikagitikavj
andauthored
fix(security): remediate npm audit findings (#2660)
* fix(security): remediate npm audit findings * fix(security): fail closed in audit wrapper --------- Co-authored-by: gitikavj <gitikavj@amazon.com>
1 parent a0f870e commit 480ef02

5 files changed

Lines changed: 323 additions & 9 deletions

File tree

‎npm-shrinkwrap.json‎

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"format": "prettier --cache --write .",
6060
"format:check": "prettier --cache --check .",
6161
"secrets:check": "secretlint '**/*'",
62-
"security:audit": "npm audit --audit-level=high --omit=dev",
62+
"security:audit": "node scripts/security-audit.mjs",
6363
"clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
6464
"prepare": "husky",
6565
"test": "vitest run --project unit",
@@ -109,7 +109,7 @@
109109
"esbuild": "^0.28.0",
110110
"fast-json-stable-stringify": "^2.1.0",
111111
"fflate": "^0.8.2",
112-
"handlebars": "^4.7.8",
112+
"handlebars": "^4.7.10",
113113
"ink": "^6.6.0",
114114
"ink-link": "5.0.0",
115115
"ink-spinner": "^5.0.0",

‎scripts/security-audit.lib.mjs‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
export const AUDIT_LEVEL = 'high';
2+
export const AUDIT_ARGS = ['audit', `--audit-level=${AUDIT_LEVEL}`, '--omit=dev', '--json'];
3+
4+
const SEVERITY_RANK = {
5+
info: 0,
6+
low: 1,
7+
moderate: 2,
8+
high: 3,
9+
critical: 4,
10+
};
11+
12+
export const TEMPORARY_AUDIT_EXCEPTIONS = {
13+
'GHSA-vfj7-8cjw-p6xm': {
14+
expiresAt: '2026-11-05T00:00:00.000Z',
15+
reason:
16+
'braces@3.0.3 is the latest release and has no patched version; it is pulled in through @aws-cdk/toolkit-lib.',
17+
},
18+
};
19+
20+
function advisoryId(url) {
21+
return url?.match(/GHSA-[\w-]+$/)?.[0];
22+
}
23+
24+
function isBlockingSeverity(severity) {
25+
const minimumSeverity = SEVERITY_RANK[AUDIT_LEVEL];
26+
return (SEVERITY_RANK[severity] ?? Number.POSITIVE_INFINITY) >= minimumSeverity;
27+
}
28+
29+
function hasNonBreakingFix(fixAvailable) {
30+
if (fixAvailable === true) {
31+
return true;
32+
}
33+
if (!fixAvailable) {
34+
return false;
35+
}
36+
if (typeof fixAvailable === 'object') {
37+
return fixAvailable.isSemVerMajor !== true;
38+
}
39+
return true;
40+
}
41+
42+
function uniqueAdvisories(advisories) {
43+
const seen = new Set();
44+
return advisories.filter(advisory => {
45+
const key = advisory.id ?? advisory.url ?? `${advisory.packageName}:${advisory.name}:${advisory.title}`;
46+
if (seen.has(key)) {
47+
return false;
48+
}
49+
seen.add(key);
50+
return true;
51+
});
52+
}
53+
54+
export function auditAdvisories(report) {
55+
return Object.entries(report.vulnerabilities ?? {}).flatMap(([packageName, vulnerability]) =>
56+
(vulnerability.via ?? [])
57+
.filter(via => typeof via === 'object')
58+
.map(via => ({
59+
id: advisoryId(via.url),
60+
packageName,
61+
name: via.name,
62+
severity: via.severity,
63+
title: via.title,
64+
url: via.url,
65+
fixAvailable: vulnerability.fixAvailable,
66+
}))
67+
);
68+
}
69+
70+
export function evaluateAuditReport(report, { status, now = new Date() }) {
71+
const advisories = auditAdvisories(report);
72+
const blockingSeverityAdvisories = advisories.filter(advisory => isBlockingSeverity(advisory.severity));
73+
const allowed = uniqueAdvisories(
74+
blockingSeverityAdvisories.filter(advisory => advisory.id && advisory.id in TEMPORARY_AUDIT_EXCEPTIONS)
75+
);
76+
const blocked = uniqueAdvisories(
77+
blockingSeverityAdvisories.filter(advisory => !advisory.id || !(advisory.id in TEMPORARY_AUDIT_EXCEPTIONS))
78+
);
79+
const errors = [];
80+
81+
if (status !== 0 && status !== 1) {
82+
errors.push(`npm audit exited with unexpected status ${status}.`);
83+
}
84+
if (status === 0 && blockingSeverityAdvisories.length > 0) {
85+
errors.push('npm audit reported blocking advisories despite exiting successfully.');
86+
}
87+
if (status === 1 && blockingSeverityAdvisories.length === 0) {
88+
errors.push('npm audit failed, but no blocking advisories could be identified from its report.');
89+
}
90+
if (blocked.length > 0) {
91+
errors.push(
92+
`npm audit found unapproved blocking advisories: ${blocked
93+
.map(advisory => advisory.id ?? advisory.name ?? advisory.packageName)
94+
.join(', ')}.`
95+
);
96+
}
97+
98+
const allowedIds = new Set(allowed.map(advisory => advisory.id));
99+
const unusedExceptions = Object.keys(TEMPORARY_AUDIT_EXCEPTIONS).filter(id => !allowedIds.has(id));
100+
if (unusedExceptions.length > 0) {
101+
errors.push(
102+
`Temporary npm audit exception(s) are no longer required: ${unusedExceptions.join(', ')}. Remove them.`
103+
);
104+
}
105+
106+
for (const advisory of allowed) {
107+
const exception = TEMPORARY_AUDIT_EXCEPTIONS[advisory.id];
108+
if (now >= new Date(exception.expiresAt)) {
109+
errors.push(
110+
`Temporary npm audit exception expired: ${advisory.id}. Recheck for patched releases and remove the exception.`
111+
);
112+
}
113+
if (hasNonBreakingFix(advisory.fixAvailable)) {
114+
errors.push(
115+
`npm reports a non-breaking fix for temporarily allowed advisory ${advisory.id}. Apply the fix and remove the exception.`
116+
);
117+
}
118+
}
119+
120+
return {
121+
ok: errors.length === 0,
122+
allowed,
123+
blocked,
124+
errors,
125+
};
126+
}
127+
128+
export function parseAuditReport(stdout) {
129+
try {
130+
return { ok: true, report: JSON.parse(stdout) };
131+
} catch {
132+
return { ok: false, error: 'npm audit failed without returning valid JSON.' };
133+
}
134+
}

‎scripts/security-audit.mjs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
AUDIT_ARGS,
3+
TEMPORARY_AUDIT_EXCEPTIONS,
4+
evaluateAuditReport,
5+
parseAuditReport,
6+
} from './security-audit.lib.mjs';
7+
import { spawnSync } from 'node:child_process';
8+
9+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
10+
const result = spawnSync(npmCommand, AUDIT_ARGS, {
11+
encoding: 'utf8',
12+
stdio: ['ignore', 'pipe', 'pipe'],
13+
});
14+
15+
if (result.error) {
16+
console.error(`Unable to run npm audit: ${result.error.message}`);
17+
process.exitCode = 1;
18+
} else if (result.signal || !Number.isInteger(result.status)) {
19+
console.error(`npm audit did not complete normally${result.signal ? ` (signal: ${result.signal})` : ''}.`);
20+
process.exitCode = 1;
21+
} else {
22+
const parsed = parseAuditReport(result.stdout);
23+
if (!parsed.ok) {
24+
process.stdout.write(result.stdout);
25+
process.stderr.write(result.stderr);
26+
console.error(parsed.error);
27+
process.exitCode = result.status || 1;
28+
} else {
29+
const decision = evaluateAuditReport(parsed.report, { status: result.status });
30+
if (!decision.ok) {
31+
process.stdout.write(result.stdout);
32+
process.stderr.write(result.stderr);
33+
for (const error of decision.errors) {
34+
console.error(error);
35+
}
36+
process.exitCode = result.status || 1;
37+
} else if (decision.allowed.length > 0) {
38+
const allowedIds = decision.allowed.map(advisory => advisory.id);
39+
console.warn(`npm audit found only temporarily allowed high-severity advisories: ${allowedIds.join(', ')}.`);
40+
for (const id of allowedIds) {
41+
const exception = TEMPORARY_AUDIT_EXCEPTIONS[id];
42+
console.warn(`- ${id}: ${exception.reason} Exception expires ${exception.expiresAt}.`);
43+
}
44+
} else {
45+
process.stdout.write(result.stdout);
46+
}
47+
}
48+
}

‎scripts/security-audit.test.mjs‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { evaluateAuditReport, parseAuditReport } from './security-audit.lib.mjs';
2+
import assert from 'node:assert/strict';
3+
import test from 'node:test';
4+
5+
function auditReport(advisories, fixAvailable = false) {
6+
return {
7+
vulnerabilities: {
8+
dependency: {
9+
via: advisories,
10+
fixAvailable,
11+
},
12+
},
13+
};
14+
}
15+
16+
const bracesAdvisory = {
17+
name: 'braces',
18+
severity: 'high',
19+
title: 'stack-exhaustion denial of service',
20+
url: 'https://github.com/advisories/GHSA-vfj7-8cjw-p6xm',
21+
};
22+
23+
test('allows the time-boxed braces advisory', () => {
24+
const report = auditReport([bracesAdvisory], {
25+
name: '@aws-cdk/toolkit-lib',
26+
version: '1.15.1',
27+
isSemVerMajor: true,
28+
});
29+
const decision = evaluateAuditReport(report, {
30+
status: 1,
31+
now: new Date('2026-11-04T23:59:59.999Z'),
32+
});
33+
34+
assert.equal(decision.ok, true);
35+
assert.deepEqual(
36+
decision.allowed.map(advisory => advisory.id),
37+
['GHSA-vfj7-8cjw-p6xm']
38+
);
39+
assert.deepEqual(decision.blocked, []);
40+
});
41+
42+
test('does not allow other high-severity advisories', () => {
43+
const report = auditReport([
44+
bracesAdvisory,
45+
{
46+
name: 'unexpected-package',
47+
severity: 'critical',
48+
title: 'unexpected vulnerability',
49+
url: 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc',
50+
},
51+
]);
52+
const decision = evaluateAuditReport(report, {
53+
status: 1,
54+
now: new Date('2026-11-04T23:59:59.999Z'),
55+
});
56+
57+
assert.equal(decision.ok, false);
58+
assert.deepEqual(
59+
decision.blocked.map(advisory => advisory.id),
60+
['GHSA-aaaa-bbbb-cccc']
61+
);
62+
});
63+
64+
for (const severity of [undefined, 'severe']) {
65+
test(`treats ${severity ?? 'missing'} severity as blocking`, () => {
66+
const report = auditReport([
67+
bracesAdvisory,
68+
{
69+
name: 'unexpected-package',
70+
severity,
71+
title: 'unexpected vulnerability',
72+
url: 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc',
73+
},
74+
]);
75+
const decision = evaluateAuditReport(report, {
76+
status: 1,
77+
now: new Date('2026-11-04T23:59:59.999Z'),
78+
});
79+
80+
assert.equal(decision.ok, false);
81+
assert.deepEqual(
82+
decision.blocked.map(advisory => advisory.id),
83+
['GHSA-aaaa-bbbb-cccc']
84+
);
85+
});
86+
}
87+
88+
test('rejects the braces exception when a non-breaking fix is available', () => {
89+
for (const fixAvailable of [true, { name: 'braces', version: '3.0.4', isSemVerMajor: false }]) {
90+
const decision = evaluateAuditReport(auditReport([bracesAdvisory], fixAvailable), {
91+
status: 1,
92+
now: new Date('2026-11-04T23:59:59.999Z'),
93+
});
94+
95+
assert.equal(decision.ok, false);
96+
assert.match(decision.errors.join('\n'), /non-breaking fix/);
97+
}
98+
});
99+
100+
test('expires the braces exception only when the audit still relies on it', () => {
101+
const decision = evaluateAuditReport(auditReport([bracesAdvisory]), {
102+
status: 1,
103+
now: new Date('2026-11-05T00:00:00.000Z'),
104+
});
105+
106+
assert.equal(decision.ok, false);
107+
assert.match(decision.errors.join('\n'), /exception expired/);
108+
});
109+
110+
test('requires stale exceptions to be removed', () => {
111+
const decision = evaluateAuditReport({ vulnerabilities: {} }, { status: 0 });
112+
113+
assert.equal(decision.ok, false);
114+
assert.match(decision.errors.join('\n'), /no longer required/);
115+
});
116+
117+
test('fails closed when npm audit returns an unexpected status', () => {
118+
const decision = evaluateAuditReport(auditReport([bracesAdvisory]), {
119+
status: 2,
120+
now: new Date('2026-11-04T23:59:59.999Z'),
121+
});
122+
123+
assert.equal(decision.ok, false);
124+
assert.match(decision.errors.join('\n'), /unexpected status 2/);
125+
});
126+
127+
test('fails closed when npm audit output is not valid JSON', () => {
128+
assert.deepEqual(parseAuditReport('not json'), {
129+
ok: false,
130+
error: 'npm audit failed without returning valid JSON.',
131+
});
132+
});

0 commit comments

Comments
 (0)