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:
parent
03d61d8bca
commit
ca43fb8dd1
2 changed files with 397 additions and 1 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import assert from 'node:assert/strict';
|
|||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { scan, discoverAll } from '../../scanners/ide-extension-scanner.mjs';
|
||||
import { scan, discoverAll, __testing as scannerInternals } from '../../scanners/ide-extension-scanner.mjs';
|
||||
import {
|
||||
discoverVSCodeExtensions,
|
||||
parseDirName,
|
||||
|
|
@ -215,6 +215,200 @@ describe('ide-extension-scanner integration', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JetBrains check unit tests — crafted manifests, no filesystem fixtures.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const jbExt = (id) => ({
|
||||
id,
|
||||
publisher: '',
|
||||
name: id,
|
||||
version: '1.0',
|
||||
location: '/fake/path',
|
||||
type: 'jetbrains',
|
||||
source: null,
|
||||
isBuiltin: false,
|
||||
installedTimestamp: null,
|
||||
targetPlatform: null,
|
||||
publisherDisplayName: null,
|
||||
signed: false,
|
||||
rootDir: '/fake',
|
||||
productDir: 'IntelliJIdea2024.3',
|
||||
});
|
||||
|
||||
describe('runJetBrainsChecks — Premain-Class detection', () => {
|
||||
it('HIGH finding when hasPremainClass = true', () => {
|
||||
const findings = scannerInternals.checkPremainClassJB(
|
||||
jbExt('com.example.premain'),
|
||||
{ hasPremainClass: true, premainClass: 'com.example.Agent' },
|
||||
'plugins/com.example.premain'
|
||||
);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0].severity, 'high');
|
||||
assert.ok(findings[0].title.includes('checkPremainClassJB'));
|
||||
});
|
||||
|
||||
it('no finding when hasPremainClass = false', () => {
|
||||
const findings = scannerInternals.checkPremainClassJB(
|
||||
jbExt('com.example.clean'),
|
||||
{ hasPremainClass: false, premainClass: null },
|
||||
'plugins/com.example.clean'
|
||||
);
|
||||
assert.equal(findings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runJetBrainsChecks — native binaries', () => {
|
||||
it('MEDIUM finding when nativeBinaries non-empty', () => {
|
||||
const findings = scannerInternals.checkNativeBinariesJB(
|
||||
jbExt('com.example.native'),
|
||||
{ nativeBinaries: [{ path: 'x.so', size: 100, sha256: 'a'.repeat(64) }] },
|
||||
'plugins/com.example.native'
|
||||
);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0].severity, 'medium');
|
||||
assert.ok(findings[0].title.includes('checkNativeBinariesJB'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('runJetBrainsChecks — broad activation', () => {
|
||||
it('HIGH on application-components declared', () => {
|
||||
const findings = scannerInternals.checkBroadActivationJB(
|
||||
jbExt('com.example.broad'),
|
||||
{ applicationComponents: ['com.X'], listeners: [], extensionDeclarations: [] },
|
||||
'plugins/com.example.broad'
|
||||
);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0].severity, 'high');
|
||||
});
|
||||
|
||||
it('MEDIUM on postStartupActivity only', () => {
|
||||
const findings = scannerInternals.checkBroadActivationJB(
|
||||
jbExt('com.example.post'),
|
||||
{
|
||||
applicationComponents: [],
|
||||
listeners: [],
|
||||
extensionDeclarations: [{ namespace: 'com.intellij', name: 'postStartupActivity', attrs: {} }],
|
||||
},
|
||||
'plugins/com.example.post'
|
||||
);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0].severity, 'medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runJetBrainsChecks — typosquat', () => {
|
||||
it('MEDIUM when distance 1 from canonical corpus entry', () => {
|
||||
const findings = scannerInternals.checkTyposquatJB(
|
||||
jbExt('com.intellij.jaba'),
|
||||
['com.intellij.java', 'org.jetbrains.kotlin'],
|
||||
'plugins/com.intellij.jaba'
|
||||
);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0].severity, 'medium');
|
||||
});
|
||||
|
||||
it('no finding on exact corpus match', () => {
|
||||
const findings = scannerInternals.checkTyposquatJB(
|
||||
jbExt('com.intellij.java'),
|
||||
['com.intellij.java'],
|
||||
'plugins/com.intellij.java'
|
||||
);
|
||||
assert.equal(findings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runJetBrainsChecks — depends chain', () => {
|
||||
it('MEDIUM when 3+ depends with mandatory', () => {
|
||||
const findings = scannerInternals.checkDependsChainJB(
|
||||
jbExt('com.example.depchain'),
|
||||
{
|
||||
depends: [
|
||||
{ id: 'a', optional: false, configFile: null },
|
||||
{ id: 'b', optional: false, configFile: null },
|
||||
{ id: 'c', optional: true, configFile: null },
|
||||
{ id: 'd', optional: false, configFile: null },
|
||||
],
|
||||
},
|
||||
'plugins/com.example.depchain'
|
||||
);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0].severity, 'medium');
|
||||
});
|
||||
|
||||
it('no finding when all optional', () => {
|
||||
const findings = scannerInternals.checkDependsChainJB(
|
||||
jbExt('com.example.opt'),
|
||||
{
|
||||
depends: [
|
||||
{ id: 'a', optional: true, configFile: null },
|
||||
{ id: 'b', optional: true, configFile: null },
|
||||
{ id: 'c', optional: true, configFile: null },
|
||||
],
|
||||
},
|
||||
'plugins/com.example.opt'
|
||||
);
|
||||
assert.equal(findings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runJetBrainsChecks — theme-with-code', () => {
|
||||
it('HIGH when themeProvider plus applicationComponents', () => {
|
||||
const findings = scannerInternals.checkThemeWithCodeJB(
|
||||
jbExt('com.example.twc'),
|
||||
{
|
||||
themeProviders: [{ id: 't', path: '/x' }],
|
||||
extensionDeclarations: [],
|
||||
applicationComponents: ['com.X'],
|
||||
},
|
||||
'plugins/com.example.twc'
|
||||
);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0].severity, 'high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runJetBrainsChecks — shaded jars', () => {
|
||||
it('MEDIUM on shaded bundled jars', () => {
|
||||
const findings = scannerInternals.checkShadedJarsJB(
|
||||
jbExt('com.example.shade'),
|
||||
{
|
||||
bundledJars: [
|
||||
{ name: 'a.jar', version: null, shaded: true, coords: null },
|
||||
{ name: 'b.jar', version: '1.0', shaded: false, coords: 'b' },
|
||||
],
|
||||
},
|
||||
'plugins/com.example.shade'
|
||||
);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0].severity, 'medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runJetBrainsChecks — full dispatcher', () => {
|
||||
it('aggregates all JB checks', () => {
|
||||
const findings = scannerInternals.runJetBrainsChecks(
|
||||
jbExt('com.example.all'),
|
||||
{
|
||||
themeProviders: [],
|
||||
applicationComponents: [],
|
||||
listeners: [],
|
||||
extensionDeclarations: [],
|
||||
depends: [],
|
||||
hasPremainClass: true,
|
||||
premainClass: 'com.X',
|
||||
nativeBinaries: [],
|
||||
bundledJars: [],
|
||||
},
|
||||
['com.intellij.java'],
|
||||
[],
|
||||
'plugins/com.example.all'
|
||||
);
|
||||
assert.ok(findings.length >= 1);
|
||||
assert.ok(findings.some(f => f.title.includes('checkPremainClassJB')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocklist matching', () => {
|
||||
it('matchBlocklistEntry matches wildcard version', async () => {
|
||||
// Unit-test the blocklist logic via scan with custom options — we inject
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue