1110 lines
42 KiB
JavaScript
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 = '6.6.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);
|
|
});
|
|
}
|