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:
Kjell Tore Guttormsen 2026-04-17 16:23:35 +02:00
commit 6252e55700
33 changed files with 1849 additions and 20 deletions

View 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;
}

View 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: [],
};
}

View 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;
}