feat(llm-security): OS sandbox for /security ide-scan <url> (v6.5.0)
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 <noreply@anthropic.com>
This commit is contained in:
parent
fe0193956d
commit
9f893c3858
11 changed files with 434 additions and 24 deletions
|
|
@ -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
|
||||
* <tempDir>. 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}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue