// ide-extension-scanner.test.mjs — Integration tests for the IDE extension scanner. // // Uses fixture trees under tests/fixtures/ide-extensions/ to simulate // real ~/.vscode/extensions/ layouts via rootsOverride injection. import { describe, it, before, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resetCounter } from '../../scanners/lib/output.mjs'; import { scan, discoverAll, __testing as scannerInternals } from '../../scanners/ide-extension-scanner.mjs'; import { discoverVSCodeExtensions, parseDirName, } from '../../scanners/lib/ide-extension-discovery.mjs'; import { parseVSCodeExtension } from '../../scanners/lib/ide-extension-parser.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const FIXTURES = resolve(__dirname, '../fixtures/ide-extensions'); const ROOT_BENIGN = resolve(FIXTURES, 'root-benign'); const ROOT_MIXED = resolve(FIXTURES, 'root-mixed'); describe('parseDirName', () => { it('parses plain publisher.name-version', () => { const out = parseDirName('ms-python.python-2024.1.0'); assert.ok(out); assert.equal(out.publisher, 'ms-python'); assert.equal(out.name, 'python'); assert.equal(out.version, '2024.1.0'); assert.equal(out.targetPlatform, null); }); it('parses prerelease suffix', () => { const out = parseDirName('publisher.name-1.2.3-beta.1'); assert.ok(out); assert.equal(out.version, '1.2.3-beta.1'); }); it('parses target platform suffix', () => { const out = parseDirName('publisher.name-1.2.3-darwin-x64'); assert.ok(out); assert.equal(out.version, '1.2.3'); assert.equal(out.targetPlatform, 'darwin-x64'); }); it('returns null for non-version-shaped dir', () => { const out = parseDirName('.obsolete'); assert.equal(out, null); }); it('returns null when identifier has no dot', () => { const out = parseDirName('noDotInIdentifier-1.0.0'); assert.equal(out, null); }); }); describe('parseVSCodeExtension', () => { it('parses a valid extension manifest', async () => { const p = resolve(ROOT_BENIGN, 'publisher.benign-ext-1.0.0'); const res = await parseVSCodeExtension(p); assert.ok(res); assert.equal(res.manifest.id, 'publisher.benign-ext'); assert.equal(res.manifest.publisher, 'publisher'); assert.equal(res.manifest.name, 'benign-ext'); assert.equal(res.manifest.main, './extension.js'); assert.ok(Array.isArray(res.manifest.activationEvents)); }); it('returns null when package.json missing', async () => { const res = await parseVSCodeExtension('/nonexistent/path'); assert.equal(res, null); }); }); describe('discoverVSCodeExtensions', () => { it('discovers extensions under rootsOverride', async () => { const { extensions, warnings, rootsScanned } = await discoverVSCodeExtensions({ rootsOverride: [ROOT_BENIGN], }); assert.equal(rootsScanned.length, 1); assert.equal(extensions.length, 2); const ids = extensions.map(e => e.id).sort(); assert.deepEqual(ids, ['publisher.benign-ext', 'theme.goodtheme']); assert.equal(warnings.length, 0); }); it('reads source/isBuiltin from extensions.json index', async () => { const { extensions } = await discoverVSCodeExtensions({ rootsOverride: [ROOT_MIXED], }); const sideloaded = extensions.find(e => e.id === 'sideloaded.extension'); assert.ok(sideloaded); assert.equal(sideloaded.source, 'vsix'); assert.equal(sideloaded.isBuiltin, false); const gallery = extensions.find(e => e.id === 'wildcard.activator'); assert.equal(gallery.source, 'gallery'); }); }); describe('ide-extension-scanner integration', () => { beforeEach(() => { resetCounter(); }); it('benign root: no CRITICAL or HIGH IDE findings', async () => { const env = await scan('all', { rootsOverride: [ROOT_BENIGN], vscodeOnly: true }); assert.equal(env.meta.extensions_discovered.vscode, 2); const ideCrit = env.extensions.flatMap(e => e.scanner_results.IDE.findings) .filter(f => f.severity === 'critical'); assert.equal(ideCrit.length, 0, `Expected no CRITICAL, got ${ideCrit.map(f => f.title).join('; ')}`); }); it('detects theme-with-code (HIGH) on evil.theme-with-code', async () => { const env = await scan('all', { rootsOverride: [ROOT_MIXED], vscodeOnly: true }); const ext = env.extensions.find(e => e.id === 'evil.theme-with-code'); assert.ok(ext, 'evil.theme-with-code not found'); const themeFindings = ext.scanner_results.IDE.findings.filter(f => f.title.toLowerCase().includes('theme')); assert.ok(themeFindings.length >= 1, 'expected theme-with-code finding'); assert.equal(themeFindings[0].severity, 'high'); }); it('detects typosquat (MEDIUM at distance=2 against top-50) on ms-pythom.pythom', async () => { const env = await scan('all', { rootsOverride: [ROOT_MIXED], vscodeOnly: true }); const ext = env.extensions.find(e => e.id === 'ms-pythom.pythom'); assert.ok(ext); const typo = ext.scanner_results.IDE.findings.filter(f => f.title.toLowerCase().includes('typosquat')); assert.ok(typo.length >= 1, 'expected typosquat finding'); assert.equal(typo[0].severity, 'medium'); assert.ok(typo[0].title.includes('ms-python.python')); }); it('detects sideload (HIGH unsigned) on sideloaded.extension', async () => { const env = await scan('all', { rootsOverride: [ROOT_MIXED], vscodeOnly: true }); const ext = env.extensions.find(e => e.id === 'sideloaded.extension'); assert.ok(ext); const sf = ext.scanner_results.IDE.findings.filter(f => f.title.toLowerCase().includes('sideloaded')); assert.ok(sf.length >= 1); assert.equal(sf[0].severity, 'high'); }); it('detects wildcard activation (MEDIUM) on wildcard.activator', async () => { const env = await scan('all', { rootsOverride: [ROOT_MIXED], vscodeOnly: true }); const ext = env.extensions.find(e => e.id === 'wildcard.activator'); assert.ok(ext); const w = ext.scanner_results.IDE.findings.filter(f => f.title.toLowerCase().includes('wildcard activation')); assert.ok(w.length >= 1, 'expected wildcard activation finding'); assert.equal(w[0].severity, 'medium'); }); it('detects dangerous uninstall hook (HIGH) on hook.uninstall', async () => { const env = await scan('all', { rootsOverride: [ROOT_MIXED], vscodeOnly: true }); const ext = env.extensions.find(e => e.id === 'hook.uninstall'); assert.ok(ext); const h = ext.scanner_results.IDE.findings.filter(f => f.title.toLowerCase().includes('uninstall hook')); assert.ok(h.length >= 1, 'expected uninstall-hook finding'); assert.equal(h[0].severity, 'high'); }); it('detects extension pack expansion (MEDIUM) on pack.big', async () => { const env = await scan('all', { rootsOverride: [ROOT_MIXED], vscodeOnly: true }); const ext = env.extensions.find(e => e.id === 'pack.big'); assert.ok(ext); const p = ext.scanner_results.IDE.findings.filter(f => f.title.toLowerCase().includes('extension pack')); assert.ok(p.length >= 1); assert.equal(p[0].severity, 'medium'); }); it('top-level verdict is WARNING/BLOCK for mixed root', async () => { const env = await scan('all', { rootsOverride: [ROOT_MIXED], vscodeOnly: true }); assert.ok( env.aggregate.verdict === 'WARNING' || env.aggregate.verdict === 'BLOCK', `Expected WARNING/BLOCK, got ${env.aggregate.verdict}`, ); }); it('all findings have DS-IDE- prefix', async () => { const env = await scan('all', { rootsOverride: [ROOT_MIXED], vscodeOnly: true }); for (const ext of env.extensions) { const ideFindings = ext.scanner_results.IDE.findings; for (const f of ideFindings) { assert.ok(f.id.startsWith('DS-IDE-'), `Expected DS-IDE- prefix, got ${f.id}`); } } }); it('single-target mode scans one extracted directory', async () => { const target = resolve(ROOT_BENIGN, 'publisher.benign-ext-1.0.0'); const env = await scan(target, { vscodeOnly: true }); assert.equal(env.extensions.length, 1); assert.equal(env.extensions[0].id, 'publisher.benign-ext'); }); it('discoverAll returns extensions list', async () => { const exts = await discoverAll({ rootsOverride: [ROOT_BENIGN] }); assert.equal(exts.length, 2); }); it('envelope shape is valid', async () => { const env = await scan('all', { rootsOverride: [ROOT_BENIGN], vscodeOnly: true }); assert.ok(env.meta); assert.ok(env.extensions); assert.ok(env.aggregate); assert.ok(env.meta.scanner); assert.ok(env.meta.version); assert.ok(typeof env.meta.duration_ms === 'number'); assert.ok(Array.isArray(env.meta.roots_scanned)); assert.ok(env.aggregate.counts); assert.ok(typeof env.aggregate.risk_score === 'number'); }); }); // --------------------------------------------------------------------------- // JetBrains check unit tests — crafted manifests, no filesystem fixtures. // --------------------------------------------------------------------------- const jbExt = (id) => ({ id, publisher: '', name: id, version: '1.0', location: '/fake/path', type: 'jetbrains', source: null, isBuiltin: false, installedTimestamp: null, targetPlatform: null, publisherDisplayName: null, signed: false, rootDir: '/fake', productDir: 'IntelliJIdea2024.3', }); describe('runJetBrainsChecks — Premain-Class detection', () => { it('HIGH finding when hasPremainClass = true', () => { const findings = scannerInternals.checkPremainClassJB( jbExt('com.example.premain'), { hasPremainClass: true, premainClass: 'com.example.Agent' }, 'plugins/com.example.premain' ); assert.equal(findings.length, 1); assert.equal(findings[0].severity, 'high'); assert.ok(findings[0].title.includes('checkPremainClassJB')); }); it('no finding when hasPremainClass = false', () => { const findings = scannerInternals.checkPremainClassJB( jbExt('com.example.clean'), { hasPremainClass: false, premainClass: null }, 'plugins/com.example.clean' ); assert.equal(findings.length, 0); }); }); describe('runJetBrainsChecks — native binaries', () => { it('MEDIUM finding when nativeBinaries non-empty', () => { const findings = scannerInternals.checkNativeBinariesJB( jbExt('com.example.native'), { nativeBinaries: [{ path: 'x.so', size: 100, sha256: 'a'.repeat(64) }] }, 'plugins/com.example.native' ); assert.equal(findings.length, 1); assert.equal(findings[0].severity, 'medium'); assert.ok(findings[0].title.includes('checkNativeBinariesJB')); }); }); describe('runJetBrainsChecks — broad activation', () => { it('HIGH on application-components declared', () => { const findings = scannerInternals.checkBroadActivationJB( jbExt('com.example.broad'), { applicationComponents: ['com.X'], listeners: [], extensionDeclarations: [] }, 'plugins/com.example.broad' ); assert.equal(findings.length, 1); assert.equal(findings[0].severity, 'high'); }); it('MEDIUM on postStartupActivity only', () => { const findings = scannerInternals.checkBroadActivationJB( jbExt('com.example.post'), { applicationComponents: [], listeners: [], extensionDeclarations: [{ namespace: 'com.intellij', name: 'postStartupActivity', attrs: {} }], }, 'plugins/com.example.post' ); assert.equal(findings.length, 1); assert.equal(findings[0].severity, 'medium'); }); }); describe('runJetBrainsChecks — typosquat', () => { it('MEDIUM when distance 1 from canonical corpus entry', () => { const findings = scannerInternals.checkTyposquatJB( jbExt('com.intellij.jaba'), ['com.intellij.java', 'org.jetbrains.kotlin'], 'plugins/com.intellij.jaba' ); assert.equal(findings.length, 1); assert.equal(findings[0].severity, 'medium'); }); it('no finding on exact corpus match', () => { const findings = scannerInternals.checkTyposquatJB( jbExt('com.intellij.java'), ['com.intellij.java'], 'plugins/com.intellij.java' ); assert.equal(findings.length, 0); }); }); describe('runJetBrainsChecks — depends chain', () => { it('MEDIUM when 3+ depends with mandatory', () => { const findings = scannerInternals.checkDependsChainJB( jbExt('com.example.depchain'), { depends: [ { id: 'a', optional: false, configFile: null }, { id: 'b', optional: false, configFile: null }, { id: 'c', optional: true, configFile: null }, { id: 'd', optional: false, configFile: null }, ], }, 'plugins/com.example.depchain' ); assert.equal(findings.length, 1); assert.equal(findings[0].severity, 'medium'); }); it('no finding when all optional', () => { const findings = scannerInternals.checkDependsChainJB( jbExt('com.example.opt'), { depends: [ { id: 'a', optional: true, configFile: null }, { id: 'b', optional: true, configFile: null }, { id: 'c', optional: true, configFile: null }, ], }, 'plugins/com.example.opt' ); assert.equal(findings.length, 0); }); }); describe('runJetBrainsChecks — theme-with-code', () => { it('HIGH when themeProvider plus applicationComponents', () => { const findings = scannerInternals.checkThemeWithCodeJB( jbExt('com.example.twc'), { themeProviders: [{ id: 't', path: '/x' }], extensionDeclarations: [], applicationComponents: ['com.X'], }, 'plugins/com.example.twc' ); assert.equal(findings.length, 1); assert.equal(findings[0].severity, 'high'); }); }); describe('runJetBrainsChecks — shaded jars', () => { it('MEDIUM on shaded bundled jars', () => { const findings = scannerInternals.checkShadedJarsJB( jbExt('com.example.shade'), { bundledJars: [ { name: 'a.jar', version: null, shaded: true, coords: null }, { name: 'b.jar', version: '1.0', shaded: false, coords: 'b' }, ], }, 'plugins/com.example.shade' ); assert.equal(findings.length, 1); assert.equal(findings[0].severity, 'medium'); }); }); describe('runJetBrainsChecks — full dispatcher', () => { it('aggregates all JB checks', () => { const findings = scannerInternals.runJetBrainsChecks( jbExt('com.example.all'), { themeProviders: [], applicationComponents: [], listeners: [], extensionDeclarations: [], depends: [], hasPremainClass: true, premainClass: 'com.X', nativeBinaries: [], bundledJars: [], }, ['com.intellij.java'], [], 'plugins/com.example.all' ); assert.ok(findings.length >= 1); assert.ok(findings.some(f => f.title.includes('checkPremainClassJB'))); }); }); describe('blocklist matching', () => { it('matchBlocklistEntry matches wildcard version', async () => { // Unit-test the blocklist logic via scan with custom options — we inject // a fake blocklist-matching extension via rootsOverride + custom fixture. // Since production blocklist may be empty, we test the code path via a // minimal manual check: parse an extension and verify scanner does not // crash on empty blocklist. const env = await scan('all', { rootsOverride: [ROOT_BENIGN], vscodeOnly: true }); const allFindings = env.extensions.flatMap(e => e.scanner_results.IDE.findings); // No blocklist matches expected for the benign root const crit = allFindings.filter(f => f.severity === 'critical'); assert.equal(crit.length, 0); }); }); describe('scanOneExtension JetBrains dispatch', () => { // Fixture-free verification: synthesize an ExtensionRecord with type:'jetbrains' // and a minimal plugin dir (lib/ with META-INF/plugin.xml) at runtime via // tests/helpers/zip-writer.mjs. Does not depend on Step 13/14 fixtures. it('dispatches JetBrains records to parseIntelliJPlugin + runJetBrainsChecks', async () => { const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); const { join } = await import('node:path'); const { tmpdir } = await import('node:os'); const { createZip } = await import('../helpers/zip-writer.mjs'); const pluginRoot = await mkdtemp(join(tmpdir(), 'llmsec-jb-disp-')); try { const libDir = join(pluginRoot, 'lib'); await mkdir(libDir, { recursive: true }); const pluginXml = ` com.example.dispatch Dispatch Test 1.0.0 Example `; const jarBuf = createZip([ { name: 'META-INF/plugin.xml', data: pluginXml }, ]); await writeFile(join(libDir, 'main.jar'), jarBuf); const ext = { id: 'com.example.dispatch', version: '1.0.0', type: 'jetbrains', location: pluginRoot, publisher: 'Example', source: 'installed', isBuiltin: false, signed: false, }; const result = await scannerInternals.scanOneExtension(ext, { targetBase: pluginRoot }); assert.equal(result.id, 'com.example.dispatch'); assert.equal(result.type, 'jetbrains'); // IDE result must exist — dispatch produced findings (possibly empty array, but scanner ran) assert.ok(result.scanner_results.IDE, 'IDE scanner did not run'); assert.equal(result.scanner_results.IDE.status, 'ok'); // Manifest was parsed via parseIntelliJPlugin (not parseVSCodeExtension) — // the scanner wouldn't have a successful IDE result if it had tried to read // package.json from a dir that only has lib/main.jar. assert.ok(!result.warnings.some(w => String(w).includes('failed to parse manifest')), `parse failed: ${result.warnings.join('; ')}`); } finally { await rm(pluginRoot, { recursive: true, force: true }); } }); it('VS Code records still route through parseVSCodeExtension (regression guard)', async () => { const env = await scan('all', { rootsOverride: [ROOT_BENIGN], vscodeOnly: true }); // Two VS Code extensions parsed successfully from the existing benign fixture. assert.equal(env.meta.extensions_discovered.vscode, 2); // None of them should be tagged as jetbrains. assert.ok(env.extensions.every(e => e.type !== 'jetbrains')); // Each has a functioning IDE scanner result (VS Code path intact). for (const ext of env.extensions) { assert.ok(ext.scanner_results.IDE, `missing IDE result for ${ext.id}`); assert.equal(ext.scanner_results.IDE.status, 'ok'); } }); }); // --------------------------------------------------------------------------- // JetBrains discovery + scan — full integration over root-jetbrains fixture // --------------------------------------------------------------------------- describe('JetBrains discovery + scan', () => { const ROOT_JB = resolve(FIXTURES, 'root-jetbrains'); let env; let allFindings; const findingsById = new Map(); // Build jars from source/ trees once before the suite. The builder is // idempotent + race-safe (atomic temp-then-rename, SHA-256 skip-if-match) // so it is safe to run under node:test's parallel file execution. before(async () => { const { buildJetBrainsFixtures } = await import('../helpers/build-jetbrains-fixtures.mjs'); await buildJetBrainsFixtures({ fixtureRoot: ROOT_JB }); resetCounter(); env = await scan('all', { rootsOverride: [ROOT_JB], intellijOnly: true }); allFindings = env.extensions.flatMap(e => e.scanner_results.IDE?.findings || []); for (const ext of env.extensions) { findingsById.set(ext.id, ext.scanner_results.IDE?.findings || []); } }); it('discovers >= 8 JetBrains plugins (Fleet excluded)', () => { assert.ok( env.meta.extensions_discovered.jetbrains >= 8, `expected >= 8 JB plugins, got ${env.meta.extensions_discovered.jetbrains}`, ); }); it('includes every benign and adversarial IntelliJ fixture', () => { for (const id of [ 'com.example.benign', 'com.example.theme-with-code', 'com.example.broad-activation', 'com.example.premain', 'com.example.native-binary', 'com.example.depends-chain', 'com.intellij.jaba', ]) { assert.ok( env.extensions.some(e => e.id === id), `expected extension ${id} in discovery, got: ${env.extensions.map(e => e.id).join(', ')}`, ); } }); it('includes the Android Studio fixture (path divergence test)', () => { assert.ok( env.extensions.some(e => e.id === 'com.google.example'), `expected com.google.example under AndroidStudio base, got: ${env.extensions.map(e => e.id).join(', ')}`, ); }); it('excludes Fleet plugins (different plugin model)', () => { assert.ok( env.extensions.every(e => !e.location.includes('Fleet')), `Fleet plugin leaked in: ${env.extensions.filter(e => e.location.includes('Fleet')).map(e => e.id).join(', ')}`, ); }); it('all JB extensions routed through JetBrains path (type=jetbrains)', () => { for (const ext of env.extensions) { assert.equal(ext.type, 'jetbrains', `${ext.id} had type=${ext.type}`); } }); it('theme-with-code fixture triggers checkThemeWithCodeJB', () => { const findings = findingsById.get('com.example.theme-with-code') || []; assert.ok( findings.some(f => f.title.includes('checkThemeWithCodeJB')), `expected theme-with-code finding; got: ${findings.map(f => f.title).join(' | ')}`, ); }); it('broad-activation fixture triggers checkBroadActivationJB', () => { const findings = findingsById.get('com.example.broad-activation') || []; assert.ok( findings.some(f => f.title.includes('checkBroadActivationJB')), `expected broad-activation finding; got: ${findings.map(f => f.title).join(' | ')}`, ); }); it('premain fixture triggers checkPremainClassJB (HIGH)', () => { const findings = findingsById.get('com.example.premain') || []; const premain = findings.filter(f => f.title.includes('checkPremainClassJB')); assert.ok(premain.length >= 1, `expected premain finding; got: ${findings.map(f => f.title).join(' | ')}`); assert.equal(premain[0].severity, 'high'); }); it('native-binary fixture triggers checkNativeBinariesJB', () => { const findings = findingsById.get('com.example.native-binary') || []; assert.ok( findings.some(f => f.title.includes('checkNativeBinariesJB')), `expected native-binary finding; got: ${findings.map(f => f.title).join(' | ')}`, ); }); it('depends-chain fixture triggers checkDependsChainJB', () => { const findings = findingsById.get('com.example.depends-chain') || []; assert.ok( findings.some(f => f.title.includes('checkDependsChainJB')), `expected depends-chain finding; got: ${findings.map(f => f.title).join(' | ')}`, ); }); it('typosquat fixture (com.intellij.jaba) triggers checkTyposquatJB', () => { const findings = findingsById.get('com.intellij.jaba') || []; assert.ok( findings.some(f => f.title.includes('checkTyposquatJB')), `expected typosquat finding; got: ${findings.map(f => f.title).join(' | ')}`, ); }); it('benign fixture produces no HIGH/CRITICAL JB findings', () => { const findings = findingsById.get('com.example.benign') || []; const highs = findings.filter(f => f.severity === 'high' || f.severity === 'critical'); assert.equal( highs.length, 0, `expected benign plugin to be clean; got: ${highs.map(f => f.title).join(' | ')}`, ); }); it('all JB findings carry DS-IDE- prefix', () => { for (const f of allFindings) { assert.ok(f.id.startsWith('DS-IDE-'), `expected DS-IDE- prefix, got ${f.id}`); } }); });