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:
Kjell Tore Guttormsen 2026-04-17 17:16:26 +02:00
commit fe0193956d
16 changed files with 1543 additions and 22 deletions

View file

@ -13,7 +13,9 @@
// Library: import { scan, discoverAll } from './ide-extension-scanner.mjs'
import { resolve, join, relative } from 'node:path';
import { writeFileSync } from 'node:fs';
import { writeFileSync, existsSync } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { discoverFiles } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
@ -25,6 +27,8 @@ import {
} from './lib/ide-extension-discovery.mjs';
import { parseVSCodeExtension, parseVsixFile } from './lib/ide-extension-parser.mjs';
import { loadTopVSCode, loadVSCodeBlocklist, normalizeId } from './lib/ide-extension-data.mjs';
import { fetchVsixFromUrl, detectUrlType } from './lib/vsix-fetch.mjs';
import { extractToDir, ZipError } from './lib/zip-extract.mjs';
import { scan as scanUnicode } from './unicode-scanner.mjs';
import { scan as scanEntropy } from './entropy-scanner.mjs';
@ -33,9 +37,66 @@ import { scan as scanTaint } from './taint-tracer.mjs';
import { scan as scanMemoryPoisoning } from './memory-poisoning-scanner.mjs';
import { scan as scanSupplyChain } from './supply-chain-recheck.mjs';
const VERSION = '6.3.0';
const VERSION = '6.4.0';
const SCANNER = 'IDE';
// ---------------------------------------------------------------------------
// URL → temp dir orchestration
// ---------------------------------------------------------------------------
function isUrlTarget(target) {
return typeof target === 'string' && /^https?:\/\//i.test(target);
}
/**
* Fetch a VSIX from a URL, extract it to a temp dir, and return the path that
* `parseVSCodeExtension` should be pointed at. VSIX layout always nests the
* extension under `extension/`.
*
* Caller MUST `await rm(result.tempDir, { recursive: true, force: true })` in finally.
*
* @param {string} url
* @returns {Promise<{ extRoot: string, tempDir: string, source: object }>}
*/
async function fetchAndExtractVsixUrl(url) {
const tempDir = await mkdtemp(join(tmpdir(), 'llm-sec-vsix-'));
try {
let fetched;
try {
fetched = await fetchVsixFromUrl(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 VSIX (${err.code}): ${err.message}`);
}
throw err;
}
// VSIX nests files under `extension/`. If that doesn't exist, fall back to
// the temp dir itself (some packagers omit the wrapper).
const nested = join(tempDir, 'extension');
const extRoot = existsSync(nested) ? nested : tempDir;
const { type: kind, ...sourceMeta } = fetched.source;
const source = {
type: 'url',
kind, // 'marketplace' | 'openvsx' | 'vsix'
url,
finalUrl: fetched.finalUrl,
sha256: fetched.sha256,
size: fetched.size,
...sourceMeta,
};
return { extRoot, tempDir, source };
} catch (err) {
// Cleanup on error before propagating.
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
}
// ---------------------------------------------------------------------------
// IDE-specific checks (operate on parsed manifest)
// ---------------------------------------------------------------------------
@ -386,10 +447,38 @@ export async function scan(target, options = {}) {
const warnings = [];
let extensions = [];
let rootsScanned = [];
let urlSource = null;
let urlTempDir = null;
const singleTargetPath = target && target !== '.' && target !== 'all' ? resolve(target) : null;
// URL mode: fetch VSIX, 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)`);
} 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 {
try {
const fetched = await fetchAndExtractVsixUrl(target);
urlSource = fetched.source;
urlTempDir = fetched.tempDir;
target = fetched.extRoot; // forward into single-target path mode
} catch (err) {
warnings.push(`URL fetch/extract failed: ${err.message}`);
}
}
}
if (singleTargetPath) {
const urlFetchFailed = isUrlTarget(target) && !urlSource;
const singleTargetPath = target && target !== '.' && target !== 'all' && !isUrlTarget(target)
? resolve(target)
: null;
try {
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) {
@ -453,7 +542,7 @@ export async function scan(target, options = {}) {
meta: {
scanner: 'ide-extension-scanner',
version: VERSION,
target: singleTargetPath || (target || 'discover-all'),
target: urlSource ? urlSource.url : (singleTargetPath || (target || 'discover-all')),
timestamp: new Date().toISOString(),
node_version: process.version,
duration_ms: Date.now() - started,
@ -463,6 +552,7 @@ export async function scan(target, options = {}) {
},
roots_scanned: rootsScanned,
online: options.online === true,
source: urlSource,
warnings,
},
extensions: perExt,
@ -476,6 +566,11 @@ export async function scan(target, options = {}) {
extensions_warning: warningCount,
},
};
} finally {
if (urlTempDir) {
await rm(urlTempDir, { recursive: true, force: true }).catch(() => {});
}
}
}
/**
@ -535,7 +630,10 @@ async function main() {
console.log(`ide-extension-scanner v${VERSION}
Usage: node ide-extension-scanner.mjs [target] [options]
target: omitted/"."/"all" = discover all installed; path to extracted extension directory = single scan
target: omitted/"."/"all" = discover all installed; path to extracted extension directory = single scan;
https://marketplace.visualstudio.com/items?itemName=publisher.name = fetch from Marketplace;
https://open-vsx.org/extension/publisher/name[/version] = fetch from OpenVSX;
https://example.com/path/foo.vsix = direct VSIX download
Options:
--vscode-only Skip JetBrains discovery