feat(llm-security): implement JetBrains discovery + Android Studio base dir
This commit is contained in:
parent
5afb9b1f33
commit
03d61d8bca
2 changed files with 208 additions and 6 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue