fix(llm-security): HIGH — one unparseable JetBrains plugin crashed ide-scan

The two manifest parsers disagree on how they signal failure:
parseVSCodeExtension returns a bare null, parseIntelliJPlugin returns a
TRUTHY { manifest: null, warnings }. scanOneExtension guarded only the
bare-null form, so every JetBrains failure path — lib/ missing, lib/ not
a directory, lib/ unreadable, no jars in lib/, no jar extractable — fell
through the guard and dereferenced `manifest.hasSignature`.

The resulting TypeError propagated out of scanOneExtension into
mapConcurrent, which awaited fn() with no per-item try/catch, so
Promise.all rejected and the ENTIRE ide-scan aborted. One malformed
plugin directory on disk was enough to take down the scan of every
other installed extension — including, in an audit context, a plugin
that is malformed precisely because it is hostile.

- scanOneExtension: guard is now `!parsed || !parsed.manifest`, and the
  parser's own warnings are carried into the result so the reason for
  the skip survives instead of being replaced by a generic message.
- unscannableExtension(): the result envelope is extracted so the early
  return and the isolation belt below produce the same shape.
- Belt (defense-in-depth): the scanOneExtension call site catches per
  extension and yields an unscannable result. mapConcurrent stays
  generic — the isolation lives at the call site, not in the helper.

Regression test drives scanOneExtension across all four JetBrains parse
failure paths plus a no-invented-findings assertion.

npm test: 1884/1884 green (1879 + 5 new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-18 09:14:12 +02:00
commit 4e16ea0e72
2 changed files with 136 additions and 14 deletions

View file

@ -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);
});
});