@nexus_js/audit 0.7.0 → 0.7.2
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/dist/engine.d.ts +62 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +261 -0
- package/dist/engine.js.map +1 -0
- package/{src/index.ts → dist/index.d.ts} +5 -27
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/override.d.ts +80 -0
- package/dist/override.d.ts.map +1 -0
- package/dist/override.js +127 -0
- package/dist/override.js.map +1 -0
- package/dist/scanner.d.ts +53 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +115 -0
- package/dist/scanner.js.map +1 -0
- package/dist/supply-chain.d.ts +59 -0
- package/dist/supply-chain.d.ts.map +1 -0
- package/dist/supply-chain.js +257 -0
- package/dist/supply-chain.js.map +1 -0
- package/package.json +17 -4
- package/src/engine.ts +0 -322
- package/src/override.ts +0 -168
- package/src/scanner.ts +0 -169
- package/src/supply-chain.ts +0 -316
package/src/supply-chain.ts
DELETED
|
@@ -1,316 +0,0 @@
|
|
|
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.';
|