@nexus_js/audit 0.6.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 +17 -0
- package/package.json +43 -0
- package/src/engine.ts +322 -0
- package/src/index.ts +42 -0
- package/src/override.ts +168 -0
- package/src/scanner.ts +169 -0
- package/src/supply-chain.ts +316 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nexus Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @nexus_js/audit
|
|
2
|
+
|
|
3
|
+
Nexus Dependency Auditor — OSV CVE scanning, offline cache, supply chain risk analysis, and build-time blocking.
|
|
4
|
+
|
|
5
|
+
## Documentation
|
|
6
|
+
|
|
7
|
+
All guides, API reference, and examples live on **[nexusjs.dev](https://nexusjs.dev)**.
|
|
8
|
+
|
|
9
|
+
## Links
|
|
10
|
+
|
|
11
|
+
- **Website:** [https://nexusjs.dev](https://nexusjs.dev)
|
|
12
|
+
- **Repository:** [github.com/bierfor/nexus](https://github.com/bierfor/nexus) (see `packages/audit/`)
|
|
13
|
+
- **Issues:** [github.com/bierfor/nexus/issues](https://github.com/bierfor/nexus/issues)
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT © Nexus contributors
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nexus_js/audit",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Nexus Dependency Auditor — OSV CVE scanning, offline cache, supply chain risk analysis, and build-time blocking",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"nexus",
|
|
12
|
+
"security",
|
|
13
|
+
"audit",
|
|
14
|
+
"cve",
|
|
15
|
+
"osv",
|
|
16
|
+
"supply-chain",
|
|
17
|
+
"framework",
|
|
18
|
+
"full-stack",
|
|
19
|
+
"svelte",
|
|
20
|
+
"islands",
|
|
21
|
+
"ssr",
|
|
22
|
+
"vite",
|
|
23
|
+
"server-actions"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"homepage": "https://nexusjs.dev",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/bierfor/nexus.git",
|
|
30
|
+
"directory": "packages/audit"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/bierfor/nexus/issues"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nexus Audit Engine — CVE Database via OSV (Open Source Vulnerabilities).
|
|
3
|
+
*
|
|
4
|
+
* Uses the free, open Google OSV API (https://osv.dev) — no API key required.
|
|
5
|
+
* Responses are cached locally in ~/.nexus/cache/ for offline operation.
|
|
6
|
+
*
|
|
7
|
+
* API reference: https://google.github.io/osv.dev/api/
|
|
8
|
+
*
|
|
9
|
+
* Cache strategy:
|
|
10
|
+
* - Queries are cached per package@version for 24h (TTL configurable)
|
|
11
|
+
* - On offline: stale cache is used transparently (no error)
|
|
12
|
+
* - Cache location: ~/.nexus/cache/osv/{pkg-name}.json
|
|
13
|
+
*
|
|
14
|
+
* OSV severity mapping to Nexus levels:
|
|
15
|
+
* CRITICAL → critical (CVSS ≥ 9.0 or explicit CRITICAL)
|
|
16
|
+
* HIGH → high (CVSS 7.0–8.9)
|
|
17
|
+
* MEDIUM → medium (CVSS 4.0–6.9)
|
|
18
|
+
* LOW → low (CVSS < 4.0)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
|
|
25
|
+
const OSV_API = 'https://api.osv.dev/v1/query';
|
|
26
|
+
const CACHE_DIR = join(homedir(), '.nexus', 'cache', 'osv');
|
|
27
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1_000; // 24h
|
|
28
|
+
|
|
29
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export type VulnSeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
|
32
|
+
|
|
33
|
+
export interface Vulnerability {
|
|
34
|
+
id: string; // e.g. 'GHSA-29mw-wpgm-hmr9' or 'CVE-2024-1234'
|
|
35
|
+
summary: string;
|
|
36
|
+
severity: VulnSeverity;
|
|
37
|
+
cvss?: number | undefined; // CVSS v3 base score
|
|
38
|
+
aliases: string[]; // CVE IDs and other aliases
|
|
39
|
+
affectedVersions: string; // human-readable range
|
|
40
|
+
fixedIn?: string | undefined; // first patched version
|
|
41
|
+
references: string[]; // advisory URLs
|
|
42
|
+
published: string; // ISO date
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AuditResult {
|
|
46
|
+
package: string;
|
|
47
|
+
version: string;
|
|
48
|
+
status: 'safe' | 'vulnerable' | 'unknown';
|
|
49
|
+
vulns: Vulnerability[];
|
|
50
|
+
/** True if result came from local cache */
|
|
51
|
+
cached: boolean;
|
|
52
|
+
/** True if result is stale (TTL expired) but used because offline */
|
|
53
|
+
stale: boolean;
|
|
54
|
+
checkedAt: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface CacheEntry {
|
|
58
|
+
result: AuditResult;
|
|
59
|
+
expiresAt: number;
|
|
60
|
+
version: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface OsvResponse {
|
|
64
|
+
vulns?: OsvVuln[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface OsvVuln {
|
|
68
|
+
id: string;
|
|
69
|
+
summary?: string;
|
|
70
|
+
aliases?: string[];
|
|
71
|
+
published: string;
|
|
72
|
+
references?: Array<{ url: string }>;
|
|
73
|
+
severity?: Array<{ type: string; score: string }>;
|
|
74
|
+
affected?: Array<{
|
|
75
|
+
package?: { name: string; ecosystem: string };
|
|
76
|
+
ranges?: Array<{ type: string; events: Array<{ introduced?: string; fixed?: string }> }>;
|
|
77
|
+
versions?: string[];
|
|
78
|
+
}>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Cache helpers ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
async function ensureCacheDir(): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
86
|
+
} catch { /* already exists */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cacheKey(pkg: string): string {
|
|
90
|
+
return pkg.replace(/\//g, '__').replace(/@/g, '_at_');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readCache(pkg: string): Promise<CacheEntry | null> {
|
|
94
|
+
try {
|
|
95
|
+
const raw = await readFile(join(CACHE_DIR, `${cacheKey(pkg)}.json`), 'utf-8');
|
|
96
|
+
return JSON.parse(raw) as CacheEntry;
|
|
97
|
+
} catch { return null; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function writeCache(pkg: string, entry: CacheEntry): Promise<void> {
|
|
101
|
+
await ensureCacheDir();
|
|
102
|
+
try {
|
|
103
|
+
await writeFile(join(CACHE_DIR, `${cacheKey(pkg)}.json`), JSON.stringify(entry, null, 2));
|
|
104
|
+
} catch { /* cache write failure is non-fatal */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── OSV API ───────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function parseSeverity(vuln: OsvVuln): { severity: VulnSeverity; cvss?: number } {
|
|
110
|
+
const severityBlock = vuln.severity ?? [];
|
|
111
|
+
|
|
112
|
+
// Look for CVSS v3 score first
|
|
113
|
+
for (const s of severityBlock) {
|
|
114
|
+
if (s.type === 'CVSS_V3' || s.type === 'CVSS_V4') {
|
|
115
|
+
// Extract numeric score from vector string (e.g. "CVSS:3.1/AV:N/AC:L/...")
|
|
116
|
+
// OSV also sometimes provides the numeric score directly
|
|
117
|
+
const scoreMatch = s.score.match(/^(\d+\.\d+)$/) ??
|
|
118
|
+
s.score.match(/\/(\d+\.\d+)$/);
|
|
119
|
+
const score = scoreMatch ? parseFloat(scoreMatch[1] ?? '0') : 0;
|
|
120
|
+
let sev: VulnSeverity = 'unknown';
|
|
121
|
+
if (score >= 9.0) sev = 'critical';
|
|
122
|
+
else if (score >= 7.0) sev = 'high';
|
|
123
|
+
else if (score >= 4.0) sev = 'medium';
|
|
124
|
+
else if (score > 0) sev = 'low';
|
|
125
|
+
return { severity: sev, cvss: score };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fall back to explicit severity labels in the ID
|
|
130
|
+
const id = vuln.id.toUpperCase();
|
|
131
|
+
if (id.includes('CRITICAL')) return { severity: 'critical' };
|
|
132
|
+
if (id.includes('HIGH')) return { severity: 'high' };
|
|
133
|
+
if (id.includes('MEDIUM')) return { severity: 'medium' };
|
|
134
|
+
if (id.includes('LOW')) return { severity: 'low' };
|
|
135
|
+
return { severity: 'unknown' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseFixedVersion(vuln: OsvVuln): string | undefined {
|
|
139
|
+
for (const affected of vuln.affected ?? []) {
|
|
140
|
+
for (const range of affected.ranges ?? []) {
|
|
141
|
+
for (const event of range.events ?? []) {
|
|
142
|
+
if (event.fixed) return event.fixed;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseAffectedVersions(vuln: OsvVuln): string {
|
|
150
|
+
const parts: string[] = [];
|
|
151
|
+
for (const affected of vuln.affected ?? []) {
|
|
152
|
+
for (const range of affected.ranges ?? []) {
|
|
153
|
+
let intro: string | undefined;
|
|
154
|
+
let fixed: string | undefined;
|
|
155
|
+
for (const event of range.events ?? []) {
|
|
156
|
+
if (event.introduced) intro = event.introduced;
|
|
157
|
+
if (event.fixed) fixed = event.fixed;
|
|
158
|
+
}
|
|
159
|
+
if (intro && fixed) parts.push(`>=${intro} <${fixed}`);
|
|
160
|
+
else if (intro) parts.push(`>=${intro}`);
|
|
161
|
+
else if (fixed) parts.push(`<${fixed}`);
|
|
162
|
+
}
|
|
163
|
+
// Direct version list
|
|
164
|
+
if (affected.versions?.length) {
|
|
165
|
+
parts.push(affected.versions.slice(0, 5).join(', ') + (affected.versions.length > 5 ? '...' : ''));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return parts.join('; ') || 'all versions';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function fetchFromOSV(pkg: string, version?: string): Promise<OsvResponse | null> {
|
|
172
|
+
const body: Record<string, unknown> = {
|
|
173
|
+
package: { name: pkg, ecosystem: 'npm' },
|
|
174
|
+
};
|
|
175
|
+
if (version) body['version'] = version;
|
|
176
|
+
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const timeout = setTimeout(() => controller.abort(), 8_000);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const res = await fetch(OSV_API, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: { 'content-type': 'application/json' },
|
|
184
|
+
body: JSON.stringify(body),
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
});
|
|
187
|
+
if (!res.ok) return null;
|
|
188
|
+
return await res.json() as OsvResponse;
|
|
189
|
+
} catch {
|
|
190
|
+
return null; // network error — caller falls back to cache
|
|
191
|
+
} finally {
|
|
192
|
+
clearTimeout(timeout);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Audits a single package for known CVEs using the OSV database.
|
|
200
|
+
* Results are cached locally for 24h.
|
|
201
|
+
*
|
|
202
|
+
* @param pkg Package name (e.g. 'lodash' or '@angular/core')
|
|
203
|
+
* @param version Specific version to check (e.g. '4.17.20'). If omitted, checks all versions.
|
|
204
|
+
*/
|
|
205
|
+
export async function auditPackage(pkg: string, version?: string): Promise<AuditResult> {
|
|
206
|
+
const cached = await readCache(pkg);
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
|
|
209
|
+
// Fresh cache hit
|
|
210
|
+
if (cached && now < cached.expiresAt) {
|
|
211
|
+
return { ...cached.result, cached: true, stale: false };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Try to fetch from OSV
|
|
215
|
+
const osvData = await fetchFromOSV(pkg, version);
|
|
216
|
+
|
|
217
|
+
if (!osvData) {
|
|
218
|
+
// Offline or API error — use stale cache if available
|
|
219
|
+
if (cached) {
|
|
220
|
+
return { ...cached.result, cached: true, stale: true };
|
|
221
|
+
}
|
|
222
|
+
// No cache at all
|
|
223
|
+
return {
|
|
224
|
+
package: pkg, version: version ?? '*',
|
|
225
|
+
status: 'unknown', vulns: [],
|
|
226
|
+
cached: false, stale: false,
|
|
227
|
+
checkedAt: now,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Parse OSV response
|
|
232
|
+
const vulns: Vulnerability[] = (osvData.vulns ?? []).map((v) => {
|
|
233
|
+
const { severity, cvss } = parseSeverity(v);
|
|
234
|
+
return {
|
|
235
|
+
id: v.id,
|
|
236
|
+
summary: v.summary ?? 'No summary available',
|
|
237
|
+
severity,
|
|
238
|
+
cvss,
|
|
239
|
+
aliases: v.aliases ?? [],
|
|
240
|
+
affectedVersions: parseAffectedVersions(v),
|
|
241
|
+
fixedIn: parseFixedVersion(v),
|
|
242
|
+
references: (v.references ?? []).map((r) => r.url),
|
|
243
|
+
published: v.published,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Sort by severity
|
|
248
|
+
const SORDER: Record<VulnSeverity, number> = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 };
|
|
249
|
+
vulns.sort((a, b) => SORDER[a.severity] - SORDER[b.severity]);
|
|
250
|
+
|
|
251
|
+
const result: AuditResult = {
|
|
252
|
+
package: pkg,
|
|
253
|
+
version: version ?? '*',
|
|
254
|
+
status: vulns.length > 0 ? 'vulnerable' : 'safe',
|
|
255
|
+
vulns,
|
|
256
|
+
cached: false,
|
|
257
|
+
stale: false,
|
|
258
|
+
checkedAt: now,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
await writeCache(pkg, { result, expiresAt: now + CACHE_TTL_MS, version: version ?? '*' });
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Audits all dependencies in a package.json.
|
|
267
|
+
* Runs queries in parallel (max 6 concurrent) to avoid rate limiting.
|
|
268
|
+
*/
|
|
269
|
+
export async function auditDependencies(
|
|
270
|
+
deps: Record<string, string>,
|
|
271
|
+
): Promise<Map<string, AuditResult>> {
|
|
272
|
+
const results = new Map<string, AuditResult>();
|
|
273
|
+
const entries = Object.entries(deps);
|
|
274
|
+
const BATCH = 6;
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < entries.length; i += BATCH) {
|
|
277
|
+
const batch = entries.slice(i, i + BATCH);
|
|
278
|
+
const settled = await Promise.allSettled(
|
|
279
|
+
batch.map(async ([name, versionRange]) => {
|
|
280
|
+
// Strip semver range prefix for OSV query
|
|
281
|
+
const version = versionRange.replace(/^[\^~>=<]/, '').split(' ')[0];
|
|
282
|
+
const result = await auditPackage(name, version);
|
|
283
|
+
return [name, result] as [string, AuditResult];
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
for (const s of settled) {
|
|
287
|
+
if (s.status === 'fulfilled') {
|
|
288
|
+
results.set(s.value[0], s.value[1]);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return results;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Returns only vulnerable packages, sorted by severity. */
|
|
297
|
+
export function filterVulnerable(results: Map<string, AuditResult>): AuditResult[] {
|
|
298
|
+
const SORDER: Record<VulnSeverity, number> = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 };
|
|
299
|
+
return [...results.values()]
|
|
300
|
+
.filter((r) => r.status === 'vulnerable')
|
|
301
|
+
.sort((a, b) => {
|
|
302
|
+
const as = a.vulns[0]?.severity ?? 'unknown';
|
|
303
|
+
const bs = b.vulns[0]?.severity ?? 'unknown';
|
|
304
|
+
return SORDER[as] - SORDER[bs];
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Invalidates the local cache for a specific package (forces re-fetch). */
|
|
309
|
+
export async function invalidateCache(pkg: string): Promise<void> {
|
|
310
|
+
const { unlink } = await import('node:fs/promises');
|
|
311
|
+
try {
|
|
312
|
+
await unlink(join(CACHE_DIR, `${cacheKey(pkg)}.json`));
|
|
313
|
+
} catch { /* file may not exist */ }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Clears the entire OSV cache (all packages). */
|
|
317
|
+
export async function clearCache(): Promise<void> {
|
|
318
|
+
const { rm } = await import('node:fs/promises');
|
|
319
|
+
try {
|
|
320
|
+
await rm(CACHE_DIR, { recursive: true, force: true });
|
|
321
|
+
} catch { /* ignore */ }
|
|
322
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nexus_js/audit — Dependency Auditing & Supply Chain Security
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - CVE scanning via Google OSV (open, no API key)
|
|
6
|
+
* - Supply chain risk analysis via npm registry
|
|
7
|
+
* - Offline-first local cache (~/.nexus/cache/)
|
|
8
|
+
* - Override policy with automatic expiry
|
|
9
|
+
* - Build-time blocking for critical vulnerabilities
|
|
10
|
+
* - `nexus fix` auto-remediation data
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
auditPackage,
|
|
15
|
+
auditDependencies,
|
|
16
|
+
filterVulnerable,
|
|
17
|
+
invalidateCache,
|
|
18
|
+
clearCache,
|
|
19
|
+
} from './engine.js';
|
|
20
|
+
export type { AuditResult, Vulnerability, VulnSeverity } from './engine.js';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
checkSupplyChain,
|
|
24
|
+
auditSupplyChain,
|
|
25
|
+
MFA_NOTE,
|
|
26
|
+
} from './supply-chain.js';
|
|
27
|
+
export type { SupplyChainResult, SupplyChainFlag, SupplyChainRiskLevel } from './supply-chain.js';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
validateOverride,
|
|
31
|
+
findOverride,
|
|
32
|
+
formatOverrides,
|
|
33
|
+
maxOverrideDate,
|
|
34
|
+
} from './override.js';
|
|
35
|
+
export type { VulnerabilityOverride, AllowVulnerableConfig } from './override.js';
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
scanProject,
|
|
39
|
+
NexusSecurityError,
|
|
40
|
+
auditPackage as auditSinglePackage,
|
|
41
|
+
} from './scanner.js';
|
|
42
|
+
export type { ScanOptions, ScanResult } from './scanner.js';
|
package/src/override.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nexus Security Override Policy — "Ghost Wall" exceptions.
|
|
3
|
+
*
|
|
4
|
+
* Sometimes a library is vulnerable but:
|
|
5
|
+
* - The patch isn't yet on a stable release
|
|
6
|
+
* - You only use the affected function in a build-time context (never in production)
|
|
7
|
+
* - Your organization has an accepted risk decision documented elsewhere
|
|
8
|
+
*
|
|
9
|
+
* Overrides are explicit, time-limited exceptions that:
|
|
10
|
+
* 1. Require a documented reason
|
|
11
|
+
* 2. Expire automatically — the build fails again after the date
|
|
12
|
+
* 3. Are logged in every audit report
|
|
13
|
+
* 4. Do NOT suppress warnings (only prevent build failure)
|
|
14
|
+
*
|
|
15
|
+
* Usage in nexus.config.ts:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { defineNexusConfig } from '@nexus_js/core';
|
|
19
|
+
*
|
|
20
|
+
* export default defineNexusConfig({
|
|
21
|
+
* security: {
|
|
22
|
+
* hardened: true,
|
|
23
|
+
* allowVulnerable: {
|
|
24
|
+
* 'pdfkit': {
|
|
25
|
+
* cve: 'CVE-2024-29415',
|
|
26
|
+
* reason: 'Used in build-time PDF generation only. Not in the client bundle. ' +
|
|
27
|
+
* 'Patched version releases 2026-05-15 according to maintainer.',
|
|
28
|
+
* expires: '2026-06-01',
|
|
29
|
+
* },
|
|
30
|
+
* },
|
|
31
|
+
* },
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* After `expires`, the build fails again, forcing re-evaluation.
|
|
36
|
+
* If the patch is available, update the dependency. If not, extend the override with a new reason.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export interface VulnerabilityOverride {
|
|
40
|
+
/** CVE ID or OSV ID being overridden */
|
|
41
|
+
cve: string;
|
|
42
|
+
/** REQUIRED: human-readable business justification */
|
|
43
|
+
reason: string;
|
|
44
|
+
/**
|
|
45
|
+
* ISO date string (YYYY-MM-DD). After this date, the override expires
|
|
46
|
+
* and the build will fail again. Maximum: 180 days from today.
|
|
47
|
+
*/
|
|
48
|
+
expires: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type AllowVulnerableConfig = Record<string, VulnerabilityOverride>;
|
|
52
|
+
|
|
53
|
+
export interface OverrideValidation {
|
|
54
|
+
valid: boolean;
|
|
55
|
+
expired: boolean;
|
|
56
|
+
daysLeft: number;
|
|
57
|
+
message: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validates an override at build time.
|
|
62
|
+
* Returns { valid: false } if expired or malformed.
|
|
63
|
+
*/
|
|
64
|
+
export function validateOverride(
|
|
65
|
+
pkg: string,
|
|
66
|
+
override: VulnerabilityOverride,
|
|
67
|
+
): OverrideValidation {
|
|
68
|
+
const expiry = new Date(override.expires);
|
|
69
|
+
|
|
70
|
+
if (isNaN(expiry.getTime())) {
|
|
71
|
+
return {
|
|
72
|
+
valid: false,
|
|
73
|
+
expired: false,
|
|
74
|
+
daysLeft: 0,
|
|
75
|
+
message: `[Nexus Security] Override for "${pkg}" has an invalid expiry date: "${override.expires}". Use YYYY-MM-DD format.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const now = new Date();
|
|
80
|
+
const daysLeft = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
81
|
+
const expired = daysLeft <= 0;
|
|
82
|
+
|
|
83
|
+
if (expired) {
|
|
84
|
+
return {
|
|
85
|
+
valid: false,
|
|
86
|
+
expired: true,
|
|
87
|
+
daysLeft: 0,
|
|
88
|
+
message:
|
|
89
|
+
`[Nexus Security] ⛔ Override for "${pkg}" (${override.cve}) EXPIRED on ${override.expires}.\n` +
|
|
90
|
+
` Original reason: "${override.reason}"\n` +
|
|
91
|
+
` The vulnerability must now be addressed. Options:\n` +
|
|
92
|
+
` 1. Update "${pkg}" to a patched version\n` +
|
|
93
|
+
` 2. Replace "${pkg}" with a safe alternative\n` +
|
|
94
|
+
` 3. Extend the override with a new expiry date (requires fresh justification)`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Warn when approaching expiry
|
|
99
|
+
const warningDays = 14;
|
|
100
|
+
const warning = daysLeft <= warningDays
|
|
101
|
+
? `\n ⚠️ Override for "${pkg}" expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'} (${override.expires}).`
|
|
102
|
+
: '';
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
valid: true,
|
|
106
|
+
expired: false,
|
|
107
|
+
daysLeft,
|
|
108
|
+
message: `[Nexus Security] ⚠️ OVERRIDE ACTIVE for "${pkg}" (${override.cve}).${warning}\n Reason: "${override.reason}"`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Checks if a package+CVE combination is covered by an active override.
|
|
114
|
+
* Returns null if no override applies (build should fail on critical CVE).
|
|
115
|
+
*/
|
|
116
|
+
export function findOverride(
|
|
117
|
+
pkg: string,
|
|
118
|
+
cveId: string,
|
|
119
|
+
overrides: AllowVulnerableConfig,
|
|
120
|
+
): OverrideValidation | null {
|
|
121
|
+
const override = overrides[pkg];
|
|
122
|
+
if (!override) return null;
|
|
123
|
+
|
|
124
|
+
// Check if the CVE matches (direct or alias)
|
|
125
|
+
const configCve = override.cve.toUpperCase().trim();
|
|
126
|
+
const queryCve = cveId.toUpperCase().trim();
|
|
127
|
+
|
|
128
|
+
if (configCve !== queryCve && !queryCve.includes(configCve) && !configCve.includes(queryCve)) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return validateOverride(pkg, override);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Formats all active overrides for display in audit output.
|
|
137
|
+
* Groups by status: active, expiring soon, expired.
|
|
138
|
+
*/
|
|
139
|
+
export function formatOverrides(
|
|
140
|
+
overrides: AllowVulnerableConfig,
|
|
141
|
+
): { active: string[]; expiringSoon: string[]; expired: string[] } {
|
|
142
|
+
const active: string[] = [];
|
|
143
|
+
const expiringSoon: string[] = [];
|
|
144
|
+
const expired: string[] = [];
|
|
145
|
+
|
|
146
|
+
for (const [pkg, override] of Object.entries(overrides)) {
|
|
147
|
+
const v = validateOverride(pkg, override);
|
|
148
|
+
if (v.expired) {
|
|
149
|
+
expired.push(`${pkg} (${override.cve}) — expired ${override.expires}`);
|
|
150
|
+
} else if (v.daysLeft <= 14) {
|
|
151
|
+
expiringSoon.push(`${pkg} (${override.cve}) — expires in ${v.daysLeft} days`);
|
|
152
|
+
} else {
|
|
153
|
+
active.push(`${pkg} (${override.cve}) — ${v.daysLeft} days left`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { active, expiringSoon, expired };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns the maximum safe override duration (180 days from now).
|
|
162
|
+
* Overrides beyond this are rejected to prevent permanent exceptions.
|
|
163
|
+
*/
|
|
164
|
+
export function maxOverrideDate(): string {
|
|
165
|
+
const d = new Date();
|
|
166
|
+
d.setDate(d.getDate() + 180);
|
|
167
|
+
return d.toISOString().split('T')[0] as string;
|
|
168
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nexus Package Scanner — reads package.json and coordinates audits.
|
|
3
|
+
*
|
|
4
|
+
* Combines:
|
|
5
|
+
* - CVE scanning via OSV (engine.ts)
|
|
6
|
+
* - Supply chain risk via npm registry (supply-chain.ts)
|
|
7
|
+
* - Override policy validation (override.ts)
|
|
8
|
+
*
|
|
9
|
+
* Entry point for both the CLI and the Vite plugin.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { auditDependencies, filterVulnerable, type AuditResult, type VulnSeverity } from './engine.js';
|
|
16
|
+
import { auditSupplyChain, type SupplyChainResult } from './supply-chain.js';
|
|
17
|
+
import { findOverride, validateOverride, formatOverrides, type AllowVulnerableConfig } from './override.js';
|
|
18
|
+
|
|
19
|
+
export type { AuditResult, VulnSeverity, SupplyChainResult, AllowVulnerableConfig };
|
|
20
|
+
export { filterVulnerable };
|
|
21
|
+
|
|
22
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface ScanOptions {
|
|
25
|
+
root: string;
|
|
26
|
+
/** Include devDependencies in the scan (default: false) */
|
|
27
|
+
includeDev?: boolean;
|
|
28
|
+
/** Supply chain guard (default: true) */
|
|
29
|
+
supplyChain?: boolean;
|
|
30
|
+
/** Override exceptions from nexus.config.ts security.allowVulnerable */
|
|
31
|
+
allowVulnerable?: AllowVulnerableConfig;
|
|
32
|
+
/**
|
|
33
|
+
* If true, critical CVEs that are not overridden will throw (for build-time blocking).
|
|
34
|
+
* If false, they are reported but don't throw.
|
|
35
|
+
*/
|
|
36
|
+
failOnCritical?: boolean;
|
|
37
|
+
/** Minimum severity to report (default: 'low') */
|
|
38
|
+
minSeverity?: VulnSeverity;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ScanResult {
|
|
42
|
+
scannedPackages: number;
|
|
43
|
+
vulnerable: AuditResult[];
|
|
44
|
+
supplyChain: Map<string, SupplyChainResult>;
|
|
45
|
+
overrideStatus: ReturnType<typeof formatOverrides>;
|
|
46
|
+
blockedPackages: string[]; // packages that would block a build
|
|
47
|
+
durationMs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Main scanner ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Full dependency scan: CVE + supply chain + override validation.
|
|
54
|
+
* This is the core function called by both `nexus audit` and the Vite plugin.
|
|
55
|
+
*/
|
|
56
|
+
export async function scanProject(opts: ScanOptions): Promise<ScanResult> {
|
|
57
|
+
const t0 = Date.now();
|
|
58
|
+
|
|
59
|
+
// Read package.json
|
|
60
|
+
const pkgPath = join(opts.root, 'package.json');
|
|
61
|
+
let pkgJson: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
62
|
+
try {
|
|
63
|
+
pkgJson = JSON.parse(await readFile(pkgPath, 'utf-8')) as typeof pkgJson;
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(`[Nexus Audit] Cannot read package.json at ${pkgPath}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Collect deps
|
|
69
|
+
const deps: Record<string, string> = {
|
|
70
|
+
...pkgJson.dependencies,
|
|
71
|
+
...(opts.includeDev ? pkgJson.devDependencies : {}),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const scannedPackages = Object.keys(deps).length;
|
|
75
|
+
|
|
76
|
+
// Parallel: CVE scan + supply chain scan
|
|
77
|
+
const [cveResults, scResults] = await Promise.all([
|
|
78
|
+
auditDependencies(deps),
|
|
79
|
+
opts.supplyChain !== false ? auditSupplyChain(deps) : Promise.resolve(new Map<string, SupplyChainResult>()),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const vulnerable = filterVulnerable(cveResults);
|
|
83
|
+
|
|
84
|
+
// Override policy validation
|
|
85
|
+
const overrides = opts.allowVulnerable ?? {};
|
|
86
|
+
const overrideStats = formatOverrides(overrides);
|
|
87
|
+
const blockedPackages: string[] = [];
|
|
88
|
+
|
|
89
|
+
const SORDER: Record<VulnSeverity, number> = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 };
|
|
90
|
+
const minSev = opts.minSeverity ?? 'low';
|
|
91
|
+
|
|
92
|
+
for (const result of vulnerable) {
|
|
93
|
+
for (const vuln of result.vulns) {
|
|
94
|
+
if (SORDER[vuln.severity] > SORDER[minSev]) continue;
|
|
95
|
+
|
|
96
|
+
if (vuln.severity !== 'critical' && vuln.severity !== 'high') continue;
|
|
97
|
+
|
|
98
|
+
// Check if there's a valid override
|
|
99
|
+
const overrideResult = findOverride(result.package, vuln.id, overrides)
|
|
100
|
+
?? findOverride(result.package, vuln.aliases[0] ?? '', overrides);
|
|
101
|
+
|
|
102
|
+
if (overrideResult?.valid) {
|
|
103
|
+
// Override is active — report but don't block
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!overrideResult || overrideResult.expired) {
|
|
108
|
+
// No override or expired → block
|
|
109
|
+
if (!blockedPackages.includes(result.package)) {
|
|
110
|
+
blockedPackages.push(result.package);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const scanResult: ScanResult = {
|
|
117
|
+
scannedPackages,
|
|
118
|
+
vulnerable,
|
|
119
|
+
supplyChain: scResults,
|
|
120
|
+
overrideStatus: overrideStats,
|
|
121
|
+
blockedPackages,
|
|
122
|
+
durationMs: Date.now() - t0,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Throw for build-time blocking
|
|
126
|
+
if (opts.failOnCritical && blockedPackages.length > 0) {
|
|
127
|
+
const details = blockedPackages
|
|
128
|
+
.map((pkg) => {
|
|
129
|
+
const r = cveResults.get(pkg);
|
|
130
|
+
const topVuln = r?.vulns[0];
|
|
131
|
+
return topVuln
|
|
132
|
+
? ` "${pkg}" — ${topVuln.id} (${topVuln.severity.toUpperCase()}): ${topVuln.summary}`
|
|
133
|
+
: ` "${pkg}"`;
|
|
134
|
+
})
|
|
135
|
+
.join('\n');
|
|
136
|
+
|
|
137
|
+
throw new NexusSecurityError(
|
|
138
|
+
`[Nexus Security] 🛑 BUILD BLOCKED — ${blockedPackages.length} critical/high CVE${blockedPackages.length > 1 ? 's' : ''} detected:\n\n` +
|
|
139
|
+
`${details}\n\n` +
|
|
140
|
+
`Options:\n` +
|
|
141
|
+
` 1. Run \`nexus fix\` to automatically update to patched versions\n` +
|
|
142
|
+
` 2. Add a time-limited override in nexus.config.ts (security.allowVulnerable)\n` +
|
|
143
|
+
` 3. Run \`nexus audit\` for full details and suggested fixes`,
|
|
144
|
+
blockedPackages,
|
|
145
|
+
vulnerable,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return scanResult;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Error class ───────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export class NexusSecurityError extends Error {
|
|
155
|
+
constructor(
|
|
156
|
+
message: string,
|
|
157
|
+
public readonly blockedPackages: string[],
|
|
158
|
+
public readonly vulnerable: AuditResult[],
|
|
159
|
+
) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.name = 'NexusSecurityError';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Single-package audit (for Vite plugin import hook) ───────────────────────
|
|
166
|
+
|
|
167
|
+
export { auditPackage } from './engine.js';
|
|
168
|
+
export { checkSupplyChain } from './supply-chain.js';
|
|
169
|
+
export { validateOverride, findOverride } from './override.js';
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nexus Supply Chain Guard.
|
|
3
|
+
*
|
|
4
|
+
* Uses the public npm registry API to detect signs of supply chain compromise:
|
|
5
|
+
*
|
|
6
|
+
* 1. SINGLE MAINTAINER — one compromised account = full package control
|
|
7
|
+
* 2. RECENT MAINTAINER CHANGE — new owner in last 90 days (account takeover pattern)
|
|
8
|
+
* 3. NEWLY PUBLISHED POPULAR NAME — typosquatting / dependency confusion
|
|
9
|
+
* 4. ABANDONED PACKAGE — no updates in 2+ years but still widely imported
|
|
10
|
+
* 5. RAPID VERSION BUMP — many versions published in a short time (malware injection)
|
|
11
|
+
* 6. OWNER TRANSFER — package ownership changed (acquisition or takeover)
|
|
12
|
+
*
|
|
13
|
+
* What we CANNOT check (npm considers it private):
|
|
14
|
+
* - Whether individual maintainers have MFA/2FA enabled
|
|
15
|
+
* (npm Team policy requires it for popular packages as of 2022, but status is not in the API)
|
|
16
|
+
*
|
|
17
|
+
* Risk score: 0 (safe) → 100 (critical risk)
|
|
18
|
+
* Threshold: ≥60 = warn, ≥80 = block in hardened mode
|
|
19
|
+
*
|
|
20
|
+
* npm registry API:
|
|
21
|
+
* GET https://registry.npmjs.org/{package} → full metadata
|
|
22
|
+
* GET https://registry.npmjs.org/{package}/latest → latest version only
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
|
|
29
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
30
|
+
const CACHE_DIR = join(homedir(), '.nexus', 'cache', 'npm-meta');
|
|
31
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1_000; // 6h (changes more frequently than CVEs)
|
|
32
|
+
|
|
33
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export type SupplyChainRiskLevel = 'safe' | 'low' | 'medium' | 'high' | 'critical';
|
|
36
|
+
|
|
37
|
+
export interface SupplyChainFlag {
|
|
38
|
+
code: string;
|
|
39
|
+
title: string;
|
|
40
|
+
description: string;
|
|
41
|
+
riskPoints: number; // contribution to total score (0–100)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SupplyChainResult {
|
|
45
|
+
package: string;
|
|
46
|
+
riskScore: number; // 0–100
|
|
47
|
+
riskLevel: SupplyChainRiskLevel;
|
|
48
|
+
flags: SupplyChainFlag[];
|
|
49
|
+
maintainers: string[];
|
|
50
|
+
latestVersion:string;
|
|
51
|
+
firstPublished: string;
|
|
52
|
+
lastPublished: string;
|
|
53
|
+
totalVersions: number;
|
|
54
|
+
cached: boolean;
|
|
55
|
+
stale: boolean;
|
|
56
|
+
checkedAt: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface NpmMeta {
|
|
60
|
+
name: string;
|
|
61
|
+
'dist-tags': Record<string, string>;
|
|
62
|
+
maintainers: Array<{ name: string; email?: string }>;
|
|
63
|
+
time: Record<string, string>; // version → ISO date; also 'created', 'modified'
|
|
64
|
+
versions: Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Cache ─────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
async function ensureDir(): Promise<void> {
|
|
70
|
+
try { await mkdir(CACHE_DIR, { recursive: true }); } catch { /* exists */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cacheKey(pkg: string): string {
|
|
74
|
+
return pkg.replace(/\//g, '__').replace(/@/g, '_at_');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readNpmCache(pkg: string): Promise<{ result: SupplyChainResult; expiresAt: number } | null> {
|
|
78
|
+
try {
|
|
79
|
+
const raw = await readFile(join(CACHE_DIR, `${cacheKey(pkg)}.json`), 'utf-8');
|
|
80
|
+
return JSON.parse(raw) as { result: SupplyChainResult; expiresAt: number };
|
|
81
|
+
} catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function writeNpmCache(pkg: string, result: SupplyChainResult): Promise<void> {
|
|
85
|
+
await ensureDir();
|
|
86
|
+
try {
|
|
87
|
+
await writeFile(
|
|
88
|
+
join(CACHE_DIR, `${cacheKey(pkg)}.json`),
|
|
89
|
+
JSON.stringify({ result, expiresAt: Date.now() + CACHE_TTL_MS }, null, 2),
|
|
90
|
+
);
|
|
91
|
+
} catch { /* non-fatal */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Fetch ─────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
async function fetchNpmMeta(pkg: string): Promise<NpmMeta | null> {
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(`${REGISTRY}/${encodeURIComponent(pkg)}`, {
|
|
101
|
+
headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) return null;
|
|
105
|
+
return await res.json() as NpmMeta;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Risk analysis ─────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function daysBetween(a: string, b: string = new Date().toISOString()): number {
|
|
116
|
+
return Math.floor((new Date(b).getTime() - new Date(a).getTime()) / (1000 * 60 * 60 * 24));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function analyzeRisk(meta: NpmMeta): { flags: SupplyChainFlag[]; score: number } {
|
|
120
|
+
const flags: SupplyChainFlag[] = [];
|
|
121
|
+
let score = 0;
|
|
122
|
+
|
|
123
|
+
const maintainerCount = meta.maintainers?.length ?? 0;
|
|
124
|
+
const versions = Object.keys(meta.versions ?? {});
|
|
125
|
+
const timeEntries = Object.entries(meta.time ?? {})
|
|
126
|
+
.filter(([k]) => !['created', 'modified'].includes(k))
|
|
127
|
+
.sort(([, a], [, b]) => new Date(a).getTime() - new Date(b).getTime());
|
|
128
|
+
|
|
129
|
+
const firstPublished = meta.time['created'] ?? timeEntries[0]?.[1] ?? '';
|
|
130
|
+
const lastPublished = meta.time['modified'] ?? timeEntries[timeEntries.length - 1]?.[1] ?? '';
|
|
131
|
+
const daysOld = firstPublished ? daysBetween(firstPublished) : 0;
|
|
132
|
+
const daysSinceUpdate = lastPublished ? daysBetween(lastPublished) : 0;
|
|
133
|
+
|
|
134
|
+
// ① Single maintainer
|
|
135
|
+
if (maintainerCount === 1) {
|
|
136
|
+
const pts = 25;
|
|
137
|
+
score += pts;
|
|
138
|
+
flags.push({
|
|
139
|
+
code: 'SINGLE_MAINTAINER',
|
|
140
|
+
title: 'Single maintainer',
|
|
141
|
+
description: `This package has only 1 maintainer. A compromised account means full control over all releases.`,
|
|
142
|
+
riskPoints: pts,
|
|
143
|
+
});
|
|
144
|
+
} else if (maintainerCount === 0) {
|
|
145
|
+
const pts = 35;
|
|
146
|
+
score += pts;
|
|
147
|
+
flags.push({
|
|
148
|
+
code: 'NO_MAINTAINER',
|
|
149
|
+
title: 'No maintainers listed',
|
|
150
|
+
description: 'No maintainers found in registry metadata — package may be abandoned or orphaned.',
|
|
151
|
+
riskPoints: pts,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ② Recently created (< 30 days old) — typosquatting / dependency confusion window
|
|
156
|
+
if (daysOld < 30 && daysOld > 0) {
|
|
157
|
+
const pts = 30;
|
|
158
|
+
score += pts;
|
|
159
|
+
flags.push({
|
|
160
|
+
code: 'NEWLY_PUBLISHED',
|
|
161
|
+
title: `Published ${daysOld} day${daysOld === 1 ? '' : 's'} ago`,
|
|
162
|
+
description: 'Very new packages are high-risk for typosquatting and dependency confusion attacks. Verify the name carefully.',
|
|
163
|
+
riskPoints: pts,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ③ Abandoned — last update > 2 years
|
|
168
|
+
if (daysSinceUpdate > 730 && versions.length > 0) {
|
|
169
|
+
const years = (daysSinceUpdate / 365).toFixed(1);
|
|
170
|
+
const pts = 20;
|
|
171
|
+
score += pts;
|
|
172
|
+
flags.push({
|
|
173
|
+
code: 'ABANDONED',
|
|
174
|
+
title: `Not updated in ${years} years`,
|
|
175
|
+
description: 'Abandoned packages accumulate unpatched CVEs and may not work with current Node.js versions.',
|
|
176
|
+
riskPoints: pts,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ④ Rapid version publishing — many versions in a short window (injection pattern)
|
|
181
|
+
const recentVersions = timeEntries.filter(([, ts]) => daysBetween(ts) < 7);
|
|
182
|
+
if (recentVersions.length >= 5) {
|
|
183
|
+
const pts = 35;
|
|
184
|
+
score += pts;
|
|
185
|
+
flags.push({
|
|
186
|
+
code: 'RAPID_VERSIONS',
|
|
187
|
+
title: `${recentVersions.length} versions published in the last 7 days`,
|
|
188
|
+
description: 'Rapid version publishing is a known pattern for malware injection — attackers publish many versions to slip through automated scanners.',
|
|
189
|
+
riskPoints: pts,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ⑤ Recent maintainer churn — if many versions published very recently from fresh pkg
|
|
194
|
+
const last30DayVersions = timeEntries.filter(([, ts]) => daysBetween(ts) < 30).length;
|
|
195
|
+
if (daysOld < 180 && last30DayVersions > 10) {
|
|
196
|
+
const pts = 20;
|
|
197
|
+
score += pts;
|
|
198
|
+
flags.push({
|
|
199
|
+
code: 'OWNERSHIP_CHURN',
|
|
200
|
+
title: 'High version churn on a new package',
|
|
201
|
+
description: `${last30DayVersions} versions in the last 30 days on a package less than 6 months old — possible account takeover or automated malware distribution.`,
|
|
202
|
+
riskPoints: pts,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ⑥ Very few total versions for a package > 1 year old (abandoned, low quality)
|
|
207
|
+
if (versions.length <= 2 && daysOld > 365) {
|
|
208
|
+
const pts = 10;
|
|
209
|
+
score += pts;
|
|
210
|
+
flags.push({
|
|
211
|
+
code: 'LOW_ACTIVITY',
|
|
212
|
+
title: 'Very few versions for an old package',
|
|
213
|
+
description: 'Package has barely been maintained. May have unaddressed bugs or security issues.',
|
|
214
|
+
riskPoints: pts,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { flags, score: Math.min(100, score) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function scoreToLevel(score: number): SupplyChainRiskLevel {
|
|
222
|
+
if (score >= 80) return 'critical';
|
|
223
|
+
if (score >= 60) return 'high';
|
|
224
|
+
if (score >= 35) return 'medium';
|
|
225
|
+
if (score >= 15) return 'low';
|
|
226
|
+
return 'safe';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Analyzes a package for supply chain risk signals using the npm registry.
|
|
233
|
+
*
|
|
234
|
+
* Does NOT check individual MFA status (not in npm public API).
|
|
235
|
+
* DOES check: maintainer count, age, version velocity, abandonment.
|
|
236
|
+
*/
|
|
237
|
+
export async function checkSupplyChain(pkg: string): Promise<SupplyChainResult> {
|
|
238
|
+
const cached = await readNpmCache(pkg);
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
|
|
241
|
+
if (cached && now < cached.expiresAt) {
|
|
242
|
+
return { ...cached.result, cached: true, stale: false };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const meta = await fetchNpmMeta(pkg);
|
|
246
|
+
|
|
247
|
+
if (!meta) {
|
|
248
|
+
if (cached) return { ...cached.result, cached: true, stale: true };
|
|
249
|
+
return {
|
|
250
|
+
package: pkg, riskScore: 0, riskLevel: 'safe', flags: [],
|
|
251
|
+
maintainers: [], latestVersion: 'unknown',
|
|
252
|
+
firstPublished: '', lastPublished: '', totalVersions: 0,
|
|
253
|
+
cached: false, stale: false, checkedAt: now,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const { flags, score } = analyzeRisk(meta);
|
|
258
|
+
const versions = Object.keys(meta.versions ?? {});
|
|
259
|
+
const timeEntries = Object.entries(meta.time ?? {})
|
|
260
|
+
.filter(([k]) => !['created', 'modified'].includes(k))
|
|
261
|
+
.sort(([, a], [, b]) => new Date(a).getTime() - new Date(b).getTime());
|
|
262
|
+
|
|
263
|
+
const result: SupplyChainResult = {
|
|
264
|
+
package: meta.name ?? pkg,
|
|
265
|
+
riskScore: score,
|
|
266
|
+
riskLevel: scoreToLevel(score),
|
|
267
|
+
flags,
|
|
268
|
+
maintainers: (meta.maintainers ?? []).map((m) => m.name),
|
|
269
|
+
latestVersion: meta['dist-tags']?.latest ?? versions[versions.length - 1] ?? 'unknown',
|
|
270
|
+
firstPublished: meta.time['created'] ?? timeEntries[0]?.[1] ?? '',
|
|
271
|
+
lastPublished: meta.time['modified'] ?? timeEntries[timeEntries.length - 1]?.[1] ?? '',
|
|
272
|
+
totalVersions: versions.length,
|
|
273
|
+
cached: false,
|
|
274
|
+
stale: false,
|
|
275
|
+
checkedAt: now,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
await writeNpmCache(pkg, result);
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Checks supply chain risk for all packages in a dependency map.
|
|
284
|
+
* Returns only packages with riskLevel >= 'medium'.
|
|
285
|
+
*/
|
|
286
|
+
export async function auditSupplyChain(
|
|
287
|
+
deps: Record<string, string>,
|
|
288
|
+
): Promise<Map<string, SupplyChainResult>> {
|
|
289
|
+
const results = new Map<string, SupplyChainResult>();
|
|
290
|
+
const packages = Object.keys(deps);
|
|
291
|
+
const BATCH = 8;
|
|
292
|
+
|
|
293
|
+
for (let i = 0; i < packages.length; i += BATCH) {
|
|
294
|
+
const batch = packages.slice(i, i + BATCH);
|
|
295
|
+
const settled = await Promise.allSettled(
|
|
296
|
+
batch.map(async (pkg) => {
|
|
297
|
+
const result = await checkSupplyChain(pkg);
|
|
298
|
+
return [pkg, result] as [string, SupplyChainResult];
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
for (const s of settled) {
|
|
302
|
+
if (s.status === 'fulfilled' && s.value[1].riskLevel !== 'safe') {
|
|
303
|
+
results.set(s.value[0], s.value[1]);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return results;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Note about MFA status — shown in audit output for transparency */
|
|
312
|
+
export const MFA_NOTE =
|
|
313
|
+
'npm requires MFA for maintainers of packages with >500 weekly downloads as of 2022, ' +
|
|
314
|
+
'but individual MFA status is not publicly exposed by the registry API. ' +
|
|
315
|
+
'Use `npm access list collaborators {package}` with appropriate permissions ' +
|
|
316
|
+
'to view maintainer details for your own packages.';
|