@lateos/npm-scan 0.15.3 → 0.15.5
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 +45 -29
- package/backend/detectors/index.js +2 -0
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -0
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -0
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -0
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -0
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -0
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -0
- package/backend/detectors/mini-shai-hulud/index.js +118 -0
- package/backend/detectors/mini-shai-hulud/iocs.json +79 -0
- package/backend/vsix-scan/detectors/activation-event-risk.js +116 -0
- package/backend/vsix-scan/detectors/burst-publish.js +52 -0
- package/backend/vsix-scan/detectors/exfil-pattern.js +88 -0
- package/backend/vsix-scan/detectors/known-ioc.js +105 -0
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -0
- package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -0
- package/backend/vsix-scan/index.js +183 -0
- package/backend/vsix-scan/marketplace-client.js +145 -0
- package/backend/vsix-scan/vsix-iocs.json +31 -0
- package/cli/cli.js +21 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@lateos/npm-scan)
|
|
4
4
|
[](LICENSING.md)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](https://github.com/lateos-ai/npm-scan)
|
|
7
7
|
[](https://github.com/lateos-ai/npm-scan)
|
|
8
8
|
[](https://hub.docker.com/r/lateos/npm-scan)
|
|
9
9
|
[](https://github.com/lateos-ai/npm-scan/actions/workflows/publish.yml)
|
|
@@ -47,7 +47,9 @@ The **Megalodon campaign** (2026) alone compromised 5,500+ repositories via fake
|
|
|
47
47
|
| Sandbox evasion detection (ATK-010) | ❌ | ❌ | ❌ | ✅ |
|
|
48
48
|
| Transitive worm propagation (ATK-011) | ❌ | ❌ | ❌ | ✅ |
|
|
49
49
|
| Campaign detection (Megalodon CI/CD) | ❌ | ❌ | ❌ | ✅ |
|
|
50
|
+
| Worm campaign detection (Mini Shai-Hulud Wave 1–3) | ❌ | ❌ | ❌ | ✅ |
|
|
50
51
|
| HF model repo impersonation + README clone | ❌ | ❌ | ❌ | ✅ |
|
|
52
|
+
| VS Code extension supply chain scan (--vsix) | ❌ | ❌ | ❌ | ✅ |
|
|
51
53
|
| Attack taxonomy (ATK series) | ❌ | ❌ | ❌ | ✅ |
|
|
52
54
|
| SBOM output (CycloneDX + SPDX) | ❌ | ✅ | ❌ | ✅ |
|
|
53
55
|
| SARIF v2.1 (GitHub Code Scanning) | ❌ | ❌ | ❌ | ✅ |
|
|
@@ -68,6 +70,8 @@ The **Megalodon campaign** (2026) alone compromised 5,500+ repositories via fake
|
|
|
68
70
|
| 🕵️ | **Heuristic static analysis** | AST-level inspection catches obfuscation, eval chains, env probing, and suspicious lifecycle scripts that regex-based tools miss |
|
|
69
71
|
| 🧠 | **Behavioral detection** | Identifies conditional triggers (time-based, CI-aware), sandbox evasion, and dormant activation patterns |
|
|
70
72
|
| 🧬 | **ATK attack taxonomy** | 11 classified attack types with NIST 800-161 mappings — versioned, documented, and PR-able |
|
|
73
|
+
| 🪱 | **Worm campaign detection** | Mini Shai-Hulud — 6 sub-checks detecting burst publish, sibling compromise, SLSA attestation mismatch, publisher drift, IOC match, and token exfil across 3 waves (TanStack, AntV/atool, Nx Console) |
|
|
74
|
+
| 🧩 | **VSIX extension scanning** | `npm-scan scan --vsix nrwl.angular-console` — detects VS Code Marketplace supply chain attacks: burst publish, publisher anomaly, activation event risk, orphan commit fetch, known IOC, and exfil patterns (Nx Console 18.95.0 CVE-2026-48027) |
|
|
71
75
|
| 📦 | **SBOM generation** | CycloneDX 1.5 and SPDX 2.3 with findings embedded as vulnerabilities |
|
|
72
76
|
| 🔍 | **SARIF output** | GitHub Advanced Security / CodeQL compatible SARIF v2.1 — shows findings directly in Security tab |
|
|
73
77
|
| 🧾 | **Compliance reporting** | NIST SP 800-161 traceability matrix + EU Cyber Resilience Act mapping (free tier) |
|
|
@@ -193,41 +197,28 @@ npm-scan scan some-package --policy .npm-scan.yml
|
|
|
193
197
|
|
|
194
198
|
# Scan a local tarball (no registry fetch needed)
|
|
195
199
|
npm-scan scan --file path/to/malicious-package.tgz
|
|
200
|
+
|
|
201
|
+
# Scan a VS Code extension for Marketplace supply chain attacks
|
|
202
|
+
npm-scan scan --vsix nrwl.angular-console
|
|
203
|
+
|
|
204
|
+
# Scan a package AND a VSIX extension together (findings merge)
|
|
205
|
+
npm-scan scan lodash --vsix nrwl.angular-console
|
|
196
206
|
```
|
|
197
207
|
|
|
198
208
|
### Scan a lockfile
|
|
199
209
|
|
|
200
210
|
```bash
|
|
201
|
-
# Scan
|
|
202
|
-
npm-scan scan
|
|
203
|
-
|
|
204
|
-
# Scan a specific lockfile
|
|
205
|
-
npm-scan scan-lockfile -f ./path/to/package-lock.json
|
|
206
|
-
|
|
207
|
-
# Scan yarn.lock or pnpm-lock.yaml
|
|
208
|
-
npm-scan scan-lockfile -f ./yarn.lock --yarn
|
|
209
|
-
npm-scan scan-lockfile -f ./pnpm-lock.yaml --pnpm
|
|
210
|
-
|
|
211
|
-
# Fail CI/CD on high or critical findings (exit code 1)
|
|
212
|
-
npm-scan scan-lockfile --fail-on high
|
|
213
|
-
|
|
214
|
-
# Fail on any findings (low and above)
|
|
215
|
-
npm-scan scan-lockfile --fail-on low
|
|
216
|
-
|
|
217
|
-
# Generate SARIF v2.1 output for GitHub Advanced Security / VS Code
|
|
218
|
-
npm-scan scan-lockfile --sarif results.sarif
|
|
219
|
-
|
|
220
|
-
# Watch for changes and auto-rescan (single lockfile)
|
|
221
|
-
npm-scan scan-lockfile --watch
|
|
211
|
+
# Scan a single package
|
|
212
|
+
npm-scan scan lodash
|
|
222
213
|
|
|
223
|
-
#
|
|
224
|
-
npm-scan scan-lockfile
|
|
214
|
+
# Scan your lockfile
|
|
215
|
+
npm-scan scan-lockfile
|
|
225
216
|
|
|
226
|
-
#
|
|
227
|
-
npm-scan scan
|
|
217
|
+
# Scan a VS Code extension for supply chain threats
|
|
218
|
+
npm-scan scan --vsix nrwl.angular-console
|
|
228
219
|
|
|
229
|
-
#
|
|
230
|
-
npm-scan
|
|
220
|
+
# View latest scans
|
|
221
|
+
npm-scan report
|
|
231
222
|
```
|
|
232
223
|
|
|
233
224
|
### Generate reports
|
|
@@ -288,10 +279,14 @@ npm-scan report --pdf # all scans (premium)
|
|
|
288
279
|
| **ATK-011** | Transitive propagation (worm-style lateral spread) | Behavioral | 🔴 high | SR-11.4 |
|
|
289
280
|
| **MEGALODON** | Megalodon CI/CD campaign — workflow C2 exfil, credential harvest, publish velocity spike, publisher drift | Static + Registry | ⚫ critical | SR-3.1, SR-7.5 |
|
|
290
281
|
| **HF_IMPERSONATION** | HuggingFace org spoof detection — Jaro-Winkler similarity against 15 known-good orgs, SimHash README clone detection, artifact mismatch (`.exe`/`.dll` in model repos), postinstall escalation, new-org amplifier | Static + Network (Stage 2) | 🔴 high / ⚫ critical | SR-2.1 |
|
|
282
|
+
| **MINI_SHAI_HULUD** | Mini Shai-Hulud worm campaign — burst publish velocity (≥3 versions/30 min), co-temporal sibling compromise, SLSA attestation mismatch (sub-60s gap, first-ever, builder mismatch), publisher drift (<10 min account change), IOC match (scope/sha512/publisher from seed file), token exfil (NPM_TOKEN/.npmrc/atob patterns), Nx Console downstream detection | Static + Registry | 🔴 high / ⚫ critical | SR-3.1, SR-7.5 |
|
|
283
|
+
| **VSIX_SCAN** | VS Code extension supply chain scan — burst publish (≥2 versions/30 min, hot-pull <20 min), publisher anomaly (account substitution, new-account on high-install ext, 15-min add+publish), activation event risk (onStartupFinished→HIGH, *→CRITICAL, escalation on shell keywords), orphan commit fetch (GitHub API SHA refs, npx git URL, MCP-disguised exfil, Bun install), known IOC (extensionId/publisherAccount/commit hash from seed), exfil patterns (cred paths, DNS tunneling, AES+RSA, anti-analysis, Bun APIs) | Static + Registry | 🟠 medium / 🔴 high / ⚫ critical | SR-3.1, SR-5.3 |
|
|
291
284
|
|
|
292
285
|
> **How evasive attacks are caught:** ATK-009 detects packages that check `process.env.CI`, probe hostnames, or use time-based activation. ATK-010 flags `debugger` statements, `os.hostname()` probes, and env fingerprinting. ATK-011 traces peer dependency graphs to detect worm-like propagation patterns.
|
|
293
286
|
> **MEGALODON** campaign detection analyzes bundled `.github/workflows/` files for C2 co-occurrence and base64 decode chains, scans tarball files for credential + outbound network patterns, detects version publish velocity spikes via npm registry metadata, and identifies publisher account drift — all without any network calls beyond the initial package fetch.
|
|
294
287
|
> **HF_IMPERSONATION** detection uses a lazy two-stage evaluation: Stage 1 scans `package.json` scripts and JS/TS sources for HuggingFace references (URLs, `from_pretrained()`, `hub.download()`) and runs Jaro-Winkler similarity against 15 known-good HF orgs — zero network. If spoofs are found, Stage 2 fetches the HF model API, computes SimHash of both READMEs for clone detection, validates artifact type consistency (e.g., `transformers` library with `.exe` files is flagged as critical), applies a new-org amplifier (<30 days), and escalates when the reference appears in a lifecycle script.
|
|
288
|
+
> **MINI_SHAI_HULUD** worm campaign detection uses a lazy two-stage evaluation: Stage 1 runs burst velocity, publisher drift, IOC, and token exfil checks (in-memory, no network). If burst triggers, Stage 2 queries npm attestation endpoints for SLSA anomalies and fetches sibling package registry metadata for co-temporal burst detection. Composite finding includes wave attribution (wave1-tanstack, wave2-antv, wave3-nx-console) and critical severity when SLSA or IOC match. NX_CONSOLE_DOWNSTREAM (D7) flags npm packages with `@nx/*` dependencies and checks for `nrwl.angular-console` in `.vscode/extensions.json`.
|
|
289
|
+
> **VSIX_SCAN** extension scanning wraps both VS Code Marketplace and Open VSX registries with rate-limited (10 req/min), cached (5 min TTL) API clients. All 6 detectors run asynchronously and aggregate into a single composite `VSIX_SCAN` finding. Zero extension code is executed — all analysis is static regex/text-pattern matching. No Bun installation required for Bun pattern detection.
|
|
295
290
|
> See [`docs/attack-taxonomy.md`](docs/attack-taxonomy.md) for full evasion surface documentation and PoC examples.
|
|
296
291
|
|
|
297
292
|
---
|
|
@@ -367,6 +362,18 @@ npm-scan scan target --policy .npm-scan.yml
|
|
|
367
362
|
| `NPM_SCAN_LICENSE_KEY` | Premium / enterprise license key | — |
|
|
368
363
|
| `NPM_SCAN_DATA_DIR` | Scan history directory | `./.npm-scan` |
|
|
369
364
|
| `NPM_SCAN_LOG_LEVEL` | Log verbosity | `info` |
|
|
365
|
+
| `NPM_SCAN_LICENSE_SECRET` | HMAC key for license generation/validation | `npm-scan-default-dev-key` |
|
|
366
|
+
|
|
367
|
+
### IOC configuration
|
|
368
|
+
|
|
369
|
+
Campaign detectors use seed IOC files for known-malicious fingerprints:
|
|
370
|
+
|
|
371
|
+
| IOC File | Detector | Types |
|
|
372
|
+
|----------|----------|-------|
|
|
373
|
+
| `backend/detectors/mini-shai-hulud/iocs.json` | Mini Shai-Hulud (Waves 1–3) | `packageScope`, `publisherAccount`, `sha512`, `extensionId` |
|
|
374
|
+
| `backend/vsix-scan/vsix-iocs.json` | VSIX extension scan | `extensionId`, `publisherAccount`, `orphanCommitHash` |
|
|
375
|
+
|
|
376
|
+
IOC files follow a unified schema (`iocs: [{ type, value, ... }]`) and are loaded at module init. Update them from your threat intel feed to extend detection coverage without code changes.
|
|
370
377
|
|
|
371
378
|
### Premium licensing
|
|
372
379
|
|
|
@@ -638,7 +645,7 @@ See the [Docker quick-start section](#-run-lateosnpm-scan-anywhere-with-docker--
|
|
|
638
645
|
|
|
639
646
|
### Free tier (shipped)
|
|
640
647
|
|
|
641
|
-
- All 11 ATK detectors + **MEGALODON** CI/CD campaign detection (D1–D6) + **HF_IMPERSONATION** detector
|
|
648
|
+
- All 11 ATK detectors + **MEGALODON** CI/CD campaign detection (D1–D6) + **HF_IMPERSONATION** detector + **MINI_SHAI_HULUD** worm campaign (D1–D7, 3 waves) + **VSIX_SCAN** extension supply chain scan (6 detectors)
|
|
642
649
|
- SBOM output (CycloneDX + SPDX)
|
|
643
650
|
- HTML, text, and compliance reports (NIST + EU CRA)
|
|
644
651
|
- Policy-as-code engine (YAML)
|
|
@@ -647,6 +654,7 @@ See the [Docker quick-start section](#-run-lateosnpm-scan-anywhere-with-docker--
|
|
|
647
654
|
- Pre-commit hook (husky + lint-staged)
|
|
648
655
|
- Docker images + Compose pipeline
|
|
649
656
|
- Watch mode (--watch / --monorepo for auto-rescan)
|
|
657
|
+
- VS Code extension scanning (--vsix flag with Marketplace + Open VSX registries)
|
|
650
658
|
|
|
651
659
|
### Premium (🔐 license key)
|
|
652
660
|
|
|
@@ -708,6 +716,14 @@ node --test test/detectors-corpus.test.js
|
|
|
708
716
|
- `test/report.test.js` — SARIF, CSV, STIG, risk score format tests
|
|
709
717
|
- `test/lockfile.test.js` — npm/yarn/pnpm parser, auto-detect, ATK-007/011 lockfile tests
|
|
710
718
|
- `test/hf-impersonation.test.js` — 13 HF impersonation detection tests (no-ref, exact match, spoof, README clone, artifact mismatch, postinstall escalation, new-org tag)
|
|
719
|
+
- `test/mini-shai-hulud.test.js` — 22 Mini Shai-Hulud worm campaign detection tests (burst, sibling, SLSA, maintainer, IOC, exfil, wave attribution)
|
|
720
|
+
- `test/vsix-scan/burst-publish.test.js` — 4 VSIX burst publish tests (threshold, sub-threshold, hot-pull, Open VSX window)
|
|
721
|
+
- `test/vsix-scan/publisher-anomaly.test.js` — 5 publisher anomaly tests (cross-namespace, new-account, add+publish, substitution, silent)
|
|
722
|
+
- `test/vsix-scan/activation-event-risk.test.js` — 5 activation event risk tests (onStartupFinished, wildcard, escalation, first-time, silent)
|
|
723
|
+
- `test/vsix-scan/orphan-commit-fetch.test.js` — 5 orphan commit tests (GitHub SHA, npx git, MCP exfil, Bun install, silent)
|
|
724
|
+
- `test/vsix-scan/known-ioc.test.js` — 4 known IOC tests (extensionId, publisher window, outside window)
|
|
725
|
+
- `test/vsix-scan/exfil-pattern.test.js` — 5 exfil pattern tests (creds, DNS tunnel, AES+RSA, anti-analysis, silent)
|
|
726
|
+
- `test/vsix-scan/integration.test.js` — 4 integration tests (Nx Console CRITICAL, safe version clean, orphan commit, skipNetwork)
|
|
711
727
|
- `test/cli.test.js` — commander integration tests (help, version, scan, report, error handling)
|
|
712
728
|
- `test/cli-lockfile.test.js` — scan-lockfile CLI options, yarn/pnpm/monorepo/watch tests
|
|
713
729
|
|
|
@@ -11,6 +11,7 @@ import * as atk010 from './atk-010-sandbox-evasion.js';
|
|
|
11
11
|
import * as atk011 from './atk-011-transitive-prop.js';
|
|
12
12
|
import { scanAll as megalodonScan } from './megalodon/index.js';
|
|
13
13
|
import { scan as hfScan } from './hf-impersonation/index.js';
|
|
14
|
+
import { scan as miniShaiHuludScan } from './mini-shai-hulud/index.js';
|
|
14
15
|
|
|
15
16
|
export async function runAll(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
16
17
|
const findings = [];
|
|
@@ -27,5 +28,6 @@ export async function runAll(pkgJson, files = [], registryMeta = null, allFiles
|
|
|
27
28
|
findings.push(...await atk011.scan(pkgJson, files));
|
|
28
29
|
findings.push(...await megalodonScan(pkgJson, allFiles || files, registryMeta));
|
|
29
30
|
findings.push(...await hfScan(pkgJson, files, registryMeta, allFiles || files));
|
|
31
|
+
findings.push(...await miniShaiHuludScan(pkgJson, files, registryMeta, allFiles || files));
|
|
30
32
|
return findings.sort((a, b) => b.severity.localeCompare(a.severity));
|
|
31
33
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export async function checkBurstPublish(registryMeta, config = {}) {
|
|
2
|
+
const windowMinutes = config.burstWindowMinutes ?? 30;
|
|
3
|
+
const threshold = config.burstVersionThreshold ?? 3;
|
|
4
|
+
|
|
5
|
+
const times = registryMeta?.time || {};
|
|
6
|
+
const entries = Object.entries(times)
|
|
7
|
+
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
8
|
+
.filter(([, t]) => t)
|
|
9
|
+
.map(([v, t]) => [v, new Date(t).getTime()])
|
|
10
|
+
.filter(([, ts]) => !Number.isNaN(ts))
|
|
11
|
+
.sort((a, b) => a[1] - b[1]);
|
|
12
|
+
|
|
13
|
+
if (entries.length === 0) return { triggered: false };
|
|
14
|
+
|
|
15
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < entries.length; i++) {
|
|
18
|
+
const windowStart = entries[i][1];
|
|
19
|
+
const windowEnd = windowStart + windowMs;
|
|
20
|
+
const inWindow = [];
|
|
21
|
+
|
|
22
|
+
for (let j = i; j < entries.length; j++) {
|
|
23
|
+
if (entries[j][1] <= windowEnd) {
|
|
24
|
+
inWindow.push(entries[j][0]);
|
|
25
|
+
} else {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (inWindow.length >= threshold) {
|
|
31
|
+
return {
|
|
32
|
+
triggered: true,
|
|
33
|
+
windowStart: new Date(windowStart).toISOString(),
|
|
34
|
+
windowEnd: new Date(windowEnd).toISOString(),
|
|
35
|
+
versionCount: inWindow.length,
|
|
36
|
+
versions: inWindow,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { triggered: false };
|
|
42
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const siblingCache = new Map();
|
|
2
|
+
|
|
3
|
+
export function clearSiblingCache() {
|
|
4
|
+
siblingCache.clear();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function checkBurstOnTimeMap(timeMap, windowMinutes, threshold) {
|
|
8
|
+
const entries = Object.entries(timeMap)
|
|
9
|
+
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
10
|
+
.filter(([, t]) => t)
|
|
11
|
+
.map(([v, t]) => [v, new Date(t).getTime()])
|
|
12
|
+
.filter(([, ts]) => !Number.isNaN(ts))
|
|
13
|
+
.sort((a, b) => a[1] - b[1]);
|
|
14
|
+
|
|
15
|
+
if (entries.length === 0) return null;
|
|
16
|
+
|
|
17
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < entries.length; i++) {
|
|
20
|
+
const wStart = entries[i][1];
|
|
21
|
+
const wEnd = wStart + windowMs;
|
|
22
|
+
const inWindow = [];
|
|
23
|
+
|
|
24
|
+
for (let j = i; j < entries.length; j++) {
|
|
25
|
+
if (entries[j][1] <= wEnd) {
|
|
26
|
+
inWindow.push(entries[j][0]);
|
|
27
|
+
} else {
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (inWindow.length >= threshold) {
|
|
33
|
+
return {
|
|
34
|
+
windowStart: new Date(wStart).toISOString(),
|
|
35
|
+
windowEnd: new Date(wEnd).toISOString(),
|
|
36
|
+
versionCount: inWindow.length,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function checkSiblingCompromise(pkgJson, config = {}) {
|
|
45
|
+
const windowMinutes = config.burstWindowMinutes ?? 30;
|
|
46
|
+
const threshold = config.burstVersionThreshold ?? 3;
|
|
47
|
+
|
|
48
|
+
const deps = {
|
|
49
|
+
...pkgJson.dependencies,
|
|
50
|
+
...pkgJson.devDependencies,
|
|
51
|
+
...pkgJson.peerDependencies,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const scopedDeps = {};
|
|
55
|
+
for (const name of Object.keys(deps)) {
|
|
56
|
+
if (name.startsWith('@')) {
|
|
57
|
+
const scope = name.split('/')[0];
|
|
58
|
+
if (!scopedDeps[scope]) scopedDeps[scope] = [];
|
|
59
|
+
scopedDeps[scope].push(name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (Object.keys(scopedDeps).length === 0) return { triggered: false };
|
|
64
|
+
|
|
65
|
+
const results = [];
|
|
66
|
+
|
|
67
|
+
for (const [scope, packages] of Object.entries(scopedDeps)) {
|
|
68
|
+
if (packages.length < 2) continue;
|
|
69
|
+
|
|
70
|
+
const burstSiblings = [];
|
|
71
|
+
|
|
72
|
+
for (const pkg of packages) {
|
|
73
|
+
let timeData = siblingCache.get(pkg);
|
|
74
|
+
if (!timeData) {
|
|
75
|
+
try {
|
|
76
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}`;
|
|
77
|
+
const res = await fetch(url);
|
|
78
|
+
if (!res.ok) continue;
|
|
79
|
+
const data = await res.json();
|
|
80
|
+
timeData = data.time || {};
|
|
81
|
+
siblingCache.set(pkg, timeData);
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const burstInfo = checkBurstOnTimeMap(timeData, windowMinutes, threshold);
|
|
88
|
+
if (burstInfo) {
|
|
89
|
+
burstSiblings.push({ name: pkg, ...burstInfo });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (burstSiblings.length >= 2) {
|
|
94
|
+
const windows = burstSiblings.map(s => ({
|
|
95
|
+
start: new Date(s.windowStart).getTime(),
|
|
96
|
+
end: new Date(s.windowEnd).getTime(),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const overlapStart = Math.max(...windows.map(w => w.start));
|
|
100
|
+
const overlapEnd = Math.min(...windows.map(w => w.end));
|
|
101
|
+
|
|
102
|
+
if (overlapStart < overlapEnd) {
|
|
103
|
+
results.push({
|
|
104
|
+
triggered: true,
|
|
105
|
+
scope,
|
|
106
|
+
siblingPackages: burstSiblings.map(s => s.name),
|
|
107
|
+
windowStart: new Date(overlapStart).toISOString(),
|
|
108
|
+
windowEnd: new Date(overlapEnd).toISOString(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (results.length === 0) return { triggered: false };
|
|
115
|
+
return { triggered: true, results };
|
|
116
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export async function checkSlsaMismatch(packageName, version, burstWindow, timeMap = {}, config = {}) {
|
|
2
|
+
if (!burstWindow?.triggered) return { triggered: false };
|
|
3
|
+
|
|
4
|
+
const anomalies = [];
|
|
5
|
+
const publishTime = timeMap?.[version];
|
|
6
|
+
if (!publishTime) return { triggered: false };
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
|
|
10
|
+
const res = await fetch(url);
|
|
11
|
+
if (!res.ok) return { triggered: false };
|
|
12
|
+
|
|
13
|
+
const data = await res.json();
|
|
14
|
+
const attestations = data?.attestations || [];
|
|
15
|
+
if (attestations.length === 0) return { triggered: false };
|
|
16
|
+
|
|
17
|
+
const publishMs = new Date(publishTime).getTime();
|
|
18
|
+
if (Number.isNaN(publishMs)) return { triggered: false };
|
|
19
|
+
|
|
20
|
+
// Check if this is the first-ever attested version for this package
|
|
21
|
+
const allVersions = Object.keys(timeMap).filter(v => v !== 'created' && v !== 'modified');
|
|
22
|
+
const currentIdx = allVersions.indexOf(version);
|
|
23
|
+
let prevHadAttestation = false;
|
|
24
|
+
|
|
25
|
+
if (currentIdx > 0) {
|
|
26
|
+
const priorVersions = allVersions.slice(0, currentIdx).slice(-2);
|
|
27
|
+
for (const pv of priorVersions) {
|
|
28
|
+
try {
|
|
29
|
+
const purl = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(pv)}`;
|
|
30
|
+
const pres = await fetch(purl);
|
|
31
|
+
if (pres.ok) {
|
|
32
|
+
const pdata = await pres.json();
|
|
33
|
+
if (pdata?.attestations?.length > 0) {
|
|
34
|
+
prevHadAttestation = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// skip prior version check
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!prevHadAttestation && priorVersions.length > 0) {
|
|
44
|
+
anomalies.push(`First-ever SLSA attestation for ${packageName}, published in burst window`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const att of attestations) {
|
|
49
|
+
const ts = att?.timestamp;
|
|
50
|
+
if (ts) {
|
|
51
|
+
const attMs = new Date(ts).getTime();
|
|
52
|
+
if (!Number.isNaN(attMs) && attMs >= publishMs && (attMs - publishMs) < 60000) {
|
|
53
|
+
const gapMs = attMs - publishMs;
|
|
54
|
+
anomalies.push(`Sub-60s attestation gap for ${version}: ${gapMs}ms`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const builderId = att?.predicate?.runDetails?.builder?.id;
|
|
59
|
+
if (builderId) {
|
|
60
|
+
const knownPrefixes = ['https://github.com/', 'https://gitlab.com/', 'https://circleci.com/'];
|
|
61
|
+
const isKnown = knownPrefixes.some(p => builderId.startsWith(p));
|
|
62
|
+
if (!isKnown) {
|
|
63
|
+
anomalies.push(`Unrecognized builder ID for ${version}: ${builderId}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
return { triggered: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { triggered: anomalies.length > 0, anomalies };
|
|
72
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export async function checkMaintainerAnomaly(registryMeta, config = {}) {
|
|
2
|
+
const versions = registryMeta?.versions || {};
|
|
3
|
+
const timeMap = registryMeta?.time || {};
|
|
4
|
+
|
|
5
|
+
const sorted = Object.entries(timeMap)
|
|
6
|
+
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
7
|
+
.filter(([, t]) => t)
|
|
8
|
+
.map(([v, t]) => ({
|
|
9
|
+
version: v,
|
|
10
|
+
time: new Date(t).getTime(),
|
|
11
|
+
user: versions[v]?._npmUser?.name,
|
|
12
|
+
}))
|
|
13
|
+
.filter(e => !Number.isNaN(e.time) && e.user)
|
|
14
|
+
.sort((a, b) => a.time - b.time);
|
|
15
|
+
|
|
16
|
+
if (sorted.length < 2) return { triggered: false };
|
|
17
|
+
|
|
18
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
19
|
+
const prev = sorted[i - 1];
|
|
20
|
+
const curr = sorted[i];
|
|
21
|
+
|
|
22
|
+
if (curr.user !== prev.user) {
|
|
23
|
+
const gapMinutes = (curr.time - prev.time) / (1000 * 60);
|
|
24
|
+
if (gapMinutes <= 10) {
|
|
25
|
+
const newUserVersions = sorted.filter(e => e.user === curr.user);
|
|
26
|
+
if (newUserVersions.length >= 2) {
|
|
27
|
+
return {
|
|
28
|
+
triggered: true,
|
|
29
|
+
signals: [{
|
|
30
|
+
type: 'PUBLISHER_DRIFT_RAPID',
|
|
31
|
+
previousPublisher: prev.user,
|
|
32
|
+
newPublisher: curr.user,
|
|
33
|
+
gapMinutes,
|
|
34
|
+
newUserVersionCount: newUserVersions.length,
|
|
35
|
+
driftVersion: curr.version,
|
|
36
|
+
driftWindowStart: new Date(curr.time).toISOString(),
|
|
37
|
+
}],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { triggered: false };
|
|
45
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
|
|
5
|
+
let iocsData = null;
|
|
6
|
+
let iocsLoaded = false;
|
|
7
|
+
let iocLoadError = null;
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
const IOC_PATH = join(__dirname, 'iocs.json');
|
|
12
|
+
|
|
13
|
+
function loadIOCData() {
|
|
14
|
+
if (iocsLoaded) return iocsData;
|
|
15
|
+
iocsLoaded = true;
|
|
16
|
+
try {
|
|
17
|
+
iocsData = JSON.parse(readFileSync(IOC_PATH, 'utf8'));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
iocLoadError = err;
|
|
20
|
+
iocsData = null;
|
|
21
|
+
}
|
|
22
|
+
return iocsData;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getIOCLoadError() {
|
|
26
|
+
return iocLoadError;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function reloadIOCData() {
|
|
30
|
+
iocsLoaded = false;
|
|
31
|
+
iocLoadError = null;
|
|
32
|
+
return loadIOCData();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, timeMap = {}) {
|
|
36
|
+
const data = loadIOCData();
|
|
37
|
+
if (!data) return { triggered: false, matches: [] };
|
|
38
|
+
|
|
39
|
+
const matches = [];
|
|
40
|
+
const allIOCs = [];
|
|
41
|
+
|
|
42
|
+
allIOCs.push(...(data.iocs || []));
|
|
43
|
+
|
|
44
|
+
for (const waveKey of Object.keys(data.waves || {})) {
|
|
45
|
+
const wave = data.waves[waveKey];
|
|
46
|
+
const waveNum = waveKey === 'wave1' ? 1 : waveKey === 'wave2' ? 2 : 3;
|
|
47
|
+
for (const ioc of (wave.iocs || [])) {
|
|
48
|
+
allIOCs.push({ ...ioc, wave: waveNum });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const ioc of allIOCs) {
|
|
53
|
+
switch (ioc.type) {
|
|
54
|
+
case 'packageName': {
|
|
55
|
+
if (ioc.value === pkgName) {
|
|
56
|
+
if (!ioc.maliciousVersions || ioc.maliciousVersions.length === 0 || ioc.maliciousVersions.includes(pkgVersion)) {
|
|
57
|
+
matches.push({ type: 'packageName', value: pkgName, wave: ioc.wave });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case 'packageScope': {
|
|
64
|
+
if (pkgName.startsWith(ioc.value)) {
|
|
65
|
+
matches.push({ type: 'packageScope', value: ioc.value, wave: ioc.wave });
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case 'sha512': {
|
|
71
|
+
if (ioc.value === sha512 && ioc.package === pkgName) {
|
|
72
|
+
matches.push({ type: 'sha512', value: sha512, wave: ioc.wave, package: pkgName });
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case 'publisherAccount': {
|
|
78
|
+
if (ioc.value === publisherAccount) {
|
|
79
|
+
const pubTime = new Date(timeMap?.[pkgVersion]).getTime();
|
|
80
|
+
const windowStart = new Date(ioc.compromiseWindowStart).getTime();
|
|
81
|
+
const windowEnd = ioc.compromiseWindowEnd
|
|
82
|
+
? new Date(ioc.compromiseWindowEnd).getTime()
|
|
83
|
+
: Infinity;
|
|
84
|
+
|
|
85
|
+
if (!Number.isNaN(pubTime) && pubTime >= windowStart && pubTime <= windowEnd) {
|
|
86
|
+
matches.push({ type: 'publisherAccount', value: publisherAccount, wave: ioc.wave });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { triggered: matches.length > 0, matches };
|
|
95
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const EXFIL_PATTERNS = [
|
|
2
|
+
/NPM_TOKEN|NODE_AUTH_TOKEN|GH_TOKEN|GITHUB_TOKEN|npm_token|node_auth_token/i,
|
|
3
|
+
/~\/(\.npmrc|\.gitconfig|\.aws\/credentials)/,
|
|
4
|
+
/\/run\/secrets\//,
|
|
5
|
+
/\$GITHUB_ENV/,
|
|
6
|
+
/process\.env\.(NPM_TOKEN|NODE_AUTH_TOKEN|GH_TOKEN|GITHUB_TOKEN)/,
|
|
7
|
+
/Buffer\.from\s*\([^)]*\)\s*\.\s*toString\s*\(\s*['"]base64['"]\s*\)/,
|
|
8
|
+
/\batob\s*\(/,
|
|
9
|
+
/\bbtoa\s*\(/,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const SUSPICIOUS_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'];
|
|
13
|
+
|
|
14
|
+
const MAX_SNIPPET_LENGTH = 200;
|
|
15
|
+
|
|
16
|
+
function truncateSnippet(text) {
|
|
17
|
+
if (text.length <= MAX_SNIPPET_LENGTH) return text;
|
|
18
|
+
return text.slice(0, MAX_SNIPPET_LENGTH - 3) + '...';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkTokenExfil(allFiles, pkgJson) {
|
|
22
|
+
const scripts = pkgJson?.scripts || {};
|
|
23
|
+
const snippets = [];
|
|
24
|
+
|
|
25
|
+
for (const hook of SUSPICIOUS_SCRIPTS) {
|
|
26
|
+
const scriptContent = scripts[hook];
|
|
27
|
+
if (!scriptContent) continue;
|
|
28
|
+
|
|
29
|
+
for (const pattern of EXFIL_PATTERNS) {
|
|
30
|
+
if (pattern.test(scriptContent)) {
|
|
31
|
+
snippets.push(truncateSnippet(scriptContent));
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { triggered: snippets.length > 0, snippets };
|
|
38
|
+
}
|