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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue