feat(llm-security): implement JetBrains discovery + Android Studio base dir

This commit is contained in:
Kjell Tore Guttormsen 2026-04-18 10:16:28 +02:00
commit 03d61d8bca
2 changed files with 208 additions and 6 deletions

View file

@ -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 `<base>/<ProductDir>/plugins/<plugin>/`.
* 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 };
}

View file

@ -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<version>/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(() => {});
});