ship-safe 6.1.1 → 6.2.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.
Files changed (47) hide show
  1. package/README.md +735 -641
  2. package/cli/agents/api-fuzzer.js +345 -345
  3. package/cli/agents/auth-bypass-agent.js +348 -348
  4. package/cli/agents/base-agent.js +272 -272
  5. package/cli/agents/cicd-scanner.js +236 -201
  6. package/cli/agents/config-auditor.js +521 -521
  7. package/cli/agents/deep-analyzer.js +6 -2
  8. package/cli/agents/git-history-scanner.js +170 -170
  9. package/cli/agents/html-reporter.js +568 -568
  10. package/cli/agents/index.js +84 -84
  11. package/cli/agents/injection-tester.js +500 -500
  12. package/cli/agents/llm-redteam.js +251 -251
  13. package/cli/agents/mobile-scanner.js +231 -231
  14. package/cli/agents/orchestrator.js +322 -322
  15. package/cli/agents/pii-compliance-agent.js +301 -301
  16. package/cli/agents/scoring-engine.js +248 -248
  17. package/cli/agents/supabase-rls-agent.js +154 -154
  18. package/cli/agents/supply-chain-agent.js +650 -507
  19. package/cli/bin/ship-safe.js +452 -426
  20. package/cli/commands/agent.js +608 -608
  21. package/cli/commands/audit.js +986 -980
  22. package/cli/commands/baseline.js +193 -193
  23. package/cli/commands/ci.js +342 -342
  24. package/cli/commands/deps.js +516 -516
  25. package/cli/commands/doctor.js +159 -159
  26. package/cli/commands/fix.js +218 -218
  27. package/cli/commands/hooks.js +268 -0
  28. package/cli/commands/init.js +407 -407
  29. package/cli/commands/mcp.js +304 -304
  30. package/cli/commands/red-team.js +7 -1
  31. package/cli/commands/remediate.js +798 -798
  32. package/cli/commands/rotate.js +571 -571
  33. package/cli/commands/scan.js +569 -569
  34. package/cli/commands/score.js +449 -449
  35. package/cli/commands/watch.js +281 -281
  36. package/cli/hooks/patterns.js +313 -0
  37. package/cli/hooks/post-tool-use.js +140 -0
  38. package/cli/hooks/pre-tool-use.js +186 -0
  39. package/cli/index.js +73 -69
  40. package/cli/providers/llm-provider.js +397 -287
  41. package/cli/utils/autofix-rules.js +74 -74
  42. package/cli/utils/cache-manager.js +311 -311
  43. package/cli/utils/output.js +230 -230
  44. package/cli/utils/patterns.js +1121 -1121
  45. package/cli/utils/pdf-generator.js +94 -94
  46. package/package.json +69 -69
  47. package/configs/supabase/rls-templates.sql +0 -242
@@ -1,507 +1,650 @@
1
- /**
2
- * SupplyChainAudit Agent
3
- * =======================
4
- *
5
- * Comprehensive supply chain security analysis.
6
- * Goes beyond npm audit: checks for dependency confusion,
7
- * typosquatting, malicious install scripts, lockfile integrity,
8
- * EPSS scoring, and KEV flagging.
9
- */
10
-
11
- import fs from 'fs';
12
- import path from 'path';
13
- import { BaseAgent, createFinding } from './base-agent.js';
14
-
15
- // Common packages that are often typosquatted
16
- const POPULAR_PACKAGES = [
17
- 'lodash', 'express', 'react', 'axios', 'moment', 'request',
18
- 'chalk', 'commander', 'debug', 'uuid', 'dotenv', 'cors',
19
- 'body-parser', 'jsonwebtoken', 'bcrypt', 'mongoose', 'sequelize',
20
- 'webpack', 'babel', 'eslint', 'prettier', 'typescript',
21
- 'next', 'nuxt', 'svelte', 'vue', 'angular',
22
- ];
23
-
24
- // Well-known packages that happen to be close to other popular names
25
- // (not typosquats — verified legitimate packages)
26
- const KNOWN_SAFE = new Set([
27
- 'ora', 'got', 'ink', 'yup', 'joi', 'ava', 'tap', 'npm', 'nwb',
28
- 'pug', 'koa', 'hap', 'ejs', 'csv', 'ws', 'pg', 'ms',
29
- ]);
30
-
31
- // Known malicious package name patterns
32
- const SUSPICIOUS_NAME_PATTERNS = [
33
- /^@[^/]+\/[^/]+-[0-9]+$/, // @scope/package-123 (random suffix)
34
- /^[a-z]+-[a-z]+-[a-z]+-[a-z]+$/, // overly-generic multi-word names
35
- ];
36
-
37
- export class SupplyChainAudit extends BaseAgent {
38
- constructor() {
39
- super('SupplyChainAudit', 'Comprehensive supply chain security audit', 'supply-chain');
40
- }
41
-
42
- async analyze(context) {
43
- const { rootPath } = context;
44
- const findings = [];
45
-
46
- // ── 1. Check package.json ─────────────────────────────────────────────────
47
- const pkgPath = path.join(rootPath, 'package.json');
48
- if (fs.existsSync(pkgPath)) {
49
- try {
50
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
51
- const allDeps = {
52
- ...(pkg.dependencies || {}),
53
- ...(pkg.devDependencies || {}),
54
- ...(pkg.optionalDependencies || {}),
55
- };
56
-
57
- // ── Typosquatting detection ───────────────────────────────────────────
58
- for (const depName of Object.keys(allDeps)) {
59
- if (KNOWN_SAFE.has(depName)) continue;
60
- for (const popular of POPULAR_PACKAGES) {
61
- const distance = this.levenshtein(depName, popular);
62
- if (distance > 0 && distance <= 2 && depName !== popular) {
63
- findings.push(createFinding({
64
- file: pkgPath,
65
- line: 0,
66
- severity: 'high',
67
- category: 'supply-chain',
68
- rule: 'TYPOSQUAT_SUSPECT',
69
- title: `Possible Typosquat: "${depName}" (similar to "${popular}")`,
70
- description: `Package "${depName}" is ${distance} character(s) away from popular package "${popular}". This could be a typosquatting attempt.`,
71
- matched: depName,
72
- fix: `Verify this is the intended package. Did you mean "${popular}"?`,
73
- }));
74
- }
75
- }
76
- }
77
-
78
- // ── Deprecated/suspicious version pins ───────────────────────────────
79
- for (const [name, version] of Object.entries(allDeps)) {
80
- if (typeof version === 'string' && version.startsWith('git+')) {
81
- findings.push(createFinding({
82
- file: pkgPath,
83
- line: 0,
84
- severity: 'high',
85
- category: 'supply-chain',
86
- rule: 'GIT_DEPENDENCY',
87
- title: `Git Dependency: ${name}`,
88
- description: `"${name}" is installed from a git URL. Git dependencies bypass registry integrity checks.`,
89
- matched: `${name}: ${version}`,
90
- fix: 'Pin to a specific commit hash or use a published npm package version',
91
- }));
92
- }
93
-
94
- if (typeof version === 'string' && version.startsWith('http')) {
95
- findings.push(createFinding({
96
- file: pkgPath,
97
- line: 0,
98
- severity: 'critical',
99
- category: 'supply-chain',
100
- rule: 'URL_DEPENDENCY',
101
- title: `URL Dependency: ${name}`,
102
- description: `"${name}" is installed from a URL. This bypasses npm registry and integrity checks.`,
103
- matched: `${name}: ${version}`,
104
- fix: 'Publish the package to npm or use a private registry',
105
- }));
106
- }
107
-
108
- if (typeof version === 'string' && version === '*') {
109
- findings.push(createFinding({
110
- file: pkgPath,
111
- line: 0,
112
- severity: 'high',
113
- category: 'supply-chain',
114
- rule: 'WILDCARD_VERSION',
115
- title: `Wildcard Version: ${name}`,
116
- description: `"${name}" uses "*" version which accepts any version including malicious updates.`,
117
- matched: `${name}: *`,
118
- fix: 'Pin to a specific version or use a caret range: "^x.y.z"',
119
- }));
120
- }
121
- }
122
-
123
- // ── Install scripts ──────────────────────────────────────────────────
124
- if (pkg.scripts) {
125
- const dangerousScripts = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall'];
126
- for (const script of dangerousScripts) {
127
- if (pkg.scripts[script]) {
128
- const cmd = pkg.scripts[script];
129
- const suspicious = /curl|wget|bash|sh\s|powershell|eval|base64|nc\s|ncat/i.test(cmd);
130
- if (suspicious) {
131
- findings.push(createFinding({
132
- file: pkgPath,
133
- line: 0,
134
- severity: 'critical',
135
- category: 'supply-chain',
136
- rule: 'SUSPICIOUS_INSTALL_SCRIPT',
137
- title: `Suspicious ${script} Script`,
138
- description: `The ${script} script contains potentially dangerous commands: ${cmd.slice(0, 100)}`,
139
- matched: cmd,
140
- fix: 'Review and remove suspicious install scripts',
141
- }));
142
- }
143
- }
144
- }
145
- }
146
-
147
- } catch { /* skip parse errors */ }
148
- }
149
-
150
- // ── 2. Dependency confusion detection ─────────────────────────────────────
151
- if (fs.existsSync(pkgPath)) {
152
- try {
153
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
154
- const allDeps = {
155
- ...(pkg.dependencies || {}),
156
- ...(pkg.devDependencies || {}),
157
- };
158
-
159
- // Check for scoped packages without registry pinning
160
- const scopedPkgs = Object.keys(allDeps).filter(n => n.startsWith('@'));
161
- if (scopedPkgs.length > 0) {
162
- const npmrcPath = path.join(rootPath, '.npmrc');
163
- const yarnrcPath = path.join(rootPath, '.yarnrc');
164
- const yarnrcYmlPath = path.join(rootPath, '.yarnrc.yml');
165
- const hasRegistryConfig = [npmrcPath, yarnrcPath, yarnrcYmlPath].some(p => {
166
- if (!fs.existsSync(p)) return false;
167
- const content = this.readFile(p) || '';
168
- // Check if any scope is pinned to a registry
169
- return /@[^:]+:registry/i.test(content) || /npmRegistryServer/i.test(content);
170
- });
171
-
172
- // Extract unique scopes
173
- const scopes = [...new Set(scopedPkgs.map(n => n.split('/')[0]))];
174
- // Check if this looks like an internal scope (not well-known public ones)
175
- const publicScopes = new Set([
176
- '@types', '@babel', '@eslint', '@jest', '@testing-library',
177
- '@react-native', '@angular', '@vue', '@nuxt', '@next',
178
- '@emotion', '@mui', '@radix-ui', '@tanstack', '@trpc',
179
- '@prisma', '@supabase', '@aws-sdk', '@azure', '@google-cloud',
180
- '@octokit', '@sentry', '@stripe', '@anthropic-ai', '@openai',
181
- ]);
182
- const internalScopes = scopes.filter(s => !publicScopes.has(s));
183
-
184
- if (internalScopes.length > 0 && !hasRegistryConfig) {
185
- findings.push(createFinding({
186
- file: pkgPath,
187
- line: 0,
188
- severity: 'high',
189
- category: 'supply-chain',
190
- rule: 'DEPCONF_NO_SCOPE_REGISTRY',
191
- title: `Scoped Packages Without Registry Pin: ${internalScopes.join(', ')}`,
192
- description: `Scoped packages (${internalScopes.join(', ')}) found without a .npmrc pinning the scope to a private registry. An attacker could claim the scope on the public npm registry.`,
193
- matched: internalScopes.join(', '),
194
- confidence: 'medium',
195
- fix: 'Add to .npmrc: @yourscope:registry=https://your-private-registry.com/',
196
- }));
197
- }
198
- }
199
-
200
- // Check for suspicious install scripts in dependencies
201
- const nodeModulesPath = path.join(rootPath, 'node_modules');
202
- if (fs.existsSync(nodeModulesPath)) {
203
- for (const depName of Object.keys(allDeps).slice(0, 50)) {
204
- const depPkgPath = path.join(nodeModulesPath, depName, 'package.json');
205
- if (!fs.existsSync(depPkgPath)) continue;
206
- try {
207
- const depPkg = JSON.parse(fs.readFileSync(depPkgPath, 'utf-8'));
208
- const scripts = depPkg.scripts || {};
209
- for (const hook of ['preinstall', 'install', 'postinstall']) {
210
- const cmd = scripts[hook];
211
- if (!cmd) continue;
212
- if (/(?:curl|wget|powershell|base64\s|eval\s|nc\s|ncat|\.sh\b)/i.test(cmd)) {
213
- findings.push(createFinding({
214
- file: depPkgPath,
215
- line: 0,
216
- severity: 'critical',
217
- category: 'supply-chain',
218
- rule: 'DEPCONF_SUSPICIOUS_INSTALL_SCRIPT',
219
- title: `Suspicious ${hook} in ${depName}`,
220
- description: `Dependency "${depName}" has a suspicious ${hook} script: ${cmd.slice(0, 120)}`,
221
- matched: cmd.slice(0, 200),
222
- fix: 'Review the script. If untrusted, remove the dependency or use npm with --ignore-scripts',
223
- }));
224
- }
225
- }
226
- } catch { /* skip */ }
227
- }
228
- }
229
- } catch { /* skip */ }
230
- }
231
-
232
- // ── 3. Check lockfile integrity ── ───────────────────────────────────────────
233
- const lockFiles = [
234
- { file: 'package-lock.json', manager: 'npm' },
235
- { file: 'yarn.lock', manager: 'yarn' },
236
- { file: 'pnpm-lock.yaml', manager: 'pnpm' },
237
- { file: 'bun.lockb', manager: 'bun' },
238
- ];
239
-
240
- const hasPackageJson = fs.existsSync(pkgPath);
241
- let hasLockfile = false;
242
-
243
- for (const { file, manager } of lockFiles) {
244
- if (fs.existsSync(path.join(rootPath, file))) {
245
- hasLockfile = true;
246
- }
247
- }
248
-
249
- if (hasPackageJson && !hasLockfile) {
250
- findings.push(createFinding({
251
- file: pkgPath,
252
- line: 0,
253
- severity: 'high',
254
- category: 'supply-chain',
255
- rule: 'MISSING_LOCKFILE',
256
- title: 'No Lock File Found',
257
- description: 'No package-lock.json, yarn.lock, or pnpm-lock.yaml found. Without a lockfile, installs are non-deterministic and vulnerable to dependency confusion.',
258
- matched: 'package.json without lockfile',
259
- fix: 'Run npm install, yarn install, or pnpm install to generate a lockfile, then commit it',
260
- }));
261
- }
262
-
263
- // ── 4. Check .npmrc for security settings ─────────────────────────────────
264
- const npmrcPath = path.join(rootPath, '.npmrc');
265
- if (fs.existsSync(npmrcPath)) {
266
- const content = this.readFile(npmrcPath) || '';
267
- if (content.includes('ignore-scripts=true')) {
268
- // Good scripts are disabled
269
- }
270
- if (content.includes('registry=') && !content.includes('registry=https://registry.npmjs.org')) {
271
- findings.push(createFinding({
272
- file: npmrcPath,
273
- line: 0,
274
- severity: 'medium',
275
- category: 'supply-chain',
276
- rule: 'CUSTOM_REGISTRY',
277
- title: 'Custom NPM Registry Configured',
278
- description: 'A custom npm registry is configured. Verify it is trusted and uses HTTPS.',
279
- matched: content.match(/registry=.*/)?.[0] || '',
280
- confidence: 'medium',
281
- fix: 'Verify the registry URL is trusted and uses HTTPS',
282
- }));
283
- }
284
- }
285
-
286
- // ── 5. Package behavioral signals (Socket-style) ─────────────────────────
287
- if (fs.existsSync(pkgPath)) {
288
- try {
289
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
290
- const allDeps = {
291
- ...(pkg.dependencies || {}),
292
- ...(pkg.devDependencies || {}),
293
- };
294
-
295
- // Scan node_modules for behavioral red flags
296
- const nodeModulesPath = path.join(rootPath, 'node_modules');
297
- if (fs.existsSync(nodeModulesPath)) {
298
- for (const depName of Object.keys(allDeps).slice(0, 50)) {
299
- const depDir = path.join(nodeModulesPath, depName);
300
- const depPkgPath = path.join(depDir, 'package.json');
301
- if (!fs.existsSync(depPkgPath)) continue;
302
- try {
303
- const depPkg = JSON.parse(fs.readFileSync(depPkgPath, 'utf-8'));
304
-
305
- // Check for postinstall scripts with network/eval calls
306
- const scripts = depPkg.scripts || {};
307
- for (const hook of ['preinstall', 'install', 'postinstall']) {
308
- const cmd = scripts[hook];
309
- if (!cmd) continue;
310
- if (/node\s+-e|node\s+--eval/.test(cmd)) {
311
- findings.push(createFinding({
312
- file: depPkgPath,
313
- line: 0,
314
- severity: 'high',
315
- category: 'supply-chain',
316
- rule: 'BEHAVIORAL_INLINE_EVAL',
317
- title: `Inline Code Execution in ${hook}: ${depName}`,
318
- description: `Dependency "${depName}" runs inline Node.js code during ${hook}. This is a common pattern in malicious packages.`,
319
- matched: cmd.slice(0, 200),
320
- fix: 'Review the inline code. Consider using --ignore-scripts or removing the dependency.',
321
- }));
322
- }
323
- }
324
- } catch { /* skip */ }
325
- }
326
- }
327
-
328
- // Detect obfuscated code patterns in dependencies
329
- const codeFiles = (context.files || []).filter(f =>
330
- f.includes('node_modules') &&
331
- !f.includes('node_modules/.cache') &&
332
- path.extname(f).toLowerCase() === '.js' &&
333
- !path.basename(f).endsWith('.min.js')
334
- ).slice(0, 30); // Sample up to 30 files
335
-
336
- for (const file of codeFiles) {
337
- const content = this.readFile(file);
338
- if (!content || content.length < 100) continue;
339
-
340
- // Excessive hex encoding
341
- const hexMatches = (content.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
342
- if (hexMatches > 20) {
343
- findings.push(createFinding({
344
- file,
345
- line: 1,
346
- severity: 'high',
347
- category: 'supply-chain',
348
- rule: 'BEHAVIORAL_HEX_OBFUSCATION',
349
- title: 'Obfuscated Code: Excessive Hex Encoding',
350
- description: `File contains ${hexMatches} hex-encoded sequences. Common in malicious packages trying to hide payload.`,
351
- matched: `${hexMatches} hex sequences detected`,
352
- fix: 'Inspect the deobfuscated code. Consider removing this dependency.',
353
- }));
354
- }
355
-
356
- // Excessive String.fromCharCode
357
- const charCodeMatches = (content.match(/String\.fromCharCode/g) || []).length;
358
- if (charCodeMatches > 5) {
359
- findings.push(createFinding({
360
- file,
361
- line: 1,
362
- severity: 'high',
363
- category: 'supply-chain',
364
- rule: 'BEHAVIORAL_CHARCODE_OBFUSCATION',
365
- title: 'Obfuscated Code: Excessive String.fromCharCode',
366
- description: `File contains ${charCodeMatches} String.fromCharCode calls. Common obfuscation technique in malicious packages.`,
367
- matched: `${charCodeMatches} String.fromCharCode calls`,
368
- fix: 'Inspect the deobfuscated code. Consider removing this dependency.',
369
- }));
370
- }
371
-
372
- // Base64 decode chains
373
- const base64Matches = (content.match(/Buffer\.from\s*\([^,]+,\s*['"]base64['"]\)/g) || []).length;
374
- if (base64Matches > 3) {
375
- findings.push(createFinding({
376
- file,
377
- line: 1,
378
- severity: 'medium',
379
- category: 'supply-chain',
380
- rule: 'BEHAVIORAL_BASE64_DECODE',
381
- title: 'Suspicious: Multiple Base64 Decode Operations',
382
- description: `File contains ${base64Matches} base64 decode operations. May indicate hidden payload.`,
383
- matched: `${base64Matches} base64 decode operations`,
384
- confidence: 'medium',
385
- fix: 'Review what data is being decoded. Legitimate use is possible but warrants inspection.',
386
- }));
387
- }
388
- }
389
-
390
- // Detect unused dependencies (in package.json but never imported)
391
- const projectFiles = (context.files || []).filter(f =>
392
- !f.includes('node_modules') &&
393
- ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(path.extname(f).toLowerCase())
394
- );
395
-
396
- if (projectFiles.length > 0 && projectFiles.length < 500) {
397
- const allImports = new Set();
398
- for (const file of projectFiles) {
399
- const content = this.readFile(file);
400
- if (!content) continue;
401
- // Capture import/require module names
402
- const importMatches = content.matchAll(/(?:from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g);
403
- for (const m of importMatches) {
404
- const mod = (m[1] || m[2] || '').split('/')[0]; // Get package name (not subpath)
405
- if (mod && !mod.startsWith('.')) allImports.add(mod);
406
- // Handle scoped packages
407
- const fullMod = m[1] || m[2] || '';
408
- if (fullMod.startsWith('@')) {
409
- const scopedPkg = fullMod.split('/').slice(0, 2).join('/');
410
- allImports.add(scopedPkg);
411
- }
412
- }
413
- }
414
-
415
- const prodDeps = Object.keys(pkg.dependencies || {});
416
- for (const dep of prodDeps) {
417
- if (!allImports.has(dep) && !dep.startsWith('@types/')) {
418
- findings.push(createFinding({
419
- file: pkgPath,
420
- line: 0,
421
- severity: 'low',
422
- category: 'supply-chain',
423
- rule: 'UNUSED_DEPENDENCY',
424
- title: `Unused Dependency: ${dep}`,
425
- description: `"${dep}" is in dependencies but never imported in project code. Unused dependencies increase attack surface.`,
426
- matched: dep,
427
- confidence: 'low',
428
- fix: `Remove if unused: npm uninstall ${dep}`,
429
- }));
430
- }
431
- }
432
- }
433
-
434
- } catch { /* skip */ }
435
- }
436
-
437
- // ── 6. Check Python requirements ──────────────────────────────────────────
438
- const reqPath = path.join(rootPath, 'requirements.txt');
439
- if (fs.existsSync(reqPath)) {
440
- const content = this.readFile(reqPath) || '';
441
- const lines = content.split('\n');
442
- for (let i = 0; i < lines.length; i++) {
443
- const line = lines[i].trim();
444
- if (!line || line.startsWith('#')) continue;
445
-
446
- // Unpinned versions
447
- if (!line.includes('==') && !line.includes('>=') && !line.includes('~=') && !line.includes('@')) {
448
- findings.push(createFinding({
449
- file: reqPath,
450
- line: i + 1,
451
- severity: 'medium',
452
- category: 'supply-chain',
453
- rule: 'UNPINNED_PYTHON_DEP',
454
- title: `Unpinned Python Dependency: ${line}`,
455
- description: 'Python dependency without version pin. Pin to a specific version for reproducible builds.',
456
- matched: line,
457
- fix: `Pin version: ${line}==x.y.z`,
458
- }));
459
- }
460
-
461
- // Git/URL dependencies
462
- if (line.includes('git+') || line.startsWith('http')) {
463
- findings.push(createFinding({
464
- file: reqPath,
465
- line: i + 1,
466
- severity: 'high',
467
- category: 'supply-chain',
468
- rule: 'GIT_PYTHON_DEP',
469
- title: `Git/URL Python Dependency: ${line.slice(0, 60)}`,
470
- description: 'Installing from git/URL bypasses PyPI integrity checks.',
471
- matched: line,
472
- fix: 'Publish to PyPI or pin to a specific commit hash',
473
- }));
474
- }
475
- }
476
- }
477
-
478
- return findings;
479
- }
480
-
481
- /**
482
- * Simple Levenshtein distance for typosquatting detection.
483
- */
484
- levenshtein(a, b) {
485
- if (a.length === 0) return b.length;
486
- if (b.length === 0) return a.length;
487
-
488
- const matrix = [];
489
- for (let i = 0; i <= b.length; i++) matrix[i] = [i];
490
- for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
491
-
492
- for (let i = 1; i <= b.length; i++) {
493
- for (let j = 1; j <= a.length; j++) {
494
- const cost = b[i - 1] === a[j - 1] ? 0 : 1;
495
- matrix[i][j] = Math.min(
496
- matrix[i - 1][j] + 1,
497
- matrix[i][j - 1] + 1,
498
- matrix[i - 1][j - 1] + cost
499
- );
500
- }
501
- }
502
-
503
- return matrix[b.length][a.length];
504
- }
505
- }
506
-
507
- export default SupplyChainAudit;
1
+ /**
2
+ * SupplyChainAudit Agent
3
+ * =======================
4
+ *
5
+ * Comprehensive supply chain security analysis.
6
+ * Goes beyond npm audit: checks for dependency confusion,
7
+ * typosquatting, malicious install scripts, lockfile integrity,
8
+ * EPSS scoring, and KEV flagging.
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { BaseAgent, createFinding } from './base-agent.js';
14
+
15
+ // =============================================================================
16
+ // KNOWN-COMPROMISED PACKAGE IOC LIST
17
+ // Source: TeamPCP/CanisterWorm campaign (March 2026) + prior incidents
18
+ // Format: { name, badVersions: [exact], note }
19
+ // =============================================================================
20
+ const COMPROMISED_PACKAGES = [
21
+ {
22
+ name: 'litellm',
23
+ badVersions: ['1.82.7', '1.82.8'],
24
+ note: 'TeamPCP supply chain attack (Mar 24 2026). Multi-stage credential stealer targeting SSH keys, cloud tokens, and AI API keys.',
25
+ },
26
+ {
27
+ name: 'axios',
28
+ badVersions: ['1.8.2'],
29
+ note: 'TeamPCP/CanisterWorm campaign (Mar 31 2026). Malicious publish delivered a Remote Access Trojan with persistence.',
30
+ },
31
+ {
32
+ name: 'telnyx',
33
+ badVersions: ['2.1.5'],
34
+ note: 'TeamPCP campaign (Mar 27 2026). Compromised PyPI release exfiltrated credentials.',
35
+ },
36
+ ];
37
+
38
+ // Packages that have no reason to depend on ICP/Internet Computer blockchain
39
+ // but CanisterWorm injected @dfinity/agent as its decentralized C2 mechanism
40
+ const ICP_BLOCKCHAIN_PACKAGES = [
41
+ '@dfinity/agent',
42
+ '@dfinity/candid',
43
+ '@dfinity/principal',
44
+ 'ic-agent',
45
+ ];
46
+
47
+ // Common packages that are often typosquatted
48
+ const POPULAR_PACKAGES = [
49
+ 'lodash', 'express', 'react', 'axios', 'moment', 'request',
50
+ 'chalk', 'commander', 'debug', 'uuid', 'dotenv', 'cors',
51
+ 'body-parser', 'jsonwebtoken', 'bcrypt', 'mongoose', 'sequelize',
52
+ 'webpack', 'babel', 'eslint', 'prettier', 'typescript',
53
+ 'next', 'nuxt', 'svelte', 'vue', 'angular',
54
+ ];
55
+
56
+ // Well-known packages that happen to be close to other popular names
57
+ // (not typosquats verified legitimate packages)
58
+ const KNOWN_SAFE = new Set([
59
+ 'ora', 'got', 'ink', 'yup', 'joi', 'ava', 'tap', 'npm', 'nwb',
60
+ 'pug', 'koa', 'hap', 'ejs', 'csv', 'ws', 'pg', 'ms',
61
+ ]);
62
+
63
+ // Known malicious package name patterns
64
+ const SUSPICIOUS_NAME_PATTERNS = [
65
+ /^@[^/]+\/[^/]+-[0-9]+$/, // @scope/package-123 (random suffix)
66
+ /^[a-z]+-[a-z]+-[a-z]+-[a-z]+$/, // overly-generic multi-word names
67
+ ];
68
+
69
+ export class SupplyChainAudit extends BaseAgent {
70
+ constructor() {
71
+ super('SupplyChainAudit', 'Comprehensive supply chain security audit', 'supply-chain');
72
+ }
73
+
74
+ async analyze(context) {
75
+ const { rootPath } = context;
76
+ const findings = [];
77
+
78
+ // ── 1. Check package.json ─────────────────────────────────────────────────
79
+ const pkgPath = path.join(rootPath, 'package.json');
80
+ if (fs.existsSync(pkgPath)) {
81
+ try {
82
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
83
+ const allDeps = {
84
+ ...(pkg.dependencies || {}),
85
+ ...(pkg.devDependencies || {}),
86
+ ...(pkg.optionalDependencies || {}),
87
+ };
88
+
89
+ // ── Typosquatting detection ───────────────────────────────────────────
90
+ for (const depName of Object.keys(allDeps)) {
91
+ if (KNOWN_SAFE.has(depName)) continue;
92
+ for (const popular of POPULAR_PACKAGES) {
93
+ const distance = this.levenshtein(depName, popular);
94
+ if (distance > 0 && distance <= 2 && depName !== popular) {
95
+ findings.push(createFinding({
96
+ file: pkgPath,
97
+ line: 0,
98
+ severity: 'high',
99
+ category: 'supply-chain',
100
+ rule: 'TYPOSQUAT_SUSPECT',
101
+ title: `Possible Typosquat: "${depName}" (similar to "${popular}")`,
102
+ description: `Package "${depName}" is ${distance} character(s) away from popular package "${popular}". This could be a typosquatting attempt.`,
103
+ matched: depName,
104
+ fix: `Verify this is the intended package. Did you mean "${popular}"?`,
105
+ }));
106
+ }
107
+ }
108
+ }
109
+
110
+ // ── Deprecated/suspicious version pins ───────────────────────────────
111
+ for (const [name, version] of Object.entries(allDeps)) {
112
+ if (typeof version === 'string' && version.startsWith('git+')) {
113
+ findings.push(createFinding({
114
+ file: pkgPath,
115
+ line: 0,
116
+ severity: 'high',
117
+ category: 'supply-chain',
118
+ rule: 'GIT_DEPENDENCY',
119
+ title: `Git Dependency: ${name}`,
120
+ description: `"${name}" is installed from a git URL. Git dependencies bypass registry integrity checks.`,
121
+ matched: `${name}: ${version}`,
122
+ fix: 'Pin to a specific commit hash or use a published npm package version',
123
+ }));
124
+ }
125
+
126
+ if (typeof version === 'string' && version.startsWith('http')) {
127
+ findings.push(createFinding({
128
+ file: pkgPath,
129
+ line: 0,
130
+ severity: 'critical',
131
+ category: 'supply-chain',
132
+ rule: 'URL_DEPENDENCY',
133
+ title: `URL Dependency: ${name}`,
134
+ description: `"${name}" is installed from a URL. This bypasses npm registry and integrity checks.`,
135
+ matched: `${name}: ${version}`,
136
+ fix: 'Publish the package to npm or use a private registry',
137
+ }));
138
+ }
139
+
140
+ if (typeof version === 'string' && version === '*') {
141
+ findings.push(createFinding({
142
+ file: pkgPath,
143
+ line: 0,
144
+ severity: 'high',
145
+ category: 'supply-chain',
146
+ rule: 'WILDCARD_VERSION',
147
+ title: `Wildcard Version: ${name}`,
148
+ description: `"${name}" uses "*" version which accepts any version including malicious updates.`,
149
+ matched: `${name}: *`,
150
+ fix: 'Pin to a specific version or use a caret range: "^x.y.z"',
151
+ }));
152
+ }
153
+ }
154
+
155
+ // ── Install scripts ──────────────────────────────────────────────────
156
+ if (pkg.scripts) {
157
+ const dangerousScripts = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall'];
158
+ for (const script of dangerousScripts) {
159
+ if (pkg.scripts[script]) {
160
+ const cmd = pkg.scripts[script];
161
+ const suspicious = /curl|wget|bash|sh\s|powershell|eval|base64|nc\s|ncat/i.test(cmd);
162
+ if (suspicious) {
163
+ findings.push(createFinding({
164
+ file: pkgPath,
165
+ line: 0,
166
+ severity: 'critical',
167
+ category: 'supply-chain',
168
+ rule: 'SUSPICIOUS_INSTALL_SCRIPT',
169
+ title: `Suspicious ${script} Script`,
170
+ description: `The ${script} script contains potentially dangerous commands: ${cmd.slice(0, 100)}`,
171
+ matched: cmd,
172
+ fix: 'Review and remove suspicious install scripts',
173
+ }));
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ } catch { /* skip parse errors */ }
180
+ }
181
+
182
+ // ── 2. Dependency confusion detection ─────────────────────────────────────
183
+ if (fs.existsSync(pkgPath)) {
184
+ try {
185
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
186
+ const allDeps = {
187
+ ...(pkg.dependencies || {}),
188
+ ...(pkg.devDependencies || {}),
189
+ };
190
+
191
+ // Check for scoped packages without registry pinning
192
+ const scopedPkgs = Object.keys(allDeps).filter(n => n.startsWith('@'));
193
+ if (scopedPkgs.length > 0) {
194
+ const npmrcPath = path.join(rootPath, '.npmrc');
195
+ const yarnrcPath = path.join(rootPath, '.yarnrc');
196
+ const yarnrcYmlPath = path.join(rootPath, '.yarnrc.yml');
197
+ const hasRegistryConfig = [npmrcPath, yarnrcPath, yarnrcYmlPath].some(p => {
198
+ if (!fs.existsSync(p)) return false;
199
+ const content = this.readFile(p) || '';
200
+ // Check if any scope is pinned to a registry
201
+ return /@[^:]+:registry/i.test(content) || /npmRegistryServer/i.test(content);
202
+ });
203
+
204
+ // Extract unique scopes
205
+ const scopes = [...new Set(scopedPkgs.map(n => n.split('/')[0]))];
206
+ // Check if this looks like an internal scope (not well-known public ones)
207
+ const publicScopes = new Set([
208
+ '@types', '@babel', '@eslint', '@jest', '@testing-library',
209
+ '@react-native', '@angular', '@vue', '@nuxt', '@next',
210
+ '@emotion', '@mui', '@radix-ui', '@tanstack', '@trpc',
211
+ '@prisma', '@supabase', '@aws-sdk', '@azure', '@google-cloud',
212
+ '@octokit', '@sentry', '@stripe', '@anthropic-ai', '@openai',
213
+ ]);
214
+ const internalScopes = scopes.filter(s => !publicScopes.has(s));
215
+
216
+ if (internalScopes.length > 0 && !hasRegistryConfig) {
217
+ findings.push(createFinding({
218
+ file: pkgPath,
219
+ line: 0,
220
+ severity: 'high',
221
+ category: 'supply-chain',
222
+ rule: 'DEPCONF_NO_SCOPE_REGISTRY',
223
+ title: `Scoped Packages Without Registry Pin: ${internalScopes.join(', ')}`,
224
+ description: `Scoped packages (${internalScopes.join(', ')}) found without a .npmrc pinning the scope to a private registry. An attacker could claim the scope on the public npm registry.`,
225
+ matched: internalScopes.join(', '),
226
+ confidence: 'medium',
227
+ fix: 'Add to .npmrc: @yourscope:registry=https://your-private-registry.com/',
228
+ }));
229
+ }
230
+ }
231
+
232
+ // Check for suspicious install scripts in dependencies
233
+ const nodeModulesPath = path.join(rootPath, 'node_modules');
234
+ if (fs.existsSync(nodeModulesPath)) {
235
+ for (const depName of Object.keys(allDeps).slice(0, 50)) {
236
+ const depPkgPath = path.join(nodeModulesPath, depName, 'package.json');
237
+ if (!fs.existsSync(depPkgPath)) continue;
238
+ try {
239
+ const depPkg = JSON.parse(fs.readFileSync(depPkgPath, 'utf-8'));
240
+ const scripts = depPkg.scripts || {};
241
+ for (const hook of ['preinstall', 'install', 'postinstall']) {
242
+ const cmd = scripts[hook];
243
+ if (!cmd) continue;
244
+ if (/(?:curl|wget|powershell|base64\s|eval\s|nc\s|ncat|\.sh\b)/i.test(cmd)) {
245
+ findings.push(createFinding({
246
+ file: depPkgPath,
247
+ line: 0,
248
+ severity: 'critical',
249
+ category: 'supply-chain',
250
+ rule: 'DEPCONF_SUSPICIOUS_INSTALL_SCRIPT',
251
+ title: `Suspicious ${hook} in ${depName}`,
252
+ description: `Dependency "${depName}" has a suspicious ${hook} script: ${cmd.slice(0, 120)}`,
253
+ matched: cmd.slice(0, 200),
254
+ fix: 'Review the script. If untrusted, remove the dependency or use npm with --ignore-scripts',
255
+ }));
256
+ }
257
+ }
258
+ } catch { /* skip */ }
259
+ }
260
+ }
261
+ } catch { /* skip */ }
262
+ }
263
+
264
+ // ── 3. Check lockfile integrity ── ───────────────────────────────────────────
265
+ const lockFiles = [
266
+ { file: 'package-lock.json', manager: 'npm' },
267
+ { file: 'yarn.lock', manager: 'yarn' },
268
+ { file: 'pnpm-lock.yaml', manager: 'pnpm' },
269
+ { file: 'bun.lockb', manager: 'bun' },
270
+ ];
271
+
272
+ const hasPackageJson = fs.existsSync(pkgPath);
273
+ let hasLockfile = false;
274
+
275
+ for (const { file, manager } of lockFiles) {
276
+ if (fs.existsSync(path.join(rootPath, file))) {
277
+ hasLockfile = true;
278
+ }
279
+ }
280
+
281
+ if (hasPackageJson && !hasLockfile) {
282
+ findings.push(createFinding({
283
+ file: pkgPath,
284
+ line: 0,
285
+ severity: 'high',
286
+ category: 'supply-chain',
287
+ rule: 'MISSING_LOCKFILE',
288
+ title: 'No Lock File Found',
289
+ description: 'No package-lock.json, yarn.lock, or pnpm-lock.yaml found. Without a lockfile, installs are non-deterministic and vulnerable to dependency confusion.',
290
+ matched: 'package.json without lockfile',
291
+ fix: 'Run npm install, yarn install, or pnpm install to generate a lockfile, then commit it',
292
+ }));
293
+ }
294
+
295
+ // ── 4. Check .npmrc for security settings ─────────────────────────────────
296
+ const npmrcPath = path.join(rootPath, '.npmrc');
297
+ if (fs.existsSync(npmrcPath)) {
298
+ const content = this.readFile(npmrcPath) || '';
299
+ if (content.includes('ignore-scripts=true')) {
300
+ // Good scripts are disabled
301
+ }
302
+ if (content.includes('registry=') && !content.includes('registry=https://registry.npmjs.org')) {
303
+ findings.push(createFinding({
304
+ file: npmrcPath,
305
+ line: 0,
306
+ severity: 'medium',
307
+ category: 'supply-chain',
308
+ rule: 'CUSTOM_REGISTRY',
309
+ title: 'Custom NPM Registry Configured',
310
+ description: 'A custom npm registry is configured. Verify it is trusted and uses HTTPS.',
311
+ matched: content.match(/registry=.*/)?.[0] || '',
312
+ confidence: 'medium',
313
+ fix: 'Verify the registry URL is trusted and uses HTTPS',
314
+ }));
315
+ }
316
+ }
317
+
318
+ // ── 5. Package behavioral signals (Socket-style) ─────────────────────────
319
+ if (fs.existsSync(pkgPath)) {
320
+ try {
321
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
322
+ const allDeps = {
323
+ ...(pkg.dependencies || {}),
324
+ ...(pkg.devDependencies || {}),
325
+ };
326
+
327
+ // Scan node_modules for behavioral red flags
328
+ const nodeModulesPath = path.join(rootPath, 'node_modules');
329
+ if (fs.existsSync(nodeModulesPath)) {
330
+ for (const depName of Object.keys(allDeps).slice(0, 50)) {
331
+ const depDir = path.join(nodeModulesPath, depName);
332
+ const depPkgPath = path.join(depDir, 'package.json');
333
+ if (!fs.existsSync(depPkgPath)) continue;
334
+ try {
335
+ const depPkg = JSON.parse(fs.readFileSync(depPkgPath, 'utf-8'));
336
+
337
+ // Check for postinstall scripts with network/eval calls
338
+ const scripts = depPkg.scripts || {};
339
+ for (const hook of ['preinstall', 'install', 'postinstall']) {
340
+ const cmd = scripts[hook];
341
+ if (!cmd) continue;
342
+ if (/node\s+-e|node\s+--eval/.test(cmd)) {
343
+ findings.push(createFinding({
344
+ file: depPkgPath,
345
+ line: 0,
346
+ severity: 'high',
347
+ category: 'supply-chain',
348
+ rule: 'BEHAVIORAL_INLINE_EVAL',
349
+ title: `Inline Code Execution in ${hook}: ${depName}`,
350
+ description: `Dependency "${depName}" runs inline Node.js code during ${hook}. This is a common pattern in malicious packages.`,
351
+ matched: cmd.slice(0, 200),
352
+ fix: 'Review the inline code. Consider using --ignore-scripts or removing the dependency.',
353
+ }));
354
+ }
355
+ }
356
+ } catch { /* skip */ }
357
+ }
358
+ }
359
+
360
+ // Detect obfuscated code patterns in dependencies
361
+ const codeFiles = (context.files || []).filter(f =>
362
+ f.includes('node_modules') &&
363
+ !f.includes('node_modules/.cache') &&
364
+ path.extname(f).toLowerCase() === '.js' &&
365
+ !path.basename(f).endsWith('.min.js')
366
+ ).slice(0, 30); // Sample up to 30 files
367
+
368
+ for (const file of codeFiles) {
369
+ const content = this.readFile(file);
370
+ if (!content || content.length < 100) continue;
371
+
372
+ // Excessive hex encoding
373
+ const hexMatches = (content.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
374
+ if (hexMatches > 20) {
375
+ findings.push(createFinding({
376
+ file,
377
+ line: 1,
378
+ severity: 'high',
379
+ category: 'supply-chain',
380
+ rule: 'BEHAVIORAL_HEX_OBFUSCATION',
381
+ title: 'Obfuscated Code: Excessive Hex Encoding',
382
+ description: `File contains ${hexMatches} hex-encoded sequences. Common in malicious packages trying to hide payload.`,
383
+ matched: `${hexMatches} hex sequences detected`,
384
+ fix: 'Inspect the deobfuscated code. Consider removing this dependency.',
385
+ }));
386
+ }
387
+
388
+ // Excessive String.fromCharCode
389
+ const charCodeMatches = (content.match(/String\.fromCharCode/g) || []).length;
390
+ if (charCodeMatches > 5) {
391
+ findings.push(createFinding({
392
+ file,
393
+ line: 1,
394
+ severity: 'high',
395
+ category: 'supply-chain',
396
+ rule: 'BEHAVIORAL_CHARCODE_OBFUSCATION',
397
+ title: 'Obfuscated Code: Excessive String.fromCharCode',
398
+ description: `File contains ${charCodeMatches} String.fromCharCode calls. Common obfuscation technique in malicious packages.`,
399
+ matched: `${charCodeMatches} String.fromCharCode calls`,
400
+ fix: 'Inspect the deobfuscated code. Consider removing this dependency.',
401
+ }));
402
+ }
403
+
404
+ // Base64 decode chains
405
+ const base64Matches = (content.match(/Buffer\.from\s*\([^,]+,\s*['"]base64['"]\)/g) || []).length;
406
+ if (base64Matches > 3) {
407
+ findings.push(createFinding({
408
+ file,
409
+ line: 1,
410
+ severity: 'medium',
411
+ category: 'supply-chain',
412
+ rule: 'BEHAVIORAL_BASE64_DECODE',
413
+ title: 'Suspicious: Multiple Base64 Decode Operations',
414
+ description: `File contains ${base64Matches} base64 decode operations. May indicate hidden payload.`,
415
+ matched: `${base64Matches} base64 decode operations`,
416
+ confidence: 'medium',
417
+ fix: 'Review what data is being decoded. Legitimate use is possible but warrants inspection.',
418
+ }));
419
+ }
420
+ }
421
+
422
+ // Detect unused dependencies (in package.json but never imported)
423
+ const projectFiles = (context.files || []).filter(f =>
424
+ !f.includes('node_modules') &&
425
+ ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(path.extname(f).toLowerCase())
426
+ );
427
+
428
+ if (projectFiles.length > 0 && projectFiles.length < 500) {
429
+ const allImports = new Set();
430
+ for (const file of projectFiles) {
431
+ const content = this.readFile(file);
432
+ if (!content) continue;
433
+ // Capture import/require module names
434
+ const importMatches = content.matchAll(/(?:from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g);
435
+ for (const m of importMatches) {
436
+ const mod = (m[1] || m[2] || '').split('/')[0]; // Get package name (not subpath)
437
+ if (mod && !mod.startsWith('.')) allImports.add(mod);
438
+ // Handle scoped packages
439
+ const fullMod = m[1] || m[2] || '';
440
+ if (fullMod.startsWith('@')) {
441
+ const scopedPkg = fullMod.split('/').slice(0, 2).join('/');
442
+ allImports.add(scopedPkg);
443
+ }
444
+ }
445
+ }
446
+
447
+ const prodDeps = Object.keys(pkg.dependencies || {});
448
+ for (const dep of prodDeps) {
449
+ if (!allImports.has(dep) && !dep.startsWith('@types/')) {
450
+ findings.push(createFinding({
451
+ file: pkgPath,
452
+ line: 0,
453
+ severity: 'low',
454
+ category: 'supply-chain',
455
+ rule: 'UNUSED_DEPENDENCY',
456
+ title: `Unused Dependency: ${dep}`,
457
+ description: `"${dep}" is in dependencies but never imported in project code. Unused dependencies increase attack surface.`,
458
+ matched: dep,
459
+ confidence: 'low',
460
+ fix: `Remove if unused: npm uninstall ${dep}`,
461
+ }));
462
+ }
463
+ }
464
+ }
465
+
466
+ } catch { /* skip */ }
467
+ }
468
+
469
+ // ── 6. npm token scope in .npmrc ──────────────────────────────────────────
470
+ if (fs.existsSync(npmrcPath)) {
471
+ const content = this.readFile(npmrcPath) || '';
472
+ // Detect auth tokens stored in .npmrc a prerequisite for worm spread
473
+ const tokenLines = content.split('\n').filter(l => /_authToken\s*=/.test(l));
474
+ for (const line of tokenLines) {
475
+ const isScopedToPackage = /\/\/registry\.npmjs\.org\/.+:_authToken/.test(line);
476
+ // Flag tokens that appear to be publish-level (not scoped to a single package)
477
+ findings.push(createFinding({
478
+ file: npmrcPath,
479
+ line: 0,
480
+ severity: 'high',
481
+ category: 'supply-chain',
482
+ rule: 'NPMRC_AUTH_TOKEN_EXPOSED',
483
+ title: 'npm Auth Token in .npmrc',
484
+ description: `An npm auth token is stored in .npmrc. ${isScopedToPackage ? 'Verify it is scoped to only the packages it needs to publish.' : 'If this token has publish rights, a compromised install script or dependency can steal it and spread a worm (CanisterWorm attack pattern).'} Commit this file only if absolutely necessary; prefer CI secret injection.`,
485
+ matched: line.replace(/=.+/, '=***'),
486
+ fix: 'Use npm token create --cidr-whitelist or scope tokens per-package. Never commit .npmrc with auth tokens.',
487
+ }));
488
+ }
489
+ }
490
+
491
+ // ── 7. Check Python requirements ──────────────────────────────────────────
492
+ const reqPath = path.join(rootPath, 'requirements.txt');
493
+ if (fs.existsSync(reqPath)) {
494
+ const content = this.readFile(reqPath) || '';
495
+ const lines = content.split('\n');
496
+ for (let i = 0; i < lines.length; i++) {
497
+ const line = lines[i].trim();
498
+ if (!line || line.startsWith('#')) continue;
499
+
500
+ // Unpinned versions
501
+ if (!line.includes('==') && !line.includes('>=') && !line.includes('~=') && !line.includes('@')) {
502
+ findings.push(createFinding({
503
+ file: reqPath,
504
+ line: i + 1,
505
+ severity: 'medium',
506
+ category: 'supply-chain',
507
+ rule: 'UNPINNED_PYTHON_DEP',
508
+ title: `Unpinned Python Dependency: ${line}`,
509
+ description: 'Python dependency without version pin. Pin to a specific version for reproducible builds.',
510
+ matched: line,
511
+ fix: `Pin version: ${line}==x.y.z`,
512
+ }));
513
+ }
514
+
515
+ // Git/URL dependencies
516
+ if (line.includes('git+') || line.startsWith('http')) {
517
+ findings.push(createFinding({
518
+ file: reqPath,
519
+ line: i + 1,
520
+ severity: 'high',
521
+ category: 'supply-chain',
522
+ rule: 'GIT_PYTHON_DEP',
523
+ title: `Git/URL Python Dependency: ${line.slice(0, 60)}`,
524
+ description: 'Installing from git/URL bypasses PyPI integrity checks.',
525
+ matched: line,
526
+ fix: 'Publish to PyPI or pin to a specific commit hash',
527
+ }));
528
+ }
529
+ }
530
+ }
531
+
532
+ // ── 8. Compromised package IOC matching (npm + PyPI) ──────────────────────
533
+ const iocSources = [];
534
+
535
+ // npm packages
536
+ if (fs.existsSync(pkgPath)) {
537
+ try {
538
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
539
+ const allDeps = {
540
+ ...(pkg.dependencies || {}),
541
+ ...(pkg.devDependencies || {}),
542
+ ...(pkg.optionalDependencies || {}),
543
+ };
544
+ for (const [name, version] of Object.entries(allDeps)) {
545
+ const ioc = COMPROMISED_PACKAGES.find(c => c.name === name);
546
+ if (ioc) {
547
+ // Strip semver range prefix (^, ~, >=, etc.) for comparison
548
+ const bare = String(version).replace(/^[\^~>=<]+/, '').trim();
549
+ if (ioc.badVersions.includes(bare)) {
550
+ iocSources.push({ file: pkgPath, name, version: bare, note: ioc.note });
551
+ }
552
+ }
553
+ }
554
+ } catch { /* skip */ }
555
+ }
556
+
557
+ // Python requirements.txt
558
+ if (fs.existsSync(path.join(rootPath, 'requirements.txt'))) {
559
+ const lines = (this.readFile(path.join(rootPath, 'requirements.txt')) || '').split('\n');
560
+ for (const line of lines) {
561
+ const m = line.trim().match(/^([\w-]+)==([\d.]+)/);
562
+ if (!m) continue;
563
+ const [, name, version] = m;
564
+ const ioc = COMPROMISED_PACKAGES.find(c => c.name === name.toLowerCase());
565
+ if (ioc && ioc.badVersions.includes(version)) {
566
+ iocSources.push({ file: path.join(rootPath, 'requirements.txt'), name, version, note: ioc.note });
567
+ }
568
+ }
569
+ }
570
+
571
+ for (const { file, name, version, note } of iocSources) {
572
+ findings.push(createFinding({
573
+ file,
574
+ line: 0,
575
+ severity: 'critical',
576
+ category: 'supply-chain',
577
+ rule: 'KNOWN_COMPROMISED_PACKAGE',
578
+ title: `Known-Compromised Package: ${name}@${version}`,
579
+ description: `${name}@${version} is a known-malicious release. ${note}`,
580
+ matched: `${name}@${version}`,
581
+ fix: `Update immediately to the latest safe release and rotate any credentials that may have been exfiltrated.`,
582
+ }));
583
+ }
584
+
585
+ // ── 9. Blockchain C2 indicators (CanisterWorm / ICP) ──────────────────────
586
+ if (fs.existsSync(path.join(rootPath, 'node_modules'))) {
587
+ try {
588
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
589
+ const allDeps = {
590
+ ...(pkg.dependencies || {}),
591
+ ...(pkg.devDependencies || {}),
592
+ };
593
+ for (const depName of Object.keys(allDeps)) {
594
+ const depPkgPath = path.join(rootPath, 'node_modules', depName, 'package.json');
595
+ if (!fs.existsSync(depPkgPath)) continue;
596
+ try {
597
+ const depPkg = JSON.parse(fs.readFileSync(depPkgPath, 'utf-8'));
598
+ const depDeps = {
599
+ ...(depPkg.dependencies || {}),
600
+ ...(depPkg.devDependencies || {}),
601
+ };
602
+ const suspiciousICP = ICP_BLOCKCHAIN_PACKAGES.filter(p => p in depDeps);
603
+ if (suspiciousICP.length > 0) {
604
+ findings.push(createFinding({
605
+ file: depPkgPath,
606
+ line: 0,
607
+ severity: 'critical',
608
+ category: 'supply-chain',
609
+ rule: 'BLOCKCHAIN_C2_INDICATOR',
610
+ title: `Blockchain C2 Indicator in ${depName}: ${suspiciousICP.join(', ')}`,
611
+ description: `Dependency "${depName}" imports ICP/Internet Computer blockchain packages (${suspiciousICP.join(', ')}). This matches the CanisterWorm attack pattern, which used an ICP canister as a decentralized, takedown-resistant C2 server to coordinate credential exfiltration.`,
612
+ matched: suspiciousICP.join(', '),
613
+ fix: 'Immediately audit or remove this dependency. ICP blockchain packages have no legitimate role in most application dependencies.',
614
+ }));
615
+ }
616
+ } catch { /* skip */ }
617
+ }
618
+ } catch { /* skip */ }
619
+ }
620
+
621
+ return findings;
622
+ }
623
+
624
+ /**
625
+ * Simple Levenshtein distance for typosquatting detection.
626
+ */
627
+ levenshtein(a, b) {
628
+ if (a.length === 0) return b.length;
629
+ if (b.length === 0) return a.length;
630
+
631
+ const matrix = [];
632
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
633
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
634
+
635
+ for (let i = 1; i <= b.length; i++) {
636
+ for (let j = 1; j <= a.length; j++) {
637
+ const cost = b[i - 1] === a[j - 1] ? 0 : 1;
638
+ matrix[i][j] = Math.min(
639
+ matrix[i - 1][j] + 1,
640
+ matrix[i][j - 1] + 1,
641
+ matrix[i - 1][j - 1] + cost
642
+ );
643
+ }
644
+ }
645
+
646
+ return matrix[b.length][a.length];
647
+ }
648
+ }
649
+
650
+ export default SupplyChainAudit;