ktg-plugin-marketplace/plugins/llm-security/tests/scanners/ide-extension-url.test.mjs
Kjell Tore Guttormsen 9f893c3858 feat(llm-security): OS sandbox for /security ide-scan <url> (v6.5.0)
VSIX fetch + extract for URL targets now runs in a sub-process wrapped by
sandbox-exec (macOS) or bwrap (Linux), reusing the same primitives proven
by the v5.1 git-clone sandbox. Defense-in-depth — even if our own
zip-extract.mjs ever has a bypass, the kernel refuses any write outside
the per-scan temp directory.

New files:
- scanners/lib/vsix-fetch-worker.mjs — sub-process worker. Argv: --url
  --tmpdir; emits one JSON line on stdout (ok/sha256/size/source/extRoot
  or ok:false/error/code). Silent on stderr. Exit 0/1.
- scanners/lib/vsix-sandbox.mjs — wrapper. Exports buildSandboxProfile,
  buildBwrapArgs, buildSandboxedWorker, runVsixWorker. 35s timeout, 1 MB
  stdout cap.

Changes:
- scanners/ide-extension-scanner.mjs: fetchAndExtractVsixUrl is now
  sandbox-aware (useSandbox option, default true). In-process logic
  preserved as fallback. New meta.source.sandbox field:
  'sandbox-exec' | 'bwrap' | 'none' | 'in-process'.
- scan(target, { useSandbox }) defaults to true; tests pass false because
  globalThis.fetch mocks do not cross process boundaries.
- Windows fallback: in-process with meta.warnings advisory.

Tests:
- 8 new tests in tests/scanners/vsix-sandbox.test.mjs (per-platform
  profile generation, worker arg construction, live worker exit
  behavior on invalid URLs — no network).
- Existing URL tests updated to opt out of sandbox (useSandbox: false).
- 1344 → 1352 tests, all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 17:28:57 +02:00

145 lines
5.7 KiB
JavaScript

// ide-extension-url.test.mjs — Integration tests for `/security ide-scan <url>`.
// Mocks global.fetch so we never hit real Marketplace / OpenVSX endpoints.
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan } from '../../scanners/ide-extension-scanner.mjs';
import { buildZip } from '../lib/build-zip.mjs';
const realFetch = globalThis.fetch;
function mockResponse(buffer, { status = 200 } = {}) {
const stream = new ReadableStream({
start(controller) { controller.enqueue(buffer); controller.close(); },
});
return new Response(stream, { status, headers: { 'content-type': 'application/octet-stream' } });
}
function jsonResponse(obj) {
return new Response(JSON.stringify(obj), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
function buildBenignVsix() {
const pkg = JSON.stringify({
publisher: 'anthropic',
name: 'claude-code',
version: '1.0.0',
engines: { vscode: '^1.80.0' },
main: './extension.js',
activationEvents: ['onCommand:claude.hello'],
categories: ['Other'],
});
return buildZip([
{ name: 'extension.vsixmanifest', data: '<PackageManifest></PackageManifest>' },
{ name: 'extension/package.json', data: pkg },
{ name: 'extension/extension.js', data: 'module.exports = { activate(){} };' },
]);
}
function installFetchRouter(routes) {
globalThis.fetch = async (url) => {
const handler = routes(url);
if (!handler) throw new Error(`unrouted fetch: ${url}`);
return handler;
};
}
describe('ide-extension-scanner — URL mode', () => {
before(() => resetCounter());
after(() => { globalThis.fetch = realFetch; });
it('rejects unsupported URL with a warning, no extensions scanned', async () => {
installFetchRouter(() => null);
const env = await scan('https://example.com/random.zip', { vscodeOnly: true, useSandbox: false });
assert.equal(env.extensions.length, 0);
assert.ok(env.meta.warnings.some(w => /unsupported URL/i.test(w)));
assert.equal(env.meta.source, null);
});
it('reports github URL as unsupported in v6.4.0', async () => {
installFetchRouter(() => null);
const env = await scan('https://github.com/anthropic/claude-code', { vscodeOnly: true, useSandbox: false });
assert.equal(env.extensions.length, 0);
assert.ok(env.meta.warnings.some(w => /GitHub repo URLs/i.test(w)));
});
it('fetches OpenVSX VSIX and scans the extracted extension', async () => {
const vsix = buildBenignVsix();
let metaCalled = false;
let downloadCalled = false;
installFetchRouter((url) => {
if (url.endsWith('/latest')) {
metaCalled = true;
return jsonResponse({ version: '1.0.0' });
}
if (url.includes('/file/') && url.endsWith('.vsix')) {
downloadCalled = true;
return mockResponse(vsix);
}
return null;
});
const env = await scan('https://open-vsx.org/extension/anthropic/claude-code', { vscodeOnly: true, useSandbox: false });
assert.ok(metaCalled, 'expected metadata fetch for latest version');
assert.ok(downloadCalled, 'expected VSIX download');
assert.equal(env.extensions.length, 1);
assert.equal(env.extensions[0].id, 'anthropic.claude-code');
assert.equal(env.extensions[0].version, '1.0.0');
assert.ok(env.meta.source);
assert.equal(env.meta.source.type, 'url');
assert.equal(env.meta.source.publisher, 'anthropic');
assert.equal(env.meta.source.name, 'claude-code');
assert.equal(env.meta.source.version, '1.0.0');
assert.match(env.meta.source.sha256, /^[a-f0-9]{64}$/);
assert.equal(env.meta.target, 'https://open-vsx.org/extension/anthropic/claude-code');
});
it('fetches Marketplace VSIX directly without metadata round-trip', async () => {
const vsix = buildBenignVsix();
let downloads = 0;
installFetchRouter((url) => {
if (url.includes('Microsoft.VisualStudio.Services.VSIXPackage')) {
downloads++;
return mockResponse(vsix);
}
return null;
});
const env = await scan('https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code', { vscodeOnly: true, useSandbox: false });
assert.equal(downloads, 1);
assert.equal(env.extensions.length, 1);
assert.equal(env.extensions[0].id, 'anthropic.claude-code');
assert.equal(env.meta.source.type, 'url');
assert.equal(env.meta.source.requestedUrl?.includes('VSIXPackage'), true);
});
it('cleans up temp dir even when extraction fails', async () => {
// Return a non-zip body so extract throws.
installFetchRouter(() => mockResponse(Buffer.from('not a zip at all')));
const env = await scan('https://example.com/bad.vsix', { vscodeOnly: true, useSandbox: false });
assert.equal(env.extensions.length, 0);
assert.ok(env.meta.warnings.some(w => /malformed VSIX/.test(w)));
});
it('rejects zip-slip VSIX as malformed', async () => {
const evil = buildZip([
{ name: 'extension/package.json', data: '{}' },
{ name: '../escape.txt', data: 'pwned' },
]);
installFetchRouter(() => mockResponse(evil));
const env = await scan('https://example.com/evil.vsix', { vscodeOnly: true, useSandbox: false });
assert.equal(env.extensions.length, 0);
assert.ok(env.meta.warnings.some(w => /malformed VSIX/.test(w) && /traversal/.test(w)));
});
it('handles fetch network failure cleanly', async () => {
installFetchRouter(() => { throw new Error('ECONNREFUSED'); });
const env = await scan('https://open-vsx.org/extension/foo/bar', { vscodeOnly: true, useSandbox: false });
assert.equal(env.extensions.length, 0);
assert.ok(env.meta.warnings.some(w => /URL fetch\/extract failed/.test(w)));
});
});