@lateos/npm-scan 0.15.4 → 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
 
@@ -43,7 +43,7 @@ export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, ti
43
43
 
44
44
  for (const waveKey of Object.keys(data.waves || {})) {
45
45
  const wave = data.waves[waveKey];
46
- const waveNum = waveKey === 'wave1' ? 1 : 2;
46
+ const waveNum = waveKey === 'wave1' ? 1 : waveKey === 'wave2' ? 2 : 3;
47
47
  for (const ioc of (wave.iocs || [])) {
48
48
  allIOCs.push({ ...ioc, wave: waveNum });
49
49
  }
@@ -28,6 +28,8 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
28
28
  slsaResult = await checkSlsaMismatch(pkgName, pkgVersion, burstResult, timeMap, config);
29
29
  }
30
30
 
31
+ const nxDownstreamResult = checkNxConsoleDownstream(pkgJson, allFiles || files);
32
+
31
33
  const triggeredChecks = [];
32
34
  if (burstResult.triggered) triggeredChecks.push('D1_BURST');
33
35
  if (siblingResult.triggered) triggeredChecks.push('D2_SIBLING');
@@ -35,6 +37,7 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
35
37
  if (maintainerResult.triggered) triggeredChecks.push('D4_MAINTAINER');
36
38
  if (iocResult.triggered) triggeredChecks.push('D5_IOC');
37
39
  if (exfilResult.triggered) triggeredChecks.push('D6_EXFIL');
40
+ if (nxDownstreamResult.triggered) triggeredChecks.push('D7_NX_CONSOLE');
38
41
 
39
42
  if (triggeredChecks.length === 0) return [];
40
43
 
@@ -43,14 +46,16 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
43
46
  waveAttribution = 'wave1-tanstack';
44
47
  } else if (pkgName.startsWith('@antv')) {
45
48
  waveAttribution = 'wave2-antv';
49
+ } else if (nxDownstreamResult.triggered) {
50
+ waveAttribution = 'wave3-nx-console';
46
51
  } else if (iocResult.matches && iocResult.matches.length > 0) {
47
52
  const waves = [...new Set(iocResult.matches.map(m => m.wave))];
48
53
  if (waves.length === 1) {
49
- waveAttribution = waves[0] === 1 ? 'wave1-tanstack' : 'wave2-antv';
54
+ waveAttribution = waves[0] === 1 ? 'wave1-tanstack' : waves[0] === 2 ? 'wave2-antv' : 'wave3-nx-console';
50
55
  }
51
56
  }
52
57
 
53
- const isCritical = slsaResult.triggered || iocResult.triggered;
58
+ const isCritical = slsaResult.triggered || iocResult.triggered || nxDownstreamResult.triggered;
54
59
 
55
60
  const evidence = {
56
61
  campaign: 'MINI_SHAI_HULUD',
@@ -65,6 +70,9 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
65
70
  attestationAnomalies: slsaResult.triggered ? slsaResult.anomalies : null,
66
71
  iocMatches: iocResult.triggered ? iocResult.matches : null,
67
72
  installScriptSnippets: exfilResult.triggered ? exfilResult.snippets : null,
73
+ nxConsoleDownstream: nxDownstreamResult.triggered
74
+ ? { nxDeps: nxDownstreamResult.nxDeps, vsCodeExtensions: nxDownstreamResult.vsCodeExtensions }
75
+ : null,
68
76
  };
69
77
 
70
78
  return [{
@@ -73,8 +81,38 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
73
81
  title: 'Mini Shai-Hulud worm campaign',
74
82
  description: `${triggeredChecks.length} signal(s): ${triggeredChecks.join(', ')}`,
75
83
  evidence: JSON.stringify(evidence),
76
- mitigation: 'Revoke all npm tokens immediately. Rotate CI/CD secrets. Audit maintainer access on all scoped packages. Review recent version publish history for anomalous bursts. Check for postinstall scripts accessing credentials or environment variables. If Wave 1 (TanStack scope): inspect GitHub Actions workflow logs for unauthorized build steps. If Wave 2 (atool/AntV scope): rotate all npm tokens associated with @antv/* packages.',
84
+ mitigation: 'Revoke all npm tokens immediately. Rotate CI/CD secrets. Audit maintainer access on all scoped packages. Review recent version publish history for anomalous bursts. Check for postinstall scripts accessing credentials or environment variables. If Wave 1 (TanStack scope): inspect GitHub Actions workflow logs for unauthorized build steps. If Wave 2 (atool/AntV scope): rotate all npm tokens associated with @antv/* packages. If Wave 3 (Nx Console): remove nrwl.angular-console extension immediately, revoke all npm tokens used in CI/CD, and audit @nx/* dependency versions.',
77
85
  }];
78
86
  }
79
87
 
88
+ function checkNxConsoleDownstream(pkgJson, allFiles) {
89
+ const deps = { ...pkgJson?.dependencies, ...pkgJson?.devDependencies, ...pkgJson?.peerDependencies };
90
+ const nxDeps = Object.keys(deps).filter(d => d.startsWith('@nx/') || d.startsWith('nrwl/'));
91
+ if (nxDeps.length === 0) return { triggered: false, nxDeps: [], vsCodeExtensions: [] };
92
+
93
+ let vsCodeExtensions = [];
94
+ if (allFiles && Array.isArray(allFiles)) {
95
+ for (const file of allFiles) {
96
+ if (file.path && (file.path.endsWith('.vscode/extensions.json') || file.path.endsWith('.vscode/extensions.json'))) {
97
+ try {
98
+ const content = typeof file.content === 'string' ? file.content : '';
99
+ const parsed = JSON.parse(content);
100
+ const allExts = [
101
+ ...(parsed.recommendations || []),
102
+ ...(parsed.unwantedRecommendations || []),
103
+ ];
104
+ const matched = allExts.filter(e => e.includes('nrwl.angular-console'));
105
+ if (matched.length > 0) {
106
+ vsCodeExtensions = matched;
107
+ }
108
+ } catch {
109
+ // non-JSON extensions.json, skip
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ return { triggered: true, nxDeps, vsCodeExtensions };
116
+ }
117
+
80
118
  export { clearSiblingCache } from './d2-sibling-compromise.js';
@@ -33,6 +33,38 @@
33
33
  "notes": "Blast radius: @antv/g2, @antv/g6, @antv/x6, @antv/l7, echarts-for-react, timeago.js. Seed IOC — update from threat intel."
34
34
  }
35
35
  ]
36
+ },
37
+ "wave3": {
38
+ "id": "nx-console-wave3",
39
+ "description": "Nx Console 18.95.0 VS Code extension compromise (May 18, 2026, CVE-2026-48027, TeamPCP) — contributor token stolen via TanStack wave1 (May 11), 7-day dwell, malicious extension published using npx to fetch 498KB obfuscated Bun payload from dangling orphan commit on nrwl/nx repo. ~3M installs exposed.",
40
+ "windowMinutes": 36,
41
+ "iocs": [
42
+ {
43
+ "type": "extensionId",
44
+ "value": "nrwl.angular-console",
45
+ "maliciousVersionRanges": ["18.95.0"],
46
+ "notes": "Nx Console v18.95.0 — malicious VS Code extension. CVE-2026-48027. Exposure window: 11 min on Marketplace, 36 min on Open VSX."
47
+ },
48
+ {
49
+ "type": "publisherAccount",
50
+ "value": "nrwl",
51
+ "compromiseWindowStart": "2026-05-11T00:00:00.000Z",
52
+ "compromiseWindowEnd": "2026-05-18T13:09:00.000Z",
53
+ "notes": "Nx contributor token stolen via TanStack wave1 on May 11; 7-day dwell before publishing malicious extension on May 18."
54
+ },
55
+ {
56
+ "type": "packageScope",
57
+ "value": "@nx",
58
+ "maliciousVersionRanges": [],
59
+ "notes": "NX_CONSOLE_DOWNSTREAM: npm packages under @nx scope deployed by compromised Nx contributor. Check for versions published within 7 days of 2026-05-18."
60
+ },
61
+ {
62
+ "type": "packageScope",
63
+ "value": "nrwl",
64
+ "maliciousVersionRanges": [],
65
+ "notes": "NX_CONSOLE_DOWNSTREAM: nrwl-scoped npm packages — monitor for anomalous burst publishing."
66
+ }
67
+ ]
36
68
  }
37
69
  },
38
70
  "iocs": [
@@ -0,0 +1,116 @@
1
+ const ACTIVATION_RISK_MATRIX = {
2
+ '*': { base: 'critical', label: 'Wildcard (all files)' },
3
+ 'onStartupFinished': { base: 'high', label: 'Startup finished' },
4
+ 'workspaceContains:**/*': { base: 'high', label: 'Workspace contains wildcard' },
5
+ 'workspaceContains': { base: 'high', label: 'Workspace contains' },
6
+ 'onCommand:*': { base: 'low', label: 'Any command' },
7
+ };
8
+
9
+ const DEFAULT_BASE_RISK = 'medium';
10
+
11
+ const ESCALATION_KEYWORDS = [
12
+ 'npx', 'bun', 'curl', 'wget', 'fetch(',
13
+ 'exec(', 'spawn(', 'execSync', 'spawnSync',
14
+ 'child_process', 'shell: true', 'detached: true',
15
+ ];
16
+
17
+ const BUNDLED_BUN_PATTERN = /bun|runtime/;
18
+
19
+ const SIZE_DELTA_THRESHOLD = 400 * 1024;
20
+
21
+ const SHELL_CMDS = ['npx', 'bun', 'curl', 'wget', 'exec', 'spawn', 'execSync'];
22
+
23
+ export async function checkActivationEventRisk(extensionManifest, versionHistory = [], priorVersions = []) {
24
+ const signals = [];
25
+
26
+ const activationEvents = extensionManifest?.activationEvents || [];
27
+ if (activationEvents.length === 0 && extensionManifest?.main) {
28
+ return { triggered: false, signals: [], riskLevel: null, why: [] };
29
+ }
30
+
31
+ let maxBaseRisk = 0;
32
+ const riskLabels = ['none', 'low', 'medium', 'high', 'critical'];
33
+ const riskValues = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
34
+
35
+ let worstEvent = null;
36
+ const why = [];
37
+
38
+ for (const event of activationEvents) {
39
+ const risk = ACTIVATION_RISK_MATRIX[event];
40
+ if (risk) {
41
+ const baseIdx = riskValues[risk.base] || riskValues[DEFAULT_BASE_RISK];
42
+ if (baseIdx > maxBaseRisk) {
43
+ maxBaseRisk = baseIdx;
44
+ worstEvent = event;
45
+ }
46
+ } else if (event.includes('*') && event !== 'onCommand:*') {
47
+ const baseIdx = riskValues['high'];
48
+ if (baseIdx > maxBaseRisk) {
49
+ maxBaseRisk = baseIdx;
50
+ worstEvent = event;
51
+ }
52
+ }
53
+ }
54
+
55
+ const contributes = extensionManifest?.contributes || {};
56
+ const commands = contributes?.commands || [];
57
+ const cmdTitles = commands.map(c => (c.title || '').toLowerCase()).join(' ');
58
+
59
+ const bundledDeps = extensionManifest?.bundledDependencies || [];
60
+ const bundledStr = Array.isArray(bundledDeps) ? bundledDeps.join(' ') : '';
61
+
62
+ const hasShellKeyword = SHELL_CMDS.some(cmd => cmdTitles.includes(cmd));
63
+ const hasBunBundled = BUNDLED_BUN_PATTERN.test(bundledStr);
64
+
65
+ const activationEventsStr = activationEvents.join(' ');
66
+ const hasShellInActivationContext = ESCALATION_KEYWORDS.some(kw => activationEventsStr.toLowerCase().includes(kw.toLowerCase()));
67
+
68
+ let escalateToCritical = false;
69
+
70
+ if (hasShellKeyword || hasBunBundled || hasShellInActivationContext) {
71
+ escalateToCritical = true;
72
+ why.push('HIGH activation event + shell/execution keywords');
73
+ }
74
+
75
+ if (versionHistory.length >= 2) {
76
+ const sizes = versionHistory
77
+ .filter(v => v.assetSize)
78
+ .map(v => v.assetSize)
79
+ .sort((a, b) => b - a);
80
+
81
+ if (sizes.length >= 2 && (sizes[0] - sizes[sizes.length - 1]) > SIZE_DELTA_THRESHOLD) {
82
+ escalateToCritical = true;
83
+ why.push(`HIGH activation event + version size delta > ${SIZE_DELTA_THRESHOLD} bytes`);
84
+ }
85
+ }
86
+
87
+ const priorActivationEvents = priorVersions
88
+ .filter(v => v.activationEvents)
89
+ .flatMap(v => v.activationEvents);
90
+
91
+ if (priorActivationEvents.length > 0) {
92
+ const newEvents = activationEvents.filter(e => !priorActivationEvents.includes(e));
93
+ if (newEvents.length > 0) {
94
+ why.push(`First-time activation event(s) added: ${newEvents.join(', ')}`);
95
+ if (!escalateToCritical && maxBaseRisk >= riskValues['high']) {
96
+ escalateToCritical = true;
97
+ }
98
+ }
99
+ }
100
+
101
+ let riskLevel = maxBaseRisk > 0 ? riskLabels[maxBaseRisk] : null;
102
+ if (escalateToCritical && riskValues[riskLevel] <= riskValues['high']) {
103
+ riskLevel = 'critical';
104
+ }
105
+
106
+ if (!riskLevel) return { triggered: false, signals: [], riskLevel: null, why: [] };
107
+
108
+ signals.push({
109
+ type: 'ACTIVATION_EVENT_RISK',
110
+ activationEvents,
111
+ riskLevel,
112
+ why,
113
+ });
114
+
115
+ return { triggered: true, signals, riskLevel, why };
116
+ }
@@ -0,0 +1,52 @@
1
+ export async function checkBurstPublish(versionHistory, config = {}) {
2
+ const windowMinutes = config.burstWindowMinutes ?? 30;
3
+ const threshold = config.burstVersionThreshold ?? 2;
4
+ const hotPullMinutes = config.hotPullMinutes ?? 20;
5
+
6
+ const entries = versionHistory
7
+ .filter(v => v.publishedAt)
8
+ .map(v => ({ version: v.version, time: new Date(v.publishedAt).getTime() }))
9
+ .filter(e => !Number.isNaN(e.time))
10
+ .sort((a, b) => a.time - b.time);
11
+
12
+ if (entries.length < threshold) return { triggered: false };
13
+
14
+ const windowMs = windowMinutes * 60 * 1000;
15
+ let burstFound = false;
16
+ let burstWindowStart = null;
17
+ let burstWindowEnd = null;
18
+ let burstVersionCount = 0;
19
+ let burstVersions = [];
20
+
21
+ for (let i = 0; i < entries.length; i++) {
22
+ const start = entries[i].time;
23
+ const end = start + windowMs;
24
+ const inWindow = entries.filter(e => e.time >= start && e.time <= end);
25
+
26
+ if (inWindow.length >= threshold) {
27
+ burstFound = true;
28
+ burstWindowStart = new Date(start).toISOString();
29
+ burstWindowEnd = new Date(end).toISOString();
30
+ burstVersionCount = inWindow.length;
31
+ burstVersions = inWindow.map(e => e.version);
32
+ break;
33
+ }
34
+ }
35
+
36
+ let hotPullDetected = false;
37
+ for (let i = 1; i < entries.length; i++) {
38
+ const gapMinutes = (entries[i].time - entries[i - 1].time) / (1000 * 60);
39
+ if (gapMinutes > 0 && gapMinutes < hotPullMinutes) {
40
+ hotPullDetected = true;
41
+ break;
42
+ }
43
+ }
44
+
45
+ return {
46
+ triggered: burstFound || hotPullDetected,
47
+ burstWindow: burstFound
48
+ ? { start: burstWindowStart, end: burstWindowEnd, versionCount: burstVersionCount, versions: burstVersions }
49
+ : null,
50
+ hotPullDetected,
51
+ };
52
+ }
@@ -0,0 +1,88 @@
1
+ const CREDENTIAL_FILE_PATTERNS = [
2
+ /~\/\.npmrc/,
3
+ /~\/\.gitconfig/,
4
+ /~\/\.aws\/credentials/,
5
+ /~\/\.ssh\/id_\w+/,
6
+ /~\/\.vault-token/,
7
+ /~\/\.claude\/settings\.json/,
8
+ /~\/Library\/Application\s+Support\/1Password\//,
9
+ /\/etc\/vault\/token/,
10
+ /\/proc\/\*\/mem/,
11
+ /\$GITHUB_ENV/,
12
+ /\$GITHUB_TOKEN/,
13
+ /\$NPM_TOKEN/,
14
+ /\$NODE_AUTH_TOKEN/,
15
+ /GH_TOKEN/,
16
+ ];
17
+
18
+ const EXFIL_CHANNEL_PATTERNS = [
19
+ /(?:[a-z0-9_-]{40,})\.[a-z0-9_-]+\.(?:com|io|org|net|app|dev|xyz)(?:\/[^\s"')\]]{0,50})?/i,
20
+ /\/gists\b.*authorization/i,
21
+ /\/repos\/[^/]+\/[^/]+\/git\/refs/i,
22
+ /AES-256-GCM/,
23
+ /RSA\/(?:PKCS|OAEP)/,
24
+ ];
25
+
26
+ const ANTI_ANALYSIS_PATTERNS = [
27
+ { pattern: /os\.cpus\(\)\.length\s*<\s*4/, label: 'CPU core count check (< 4)' },
28
+ { pattern: /Intl\.DateTimeFormat.*(?:timeZone|locale)/, label: 'Timezone/locale check' },
29
+ { pattern: /Intl\.DateTimeFormat.*\b(?:ru|rus|kz|by|cn|cns)\b/i, label: 'CIS/locale filtering' },
30
+ { pattern: /\bspawn\(\s*[^,]+,\s*\{[^}]*detached:\s*true\s*\}/, label: 'Detached process spawn' },
31
+ { pattern: /\bBUN_INSTALL\b/, label: 'BUN_INSTALL env reference' },
32
+ { pattern: /~\/\.bun\/bin\/bun/, label: 'Bun binary path' },
33
+ { pattern: /\bBun\.file\(/, label: 'Bun.file() API' },
34
+ { pattern: /\bBun\.serve\(/, label: 'Bun.serve() API' },
35
+ ];
36
+
37
+ function truncateSnippet(str, maxLen = 200) {
38
+ if (!str || str.length <= maxLen) return str || '';
39
+ return str.slice(0, maxLen) + '...';
40
+ }
41
+
42
+ export async function checkExfilPattern(extensionFiles = []) {
43
+ const signals = [];
44
+ const exfilPatterns = [];
45
+ const antiAnalysisTechniques = [];
46
+
47
+ for (const file of extensionFiles) {
48
+ const content = typeof file.content === 'string' ? file.content : '';
49
+ if (!content) continue;
50
+ const path = file.path || '';
51
+
52
+ for (const cp of CREDENTIAL_FILE_PATTERNS) {
53
+ const match = content.match(cp);
54
+ if (match) {
55
+ const snippet = truncateSnippet(match[0]);
56
+ if (!exfilPatterns.some(e => e.includes(snippet))) {
57
+ exfilPatterns.push(`${path}: ${snippet}`);
58
+ signals.push({ type: 'CREDENTIAL_FILE_TARGET', pattern: cp.source, file: path });
59
+ }
60
+ }
61
+ }
62
+
63
+ for (const ep of EXFIL_CHANNEL_PATTERNS) {
64
+ const match = content.match(ep);
65
+ if (match) {
66
+ const snippet = truncateSnippet(match[0]);
67
+ exfilPatterns.push(`${path}: ${snippet}`);
68
+ signals.push({ type: 'EXFIL_CHANNEL', pattern: ep.source, file: path });
69
+ }
70
+ }
71
+
72
+ for (const ap of ANTI_ANALYSIS_PATTERNS) {
73
+ if (ap.pattern.test(content)) {
74
+ if (!antiAnalysisTechniques.includes(ap.label)) {
75
+ antiAnalysisTechniques.push(ap.label);
76
+ signals.push({ type: 'ANTI_ANALYSIS', technique: ap.label, file: path });
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ return {
83
+ triggered: signals.length > 0,
84
+ signals,
85
+ exfilPatterns,
86
+ antiAnalysisTechniques,
87
+ };
88
+ }
@@ -0,0 +1,105 @@
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, '..', 'vsix-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 checkKnownIOC(extensionId, version, publisherAccount, orphanCommits = [], versionHistory = []) {
36
+ const data = loadIOCData();
37
+ if (!data) return { triggered: false, matches: [] };
38
+
39
+ const matches = [];
40
+ const iocs = data.iocs || [];
41
+
42
+ for (const ioc of iocs) {
43
+ switch (ioc.type) {
44
+ case 'extensionId': {
45
+ if (ioc.value === extensionId) {
46
+ if (!ioc.maliciousVersions || ioc.maliciousVersions.length === 0 || ioc.maliciousVersions.includes(version)) {
47
+ matches.push({
48
+ type: 'extensionId',
49
+ value: extensionId,
50
+ maliciousVersion: version,
51
+ wave: ioc.wave,
52
+ cve: ioc.cve,
53
+ exposureWindowStart: ioc.exposureWindowStart,
54
+ exposureWindowEnd: ioc.exposureWindowEnd,
55
+ });
56
+ }
57
+ }
58
+ break;
59
+ }
60
+
61
+ case 'publisherAccount': {
62
+ if (ioc.value === publisherAccount) {
63
+ const pubTime = versionHistory.length > 0
64
+ ? new Date(versionHistory[versionHistory.length - 1]?.publishedAt).getTime()
65
+ : null;
66
+
67
+ const windowStart = new Date(ioc.compromiseWindowStart).getTime();
68
+ const windowEnd = ioc.compromiseWindowEnd
69
+ ? new Date(ioc.compromiseWindowEnd).getTime()
70
+ : Infinity;
71
+
72
+ if (pubTime && !Number.isNaN(pubTime) && pubTime >= windowStart && pubTime <= windowEnd) {
73
+ matches.push({
74
+ type: 'publisherAccount',
75
+ value: publisherAccount,
76
+ wave: ioc.wave,
77
+ compromiseWindowStart: ioc.compromiseWindowStart,
78
+ compromiseWindowEnd: ioc.compromiseWindowEnd,
79
+ });
80
+ }
81
+ }
82
+ break;
83
+ }
84
+
85
+ case 'orphanCommitHash': {
86
+ for (const commit of orphanCommits) {
87
+ if (ioc.value === commit || (ioc.value === 'PLACEHOLDER_UPDATE_FROM_THREAT_INTEL')) {
88
+ continue;
89
+ }
90
+ if (ioc.value && commit && ioc.value.toLowerCase() === commit.toLowerCase()) {
91
+ matches.push({
92
+ type: 'orphanCommitHash',
93
+ value: commit,
94
+ repo: ioc.repo,
95
+ wave: ioc.wave,
96
+ });
97
+ }
98
+ }
99
+ break;
100
+ }
101
+ }
102
+ }
103
+
104
+ return { triggered: matches.length > 0, matches };
105
+ }
@@ -0,0 +1,69 @@
1
+ const GITHUB_COMMIT_SHA_PATTERN = /api\.github\.com\/repos\/[^/]+\/[^/]+\/git\/commits\/[a-f0-9]{40}/;
2
+ const NPX_GIT_URL_PATTERN = /npx\s+.*github\.com.*#[a-f0-9]{8,}/;
3
+ const MCP_KEYWORDS = ['mcp', 'model-context-protocol', 'claude', 'setup', 'init'];
4
+ const EXTERNAL_FETCH_PATTERN = /(?:https?:\/\/)[^\s"')\]]+(?:\.com|\.io|\.org|\.dev|\.app|\.net)[^\s"')\]]*/;
5
+ const NON_NPMJS_FETCH = /(?:fetch|curl|wget)\s*\(?\s*["']https?:\/\/(?!(?:.*npmjs\.org|.*npm\.js\.org|.*github\.com))[^"']+/;
6
+ const BUN_PATTERNS = [/bun\s+install/, /install\s+.*bun/, /\bbunx\b/, /\.bun\/bin\//];
7
+ const NPX_GIT_SHORT = /npx\s+.*github\.com.*#[a-f0-9]{8,}/;
8
+
9
+ export async function checkOrphanCommitFetch(extensionFiles = []) {
10
+ const signals = [];
11
+ const indicators = [];
12
+
13
+ for (const file of extensionFiles) {
14
+ const content = typeof file.content === 'string' ? file.content : '';
15
+ if (!content) continue;
16
+ const path = file.path || '';
17
+
18
+ if (GITHUB_COMMIT_SHA_PATTERN.test(content)) {
19
+ const matches = content.match(GITHUB_COMMIT_SHA_PATTERN);
20
+ if (matches) {
21
+ indicators.push(`${path}: GitHub git commit SHA reference`);
22
+ signals.push({
23
+ type: 'ORPHAN_COMMIT_GITHUB_API',
24
+ indicator: 'GitHub API direct commit SHA resolution',
25
+ file: path,
26
+ });
27
+ }
28
+ }
29
+
30
+ if (NPX_GIT_URL_PATTERN.test(content)) {
31
+ const matches = content.match(NPX_GIT_URL_PATTERN);
32
+ if (matches) {
33
+ indicators.push(`${path}: npx with git URL`);
34
+ signals.push({
35
+ type: 'NPX_GIT_URL',
36
+ indicator: 'npx resolves from git URL (non-registry)',
37
+ file: path,
38
+ });
39
+ }
40
+ }
41
+
42
+ const hasMCPKeywords = MCP_KEYWORDS.some(kw =>
43
+ new RegExp(`\\b${kw}\\b`, 'i').test(content));
44
+ const hasExternalFetch = NON_NPMJS_FETCH.test(content);
45
+
46
+ if (hasMCPKeywords && hasExternalFetch) {
47
+ indicators.push(`${path}: MCP-adjacent keywords + external fetch`);
48
+ signals.push({
49
+ type: 'MCP_DISGUISED_EXFIL',
50
+ indicator: 'Shell command disguised as MCP setup',
51
+ file: path,
52
+ });
53
+ }
54
+
55
+ for (const bp of BUN_PATTERNS) {
56
+ if (bp.test(content)) {
57
+ indicators.push(`${path}: Bun installation pattern`);
58
+ signals.push({
59
+ type: 'BUN_INSTALL',
60
+ indicator: `Bun runtime install pattern: ${bp.source}`,
61
+ file: path,
62
+ });
63
+ break;
64
+ }
65
+ }
66
+ }
67
+
68
+ return { triggered: signals.length > 0, signals, indicators };
69
+ }
@@ -0,0 +1,70 @@
1
+ export async function checkPublisherAnomaly(extensionMetadata, publisherProfile, versionHistory, config = {}) {
2
+ const signals = [];
3
+
4
+ const crossNamespaceThreshold = config.crossNamespaceThreshold ?? 3;
5
+ const crossNamespaceDays = config.crossNamespaceDays ?? 14;
6
+ const newAccountAgeDays = config.newAccountAgeDays ?? 30;
7
+ const highInstallThreshold = config.highInstallThreshold ?? 100000;
8
+ const addPublishWindowMinutes = config.addPublishWindowMinutes ?? 15;
9
+
10
+ const versions = versionHistory || [];
11
+ if (versions.length === 0) return { triggered: false, signals: [] };
12
+
13
+ const publishers = [...new Set(versions.map(v => v.publishedBy).filter(Boolean))];
14
+ if (publishers.length === 0) return { triggered: false, signals: [] };
15
+
16
+ const sortedVersions = [...versions]
17
+ .filter(v => v.publishedAt)
18
+ .sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
19
+
20
+ const extPublisher = publishers[0];
21
+ const allSame = publishers.every(p => p === extPublisher);
22
+
23
+ if (!allSame) {
24
+ for (const pub of publishers) {
25
+ if (pub !== extPublisher) {
26
+ signals.push({
27
+ type: 'PUBLISHER_ACCOUNT_SUBSTITUTION',
28
+ expectedPublisher: extPublisher,
29
+ unexpectedPublisher: pub,
30
+ });
31
+ }
32
+ }
33
+ }
34
+
35
+ const extInstallCount = extensionMetadata?.statistics?.find(s => s.statisticName === 'install')?.value || 0;
36
+
37
+ const extAgeDays = publisherProfile?.dateCreated
38
+ ? (Date.now() - new Date(publisherProfile.dateCreated).getTime()) / (1000 * 60 * 60 * 24)
39
+ : null;
40
+
41
+ if (extAgeDays !== null && extAgeDays < newAccountAgeDays && extInstallCount >= highInstallThreshold) {
42
+ signals.push({
43
+ type: 'NEW_ACCOUNT_HIGH_INSTALL',
44
+ accountAgeDays: Math.round(extAgeDays),
45
+ installCount: extInstallCount,
46
+ });
47
+ }
48
+
49
+ if (sortedVersions.length >= 2) {
50
+ const sorted = sortedVersions;
51
+ for (let i = 1; i < sorted.length; i++) {
52
+ const prev = sorted[i - 1];
53
+ const curr = sorted[i];
54
+ if (curr.publishedBy !== prev.publishedBy) {
55
+ const gapMinutes = (new Date(curr.publishedAt) - new Date(prev.publishedAt)) / (1000 * 60);
56
+ if (gapMinutes <= addPublishWindowMinutes) {
57
+ signals.push({
58
+ type: 'ADD_PUBLISH_RAPID',
59
+ version: curr.version,
60
+ previousPublisher: prev.publishedBy,
61
+ newPublisher: curr.publishedBy,
62
+ gapMinutes: Math.round(gapMinutes * 100) / 100,
63
+ });
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ return { triggered: signals.length > 0, signals };
70
+ }
@@ -0,0 +1,183 @@
1
+ import { checkBurstPublish } from './detectors/burst-publish.js';
2
+ import { checkPublisherAnomaly } from './detectors/publisher-anomaly.js';
3
+ import { checkActivationEventRisk } from './detectors/activation-event-risk.js';
4
+ import { checkOrphanCommitFetch } from './detectors/orphan-commit-fetch.js';
5
+ import { checkKnownIOC } from './detectors/known-ioc.js';
6
+ import { checkExfilPattern } from './detectors/exfil-pattern.js';
7
+ import { getExtensionMetadata, getVersionHistory, getPublisherProfile, getOpenVsxMetadata, getOpenVsxVersionHistory } from './marketplace-client.js';
8
+
9
+ const SEVERITY_SCORE = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
10
+ const SEVERITY_LABELS = ['none', 'low', 'medium', 'high', 'critical'];
11
+
12
+ export async function vsixScan(extensionId, options = {}) {
13
+ const { publisherId, extensionName } = parseExtensionId(extensionId);
14
+
15
+ const marketplaceMeta = options.marketplaceMeta || (options.skipNetwork ? null : await getExtensionMetadata(publisherId, extensionName));
16
+ const marketplaceVersions = options.marketplaceVersions || (marketplaceMeta ? await getVersionHistory(publisherId, extensionName) : []);
17
+ const openVsxVersions = options.openVsxVersions || (options.skipNetwork ? [] : await getOpenVsxVersionHistory(publisherId, extensionName));
18
+ const publisherProfile = options.publisherProfile || (options.skipNetwork ? null : await getPublisherProfile(publisherId));
19
+
20
+ const allVersions = mergeVersionHistories(marketplaceVersions, openVsxVersions);
21
+ const manifest = options.manifest || extractManifest(marketplaceMeta, extensionId);
22
+
23
+ const config = options.config || {};
24
+
25
+ const activationResult = await checkActivationEventRisk(
26
+ manifest,
27
+ allVersions,
28
+ options.priorVersions || [],
29
+ );
30
+
31
+ const burstResult = await checkBurstPublish(allVersions, config);
32
+
33
+ const publisherResult = await checkPublisherAnomaly(
34
+ manifest || {},
35
+ publisherProfile || {},
36
+ allVersions,
37
+ config,
38
+ );
39
+
40
+ const orphanResult = await checkOrphanCommitFetch(options.extensionFiles || []);
41
+
42
+ const iocResult = await checkKnownIOC(
43
+ extensionId,
44
+ options.version || (allVersions.length > 0 ? allVersions[allVersions.length - 1].version : 'unknown'),
45
+ publisherId,
46
+ orphanResult.signals
47
+ .filter(s => s.type === 'ORPHAN_COMMIT_GITHUB_API')
48
+ .map(s => s.indicator),
49
+ allVersions,
50
+ );
51
+
52
+ const exfilResult = await checkExfilPattern(options.extensionFiles || []);
53
+
54
+ const triggeredSignals = [];
55
+ if (burstResult.triggered) triggeredSignals.push('VSIX_BURST_PUBLISH');
56
+ if (publisherResult.triggered) triggeredSignals.push('VSIX_PUBLISHER_ANOMALY');
57
+ if (activationResult.triggered) triggeredSignals.push('VSIX_ACTIVATION_EVENT_RISK');
58
+ if (orphanResult.triggered) triggeredSignals.push('VSIX_ORPHAN_COMMIT_FETCH');
59
+ if (iocResult.triggered) triggeredSignals.push('VSIX_KNOWN_IOC');
60
+ if (exfilResult.triggered) triggeredSignals.push('VSIX_EXFIL_PATTERN');
61
+
62
+ if (triggeredSignals.length === 0) return [];
63
+
64
+ const registryLabels = [];
65
+ if (marketplaceVersions.length > 0) registryLabels.push('marketplace');
66
+ if (openVsxVersions.length > 0) registryLabels.push('open-vsx');
67
+
68
+ const maxSeverity = triggeredSignals.reduce((max, s) => {
69
+ if (s === 'VSIX_KNOWN_IOC' || s === 'VSIX_ORPHAN_COMMIT_FETCH') return Math.max(max, 4);
70
+ if (s === 'VSIX_BURST_PUBLISH' || s === 'VSIX_PUBLISHER_ANOMALY' || s === 'VSIX_EXFIL_PATTERN') return Math.max(max, 3);
71
+ if (s === 'VSIX_ACTIVATION_EVENT_RISK') return Math.max(max, 3);
72
+ return max;
73
+ }, 0);
74
+
75
+ const finalSeverity = SEVERITY_LABELS[maxSeverity] || 'high';
76
+
77
+ const latestVersion = allVersions.length > 0 ? allVersions[allVersions.length - 1].version : 'unknown';
78
+ let exposureWindowMinutes = null;
79
+ if (burstResult.hotPullDetected && allVersions.length >= 2) {
80
+ const sorted = [...allVersions].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
81
+ const gap = (new Date(sorted[0].publishedAt) - new Date(sorted[1].publishedAt)) / (1000 * 60);
82
+ exposureWindowMinutes = Math.round(gap);
83
+ }
84
+
85
+ const evidence = {
86
+ extensionId,
87
+ maliciousVersion: latestVersion,
88
+ registries: registryLabels,
89
+ exposureWindowMinutes,
90
+ triggeredSignals,
91
+ burstWindow: burstResult.burstWindow,
92
+ hotPullDetected: burstResult.hotPullDetected,
93
+ publisherSignals: publisherResult.triggered ? publisherResult.signals : null,
94
+ activationEvents: manifest?.activationEvents || null,
95
+ activationRisk: activationResult.triggered ? { riskLevel: activationResult.riskLevel, why: activationResult.why } : null,
96
+ orphanCommitIndicators: orphanResult.triggered ? orphanResult.indicators : null,
97
+ iocMatches: iocResult.triggered ? iocResult.matches : null,
98
+ exfilPatterns: exfilResult.triggered ? exfilResult.exfilPatterns : null,
99
+ antiAnalysisTechniques: exfilResult.triggered ? exfilResult.antiAnalysisTechniques : null,
100
+ };
101
+
102
+ const remediationGuidance = buildRemediation(triggeredSignals, extensionId);
103
+
104
+ return [{
105
+ id: 'VSIX_SCAN',
106
+ severity: finalSeverity,
107
+ title: `VS Code extension risk: ${extensionId}`,
108
+ description: `${triggeredSignals.length} signal(s): ${triggeredSignals.join(', ')}`,
109
+ evidence: JSON.stringify(evidence),
110
+ mitigation: remediationGuidance,
111
+ }];
112
+ }
113
+
114
+ function parseExtensionId(id) {
115
+ const idx = id.indexOf('.');
116
+ if (idx === -1 || idx === 0 || idx === id.length - 1) {
117
+ throw new Error(`Invalid extension ID: ${id}. Expected format: publisher.extension-name`);
118
+ }
119
+ return { publisherId: id.slice(0, idx), extensionName: id.slice(idx + 1) };
120
+ }
121
+
122
+ function mergeVersionHistories(marketplace, openVsx) {
123
+ const seen = new Set();
124
+ const merged = [];
125
+
126
+ for (const v of marketplace) {
127
+ if (!seen.has(v.version)) {
128
+ seen.add(v.version);
129
+ merged.push({ ...v, registries: ['marketplace'] });
130
+ }
131
+ }
132
+
133
+ for (const v of openVsx) {
134
+ if (!seen.has(v.version)) {
135
+ seen.add(v.version);
136
+ merged.push({ ...v, registries: ['open-vsx'] });
137
+ } else {
138
+ const existing = merged.find(m => m.version === v.version);
139
+ if (existing) {
140
+ existing.registries.push('open-vsx');
141
+ }
142
+ }
143
+ }
144
+
145
+ return merged.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
146
+ }
147
+
148
+ function extractManifest(marketplaceMeta, extensionId) {
149
+ if (!marketplaceMeta?.results?.[0]?.extensions?.[0]) return {};
150
+ const ext = marketplaceMeta.results[0].extensions[0];
151
+ const manifestStr = ext.galleryApiUrl || ext.manifest;
152
+ if (!manifestStr) return {};
153
+
154
+ try {
155
+ if (typeof manifestStr === 'object') return manifestStr;
156
+ return JSON.parse(manifestStr);
157
+ } catch {
158
+ return {};
159
+ }
160
+ }
161
+
162
+ function buildRemediation(triggeredSignals, extensionId) {
163
+ const parts = [];
164
+ if (triggeredSignals.includes('VSIX_KNOWN_IOC')) {
165
+ parts.push(`Extension ${extensionId} matches known campaign IOC. Remove immediately.`);
166
+ }
167
+ if (triggeredSignals.includes('VSIX_BURST_PUBLISH')) {
168
+ parts.push('Suspicious publish velocity detected. Verify publisher release history.');
169
+ }
170
+ if (triggeredSignals.includes('VSIX_PUBLISHER_ANOMALY')) {
171
+ parts.push('Publisher account anomaly detected. Verify publisher identity.');
172
+ }
173
+ if (triggeredSignals.includes('VSIX_ACTIVATION_EVENT_RISK')) {
174
+ parts.push('Risky activation events detected. Review extension activation scope.');
175
+ }
176
+ if (triggeredSignals.includes('VSIX_ORPHAN_COMMIT_FETCH')) {
177
+ parts.push('Dangling orphan commit fetch detected — technical signature of Nx Console attack.');
178
+ }
179
+ if (triggeredSignals.includes('VSIX_EXFIL_PATTERN')) {
180
+ parts.push('Credential exfiltration patterns detected. Revoke all tokens.');
181
+ }
182
+ return parts.join(' ');
183
+ }
@@ -0,0 +1,145 @@
1
+ const MARKETPLACE_API = 'https://marketplace.visualstudio.com/_apis/public/gallery';
2
+ const OPENVSX_API = 'https://open-vsx.org/api';
3
+
4
+ const _cache = new Map();
5
+ const CACHE_TTL = 5 * 60 * 1000;
6
+ const RATE_LIMIT_MS = 6000;
7
+ let _lastFetchTime = 0;
8
+
9
+ function sleep(ms) {
10
+ return new Promise(r => setTimeout(r, ms));
11
+ }
12
+
13
+ async function rateLimitedFetch(url) {
14
+ const now = Date.now();
15
+ const elapsed = now - _lastFetchTime;
16
+ if (elapsed < RATE_LIMIT_MS) {
17
+ await sleep(RATE_LIMIT_MS - elapsed);
18
+ }
19
+ _lastFetchTime = Date.now();
20
+
21
+ const cached = _cache.get(url);
22
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
23
+ return cached.data;
24
+ }
25
+
26
+ let res;
27
+ try {
28
+ res = await fetch(url);
29
+ if (res.status === 429) {
30
+ const retryAfter = parseInt(res.headers.get('Retry-After') || '10', 10);
31
+ await sleep(retryAfter * 1000);
32
+ res = await fetch(url);
33
+ }
34
+ if (!res.ok) {
35
+ console.debug(`Marketplace API warning: ${url} returned ${res.status}`);
36
+ return null;
37
+ }
38
+ const data = await res.json();
39
+ _cache.set(url, { data, fetchedAt: Date.now() });
40
+ return data;
41
+ } catch (err) {
42
+ console.debug(`Marketplace API error: ${err.message}`);
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function parseExtensionId(id) {
48
+ const parts = id.split('.');
49
+ if (parts.length < 2) throw new Error(`Invalid extension ID: ${id}`);
50
+ return { publisherId: parts[0], extensionName: parts.slice(1).join('.') };
51
+ }
52
+
53
+ export async function getExtensionMetadata(publisherId, extensionName) {
54
+ const url = `${MARKETPLACE_API}/extensionquery`;
55
+ const body = {
56
+ filters: [{
57
+ criteria: [
58
+ { filterType: 8, value: `${publisherId}.${extensionName}` },
59
+ ],
60
+ }],
61
+ flags: 914,
62
+ };
63
+
64
+ const cached = _cache.get(url + JSON.stringify(body));
65
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
66
+ return cached.data;
67
+ }
68
+
69
+ const now = Date.now();
70
+ const elapsed = now - _lastFetchTime;
71
+ if (elapsed < RATE_LIMIT_MS) {
72
+ await sleep(RATE_LIMIT_MS - elapsed);
73
+ }
74
+ _lastFetchTime = Date.now();
75
+
76
+ let res;
77
+ try {
78
+ res = await fetch(url, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json;api-version=3.0-preview.1' },
81
+ body: JSON.stringify(body),
82
+ });
83
+ if (res.status === 429) {
84
+ const retryAfter = parseInt(res.headers.get('Retry-After') || '10', 10);
85
+ await sleep(retryAfter * 1000);
86
+ res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json;api-version=3.0-preview.1' }, body: JSON.stringify(body) });
87
+ }
88
+ if (!res.ok) {
89
+ console.debug(`Marketplace API warning: ${url} returned ${res.status}`);
90
+ return null;
91
+ }
92
+ const data = await res.json();
93
+ _cache.set(url + JSON.stringify(body), { data, fetchedAt: Date.now() });
94
+ return data;
95
+ } catch (err) {
96
+ console.debug(`Marketplace API error: ${err.message}`);
97
+ return null;
98
+ }
99
+ }
100
+
101
+ export async function getVersionHistory(publisherId, extensionName) {
102
+ const data = await getExtensionMetadata(publisherId, extensionName);
103
+ if (!data?.results?.[0]?.extensions?.[0]) return [];
104
+
105
+ const extension = data.results[0].extensions[0];
106
+ const versions = extension.versions || [];
107
+
108
+ return versions.map(v => ({
109
+ version: v.version,
110
+ publishedAt: v.lastUpdated || v.publishedDate,
111
+ publishedBy: extension.publisher?.publisherName || publisherId,
112
+ assetSha256: v.assetUri ? null : null,
113
+ flags: v.flags ? [String(v.flags)] : [],
114
+ }));
115
+ }
116
+
117
+ export async function getPublisherProfile(publisherId) {
118
+ const url = `${MARKETPLACE_API}/publishers/${publisherId}`;
119
+ return rateLimitedFetch(url);
120
+ }
121
+
122
+ export async function getOpenVsxMetadata(namespace, name) {
123
+ const url = `${OPENVSX_API}/${namespace}/${name}`;
124
+ return rateLimitedFetch(url);
125
+ }
126
+
127
+ export async function getOpenVsxVersionHistory(namespace, name) {
128
+ const data = await getOpenVsxMetadata(namespace, name);
129
+ if (!data) return [];
130
+ const versions = data.allVersions || {};
131
+ const files = data.files || {};
132
+
133
+ return Object.entries(versions).map(([version, publishedAt]) => ({
134
+ version,
135
+ publishedAt: typeof publishedAt === 'string' ? publishedAt : data.timestamp,
136
+ publishedBy: data.namespace || namespace,
137
+ assetSha256: files?.[version]?.sha256 || null,
138
+ flags: [],
139
+ }));
140
+ }
141
+
142
+ export function clearMarketplaceCache() {
143
+ _cache.clear();
144
+ _lastFetchTime = 0;
145
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "lastUpdated": "2026-05-25",
3
+ "schema": "vsix-ioc-v1",
4
+ "iocs": [
5
+ {
6
+ "type": "extensionId",
7
+ "value": "nrwl.angular-console",
8
+ "maliciousVersions": ["18.95.0"],
9
+ "wave": "nx-console-wave3",
10
+ "cve": "CVE-2026-48027",
11
+ "exposureWindowStart": "2026-05-18T12:30:00Z",
12
+ "exposureWindowEnd": "2026-05-18T13:09:00Z",
13
+ "registries": ["marketplace", "open-vsx"],
14
+ "safeVersion": ">=18.100.0",
15
+ "source": "https://nx.dev/blog/nx-console-v18-95-0-postmortem"
16
+ },
17
+ {
18
+ "type": "publisherAccount",
19
+ "value": "nrwl",
20
+ "compromiseWindowStart": "2026-05-11T00:00:00Z",
21
+ "compromiseWindowEnd": "2026-05-18T13:09:00Z",
22
+ "note": "Contributor token stolen via TanStack wave1 on May 11; 7-day dwell before publish"
23
+ },
24
+ {
25
+ "type": "orphanCommitHash",
26
+ "value": "PLACEHOLDER_UPDATE_FROM_THREAT_INTEL",
27
+ "repo": "nrwl/nx",
28
+ "note": "Dangling commit hosting 498KB Bun payload — update hash from StepSecurity IOC report"
29
+ }
30
+ ]
31
+ }
package/cli/cli.js CHANGED
@@ -38,6 +38,7 @@ program
38
38
  .option('--cache-dir <path>', 'Cache directory for offline/air-gapped scans')
39
39
  .option('--cache-ttl <seconds>', 'Cache TTL in seconds (default: 604800 = 7 days)', '604800')
40
40
  .option('--cache-size <bytes>', 'Max cache size in bytes (default: 1GB)', '1000000000')
41
+ .option('--vsix <extensionId>', 'Scan a VS Code extension (e.g. nrwl.angular-console)')
41
42
  .action(async (target, options) => {
42
43
  try {
43
44
  if (options.fips) {
@@ -50,11 +51,21 @@ program
50
51
  cacheMaxSize: parseInt(options.cacheSize || '1000000000')
51
52
  };
52
53
 
53
- if (!target && !options.file) {
54
- console.error('Error: specify a package name or --file <path>');
54
+ if (!target && !options.file && !options.vsix) {
55
+ console.error('Error: specify a package name, --file <path>, or --vsix <extensionId>');
55
56
  process.exit(1);
56
57
  }
57
58
 
59
+ if (options.vsix && !target && !options.file) {
60
+ const { vsixScan } = await import('../backend/vsix-scan/index.js');
61
+ const vsixFindings = await vsixScan(options.vsix);
62
+ const { saveScan } = await import('../backend/db.js');
63
+ const scanId = await saveScan(options.vsix, 'latest', vsixFindings);
64
+ const vsixOutput = JSON.stringify({ scanId, findings: vsixFindings, blocked: false, riskScore: 0, vsix: true }, null, 2);
65
+ console.log(vsixOutput);
66
+ return;
67
+ }
68
+
58
69
  const policy = options.policy
59
70
  ? await import('../backend/policy.js').then(m => m.loadPolicy(options.policy))
60
71
  : null;
@@ -72,10 +83,16 @@ program
72
83
  : await import('../backend/fetch.js').then(m => m.fetchPackage(target, fetchOptions));
73
84
  const pkgName = target || pkgJson.name || 'unknown';
74
85
  const findings = await import('../backend/detectors/index.js').then(m => m.runAll(pkgJson, jsFiles, meta, allFiles));
86
+ let vsixFindings = [];
87
+ if (options.vsix) {
88
+ const { vsixScan } = await import('../backend/vsix-scan/index.js');
89
+ vsixFindings = await vsixScan(options.vsix);
90
+ }
91
+ const allFindings = [...findings, ...vsixFindings];
75
92
  const { saveScan } = await import('../backend/db.js');
76
- const scanId = await saveScan(pkgName, 'latest', findings);
93
+ const scanId = await saveScan(pkgName, 'latest', allFindings);
77
94
 
78
- let outputFindings = findings;
95
+ let outputFindings = allFindings;
79
96
  let blocked = false;
80
97
 
81
98
  if (policy) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lateos/npm-scan",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
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": {