Skip to content

Commit eba6ce8

Browse files
CopilotMossaka
andauthored
feat(ci): add SARIF output to npm audit workflow (#433)
* Initial plan * feat(ci): add npm audit SARIF conversion and upload Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * fix(ci): add null safety check for npm audit vulnerabilities Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> Co-authored-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com>
1 parent 097e186 commit eba6ce8

2 files changed

Lines changed: 332 additions & 2 deletions

File tree

.github/workflows/dependency-audit.yml

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ on:
1414

1515
permissions:
1616
contents: read
17+
security-events: write
1718

1819
jobs:
1920
audit-main:
@@ -34,7 +35,21 @@ jobs:
3435
- name: Install dependencies
3536
run: npm ci
3637

37-
- name: Run npm audit
38+
- name: Run npm audit (JSON output for SARIF)
39+
run: npm audit --json > npm-audit-main.json || true
40+
41+
- name: Convert npm audit to SARIF
42+
if: always()
43+
run: npx tsx scripts/ci/npm-audit-to-sarif.ts npm-audit-main.json npm-audit-main.sarif
44+
45+
- name: Upload npm audit SARIF to GitHub Security tab
46+
if: always()
47+
uses: github/codeql-action/upload-sarif@f68537f3d8a6955880f700730943f8a754454193 # v4
48+
with:
49+
sarif_file: npm-audit-main.sarif
50+
category: npm-audit-main
51+
52+
- name: Run npm audit (fail on high/critical)
3853
run: npm audit --audit-level=high
3954

4055
audit-docs:
@@ -57,6 +72,21 @@ jobs:
5772
run: npm ci
5873
working-directory: docs-site
5974

60-
- name: Run npm audit
75+
- name: Run npm audit (JSON output for SARIF)
76+
run: npm audit --json > npm-audit-docs.json || true
77+
working-directory: docs-site
78+
79+
- name: Convert npm audit to SARIF
80+
if: always()
81+
run: npx tsx scripts/ci/npm-audit-to-sarif.ts docs-site/npm-audit-docs.json npm-audit-docs.sarif
82+
83+
- name: Upload npm audit SARIF to GitHub Security tab
84+
if: always()
85+
uses: github/codeql-action/upload-sarif@f68537f3d8a6955880f700730943f8a754454193 # v4
86+
with:
87+
sarif_file: npm-audit-docs.sarif
88+
category: npm-audit-docs
89+
90+
- name: Run npm audit (fail on high/critical)
6191
run: npm audit --audit-level=high
6292
working-directory: docs-site

scripts/ci/npm-audit-to-sarif.ts

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Convert npm audit JSON output to SARIF format for GitHub Security tab
4+
*
5+
* Usage:
6+
* npm audit --json | npx tsx scripts/ci/npm-audit-to-sarif.ts > results.sarif
7+
* npm audit --json > audit.json && npx tsx scripts/ci/npm-audit-to-sarif.ts audit.json results.sarif
8+
*/
9+
10+
import * as fs from 'fs';
11+
import * as path from 'path';
12+
13+
interface NpmAuditVia {
14+
source?: number;
15+
name: string;
16+
severity: string;
17+
title: string;
18+
url: string;
19+
range?: string;
20+
}
21+
22+
interface NpmAuditVulnerability {
23+
name: string;
24+
severity: string;
25+
isDirect: boolean;
26+
via: (string | NpmAuditVia)[];
27+
effects: string[];
28+
range: string;
29+
nodes: string[];
30+
fixAvailable: boolean | {
31+
name: string;
32+
version: string;
33+
isSemVerMajor: boolean;
34+
};
35+
}
36+
37+
interface NpmAuditReport {
38+
auditReportVersion: number;
39+
vulnerabilities: Record<string, NpmAuditVulnerability>;
40+
metadata: {
41+
vulnerabilities: {
42+
info: number;
43+
low: number;
44+
moderate: number;
45+
high: number;
46+
critical: number;
47+
total: number;
48+
};
49+
};
50+
}
51+
52+
interface SarifLocation {
53+
physicalLocation: {
54+
artifactLocation: {
55+
uri: string;
56+
};
57+
region?: {
58+
startLine: number;
59+
};
60+
};
61+
}
62+
63+
interface SarifResult {
64+
ruleId: string;
65+
ruleIndex: number;
66+
level: 'error' | 'warning' | 'note';
67+
message: {
68+
text: string;
69+
};
70+
locations: SarifLocation[];
71+
properties?: {
72+
severity: string;
73+
packageName: string;
74+
vulnerableVersionRange: string;
75+
fixAvailable: boolean;
76+
};
77+
}
78+
79+
interface SarifRule {
80+
id: string;
81+
shortDescription: {
82+
text: string;
83+
};
84+
fullDescription: {
85+
text: string;
86+
};
87+
helpUri?: string;
88+
properties: {
89+
tags: string[];
90+
precision: string;
91+
'security-severity': string;
92+
};
93+
}
94+
95+
interface SarifReport {
96+
version: '2.1.0';
97+
$schema: string;
98+
runs: Array<{
99+
tool: {
100+
driver: {
101+
name: string;
102+
informationUri: string;
103+
semanticVersion: string;
104+
rules: SarifRule[];
105+
};
106+
};
107+
results: SarifResult[];
108+
}>;
109+
}
110+
111+
/**
112+
* Map npm severity to SARIF level
113+
*/
114+
function mapSeverityToLevel(severity: string): 'error' | 'warning' | 'note' {
115+
switch (severity.toLowerCase()) {
116+
case 'critical':
117+
case 'high':
118+
return 'error';
119+
case 'moderate':
120+
return 'warning';
121+
case 'low':
122+
case 'info':
123+
return 'note';
124+
default:
125+
return 'warning';
126+
}
127+
}
128+
129+
/**
130+
* Map npm severity to SARIF security-severity (0.0 to 10.0 scale)
131+
*/
132+
function mapSeverityToScore(severity: string): string {
133+
switch (severity.toLowerCase()) {
134+
case 'critical':
135+
return '9.0';
136+
case 'high':
137+
return '7.0';
138+
case 'moderate':
139+
return '5.0';
140+
case 'low':
141+
return '3.0';
142+
case 'info':
143+
return '1.0';
144+
default:
145+
return '5.0';
146+
}
147+
}
148+
149+
/**
150+
* Convert npm audit JSON to SARIF format
151+
*/
152+
function convertToSarif(npmAudit: NpmAuditReport, packageJsonPath: string = 'package.json'): SarifReport {
153+
const rules: SarifRule[] = [];
154+
const results: SarifResult[] = [];
155+
const ruleMap = new Map<string, number>();
156+
157+
// Process each vulnerability (with null safety check)
158+
const vulnerabilities = npmAudit.vulnerabilities || {};
159+
for (const [pkgName, vuln] of Object.entries(vulnerabilities)) {
160+
// Extract advisory details from 'via' array
161+
const advisories = vuln.via.filter((v): v is NpmAuditVia => typeof v !== 'string');
162+
163+
for (const advisory of advisories) {
164+
const ruleId = `npm-audit-${advisory.source || pkgName.replace(/[^a-zA-Z0-9-]/g, '-')}`;
165+
166+
// Create rule if not already added
167+
if (!ruleMap.has(ruleId)) {
168+
const ruleIndex = rules.length;
169+
ruleMap.set(ruleId, ruleIndex);
170+
171+
rules.push({
172+
id: ruleId,
173+
shortDescription: {
174+
text: advisory.title || `${vuln.severity} severity vulnerability in ${pkgName}`,
175+
},
176+
fullDescription: {
177+
text: `Package ${pkgName} has a ${vuln.severity} severity vulnerability. ${advisory.title || ''}. Vulnerable versions: ${vuln.range}.`,
178+
},
179+
helpUri: advisory.url,
180+
properties: {
181+
tags: ['security', 'dependency', 'npm'],
182+
precision: 'high',
183+
'security-severity': mapSeverityToScore(vuln.severity),
184+
},
185+
});
186+
}
187+
188+
const ruleIndex = ruleMap.get(ruleId)!;
189+
190+
// Create result for this vulnerability
191+
const fixMessage = vuln.fixAvailable
192+
? typeof vuln.fixAvailable === 'object'
193+
? ` A fix is available by upgrading to ${vuln.fixAvailable.name}@${vuln.fixAvailable.version}.`
194+
: ' A fix is available.'
195+
: ' No fix is currently available.';
196+
197+
results.push({
198+
ruleId,
199+
ruleIndex,
200+
level: mapSeverityToLevel(vuln.severity),
201+
message: {
202+
text: `${advisory.title || `Vulnerability in ${pkgName}`}${fixMessage}`,
203+
},
204+
locations: [
205+
{
206+
physicalLocation: {
207+
artifactLocation: {
208+
uri: packageJsonPath,
209+
},
210+
region: {
211+
startLine: 1,
212+
},
213+
},
214+
},
215+
],
216+
properties: {
217+
severity: vuln.severity,
218+
packageName: pkgName,
219+
vulnerableVersionRange: vuln.range,
220+
fixAvailable: !!vuln.fixAvailable,
221+
},
222+
});
223+
}
224+
}
225+
226+
return {
227+
version: '2.1.0',
228+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
229+
runs: [
230+
{
231+
tool: {
232+
driver: {
233+
name: 'npm audit',
234+
informationUri: 'https://docs.npmjs.com/cli/v10/commands/npm-audit',
235+
semanticVersion: '1.0.0',
236+
rules,
237+
},
238+
},
239+
results,
240+
},
241+
],
242+
};
243+
}
244+
245+
/**
246+
* Main function
247+
*/
248+
function main() {
249+
const args = process.argv.slice(2);
250+
let inputData = '';
251+
let outputFile: string | null = null;
252+
253+
// Parse command line arguments
254+
if (args.length === 0) {
255+
// Read from stdin
256+
inputData = fs.readFileSync(0, 'utf-8');
257+
} else if (args.length === 1) {
258+
// Read from file
259+
inputData = fs.readFileSync(args[0], 'utf-8');
260+
} else if (args.length === 2) {
261+
// Read from file and write to output file
262+
inputData = fs.readFileSync(args[0], 'utf-8');
263+
outputFile = args[1];
264+
} else {
265+
console.error('Usage: npm-audit-to-sarif.ts [input.json] [output.sarif]');
266+
process.exit(1);
267+
}
268+
269+
try {
270+
const npmAudit: NpmAuditReport = JSON.parse(inputData);
271+
272+
// Determine package.json path based on input file location
273+
let packageJsonPath = 'package.json';
274+
if (args[0] && args[0] !== '-') {
275+
const inputDir = path.dirname(args[0]);
276+
packageJsonPath = path.join(inputDir, 'package.json');
277+
// Make path relative to current working directory for SARIF
278+
packageJsonPath = path.relative(process.cwd(), packageJsonPath) || 'package.json';
279+
}
280+
281+
const sarif = convertToSarif(npmAudit, packageJsonPath);
282+
const sarifJson = JSON.stringify(sarif, null, 2);
283+
284+
if (outputFile) {
285+
fs.writeFileSync(outputFile, sarifJson);
286+
console.error(`SARIF report written to ${outputFile}`);
287+
} else {
288+
console.log(sarifJson);
289+
}
290+
} catch (error) {
291+
console.error('Error converting npm audit to SARIF:', error);
292+
process.exit(1);
293+
}
294+
}
295+
296+
if (require.main === module) {
297+
main();
298+
}
299+
300+
export { convertToSarif, NpmAuditReport, SarifReport };

0 commit comments

Comments
 (0)