ktg-plugin-marketplace/plugins/llm-security/scanners/ide-extension-scanner.mjs
Kjell Tore Guttormsen 3b57dfbf6d chore(release): bump to v7.2.0
Batch B release — closes critical-review B-tier scanner defects
(B3, B5, B6, B7) and the v7.2.0 evasion-arsenal hardening patches
(E1, E4, E5, E7, E15, E16, E17, E18). Tests 1522 → 1665+, attack
simulator 64 → 72 (100 % pass).

Version updates across the 6 sync targets:

  - package.json
  - .claude-plugin/plugin.json
  - CLAUDE.md (header + test count: 1511 → 1665+)
  - README.md (badge + Version History row)
  - scanners/ide-extension-scanner.mjs (VERSION constant)
  - ../../README.md (marketplace root)

CHANGELOG [7.2.0] entry per Keep a Changelog with full Added /
Changed / Documentation / Tests / Notes breakdown.

Refs: Batch B Wave 6 / Step 15
2026-04-29 15:40:15 +02:00

1110 lines
42 KiB
JavaScript

#!/usr/bin/env node
// ide-extension-scanner.mjs — Scan installed VS Code (and forks) extensions for supply-chain,
// typosquat, obfuscation, theme-with-code, sideload, broad activation, and nested deps.
//
// Standalone — NOT registered in scan-orchestrator.mjs.
// Reuses existing scanners (UNI, ENT, NET, TNT, MEM, SCR) via direct import.
//
// Scanner prefix: IDE
// OWASP: LLM01, LLM02, LLM03, LLM06, ASI02, ASI04
// Zero external dependencies — Node.js builtins only.
//
// CLI: node scanners/ide-extension-scanner.mjs [target] [options]
// Library: import { scan, discoverAll } from './ide-extension-scanner.mjs'
import { resolve, join, relative } from 'node:path';
import { writeFileSync, existsSync } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { discoverFiles } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY, riskScore, riskBand, verdict } from './lib/severity.mjs';
import { levenshtein } from './lib/string-utils.mjs';
import {
discoverVSCodeExtensions,
discoverJetBrainsExtensions,
} from './lib/ide-extension-discovery.mjs';
import { parseVSCodeExtension, parseVsixFile, parseIntelliJPlugin } from './lib/ide-extension-parser.mjs';
import {
loadTopVSCode,
loadVSCodeBlocklist,
loadTopJetBrains,
loadJetBrainsBlocklist,
normalizeId,
} from './lib/ide-extension-data.mjs';
import { fetchVsixFromUrl, fetchPluginFromUrl, detectUrlType } from './lib/vsix-fetch.mjs';
import { extractToDir, ZipError } from './lib/zip-extract.mjs';
import {
runVsixWorker,
runPluginWorker,
DEFAULT_VSIX_WORKER_PATH,
DEFAULT_JETBRAINS_WORKER_PATH,
} from './lib/vsix-sandbox.mjs';
import { scan as scanUnicode } from './unicode-scanner.mjs';
import { scan as scanEntropy } from './entropy-scanner.mjs';
import { scan as scanNetwork } from './network-mapper.mjs';
import { scan as scanTaint } from './taint-tracer.mjs';
import { scan as scanMemoryPoisoning } from './memory-poisoning-scanner.mjs';
import { scan as scanSupplyChain } from './supply-chain-recheck.mjs';
const VERSION = '7.2.0';
const SCANNER = 'IDE';
// ---------------------------------------------------------------------------
// URL → temp dir orchestration
// ---------------------------------------------------------------------------
function isUrlTarget(target) {
return typeof target === 'string' && /^https?:\/\//i.test(target);
}
/**
* Fetch a VSIX from a URL, extract it to a temp dir, and return the path that
* `parseVSCodeExtension` should be pointed at. VSIX layout always nests the
* extension under `extension/`.
*
* Two modes:
* - useSandbox=true (default for CLI): spawns vsix-fetch-worker.mjs under
* sandbox-exec (macOS) / bwrap (Linux) so any FS write is restricted to
* <tempDir>. Defense-in-depth against zip-extract bugs.
* - useSandbox=false: runs fetch + extract in-process. Used by tests that
* mock globalThis.fetch (mocking does not cross process boundaries).
*
* Caller MUST `await rm(result.tempDir, { recursive: true, force: true })` in finally.
*
* @param {string} url
* @param {{ useSandbox?: boolean }} [opts]
* @returns {Promise<{ extRoot: string, tempDir: string, source: object, sandbox: 'sandbox-exec'|'bwrap'|null|'in-process' }>}
*/
async function fetchAndExtractVsixUrl(url, opts = {}) {
const useSandbox = opts.useSandbox !== false;
const tempDir = await mkdtemp(join(tmpdir(), 'llm-sec-vsix-'));
try {
if (useSandbox) {
const { ok, sandbox, payload } = await runVsixWorker(url, tempDir);
if (!ok) {
const msg = payload && payload.error ? payload.error : 'worker failed';
throw new Error(msg);
}
const { type: kind, ...sourceMeta } = payload.source;
const source = {
type: 'url',
kind,
url,
finalUrl: payload.finalUrl,
sha256: payload.sha256,
size: payload.size,
sandbox: sandbox || 'none',
...sourceMeta,
};
return { extRoot: payload.extRoot, tempDir, source, sandbox: sandbox || null };
}
// In-process path (tests, or fallback when caller wants no sub-process).
let fetched;
try {
fetched = await fetchVsixFromUrl(url);
} catch (err) {
throw new Error(`fetch failed: ${err.message}`);
}
try {
await extractToDir(fetched.buffer, tempDir);
} catch (err) {
if (err instanceof ZipError) {
throw new Error(`malformed VSIX (${err.code}): ${err.message}`);
}
throw err;
}
const nested = join(tempDir, 'extension');
const extRoot = existsSync(nested) ? nested : tempDir;
const { type: kind, ...sourceMeta } = fetched.source;
const source = {
type: 'url',
kind,
url,
finalUrl: fetched.finalUrl,
sha256: fetched.sha256,
size: fetched.size,
sandbox: 'in-process',
...sourceMeta,
};
return { extRoot, tempDir, source, sandbox: 'in-process' };
} catch (err) {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
}
/**
* Generalized URL fetch + extract for JetBrains plugins (and callable for VSIX
* too via `workerKind: 'vsix'`). Uses the generalized `runPluginWorker` from
* `vsix-sandbox.mjs` so both worker kinds share the same sandbox pipeline.
*
* JetBrains-specific differences from the VSIX helper:
* - Worker is `DEFAULT_JETBRAINS_WORKER_PATH` (emits the plugin root under
* <tempDir>, not `<tempDir>/extension`).
* - In-process fallback uses `fetchJetBrainsPlugin` + manual extRoot probe
* mirroring the worker (first child of tempDir with `lib/*.jar`).
*
* Caller MUST `await rm(result.tempDir, { recursive: true, force: true })` in finally.
*
* @param {string} url
* @param {{ useSandbox?: boolean, workerKind?: 'jetbrains'|'vsix' }} [opts]
* @returns {Promise<{ extRoot: string, tempDir: string, source: object, sandbox: 'sandbox-exec'|'bwrap'|null|'in-process' }>}
*/
async function fetchAndExtractPluginUrl(url, opts = {}) {
const useSandbox = opts.useSandbox !== false;
const workerKind = opts.workerKind || 'jetbrains';
const workerPath = workerKind === 'vsix' ? DEFAULT_VSIX_WORKER_PATH : DEFAULT_JETBRAINS_WORKER_PATH;
const tempDir = await mkdtemp(join(tmpdir(), `llm-sec-${workerKind}-`));
try {
if (useSandbox) {
const { ok, sandbox, payload } = await runPluginWorker(
workerPath,
['--url', url, '--tmpdir', tempDir],
tempDir,
);
if (!ok) {
const msg = payload && payload.error ? payload.error : 'worker failed';
throw new Error(msg);
}
const { type: kind, ...sourceMeta } = payload.source;
const source = {
type: 'url',
kind,
url,
finalUrl: payload.finalUrl,
sha256: payload.sha256,
size: payload.size,
sandbox: sandbox || 'none',
...sourceMeta,
};
return { extRoot: payload.extRoot, tempDir, source, sandbox: sandbox || null };
}
// In-process path — used by tests that mock globalThis.fetch.
let fetched;
try {
fetched = await fetchPluginFromUrl(url);
} catch (err) {
throw new Error(`fetch failed: ${err.message}`);
}
try {
await extractToDir(fetched.buffer, tempDir);
} catch (err) {
if (err instanceof ZipError) {
throw new Error(`malformed plugin archive (${err.code}): ${err.message}`);
}
throw err;
}
// JetBrains archives: first child dir containing lib/*.jar is the plugin root.
let extRoot = tempDir;
if (workerKind === 'jetbrains') {
try {
const { readdirSync, statSync } = await import('node:fs');
for (const name of readdirSync(tempDir)) {
const candidate = join(tempDir, name);
try {
if (!statSync(candidate).isDirectory()) continue;
const libDir = join(candidate, 'lib');
if (!statSync(libDir).isDirectory()) continue;
const libEntries = readdirSync(libDir);
if (libEntries.some((n) => n.toLowerCase().endsWith('.jar'))) {
extRoot = candidate;
break;
}
} catch { /* skip */ }
}
} catch { /* fallback to tempDir */ }
} else {
const nested = join(tempDir, 'extension');
if (existsSync(nested)) extRoot = nested;
}
const { type: kind, ...sourceMeta } = fetched.source;
const source = {
type: 'url',
kind,
url,
finalUrl: fetched.finalUrl,
sha256: fetched.sha256,
size: fetched.size,
sandbox: 'in-process',
...sourceMeta,
};
return { extRoot, tempDir, source, sandbox: 'in-process' };
} catch (err) {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
}
// ---------------------------------------------------------------------------
// IDE-specific checks (operate on parsed manifest)
// ---------------------------------------------------------------------------
function matchBlocklistEntry(id, version, entry) {
const [blockId, blockVer] = entry.split('@');
if (!blockId) return false;
if (normalizeId(blockId) !== normalizeId(id)) return false;
if (!blockVer || blockVer === '*') return true;
return blockVer === version;
}
function checkBlocklist(ext, manifest, blocklist, relLocation) {
const findings = [];
for (const entry of blocklist) {
if (matchBlocklistEntry(ext.id, ext.version, entry)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.CRITICAL,
title: `Block-listed extension: ${ext.id}@${ext.version}`,
description: `Extension ID matches entry in known-malicious blocklist (${entry}).`,
file: relLocation,
evidence: `id=${ext.id} version=${ext.version}`,
owasp: 'LLM03, ASI04',
recommendation: `Uninstall immediately via VS Code Extensions view, or run: code --uninstall-extension ${ext.id}`,
}));
break;
}
}
return findings;
}
function checkThemeWithCode(ext, manifest, relLocation) {
const findings = [];
const cats = manifest.categories.map(c => c.toLowerCase());
if (!cats.includes('themes')) return findings;
const hasMain = !!manifest.main || !!manifest.browser;
const hasActivation = Array.isArray(manifest.activationEvents) && manifest.activationEvents.length > 0;
if (!hasMain && !hasActivation) return findings;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.HIGH,
title: `Theme extension has executable code: ${ext.id}`,
description: 'Extensions categorized as "Themes" should not require runtime entry points. Presence of main/browser/activationEvents is a strong red flag (see Material Theme malware case).',
file: relLocation,
evidence: `categories=${JSON.stringify(manifest.categories)} main=${manifest.main} activationEvents=${JSON.stringify(manifest.activationEvents)}`,
owasp: 'LLM06, ASI02',
recommendation: `Audit ${manifest.main || manifest.browser} for data exfiltration logic. Consider uninstalling.`,
}));
return findings;
}
function checkSideload(ext, manifest, relLocation) {
const findings = [];
if (ext.source !== 'vsix') return findings;
const sev = ext.signed ? SEVERITY.MEDIUM : SEVERITY.HIGH;
findings.push(finding({
scanner: SCANNER,
severity: sev,
title: `Sideloaded extension (source=vsix): ${ext.id}`,
description: ext.signed
? 'Extension installed from local .vsix file. Signature present — possibly Marketplace-downloaded .vsix. Verify provenance.'
: 'Extension installed from local .vsix file without signature verification. Marketplace malware-scan and publisher trust bypassed.',
file: relLocation,
evidence: `source=vsix signed=${ext.signed}`,
owasp: 'LLM03',
recommendation: 'Verify source of .vsix file. Prefer Marketplace installs.',
}));
return findings;
}
function checkBroadActivation(ext, manifest, topSet, relLocation) {
const findings = [];
const events = manifest.activationEvents || [];
const hasStar = events.includes('*');
const hasStartup = events.includes('onStartupFinished');
if (!hasStar && !hasStartup) return findings;
// Suppress exact match with top-list (trusted baseline)
if (topSet.has(ext.id)) return findings;
if (hasStar) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `Wildcard activation (*): ${ext.id}`,
description: 'Extension activates on any workspace event via "*". Broad activation surface is unusual and should be justified.',
file: relLocation,
evidence: 'activationEvents includes "*"',
owasp: 'LLM06',
recommendation: 'Audit extension behavior. Review if broad activation is justified.',
}));
} else if (hasStartup) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.LOW,
title: `Startup activation: ${ext.id}`,
description: 'Extension activates on onStartupFinished. Near-wildcard activation surface.',
file: relLocation,
evidence: 'activationEvents includes "onStartupFinished"',
owasp: 'LLM06',
recommendation: 'Confirm extension is trusted.',
}));
}
return findings;
}
function checkTyposquat(ext, topList, relLocation) {
const findings = [];
const topSet = new Set(topList);
if (topSet.has(ext.id)) return findings; // exact legit match
let best = null;
let bestDist = 99;
for (let i = 0; i < topList.length; i++) {
const target = topList[i];
if (Math.abs(target.length - ext.id.length) > 2) continue;
const d = levenshtein(ext.id, target);
if (d < bestDist) {
bestDist = d;
best = { target, rank: i };
if (d === 1) break;
}
}
if (!best || bestDist > 2) return findings;
let sev = null;
if (bestDist === 1) sev = SEVERITY.HIGH;
else if (bestDist === 2 && best.rank < 50) sev = SEVERITY.MEDIUM;
if (!sev) return findings;
findings.push(finding({
scanner: SCANNER,
severity: sev,
title: `Possible typosquat: "${ext.id}" vs "${best.target}" (Levenshtein=${bestDist})`,
description: `Extension ID is ${bestDist} edit(s) from top-${best.rank + 1} extension "${best.target}". Common impersonation pattern (TigerJack, publisher spoofing).`,
file: relLocation,
evidence: `candidate=${best.target} distance=${bestDist}`,
owasp: 'LLM03',
recommendation: `Verify publisher identity. If "${best.target}" is what you intended, uninstall this and install from the verified publisher.`,
}));
return findings;
}
function checkExtensionPackExpansion(ext, manifest, relLocation) {
const findings = [];
const pack = manifest.extensionPack || [];
if (pack.length < 3) return findings;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `Extension pack installs ${pack.length} bundled extensions: ${ext.id}`,
description: 'Extension packs amplify trust chain — installing one extension installs N others, each with its own risk surface.',
file: relLocation,
evidence: `extensionPack=[${pack.slice(0, 3).join(', ')}${pack.length > 3 ? ', ...' : ''}]`,
owasp: 'LLM03',
recommendation: 'Audit each bundled extension individually.',
}));
return findings;
}
const SHELL_PATTERNS = /\b(child_process|curl|wget|\brm\b|powershell|iex|Invoke-Expression|Start-Process|Invoke-WebRequest)\b/i;
function checkUninstallHook(ext, manifest, relLocation) {
const findings = [];
const scripts = manifest.scripts || {};
const hook = scripts['vscode:uninstall'];
if (!hook || typeof hook !== 'string') return findings;
const matches = SHELL_PATTERNS.test(hook);
findings.push(finding({
scanner: SCANNER,
severity: matches ? SEVERITY.HIGH : SEVERITY.LOW,
title: `Uninstall hook defined: ${ext.id}`,
description: matches
? 'Uninstall script references shell patterns (child_process, curl, rm, powershell etc.). Persistence hook risk.'
: 'Extension defines a vscode:uninstall script. Review what it does.',
file: relLocation,
evidence: hook.slice(0, 200),
owasp: 'LLM06, ASI02',
recommendation: 'Inspect the uninstall hook before uninstalling.',
}));
return findings;
}
function runIdeChecks(ext, manifest, topList, blocklist, relLocation) {
const topSet = new Set(topList);
const out = [];
out.push(...checkBlocklist(ext, manifest, blocklist, relLocation));
out.push(...checkThemeWithCode(ext, manifest, relLocation));
out.push(...checkSideload(ext, manifest, relLocation));
out.push(...checkBroadActivation(ext, manifest, topSet, relLocation));
out.push(...checkTyposquat(ext, topList, relLocation));
out.push(...checkExtensionPackExpansion(ext, manifest, relLocation));
out.push(...checkUninstallHook(ext, manifest, relLocation));
return out;
}
// ---------------------------------------------------------------------------
// JetBrains-specific checks
// ---------------------------------------------------------------------------
function checkBlocklistJB(ext, manifest, blocklist, relLocation) {
return checkBlocklist(ext, manifest, blocklist, relLocation);
}
function checkThemeWithCodeJB(ext, manifest, relLocation) {
const findings = [];
const themeProviders = manifest.themeProviders || [];
const extDecls = manifest.extensionDeclarations || [];
const appComps = manifest.applicationComponents || [];
if (themeProviders.length === 0) return findings;
if (extDecls.length > themeProviders.length || appComps.length > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.HIGH,
title: `checkThemeWithCodeJB: theme plugin has executable code: ${ext.id}`,
description: 'JetBrains plugin declares themeProviders but also has executable extension points or application-components. Theme plugins should be UI-only.',
file: relLocation,
evidence: `themeProviders=${themeProviders.length} extensions=${extDecls.length} applicationComponents=${appComps.length}`,
owasp: 'LLM06, ASI02',
recommendation: 'Audit non-theme extension points. Consider uninstalling.',
}));
}
return findings;
}
function checkBroadActivationJB(ext, manifest, relLocation) {
const findings = [];
const appComps = manifest.applicationComponents || [];
const listeners = manifest.listeners || [];
const extDecls = manifest.extensionDeclarations || [];
const hasAppLifecycleListener = listeners.some(l => typeof l.topic === 'string' && l.topic.includes('AppLifecycleListener'));
if (appComps.length > 0 || hasAppLifecycleListener) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.HIGH,
title: `checkBroadActivationJB: eager startup activation: ${ext.id}`,
description: appComps.length > 0
? 'Plugin declares legacy <application-components> which load at IDE startup. Deprecated but not malicious — review for necessity.'
: 'Plugin listens to AppLifecycleListener.appStarted — runs at IDE startup.',
file: relLocation,
evidence: `applicationComponents=${appComps.length} listeners=${listeners.map(l => l.topic).join(',')}`,
owasp: 'LLM06',
recommendation: 'Verify startup-time activation is necessary.',
}));
return findings;
}
const POSTSTARTUP_NAMES = new Set(['postStartupActivity', 'backgroundPostStartupActivity']);
const postStartupCount = extDecls.filter(d => POSTSTARTUP_NAMES.has(d.name)).length;
const preloadAppService = extDecls.some(d =>
d.name === 'applicationService' && d.attrs && d.attrs.preload === 'true'
);
if (postStartupCount > 0 || preloadAppService) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkBroadActivationJB: post-startup activation: ${ext.id}`,
description: 'Plugin uses postStartupActivity or preloaded applicationService — runs shortly after IDE startup.',
file: relLocation,
evidence: `postStartupActivity=${postStartupCount} preloadAppService=${preloadAppService}`,
owasp: 'LLM06',
recommendation: 'Review what the startup activity does.',
}));
}
return findings;
}
// Whitelist: IDs that are legit despite being close to a corpus entry.
// Empty by design — the typosquat logic resolves most cases via "corpus entry wins".
const JB_TYPOSQUAT_WHITELIST = new Set();
function checkTyposquatJB(ext, topList, relLocation) {
const findings = [];
if (!Array.isArray(topList) || topList.length === 0) return findings;
const scannedId = normalizeId(ext.id);
if (JB_TYPOSQUAT_WHITELIST.has(scannedId)) return findings;
// Exact corpus match = legitimate canonical
for (const entry of topList) {
if (normalizeId(entry) === scannedId) return findings;
}
for (const entry of topList) {
const corpusId = normalizeId(entry);
if (corpusId === scannedId) continue;
if (Math.abs(corpusId.length - scannedId.length) > 2) continue;
const d = levenshtein(scannedId, corpusId);
if (d > 0 && d <= 2) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkTyposquatJB: possible typosquat: "${ext.id}" vs "${entry}" (distance=${d})`,
description: 'JetBrains plugin ID is close to a known legitimate plugin ID. See research brief §4 — JetBrains corpus is legitimate canonical IDs; anything within Levenshtein 2 is suspicious.',
file: relLocation,
evidence: `scanned=${scannedId} corpus=${corpusId} distance=${d}`,
owasp: 'LLM03',
recommendation: `Verify publisher. If "${entry}" was intended, uninstall this plugin and install the canonical one.`,
}));
break; // one finding per plugin
}
}
return findings;
}
function checkDependsChainJB(ext, manifest, relLocation) {
const findings = [];
const depends = manifest.depends || [];
if (depends.length < 3) return findings;
const hasMandatory = depends.some(d => d.optional === false);
if (!hasMandatory) return findings;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkDependsChainJB: deep mandatory dependency chain: ${ext.id}`,
description: `Plugin declares ${depends.length} <depends>, at least one mandatory — amplified trust chain.`,
file: relLocation,
evidence: `depends=${depends.map(d => d.id).slice(0, 5).join(',')}`,
owasp: 'LLM03',
recommendation: 'Audit each mandatory dependency.',
}));
return findings;
}
function checkPremainClassJB(ext, manifest, relLocation) {
const findings = [];
if (!manifest.hasPremainClass) return findings;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.HIGH,
title: `checkPremainClassJB: Java agent detected: ${ext.id}`,
description: `Plugin JAR declares Premain-Class=${manifest.premainClass} in MANIFEST.MF — loads as a JVM agent with bytecode-rewrite capability.`,
file: relLocation,
evidence: `premainClass=${manifest.premainClass}`,
owasp: 'LLM06, ASI02',
recommendation: 'Audit the premain class. Legitimate profilers will trigger this too — verify the vendor.',
}));
return findings;
}
function checkNativeBinariesJB(ext, manifest, relLocation) {
const findings = [];
const binaries = manifest.nativeBinaries || [];
if (binaries.length === 0) return findings;
const top = binaries.slice(0, 3).map(b => `${b.path}(${b.size}B, ${b.sha256.slice(0, 12)}…)`);
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkNativeBinariesJB: plugin bundles ${binaries.length} native binaries: ${ext.id}`,
description: 'Native binaries (.dll/.so/.dylib/.jnilib/.exe) run outside JVM sandbox. Benign for some plugins (e.g. jssc) but non-zero signal.',
file: relLocation,
evidence: top.join(' | '),
owasp: 'LLM06',
recommendation: 'Verify each binary via VirusTotal or vendor checksum.',
}));
return findings;
}
function checkShadedJarsJB(ext, manifest, relLocation) {
const findings = [];
const shaded = (manifest.bundledJars || []).filter(j => j.shaded);
if (shaded.length === 0) return findings;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkShadedJarsJB: ${shaded.length} shaded jars (cannot audit via OSV): ${ext.id}`,
description: 'Plugin bundles jars without Implementation-Title/Version in MANIFEST.MF. Vulnerability scanning against OSV/Maven coords not possible.',
file: relLocation,
evidence: `shaded=${shaded.slice(0, 5).map(j => j.name).join(',')}`,
owasp: 'LLM05',
recommendation: 'Ask vendor for SBOM; consider declining plugin if origin unknown.',
}));
return findings;
}
function runJetBrainsChecks(ext, manifest, topList, blocklist, relLocation) {
const out = [];
out.push(...checkBlocklistJB(ext, manifest, blocklist, relLocation));
out.push(...checkThemeWithCodeJB(ext, manifest, relLocation));
out.push(...checkBroadActivationJB(ext, manifest, relLocation));
out.push(...checkTyposquatJB(ext, topList, relLocation));
out.push(...checkDependsChainJB(ext, manifest, relLocation));
out.push(...checkPremainClassJB(ext, manifest, relLocation));
out.push(...checkNativeBinariesJB(ext, manifest, relLocation));
out.push(...checkShadedJarsJB(ext, manifest, relLocation));
return out;
}
// ---------------------------------------------------------------------------
// Reused-scanner orchestration per extension
// ---------------------------------------------------------------------------
async function scanOneExtension(ext, options) {
const started = Date.now();
const warnings = [];
// Parse manifest — dispatch on extension type
const parsed = ext.type === 'jetbrains'
? await parseIntelliJPlugin(ext.location)
: await parseVSCodeExtension(ext.location);
if (!parsed) {
return {
id: ext.id,
version: ext.version,
type: ext.type,
location: ext.location,
publisher: ext.publisher,
source: ext.source,
is_builtin: ext.isBuiltin,
signed: ext.signed,
scanner_results: {},
warnings: [`failed to parse manifest for ${ext.id}`],
aggregate: { counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, risk_score: 0, risk_band: 'Low', verdict: 'ALLOW' },
duration_ms: Date.now() - started,
};
}
const manifest = parsed.manifest;
warnings.push(...parsed.warnings);
const isJetBrains = ext.type === 'jetbrains';
const topList = isJetBrains ? [] : await loadTopVSCode();
const blocklist = isJetBrains ? [] : await loadVSCodeBlocklist();
const topListJB = isJetBrains ? await loadTopJetBrains() : [];
const blocklistJB = isJetBrains ? await loadJetBrainsBlocklist() : [];
const relLocation = relative(options.targetBase || ext.location, ext.location) || '.';
// Discover files (Pass A) — excludes node_modules, used for ENT/NET/TNT/UNI
const discovery = await discoverFiles(ext.location).catch(() => ({ files: [], skipped: 0, truncated: false }));
// Pass B for MEM — filter to README/CHANGELOG/package.json only (VS Code),
// plus plugin.xml and META-INF/MANIFEST.MF for JetBrains plugins.
const memFiles = discovery.files.filter(f => {
const lower = (f.relPath || '').toLowerCase();
if (lower === 'readme.md' || lower === 'changelog.md' || lower === 'package.json') return true;
if (isJetBrains) {
if (lower === 'plugin.xml' || lower.endsWith('/plugin.xml')) return true;
if (lower === 'meta-inf/manifest.mf' || lower.endsWith('/meta-inf/manifest.mf')) return true;
}
return false;
});
// IDE-specific findings — dispatch on extension type
const ideFindings = isJetBrains
? runJetBrainsChecks(
{ ...ext, signed: manifest.hasSignature || ext.signed },
manifest,
topListJB,
blocklistJB,
relLocation,
)
: runIdeChecks(
{ ...ext, signed: manifest.hasSignature || ext.signed },
manifest,
topList,
blocklist,
relLocation,
);
const ideResult = scannerResult(SCANNER, 'ok', ideFindings, 1, Date.now() - started);
// Run reused scanners (each is independent; run sequentially to avoid burst-rate issues)
const scanner_results = { IDE: ideResult };
try {
scanner_results.UNI = await scanUnicode(ext.location, discovery);
} catch (err) {
scanner_results.UNI = scannerResult('UNI', 'error', [], 0, 0, err.message);
}
try {
scanner_results.ENT = await scanEntropy(ext.location, discovery);
} catch (err) {
scanner_results.ENT = scannerResult('ENT', 'error', [], 0, 0, err.message);
}
try {
scanner_results.NET = await scanNetwork(ext.location, discovery);
} catch (err) {
scanner_results.NET = scannerResult('NET', 'error', [], 0, 0, err.message);
}
try {
scanner_results.TNT = await scanTaint(ext.location, discovery);
} catch (err) {
scanner_results.TNT = scannerResult('TNT', 'error', [], 0, 0, err.message);
}
try {
scanner_results.MEM = await scanMemoryPoisoning(ext.location, { ...discovery, files: memFiles });
} catch (err) {
scanner_results.MEM = scannerResult('MEM', 'error', [], 0, 0, err.message);
}
try {
// SCR walks its own lockfiles; discovery is unused by it.
scanner_results.SCR = await scanSupplyChain(ext.location, discovery);
} catch (err) {
scanner_results.SCR = scannerResult('SCR', 'error', [], 0, 0, err.message);
}
// Aggregate per-extension
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const r of Object.values(scanner_results)) {
for (const sev of Object.keys(counts)) {
counts[sev] += (r.counts && r.counts[sev]) || 0;
}
}
const score = riskScore(counts);
return {
id: ext.id,
version: ext.version,
type: ext.type,
location: ext.location,
publisher: ext.publisher,
source: ext.source,
is_builtin: ext.isBuiltin,
signed: manifest.hasSignature || ext.signed,
warnings,
scanner_results,
aggregate: {
counts,
risk_score: score,
risk_band: riskBand(score),
verdict: verdict(counts),
},
duration_ms: Date.now() - started,
};
}
// ---------------------------------------------------------------------------
// Bounded concurrency helper
// ---------------------------------------------------------------------------
async function mapConcurrent(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
async function worker() {
while (true) {
const idx = i++;
if (idx >= items.length) return;
out[idx] = await fn(items[idx], idx);
}
}
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, () => worker());
await Promise.all(workers);
return out;
}
// ---------------------------------------------------------------------------
// Top-level scan
// ---------------------------------------------------------------------------
/**
* Discover + scan installed extensions.
* @param {string|null} target - null/'.' => discover all; absolute path to an extracted ext dir => scan single.
* @param {object} [options]
* @param {boolean} [options.vscodeOnly=false]
* @param {boolean} [options.intellijOnly=false]
* @param {boolean} [options.includeBuiltin=false]
* @param {boolean} [options.online=false]
* @param {string[]} [options.rootsOverride]
* @param {number} [options.concurrency=4]
* @returns {Promise<object>} - Envelope
*/
export async function scan(target, options = {}) {
const started = Date.now();
const warnings = [];
let extensions = [];
let rootsScanned = [];
let urlSource = null;
let urlTempDir = null;
// URL mode: fetch plugin archive, extract to temp dir, then treat extracted dir as single target.
if (isUrlTarget(target)) {
const detected = detectUrlType(target);
if (detected.type === 'unknown') {
warnings.push(`unsupported URL: ${target} (expected VS Code Marketplace, OpenVSX, direct .vsix, or plugins.jetbrains.com)`);
} else if (detected.type === 'github') {
warnings.push('GitHub repo URLs are not supported in v6.4.0 — would require build step. Use the Marketplace, OpenVSX, or a direct .vsix link.');
} else if (detected.type === 'jetbrains') {
try {
const fetched = await fetchAndExtractPluginUrl(target, {
useSandbox: options.useSandbox,
workerKind: 'jetbrains',
});
urlSource = fetched.source;
urlTempDir = fetched.tempDir;
target = fetched.extRoot;
if (fetched.sandbox === null && options.useSandbox !== false) {
warnings.push('OS sandbox unavailable on this platform — JetBrains plugin extracted without sandbox-exec/bwrap. Defense-in-depth reduced to in-process zip-extract validation.');
}
} catch (err) {
warnings.push(`URL fetch/extract failed: ${err.message}`);
}
} else {
try {
const fetched = await fetchAndExtractVsixUrl(target, { useSandbox: options.useSandbox });
urlSource = fetched.source;
urlTempDir = fetched.tempDir;
target = fetched.extRoot; // forward into single-target path mode
if (fetched.sandbox === null && options.useSandbox !== false) {
warnings.push('OS sandbox unavailable on this platform — VSIX extracted without sandbox-exec/bwrap. Defense-in-depth reduced to in-process zip-extract validation.');
}
} catch (err) {
warnings.push(`URL fetch/extract failed: ${err.message}`);
}
}
}
const urlFetchFailed = isUrlTarget(target) && !urlSource;
const singleTargetPath = target && target !== '.' && target !== 'all' && !isUrlTarget(target)
? resolve(target)
: null;
try {
if (urlFetchFailed) {
// Don't fall through to discovery when the user asked for a specific URL.
} else if (singleTargetPath) {
// Single-directory mode — detect plugin type from layout.
// - `lib/*.jar` subtree → JetBrains plugin (parsed via parseIntelliJPlugin)
// - `package.json` at root → VS Code extension (parsed via parseVSCodeExtension)
// - neither → warn + skip
const hasLibDir = existsSync(join(singleTargetPath, 'lib'));
const hasPackageJson = existsSync(join(singleTargetPath, 'package.json'));
const isJetBrainsLayout = hasLibDir && !hasPackageJson;
if (isJetBrainsLayout) {
const parsed = await parseIntelliJPlugin(singleTargetPath);
if (!parsed || !parsed.manifest) {
warnings.push(`cannot parse JetBrains plugin at ${singleTargetPath}`);
if (parsed && parsed.warnings) warnings.push(...parsed.warnings);
} else {
const m = parsed.manifest;
extensions.push({
id: m.id,
publisher: m.publisher || null,
name: m.name || null,
version: m.version || null,
location: singleTargetPath,
type: 'jetbrains',
source: null,
isBuiltin: false,
installedTimestamp: null,
targetPlatform: null,
publisherDisplayName: null,
signed: false,
rootDir: singleTargetPath,
});
rootsScanned.push(singleTargetPath);
warnings.push(...parsed.warnings);
}
} else if (hasPackageJson) {
const parsed = await parseVSCodeExtension(singleTargetPath);
if (!parsed) {
warnings.push(`cannot parse extension at ${singleTargetPath}`);
} else {
const m = parsed.manifest;
extensions.push({
id: m.id,
publisher: m.publisher,
name: m.name,
version: m.version,
location: singleTargetPath,
type: 'vscode',
source: null,
isBuiltin: false,
installedTimestamp: null,
targetPlatform: null,
publisherDisplayName: null,
signed: m.hasSignature,
rootDir: singleTargetPath,
});
rootsScanned.push(singleTargetPath);
}
} else {
warnings.push(`cannot determine plugin type at ${singleTargetPath} (no package.json, no lib/ dir)`);
}
} else {
// Discovery mode
if (!options.intellijOnly) {
const vs = await discoverVSCodeExtensions({
rootsOverride: options.rootsOverride,
includeBuiltin: options.includeBuiltin,
followSymlinks: options.followSymlinks,
});
extensions.push(...vs.extensions);
warnings.push(...vs.warnings);
rootsScanned.push(...vs.rootsScanned);
}
if (!options.vscodeOnly) {
const jb = await discoverJetBrainsExtensions({
rootsOverride: options.rootsOverride,
});
extensions.push(...jb.extensions);
warnings.push(...jb.warnings);
rootsScanned.push(...jb.rootsScanned);
}
}
const targetBase = singleTargetPath || (rootsScanned[0] || process.cwd());
const concurrency = Math.max(1, Math.min(options.concurrency || 4, 16));
const perExt = await mapConcurrent(extensions, concurrency, ext =>
scanOneExtension(ext, { targetBase, online: options.online === true }));
// Top-level aggregate
const aggCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
let blocked = 0, warningCount = 0;
for (const r of perExt) {
for (const sev of Object.keys(aggCounts)) aggCounts[sev] += r.aggregate.counts[sev] || 0;
if (r.aggregate.verdict === 'BLOCK') blocked++;
else if (r.aggregate.verdict === 'WARNING') warningCount++;
}
const topScore = riskScore(aggCounts);
return {
meta: {
scanner: 'ide-extension-scanner',
version: VERSION,
target: urlSource ? urlSource.url : (singleTargetPath || (target || 'discover-all')),
timestamp: new Date().toISOString(),
node_version: process.version,
duration_ms: Date.now() - started,
extensions_discovered: {
vscode: extensions.filter(e => e.type === 'vscode').length,
jetbrains: extensions.filter(e => e.type === 'jetbrains').length,
},
roots_scanned: rootsScanned,
online: options.online === true,
source: urlSource,
warnings,
},
extensions: perExt,
aggregate: {
counts: aggCounts,
risk_score: topScore,
risk_band: riskBand(topScore),
verdict: verdict(aggCounts),
extensions_total: extensions.length,
extensions_blocked: blocked,
extensions_warning: warningCount,
},
};
} finally {
if (urlTempDir) {
await rm(urlTempDir, { recursive: true, force: true }).catch(() => {});
}
}
}
// Internal exports for unit testing only — not a stable API.
export const __testing = {
runJetBrainsChecks,
checkThemeWithCodeJB,
checkBroadActivationJB,
checkTyposquatJB,
checkDependsChainJB,
checkPremainClassJB,
checkNativeBinariesJB,
checkShadedJarsJB,
scanOneExtension,
};
/**
* Discovery-only (for tests/debugging).
* @param {object} [options]
*/
export async function discoverAll(options = {}) {
const vs = await discoverVSCodeExtensions({
rootsOverride: options.rootsOverride,
includeBuiltin: options.includeBuiltin,
followSymlinks: options.followSymlinks,
});
return vs.extensions;
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { target: null, vscodeOnly: false, intellijOnly: false, includeBuiltin: false, online: false, format: 'json', failOn: null, outputFile: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--vscode-only') args.vscodeOnly = true;
else if (a === '--intellij-only') args.intellijOnly = true;
else if (a === '--include-builtin') args.includeBuiltin = true;
else if (a === '--online') args.online = true;
else if (a === '--format') args.format = argv[++i];
else if (a === '--fail-on') args.failOn = argv[++i];
else if (a === '--output-file') args.outputFile = argv[++i];
else if (a === '--help' || a === '-h') args.help = true;
else if (!args.target) args.target = a;
}
return args;
}
function toCompact(env) {
const lines = [];
lines.push(`ide-extension-scanner v${VERSION}`);
lines.push(`target=${env.meta.target} extensions=${env.aggregate.extensions_total} duration=${env.meta.duration_ms}ms`);
lines.push(`verdict=${env.aggregate.verdict} risk=${env.aggregate.risk_score} (${env.aggregate.risk_band})`);
lines.push(`counts: crit=${env.aggregate.counts.critical} high=${env.aggregate.counts.high} med=${env.aggregate.counts.medium} low=${env.aggregate.counts.low} info=${env.aggregate.counts.info}`);
for (const ext of env.extensions) {
if (ext.aggregate.verdict === 'ALLOW' && ext.aggregate.counts.info === 0) continue;
lines.push(`- ${ext.id}@${ext.version}${ext.aggregate.verdict} (risk=${ext.aggregate.risk_score})`);
const all = Object.values(ext.scanner_results || {}).flatMap(r => r.findings || []);
for (const f of all.slice(0, 3)) {
lines.push(` [${f.severity.toUpperCase()}] ${f.scanner}: ${f.title}`);
}
}
return lines.join('\n');
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(`ide-extension-scanner v${VERSION}
Usage: node ide-extension-scanner.mjs [target] [options]
target: omitted/"."/"all" = discover all installed; path to extracted extension directory = single scan;
https://marketplace.visualstudio.com/items?itemName=publisher.name = fetch from Marketplace;
https://open-vsx.org/extension/publisher/name[/version] = fetch from OpenVSX;
https://example.com/path/foo.vsix = direct VSIX download
Options:
--vscode-only Skip JetBrains discovery
--intellij-only Skip VS Code discovery
--include-builtin Include Microsoft builtin extensions
--online Enable Marketplace/OSV.dev lookups (opt-in)
--format <fmt> json (default) | compact
--fail-on <severity> Exit 1 if findings at/above severity (critical|high|medium|low)
--output-file <path> Write JSON envelope to file (still prints compact to stdout)
-h, --help Show help
`);
process.exit(0);
}
const env = await scan(args.target, {
vscodeOnly: args.vscodeOnly,
intellijOnly: args.intellijOnly,
includeBuiltin: args.includeBuiltin,
online: args.online,
});
if (args.outputFile) {
try { writeFileSync(args.outputFile, JSON.stringify(env, null, 2)); }
catch (err) { console.error(`Failed to write ${args.outputFile}: ${err.message}`); process.exit(3); }
console.log(toCompact(env));
} else if (args.format === 'compact') {
console.log(toCompact(env));
} else {
console.log(JSON.stringify(env, null, 2));
}
if (args.failOn) {
const order = ['low', 'medium', 'high', 'critical'];
const threshold = order.indexOf(String(args.failOn).toLowerCase());
if (threshold < 0) {
console.error(`Invalid --fail-on: ${args.failOn}`);
process.exit(2);
}
for (let i = threshold; i < order.length; i++) {
if ((env.aggregate.counts[order[i]] || 0) > 0) process.exit(1);
}
}
}
const isMain = fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
main().catch(err => {
console.error(err.stack || err.message || err);
process.exit(2);
});
}