diff --git a/packages/deepsec/build.mjs b/packages/deepsec/build.mjs index c0eb21a..6e57068 100644 --- a/packages/deepsec/build.mjs +++ b/packages/deepsec/build.mjs @@ -15,6 +15,7 @@ const external = [ "@openai/codex-sdk", "@vercel/sandbox", "jiti", + "tar", ]; const common = { diff --git a/packages/deepsec/package.json b/packages/deepsec/package.json index db2d8cc..ef93c4f 100644 --- a/packages/deepsec/package.json +++ b/packages/deepsec/package.json @@ -36,7 +36,8 @@ "@openai/codex": "^0.125.0", "@openai/codex-sdk": "^0.125.0", "@vercel/sandbox": "^1.9.0", - "jiti": "^2.4.0" + "jiti": "^2.4.0", + "tar": "^7.5.2" }, "devDependencies": { "@deepsec/core": "workspace:*", diff --git a/packages/deepsec/src/sandbox/download.ts b/packages/deepsec/src/sandbox/download.ts index bb599a8..bb54699 100644 --- a/packages/deepsec/src/sandbox/download.ts +++ b/packages/deepsec/src/sandbox/download.ts @@ -1,4 +1,3 @@ -import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { dataDir } from "@deepsec/core"; @@ -115,32 +114,57 @@ export async function downloadResults( return count; } +// The tarball is produced inside the Vercel Sandbox VM, which the README +// threat model explicitly treats as untrusted (prompt-injected agents can +// run shell commands there). A previous `tar -xzvf` shell-out followed +// pre-existing symlinks during extraction, turning sandbox-side code +// execution into orchestrator-host arbitrary file write +// (CVE-2007-4131 / CVE-2018-20482 class). Switch to node-tar, which: +// - strips absolute paths and `..` components by default +// - refuses to extract symlinks pointing outside cwd +// - opens regular files with O_NOFOLLOW under the hood +// The explicit `filter` below additionally rejects symlink AND hardlink +// members entirely — the agent only ever writes regular FileRecord JSON, +// so anything else in the archive is by definition adversarial. async function extractTarballLocally(tarPath: string, destDir: string): Promise { - // Use `tar -xzvf` and count emitted lines for "files extracted" feedback. - return await new Promise((resolve, reject) => { - const child = spawn("tar", ["-xzvf", tarPath, "-C", destDir], { - stdio: ["ignore", "pipe", "pipe"], - }); - let extracted = 0; - let stderr = ""; - let stdoutBuf = ""; - child.stdout.on("data", (c: Buffer) => { - stdoutBuf += c.toString(); - let nl: number; - while ((nl = stdoutBuf.indexOf("\n")) !== -1) { - const line = stdoutBuf.slice(0, nl); - stdoutBuf = stdoutBuf.slice(nl + 1); - if (line && !line.endsWith("/")) extracted++; + const tar = await loadTar(); + let extracted = 0; + await tar.extract({ + file: tarPath, + cwd: destDir, + filter: (_p, entry) => { + const t = (entry as { type?: string }).type; + if (t === "SymbolicLink" || t === "Link") { + throw new Error(`refusing to extract: tarball contains ${t} member`); } - }); - child.stderr.on("data", (c: Buffer) => (stderr += c.toString())); - child.on("error", reject); - child.on("close", (code) => { - if (code !== 0) { - reject(new Error(`tar -xzf exited ${code}: ${stderr.slice(0, 500)}`)); - } else { - resolve(extracted); - } - }); + return true; + }, + onentry: (entry: { type?: string }) => { + if (entry.type === "File") extracted++; + }, }); + return extracted; +} + +// node-tar is a declared dependency; keep it external in the bundle so +// `import("tar")` resolves from the installed package at runtime (pnpm, npm, +// or global installs; no reliance on node_modules/.pnpm layout). +let cachedTar: TarModule | undefined; + +interface TarExtractOptions { + file: string; + cwd: string; + filter?: (path: string, entry: unknown) => boolean; + onentry?: (entry: { type?: string }) => void; +} + +interface TarModule { + extract: (opts: TarExtractOptions) => Promise; +} + +async function loadTar(): Promise { + if (cachedTar) return cachedTar; + const mod = (await import("tar")) as TarModule; + cachedTar = mod; + return mod; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7628472..6e36add 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: jiti: specifier: ^2.4.0 version: 2.6.1 + tar: + specifier: ^7.5.2 + version: 7.5.13 devDependencies: '@deepsec/core': specifier: workspace:* @@ -800,6 +803,13 @@ packages: engines: {node: '>=18'} dev: false + /@isaacs/fs-minipass@4.0.1: + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + dependencies: + minipass: 7.1.3 + dev: false + /@jridgewell/sourcemap-codec@1.5.5: resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} dev: true @@ -1721,6 +1731,11 @@ packages: engines: {node: '>= 16'} dev: true + /chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + dev: false + /commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -2291,6 +2306,13 @@ packages: engines: {node: '>=16 || 14 >=14.17'} dev: false + /minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + dependencies: + minipass: 7.1.3 + dev: false + /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2694,6 +2716,17 @@ packages: - react-native-b4a dev: false + /tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + engines: {node: '>=18'} + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + dev: false + /text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} dependencies: @@ -2982,6 +3015,11 @@ packages: os-paths: 4.4.0 dev: false + /yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + dev: false + /yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'}