diff --git a/plugins/llm-security/scanners/ide-extension-scanner.mjs b/plugins/llm-security/scanners/ide-extension-scanner.mjs index 3c0f395..537b807 100644 --- a/plugins/llm-security/scanners/ide-extension-scanner.mjs +++ b/plugins/llm-security/scanners/ide-extension-scanner.mjs @@ -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 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} , 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] diff --git a/plugins/llm-security/tests/scanners/ide-extension-scanner.test.mjs b/plugins/llm-security/tests/scanners/ide-extension-scanner.test.mjs index 9a4a398..04096ec 100644 --- a/plugins/llm-security/tests/scanners/ide-extension-scanner.test.mjs +++ b/plugins/llm-security/tests/scanners/ide-extension-scanner.test.mjs @@ -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