Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/deepsec/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const external = [
"@openai/codex-sdk",
"@vercel/sandbox",
"jiti",
"tar",
];

const common = {
Expand Down
3 changes: 2 additions & 1 deletion packages/deepsec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
76 changes: 50 additions & 26 deletions packages/deepsec/src/sandbox/download.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<number> {
// Use `tar -xzvf` and count emitted lines for "files extracted" feedback.
return await new Promise<number>((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;
Comment thread
vercel[bot] marked this conversation as resolved.
onentry?: (entry: { type?: string }) => void;
}

interface TarModule {
extract: (opts: TarExtractOptions) => Promise<void>;
}

async function loadTar(): Promise<TarModule> {
if (cachedTar) return cachedTar;
const mod = (await import("tar")) as TarModule;
cachedTar = mod;
return mod;
}
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.