#!/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.3.1'; 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 * . 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 * , not `/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 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} , 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} - 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 json (default) | compact --fail-on Exit 1 if findings at/above severity (critical|high|medium|low) --output-file 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); }); }