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

@ -5,8 +5,11 @@
// - type: 'vscode' → parseVSCodeExtension (package.json + contributes)
// - type: 'jetbrains' → parseIntelliJPlugin (plugin.xml + MANIFEST.MF inside JARs)
import { readFile, access } from 'node:fs/promises';
import { join } from 'node:path';
import { readFile, readdir, stat, mkdtemp, rm, access } from 'node:fs/promises';
import { join, basename } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { extractToDir } from './zip-extract.mjs';
async function pathExists(p) {
try { await access(p); return true; } catch { return false; }
@ -400,12 +403,217 @@ export function parseManifestMf(mfString) {
return out;
}
const NATIVE_BIN_RE = /\.(dll|so|dylib|jnilib|exe)$/i;
const SIGNATURE_FILE_RE = /\.(SF|RSA|DSA|EC)$/;
async function walkFiles(rootDir) {
const out = [];
async function recurse(dir) {
let entries;
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) await recurse(full);
else if (entry.isFile()) out.push(full);
}
}
await recurse(rootDir);
return out;
}
/**
* Parse an IntelliJ plugin directory. Implemented in Step 6 (v6.6.0).
* Stub preserved until Step 6 lands.
* Parse an IntelliJ plugin directory layout:
* <pluginRoot>/lib/*.jar main jar contains META-INF/plugin.xml
*
* @param {string} pluginRoot
* @returns {Promise<null>}
* @returns {Promise<{ manifest: ParsedManifest, warnings: string[] } | null>}
*/
export async function parseIntelliJPlugin(pluginRoot) {
return null;
if (typeof pluginRoot !== 'string' || !pluginRoot) return null;
const warnings = [];
const libDir = join(pluginRoot, 'lib');
try {
const s = await stat(libDir);
if (!s.isDirectory()) {
warnings.push('IDE-JB-NO-LIB-DIR: lib is not a directory');
return { manifest: null, warnings };
}
} catch {
warnings.push('IDE-JB-NO-LIB-DIR: lib directory missing');
return { manifest: null, warnings };
}
let jarNames;
try {
jarNames = (await readdir(libDir)).filter(n => n.toLowerCase().endsWith('.jar'));
} catch {
warnings.push('IDE-JB-NO-LIB-DIR: cannot read lib');
return { manifest: null, warnings };
}
if (jarNames.length === 0) {
warnings.push('IDE-JB-NO-PLUGIN-XML: no jars in lib/');
return { manifest: null, warnings };
}
const extractionRoot = await mkdtemp(join(tmpdir(), 'llmsec-jb-'));
const extractedJars = [];
try {
for (const jarName of jarNames) {
const jarPath = join(libDir, jarName);
try {
const jarBuffer = await readFile(jarPath);
const jarDir = await mkdtemp(join(extractionRoot, 'jar-'));
await extractToDir(jarBuffer, jarDir);
extractedJars.push({ jarPath, jarName, jarDir });
} catch (err) {
warnings.push(`IDE-JB-JAR-EXTRACT: ${jarName}: ${err.message}`);
}
}
if (extractedJars.length === 0) {
warnings.push('IDE-JB-NO-PLUGIN-XML: no jars could be extracted');
return { manifest: null, warnings };
}
// Locate main jar: first one containing META-INF/plugin.xml
let mainJar = null;
const mainJarCandidates = [];
for (const ej of extractedJars) {
const xmlPath = join(ej.jarDir, 'META-INF', 'plugin.xml');
if (await pathExists(xmlPath)) {
mainJarCandidates.push(ej);
if (!mainJar) mainJar = ej;
}
}
if (!mainJar) {
warnings.push('IDE-JB-NO-PLUGIN-XML: no jar contains META-INF/plugin.xml');
return { manifest: null, warnings };
}
if (mainJarCandidates.length > 1) {
warnings.push(`IDE-JB-MULTIPLE-PLUGIN-XML: ${mainJarCandidates.length} jars contain plugin.xml; first wins`);
}
// Parse plugin.xml
let pluginXmlResult;
try {
const xmlRaw = await readFile(join(mainJar.jarDir, 'META-INF', 'plugin.xml'), 'utf8');
pluginXmlResult = parsePluginXml(xmlRaw);
} catch (err) {
warnings.push(`IDE-JB-PLUGIN-XML-READ: ${err.message}`);
return { manifest: null, warnings };
}
if (pluginXmlResult.warnings.length) warnings.push(...pluginXmlResult.warnings);
if (!pluginXmlResult.manifest) {
warnings.push('IDE-JB-PLUGIN-XML-PARSE: unparseable plugin.xml');
return { manifest: null, warnings };
}
const px = pluginXmlResult.manifest;
// Parse main jar MANIFEST.MF
let mainMf = { mainClass: null, premainClass: null, implTitle: null, implVersion: null, premainAttrs: {} };
const mainMfPath = join(mainJar.jarDir, 'META-INF', 'MANIFEST.MF');
if (await pathExists(mainMfPath)) {
try {
const mfRaw = await readFile(mainMfPath, 'utf8');
mainMf = parseManifestMf(mfRaw);
} catch (err) {
warnings.push(`IDE-JB-MANIFEST-MF-READ: ${err.message}`);
}
}
// Walk ALL jar-dirs for native binaries
const nativeBinaries = [];
for (const ej of extractedJars) {
const files = await walkFiles(ej.jarDir);
for (const f of files) {
if (NATIVE_BIN_RE.test(f)) {
try {
const buf = await readFile(f);
const s = await stat(f);
nativeBinaries.push({
path: `${ej.jarName}:${f.slice(ej.jarDir.length + 1)}`,
size: s.size,
sha256: createHash('sha256').update(buf).digest('hex'),
});
} catch (err) {
warnings.push(`IDE-JB-NATIVE-READ: ${err.message}`);
}
}
}
}
// Parse every jar's MANIFEST.MF for bundled-jars list
const bundledJars = [];
for (const ej of extractedJars) {
const mfPath = join(ej.jarDir, 'META-INF', 'MANIFEST.MF');
let mf = { implTitle: null, implVersion: null };
if (await pathExists(mfPath)) {
try {
mf = parseManifestMf(await readFile(mfPath, 'utf8'));
} catch {
// fall through with nulls
}
}
bundledJars.push({
name: ej.jarName,
version: mf.implVersion || null,
shaded: !mf.implTitle || !mf.implVersion,
coords: mf.implTitle || null,
});
}
// Signature check on main jar
let hasSignature = false;
try {
const metaInfDir = join(mainJar.jarDir, 'META-INF');
const metaEntries = await readdir(metaInfDir);
hasSignature = metaEntries.some(f => SIGNATURE_FILE_RE.test(f));
} catch { /* no META-INF */ }
const pluginId = px.pluginId || basename(pluginRoot);
const manifest = {
type: 'jetbrains',
id: pluginId.toLowerCase(),
pluginId,
publisher: (px.vendor || '').toLowerCase(),
name: px.name || '',
version: px.version || '',
engines: {},
main: null,
browser: null,
activationEvents: [],
contributes: {},
extensionPack: [],
extensionDependencies: [],
extensionKind: [],
categories: [],
capabilities: {},
scripts: {},
repository: px.vendorUrl || null,
dependencies: {},
hasSignature,
sinceBuild: px.sinceBuild,
untilBuild: px.untilBuild,
depends: px.depends,
extensionDeclarations: px.extensionDeclarations,
applicationComponents: px.applicationComponents,
listeners: px.listeners,
themeProviders: px.themeProviders,
hasPremainClass: Boolean(mainMf.premainClass),
premainClass: mainMf.premainClass || null,
nativeBinaries,
bundledJars,
};
return { manifest, warnings };
} catch (err) {
warnings.push(`IDE-JB-UNCAUGHT: ${err.message}`);
return { manifest: null, warnings };
} finally {
await rm(extractionRoot, { recursive: true, force: true });
}
}