cra-audit 1.1.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { writeJson } = require('../utils/fs');
6
+ const { logger } = require('../utils/logger');
7
+ const { parseLockfile } = require('../core/lockfile-parser');
8
+ const { normalizeAllowlist, assessmentFor, STATUSES } = require('../core/vex');
9
+
10
+ const TOOL_VERSION = require('../../package.json').version;
11
+ const INFO_URI = 'https://github.com/migohe14/cra-audit';
12
+
13
+ /** GitHub code scanning ranks alerts by this 0-10 score. */
14
+ const SECURITY_SEVERITY = { critical: 9.5, high: 8.0, moderate: 5.5, low: 2.0, info: 0.5, unknown: 5.0 };
15
+
16
+ /**
17
+ * Converts an audit report to SARIF 2.1.0, the format GitHub code scanning
18
+ * ingests (`github/codeql-action/upload-sarif`). Each advisory on each
19
+ * vulnerable component becomes one result, located on the component's entry
20
+ * in the lockfile; license problems become results too.
21
+ *
22
+ * Advisories assessed as not_affected in the policy are kept as suppressed
23
+ * `note` results carrying the justification, so the decision stays visible.
24
+ *
25
+ * @param {import('../core/auditor').AuditResult} report
26
+ * @param {string} projectRoot
27
+ * @param {object} [policy]
28
+ */
29
+ function buildSarif(report, projectRoot, policy = {}) {
30
+ const rules = new Map();
31
+ const results = [];
32
+ const lock = lockfileLocator(projectRoot);
33
+ const assessments = normalizeAllowlist(policy);
34
+
35
+ const vulns = report.sections.vulnerabilities;
36
+ if (vulns && vulns.ok) {
37
+ for (const finding of vulns.vulnerabilities) {
38
+ for (const source of finding.sources) {
39
+ const ruleId = source.id || (source.url ? source.url.split('/').pop() : `${finding.name}-vulnerability`);
40
+ if (!rules.has(ruleId)) rules.set(ruleId, vulnerabilityRule(ruleId, finding, source));
41
+ const assessment = finding.malicious ? null : assessmentFor(finding, source, assessments);
42
+ const accepted = Boolean(assessment && STATUSES[assessment.status].accepts);
43
+ const result = {
44
+ ruleId,
45
+ level: accepted ? 'note'
46
+ : finding.malicious || source.kev || ['critical', 'high'].includes(source.severity || finding.severity) ? 'error'
47
+ : (source.severity || finding.severity) === 'moderate' ? 'warning' : 'note',
48
+ message: { text: vulnerabilityMessage(finding, source) },
49
+ locations: [lock.locate(finding.name, finding.version)],
50
+ partialFingerprints: { 'craAudit/v1': `${ruleId}:${finding.name}@${finding.version || ''}` },
51
+ };
52
+ if (accepted) {
53
+ result.suppressions = [{
54
+ kind: 'external',
55
+ status: 'accepted',
56
+ justification: [assessment.status, assessment.justification, assessment.detail].filter(Boolean).join(' — '),
57
+ }];
58
+ }
59
+ results.push(result);
60
+ }
61
+ }
62
+ }
63
+
64
+ const licenses = report.sections.licenses;
65
+ if (licenses && licenses.ok) {
66
+ const s = licenses.summary;
67
+ const groups = [
68
+ ['cra-audit/license-denied', s.denied, 'error', (c) => `${c.name}@${c.version} is licensed under ${c.license}, which the policy denies.`],
69
+ ['cra-audit/license-not-allowed', s.notAllowed, 'warning', (c) => `${c.name}@${c.version} is licensed under ${c.license}, which is not in the policy allowlist.`],
70
+ ['cra-audit/license-missing', s.missing, 'warning', (c) => `${c.name}@${c.version} has no documented license (TR-03183-2 §5.2.2).`],
71
+ ];
72
+ for (const [ruleId, list, level, text] of groups) {
73
+ if (!list.length) continue;
74
+ rules.set(ruleId, licenseRule(ruleId));
75
+ for (const c of list) {
76
+ results.push({
77
+ ruleId,
78
+ level,
79
+ message: { text: text(c) },
80
+ locations: [lock.locate(c.name, c.version)],
81
+ partialFingerprints: { 'craAudit/v1': `${ruleId}:${c.name}@${c.version}` },
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ return {
88
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
89
+ version: '2.1.0',
90
+ runs: [{
91
+ tool: {
92
+ driver: {
93
+ name: 'cra-audit',
94
+ version: TOOL_VERSION,
95
+ semanticVersion: TOOL_VERSION,
96
+ informationUri: INFO_URI,
97
+ rules: [...rules.values()],
98
+ },
99
+ },
100
+ originalUriBaseIds: { '%SRCROOT%': { uri: toFileUri(lock.repoRoot) } },
101
+ results,
102
+ }],
103
+ };
104
+ }
105
+
106
+ function vulnerabilityRule(ruleId, finding, source) {
107
+ const severity = source.severity || finding.severity;
108
+ const score = finding.malicious ? 10 : source.cvss || SECURITY_SEVERITY[severity] || 5;
109
+ const tags = ['security', 'vulnerability', 'supply-chain', 'cra'];
110
+ if (finding.malicious) tags.push('malicious');
111
+ if (source.kev) tags.push('actively-exploited');
112
+ const cves = (source.aliases || []).filter((a) => /^CVE-/i.test(a));
113
+ const title = finding.malicious ? `Malicious package: ${finding.name}` : source.title || ruleId;
114
+
115
+ return {
116
+ id: ruleId,
117
+ name: ruleId.replace(/[^A-Za-z0-9]/g, ''),
118
+ shortDescription: { text: title },
119
+ fullDescription: { text: [title, cves.length ? `(${cves.join(', ')})` : ''].join(' ').trim() },
120
+ helpUri: source.url || INFO_URI,
121
+ help: {
122
+ text: finding.malicious
123
+ ? 'This release was published by an attacker. Remove it, reinstall from a clean lockfile, and rotate every credential available to machines that installed it.'
124
+ : 'Update the dependency to a fixed version, or record in .cra-audit.json why it is not exploitable in your product (the assessment goes to the VEX document).',
125
+ },
126
+ properties: {
127
+ tags,
128
+ precision: 'very-high',
129
+ 'security-severity': String(Math.min(10, Number(score)).toFixed(1)),
130
+ },
131
+ };
132
+ }
133
+
134
+ function vulnerabilityMessage(finding, source) {
135
+ const pkg = `${finding.name}${finding.version ? `@${finding.version}` : ''}`;
136
+ if (finding.malicious) {
137
+ return `[MALICIOUS] ${pkg} is a known compromised release (${source.id}). Remove it and rotate exposed credentials.`;
138
+ }
139
+ const parts = [];
140
+ if (source.kev) parts.push(`[ACTIVELY EXPLOITED — CISA KEV since ${source.kev.dateAdded}; CRA Art. 14 reporting may apply]`);
141
+ parts.push(`${pkg}: ${source.title || source.id}`);
142
+ const cves = (source.aliases || []).filter((a) => /^CVE-/i.test(a));
143
+ if (cves.length) parts.push(`(${cves.join(', ')})`);
144
+ const fixed = source.fixed || (finding.fixAvailable && finding.fixAvailable.version);
145
+ parts.push(fixed ? `Fixed in ${fixed}.` : 'No fixed version available.');
146
+ if (!finding.direct) parts.push('Transitive dependency.');
147
+ return parts.join(' ');
148
+ }
149
+
150
+ function licenseRule(ruleId) {
151
+ const texts = {
152
+ 'cra-audit/license-denied': 'Dependency license denied by the policy',
153
+ 'cra-audit/license-not-allowed': 'Dependency license outside the policy allowlist',
154
+ 'cra-audit/license-missing': 'Dependency without a documented license',
155
+ };
156
+ return {
157
+ id: ruleId,
158
+ name: ruleId.replace(/[^A-Za-z0-9]/g, ''),
159
+ shortDescription: { text: texts[ruleId] },
160
+ helpUri: INFO_URI,
161
+ properties: { tags: ['license', 'compliance', 'cra'], precision: 'high' },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Finds the line of `name@version` in the project lockfile so each alert
167
+ * points at the exact entry. Falls back to package.json line 1.
168
+ */
169
+ function lockfileLocator(projectRoot) {
170
+ const repoRoot = findRepoRoot(projectRoot);
171
+ const parsed = parseLockfile(projectRoot);
172
+ const file = parsed.lockfileName ? path.join(projectRoot, parsed.lockfileName) : path.join(projectRoot, 'package.json');
173
+ let lines = [];
174
+ try {
175
+ lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
176
+ } catch {
177
+ // Unreadable lockfile: every result points at line 1.
178
+ }
179
+ const uri = path.relative(repoRoot, file).split(path.sep).join('/');
180
+
181
+ const locate = (name, version) => {
182
+ const line = findEntryLine(lines, name, version);
183
+ return {
184
+ physicalLocation: {
185
+ artifactLocation: { uri, uriBaseId: '%SRCROOT%' },
186
+ region: { startLine: line },
187
+ },
188
+ };
189
+ };
190
+ return { repoRoot, locate };
191
+ }
192
+
193
+ function findEntryLine(lines, name, version) {
194
+ const escaped = name.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
195
+ // npm: "node_modules/name": { | yarn: name@range: / "name@npm:range": | pnpm: name@version: / /name/version:
196
+ const header = new RegExp(`(node_modules/${escaped}"\\s*:|^\\s*["']?/?${escaped}(@|/)[^\\s]*:?\\s*$|^\\s*["']?/?${escaped}@)`);
197
+ for (let i = 0; i < lines.length; i++) {
198
+ if (!header.test(lines[i])) continue;
199
+ if (!version) return i + 1;
200
+ // The entry's version is on the key itself (pnpm) or within the next lines.
201
+ if (lines[i].includes(version)) return i + 1;
202
+ for (let j = i + 1; j < Math.min(lines.length, i + 8); j++) {
203
+ if (lines[j].includes(`"version": "${version}"`) || lines[j].includes(`version "${version}"`) ||
204
+ new RegExp(`^\\s*version:\\s*["']?${version.replace(/\./g, '\\.')}["']?\\s*$`).test(lines[j])) {
205
+ return i + 1;
206
+ }
207
+ }
208
+ }
209
+ return 1;
210
+ }
211
+
212
+ /** Nearest folder with a .git entry: SARIF paths must be repository-relative. */
213
+ function findRepoRoot(start) {
214
+ let dir = path.resolve(start);
215
+ for (;;) {
216
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
217
+ const parent = path.dirname(dir);
218
+ if (parent === dir) return path.resolve(start);
219
+ dir = parent;
220
+ }
221
+ }
222
+
223
+ function toFileUri(dir) {
224
+ const normalized = dir.split(path.sep).join('/');
225
+ return `file://${normalized.startsWith('/') ? '' : '/'}${normalized}/`;
226
+ }
227
+
228
+ /**
229
+ * Writes the SARIF report to disk. `quiet` keeps stdout clean when it
230
+ * carries the JSON report.
231
+ */
232
+ function reportSarif(report, projectRoot, outputPath, { quiet = false, policy } = {}) {
233
+ const outPath = path.isAbsolute(outputPath) ? outputPath : path.join(process.cwd(), outputPath);
234
+ writeJson(outPath, buildSarif(report, projectRoot, policy));
235
+ if (!quiet) logger.success(`SARIF report written to: ${outPath}`);
236
+ }
237
+
238
+ module.exports = { buildSarif, reportSarif, findEntryLine };
package/src/utils/http.js CHANGED
@@ -8,21 +8,28 @@
8
8
 
9
9
  /**
10
10
  * @param {string} url
11
- * @param {{ timeout?: number, headers?: object }} [options]
11
+ * @param {{ timeout?: number, headers?: object, body?: any }} [options]
12
+ * `body`: sent as a JSON POST when present.
12
13
  * @returns {Promise<{ ok: boolean, status: number, json: any, headers: Headers }|null>}
13
14
  */
14
- async function fetchJson(url, { timeout = 8000, headers = {} } = {}) {
15
+ async function fetchJson(url, { timeout = 8000, headers = {}, body } = {}) {
15
16
  if (typeof fetch !== 'function') return null;
16
17
 
17
18
  const controller = new AbortController();
18
19
  const timer = setTimeout(() => controller.abort(), timeout);
19
20
 
20
21
  try {
21
- const res = await fetch(url, {
22
+ const init = {
22
23
  headers: { Accept: 'application/json', ...headers },
23
24
  signal: controller.signal,
24
25
  redirect: 'follow',
25
- });
26
+ };
27
+ if (body !== undefined) {
28
+ init.method = 'POST';
29
+ init.headers['Content-Type'] = 'application/json';
30
+ init.body = JSON.stringify(body);
31
+ }
32
+ const res = await fetch(url, init);
26
33
  let json = null;
27
34
  try {
28
35
  json = await res.json();