feat(llm-security): implement parseIntelliJPlugin with nested-jar extraction

This commit is contained in:
Kjell Tore Guttormsen 2026-04-18 10:15:12 +02:00
commit 5afb9b1f33
3 changed files with 479 additions and 7 deletions

View file

@ -0,0 +1,127 @@
// zip-writer.mjs — Minimal stored-method (no compression) ZIP writer.
// Zero dependencies. Deterministic output: fixed DOS timestamp, sorted entry order.
//
// Writes a valid ZIP that zip-extract.mjs can parse. Uses method=0 (STORE),
// CRC-32 computed, no encryption, no ZIP64. Suitable for tiny test fixtures.
import { createHash } from 'node:crypto';
// CRC-32 table (IEEE 802.3 polynomial).
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
c = (CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8)) >>> 0;
}
return (c ^ 0xFFFFFFFF) >>> 0;
}
const DOS_DATE = ((1980 - 1980) << 9) | (1 << 5) | 1; // 1980-01-01
const DOS_TIME = 0; // 00:00:00
/**
* Build a ZIP buffer from a list of entries.
*
* @param {Array<{name: string, data: Buffer | string}>} entries
* @returns {Buffer}
*/
export function createZip(entries) {
// Normalize + sort for determinism
const normalized = entries
.map(e => ({
name: e.name,
data: Buffer.isBuffer(e.data) ? e.data : Buffer.from(e.data, 'utf8'),
}))
.sort((a, b) => a.name.localeCompare(b.name));
const chunks = [];
const centralHeaders = [];
let offset = 0;
for (const e of normalized) {
const nameBuf = Buffer.from(e.name, 'utf8');
const c = crc32(e.data);
const sz = e.data.length;
// Local File Header (30 + nameLen)
const lfh = Buffer.alloc(30);
lfh.writeUInt32LE(0x04034b50, 0); // signature
lfh.writeUInt16LE(20, 4); // version needed
lfh.writeUInt16LE(0, 6); // flags
lfh.writeUInt16LE(0, 8); // method = STORE
lfh.writeUInt16LE(DOS_TIME, 10);
lfh.writeUInt16LE(DOS_DATE, 12);
lfh.writeUInt32LE(c, 14); // crc32
lfh.writeUInt32LE(sz, 18); // compressed size
lfh.writeUInt32LE(sz, 22); // uncompressed size
lfh.writeUInt16LE(nameBuf.length, 26);
lfh.writeUInt16LE(0, 28); // extra field length
chunks.push(lfh);
chunks.push(nameBuf);
chunks.push(e.data);
const localOffset = offset;
offset += 30 + nameBuf.length + sz;
// Central Directory Header (46 + nameLen)
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(0x02014b50, 0); // signature
cdh.writeUInt16LE(20, 4); // version made by
cdh.writeUInt16LE(20, 6); // version needed
cdh.writeUInt16LE(0, 8); // flags
cdh.writeUInt16LE(0, 10); // method
cdh.writeUInt16LE(DOS_TIME, 12);
cdh.writeUInt16LE(DOS_DATE, 14);
cdh.writeUInt32LE(c, 16);
cdh.writeUInt32LE(sz, 20);
cdh.writeUInt32LE(sz, 24);
cdh.writeUInt16LE(nameBuf.length, 28);
cdh.writeUInt16LE(0, 30); // extra
cdh.writeUInt16LE(0, 32); // comment
cdh.writeUInt16LE(0, 34); // disk
cdh.writeUInt16LE(0, 36); // internal attrs
cdh.writeUInt32LE(0, 38); // external attrs
cdh.writeUInt32LE(localOffset, 42);
centralHeaders.push({ cdh, nameBuf });
}
const centralStart = offset;
for (const { cdh, nameBuf } of centralHeaders) {
chunks.push(cdh);
chunks.push(nameBuf);
offset += cdh.length + nameBuf.length;
}
const centralSize = offset - centralStart;
// End of Central Directory
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(normalized.length, 8);
eocd.writeUInt16LE(normalized.length, 10);
eocd.writeUInt32LE(centralSize, 12);
eocd.writeUInt32LE(centralStart, 16);
eocd.writeUInt16LE(0, 20); // comment length
chunks.push(eocd);
return Buffer.concat(chunks);
}
/**
* Compute SHA-256 of a buffer (hex).
*/
export function sha256Hex(buf) {
return createHash('sha256').update(buf).digest('hex');
}

View file

@ -2,12 +2,18 @@
//
// All inputs are inline strings — no filesystem fixtures required.
import { describe, it } from 'node:test';
import { describe, it, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import {
parsePluginXml,
parseManifestMf,
parseIntelliJPlugin,
} from '../../scanners/lib/ide-extension-parser.mjs';
import { createZip } from '../helpers/zip-writer.mjs';
describe('parsePluginXml — happy path', () => {
const xml = `<?xml version="1.0"?>
@ -241,3 +247,134 @@ describe('parseManifestMf', () => {
assert.deepEqual(parseManifestMf(lf), parseManifestMf(crlf));
});
});
// ---------------------------------------------------------------------------
// parseIntelliJPlugin — synthetic plugin dirs built in-test via zip-writer
// ---------------------------------------------------------------------------
const TEST_TMP_PREFIX = 'llmsec-jbparse-test-';
const createdRoots = [];
async function makePluginDir(jars) {
const root = await mkdtemp(join(tmpdir(), TEST_TMP_PREFIX));
createdRoots.push(root);
await mkdir(join(root, 'lib'), { recursive: true });
for (const { name, entries } of jars) {
const buf = createZip(entries);
await writeFile(join(root, 'lib', name), buf);
}
return root;
}
const BENIGN_PLUGIN_XML = `<?xml version="1.0"?>
<idea-plugin>
<id>com.example.benign</id>
<name>Benign</name>
<version>1.0</version>
<vendor>Example</vendor>
</idea-plugin>`;
describe('parseIntelliJPlugin — benign synthetic plugin', () => {
it('extracts pluginId, depends, no native/premain/signature', async () => {
const root = await makePluginDir([
{
name: 'main.jar',
entries: [
{ name: 'META-INF/plugin.xml', data: BENIGN_PLUGIN_XML },
{ name: 'META-INF/MANIFEST.MF', data: 'Manifest-Version: 1.0\n' },
],
},
]);
const res = await parseIntelliJPlugin(root);
assert.ok(res, 'expected non-null result');
assert.ok(res.manifest, 'expected manifest');
assert.equal(res.manifest.type, 'jetbrains');
assert.equal(res.manifest.pluginId, 'com.example.benign');
assert.equal(res.manifest.nativeBinaries.length, 0);
assert.equal(res.manifest.hasPremainClass, false);
assert.equal(res.manifest.hasSignature, false);
assert.ok(Array.isArray(res.manifest.bundledJars));
assert.equal(res.manifest.bundledJars.length, 1);
});
});
describe('parseIntelliJPlugin — Premain-Class detection', () => {
it('hasPremainClass === true when MANIFEST.MF sets it', async () => {
const root = await makePluginDir([
{
name: 'main.jar',
entries: [
{ name: 'META-INF/plugin.xml', data: BENIGN_PLUGIN_XML },
{
name: 'META-INF/MANIFEST.MF',
data: 'Manifest-Version: 1.0\nPremain-Class: com.example.Agent\n',
},
],
},
]);
const res = await parseIntelliJPlugin(root);
assert.equal(res.manifest.hasPremainClass, true);
assert.equal(res.manifest.premainClass, 'com.example.Agent');
});
});
describe('parseIntelliJPlugin — native binary detection', () => {
it('collects .so files with SHA-256 and size', async () => {
const nativeBytes = Buffer.alloc(16, 0xAB);
const expectedSha = createHash('sha256').update(nativeBytes).digest('hex');
const root = await makePluginDir([
{
name: 'main.jar',
entries: [
{ name: 'META-INF/plugin.xml', data: BENIGN_PLUGIN_XML },
{ name: 'native/dummy.so', data: nativeBytes },
],
},
]);
const res = await parseIntelliJPlugin(root);
assert.equal(res.manifest.nativeBinaries.length, 1);
assert.equal(res.manifest.nativeBinaries[0].size, 16);
assert.equal(res.manifest.nativeBinaries[0].sha256, expectedSha);
});
});
describe('parseIntelliJPlugin — failure modes', () => {
it('missing lib/ returns null with IDE-JB-NO-LIB-DIR warning', async () => {
const root = await mkdtemp(join(tmpdir(), TEST_TMP_PREFIX));
createdRoots.push(root);
const res = await parseIntelliJPlugin(root);
assert.equal(res.manifest, null);
assert.ok(res.warnings.some(w => w.startsWith('IDE-JB-NO-LIB-DIR')));
});
it('no plugin.xml in any jar returns null with IDE-JB-NO-PLUGIN-XML', async () => {
const root = await makePluginDir([
{
name: 'empty.jar',
entries: [
{ name: 'META-INF/MANIFEST.MF', data: 'Manifest-Version: 1.0\n' },
],
},
]);
const res = await parseIntelliJPlugin(root);
assert.equal(res.manifest, null);
assert.ok(res.warnings.some(w => w.includes('NO-PLUGIN-XML')));
});
});
describe('parseIntelliJPlugin — temp dir cleanup', () => {
after(async () => {
// Cleanup test plugin roots
for (const r of createdRoots) {
await rm(r, { recursive: true, force: true }).catch(() => {});
}
// Assert no llmsec-jb-* temp dirs remain
const entries = await readdir(tmpdir()).catch(() => []);
const leaked = entries.filter(n => n.startsWith('llmsec-jb-'));
assert.equal(leaked.length, 0, `leaked temp dirs: ${leaked.join(', ')}`);
});
it('cleanup runs (checked via after hook)', () => {
assert.ok(true);
});
});