ktg-plugin-marketplace/plugins/llm-security/scanners/ide-extension-scanner.mjs
Kjell Tore Guttormsen 9f893c3858 feat(llm-security): OS sandbox for /security ide-scan <url> (v6.5.0)
VSIX fetch + extract for URL targets now runs in a sub-process wrapped by
sandbox-exec (macOS) or bwrap (Linux), reusing the same primitives proven
by the v5.1 git-clone sandbox. Defense-in-depth — even if our own
zip-extract.mjs ever has a bypass, the kernel refuses any write outside
the per-scan temp directory.

New files:
- scanners/lib/vsix-fetch-worker.mjs — sub-process worker. Argv: --url
  --tmpdir; emits one JSON line on stdout (ok/sha256/size/source/extRoot
  or ok:false/error/code). Silent on stderr. Exit 0/1.
- scanners/lib/vsix-sandbox.mjs — wrapper. Exports buildSandboxProfile,
  buildBwrapArgs, buildSandboxedWorker, runVsixWorker. 35s timeout, 1 MB
  stdout cap.

Changes:
- scanners/ide-extension-scanner.mjs: fetchAndExtractVsixUrl is now
  sandbox-aware (useSandbox option, default true). In-process logic
  preserved as fallback. New meta.source.sandbox field:
  'sandbox-exec' | 'bwrap' | 'none' | 'in-process'.
- scan(target, { useSandbox }) defaults to true; tests pass false because
  globalThis.fetch mocks do not cross process boundaries.
- Windows fallback: in-process with meta.warnings advisory.

Tests:
- 8 new tests in tests/scanners/vsix-sandbox.test.mjs (per-platform
  profile generation, worker arg construction, live worker exit
  behavior on invalid URLs — no network).
- Existing URL tests updated to opt out of sandbox (useSandbox: false).
- 1344 → 1352 tests, all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 17:28:57 +02:00

719 lines
27 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 } from './lib/ide-extension-parser.mjs';
import { loadTopVSCode, loadVSCodeBlocklist, normalizeId } from './lib/ide-extension-data.mjs';
import { fetchVsixFromUrl, detectUrlType } from './lib/vsix-fetch.mjs';
import { extractToDir, ZipError } from './lib/zip-extract.mjs';
import { runVsixWorker } 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 = '6.5.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;
}
}
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// Reused-scanner orchestration per extension
// ---------------------------------------------------------------------------
async function scanOneExtension(ext, options) {
const started = Date.now();
const warnings = [];
// Parse manifest
const parsed = 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 topList = await loadTopVSCode();
const blocklist = await loadVSCodeBlocklist();
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
const memFiles = discovery.files.filter(f => {
const lower = (f.relPath || '').toLowerCase();
return lower === 'readme.md' || lower === 'changelog.md' || lower === 'package.json';
});
// IDE-specific findings
const ideFindings = 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 VSIX, 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, or direct .vsix)`);
} 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 {
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
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 {
// 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({});
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(() => {});
}
}
}
/**
* 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);
});
}