llm-security/scanners/lib/ide-extension-parser.mjs
Kjell Tore Guttormsen a035455979 fix(llm-security): out-of-range char-ref silently emptied a plugin.xml field
decodeEntities guarded parseInt with Number.isFinite, which bounds
nothing: any numeric character reference above 0x10FFFF produced a
finite integer that String.fromCodePoint rejects with RangeError.

Correction to the review's framing: this does NOT break parsePluginXml's
no-throw contract. The per-field safe() wrapper catches it. The actual
defect is quieter — the affected field is discarded and replaced with
'', with only a warning. A plugin whose <name> carries one out-of-range
reference parses as name: "" while pluginId and every other field
survive, so name-based checks (JetBrains typosquat detection against the
top-plugin list) run against an empty string.

Severity is below the HIGH it was filed as: a document containing a code
point above 0x10FFFF is not well-formed XML, so IntelliJ would reject
such a plugin too — the evasion yields a plugin that does not load. The
fix is still correct and one line of logic.

- isDecodableCodePoint(): integer, >= 0, <= 0x10FFFF.
- Undecodable references are now left literal, matching how lenient
  parsers treat unrecognised entities and how this same function already
  treats unknown named entities.

Regression test drives parsePluginXml with hex and decimal references
just past and far past the maximum, asserts the no-throw contract still
holds, and pins that valid references, the maximum valid code point, and
named entities still decode.

npm test: 1901/1901 green (1890 + 11 new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
2026-07-18 09:21:56 +02:00

627 lines
21 KiB
JavaScript

// ide-extension-parser.mjs — Parse IDE extension manifests into a normalized shape.
// Zero dependencies (Node.js builtins only).
//
// Two extension types are supported:
// - type: 'vscode' → parseVSCodeExtension (package.json + contributes)
// - type: 'jetbrains' → parseIntelliJPlugin (plugin.xml + MANIFEST.MF inside JARs)
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; }
}
/**
* @typedef {object} ParsedManifest
* @property {'vscode'|'jetbrains'} type
*
* // Shared / VS Code fields (kept at top level for backward compat with runIdeChecks)
* @property {string} id
* @property {string} publisher
* @property {string} name
* @property {string} version
* @property {object} engines
* @property {string|null} main
* @property {string|null} browser
* @property {string[]} activationEvents
* @property {object} contributes
* @property {string[]} extensionPack
* @property {string[]} extensionDependencies
* @property {string[]} extensionKind
* @property {string[]} categories
* @property {object} capabilities
* @property {object} scripts
* @property {object|string|null} repository
* @property {object} dependencies
* @property {boolean} hasSignature
*
* // JetBrains-only fields (present only when type === 'jetbrains')
* @property {string} [pluginId]
* @property {string|null} [sinceBuild]
* @property {string|null} [untilBuild]
* @property {Array<{id:string, optional:boolean, configFile:string|null}>} [depends]
* @property {Array<{namespace:string, name:string, attrs:object}>} [extensionDeclarations]
* @property {string[]} [applicationComponents]
* @property {Array<{topic:string, class:string}>} [listeners]
* @property {boolean} [hasPremainClass]
* @property {string|null} [premainClass]
* @property {Array<{path:string, size:number, sha256:string}>} [nativeBinaries]
* @property {Array<{name:string, version:string|null, shaded:boolean, coords:string|null}>} [bundledJars]
* @property {Array<{id:string, path:string}>} [themeProviders]
*/
/**
* Parse a VS Code extension directory.
* @param {string} extRoot - Absolute path to extracted extension root.
* @returns {Promise<{ manifest: ParsedManifest, warnings: string[] } | null>}
*/
export async function parseVSCodeExtension(extRoot) {
const warnings = [];
const pkgPath = join(extRoot, 'package.json');
let raw;
try {
raw = await readFile(pkgPath, 'utf8');
} catch (err) {
return null;
}
let pkg;
try {
pkg = JSON.parse(raw);
} catch (err) {
warnings.push(`malformed package.json at ${pkgPath}: ${err.message}`);
return null;
}
if (!pkg || typeof pkg !== 'object') {
warnings.push(`package.json at ${pkgPath} is not an object`);
return null;
}
const publisher = typeof pkg.publisher === 'string' ? pkg.publisher : '';
const name = typeof pkg.name === 'string' ? pkg.name : '';
const version = typeof pkg.version === 'string' ? pkg.version : '';
if (!publisher || !name) {
warnings.push(`missing publisher/name in ${pkgPath}`);
return null;
}
const hasSignature = await pathExists(join(extRoot, '.signature.p7s'));
const manifest = {
type: 'vscode',
id: `${publisher}.${name}`.toLowerCase(),
publisher: publisher.toLowerCase(),
name: name.toLowerCase(),
version,
engines: pkg.engines && typeof pkg.engines === 'object' ? pkg.engines : {},
main: typeof pkg.main === 'string' ? pkg.main : null,
browser: typeof pkg.browser === 'string' ? pkg.browser : null,
activationEvents: Array.isArray(pkg.activationEvents) ? pkg.activationEvents.filter(e => typeof e === 'string') : [],
contributes: pkg.contributes && typeof pkg.contributes === 'object' ? pkg.contributes : {},
extensionPack: Array.isArray(pkg.extensionPack) ? pkg.extensionPack.filter(e => typeof e === 'string') : [],
extensionDependencies: Array.isArray(pkg.extensionDependencies) ? pkg.extensionDependencies.filter(e => typeof e === 'string') : [],
extensionKind: Array.isArray(pkg.extensionKind) ? pkg.extensionKind.filter(e => typeof e === 'string') : [],
categories: Array.isArray(pkg.categories) ? pkg.categories.filter(c => typeof c === 'string') : [],
capabilities: pkg.capabilities && typeof pkg.capabilities === 'object' ? pkg.capabilities : {},
scripts: pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {},
repository: pkg.repository || null,
dependencies: pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {},
hasSignature,
};
return { manifest, warnings };
}
/**
* Parse a .vsix file. Stub — caller extracts first via lib/zip-extract.mjs.
* @param {string} vsixPath
* @throws {Error}
*/
export async function parseVsixFile(vsixPath) {
throw new Error(`VSIX parsing not implemented in library-direct form. Extract manually (unzip ${vsixPath}) and pass the extracted directory.`);
}
// ---------------------------------------------------------------------------
// JetBrains helpers — zero-dep plugin.xml + MANIFEST.MF parsers
// ---------------------------------------------------------------------------
const NAMED_ENTITIES = {
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
};
/**
* Decode XML entity references in text content (non-CDATA).
* @param {string} s
* @returns {string}
*/
/** Unicode's maximum code point — String.fromCodePoint throws RangeError above it. */
const MAX_CODE_POINT = 0x10FFFF;
/** Number.isFinite does not bound the value; fromCodePoint needs an actual range check. */
function isDecodableCodePoint(cp) {
return Number.isInteger(cp) && cp >= 0 && cp <= MAX_CODE_POINT;
}
function decodeEntities(s) {
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (full, inner) => {
if (inner.startsWith('#x') || inner.startsWith('#X')) {
const cp = parseInt(inner.slice(2), 16);
return isDecodableCodePoint(cp) ? String.fromCodePoint(cp) : full;
}
if (inner.startsWith('#')) {
const cp = parseInt(inner.slice(1), 10);
return isDecodableCodePoint(cp) ? String.fromCodePoint(cp) : full;
}
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, inner)
? NAMED_ENTITIES[inner]
: full;
});
}
/**
* Capture the first match of a named element. Returns its text content
* (with CDATA honoured, otherwise entity-decoded), or null.
* @param {string} xml
* @param {string} tag
* @returns {string|null}
*/
function firstElementText(xml, tag) {
const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
const m = xml.match(re);
if (!m) return null;
return normalizeTextContent(m[1]);
}
function normalizeTextContent(raw) {
const trimmed = raw.trim();
if (trimmed.startsWith('<![CDATA[') && trimmed.endsWith(']]>')) {
return trimmed.slice(9, -3);
}
return decodeEntities(trimmed);
}
/**
* Parse a `key="value"` or `key='value'` attribute list.
* @param {string} attrBlob - e.g. ` id="X" path='Y'`
* @returns {Record<string,string>}
*/
function parseAttrs(attrBlob) {
const attrs = {};
if (!attrBlob) return attrs;
const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let m;
while ((m = re.exec(attrBlob)) !== null) {
attrs[m[1]] = decodeEntities(m[2] !== undefined ? m[2] : m[3]);
}
return attrs;
}
/**
* Parse a JetBrains `plugin.xml` document.
*
* Regex-based, intentionally lenient. Malformed input returns
* `{ manifest: null, warnings: [...] }` rather than throwing.
*
* @param {string} xmlString
* @returns {{ manifest: object|null, warnings: string[] }}
*/
export function parsePluginXml(xmlString) {
const warnings = [];
if (typeof xmlString !== 'string') {
return { manifest: null, warnings: ['input is not a string'] };
}
// --- Pre-processing pipeline ---
let xml = xmlString.replace(/^\uFEFF/, '');
xml = xml.replace(/\r\n?/g, '\n');
xml = xml.replace(/<!--[\s\S]*?-->/g, '');
// Bail on obviously malformed (no <idea-plugin> root and no <id>)
if (!/<idea-plugin\b/i.test(xml) && !/<id\b/i.test(xml)) {
warnings.push('no <idea-plugin> or <id> element found — not a plugin.xml');
return { manifest: null, warnings };
}
// Bail on unbalanced-tag smell: count open vs close for <id> and <name>.
// Both are required non-self-closing elements in plugin.xml, so any mismatch
// signals truncation or malformed input.
const balanceChecks = [
{ open: /<id\b[^>]*>/g, close: /<\/id>/g, tag: 'id' },
{ open: /<name\b[^>]*>/g, close: /<\/name>/g, tag: 'name' },
];
for (const { open, close, tag } of balanceChecks) {
const o = (xml.match(open) || []).length;
const c = (xml.match(close) || []).length;
if (o > 0 && o !== c) {
warnings.push(`unbalanced <${tag}> tags — truncated input`);
return { manifest: null, warnings };
}
}
const safe = (fn, label) => {
try { return fn(); } catch (err) {
warnings.push(`${label}: ${err.message}`);
return null;
}
};
const pluginId = safe(() => firstElementText(xml, 'id'), 'pluginId') || '';
const name = safe(() => firstElementText(xml, 'name'), 'name') || '';
const version = safe(() => firstElementText(xml, 'version'), 'version') || '';
// <vendor url="..." email="...">Name</vendor>
let vendor = '';
let vendorUrl = null;
const vendorMatch = xml.match(/<vendor\b([^>]*)>([\s\S]*?)<\/vendor>/i);
if (vendorMatch) {
const attrs = parseAttrs(vendorMatch[1]);
vendorUrl = attrs.url || null;
vendor = normalizeTextContent(vendorMatch[2]);
}
// <idea-version since-build="A" until-build="B"/>
let sinceBuild = null;
let untilBuild = null;
const ideaVersionMatch = xml.match(/<idea-version\b([^>]*)\/?\s*>/i);
if (ideaVersionMatch) {
const attrs = parseAttrs(ideaVersionMatch[1]);
sinceBuild = attrs['since-build'] || null;
untilBuild = attrs['until-build'] || null;
}
// <depends optional="true" config-file="X">ID</depends>
const depends = [];
const dependsRe = /<depends\b([^>]*)>([\s\S]*?)<\/depends>/gi;
let dm;
while ((dm = dependsRe.exec(xml)) !== null) {
const attrs = parseAttrs(dm[1]);
depends.push({
id: normalizeTextContent(dm[2]),
optional: attrs.optional === 'true',
configFile: attrs['config-file'] || null,
});
}
// <extensions defaultExtensionNs="com.intellij">...<extensions>
const extensionDeclarations = [];
const themeProviders = [];
const extBlockRe = /<extensions\b([^>]*)>([\s\S]*?)<\/extensions>/gi;
let em;
while ((em = extBlockRe.exec(xml)) !== null) {
const attrs = parseAttrs(em[1]);
const ns = attrs.defaultExtensionNs || 'com.intellij';
const body = em[2];
// Children: <(name) ... /> or <(name) ...>...</(name)>
// Use [^>]*? (non-greedy, slash allowed in attr values like path="/x/y")
// so self-closing elements with slashes in attributes still match.
const childRe = /<([\w.-]+)\b([^>]*?)(?:\/\s*>|>([\s\S]*?)<\/\1>)/g;
let cm;
while ((cm = childRe.exec(body)) !== null) {
const childName = cm[1];
const childAttrs = parseAttrs(cm[2]);
extensionDeclarations.push({ namespace: ns, name: childName, attrs: childAttrs });
if (childName === 'themeProvider') {
themeProviders.push({
id: childAttrs.id || '',
path: childAttrs.path || '',
});
}
}
}
// <application-components><component><implementation-class>X</implementation-class></component></application-components>
const applicationComponents = [];
const appCompBlockRe = /<application-components\b[^>]*>([\s\S]*?)<\/application-components>/gi;
let am;
while ((am = appCompBlockRe.exec(xml)) !== null) {
const implRe = /<implementation-class>\s*([\s\S]*?)\s*<\/implementation-class>/g;
let im;
while ((im = implRe.exec(am[1])) !== null) {
applicationComponents.push(decodeEntities(im[1]).trim());
}
}
// <applicationListener topic="X" class="Y"/>
const listeners = [];
const listenerRe = /<applicationListener\b([^/>]*)\/?\s*>/gi;
let lm;
while ((lm = listenerRe.exec(xml)) !== null) {
const attrs = parseAttrs(lm[1]);
listeners.push({
topic: attrs.topic || '',
class: attrs.class || '',
});
}
return {
manifest: {
pluginId,
name,
version,
vendor,
vendorUrl,
sinceBuild,
untilBuild,
depends,
extensionDeclarations,
applicationComponents,
listeners,
themeProviders,
},
warnings,
};
}
/**
* Parse a `META-INF/MANIFEST.MF` file. Simple `Key: Value` line protocol.
* Handles RFC-822 72-char continuation lines (lines starting with space/tab
* are appended to the previous line's value).
*
* @param {string} mfString
* @returns {{mainClass: string|null, premainClass: string|null, implTitle: string|null, implVersion: string|null, premainAttrs: object}}
*/
export function parseManifestMf(mfString) {
const out = {
mainClass: null,
premainClass: null,
implTitle: null,
implVersion: null,
premainAttrs: {},
};
if (typeof mfString !== 'string' || mfString.length === 0) return out;
// Pre-processing
let s = mfString.replace(/^\uFEFF/, '');
s = s.replace(/\r\n?/g, '\n');
// Concatenate continuation lines (lines beginning with a single space or tab).
const rawLines = s.split('\n');
const logical = [];
for (const line of rawLines) {
if (line.length > 0 && (line[0] === ' ' || line[0] === '\t') && logical.length > 0) {
logical[logical.length - 1] += line.slice(1);
} else {
logical.push(line);
}
}
for (const line of logical) {
if (!line || !line.includes(': ')) continue;
const idx = line.indexOf(': ');
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 2);
if (key === 'Main-Class') out.mainClass = value;
else if (key === 'Premain-Class') out.premainClass = value;
else if (key === 'Implementation-Title') out.implTitle = value;
else if (key === 'Implementation-Version') out.implVersion = value;
// Forensic collection of all Premain-* + Agent-* attributes
if (/^(Premain-|Agent-|Boot-Class-Path|Can-)/.test(key)) {
out.premainAttrs[key] = value;
}
}
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 layout:
* <pluginRoot>/lib/*.jar — main jar contains META-INF/plugin.xml
*
* @param {string} pluginRoot
* @returns {Promise<{ manifest: ParsedManifest, warnings: string[] } | null>}
*/
export async function parseIntelliJPlugin(pluginRoot) {
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 });
}
}