From a9759e0b1633c8482225f4f2a94b3f4993de07cb Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 17 Apr 2026 17:28:57 +0200 Subject: [PATCH] feat(llm-security): OS sandbox for /security ide-scan (v6.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 18 +++ CLAUDE.md | 6 +- README.md | 3 +- package.json | 2 +- scanners/ide-extension-scanner.mjs | 50 +++++-- scanners/lib/vsix-fetch-worker.mjs | 76 ++++++++++ scanners/lib/vsix-sandbox.mjs | 169 ++++++++++++++++++++++ tests/scanners/ide-extension-url.test.mjs | 14 +- tests/scanners/vsix-sandbox.test.mjs | 112 ++++++++++++++ 10 files changed, 431 insertions(+), 21 deletions(-) create mode 100644 scanners/lib/vsix-fetch-worker.mjs create mode 100644 scanners/lib/vsix-sandbox.mjs create mode 100644 tests/scanners/vsix-sandbox.test.mjs diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3ff6c17..5ddfa31 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "llm-security", "description": "Security scanning, auditing, and threat modeling for Claude Code projects. Detects secrets, validates MCP servers, assesses security posture, and generates threat models aligned with OWASP LLM Top 10.", - "version": "6.4.0" + "version": "6.5.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c12b44d..c953e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to the LLM Security Plugin are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [6.5.0] - 2026-04-17 + +### Added +- **OS sandbox for `/security ide-scan `.** VSIX fetch + extract now runs in a sub-process wrapped by `sandbox-exec` (macOS) or `bwrap` (Linux), reusing the same primitives proven by the `git clone` sandbox introduced in v5.1. Defense-in-depth: even if `zip-extract.mjs` has an undiscovered bypass, the kernel refuses any write outside the per-scan temp directory +- **`scanners/lib/vsix-fetch-worker.mjs`** — Sub-process worker. Argv: `--url --tmpdir `. Emits a single JSON line on stdout (`{ok, sha256, size, finalUrl, source, extRoot}` or `{ok:false, error, code?}`). Exit 0 on success, 1 on failure. Silent on stderr +- **`scanners/lib/vsix-sandbox.mjs`** — Wrapper. Exports `buildSandboxProfile`, `buildBwrapArgs`, `buildSandboxedWorker(tmpDir, args)`, `runVsixWorker(url, tmpDir, opts)`. 35 s timeout, 1 MB stdout cap, deterministic JSON-line protocol +- **`scan(url, { useSandbox })` option.** Default `true` for CLI invocations; tests pass `false` to keep `globalThis.fetch` mocking working (mocks do not cross process boundaries). When sandbox unavailable on the platform (e.g., Windows), a warning is added to `meta.warnings` and the scan still completes via the in-process fallback +- **`meta.source.sandbox`** — New envelope field: `'sandbox-exec' | 'bwrap' | 'none' | 'in-process'`. Tells the report which protection layer was actually active +- **8 new tests** in `tests/scanners/vsix-sandbox.test.mjs` covering profile generation per platform, worker arg construction, and live worker exit behavior on invalid URLs (no network required) + +### Changed +- `fetchAndExtractVsixUrl` in `ide-extension-scanner.mjs` is now sandbox-aware (`useSandbox` option, default `true`). Existing in-process logic preserved as fallback path +- Version bump: 6.4.0 → 6.5.0 across all files + +### Why +- Aligns the IDE-scan URL pipeline with the same defense-in-depth posture as the GitHub clone pipeline — kernel-enforced FS confinement instead of in-process validation alone +- VSIX is untrusted bytes from a third-party registry; even with hardened parsing, an OS sandbox is the right blast-radius constraint for filesystem writes + ## [6.4.0] - 2026-04-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0ddc852..21c83fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ -# LLM Security Plugin (v6.4.0) +# LLM Security Plugin (v6.5.0) -Security scanning, auditing, and threat modeling for Claude Code projects. 5 frameworks: OWASP LLM Top 10, Agentic AI Top 10 (ASI), Skills Top 10 (AST), MCP Top 10, AI Agent Traps (DeepMind). 1344 tests. +Security scanning, auditing, and threat modeling for Claude Code projects. 5 frameworks: OWASP LLM Top 10, Agentic AI Top 10 (ASI), Skills Top 10 (AST), MCP Top 10, AI Agent Traps (DeepMind). 1352 tests. ## Commands @@ -98,6 +98,8 @@ Scanner prefix: MCI. OWASP: MCP03, MCP06, MCP09. Invoked by `mcp-inspect` and `m **v6.4.0 — URL support.** Targets can be Marketplace, OpenVSX, or direct `.vsix` URLs. Pipeline: `lib/vsix-fetch.mjs` (HTTPS-only fetch with 50MB cap, 30s timeout, SHA-256, manual redirect host whitelist) → `lib/zip-extract.mjs` (zero-dep ZIP parser, rejects zip-slip/symlink/absolute/drive-letter/encrypted/ZIP64, caps: 10 000 entries, 500MB uncomp, 100x ratio, depth 20) → existing scan pipeline against extracted `extension/` subdir → temp dir always cleaned in `try/finally`. Envelope.meta.source = `{ type: "url", kind, url, finalUrl, sha256, size, publisher?, name?, version? }`. +**v6.5.0 — OS sandbox.** Fetch + extract for URL targets now spawns `lib/vsix-fetch-worker.mjs` in a sub-process wrapped by `sandbox-exec` (macOS) or `bwrap` (Linux) — same primitives reused from `git-clone.mjs`. Helper: `lib/vsix-sandbox.mjs` exports `buildSandboxProfile`, `buildBwrapArgs`, `buildSandboxedWorker`, `runVsixWorker`. Worker IPC: argv `--url --tmpdir ` → single JSON line on stdout (`{ok, sha256, size, finalUrl, source, extRoot}` or `{ok:false, error, code?}`). Defense-in-depth — if the in-process ZIP parser ever has a bypass, the kernel still refuses writes outside ``. `scan(target, { useSandbox })` defaults to `true`; tests pass `false` since `globalThis.fetch` mocks do not cross process boundaries. Windows fallback: in-process with `meta.warnings` advisory. Envelope `meta.source.sandbox`: `'sandbox-exec' | 'bwrap' | 'none' | 'in-process'`. + Run: `node scanners/ide-extension-scanner.mjs [target|url] [--vscode-only] [--intellij-only] [--include-builtin] [--online] [--format json|compact] [--fail-on ] [--output-file ]`. Invoked by `/security ide-scan`. ## Token Budget (ENFORCED) diff --git a/README.md b/README.md index 8f0db7c..8ad70ce 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ *Built for my own Claude Code workflow and shared openly for anyone who finds it useful. This is a solo project — bug reports and feature requests are welcome, but pull requests are not accepted.* -![Version](https://img.shields.io/badge/version-6.4.0-blue) +![Version](https://img.shields.io/badge/version-6.5.0-blue) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![Agents](https://img.shields.io/badge/agents-6-orange) ![Scanners](https://img.shields.io/badge/scanners-22-cyan) @@ -822,6 +822,7 @@ This plugin provides full-stack security hardening (static analysis + supply cha | Version | Date | Highlights | |---------|------|------------| +| **6.5.0** | 2026-04-17 | **OS sandbox for `/security ide-scan `.** VSIX fetch + extract 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 `lib/zip-extract.mjs` ever has a bypass, the kernel refuses any write outside the per-scan temp directory. New: `lib/vsix-fetch-worker.mjs` (sub-process worker with deterministic JSON-line IPC) and `lib/vsix-sandbox.mjs` (`buildSandboxProfile` / `buildBwrapArgs` / `buildSandboxedWorker` / `runVsixWorker`, 35 s timeout, 1 MB stdout cap). New `scan(target, { useSandbox })` option (default `true` for CLI; tests use `false` since `globalThis.fetch` mocks do not cross processes). Windows fallback: in-process with `meta.warnings` advisory. Envelope `meta.source.sandbox` field: `'sandbox-exec' \| 'bwrap' \| 'none' \| 'in-process'`. 1352 tests (was 1344). | | **6.4.0** | 2026-04-17 | **`/security ide-scan ` — pre-install verification.** The IDE extension scanner now accepts URLs and fetches the VSIX before scanning. Supported: VS Code Marketplace (`https://marketplace.visualstudio.com/items?itemName=publisher.name`), OpenVSX (`https://open-vsx.org/extension/publisher/name[/version]`), and direct `.vsix` URLs. New libraries: `lib/vsix-fetch.mjs` (HTTPS-only fetch with 50MB cap, 30s timeout, SHA-256, manual host-whitelisted redirects) and `lib/zip-extract.mjs` (zero-dep ZIP parser, rejects zip-slip / symlinks / absolute paths / drive letters / encrypted entries / ZIP64; caps: 10 000 entries, 500MB uncompressed, 100x expansion ratio, depth 20). Temp dir always cleaned in `try/finally`. Envelope `meta.source` carries `{ type: "url", kind, url, finalUrl, sha256, size, publisher, name, version }`. New knowledge file: `marketplace-api-notes.md`. GitHub repo URLs intentionally not supported (would require a build step). 1344 tests (was 1296). | | **6.3.0** | 2026-04-17 | **IDE extension prescan.** New `/security ide-scan` command and `ide-extension-scanner.mjs` (prefix IDE) discover and audit installed VS Code extensions (and forks: Cursor, Windsurf, VSCodium, code-server, Insiders, Remote-SSH; JetBrains is a v1.1 stub). 7 IDE-specific checks: blocklist match, theme-with-code, sideload (`.vsix`), broad activation (`*`, `onStartupFinished`), Levenshtein typosquat ≤2 vs top-100, extension-pack expansion, dangerous `vscode:uninstall` hooks. Per-extension orchestration of UNI/ENT/NET/TNT/MEM/SCR scanners with bounded concurrency. OS-aware discovery via `lib/ide-extension-discovery.mjs` (Platform-specific suffix parsing for `darwin-x64`, `linux-arm64`, etc.). Offline-first; `--online` opt-in for future Marketplace/OSV.dev lookups. New knowledge files: `ide-extension-threat-patterns.md` (10 categories, 2024-2026 case studies from Koi Security — GlassWorm, WhiteCobra, TigerJack, Material Theme), `top-vscode-extensions.json` (typosquat seed + blocklist), `top-jetbrains-plugins.json` (stub). 1296 tests (was 1274). | | **6.2.0** | 2026-04-17 | **Opus 4.7 + Claude Code 2.1.112 alignment.** Bash-normalize extended with T5 (`${IFS}` word-splitting) and T6 (ANSI-C `$'\xHH'` hex quoting) layers. New `pre-compact-scan.mjs` PreCompact hook — scans transcript tail (500 KB cap, <500 ms) for injection + credentials before context compaction. Modes: `block` / `warn` / `off` via `LLM_SECURITY_PRECOMPACT_MODE`. Agent files reframed for Opus 4.7's more literal instruction-following (Step 0 generaliseringsgrense + parallell Read-hint in skill-scanner + mcp-scanner). New `docs/security-hardening-guide.md` with env-var reference, sandboxing notes, system-card §5.2.1 / §6.3.1.1 mapping. CLAUDE.md Defense Philosophy links to system card. 1274 tests (was 1264). | diff --git a/package.json b/package.json index 879b50a..f40c437 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "llm-security", - "version": "6.4.0", + "version": "6.5.0", "description": "Security scanning, auditing, and threat modeling for Claude Code projects", "type": "module", "bin": { diff --git a/scanners/ide-extension-scanner.mjs b/scanners/ide-extension-scanner.mjs index 0da060a..3c0f395 100644 --- a/scanners/ide-extension-scanner.mjs +++ b/scanners/ide-extension-scanner.mjs @@ -29,6 +29,7 @@ import { parseVSCodeExtension, parseVsixFile } from './lib/ide-extension-parser. 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 { runVsixWorker } from './lib/vsix-sandbox.mjs'; import { scan as scanUnicode } from './unicode-scanner.mjs'; import { scan as scanEntropy } from './entropy-scanner.mjs'; @@ -37,7 +38,7 @@ 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.4.0'; +const VERSION = '6.5.0'; const SCANNER = 'IDE'; // --------------------------------------------------------------------------- @@ -53,14 +54,44 @@ function isUrlTarget(target) { * `parseVSCodeExtension` should be pointed at. VSIX layout always nests the * extension under `extension/`. * + * Two modes: + * - useSandbox=true (default for CLI): spawns vsix-fetch-worker.mjs under + * sandbox-exec (macOS) / bwrap (Linux) so any FS write is restricted to + * . Defense-in-depth against zip-extract bugs. + * - useSandbox=false: runs fetch + extract in-process. Used by tests that + * mock globalThis.fetch (mocking does not cross process boundaries). + * * Caller MUST `await rm(result.tempDir, { recursive: true, force: true })` in finally. * * @param {string} url - * @returns {Promise<{ extRoot: string, tempDir: string, source: object }>} + * @param {{ useSandbox?: boolean }} [opts] + * @returns {Promise<{ extRoot: string, tempDir: string, source: object, sandbox: 'sandbox-exec'|'bwrap'|null|'in-process' }>} */ -async function fetchAndExtractVsixUrl(url) { +async function fetchAndExtractVsixUrl(url, opts = {}) { + const useSandbox = opts.useSandbox !== false; const tempDir = await mkdtemp(join(tmpdir(), 'llm-sec-vsix-')); try { + if (useSandbox) { + const { ok, sandbox, payload } = await runVsixWorker(url, tempDir); + if (!ok) { + const msg = payload && payload.error ? payload.error : 'worker failed'; + throw new Error(msg); + } + const { type: kind, ...sourceMeta } = payload.source; + const source = { + type: 'url', + kind, + url, + finalUrl: payload.finalUrl, + sha256: payload.sha256, + size: payload.size, + sandbox: sandbox || 'none', + ...sourceMeta, + }; + return { extRoot: payload.extRoot, tempDir, source, sandbox: sandbox || null }; + } + + // In-process path (tests, or fallback when caller wants no sub-process). let fetched; try { fetched = await fetchVsixFromUrl(url); @@ -75,23 +106,21 @@ async function fetchAndExtractVsixUrl(url) { } 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' + kind, url, finalUrl: fetched.finalUrl, sha256: fetched.sha256, size: fetched.size, + sandbox: 'in-process', ...sourceMeta, }; - return { extRoot, tempDir, source }; + return { extRoot, tempDir, source, sandbox: 'in-process' }; } catch (err) { - // Cleanup on error before propagating. await rm(tempDir, { recursive: true, force: true }).catch(() => {}); throw err; } @@ -459,10 +488,13 @@ export async function scan(target, options = {}) { 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); + const fetched = await fetchAndExtractVsixUrl(target, { useSandbox: options.useSandbox }); urlSource = fetched.source; urlTempDir = fetched.tempDir; target = fetched.extRoot; // forward into single-target path mode + if (fetched.sandbox === null && options.useSandbox !== false) { + warnings.push('OS sandbox unavailable on this platform — VSIX extracted without sandbox-exec/bwrap. Defense-in-depth reduced to in-process zip-extract validation.'); + } } catch (err) { warnings.push(`URL fetch/extract failed: ${err.message}`); } diff --git a/scanners/lib/vsix-fetch-worker.mjs b/scanners/lib/vsix-fetch-worker.mjs new file mode 100644 index 0000000..a7fa0d3 --- /dev/null +++ b/scanners/lib/vsix-fetch-worker.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// vsix-fetch-worker.mjs — Sub-process worker that fetches a VSIX URL and extracts +// it to a writable directory. Designed to be spawned under sandbox-exec (macOS), +// bwrap (Linux), or directly (Windows fallback). +// +// Contract: +// stdin: none +// argv: --url --tmpdir +// stdout: single JSON line {ok:true, sha256, size, finalUrl, source, extRoot} +// on success, or {ok:false, error:"", code?:""} on failure +// stderr: never (silent — all errors via JSON on stdout) +// exit: 0 on success, 1 on any failure (caller still parses stdout) +// +// Why a worker: the parent process can wrap this command in sandbox-exec / bwrap +// so any filesystem write the ZIP extractor performs is restricted to . +// Defense-in-depth — even if our own zip-slip / symlink validation has a bug, +// the OS sandbox cannot let bytes land outside . + +import { writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { fetchVsixFromUrl } from './vsix-fetch.mjs'; +import { extractToDir, ZipError } from './zip-extract.mjs'; + +function emit(obj) { + process.stdout.write(JSON.stringify(obj) + '\n'); +} + +function parseArgs(argv) { + const out = { url: null, tmpdir: null }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--url' && i + 1 < argv.length) out.url = argv[++i]; + else if (argv[i] === '--tmpdir' && i + 1 < argv.length) out.tmpdir = argv[++i]; + } + return out; +} + +async function main() { + const { url, tmpdir: dir } = parseArgs(process.argv.slice(2)); + if (!url || !dir) { + emit({ ok: false, error: 'missing --url or --tmpdir' }); + process.exit(1); + } + let fetched; + try { + fetched = await fetchVsixFromUrl(url); + } catch (err) { + emit({ ok: false, error: `fetch failed: ${err.message}` }); + process.exit(1); + } + try { + await extractToDir(fetched.buffer, dir); + } catch (err) { + if (err instanceof ZipError) { + emit({ ok: false, error: `malformed VSIX (${err.code}): ${err.message}`, code: err.code }); + } else { + emit({ ok: false, error: `extract failed: ${err.message}` }); + } + process.exit(1); + } + const nested = join(dir, 'extension'); + const extRoot = existsSync(nested) ? nested : dir; + emit({ + ok: true, + sha256: fetched.sha256, + size: fetched.size, + finalUrl: fetched.finalUrl, + source: fetched.source, + extRoot, + }); + process.exit(0); +} + +main().catch((err) => { + emit({ ok: false, error: `worker crashed: ${err.message || String(err)}` }); + process.exit(1); +}); diff --git a/scanners/lib/vsix-sandbox.mjs b/scanners/lib/vsix-sandbox.mjs new file mode 100644 index 0000000..b0cdcaa --- /dev/null +++ b/scanners/lib/vsix-sandbox.mjs @@ -0,0 +1,169 @@ +// vsix-sandbox.mjs — Spawn vsix-fetch-worker.mjs under an OS sandbox so any +// filesystem writes the ZIP extractor performs are restricted to a single +// temp directory. +// +// Reuses the same sandbox primitives proven by `git-clone.mjs`: +// - macOS: sandbox-exec with a deny-file-write profile + subpath allow +// - Linux: bwrap with --ro-bind / --bind / --unshare-all +// - Windows / fallback: spawn directly + WARN to stderr (no OS sandbox) +// +// Defense-in-depth: even if our own zip-extract.mjs has a zip-slip / symlink +// bypass we did not foresee, the OS will refuse the write. The in-process +// validation in zip-extract.mjs remains the first line of defense. + +import { spawn } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve as resolvePath } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WORKER_PATH = resolvePath(__dirname, 'vsix-fetch-worker.mjs'); +const WORKER_TIMEOUT_MS = 35_000; // fetch is 30s, give worker 5s of slack +const MAX_OUTPUT_BYTES = 1024 * 1024; // 1MB JSON cap (output is tiny in practice) + +/** + * Build the sandbox-exec profile that allows writes only to `allowedWritePath`. + * Mirrors `buildSandboxProfile` in git-clone.mjs. + * @returns {string|null} null if not on macOS or sandbox-exec missing + */ +export function buildSandboxProfile(allowedWritePath) { + if (process.platform !== 'darwin') return null; + const check = spawnSync('which', ['sandbox-exec'], { encoding: 'utf8' }); + if (check.status !== 0) return null; + const realPath = realpathSync(allowedWritePath); + return [ + '(version 1)', + '(allow default)', + '(deny file-write*)', + `(allow file-write* (subpath "${realPath}"))`, + '(allow file-write* (literal "/dev/null"))', + '(allow file-write* (literal "/dev/tty"))', + ].join(''); +} + +/** + * Build bwrap arguments that allow writes only to `allowedWritePath`. + * Mirrors `buildBwrapArgs` in git-clone.mjs. + * @returns {string[]|null} null if not on Linux or bwrap unusable + */ +export function buildBwrapArgs(allowedWritePath, innerArgs) { + if (process.platform !== 'linux') return null; + const check = spawnSync('which', ['bwrap'], { encoding: 'utf8' }); + if (check.status !== 0) return null; + // Probe — bwrap is shipped on Ubuntu 24.04+ but may need admin AppArmor config. + const probe = spawnSync( + 'bwrap', + ['--ro-bind', '/', '/', '--dev', '/dev', '/bin/true'], + { stdio: 'ignore', timeout: 5000 }, + ); + if (probe.status !== 0) return null; + return [ + '--ro-bind', '/', '/', + '--bind', allowedWritePath, allowedWritePath, + '--dev', '/dev', + '--unshare-all', + '--new-session', + '--die-with-parent', + ...innerArgs, + ]; +} + +/** + * Resolve the spawn command for a worker invocation. Returns the OS sandbox + * if available; otherwise plain `node` with a `sandbox: null` flag so the + * caller can WARN. Identical pattern to `buildSandboxedClone`. + * + * @param {string} tmpDir writable temp dir for the worker + * @param {string[]} workerArgs argv for the worker (after `node `) + * @returns {{cmd:string, args:string[], sandbox: 'sandbox-exec'|'bwrap'|null}} + */ +export function buildSandboxedWorker(tmpDir, workerArgs) { + const innerArgs = ['node', WORKER_PATH, ...workerArgs]; + + const profile = buildSandboxProfile(tmpDir); + if (profile) { + return { cmd: 'sandbox-exec', args: ['-p', profile, ...innerArgs], sandbox: 'sandbox-exec' }; + } + + const bwrapArgs = buildBwrapArgs(tmpDir, innerArgs); + if (bwrapArgs) { + return { cmd: 'bwrap', args: bwrapArgs, sandbox: 'bwrap' }; + } + + // Fallback — Windows or sandbox tools missing. Worker still runs, just not boxed. + return { cmd: innerArgs[0], args: innerArgs.slice(1), sandbox: null }; +} + +/** + * Spawn the VSIX worker and parse its single JSON line of output. + * + * @param {string} url URL to fetch (passed via argv to worker) + * @param {string} tmpDir writable directory for extracted files + * @param {object} [opts] + * @param {boolean} [opts.allowFallback=true] if false, throw when no OS sandbox + * @returns {Promise<{ok:boolean, sandbox:'sandbox-exec'|'bwrap'|null, payload:object}>} + */ +export function runVsixWorker(url, tmpDir, opts = {}) { + const { allowFallback = true } = opts; + const { cmd, args, sandbox } = buildSandboxedWorker(tmpDir, ['--url', url, '--tmpdir', tmpDir]); + + if (!sandbox && !allowFallback) { + return Promise.reject(new Error('no OS sandbox available and fallback disabled')); + } + + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: WORKER_TIMEOUT_MS, + env: { ...process.env }, + }); + + let stdout = ''; + let stdoutBytes = 0; + let truncated = false; + child.stdout.on('data', (chunk) => { + stdoutBytes += chunk.length; + if (stdoutBytes > MAX_OUTPUT_BYTES) { + truncated = true; + try { child.kill('SIGKILL'); } catch {} + return; + } + stdout += chunk.toString('utf8'); + }); + + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); + + child.on('error', (err) => reject(new Error(`worker spawn failed: ${err.message}`))); + + child.on('close', (code, signal) => { + if (truncated) { + return reject(new Error('worker output exceeded 1MB cap')); + } + if (signal === 'SIGTERM' && code === null) { + return reject(new Error(`worker timed out after ${WORKER_TIMEOUT_MS}ms`)); + } + // Parse the last non-empty line as JSON (worker writes one line on success/failure). + const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean); + const last = lines[lines.length - 1]; + if (!last) { + const tail = stderr.trim().slice(0, 200); + return reject(new Error(`worker produced no output${tail ? ` (stderr: ${tail})` : ''}`)); + } + let payload; + try { + payload = JSON.parse(last); + } catch { + return reject(new Error(`worker emitted non-JSON: ${last.slice(0, 120)}`)); + } + resolve({ ok: payload.ok === true, sandbox, payload }); + }); + }); +} + +export const __testing = { + WORKER_PATH, + WORKER_TIMEOUT_MS, + MAX_OUTPUT_BYTES, +}; diff --git a/tests/scanners/ide-extension-url.test.mjs b/tests/scanners/ide-extension-url.test.mjs index bf32a65..4766f60 100644 --- a/tests/scanners/ide-extension-url.test.mjs +++ b/tests/scanners/ide-extension-url.test.mjs @@ -54,7 +54,7 @@ describe('ide-extension-scanner — URL mode', () => { it('rejects unsupported URL with a warning, no extensions scanned', async () => { installFetchRouter(() => null); - const env = await scan('https://example.com/random.zip', { vscodeOnly: true }); + 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); @@ -62,7 +62,7 @@ describe('ide-extension-scanner — URL mode', () => { 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 }); + 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))); }); @@ -83,7 +83,7 @@ describe('ide-extension-scanner — URL mode', () => { return null; }); - const env = await scan('https://open-vsx.org/extension/anthropic/claude-code', { vscodeOnly: true }); + 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); @@ -109,7 +109,7 @@ describe('ide-extension-scanner — URL mode', () => { return null; }); - const env = await scan('https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code', { vscodeOnly: true }); + 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'); @@ -120,7 +120,7 @@ describe('ide-extension-scanner — URL mode', () => { 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 }); + 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))); }); @@ -131,14 +131,14 @@ describe('ide-extension-scanner — URL mode', () => { { name: '../escape.txt', data: 'pwned' }, ]); installFetchRouter(() => mockResponse(evil)); - const env = await scan('https://example.com/evil.vsix', { vscodeOnly: true }); + 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 }); + 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))); }); diff --git a/tests/scanners/vsix-sandbox.test.mjs b/tests/scanners/vsix-sandbox.test.mjs new file mode 100644 index 0000000..805d382 --- /dev/null +++ b/tests/scanners/vsix-sandbox.test.mjs @@ -0,0 +1,112 @@ +// vsix-sandbox.test.mjs — Tests for the VSIX sandbox wrapper and worker. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + buildSandboxProfile, + buildBwrapArgs, + buildSandboxedWorker, + runVsixWorker, + __testing, +} from '../../scanners/lib/vsix-sandbox.mjs'; + +describe('vsix-sandbox — buildSandboxProfile', () => { + it('returns null on non-darwin', () => { + if (process.platform === 'darwin') return; // Not applicable here. + const profile = buildSandboxProfile('/tmp'); + assert.equal(profile, null); + }); + + it('returns a valid profile string on macOS when sandbox-exec exists', () => { + if (process.platform !== 'darwin') return; + const has = spawnSync('which', ['sandbox-exec'], { encoding: 'utf8' }); + if (has.status !== 0) return; + const profile = buildSandboxProfile('/tmp'); + assert.ok(profile, 'expected profile string on macOS'); + assert.match(profile, /\(version 1\)/); + assert.match(profile, /\(deny file-write\*\)/); + assert.match(profile, /\(allow file-write\* \(subpath /); + }); +}); + +describe('vsix-sandbox — buildBwrapArgs', () => { + it('returns null on non-linux', () => { + if (process.platform === 'linux') return; + const args = buildBwrapArgs('/tmp', ['/bin/true']); + assert.equal(args, null); + }); +}); + +describe('vsix-sandbox — buildSandboxedWorker', () => { + it('returns sandbox-exec on macOS, bwrap on Linux, or null fallback', () => { + const { cmd, args, sandbox } = buildSandboxedWorker('/tmp', ['--url', 'https://x', '--tmpdir', '/tmp']); + assert.ok(cmd); + assert.ok(Array.isArray(args)); + if (process.platform === 'darwin') { + const has = spawnSync('which', ['sandbox-exec'], { encoding: 'utf8' }); + if (has.status === 0) { + assert.equal(sandbox, 'sandbox-exec'); + assert.equal(cmd, 'sandbox-exec'); + assert.equal(args[0], '-p'); + } + } else if (process.platform === 'linux') { + // Could be 'bwrap' or null depending on availability — both are valid. + assert.ok(sandbox === 'bwrap' || sandbox === null); + } else { + assert.equal(sandbox, null); + assert.equal(cmd, 'node'); + } + }); + + it('always includes the worker path and forwarded args', () => { + const { args } = buildSandboxedWorker('/tmp', ['--url', 'https://example/', '--tmpdir', '/tmp']); + const joined = args.join(' '); + assert.match(joined, /vsix-fetch-worker\.mjs/); + assert.match(joined, /--url https:\/\/example\//); + assert.match(joined, /--tmpdir \/tmp/); + }); +}); + +describe('vsix-sandbox — runVsixWorker (live worker, no network)', () => { + it('handles non-HTTPS URL: worker exits with ok:false and a fetch error', async () => { + const dir = await mkdtemp(join(tmpdir(), 'llm-sec-vsix-test-')); + try { + const { ok, payload, sandbox } = await runVsixWorker('http://example.com/foo.vsix', dir); + assert.equal(ok, false); + assert.ok(payload.error, 'expected error message'); + assert.match(payload.error, /fetch failed|HTTPS|unsupported/i); + // Sandbox may be 'sandbox-exec', 'bwrap', or null on Windows. All valid. + assert.ok(sandbox === 'sandbox-exec' || sandbox === 'bwrap' || sandbox === null); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('handles unsupported URL kind: worker exits with ok:false', async () => { + const dir = await mkdtemp(join(tmpdir(), 'llm-sec-vsix-test-')); + try { + const { ok, payload } = await runVsixWorker('https://example.com/random.zip', dir); + assert.equal(ok, false); + assert.match(payload.error, /unsupported URL|fetch failed/i); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('rejects when no --url or --tmpdir is provided (worker arg validation)', async () => { + // Construct a minimal direct worker call without any args. + const { spawn } = await import('node:child_process'); + const child = spawn('node', [__testing.WORKER_PATH], { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + child.stdout.on('data', (c) => { out += c.toString('utf8'); }); + const code = await new Promise((resolve) => child.on('close', resolve)); + assert.equal(code, 1); + const parsed = JSON.parse(out.trim()); + assert.equal(parsed.ok, false); + assert.match(parsed.error, /missing --url or --tmpdir/); + }); +});