diff --git a/plugins/llm-security/scanners/lib/ide-extension-discovery.mjs b/plugins/llm-security/scanners/lib/ide-extension-discovery.mjs index f3552f0..1577bcd 100644 --- a/plugins/llm-security/scanners/lib/ide-extension-discovery.mjs +++ b/plugins/llm-security/scanners/lib/ide-extension-discovery.mjs @@ -52,6 +52,24 @@ export function getJetBrainsBaseDir() { return existsSync(base) ? base : null; } +/** + * Return the Android Studio base directory. Diverges from JetBrains on Linux: + * Android Studio uses ~/.config/Google (config path) rather than ~/.local/share. + * @returns {string|null} + */ +export function getAndroidStudioBaseDir() { + const home = homedir(); + let base; + if (process.platform === 'darwin') { + base = join(home, 'Library', 'Application Support', 'Google'); + } else if (process.platform === 'win32') { + base = join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'Google'); + } else { + base = join(home, '.config', 'Google'); + } + return existsSync(base) ? base : null; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -257,15 +275,99 @@ export async function discoverVSCodeExtensions(options = {}) { // JetBrains discovery — stub (v1.1) // --------------------------------------------------------------------------- +// Matches JetBrains + Android Studio product directory names. Explicitly excludes +// Fleet (separate SDK, different plugin model) and Toolbox (launcher cache dir). +const JB_PRODUCT_DIR_RE = /^(IntelliJIdea|IdeaIC|PyCharm|PyCharmCE|WebStorm|GoLand|PhpStorm|RubyMine|CLion|DataGrip|Rider|RustRover|DataSpell|Aqua|AndroidStudio)([0-9]+\.[0-9]+(?:\.[0-9]+)?)?$/; + /** - * Discover JetBrains plugins. Stub returns empty for v6.3.0. + * Discover JetBrains + Android Studio plugins from installed IDE directories. + * + * **`rootsOverride` semantics for JetBrains:** Each entry is a BASE directory — + * the equivalent of `getJetBrainsBaseDir()` output (the `JetBrains/` or + * `Google/` parent). The discovery walks `//plugins//`. + * This differs from VS Code where `rootsOverride` entries point at the + * `extensions/` level directly. Rationale: JetBrains has many product dirs per + * base (IntelliJIdea, PyCharm, GoLand, AndroidStudio…), so base-level injection + * is the natural analogue for tests. + * + * Fleet and Toolbox are always excluded. + * * @param {object} [options] + * @param {string[]} [options.rootsOverride] - Base directories (not `plugins/` level). + * @param {boolean} [options.followSymlinks=false] * @returns {Promise<{ extensions: ExtensionRecord[], warnings: string[], rootsScanned: string[] }>} */ export async function discoverJetBrainsExtensions(options = {}) { - return { - extensions: [], - warnings: ['IntelliJ plugin discovery deferred to v1.1 (see knowledge/ide-extension-threat-patterns.md)'], - rootsScanned: [], - }; + const warnings = []; + const extensions = []; + const rootsScanned = []; + + let baseDirs; + if (Array.isArray(options.rootsOverride) && options.rootsOverride.length > 0) { + baseDirs = options.rootsOverride; + } else { + baseDirs = [getJetBrainsBaseDir(), getAndroidStudioBaseDir()].filter(Boolean); + } + + for (const base of baseDirs) { + if (!await pathExists(base)) continue; + rootsScanned.push(base); + + let productEntries; + try { + productEntries = await readdir(base, { withFileTypes: true }); + } catch (err) { + warnings.push(`failed to read ${base}: ${err.message}`); + continue; + } + + for (const pe of productEntries) { + if (!pe.isDirectory() && !(pe.isSymbolicLink() && options.followSymlinks)) continue; + if (pe.name.startsWith('.')) continue; + if (!JB_PRODUCT_DIR_RE.test(pe.name)) continue; // excludes Fleet, Toolbox + + const productDir = join(base, pe.name); + const pluginsDir = join(productDir, 'plugins'); + if (!await pathExists(pluginsDir)) continue; + + let pluginEntries; + try { + pluginEntries = await readdir(pluginsDir, { withFileTypes: true }); + } catch (err) { + warnings.push(`failed to read ${pluginsDir}: ${err.message}`); + continue; + } + + for (const plug of pluginEntries) { + if (plug.name.startsWith('.')) continue; + if (plug.name === 'disabled_plugins.txt') continue; + if (plug.isSymbolicLink() && !options.followSymlinks) continue; + if (!plug.isDirectory() && !plug.isSymbolicLink()) continue; + + const pluginDir = join(pluginsDir, plug.name); + // Safety: skip anything rooted under Toolbox cache + if (pluginDir.includes(join('Caches', 'JetBrains', 'Toolbox'))) continue; + + const id = plug.name.toLowerCase(); + extensions.push({ + id, + publisher: '', + name: plug.name, + version: '', + location: pluginDir, + type: 'jetbrains', + source: null, + isBuiltin: false, + installedTimestamp: null, + targetPlatform: null, + publisherDisplayName: null, + signed: false, + rootDir: base, + productDir: pe.name, + }); + } + } + } + + return { extensions, warnings, rootsScanned }; } diff --git a/plugins/llm-security/tests/scanners/ide-extension-discovery.test.mjs b/plugins/llm-security/tests/scanners/ide-extension-discovery.test.mjs new file mode 100644 index 0000000..041fb75 --- /dev/null +++ b/plugins/llm-security/tests/scanners/ide-extension-discovery.test.mjs @@ -0,0 +1,100 @@ +// ide-extension-discovery.test.mjs — discoverJetBrainsExtensions integration tests. +// All fixtures built in-test via mkdtemp + mkdir — no committed filesystem state. + +import { describe, it, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + discoverJetBrainsExtensions, + getAndroidStudioBaseDir, +} from '../../scanners/lib/ide-extension-discovery.mjs'; + +const TEST_PREFIX = 'llmsec-jbdisc-'; +const created = []; + +async function buildFixtureBase(layout) { + const base = await mkdtemp(join(tmpdir(), TEST_PREFIX)); + created.push(base); + for (const [product, plugins] of Object.entries(layout)) { + for (const plug of plugins) { + await mkdir(join(base, product, 'plugins', plug), { recursive: true }); + } + } + return base; +} + +describe('discoverJetBrainsExtensions — basic walk', () => { + it('finds plugins under IntelliJIdea/plugins/', async () => { + const base = await buildFixtureBase({ + 'IntelliJIdea2024.3': ['com.example.one', 'com.example.two'], + }); + const res = await discoverJetBrainsExtensions({ rootsOverride: [base] }); + assert.equal(res.extensions.length, 2); + assert.ok(res.extensions.every(e => e.type === 'jetbrains')); + assert.ok(res.extensions.some(e => e.name === 'com.example.one')); + assert.equal(res.extensions[0].productDir, 'IntelliJIdea2024.3'); + }); +}); + +describe('discoverJetBrainsExtensions — Fleet excluded', () => { + it('ignores Fleet directory', async () => { + const base = await buildFixtureBase({ + 'IntelliJIdea2024.3': ['com.example.good'], + 'Fleet': ['com.example.fleet'], + }); + const res = await discoverJetBrainsExtensions({ rootsOverride: [base] }); + assert.equal(res.extensions.length, 1); + assert.equal(res.extensions[0].name, 'com.example.good'); + assert.ok(res.extensions.every(e => !e.location.includes('Fleet'))); + }); +}); + +describe('discoverJetBrainsExtensions — Android Studio support', () => { + it('discovers from AndroidStudio product dir', async () => { + const base = await buildFixtureBase({ + 'AndroidStudio2024.3.1': ['com.google.example'], + }); + const res = await discoverJetBrainsExtensions({ rootsOverride: [base] }); + assert.equal(res.extensions.length, 1); + assert.equal(res.extensions[0].productDir, 'AndroidStudio2024.3.1'); + }); +}); + +describe('discoverJetBrainsExtensions — multi-product', () => { + it('walks all matching product dirs', async () => { + const base = await buildFixtureBase({ + 'IntelliJIdea2024.3': ['a'], + 'PyCharm2024.3': ['b'], + 'GoLand2024.3': ['c'], + 'Toolbox': ['d'], // excluded + }); + const res = await discoverJetBrainsExtensions({ rootsOverride: [base] }); + assert.equal(res.extensions.length, 3); + assert.ok(!res.extensions.some(e => e.location.includes('Toolbox'))); + }); +}); + +describe('discoverJetBrainsExtensions — skip hidden + disabled_plugins.txt', () => { + it('ignores dotfile plugins and disabled_plugins.txt', async () => { + const base = await buildFixtureBase({ + 'IntelliJIdea2024.3': ['.hidden', 'com.real', 'disabled_plugins.txt'], + }); + const res = await discoverJetBrainsExtensions({ rootsOverride: [base] }); + assert.equal(res.extensions.length, 1); + assert.equal(res.extensions[0].name, 'com.real'); + }); +}); + +describe('getAndroidStudioBaseDir — Linux path divergence', () => { + it('is a path-producing function (no exception)', () => { + // Actual return value is null or a path depending on host — just assert no throw. + const v = getAndroidStudioBaseDir(); + assert.ok(v === null || typeof v === 'string'); + }); +}); + +after(async () => { + for (const r of created) await rm(r, { recursive: true, force: true }).catch(() => {}); +});