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
126
plugins/llm-security/tests/scanners/vsix-fetch.test.mjs
Normal file
126
plugins/llm-security/tests/scanners/vsix-fetch.test.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// vsix-fetch.test.mjs — Unit tests for URL detection + body capping.
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { detectUrlType, __testing } from '../../scanners/lib/vsix-fetch.mjs';
|
||||
|
||||
const { isAllowedHost, readBodyCapped, MAX_VSIX_BYTES } = __testing;
|
||||
|
||||
describe('detectUrlType', () => {
|
||||
it('detects VS Code Marketplace URL', () => {
|
||||
const out = detectUrlType('https://marketplace.visualstudio.com/items?itemName=ms-python.python');
|
||||
assert.equal(out.type, 'marketplace');
|
||||
assert.equal(out.publisher, 'ms-python');
|
||||
assert.equal(out.name, 'python');
|
||||
});
|
||||
|
||||
it('returns unknown for marketplace URL without itemName', () => {
|
||||
const out = detectUrlType('https://marketplace.visualstudio.com/items');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
|
||||
it('returns unknown for marketplace itemName without dot', () => {
|
||||
const out = detectUrlType('https://marketplace.visualstudio.com/items?itemName=foobar');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
|
||||
it('detects OpenVSX URL with version', () => {
|
||||
const out = detectUrlType('https://open-vsx.org/extension/anthropic/claude-code/1.2.3');
|
||||
assert.equal(out.type, 'openvsx');
|
||||
assert.equal(out.publisher, 'anthropic');
|
||||
assert.equal(out.name, 'claude-code');
|
||||
assert.equal(out.version, '1.2.3');
|
||||
});
|
||||
|
||||
it('detects OpenVSX URL without version', () => {
|
||||
const out = detectUrlType('https://open-vsx.org/extension/anthropic/claude-code');
|
||||
assert.equal(out.type, 'openvsx');
|
||||
assert.equal(out.publisher, 'anthropic');
|
||||
assert.equal(out.name, 'claude-code');
|
||||
assert.equal(out.version, null);
|
||||
});
|
||||
|
||||
it('detects direct .vsix download', () => {
|
||||
const out = detectUrlType('https://example.com/path/extension.vsix');
|
||||
assert.equal(out.type, 'vsix');
|
||||
});
|
||||
|
||||
it('detects GitHub URL as github (unsupported)', () => {
|
||||
const out = detectUrlType('https://github.com/anthropic/claude-code');
|
||||
assert.equal(out.type, 'github');
|
||||
});
|
||||
|
||||
it('rejects plain HTTP', () => {
|
||||
const out = detectUrlType('http://marketplace.visualstudio.com/items?itemName=ms-python.python');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
|
||||
it('returns unknown for malformed URL', () => {
|
||||
const out = detectUrlType('not a url');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
|
||||
it('returns unknown for unrelated HTTPS URL', () => {
|
||||
const out = detectUrlType('https://example.com/somefile.zip');
|
||||
assert.equal(out.type, 'unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedHost', () => {
|
||||
it('allows marketplace gallery cdn for marketplace fetches', () => {
|
||||
assert.equal(isAllowedHost('foo.gallerycdn.vsassets.io', 'marketplace'), true);
|
||||
assert.equal(isAllowedHost('marketplace.visualstudio.com', 'marketplace'), true);
|
||||
});
|
||||
|
||||
it('rejects unrelated host for marketplace fetches', () => {
|
||||
assert.equal(isAllowedHost('evil.example.com', 'marketplace'), false);
|
||||
});
|
||||
|
||||
it('allows openvsx blob storage', () => {
|
||||
assert.equal(isAllowedHost('open-vsx.org', 'openvsx'), true);
|
||||
assert.equal(isAllowedHost('openvsxorg.blob.core.windows.net', 'openvsx'), true);
|
||||
});
|
||||
|
||||
it('rejects unrelated host for openvsx fetches', () => {
|
||||
assert.equal(isAllowedHost('evil.example.com', 'openvsx'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBodyCapped', () => {
|
||||
function makeStreamResponse(chunks) {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream);
|
||||
}
|
||||
|
||||
it('reads small body fully and computes SHA-256', async () => {
|
||||
const data = new TextEncoder().encode('hello world');
|
||||
const res = makeStreamResponse([data]);
|
||||
const ctrl = new AbortController();
|
||||
const out = await readBodyCapped(res, ctrl);
|
||||
assert.equal(out.size, 11);
|
||||
assert.equal(out.buffer.toString('utf8'), 'hello world');
|
||||
// sha256("hello world")
|
||||
assert.equal(out.sha256, 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9');
|
||||
});
|
||||
|
||||
it('aborts when body exceeds MAX_VSIX_BYTES', async () => {
|
||||
// Stream a small chunk repeated such that total > cap.
|
||||
const chunkSize = 1024 * 1024;
|
||||
const chunk = new Uint8Array(chunkSize);
|
||||
const totalChunks = Math.ceil(MAX_VSIX_BYTES / chunkSize) + 2; // overshoot
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (let i = 0; i < totalChunks; i++) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const res = new Response(stream);
|
||||
const ctrl = new AbortController();
|
||||
await assert.rejects(() => readBodyCapped(res, ctrl), /exceeds maximum size/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue