iamsecure 0.0.0-stage → 0.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.
- package/LICENSE +21 -0
- package/README.md +127 -2
- package/bin/iamsecure.js +136 -0
- package/package.json +36 -4
- package/src/ai/index.js +170 -0
- package/src/ai/providers.js +161 -0
- package/src/code/context.js +123 -0
- package/src/code/engine.js +117 -0
- package/src/code/files.js +85 -0
- package/src/code/index.js +30 -0
- package/src/code/rules.js +545 -0
- package/src/code/secrets.js +123 -0
- package/src/code/strip.js +51 -0
- package/src/deps/cvss.js +59 -0
- package/src/deps/graph.js +88 -0
- package/src/deps/index.js +144 -0
- package/src/deps/lockfiles/composer.js +31 -0
- package/src/deps/lockfiles/npm.js +77 -0
- package/src/deps/lockfiles/pnpm.js +56 -0
- package/src/deps/lockfiles/python.js +101 -0
- package/src/deps/lockfiles/yarn.js +93 -0
- package/src/deps/osv.js +106 -0
- package/src/project.js +102 -0
- package/src/report/sarif.js +58 -0
- package/src/report/terminal.js +253 -0
- package/src/risk/exploit.js +53 -0
- package/src/risk/score.js +215 -0
- package/src/scan.js +91 -0
- package/src/ui/index.html +980 -0
- package/src/ui/server.js +97 -0
- package/src/util/cache.js +35 -0
- package/src/util/http.js +43 -0
- package/src/util/semver.js +23 -0
- package/src/util/toml.js +129 -0
- package/src/util/yaml.js +85 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Turns "how bad is this bug in general" (severity) into "how bad is it HERE" (risk).
|
|
2
|
+
//
|
|
3
|
+
// risk = severity × exposure(where it runs, what kind of bug) × attack-vector × exploit-intel × reachability
|
|
4
|
+
//
|
|
5
|
+
// Every multiplier that fires adds a human-readable reason, so the ranking is explainable.
|
|
6
|
+
|
|
7
|
+
import { levelFromScore } from '../deps/cvss.js';
|
|
8
|
+
import { CONTEXT_LABEL, mergeContexts, frameworkRuntime } from '../code/context.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_WEIGHTS = { server: 1, universal: 1, client: 0.6, build: 0.3, unused: 0.5, unknown: 0.85 };
|
|
11
|
+
|
|
12
|
+
/** Vulnerability classes. `client` = how much the bug still matters when the code only runs in a browser. */
|
|
13
|
+
export const VULN_CLASSES = [
|
|
14
|
+
{ id: 'malicious', name: 'malicious code', cwes: [], re: /malicious (code|package)/i, weights: { client: 1, build: 1, unused: 1 } },
|
|
15
|
+
{ id: 'secret', name: 'leaked secret', clientNote: 'anything in client code ships to every visitor', cwes: ['CWE-798'], re: null, weights: { client: 1, server: 0.9, build: 0.8, unknown: 0.9 } },
|
|
16
|
+
{ id: 'rce', name: 'code execution', clientNote: 'code runs in the visitor\'s browser sandbox, not on your server', cwes: ['CWE-94', 'CWE-95', 'CWE-77', 'CWE-78', 'CWE-502', 'CWE-1336'], re: /remote code execution|command injection|code injection|\brce\b|arbitrary code|template injection/i, weights: { client: 0.5 } },
|
|
17
|
+
{ id: 'sqli', name: 'SQL injection', clientNote: 'the browser has no direct database access', cwes: ['CWE-89', 'CWE-943'], re: /sql injection|nosql injection/i, weights: { client: 0.3 } },
|
|
18
|
+
{ id: 'auth', name: 'auth bypass', clientNote: 'your server must enforce auth anyway', cwes: ['CWE-285', 'CWE-287', 'CWE-288', 'CWE-290', 'CWE-306', 'CWE-347', 'CWE-863', 'CWE-639', 'CWE-1390'], re: /authori[sz]ation bypass|authentication bypass|auth bypass|signature (verification|bypass)|forge/i, weights: { client: 0.4 } },
|
|
19
|
+
{ id: 'ssrf', name: 'SSRF', clientNote: 'browser requests can\'t reach your internal network', cwes: ['CWE-918'], re: /\bssrf\b|server-side request forgery/i, weights: { client: 0.25 } },
|
|
20
|
+
{ id: 'path', name: 'path traversal', clientNote: 'the browser has no access to your server\'s filesystem', cwes: ['CWE-22', 'CWE-23', 'CWE-24', 'CWE-73'], re: /path traversal|directory traversal|arbitrary file (read|write)/i, weights: { client: 0.25 } },
|
|
21
|
+
{ id: 'xss', name: 'XSS', clientNote: 'XSS targets exactly this environment', cwes: ['CWE-79', 'CWE-80', 'CWE-116'], re: /cross[- ]site scripting|\bxss\b|html injection/i, weights: { client: 0.95 } },
|
|
22
|
+
{ id: 'proto', name: 'prototype pollution', clientNote: 'can still be chained into client-side XSS', cwes: ['CWE-1321', 'CWE-915'], re: /prototype pollution/i, weights: { client: 0.65 } },
|
|
23
|
+
{ id: 'redirect', name: 'open redirect', clientNote: 'still usable for phishing', cwes: ['CWE-601'], re: /open redirect/i, weights: { client: 0.7 } },
|
|
24
|
+
{ id: 'dos', name: 'denial of service', clientNote: 'an attacker can only freeze their own tab', cwes: ['CWE-400', 'CWE-1333', 'CWE-770', 'CWE-674', 'CWE-835', 'CWE-407', 'CWE-776', 'CWE-1050', 'CWE-834'], re: /denial[- ]of[- ]service|\bdos\b|redos|regular expression|backtracking|resource exhaustion|uncontrolled resource|infinite loop|memory exhaustion/i, weights: { client: 0.2 } },
|
|
25
|
+
{ id: 'leak', name: 'information leak', clientNote: 'only data already sent to the browser is at risk', cwes: ['CWE-200', 'CWE-201', 'CWE-209', 'CWE-359', 'CWE-522', 'CWE-532', 'CWE-312'], re: /credential leak|information (disclosure|exposure)|exposure of sensitive|leaks?\b/i, weights: { client: 0.5 } },
|
|
26
|
+
{ id: 'crypto', name: 'weak crypto', clientNote: 'client-side crypto is rarely security-critical', cwes: ['CWE-327', 'CWE-328', 'CWE-330', 'CWE-338', 'CWE-326'], re: /weak (crypto|hash|random)|insecure random|predictable/i, weights: { client: 0.5 } },
|
|
27
|
+
];
|
|
28
|
+
const OTHER = { id: 'other', name: 'vulnerability', cwes: [], re: null, weights: {} };
|
|
29
|
+
|
|
30
|
+
export function classifyVuln({ cwes = [], text = '', category }) {
|
|
31
|
+
if (category) return VULN_CLASSES.find((c) => c.id === category) ?? OTHER;
|
|
32
|
+
return (
|
|
33
|
+
VULN_CLASSES.find((c) => c.cwes.some((cwe) => cwes.includes(cwe))) ??
|
|
34
|
+
VULN_CLASSES.find((c) => c.re?.test(text)) ??
|
|
35
|
+
OTHER
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const STOP = new Set(['true', 'false', 'null', 'undefined', 'object', 'string', 'number', 'function', 'prototype', '__proto__', 'constructor', 'require', 'import', 'export', 'index', 'package', 'node', 'npm', 'json', 'options', 'config', 'version', 'default', 'this', 'window', 'document', 'http', 'https', 'localhost', 'test', 'name', 'data', 'keys', 'def', 'read', 'build', 'stack', 'prop', 'auth', 'username', 'password', 'cookie', 'value', 'key', 'url', 'path', 'input', 'output', 'error', 'false']);
|
|
40
|
+
|
|
41
|
+
/** Function/property names an advisory calls out in `backticks` — candidates for a reachability check. */
|
|
42
|
+
export function vulnerableSymbols(adv, pkgName) {
|
|
43
|
+
const ids = new Set();
|
|
44
|
+
for (const m of `${adv.summary}\n${adv.details}`.matchAll(/`([^`\n]{2,60})`/g)) {
|
|
45
|
+
const s = m[1].trim().replace(/\(.*\)$/, '');
|
|
46
|
+
if (!/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(s)) continue;
|
|
47
|
+
const last = s.split('.').pop();
|
|
48
|
+
if (last.length < 3 || /^[A-Z0-9_]+$/.test(last) || /^[0-9a-f]{6,}$/.test(last) || STOP.has(last.toLowerCase()) || last === pkgName || pkgName.endsWith('/' + last)) continue;
|
|
49
|
+
ids.add(last);
|
|
50
|
+
}
|
|
51
|
+
return [...ids].slice(0, 10);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find a call to one of `symbols` made through this package's import:
|
|
58
|
+
* `binding.sym(` for default/namespace imports, `sym(` (or its alias) for named imports.
|
|
59
|
+
*/
|
|
60
|
+
function findSymbolUse(symbols, uses, texts) {
|
|
61
|
+
for (const u of uses) {
|
|
62
|
+
const text = texts.get(u.file);
|
|
63
|
+
if (!text) continue;
|
|
64
|
+
const lines = text.split('\n');
|
|
65
|
+
const importText = lines.slice(u.line - 1, u.line + 6).join('\n').split(/\bfrom\b|require\s*\(/)[0];
|
|
66
|
+
for (const sym of symbols) {
|
|
67
|
+
const s = escapeRe(sym);
|
|
68
|
+
const named = new RegExp(`[{,]\\s*${s}(?:\\s+as\\s+([\\w$]+))?\\s*[,}]`).exec(importText);
|
|
69
|
+
let re;
|
|
70
|
+
if (named) re = new RegExp(`(?<![\\w$.])${escapeRe(named[1] ?? sym)}\\s*\\(`, 'g');
|
|
71
|
+
else if (u.binding) re = new RegExp(`(?<![\\w$.])${escapeRe(u.binding)}\\s*\\.\\s*${s}\\b`, 'g');
|
|
72
|
+
else continue;
|
|
73
|
+
for (const m of text.matchAll(re)) {
|
|
74
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
75
|
+
if (line !== u.line) return { symbol: sym, file: u.file, line };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Where a vulnerable package actually runs, inferred from which direct deps pull it in and where those are imported. */
|
|
83
|
+
function dependencyExposure(f, src, runtime) {
|
|
84
|
+
// PHP/Python packages run in the backend; import names don't map cleanly to package names, so don't guess.
|
|
85
|
+
if (f.package.ecosystem !== 'npm') {
|
|
86
|
+
return { context: f.package.prod ? 'server' : 'build', evidence: [f.package.prod ? `${f.package.ecosystem} runtime dependency` : 'dev dependency'] };
|
|
87
|
+
}
|
|
88
|
+
if (!src) return { context: f.package.prod ? 'unknown' : 'build', evidence: [] };
|
|
89
|
+
const contexts = new Set();
|
|
90
|
+
const evidence = [];
|
|
91
|
+
for (const root of f.package.introducedBy) {
|
|
92
|
+
const uses = src.usage.get(root);
|
|
93
|
+
if (uses?.length) {
|
|
94
|
+
for (const u of uses) contexts.add(u.context);
|
|
95
|
+
const best = uses.find((u) => u.context === 'server' || u.context === 'universal') ?? uses[0];
|
|
96
|
+
evidence.push(`${root === f.package.name ? 'imported' : `via ${root}`} in ${best.file}:${best.line}`);
|
|
97
|
+
} else if (runtime[root]) {
|
|
98
|
+
runtime[root].forEach((c) => contexts.add(c));
|
|
99
|
+
evidence.push(`${root} is loaded by the framework`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const context = mergeContexts(contexts) ?? (f.package.prod ? 'unused' : 'build');
|
|
103
|
+
return { context, evidence: evidence.slice(0, 3) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const round1 = (x) => Math.round(x * 10) / 10;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Compute riskScore/risk/exposure/reasons on every finding (mutates).
|
|
110
|
+
* @param {any[]} findings
|
|
111
|
+
* @param {{src: ReturnType<import('../code/index.js').analyzeSource>|null, framework: string, exploit?: {kev:Map<string,any>, epss:Map<string,any>}}} ctx
|
|
112
|
+
*/
|
|
113
|
+
export function scoreFindings(findings, { src, framework, exploit }) {
|
|
114
|
+
const runtime = frameworkRuntime(framework);
|
|
115
|
+
for (const f of findings) {
|
|
116
|
+
const reasons = [];
|
|
117
|
+
let s = f.severityScore;
|
|
118
|
+
const adv = f.advisory;
|
|
119
|
+
|
|
120
|
+
const cls = classifyVuln(
|
|
121
|
+
f.kind === 'dependency'
|
|
122
|
+
? { cwes: adv.cwes, text: `${adv.summary} ${adv.malicious ? 'malicious code' : ''}` }
|
|
123
|
+
: { category: f.category },
|
|
124
|
+
);
|
|
125
|
+
f.vulnClass = cls.id;
|
|
126
|
+
|
|
127
|
+
// 1. Exposure
|
|
128
|
+
const exp =
|
|
129
|
+
f.kind === 'dependency'
|
|
130
|
+
? dependencyExposure(f, src, runtime)
|
|
131
|
+
: { context: f.context ?? src?.contexts.get(f.location?.file) ?? 'unknown', evidence: [] };
|
|
132
|
+
const weight = { ...DEFAULT_WEIGHTS, ...cls.weights }[exp.context] ?? 1;
|
|
133
|
+
f.exposure = { context: exp.context, label: CONTEXT_LABEL[exp.context], evidence: exp.evidence };
|
|
134
|
+
s *= weight;
|
|
135
|
+
const why = weight < 1 || exp.context === 'client' ? exposureReason(exp.context, cls, f) : null;
|
|
136
|
+
if (why) reasons.push(why);
|
|
137
|
+
else if (f.kind === 'dependency' && exp.evidence.length) reasons.push(`Runs on the server — ${exp.evidence[0]}`);
|
|
138
|
+
|
|
139
|
+
// 2. Attack vector (CVSS)
|
|
140
|
+
const m = adv?.cvss?.metrics;
|
|
141
|
+
if (m && (exp.context === 'server' || exp.context === 'universal')) {
|
|
142
|
+
if (m.AV === 'L' || m.AV === 'P') {
|
|
143
|
+
s *= 0.75;
|
|
144
|
+
reasons.push(`Needs ${m.AV === 'L' ? 'local' : 'physical'} access to exploit (CVSS AV:${m.AV})`);
|
|
145
|
+
}
|
|
146
|
+
if (m.PR === 'H') {
|
|
147
|
+
s *= 0.85;
|
|
148
|
+
reasons.push('Attacker needs high privileges first (CVSS PR:H)');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Exploit intelligence
|
|
153
|
+
if (f.kind === 'dependency' && exploit) {
|
|
154
|
+
const cves = [adv.id, ...adv.aliases].filter((id) => id.startsWith('CVE-'));
|
|
155
|
+
const kev = cves.map((c) => exploit.kev.get(c)).find(Boolean);
|
|
156
|
+
const epss = cves.map((c) => exploit.epss.get(c)).filter(Boolean).sort((a, b) => b.epss - a.epss)[0];
|
|
157
|
+
f.exploit = { cves, kev: kev ?? null, epss: epss ?? null };
|
|
158
|
+
if (kev) {
|
|
159
|
+
const live = !['build', 'unused'].includes(exp.context);
|
|
160
|
+
s = live ? Math.max(s * 1.4, 9) : s * 1.3;
|
|
161
|
+
reasons.push(`CISA KEV: actively exploited in the wild since ${kev.dateAdded}${kev.ransomware ? ' (used by ransomware)' : ''}`);
|
|
162
|
+
}
|
|
163
|
+
if (epss) {
|
|
164
|
+
const pct = (epss.epss * 100).toFixed(epss.epss < 0.01 ? 2 : 1);
|
|
165
|
+
if (epss.epss >= 0.5) (s *= 1.3), reasons.push(`EPSS ${pct}% — very likely to be exploited soon`);
|
|
166
|
+
else if (epss.epss >= 0.1) (s *= 1.15), reasons.push(`EPSS ${pct}% — elevated exploitation likelihood`);
|
|
167
|
+
else if (epss.epss < 0.001 && !kev) s *= 0.95;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 4. Reachability: does our code call the function the advisory names?
|
|
172
|
+
if (f.kind === 'dependency' && src && f.package.direct && !adv.malicious) {
|
|
173
|
+
const uses = src.usage.get(f.package.name) ?? [];
|
|
174
|
+
const symbols = vulnerableSymbols(adv, f.package.name);
|
|
175
|
+
if (uses.length && symbols.length) {
|
|
176
|
+
const hit = findSymbolUse(symbols, uses, src.texts);
|
|
177
|
+
if (hit) {
|
|
178
|
+
s *= 1.15;
|
|
179
|
+
f.reachable = hit;
|
|
180
|
+
reasons.push(`Your code calls \`${hit.symbol}\` named in the advisory (${hit.file}:${hit.line})`);
|
|
181
|
+
} else {
|
|
182
|
+
// Advisories often name internal helpers, so absence is only weak evidence.
|
|
183
|
+
s *= 0.9;
|
|
184
|
+
f.reachable = false;
|
|
185
|
+
reasons.push(`No direct calls found to the functions named in the advisory (${symbols.slice(0, 4).map((x) => `\`${x}\``).join(', ')}) — they may be internal; --ai can confirm`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (adv?.malicious) {
|
|
191
|
+
s = 10;
|
|
192
|
+
reasons.unshift('Known MALICIOUS package — its install scripts run on every machine that installs it, dev-only or not');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
f.riskScore = round1(Math.min(10, Math.max(0, s)));
|
|
196
|
+
f.risk = levelFromScore(f.riskScore);
|
|
197
|
+
f.reasons = reasons;
|
|
198
|
+
}
|
|
199
|
+
return findings;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function exposureReason(context, cls, f) {
|
|
203
|
+
switch (context) {
|
|
204
|
+
case 'client':
|
|
205
|
+
return `Only runs in the browser — ${cls.clientNote ?? `${cls.name} impact is reduced there`}`;
|
|
206
|
+
case 'build':
|
|
207
|
+
return f.kind === 'dependency' ? 'Dev/build/test tooling only — not shipped to production' : 'In test/build code — not shipped to production';
|
|
208
|
+
case 'unused':
|
|
209
|
+
return 'Installed as a production dependency but never imported — remove it if unused';
|
|
210
|
+
case 'unknown':
|
|
211
|
+
return 'Could not determine where this runs';
|
|
212
|
+
default:
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { loadProject } from './project.js';
|
|
2
|
+
import { loadGraphs, scanDependencies, treeStats } from './deps/index.js';
|
|
3
|
+
import { LEVELS } from './deps/cvss.js';
|
|
4
|
+
import { analyzeSource } from './code/index.js';
|
|
5
|
+
import { scanCode } from './code/engine.js';
|
|
6
|
+
import { scanSecrets } from './code/secrets.js';
|
|
7
|
+
import { loadExploitData } from './risk/exploit.js';
|
|
8
|
+
import { scoreFindings } from './risk/score.js';
|
|
9
|
+
|
|
10
|
+
export const VERSION = '0.1.0';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run a full scan and return a JSON-serialisable report.
|
|
14
|
+
* @param {string} root
|
|
15
|
+
* @param {{deps?: boolean, code?: boolean, exploit?: boolean, log?: (msg:string)=>void}} [opts]
|
|
16
|
+
*/
|
|
17
|
+
export async function scan(root, opts = {}) {
|
|
18
|
+
const { deps = true, code = true, exploit = true, log = () => {} } = opts;
|
|
19
|
+
const started = Date.now();
|
|
20
|
+
const project = loadProject(root);
|
|
21
|
+
const warnings = [];
|
|
22
|
+
const findings = [];
|
|
23
|
+
|
|
24
|
+
log(`Resolving dependency tree…`);
|
|
25
|
+
const { graphs, warnings: depWarnings } = loadGraphs(project);
|
|
26
|
+
warnings.push(...depWarnings);
|
|
27
|
+
const stats = { packages: treeStats(graphs) };
|
|
28
|
+
|
|
29
|
+
if (deps && graphs.length) {
|
|
30
|
+
log(`Checking ${stats.packages.total} packages against OSV.dev…`);
|
|
31
|
+
try {
|
|
32
|
+
findings.push(...(await scanDependencies(graphs)));
|
|
33
|
+
} catch (err) {
|
|
34
|
+
warnings.push(`Vulnerability lookup failed: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let src = null;
|
|
39
|
+
if (code) {
|
|
40
|
+
log('Analysing source code…');
|
|
41
|
+
src = analyzeSource(project);
|
|
42
|
+
stats.files = src.contexts.size;
|
|
43
|
+
findings.push(...scanCode(src), ...scanSecrets(src, project.root));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let exploitData;
|
|
47
|
+
const cves = [...new Set(findings.flatMap((f) => (f.advisory ? [f.advisory.id, ...f.advisory.aliases] : [])).filter((id) => id.startsWith('CVE-')))];
|
|
48
|
+
if (exploit && cves.length) {
|
|
49
|
+
log(`Checking ${cves.length} CVEs against CISA KEV + EPSS…`);
|
|
50
|
+
exploitData = await loadExploitData(cves);
|
|
51
|
+
warnings.push(...exploitData.warnings);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
scoreFindings(findings, { src, framework: project.framework, exploit: exploitData });
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
tool: { name: 'iamsecure', version: VERSION },
|
|
58
|
+
scannedAt: new Date().toISOString(),
|
|
59
|
+
durationMs: Date.now() - started,
|
|
60
|
+
project: { name: project.name, root: project.root, framework: project.framework },
|
|
61
|
+
sourceFiles: src ? [...src.contexts].slice(0, 400).map(([file, context]) => ({ file, context })) : [],
|
|
62
|
+
stats,
|
|
63
|
+
warnings,
|
|
64
|
+
summary: summarize(findings),
|
|
65
|
+
findings: sortFindings(findings, 'risk'),
|
|
66
|
+
tree: graphs.map(serializeGraph),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sortFindings(findings, by = 'risk') {
|
|
71
|
+
const primary = by === 'severity' ? 'severityScore' : 'riskScore';
|
|
72
|
+
const secondary = by === 'severity' ? 'riskScore' : 'severityScore';
|
|
73
|
+
return [...findings].sort((a, b) => b[primary] - a[primary] || b[secondary] - a[secondary] || a.title.localeCompare(b.title));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function summarize(findings) {
|
|
77
|
+
const count = (field) => Object.fromEntries(LEVELS.slice(0, 4).map((l) => [l, findings.filter((f) => f[field] === l).length]));
|
|
78
|
+
return { total: findings.length, bySeverity: count('severity'), byRisk: count('risk') };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compact tree for the dashboard: direct deps and the vulnerable packages beneath them. */
|
|
82
|
+
function serializeGraph(g) {
|
|
83
|
+
return {
|
|
84
|
+
ecosystem: g.ecosystem,
|
|
85
|
+
lockfile: g.lockfile,
|
|
86
|
+
roots: g.roots.map((r) => ({ name: r.name, key: r.key, dev: r.dev, workspace: r.workspace })),
|
|
87
|
+
nodes: Object.fromEntries(
|
|
88
|
+
[...g.nodes.values()].filter((n) => !n.local).map((n) => [n.key, { deps: [...n.deps], prod: !!n.prod }]),
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
}
|