feat(llm-security): add runJetBrainsChecks with 7 JB-specific checks (inc. shaded-jar advisory)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-04-18 10:20:42 +02:00
commit ca43fb8dd1
2 changed files with 397 additions and 1 deletions

View file

@ -318,6 +318,196 @@ function runIdeChecks(ext, manifest, topList, blocklist, relLocation) {
return out;
}
// ---------------------------------------------------------------------------
// JetBrains-specific checks
// ---------------------------------------------------------------------------
function checkBlocklistJB(ext, manifest, blocklist, relLocation) {
return checkBlocklist(ext, manifest, blocklist, relLocation);
}
function checkThemeWithCodeJB(ext, manifest, relLocation) {
const findings = [];
const themeProviders = manifest.themeProviders || [];
const extDecls = manifest.extensionDeclarations || [];
const appComps = manifest.applicationComponents || [];
if (themeProviders.length === 0) return findings;
if (extDecls.length > themeProviders.length || appComps.length > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.HIGH,
title: `checkThemeWithCodeJB: theme plugin has executable code: ${ext.id}`,
description: 'JetBrains plugin declares themeProviders but also has executable extension points or application-components. Theme plugins should be UI-only.',
file: relLocation,
evidence: `themeProviders=${themeProviders.length} extensions=${extDecls.length} applicationComponents=${appComps.length}`,
owasp: 'LLM06, ASI02',
recommendation: 'Audit non-theme extension points. Consider uninstalling.',
}));
}
return findings;
}
function checkBroadActivationJB(ext, manifest, relLocation) {
const findings = [];
const appComps = manifest.applicationComponents || [];
const listeners = manifest.listeners || [];
const extDecls = manifest.extensionDeclarations || [];
const hasAppLifecycleListener = listeners.some(l => typeof l.topic === 'string' && l.topic.includes('AppLifecycleListener'));
if (appComps.length > 0 || hasAppLifecycleListener) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.HIGH,
title: `checkBroadActivationJB: eager startup activation: ${ext.id}`,
description: appComps.length > 0
? 'Plugin declares legacy <application-components> which load at IDE startup. Deprecated but not malicious — review for necessity.'
: 'Plugin listens to AppLifecycleListener.appStarted — runs at IDE startup.',
file: relLocation,
evidence: `applicationComponents=${appComps.length} listeners=${listeners.map(l => l.topic).join(',')}`,
owasp: 'LLM06',
recommendation: 'Verify startup-time activation is necessary.',
}));
return findings;
}
const POSTSTARTUP_NAMES = new Set(['postStartupActivity', 'backgroundPostStartupActivity']);
const postStartupCount = extDecls.filter(d => POSTSTARTUP_NAMES.has(d.name)).length;
const preloadAppService = extDecls.some(d =>
d.name === 'applicationService' && d.attrs && d.attrs.preload === 'true'
);
if (postStartupCount > 0 || preloadAppService) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkBroadActivationJB: post-startup activation: ${ext.id}`,
description: 'Plugin uses postStartupActivity or preloaded applicationService — runs shortly after IDE startup.',
file: relLocation,
evidence: `postStartupActivity=${postStartupCount} preloadAppService=${preloadAppService}`,
owasp: 'LLM06',
recommendation: 'Review what the startup activity does.',
}));
}
return findings;
}
// Whitelist: IDs that are legit despite being close to a corpus entry.
// Empty by design — the typosquat logic resolves most cases via "corpus entry wins".
const JB_TYPOSQUAT_WHITELIST = new Set();
function checkTyposquatJB(ext, topList, relLocation) {
const findings = [];
if (!Array.isArray(topList) || topList.length === 0) return findings;
const scannedId = normalizeId(ext.id);
if (JB_TYPOSQUAT_WHITELIST.has(scannedId)) return findings;
// Exact corpus match = legitimate canonical
for (const entry of topList) {
if (normalizeId(entry) === scannedId) return findings;
}
for (const entry of topList) {
const corpusId = normalizeId(entry);
if (corpusId === scannedId) continue;
if (Math.abs(corpusId.length - scannedId.length) > 2) continue;
const d = levenshtein(scannedId, corpusId);
if (d > 0 && d <= 2) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkTyposquatJB: possible typosquat: "${ext.id}" vs "${entry}" (distance=${d})`,
description: 'JetBrains plugin ID is close to a known legitimate plugin ID. See research brief §4 — JetBrains corpus is legitimate canonical IDs; anything within Levenshtein 2 is suspicious.',
file: relLocation,
evidence: `scanned=${scannedId} corpus=${corpusId} distance=${d}`,
owasp: 'LLM03',
recommendation: `Verify publisher. If "${entry}" was intended, uninstall this plugin and install the canonical one.`,
}));
break; // one finding per plugin
}
}
return findings;
}
function checkDependsChainJB(ext, manifest, relLocation) {
const findings = [];
const depends = manifest.depends || [];
if (depends.length < 3) return findings;
const hasMandatory = depends.some(d => d.optional === false);
if (!hasMandatory) return findings;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkDependsChainJB: deep mandatory dependency chain: ${ext.id}`,
description: `Plugin declares ${depends.length} <depends>, at least one mandatory — amplified trust chain.`,
file: relLocation,
evidence: `depends=${depends.map(d => d.id).slice(0, 5).join(',')}`,
owasp: 'LLM03',
recommendation: 'Audit each mandatory dependency.',
}));
return findings;
}
function checkPremainClassJB(ext, manifest, relLocation) {
const findings = [];
if (!manifest.hasPremainClass) return findings;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.HIGH,
title: `checkPremainClassJB: Java agent detected: ${ext.id}`,
description: `Plugin JAR declares Premain-Class=${manifest.premainClass} in MANIFEST.MF — loads as a JVM agent with bytecode-rewrite capability.`,
file: relLocation,
evidence: `premainClass=${manifest.premainClass}`,
owasp: 'LLM06, ASI02',
recommendation: 'Audit the premain class. Legitimate profilers will trigger this too — verify the vendor.',
}));
return findings;
}
function checkNativeBinariesJB(ext, manifest, relLocation) {
const findings = [];
const binaries = manifest.nativeBinaries || [];
if (binaries.length === 0) return findings;
const top = binaries.slice(0, 3).map(b => `${b.path}(${b.size}B, ${b.sha256.slice(0, 12)}…)`);
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkNativeBinariesJB: plugin bundles ${binaries.length} native binaries: ${ext.id}`,
description: 'Native binaries (.dll/.so/.dylib/.jnilib/.exe) run outside JVM sandbox. Benign for some plugins (e.g. jssc) but non-zero signal.',
file: relLocation,
evidence: top.join(' | '),
owasp: 'LLM06',
recommendation: 'Verify each binary via VirusTotal or vendor checksum.',
}));
return findings;
}
function checkShadedJarsJB(ext, manifest, relLocation) {
const findings = [];
const shaded = (manifest.bundledJars || []).filter(j => j.shaded);
if (shaded.length === 0) return findings;
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.MEDIUM,
title: `checkShadedJarsJB: ${shaded.length} shaded jars (cannot audit via OSV): ${ext.id}`,
description: 'Plugin bundles jars without Implementation-Title/Version in MANIFEST.MF. Vulnerability scanning against OSV/Maven coords not possible.',
file: relLocation,
evidence: `shaded=${shaded.slice(0, 5).map(j => j.name).join(',')}`,
owasp: 'LLM05',
recommendation: 'Ask vendor for SBOM; consider declining plugin if origin unknown.',
}));
return findings;
}
function runJetBrainsChecks(ext, manifest, topList, blocklist, relLocation) {
const out = [];
out.push(...checkBlocklistJB(ext, manifest, blocklist, relLocation));
out.push(...checkThemeWithCodeJB(ext, manifest, relLocation));
out.push(...checkBroadActivationJB(ext, manifest, relLocation));
out.push(...checkTyposquatJB(ext, topList, relLocation));
out.push(...checkDependsChainJB(ext, manifest, relLocation));
out.push(...checkPremainClassJB(ext, manifest, relLocation));
out.push(...checkNativeBinariesJB(ext, manifest, relLocation));
out.push(...checkShadedJarsJB(ext, manifest, relLocation));
return out;
}
// ---------------------------------------------------------------------------
// Reused-scanner orchestration per extension
// ---------------------------------------------------------------------------
@ -605,6 +795,18 @@ export async function scan(target, options = {}) {
}
}
// Internal exports for unit testing only — not a stable API.
export const __testing = {
runJetBrainsChecks,
checkThemeWithCodeJB,
checkBroadActivationJB,
checkTyposquatJB,
checkDependsChainJB,
checkPremainClassJB,
checkNativeBinariesJB,
checkShadedJarsJB,
};
/**
* Discovery-only (for tests/debugging).
* @param {object} [options]