feat(llm-security): /security ide-scan <url> — Marketplace/OpenVSX/direct VSIX (v6.4.0)
Pre-installation verification of VS Code extensions via URL — fetch a remote VSIX, extract it in a hardened sandbox, and run the existing IDE scanner pipeline against it. No npm dependencies. Sources: - VS Code Marketplace (publisher.gallery.vsassets.io direct download) - OpenVSX (open-vsx.org official API) - Direct .vsix HTTPS URLs Defenses: - HTTPS-only, TLS verified, manual redirect with per-source host whitelist - 30s total timeout via AbortController - 50MB compressed cap, 500MB uncompressed, 100x expansion ratio - Zero-dep ZIP extractor: zip-slip, absolute paths, drive letters, NUL bytes, symlinks (Unix mode 0xA000), depth limits, ZIP64 rejected, encrypted rejected - SHA-256 streamed during fetch, surfaced in meta.source - Temp dir cleanup in all paths (try/finally) Files: - scanners/lib/vsix-fetch.mjs (HTTPS fetcher, host whitelist, streaming SHA-256) - scanners/lib/zip-extract.mjs (zero-dep parser with hardening caps) - knowledge/marketplace-api-notes.md (endpoint reference) - 3 test files (48 tests added: vsix-fetch, zip-extract, ide-extension-url) Tests: 1296 → 1344 (all green). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6252e55700
commit
fe0193956d
16 changed files with 1543 additions and 22 deletions
145
plugins/llm-security/tests/scanners/ide-extension-url.test.mjs
Normal file
145
plugins/llm-security/tests/scanners/ide-extension-url.test.mjs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// 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 });
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /URL fetch\/extract failed/.test(w)));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue