feat(llm-security): URL-fetch support for JetBrains Marketplace (v6.6.0)

This commit is contained in:
Kjell Tore Guttormsen 2026-04-18 10:46:13 +02:00
commit 378e177000
3 changed files with 569 additions and 26 deletions

View file

@ -33,9 +33,14 @@ import {
loadJetBrainsBlocklist,
normalizeId,
} from './lib/ide-extension-data.mjs';
import { fetchVsixFromUrl, detectUrlType } from './lib/vsix-fetch.mjs';
import { fetchVsixFromUrl, fetchPluginFromUrl, detectUrlType } from './lib/vsix-fetch.mjs';
import { extractToDir, ZipError } from './lib/zip-extract.mjs';
import { runVsixWorker } from './lib/vsix-sandbox.mjs';
import {
runVsixWorker,
runPluginWorker,
DEFAULT_VSIX_WORKER_PATH,
DEFAULT_JETBRAINS_WORKER_PATH,
} from './lib/vsix-sandbox.mjs';
import { scan as scanUnicode } from './unicode-scanner.mjs';
import { scan as scanEntropy } from './entropy-scanner.mjs';
@ -132,6 +137,111 @@ async function fetchAndExtractVsixUrl(url, opts = {}) {
}
}
/**
* Generalized URL fetch + extract for JetBrains plugins (and callable for VSIX
* too via `workerKind: 'vsix'`). Uses the generalized `runPluginWorker` from
* `vsix-sandbox.mjs` so both worker kinds share the same sandbox pipeline.
*
* JetBrains-specific differences from the VSIX helper:
* - Worker is `DEFAULT_JETBRAINS_WORKER_PATH` (emits the plugin root under
* <tempDir>, not `<tempDir>/extension`).
* - In-process fallback uses `fetchJetBrainsPlugin` + manual extRoot probe
* mirroring the worker (first child of tempDir with `lib/*.jar`).
*
* Caller MUST `await rm(result.tempDir, { recursive: true, force: true })` in finally.
*
* @param {string} url
* @param {{ useSandbox?: boolean, workerKind?: 'jetbrains'|'vsix' }} [opts]
* @returns {Promise<{ extRoot: string, tempDir: string, source: object, sandbox: 'sandbox-exec'|'bwrap'|null|'in-process' }>}
*/
async function fetchAndExtractPluginUrl(url, opts = {}) {
const useSandbox = opts.useSandbox !== false;
const workerKind = opts.workerKind || 'jetbrains';
const workerPath = workerKind === 'vsix' ? DEFAULT_VSIX_WORKER_PATH : DEFAULT_JETBRAINS_WORKER_PATH;
const tempDir = await mkdtemp(join(tmpdir(), `llm-sec-${workerKind}-`));
try {
if (useSandbox) {
const { ok, sandbox, payload } = await runPluginWorker(
workerPath,
['--url', url, '--tmpdir', tempDir],
tempDir,
);
if (!ok) {
const msg = payload && payload.error ? payload.error : 'worker failed';
throw new Error(msg);
}
const { type: kind, ...sourceMeta } = payload.source;
const source = {
type: 'url',
kind,
url,
finalUrl: payload.finalUrl,
sha256: payload.sha256,
size: payload.size,
sandbox: sandbox || 'none',
...sourceMeta,
};
return { extRoot: payload.extRoot, tempDir, source, sandbox: sandbox || null };
}
// In-process path — used by tests that mock globalThis.fetch.
let fetched;
try {
fetched = await fetchPluginFromUrl(url);
} catch (err) {
throw new Error(`fetch failed: ${err.message}`);
}
try {
await extractToDir(fetched.buffer, tempDir);
} catch (err) {
if (err instanceof ZipError) {
throw new Error(`malformed plugin archive (${err.code}): ${err.message}`);
}
throw err;
}
// JetBrains archives: first child dir containing lib/*.jar is the plugin root.
let extRoot = tempDir;
if (workerKind === 'jetbrains') {
try {
const { readdirSync, statSync } = await import('node:fs');
for (const name of readdirSync(tempDir)) {
const candidate = join(tempDir, name);
try {
if (!statSync(candidate).isDirectory()) continue;
const libDir = join(candidate, 'lib');
if (!statSync(libDir).isDirectory()) continue;
const libEntries = readdirSync(libDir);
if (libEntries.some((n) => n.toLowerCase().endsWith('.jar'))) {
extRoot = candidate;
break;
}
} catch { /* skip */ }
}
} catch { /* fallback to tempDir */ }
} else {
const nested = join(tempDir, 'extension');
if (existsSync(nested)) extRoot = nested;
}
const { type: kind, ...sourceMeta } = fetched.source;
const source = {
type: 'url',
kind,
url,
finalUrl: fetched.finalUrl,
sha256: fetched.sha256,
size: fetched.size,
sandbox: 'in-process',
...sourceMeta,
};
return { extRoot, tempDir, source, sandbox: 'in-process' };
} catch (err) {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
}
// ---------------------------------------------------------------------------
// IDE-specific checks (operate on parsed manifest)
// ---------------------------------------------------------------------------
@ -694,13 +804,28 @@ export async function scan(target, options = {}) {
let urlSource = null;
let urlTempDir = null;
// URL mode: fetch VSIX, extract to temp dir, then treat extracted dir as single target.
// URL mode: fetch plugin archive, extract to temp dir, then treat extracted dir as single target.
if (isUrlTarget(target)) {
const detected = detectUrlType(target);
if (detected.type === 'unknown') {
warnings.push(`unsupported URL: ${target} (expected VS Code Marketplace, OpenVSX, or direct .vsix)`);
warnings.push(`unsupported URL: ${target} (expected VS Code Marketplace, OpenVSX, direct .vsix, or plugins.jetbrains.com)`);
} else if (detected.type === 'github') {
warnings.push('GitHub repo URLs are not supported in v6.4.0 — would require build step. Use the Marketplace, OpenVSX, or a direct .vsix link.');
} else if (detected.type === 'jetbrains') {
try {
const fetched = await fetchAndExtractPluginUrl(target, {
useSandbox: options.useSandbox,
workerKind: 'jetbrains',
});
urlSource = fetched.source;
urlTempDir = fetched.tempDir;
target = fetched.extRoot;
if (fetched.sandbox === null && options.useSandbox !== false) {
warnings.push('OS sandbox unavailable on this platform — JetBrains plugin extracted without sandbox-exec/bwrap. Defense-in-depth reduced to in-process zip-extract validation.');
}
} catch (err) {
warnings.push(`URL fetch/extract failed: ${err.message}`);
}
} else {
try {
const fetched = await fetchAndExtractVsixUrl(target, { useSandbox: options.useSandbox });
@ -726,28 +851,64 @@ export async function scan(target, options = {}) {
if (urlFetchFailed) {
// Don't fall through to discovery when the user asked for a specific URL.
} else if (singleTargetPath) {
// Single-directory mode
const parsed = await parseVSCodeExtension(singleTargetPath);
if (!parsed) {
warnings.push(`cannot parse extension at ${singleTargetPath}`);
// Single-directory mode — detect plugin type from layout.
// - `lib/*.jar` subtree → JetBrains plugin (parsed via parseIntelliJPlugin)
// - `package.json` at root → VS Code extension (parsed via parseVSCodeExtension)
// - neither → warn + skip
const hasLibDir = existsSync(join(singleTargetPath, 'lib'));
const hasPackageJson = existsSync(join(singleTargetPath, 'package.json'));
const isJetBrainsLayout = hasLibDir && !hasPackageJson;
if (isJetBrainsLayout) {
const parsed = await parseIntelliJPlugin(singleTargetPath);
if (!parsed || !parsed.manifest) {
warnings.push(`cannot parse JetBrains plugin at ${singleTargetPath}`);
if (parsed && parsed.warnings) warnings.push(...parsed.warnings);
} else {
const m = parsed.manifest;
extensions.push({
id: m.id,
publisher: m.publisher || null,
name: m.name || null,
version: m.version || null,
location: singleTargetPath,
type: 'jetbrains',
source: null,
isBuiltin: false,
installedTimestamp: null,
targetPlatform: null,
publisherDisplayName: null,
signed: false,
rootDir: singleTargetPath,
});
rootsScanned.push(singleTargetPath);
warnings.push(...parsed.warnings);
}
} else if (hasPackageJson) {
const parsed = await parseVSCodeExtension(singleTargetPath);
if (!parsed) {
warnings.push(`cannot parse extension at ${singleTargetPath}`);
} else {
const m = parsed.manifest;
extensions.push({
id: m.id,
publisher: m.publisher,
name: m.name,
version: m.version,
location: singleTargetPath,
type: 'vscode',
source: null,
isBuiltin: false,
installedTimestamp: null,
targetPlatform: null,
publisherDisplayName: null,
signed: m.hasSignature,
rootDir: singleTargetPath,
});
rootsScanned.push(singleTargetPath);
}
} else {
const m = parsed.manifest;
extensions.push({
id: m.id,
publisher: m.publisher,
name: m.name,
version: m.version,
location: singleTargetPath,
type: 'vscode',
source: null,
isBuiltin: false,
installedTimestamp: null,
targetPlatform: null,
publisherDisplayName: null,
signed: m.hasSignature,
rootDir: singleTargetPath,
});
rootsScanned.push(singleTargetPath);
warnings.push(`cannot determine plugin type at ${singleTargetPath} (no package.json, no lib/ dir)`);
}
} else {
// Discovery mode
@ -762,7 +923,9 @@ export async function scan(target, options = {}) {
rootsScanned.push(...vs.rootsScanned);
}
if (!options.vscodeOnly) {
const jb = await discoverJetBrainsExtensions({});
const jb = await discoverJetBrainsExtensions({
rootsOverride: options.rootsOverride,
});
extensions.push(...jb.extensions);
warnings.push(...jb.warnings);
rootsScanned.push(...jb.rootsScanned);

View file

@ -0,0 +1,114 @@
#!/usr/bin/env node
// jetbrains-fetch-worker.mjs — Sub-process worker that fetches a JetBrains
// plugin URL and extracts the downloaded ZIP into a writable directory.
// Mirrors `vsix-fetch-worker.mjs` IPC exactly.
//
// Contract:
// stdin: none
// argv: --url <url> --tmpdir <writable-dir>
// stdout: single JSON line {ok:true, sha256, size, finalUrl, source, extRoot}
// on success, or {ok:false, error:"<msg>", code?:"<ZIP_CODE>"} on failure
// stderr: never (silent — all errors via JSON on stdout)
// exit: 0 on success, 1 on any failure (caller still parses stdout)
//
// Key JetBrains-specific difference from the VSIX worker: plugin archives are
// NOT nested under `extension/`. The top-level entry is usually the plugin
// directory itself — identified by the presence of a `lib/` subdir containing
// at least one `*.jar`. If no such directory is found, fall back to `<tmpdir>`
// itself and let the parser surface a warning.
import { existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { fetchJetBrainsPlugin, detectUrlType } from './vsix-fetch.mjs';
import { extractToDir, ZipError } from './zip-extract.mjs';
function emit(obj) {
process.stdout.write(JSON.stringify(obj) + '\n');
}
function parseArgs(argv) {
const out = { url: null, tmpdir: null };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--url' && i + 1 < argv.length) out.url = argv[++i];
else if (argv[i] === '--tmpdir' && i + 1 < argv.length) out.tmpdir = argv[++i];
}
return out;
}
// Find the top-level plugin dir inside an extracted JetBrains archive.
// JetBrains plugin zips contain `<plugin-name>/lib/*.jar`. We walk immediate
// children of <tmpdir>; the first child with a `lib/` subdir containing a
// `.jar` file is the plugin root. Fallback: return <tmpdir>.
function findPluginRoot(tmpdir) {
let entries;
try { entries = readdirSync(tmpdir); } catch { return tmpdir; }
for (const name of entries) {
const candidate = join(tmpdir, name);
let s;
try { s = statSync(candidate); } catch { continue; }
if (!s.isDirectory()) continue;
const libDir = join(candidate, 'lib');
let libStat;
try { libStat = statSync(libDir); } catch { continue; }
if (!libStat.isDirectory()) continue;
let libEntries;
try { libEntries = readdirSync(libDir); } catch { continue; }
if (libEntries.some((n) => n.toLowerCase().endsWith('.jar'))) {
return candidate;
}
}
return tmpdir;
}
async function main() {
const { url, tmpdir: dir } = parseArgs(process.argv.slice(2));
if (!url || !dir) {
emit({ ok: false, error: 'missing --url or --tmpdir' });
process.exit(1);
}
const detected = detectUrlType(url);
if (detected.type !== 'jetbrains') {
emit({ ok: false, error: `worker expected JetBrains URL, got type=${detected.type}` });
process.exit(1);
}
let fetched;
try {
fetched = await fetchJetBrainsPlugin({
numericId: detected.numericId,
xmlId: detected.xmlId,
version: detected.version,
});
} catch (err) {
emit({ ok: false, error: `fetch failed: ${err.message}` });
process.exit(1);
}
try {
await extractToDir(fetched.buffer, dir);
} catch (err) {
if (err instanceof ZipError) {
emit({ ok: false, error: `malformed JetBrains plugin (${err.code}): ${err.message}`, code: err.code });
} else {
emit({ ok: false, error: `extract failed: ${err.message}` });
}
process.exit(1);
}
const extRoot = existsSync(dir) ? findPluginRoot(dir) : dir;
emit({
ok: true,
sha256: fetched.sha256,
size: fetched.size,
finalUrl: fetched.finalUrl,
source: fetched.source,
extRoot,
});
process.exit(0);
}
main().catch((err) => {
emit({ ok: false, error: `worker crashed: ${err.message || String(err)}` });
process.exit(1);
});