diff --git a/scanners/ide-extension-scanner.mjs b/scanners/ide-extension-scanner.mjs index f33a1c2..8666cfe 100644 --- a/scanners/ide-extension-scanner.mjs +++ b/scanners/ide-extension-scanner.mjs @@ -628,6 +628,30 @@ function runJetBrainsChecks(ext, manifest, topList, blocklist, relLocation) { // Reused-scanner orchestration per extension // --------------------------------------------------------------------------- +/** + * Result envelope for an extension that could not be scanned at all. + * Shape-compatible with a normal per-extension result so the top-level + * aggregation loop can consume it without special-casing. + * @param {object} ext + * @param {...string} reasons + */ +function unscannableExtension(ext, ...reasons) { + 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: reasons.filter(Boolean), + aggregate: { counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, risk_score: 0, risk_band: 'Low', verdict: 'ALLOW' }, + duration_ms: 0, + }; +} + async function scanOneExtension(ext, options) { const started = Date.now(); const warnings = []; @@ -636,19 +660,16 @@ async function scanOneExtension(ext, options) { const parsed = ext.type === 'jetbrains' ? await parseIntelliJPlugin(ext.location) : await parseVSCodeExtension(ext.location); - if (!parsed) { + // Two "unparseable" shapes reach here: parseVSCodeExtension returns a bare + // null, parseIntelliJPlugin returns a TRUTHY { manifest: null, warnings }. + // Both must short-circuit — otherwise the null manifest is dereferenced below. + if (!parsed || !parsed.manifest) { 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' }, + ...unscannableExtension( + ext, + `failed to parse manifest for ${ext.id}`, + ...(parsed?.warnings || []), + ), duration_ms: Date.now() - started, }; } @@ -935,8 +956,17 @@ export async function scan(target, options = {}) { 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 })); + // Fault isolation (belt): one unscannable extension must never abort the run. + // scanOneExtension is hardened against the known null-manifest case, but any + // future throw would otherwise reject mapConcurrent's Promise.all and fail the + // entire ide-scan because of a single bad plugin on disk. + const perExt = await mapConcurrent(extensions, concurrency, async ext => { + try { + return await scanOneExtension(ext, { targetBase, online: options.online === true }); + } catch (err) { + return unscannableExtension(ext, `scan failed: ${err?.message || err}`); + } + }); // Top-level aggregate const aggCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; diff --git a/tests/scanners/ide-extension-null-manifest.test.mjs b/tests/scanners/ide-extension-null-manifest.test.mjs new file mode 100644 index 0000000..74906eb --- /dev/null +++ b/tests/scanners/ide-extension-null-manifest.test.mjs @@ -0,0 +1,92 @@ +// ide-extension-null-manifest.test.mjs — Regression tests for unparseable +// JetBrains plugins. +// +// parseIntelliJPlugin signals "could not parse" as { manifest: null, warnings } +// — a TRUTHY object — while parseVSCodeExtension signals it as a bare null. +// scanOneExtension only guarded the bare-null form, so a JetBrains plugin with +// no lib/ directory, an unreadable lib/, no jars, or no extractable jar reached +// `manifest.hasSignature` and threw a TypeError. mapConcurrent had no per-item +// isolation, so that one plugin aborted the entire ide-scan. + +import { describe, it, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { __testing } from '../../scanners/ide-extension-scanner.mjs'; + +const { scanOneExtension } = __testing; +const created = []; + +after(async () => { + for (const dir of created) await rm(dir, { recursive: true, force: true }); +}); + +async function makePluginRoot(name) { + const root = await mkdtemp(join(tmpdir(), 'llmsec-jb-plugin-')); + created.push(root); + const dir = join(root, name); + await mkdir(dir, { recursive: true }); + return dir; +} + +function jbExt(location, id) { + return { + id, + version: '1.0.0', + type: 'jetbrains', + location, + publisher: 'unknown', + source: 'test', + isBuiltin: false, + signed: false, + }; +} + +describe('ide-extension-scanner — unparseable JetBrains plugin', () => { + it('does not throw when lib/ is missing entirely', async () => { + const dir = await makePluginRoot('NoLibPlugin'); + const result = await scanOneExtension(jbExt(dir, 'NoLibPlugin'), { targetBase: dir }); + assert.ok(result, 'expected a result object, not a throw'); + assert.equal(result.id, 'NoLibPlugin'); + assert.ok( + result.warnings.some(w => /lib/i.test(w)), + `expected a parse warning, got: ${JSON.stringify(result.warnings)}` + ); + assert.equal(result.aggregate.verdict, 'ALLOW'); + }); + + it('does not throw when lib/ exists but holds no jars', async () => { + const dir = await makePluginRoot('EmptyLibPlugin'); + await mkdir(join(dir, 'lib'), { recursive: true }); + await writeFile(join(dir, 'lib', 'notes.txt'), 'not a jar', 'utf8'); + const result = await scanOneExtension(jbExt(dir, 'EmptyLibPlugin'), { targetBase: dir }); + assert.ok(result, 'expected a result object, not a throw'); + assert.ok(result.warnings.length >= 1); + assert.equal(result.aggregate.verdict, 'ALLOW'); + }); + + it('does not throw when lib/ is a file rather than a directory', async () => { + const dir = await makePluginRoot('LibIsFilePlugin'); + await writeFile(join(dir, 'lib'), 'this is not a directory', 'utf8'); + const result = await scanOneExtension(jbExt(dir, 'LibIsFilePlugin'), { targetBase: dir }); + assert.ok(result, 'expected a result object, not a throw'); + assert.ok(result.warnings.length >= 1); + }); + + it('does not throw when the only jar is corrupt (unextractable)', async () => { + const dir = await makePluginRoot('CorruptJarPlugin'); + await mkdir(join(dir, 'lib'), { recursive: true }); + await writeFile(join(dir, 'lib', 'broken.jar'), 'PK truncated garbage', 'utf8'); + const result = await scanOneExtension(jbExt(dir, 'CorruptJarPlugin'), { targetBase: dir }); + assert.ok(result, 'expected a result object, not a throw'); + assert.ok(result.warnings.length >= 1); + }); + + it('reports a parse failure without inventing findings', async () => { + const dir = await makePluginRoot('QuietPlugin'); + const result = await scanOneExtension(jbExt(dir, 'QuietPlugin'), { targetBase: dir }); + const counts = result.aggregate.counts; + assert.equal(counts.critical + counts.high + counts.medium, 0); + }); +});