feat(llm-security): add /security ide-scan — VS Code / JetBrains extension prescan (v6.3.0)
New standalone scanner (prefix IDE) discovers installed VS Code extensions across forks (Cursor, Windsurf, VSCodium, code-server, Insiders, Remote-SSH) and runs 7 IDE-specific threat checks: blocklist match (CRITICAL), theme-with-code, sideload (unsigned .vsix), dangerous uninstall hook (HIGH), wildcard activation, extension-pack expansion, typosquat (MEDIUM). Per-extension reuse of UNI/ENT/NET/TNT/MEM/SCR scanners with bounded concurrency. Offline-first; --online opt-in. JetBrains discovery stubbed for v1.1. 22 new tests (1296 total, was 1274). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7bcf5fae9d
commit
6252e55700
33 changed files with 1849 additions and 20 deletions
589
plugins/llm-security/scanners/ide-extension-scanner.mjs
Normal file
589
plugins/llm-security/scanners/ide-extension-scanner.mjs
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
#!/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 } from 'node:fs';
|
||||
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 { 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.3.0';
|
||||
const SCANNER = 'IDE';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 = [];
|
||||
|
||||
const singleTargetPath = target && target !== '.' && target !== 'all' ? resolve(target) : null;
|
||||
|
||||
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: 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,
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
69
plugins/llm-security/scanners/lib/ide-extension-data.mjs
Normal file
69
plugins/llm-security/scanners/lib/ide-extension-data.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// ide-extension-data.mjs — Loads top-extensions + blocklist from knowledge files.
|
||||
// Zero dependencies (Node.js builtins only).
|
||||
// Used by ide-extension-scanner.mjs for typosquat + blocklist checks.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const KNOWLEDGE_DIR = join(__dirname, '..', '..', 'knowledge');
|
||||
|
||||
let _vscode = null;
|
||||
let _jetbrains = null;
|
||||
|
||||
async function loadJson(path) {
|
||||
try {
|
||||
const raw = await readFile(path, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load top VS Code extension IDs.
|
||||
* @returns {Promise<string[]>} Lowercased "publisher.name" strings.
|
||||
*/
|
||||
export async function loadTopVSCode() {
|
||||
if (_vscode !== null) return _vscode.vscode || [];
|
||||
_vscode = await loadJson(join(KNOWLEDGE_DIR, 'top-vscode-extensions.json')) || { vscode: [], blocklist: [] };
|
||||
return (_vscode.vscode || []).map(normalizeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load VS Code extension blocklist entries.
|
||||
* @returns {Promise<string[]>} Entries of form "publisher.name@version" or "publisher.name@*".
|
||||
*/
|
||||
export async function loadVSCodeBlocklist() {
|
||||
if (_vscode !== null) return _vscode.blocklist || [];
|
||||
_vscode = await loadJson(join(KNOWLEDGE_DIR, 'top-vscode-extensions.json')) || { vscode: [], blocklist: [] };
|
||||
return _vscode.blocklist || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load top JetBrains plugin IDs (stub for v1.1).
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function loadTopJetBrains() {
|
||||
if (_jetbrains !== null) return _jetbrains.jetbrains || [];
|
||||
_jetbrains = await loadJson(join(KNOWLEDGE_DIR, 'top-jetbrains-plugins.json')) || { jetbrains: [], blocklist: [] };
|
||||
return (_jetbrains.jetbrains || []).map(normalizeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize extension ID for comparison.
|
||||
* @param {string} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeId(id) {
|
||||
return String(id || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset cache (for tests).
|
||||
*/
|
||||
export function _resetCache() {
|
||||
_vscode = null;
|
||||
_jetbrains = null;
|
||||
}
|
||||
271
plugins/llm-security/scanners/lib/ide-extension-discovery.mjs
Normal file
271
plugins/llm-security/scanners/lib/ide-extension-discovery.mjs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
// ide-extension-discovery.mjs — OS-aware discovery of installed VS Code / JetBrains extensions.
|
||||
// Zero dependencies (Node.js builtins only).
|
||||
//
|
||||
// VS Code + forks (Cursor, Windsurf, VSCodium, code-server, Insiders, Remote-SSH):
|
||||
// Parses extensions.json (per-dir manifest) + falls back to dir-name regex.
|
||||
// JetBrains: stub (v1.1).
|
||||
|
||||
import { readFile, readdir, stat, lstat, access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OS path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return all candidate VS Code extension root directories for current OS.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getVSCodeExtensionRoots() {
|
||||
const home = homedir();
|
||||
const roots = [
|
||||
join(home, '.vscode', 'extensions'),
|
||||
join(home, '.vscode-insiders', 'extensions'),
|
||||
join(home, '.cursor', 'extensions'),
|
||||
join(home, '.windsurf', 'extensions'),
|
||||
join(home, '.vscode-oss', 'extensions'), // VSCodium
|
||||
join(home, '.vscode-server', 'extensions'), // Remote-SSH
|
||||
];
|
||||
if (process.platform === 'linux') {
|
||||
roots.push(join(home, '.local', 'share', 'code-server', 'extensions'));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the JetBrains base directory (contains per-IDE-per-version subdirectories).
|
||||
* Actual plugins live under <base>/<IDE><Edition><Version>/plugins/. v1.1 walks these.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getJetBrainsBaseDir() {
|
||||
const home = homedir();
|
||||
let base;
|
||||
if (process.platform === 'darwin') {
|
||||
base = join(home, 'Library', 'Application Support', 'JetBrains');
|
||||
} else if (process.platform === 'win32') {
|
||||
base = join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'JetBrains');
|
||||
} else {
|
||||
base = join(home, '.local', 'share', 'JetBrains');
|
||||
}
|
||||
return existsSync(base) ? base : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Known VS Code target-platform suffixes per
|
||||
// https://code.visualstudio.com/api/working-with-extensions/publishing-extension#platformspecific-extensions
|
||||
const PLATFORM_SUFFIXES = [
|
||||
'win32-x64', 'win32-ia32', 'win32-arm64',
|
||||
'linux-x64', 'linux-arm64', 'linux-armhf',
|
||||
'darwin-x64', 'darwin-arm64',
|
||||
'alpine-x64', 'alpine-arm64',
|
||||
'web',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse directory name of form "publisher.name-version[-platform]".
|
||||
* Strategy: strip a trailing known-platform suffix first, then match identifier + version.
|
||||
* Returns { publisher, name, version, targetPlatform } or null.
|
||||
* @param {string} dirName
|
||||
*/
|
||||
export function parseDirName(dirName) {
|
||||
let trimmed = dirName;
|
||||
let targetPlatform = null;
|
||||
for (const plat of PLATFORM_SUFFIXES) {
|
||||
const suffix = '-' + plat;
|
||||
if (trimmed.toLowerCase().endsWith(suffix)) {
|
||||
targetPlatform = plat;
|
||||
trimmed = trimmed.slice(0, -suffix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const m = trimmed.match(/^(.+?)-(\d+\.\d+\.\d+(?:-[a-z0-9.]+)?)$/i);
|
||||
if (!m) return null;
|
||||
const idPart = m[1];
|
||||
const version = m[2];
|
||||
const dotIdx = idPart.indexOf('.');
|
||||
if (dotIdx === -1) return null;
|
||||
return {
|
||||
publisher: idPart.slice(0, dotIdx),
|
||||
name: idPart.slice(dotIdx + 1),
|
||||
version,
|
||||
targetPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson(filePath) {
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(p) {
|
||||
try { await access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function isSymlink(p) {
|
||||
try {
|
||||
const s = await lstat(p);
|
||||
return s.isSymbolicLink();
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VS Code discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @typedef {object} ExtensionRecord
|
||||
* @property {string} id
|
||||
* @property {string} publisher
|
||||
* @property {string} name
|
||||
* @property {string} version
|
||||
* @property {string} location
|
||||
* @property {'vscode'|'jetbrains'} type
|
||||
* @property {'gallery'|'vsix'|null} source
|
||||
* @property {boolean} isBuiltin
|
||||
* @property {number|null} installedTimestamp
|
||||
* @property {string|null} targetPlatform
|
||||
* @property {string|null} publisherDisplayName
|
||||
* @property {boolean} signed
|
||||
* @property {string} rootDir
|
||||
*/
|
||||
|
||||
/**
|
||||
* Discover VS Code extensions across all roots.
|
||||
* @param {object} [options]
|
||||
* @param {string[]} [options.rootsOverride] - Test injection: use these roots only.
|
||||
* @param {boolean} [options.includeBuiltin=false]
|
||||
* @param {boolean} [options.followSymlinks=false]
|
||||
* @returns {Promise<{ extensions: ExtensionRecord[], warnings: string[], rootsScanned: string[] }>}
|
||||
*/
|
||||
export async function discoverVSCodeExtensions(options = {}) {
|
||||
const warnings = [];
|
||||
const extensions = [];
|
||||
const rootsScanned = [];
|
||||
|
||||
let roots;
|
||||
if (options.rootsOverride) {
|
||||
roots = options.rootsOverride;
|
||||
} else if (process.env.LLM_SECURITY_IDE_ROOTS) {
|
||||
roots = process.env.LLM_SECURITY_IDE_ROOTS.split(':').filter(Boolean);
|
||||
} else {
|
||||
roots = getVSCodeExtensionRoots();
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
if (!await pathExists(root)) continue;
|
||||
rootsScanned.push(root);
|
||||
|
||||
// Load per-root extensions.json (machine index) to get metadata.source, isBuiltin etc.
|
||||
const indexPath = join(root, 'extensions.json');
|
||||
const index = await readJson(indexPath);
|
||||
const metaByRelLoc = new Map();
|
||||
if (Array.isArray(index)) {
|
||||
for (const entry of index) {
|
||||
if (entry && entry.relativeLocation) {
|
||||
metaByRelLoc.set(entry.relativeLocation, entry);
|
||||
}
|
||||
}
|
||||
} else if (index !== null) {
|
||||
warnings.push(`malformed extensions.json in ${root}`);
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
warnings.push(`failed to read ${root}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip dotfiles, extensions.json, .obsolete
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
if (entry.name === 'extensions.json') continue;
|
||||
|
||||
// Check symlink handling
|
||||
if (entry.isSymbolicLink()) {
|
||||
if (!options.followSymlinks) continue;
|
||||
} else if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extDir = join(root, entry.name);
|
||||
const parsed = parseDirName(entry.name);
|
||||
|
||||
// Read package.json to get authoritative publisher + name
|
||||
const pkgPath = join(extDir, 'package.json');
|
||||
const pkg = await readJson(pkgPath);
|
||||
let publisher = pkg?.publisher;
|
||||
let name = pkg?.name;
|
||||
let version = pkg?.version;
|
||||
|
||||
if (!publisher || !name) {
|
||||
if (!parsed) {
|
||||
warnings.push(`could not identify extension in ${extDir}`);
|
||||
continue;
|
||||
}
|
||||
publisher = publisher || parsed.publisher;
|
||||
name = name || parsed.name;
|
||||
version = version || parsed.version;
|
||||
}
|
||||
|
||||
if (!publisher || !name || !version) {
|
||||
warnings.push(`incomplete identity for ${extDir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = `${publisher}.${name}`.toLowerCase();
|
||||
const indexEntry = metaByRelLoc.get(entry.name);
|
||||
const meta = indexEntry?.metadata || {};
|
||||
const isBuiltin = meta.isBuiltin === true;
|
||||
|
||||
if (isBuiltin && !options.includeBuiltin) continue;
|
||||
|
||||
const signed = await pathExists(join(extDir, '.signature.p7s'));
|
||||
|
||||
extensions.push({
|
||||
id,
|
||||
publisher: publisher.toLowerCase(),
|
||||
name: name.toLowerCase(),
|
||||
version,
|
||||
location: extDir,
|
||||
type: 'vscode',
|
||||
source: meta.source || null,
|
||||
isBuiltin,
|
||||
installedTimestamp: typeof meta.installedTimestamp === 'number' ? meta.installedTimestamp : null,
|
||||
targetPlatform: meta.targetPlatform || parsed?.targetPlatform || null,
|
||||
publisherDisplayName: meta.publisherDisplayName || null,
|
||||
signed,
|
||||
rootDir: root,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { extensions, warnings, rootsScanned };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JetBrains discovery — stub (v1.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover JetBrains plugins. Stub returns empty for v6.3.0.
|
||||
* @param {object} [options]
|
||||
* @returns {Promise<{ extensions: ExtensionRecord[], warnings: string[], rootsScanned: string[] }>}
|
||||
*/
|
||||
export async function discoverJetBrainsExtensions(options = {}) {
|
||||
return {
|
||||
extensions: [],
|
||||
warnings: ['IntelliJ plugin discovery deferred to v1.1 (see knowledge/ide-extension-threat-patterns.md)'],
|
||||
rootsScanned: [],
|
||||
};
|
||||
}
|
||||
112
plugins/llm-security/scanners/lib/ide-extension-parser.mjs
Normal file
112
plugins/llm-security/scanners/lib/ide-extension-parser.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// ide-extension-parser.mjs — Parse VS Code extension package.json into normalized manifest.
|
||||
// Zero dependencies (Node.js builtins only).
|
||||
|
||||
import { readFile, access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
async function pathExists(p) {
|
||||
try { await access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} ParsedManifest
|
||||
* @property {string} id
|
||||
* @property {string} publisher
|
||||
* @property {string} name
|
||||
* @property {string} version
|
||||
* @property {object} engines
|
||||
* @property {string|null} main
|
||||
* @property {string|null} browser
|
||||
* @property {string[]} activationEvents
|
||||
* @property {object} contributes
|
||||
* @property {string[]} extensionPack
|
||||
* @property {string[]} extensionDependencies
|
||||
* @property {string[]} extensionKind
|
||||
* @property {string[]} categories
|
||||
* @property {object} capabilities
|
||||
* @property {object} scripts
|
||||
* @property {object|string|null} repository
|
||||
* @property {object} dependencies
|
||||
* @property {boolean} hasSignature
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a VS Code extension directory.
|
||||
* @param {string} extRoot - Absolute path to extracted extension root.
|
||||
* @returns {Promise<{ manifest: ParsedManifest, warnings: string[] } | null>}
|
||||
*/
|
||||
export async function parseVSCodeExtension(extRoot) {
|
||||
const warnings = [];
|
||||
const pkgPath = join(extRoot, 'package.json');
|
||||
let raw;
|
||||
try {
|
||||
raw = await readFile(pkgPath, 'utf8');
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
warnings.push(`malformed package.json at ${pkgPath}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!pkg || typeof pkg !== 'object') {
|
||||
warnings.push(`package.json at ${pkgPath} is not an object`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const publisher = typeof pkg.publisher === 'string' ? pkg.publisher : '';
|
||||
const name = typeof pkg.name === 'string' ? pkg.name : '';
|
||||
const version = typeof pkg.version === 'string' ? pkg.version : '';
|
||||
|
||||
if (!publisher || !name) {
|
||||
warnings.push(`missing publisher/name in ${pkgPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasSignature = await pathExists(join(extRoot, '.signature.p7s'));
|
||||
|
||||
const manifest = {
|
||||
id: `${publisher}.${name}`.toLowerCase(),
|
||||
publisher: publisher.toLowerCase(),
|
||||
name: name.toLowerCase(),
|
||||
version,
|
||||
engines: pkg.engines && typeof pkg.engines === 'object' ? pkg.engines : {},
|
||||
main: typeof pkg.main === 'string' ? pkg.main : null,
|
||||
browser: typeof pkg.browser === 'string' ? pkg.browser : null,
|
||||
activationEvents: Array.isArray(pkg.activationEvents) ? pkg.activationEvents.filter(e => typeof e === 'string') : [],
|
||||
contributes: pkg.contributes && typeof pkg.contributes === 'object' ? pkg.contributes : {},
|
||||
extensionPack: Array.isArray(pkg.extensionPack) ? pkg.extensionPack.filter(e => typeof e === 'string') : [],
|
||||
extensionDependencies: Array.isArray(pkg.extensionDependencies) ? pkg.extensionDependencies.filter(e => typeof e === 'string') : [],
|
||||
extensionKind: Array.isArray(pkg.extensionKind) ? pkg.extensionKind.filter(e => typeof e === 'string') : [],
|
||||
categories: Array.isArray(pkg.categories) ? pkg.categories.filter(c => typeof c === 'string') : [],
|
||||
capabilities: pkg.capabilities && typeof pkg.capabilities === 'object' ? pkg.capabilities : {},
|
||||
scripts: pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {},
|
||||
repository: pkg.repository || null,
|
||||
dependencies: pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {},
|
||||
hasSignature,
|
||||
};
|
||||
|
||||
return { manifest, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a .vsix file. Stub for v1 — user must extract first.
|
||||
* @param {string} vsixPath
|
||||
* @throws {Error}
|
||||
*/
|
||||
export async function parseVsixFile(vsixPath) {
|
||||
throw new Error(`VSIX parsing not implemented in v6.3.0. Extract manually (unzip ${vsixPath}) and pass the extracted directory.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an IntelliJ plugin. Stub for v1.1.
|
||||
* @param {string} pluginRoot
|
||||
* @returns {Promise<null>}
|
||||
*/
|
||||
export async function parseIntelliJPlugin(pluginRoot) {
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue