@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 CHANGED
@@ -3,7 +3,7 @@
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-384%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
6
+ [![Tests](https://img.shields.io/badge/tests-428%20passing-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
7
7
  [![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen?style=flat-square)](https://github.com/lateos-ai/npm-scan)
8
8
  [![Docker](https://img.shields.io/badge/docker-lateos%2Fnpm--scan-2496ED?style=flat-square&logo=docker)](https://hub.docker.com/r/lateos/npm-scan)
9
9
  [![Sigstore](https://img.shields.io/static/v1?label=Sigstore&message=Provenance&color=green&style=flat-square&logo=sigstore)](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 the current project's dependencies (auto-detects npm/yarn/pnpm)
202
- npm-scan scan-lockfile
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
- # Watch with faster debounce (500ms) — great for dev workflows
224
- npm-scan scan-lockfile --watch --debounce 500
214
+ # Scan your lockfile
215
+ npm-scan scan-lockfile
225
216
 
226
- # Watch monorepo (all lockfiles npm/yarn/pnpm in workspace)
227
- npm-scan scan-lockfile --watch --monorepo
217
+ # Scan a VS Code extension for supply chain threats
218
+ npm-scan scan --vsix nrwl.angular-console
228
219
 
229
- # Output only risk score (0-10) for dashboards/thresholds
230
- npm-scan scan-lockfile --score-only
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
+ }