
Deepsec
- 159 installs
- 6.4k repo stars
- Updated July 26, 2026
- vercel-labs/deepsec
deepsec skill for running AI vulnerability scans, configuring projects, and authoring matchers and plugins.
About
Meta skill for deepsec, an AI-powered vulnerability scanner activated when deepsec ships in node_modules or from a vercel-labs/deepsec clone. Points to dist/docs for getting-started, configuration, plugins, writing-matchers, models, vercel-setup, architecture, data-layout, and faq. References samples/webapp as a complete config with inline plugin, custom matchers, INFO.md, and config.json. Instructs reading docs before answering because CLI flags, defaults, and plugin contract fields change. Maps common questions to specific doc files rather than paraphrasing from training data.
- AI-powered vulnerability scanner with docs in node_modules/deepsec/dist/docs
- Configuration reference in configuration.md and samples/webapp example
- Plugin slots: matchers, notifiers, ownership, people, executor
- writing-matchers.md for growing matcher set with a coding agent
- vercel-setup.md for AI Gateway and Vercel Sandbox token setup
Deepsec by the numbers
- 159 all-time installs (skills.sh)
- +9 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #858 of 2,209 Security skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
deepsec capabilities & compatibility
- Capabilities
- run deepsec scan · configure deepsec project · author matchers · write plugins · read deepsec docs
- Works with
- vercel
- Use cases
- security audit · testing
- Runs
- Runs locally
npx skills add https://github.com/vercel-labs/deepsec --skill deepsecAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 159 |
|---|---|
| repo stars | ★ 6.4k |
| Last updated | July 26, 2026 |
| Repository | vercel-labs/deepsec ↗ |
How do I run, configure, or extend deepsec vulnerability scans in my project?
Run, configure, and extend deepsec AI-powered vulnerability scans including matchers, plugins, and project configuration in node_modules or clone setups.
Who is it for?
Developers using deepsec locally or from vercel-labs/deepsec clone for security scanning.
Skip if: Generic SAST tools unrelated to deepsec or production incident response.
When should I use this skill?
User asks how to scan, configure, or extend deepsec in a project with deepsec installed.
What you get
Scan executed or config/matcher/plugin authored following current deepsec documentation.
Files
deepsec
deepsec is an AI-powered vulnerability scanner. This skill activates when deepsec ships inside node_modules/ — typically because the user ran npx deepsec … (which caches the package locally). In the more common dedicated-git setup the user works inside a clone of vercel-labs/deepsec and the same docs sit at docs/ from the repo root — read those instead when this skill fires from outside a node_modules.
When the user asks how to use, configure, or extend deepsec, read the relevant doc before answering — the docs are the source of truth, not your training data.
Where the docs are
node_modules/deepsec/dist/docs/ (or <deepsec-clone>/docs/):
getting-started.md— first-scan walkthroughconfiguration.md— fulldeepsec.config.tsreferenceplugins.md— plugin slots (matchers, notifiers, ownership, people, executor)writing-matchers.md— how to grow the matcher set with a coding agentmodels.md— model selection, defaults, refusals, future modelsvercel-setup.md— getting AI Gateway and Vercel Sandbox keys / tokensarchitecture.md— pipeline internalsdata-layout.md—data/schemas (FileRecord, RunMeta, …)faq.md— cost, model choice, sandbox mode, FP rate
Worked example
node_modules/deepsec/dist/samples/webapp/ (or <deepsec-clone>/samples/webapp/) is a complete reference setup — deepsec.config.ts with an inline plugin, two custom matchers under matchers/, an INFO.md for AI prompt context, and a per-project config.json. When the user asks "what should my config look like?", read this directory.
How to answer common questions
- "How do I run a scan?" →
getting-started.md. - "What goes in `deepsec.config.ts`?" →
configuration.md+samples/webapp/deepsec.config.ts. - "How do I add a matcher?" →
writing-matchers.md+samples/webapp/matchers/*.ts. - "How do I write a plugin?" →
plugins.md+samples/webapp/deepsec.config.ts(inline plugin pattern). - "What does deepsec actually do?" →
architecture.md. - "What's in `data/<id>/files/foo.json`?" →
data-layout.md. - "Which model / agent should I use?" →
models.md. - "How do I get an AI Gateway / Sandbox token?" →
vercel-setup.md.
Read the doc before paraphrasing. The CLI flag set, defaults, and plugin-contract field names change — quote the doc, don't recall.
# Staged from the workspace root by build.mjs at bundle time so the
# `files` field in package.json picks them up at pack/publish time.
README.md
LICENSE
NOTICE
import { chmodSync, cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { generateDtsBundle } from "dts-bundle-generator";
import { build } from "esbuild";
const __dirname = dirname(fileURLToPath(import.meta.url));
const distDir = resolve(__dirname, "dist");
const repoRoot = resolve(__dirname, "../..");
// Externalized at runtime: native binaries, heavy SDKs, and jiti (which
// bundles its own esbuild — re-bundling it produces broken output).
const external = [
"@anthropic-ai/claude-agent-sdk",
"@openai/codex",
"@openai/codex-sdk",
"@vercel/sandbox",
"jiti",
];
const common = {
bundle: true,
platform: "node",
format: "esm",
target: "node22",
external,
sourcemap: false,
legalComments: "none",
logLevel: "info",
};
rmSync(distDir, { recursive: true, force: true });
mkdirSync(distDir, { recursive: true });
// CJS deps bundled into ESM use `require()` for Node builtins; give them a
// real `require` via createRequire so the call resolves at runtime.
const requireShim = `
import { createRequire as __topLevelCreateRequire } from "node:module";
const require = __topLevelCreateRequire(import.meta.url);
`.trim();
await build({
...common,
entryPoints: [resolve(__dirname, "src/cli.ts")],
outfile: resolve(distDir, "cli.mjs"),
banner: { js: `#!/usr/bin/env node\n${requireShim}` },
});
chmodSync(resolve(distDir, "cli.mjs"), 0o755);
await build({
...common,
entryPoints: [resolve(__dirname, "src/config.ts")],
outfile: resolve(distDir, "config.mjs"),
banner: { js: requireShim },
});
// Bundle config.d.ts into a single self-contained file. The runtime side is
// already inlined by esbuild, but tsc would emit `from "@deepsec/core"`
// re-exports — broken for consumers, since those workspace packages are not
// published. dts-bundle-generator inlines all referenced types from internal
// workspace packages.
const dtsBundles = generateDtsBundle(
[
{
filePath: resolve(__dirname, "src/config.ts"),
output: { noBanner: true, exportReferencedTypes: false },
},
],
{ preferredConfigPath: resolve(__dirname, "tsconfig.dts.json") },
);
writeFileSync(resolve(distDir, "config.d.ts"), dtsBundles[0]);
cpSync(resolve(repoRoot, "docs"), resolve(distDir, "docs"), { recursive: true });
cpSync(resolve(repoRoot, "samples"), resolve(distDir, "samples"), {
recursive: true,
filter: (src) => !/(^|\/)data(\/|$)/.test(src) && !/(^|\/)node_modules(\/|$)/.test(src),
});
// The request-proxy is a standalone .mjs that runs on the sandbox worker
// (not bundled into cli.mjs — it executes in its own node process). Ship it
// verbatim so installed-mode workers can spawn it from node_modules/deepsec/.
mkdirSync(resolve(distDir, "sandbox"), { recursive: true });
cpSync(
resolve(__dirname, "src/sandbox/request-proxy.mjs"),
resolve(distDir, "sandbox/request-proxy.mjs"),
);
// README.md, LICENSE, and NOTICE live at the workspace root for repo
// browsing. `files` in package.json names them at the package root, so
// stage them here.
cpSync(resolve(repoRoot, "README.md"), resolve(__dirname, "README.md"));
cpSync(resolve(repoRoot, "LICENSE"), resolve(__dirname, "LICENSE"));
cpSync(resolve(repoRoot, "NOTICE"), resolve(__dirname, "NOTICE"));
console.log("\nBundle complete:");
console.log(" dist/cli.mjs");
console.log(" dist/config.mjs");
console.log(" dist/sandbox/request-proxy.mjs");
console.log(" dist/docs/");
console.log(" dist/samples/");
console.log(" README.md");
console.log(" LICENSE");
console.log(" NOTICE");
{
"name": "deepsec",
"version": "2.0.12",
"description": "AI-powered vulnerability scanner for any codebase",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/vercel-labs/deepsec"
},
"type": "module",
"bin": {
"deepsec": "./dist/cli.mjs"
},
"exports": {
".": "./dist/cli.mjs",
"./config": {
"types": "./dist/config.d.ts",
"import": "./dist/config.mjs"
}
},
"files": [
"dist",
"README.md",
"LICENSE",
"NOTICE",
"SKILL.md"
],
"scripts": {
"build": "tsc --noEmit",
"bundle": "pnpm --filter @deepsec/core --filter @deepsec/scanner build && node build.mjs",
"prepack": "pnpm bundle",
"prepublishOnly": "pnpm -w validate && node -e \"const fs=require('node:fs');for(const f of ['dist/cli.mjs','dist/config.mjs','dist/config.d.ts','dist/sandbox/request-proxy.mjs','dist/docs/getting-started.md','dist/samples/webapp/deepsec.config.ts','SKILL.md','README.md','LICENSE','NOTICE']){if(!fs.existsSync(f)){console.error('Missing: '+f);process.exit(1)}}console.log('Pre-publish checks passed.')\""
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.158",
"@openai/codex": "^0.125.0",
"@openai/codex-sdk": "^0.125.0",
"@vercel/oidc": "^3.4.0",
"@vercel/sandbox": "^1.9.0",
"jiti": "^2.4.0",
"minimatch": "^10.0.0",
"tar": "^7.5.13"
},
"devDependencies": {
"@deepsec/core": "workspace:*",
"@deepsec/processor": "workspace:*",
"@deepsec/scanner": "workspace:*",
"commander": "^13.0.0",
"dotenv": "^16.4.0",
"dts-bundle-generator": "^9.5.1",
"esbuild": "^0.25.12"
}
}
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildSandboxEnv, resolveBrokeredCredentials } from "../sandbox/setup.js";
const TOUCHED_KEYS = [
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
] as const;
describe("credential brokering", () => {
let saved: Record<string, string | undefined> = {};
beforeEach(() => {
saved = {};
for (const k of TOUCHED_KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
for (const k of TOUCHED_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
describe("resolveBrokeredCredentials", () => {
it("captures ANTHROPIC_AUTH_TOKEN from the orchestrator env", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "vck_real";
const c = resolveBrokeredCredentials("claude-agent-sdk");
expect(c.anthropicToken).toBe("vck_real");
expect(c.openaiToken).toBeUndefined();
});
it("falls back to ANTHROPIC token for OpenAI on the codex path", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "vck_real";
const c = resolveBrokeredCredentials("codex");
expect(c.openaiToken).toBe("vck_real");
});
it("does not borrow ANTHROPIC token for OpenAI off the codex path", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "vck_real";
const c = resolveBrokeredCredentials("claude-agent-sdk");
expect(c.openaiToken).toBeUndefined();
});
it("prefers an explicit OPENAI_API_KEY over the anthropic fallback", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "vck_anthropic";
process.env.OPENAI_API_KEY = "sk-explicit";
const c = resolveBrokeredCredentials("codex");
expect(c.openaiToken).toBe("sk-explicit");
});
});
describe("buildSandboxEnv (the env handed to Sandbox.create)", () => {
it("never contains a real ANTHROPIC token", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "vck_supersecret_realvalue";
process.env.ANTHROPIC_BASE_URL = "https://ai-gateway.vercel.sh";
const credentials = resolveBrokeredCredentials("claude-agent-sdk");
const env = buildSandboxEnv("claude-agent-sdk", credentials);
// The placeholder is set so the SDK can construct, but the real
// token does not appear anywhere in the env map.
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe("vck_supersecret_realvalue");
for (const v of Object.values(env)) {
expect(v).not.toContain("vck_supersecret_realvalue");
}
});
it("never contains a real OPENAI key on the codex path", () => {
process.env.OPENAI_API_KEY = "sk-real-openai-secret";
process.env.OPENAI_BASE_URL = "https://api.openai.com";
const credentials = resolveBrokeredCredentials("codex");
const env = buildSandboxEnv("codex", credentials);
expect(env.OPENAI_API_KEY).toBeDefined();
expect(env.OPENAI_API_KEY).not.toBe("sk-real-openai-secret");
for (const v of Object.values(env)) {
expect(v).not.toContain("sk-real-openai-secret");
}
});
it("omits the placeholder when the orchestrator has no token to broker", () => {
// No ANTHROPIC_AUTH_TOKEN set — SDK should fail fast at init rather
// than 401 from upstream after a brokered placeholder gets through.
const credentials = resolveBrokeredCredentials("claude-agent-sdk");
const env = buildSandboxEnv("claude-agent-sdk", credentials);
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
});
it("preserves routing env (base URLs) so the agent knows where to send traffic", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "vck_real";
process.env.ANTHROPIC_BASE_URL = "https://ai-gateway.vercel.sh";
const credentials = resolveBrokeredCredentials("claude-agent-sdk");
const env = buildSandboxEnv("claude-agent-sdk", credentials);
// ANTHROPIC_BASE_URL gets rewritten to the local proxy by buildSandboxEnv
// so the agent talks to the in-VM proxy. The original is exposed as
// ANTHROPIC_UPSTREAM_BASE_URL for the proxy to forward to.
expect(env.ANTHROPIC_UPSTREAM_BASE_URL).toBe("https://ai-gateway.vercel.sh");
expect(env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
});
});
});
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveFiles } from "../file-sources.js";
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const c of cleanups.reverse()) c();
cleanups.length = 0;
});
function tempRepo(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-fs-"));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
for (const args of [
["init", "-q"],
["config", "user.email", "t@t"],
["config", "user.name", "t"],
["config", "commit.gpgsign", "false"],
]) {
const r = spawnSync("git", args, { cwd: root, encoding: "utf-8" });
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
}
return root;
}
function gitCommit(root: string, msg: string) {
spawnSync("git", ["add", "-A"], { cwd: root });
const r = spawnSync("git", ["commit", "-q", "-m", msg], { cwd: root, encoding: "utf-8" });
if (r.status !== 0) throw new Error(`git commit: ${r.stderr}`);
}
function write(root: string, rel: string, content: string) {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
describe("resolveFiles()", () => {
it("rejects multiple sources", () => {
expect(() =>
resolveFiles({
rootPath: process.cwd(),
diff: "HEAD",
files: ["x.ts"],
}),
).toThrow(/Conflicting/);
});
it("--diff returns files changed between ref and HEAD", () => {
const root = tempRepo();
write(root, "src/keep.ts", "1\n");
write(root, "src/changed.ts", "1\n");
gitCommit(root, "init");
write(root, "src/changed.ts", "2\n");
write(root, "src/added.ts", "new\n");
gitCommit(root, "second");
const { filePaths, sourceLabel } = resolveFiles({ rootPath: root, diff: "HEAD~1" });
expect(filePaths.sort()).toEqual(["src/added.ts", "src/changed.ts"]);
expect(sourceLabel).toBe("git-diff:HEAD~1");
});
it("filters out IGNORE_DIRS by default", () => {
const root = tempRepo();
write(root, "src/real.ts", "1\n");
write(root, "src/real.test.ts", "1\n");
write(root, "dist/built.js", "1\n");
gitCommit(root, "init");
write(root, "src/real.ts", "2\n");
write(root, "src/real.test.ts", "2\n");
write(root, "dist/built.js", "2\n");
gitCommit(root, "edit");
const { filePaths } = resolveFiles({ rootPath: root, diff: "HEAD~1" });
expect(filePaths).toEqual(["src/real.ts"]);
});
it("--no-ignore preserves test/dist paths", () => {
const root = tempRepo();
write(root, "src/real.test.ts", "1\n");
gitCommit(root, "init");
write(root, "src/real.test.ts", "2\n");
gitCommit(root, "edit");
const { filePaths } = resolveFiles({ rootPath: root, diff: "HEAD~1", noIgnore: true });
expect(filePaths).toEqual(["src/real.test.ts"]);
});
it("filters generated deepsec data records from explicit file lists", () => {
const root = tempRepo();
const oldDataRoot = process.env.DEEPSEC_DATA_ROOT;
process.env.DEEPSEC_DATA_ROOT = path.join(root, "data{prod,dev}");
cleanups.push(() => {
if (oldDataRoot === undefined) delete process.env.DEEPSEC_DATA_ROOT;
else process.env.DEEPSEC_DATA_ROOT = oldDataRoot;
});
write(
root,
"data{prod,dev}/app/project.json",
JSON.stringify({
projectId: "app",
rootPath: root,
createdAt: "2026-01-01T00:00:00.000Z",
}),
);
write(root, "data{prod,dev}/app/files/src/generated.ts.json", "{}");
write(
root,
"data{prod,dev}/other/project.json",
JSON.stringify({
projectId: "other",
rootPath: root,
createdAt: "2026-01-01T00:00:00.000Z",
}),
);
write(root, "data{prod,dev}/other/files/src/generated.ts.json", "{}");
write(
root,
"data/legacy/project.json",
JSON.stringify({
projectId: "legacy",
rootPath: root,
createdAt: "2026-01-01T00:00:00.000Z",
}),
);
write(root, "data/legacy/files/src/generated.ts.json", "{}");
write(root, "data/users/project.json", JSON.stringify({ name: "app users" }));
write(root, "data/users/files/real.ts", "1\n");
const { filePaths } = resolveFiles({
rootPath: root,
files: [
"data{prod,dev}/app/files/src/generated.ts.json",
"data{prod,dev}/other/files/src/generated.ts.json",
"data/legacy/files/src/generated.ts.json",
"data/users/files/real.ts",
],
});
expect(filePaths).toEqual(["data/users/files/real.ts"]);
});
it("--files accepts an explicit list and drops missing entries", () => {
const root = tempRepo();
write(root, "real.ts", "x\n");
const { filePaths, sourceLabel } = resolveFiles({
rootPath: root,
files: ["real.ts", "ghost.ts"],
});
expect(filePaths).toEqual(["real.ts"]);
expect(sourceLabel).toBe("files:cli");
});
it("--files-from reads newline-delimited paths", () => {
const root = tempRepo();
write(root, "a.ts", "x\n");
write(root, "b.ts", "x\n");
const listPath = path.join(root, "list.txt");
fs.writeFileSync(listPath, "a.ts\nb.ts\n\n");
const { filePaths } = resolveFiles({ rootPath: root, filesFrom: listPath });
expect(filePaths.sort()).toEqual(["a.ts", "b.ts"]);
});
it("strips leading ./ and rejects paths outside root", () => {
const root = tempRepo();
write(root, "real.ts", "x\n");
const { filePaths } = resolveFiles({
rootPath: root,
files: ["./real.ts", "../escape.ts"],
});
expect(filePaths).toEqual(["real.ts"]);
});
it("dedupes overlapping inputs", () => {
const root = tempRepo();
write(root, "x.ts", "1\n");
const { filePaths } = resolveFiles({ rootPath: root, files: ["x.ts", "x.ts", "./x.ts"] });
expect(filePaths).toEqual(["x.ts"]);
});
});
import type { Severity } from "@deepsec/core";
import { describe, expect, it } from "vitest";
import { formatCount, formatDuration, severityColor } from "../formatters.js";
describe("severityColor", () => {
it("returns red for CRITICAL", () => {
expect(severityColor("CRITICAL")).toBe("\x1b[31m");
});
it("returns yellow for HIGH", () => {
expect(severityColor("HIGH")).toBe("\x1b[33m");
});
it("returns cyan for MEDIUM", () => {
expect(severityColor("MEDIUM")).toBe("\x1b[36m");
});
it("returns magenta for HIGH_BUG", () => {
expect(severityColor("HIGH_BUG")).toBe("\x1b[35m");
});
it("returns magenta for BUG", () => {
expect(severityColor("BUG")).toBe("\x1b[35m");
});
it("returns a color for every severity level", () => {
const severities: Severity[] = ["CRITICAL", "HIGH", "MEDIUM", "HIGH_BUG", "BUG"];
for (const sev of severities) {
expect(severityColor(sev)).toBeTruthy();
}
});
});
describe("formatDuration", () => {
it("formats seconds", () => {
expect(formatDuration(5000)).toBe("5s");
expect(formatDuration(59000)).toBe("59s");
});
it("formats minutes and seconds", () => {
expect(formatDuration(90_000)).toBe("1m 30s");
expect(formatDuration(3_540_000)).toBe("59m 0s");
});
it("formats hours", () => {
expect(formatDuration(3_661_000)).toBe("1h 1m");
});
it("formats zero", () => {
expect(formatDuration(0)).toBe("0s");
});
});
describe("formatCount", () => {
it("pluralizes correctly", () => {
expect(formatCount(0, "file")).toBe("0 files");
expect(formatCount(1, "file")).toBe("1 file");
expect(formatCount(2, "file")).toBe("2 files");
});
});
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AnalysisEntry, FileRecord, Finding } from "@deepsec/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
mergeAfterExtract,
mergeFileRecord,
snapshotFileRecords,
} from "../sandbox/merge-records.js";
function entry(runId: string, agentType: string, investigatedAt: string): AnalysisEntry {
return {
runId,
investigatedAt,
durationMs: 1000,
agentType,
model: agentType === "codex" ? "gpt-5.5" : "claude-opus-4-7",
modelConfig: {},
findingCount: 0,
};
}
function finding(vulnSlug: string, title: string, extras: Partial<Finding> = {}): Finding {
return {
severity: "HIGH",
vulnSlug,
title,
description: "d",
lineNumbers: [1],
recommendation: "r",
confidence: "high",
...extras,
};
}
function record(overrides: Partial<FileRecord> = {}): FileRecord {
return {
filePath: "src/foo.ts",
projectId: "p",
candidates: [],
lastScannedAt: "2026-05-06T15:00:00.000Z",
lastScannedRunId: "scan1",
fileHash: "h",
findings: [],
analysisHistory: [],
status: "pending",
...overrides,
};
}
describe("mergeFileRecord", () => {
it("unions analysisHistory by runId and sorts by investigatedAt", () => {
const host = record({
analysisHistory: [entry("claude-run", "claude-agent-sdk", "2026-05-06T15:54:37.000Z")],
status: "analyzed",
});
const incoming = record({
analysisHistory: [entry("codex-run", "codex", "2026-05-06T15:59:48.000Z")],
status: "analyzed",
});
const merged = mergeFileRecord(host, incoming);
expect(merged.analysisHistory.map((e) => e.runId)).toEqual(["claude-run", "codex-run"]);
expect(merged.status).toBe("analyzed");
});
it("dedupes analysisHistory when both sides serialize the same runId", () => {
const sameEntry = entry("run-x", "codex", "2026-05-06T15:59:48.000Z");
const host = record({ analysisHistory: [sameEntry] });
const incoming = record({
analysisHistory: [{ ...sameEntry, findingCount: 5 }],
});
const merged = mergeFileRecord(host, incoming);
expect(merged.analysisHistory).toHaveLength(1);
expect(merged.analysisHistory[0].findingCount).toBe(5); // incoming wins
});
it("unions findings by vulnSlug+title signature", () => {
const host = record({
findings: [finding("xss", "XSS via innerHTML")],
});
const incoming = record({
findings: [finding("ssrf", "SSRF in webhook handler")],
});
const merged = mergeFileRecord(host, incoming);
expect(merged.findings.map((f) => f.vulnSlug).sort()).toEqual(["ssrf", "xss"]);
});
it("preserves revalidation/triage from either side when finding signatures match", () => {
const hostFinding = finding("xss", "XSS", {
revalidation: {
verdict: "true-positive",
reasoning: "confirmed",
revalidatedAt: "2026-05-06T16:00:00.000Z",
runId: "reval-run",
model: "gpt-5.5",
},
});
const incomingFinding = finding("xss", "xss", {
triage: {
priority: "P0",
exploitability: "trivial",
impact: "critical",
reasoning: "trivial RCE",
triagedAt: "2026-05-06T16:05:00.000Z",
model: "claude-sonnet-4-6",
},
});
const merged = mergeFileRecord(
record({ findings: [hostFinding] }),
record({ findings: [incomingFinding] }),
);
expect(merged.findings).toHaveLength(1);
expect(merged.findings[0].revalidation?.verdict).toBe("true-positive");
expect(merged.findings[0].triage?.priority).toBe("P0");
});
it("preserves gitInfo when incoming lacks it", () => {
const host = record({
gitInfo: {
recentCommitters: [{ name: "alice", email: "a@x", date: "2026-05-06" }],
enrichedAt: "2026-05-06T15:00:00.000Z",
},
});
const incoming = record({ gitInfo: undefined });
const merged = mergeFileRecord(host, incoming);
expect(merged.gitInfo?.recentCommitters[0].name).toBe("alice");
});
it("status: 'analyzed' on either side wins", () => {
expect(
mergeFileRecord(record({ status: "analyzed" }), record({ status: "processing" })).status,
).toBe("analyzed");
expect(
mergeFileRecord(record({ status: "pending" }), record({ status: "analyzed" })).status,
).toBe("analyzed");
expect(
mergeFileRecord(record({ status: "processing" }), record({ status: "error" })).status,
).toBe("error");
});
it("reproduces the parallel-orchestrator scenario end-to-end", () => {
// Repro: claude orchestrator finished a sandbox at 15:54:37 (host
// gets [claudeA]). Codex orchestrator started at 15:58:21, snapshotted
// the host (got [claudeA]), processed and is now uploading [claudeA, codexB].
//
// Meanwhile, ANOTHER claude sandbox started before 15:58:21 (so its
// local snapshot was empty) finished and is uploading [claudeC]. If
// its tarball lands AFTER codex's, the naive overwrite drops both
// claudeA and codexB. With the merge, all three survive.
let host = record({
analysisHistory: [entry("claudeA", "claude-agent-sdk", "2026-05-06T15:54:37.000Z")],
status: "analyzed",
});
// Codex sandbox uploads [claudeA, codexB] — extract overlays it on host
const codexUpload = record({
analysisHistory: [
entry("claudeA", "claude-agent-sdk", "2026-05-06T15:54:37.000Z"),
entry("codexB", "codex", "2026-05-06T15:59:48.000Z"),
],
status: "analyzed",
});
host = mergeFileRecord(host, codexUpload);
// Late-arriving claude sandbox uploads [claudeC] only (its local snapshot
// had been empty when the run started)
const lateClaudeUpload = record({
analysisHistory: [entry("claudeC", "claude-agent-sdk", "2026-05-06T16:02:00.000Z")],
status: "analyzed",
});
host = mergeFileRecord(host, lateClaudeUpload);
expect(host.analysisHistory.map((e) => e.runId)).toEqual(["claudeA", "codexB", "claudeC"]);
});
});
describe("snapshotFileRecords + mergeAfterExtract", () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-merge-"));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it("returns an empty snapshot when files/ doesn't exist", () => {
expect(snapshotFileRecords(dir).size).toBe(0);
});
it("walks files/ recursively and indexes records by relative path", () => {
const inner = path.join(dir, "files", "src", "nested");
fs.mkdirSync(inner, { recursive: true });
fs.writeFileSync(
path.join(inner, "deep.ts.json"),
JSON.stringify(record({ filePath: "src/nested/deep.ts" })),
);
fs.writeFileSync(
path.join(dir, "files", "shallow.ts.json"),
JSON.stringify(record({ filePath: "shallow.ts" })),
);
const snap = snapshotFileRecords(dir);
expect(snap.size).toBe(2);
expect(snap.has(path.join("files", "src", "nested", "deep.ts.json"))).toBe(true);
expect(snap.has(path.join("files", "shallow.ts.json"))).toBe(true);
});
it("rewrites only files that existed in both the host snapshot and the post-extract state", () => {
// Pre-extraction: host has a record with claude history
const filesDir = path.join(dir, "files", "src");
fs.mkdirSync(filesDir, { recursive: true });
const recPath = path.join(filesDir, "foo.ts.json");
fs.writeFileSync(
recPath,
JSON.stringify(
record({
filePath: "src/foo.ts",
analysisHistory: [entry("claudeA", "claude-agent-sdk", "2026-05-06T15:54:37.000Z")],
status: "analyzed",
}),
),
);
const snap = snapshotFileRecords(dir);
// Simulate a tar extract overwriting that file with [codexB] only —
// the pattern that drops history without merging.
fs.writeFileSync(
recPath,
JSON.stringify(
record({
filePath: "src/foo.ts",
analysisHistory: [entry("codexB", "codex", "2026-05-06T15:59:48.000Z")],
status: "analyzed",
}),
),
);
// A second file the host had nothing for (sandbox-only contribution)
const newFilePath = path.join(filesDir, "bar.ts.json");
fs.writeFileSync(
newFilePath,
JSON.stringify(
record({
filePath: "src/bar.ts",
analysisHistory: [entry("codexB2", "codex", "2026-05-06T15:59:50.000Z")],
}),
),
);
const merged = mergeAfterExtract(dir, snap, "p");
expect(merged).toBe(1);
const fooAfter = JSON.parse(fs.readFileSync(recPath, "utf-8"));
expect(fooAfter.analysisHistory.map((e: AnalysisEntry) => e.runId)).toEqual([
"claudeA",
"codexB",
]);
// bar.ts wasn't in the host snapshot, so it's left alone.
const barAfter = JSON.parse(fs.readFileSync(newFilePath, "utf-8"));
expect(barAfter.analysisHistory.map((e: AnalysisEntry) => e.runId)).toEqual(["codexB2"]);
});
it("skips malformed JSON in the snapshot rather than throwing", () => {
const filesDir = path.join(dir, "files");
fs.mkdirSync(filesDir, { recursive: true });
fs.writeFileSync(path.join(filesDir, "broken.ts.json"), "{not valid json");
fs.writeFileSync(path.join(filesDir, "ok.ts.json"), JSON.stringify(record()));
const snap = snapshotFileRecords(dir);
expect(snap.size).toBe(1);
});
});
import { describe, expect, it } from "vitest";
import { formatCacheHitRate, formatCost, formatPct, formatTokens } from "../commands/metrics.js";
describe("formatCost", () => {
it("renders $0 exactly", () => {
expect(formatCost(0)).toBe("$0");
});
it("uses 4 decimals for sub-cent costs", () => {
expect(formatCost(0.0023)).toBe("$0.0023");
});
it("uses 3 decimals for sub-dollar costs", () => {
expect(formatCost(0.045)).toBe("$0.045");
});
it("uses 2 decimals between $1 and $100", () => {
expect(formatCost(1.5)).toBe("$1.50");
expect(formatCost(42.789)).toBe("$42.79");
});
it("drops decimals at $100+", () => {
expect(formatCost(123.45)).toBe("$123");
});
});
describe("formatTokens", () => {
it("returns 0 for zero", () => {
expect(formatTokens(0)).toBe("0");
});
it("returns the raw number under 1K", () => {
expect(formatTokens(950)).toBe("950");
});
it("uses K/M/B suffixes", () => {
expect(formatTokens(1500)).toBe("1.5K");
expect(formatTokens(2_500_000)).toBe("2.5M");
expect(formatTokens(3_500_000_000)).toBe("3.5B");
});
});
describe("formatPct", () => {
it("rounds to whole percents", () => {
expect(formatPct(1, 3)).toBe("33%");
expect(formatPct(2, 3)).toBe("67%");
});
it("returns em-dash on zero denominator (avoids NaN%)", () => {
expect(formatPct(0, 0)).toBe("—");
});
});
describe("formatCacheHitRate", () => {
it("computes cacheRead / total input without double-counting", () => {
expect(formatCacheHitRate({ input: 20, cacheRead: 80, cacheCreation: 0 })).toBe("80%");
});
it("counts cacheCreation as uncached input in the denominator", () => {
// 800 cached out of 1000 total input (100 uncached + 100 creation + 800 read) → 80%.
expect(formatCacheHitRate({ input: 100, cacheRead: 800, cacheCreation: 100 })).toBe("80%");
});
it("returns em-dash when there is no input", () => {
expect(formatCacheHitRate({ input: 0, cacheRead: 0, cacheCreation: 0 })).toBe("—");
});
it("is 0% when nothing was read from cache", () => {
expect(formatCacheHitRate({ input: 500, cacheRead: 0, cacheCreation: 0 })).toBe("0%");
});
});
import type { NetworkPolicy, NetworkPolicyRule } from "@vercel/sandbox";
import { describe, expect, it } from "vitest";
import { buildWorkerNetworkPolicy } from "../sandbox/setup.js";
function policyRecord(p: NetworkPolicy): Record<string, NetworkPolicyRule[]> {
if (typeof p === "string") throw new Error("expected custom policy, got " + p);
const a = p.allow;
if (!a || Array.isArray(a)) throw new Error("expected record-form allow map");
return a;
}
function allowedHosts(p: NetworkPolicy): string[] {
return Object.keys(policyRecord(p)).sort();
}
function bearerFor(p: NetworkPolicy, host: string): string | null {
const rules = policyRecord(p)[host];
if (!rules) return null;
for (const rule of rules) {
for (const t of rule.transform ?? []) {
const auth = t.headers?.authorization ?? t.headers?.Authorization;
if (auth) return auth;
}
}
return null;
}
describe("buildWorkerNetworkPolicy", () => {
it("uses ANTHROPIC_UPSTREAM_BASE_URL host on the claude path", () => {
const policy = buildWorkerNetworkPolicy(
{ ANTHROPIC_UPSTREAM_BASE_URL: "https://ai-gateway.vercel.sh" },
"claude-agent-sdk",
);
expect(allowedHosts(policy)).toEqual(["ai-gateway.vercel.sh"]);
});
it("uses OPENAI_BASE_URL host on the codex path", () => {
const policy = buildWorkerNetworkPolicy(
{ OPENAI_BASE_URL: "https://ai-gateway.vercel.sh/v1" },
"codex",
);
expect(allowedHosts(policy)).toEqual(["ai-gateway.vercel.sh"]);
});
it("ignores ANTHROPIC_UPSTREAM_BASE_URL when agentType is codex", () => {
const policy = buildWorkerNetworkPolicy(
{
ANTHROPIC_UPSTREAM_BASE_URL: "https://api.anthropic.com",
OPENAI_BASE_URL: "https://api.openai.com",
},
"codex",
);
expect(allowedHosts(policy)).toEqual(["api.openai.com"]);
});
it("falls back to the provider default when no upstream URL is set", () => {
const claudePolicy = buildWorkerNetworkPolicy({}, "claude-agent-sdk");
expect(allowedHosts(claudePolicy)).toEqual(["api.anthropic.com"]);
const codexPolicy = buildWorkerNetworkPolicy({}, "codex");
expect(allowedHosts(codexPolicy)).toEqual(["api.openai.com"]);
});
it("falls back when the URL is unparseable", () => {
const policy = buildWorkerNetworkPolicy(
{ ANTHROPIC_UPSTREAM_BASE_URL: "not a url" },
"claude-agent-sdk",
);
expect(allowedHosts(policy)).toEqual(["api.anthropic.com"]);
});
it("merges extraAllowedHosts with the derived host", () => {
const policy = buildWorkerNetworkPolicy(
{ ANTHROPIC_UPSTREAM_BASE_URL: "https://ai-gateway.vercel.sh" },
"claude-agent-sdk",
{},
["telemetry.example.com"],
);
expect(allowedHosts(policy)).toEqual(["ai-gateway.vercel.sh", "telemetry.example.com"]);
});
it("dedupes when extras overlap with the derived host", () => {
const policy = buildWorkerNetworkPolicy(
{ ANTHROPIC_UPSTREAM_BASE_URL: "https://api.anthropic.com" },
"claude-agent-sdk",
{},
["api.anthropic.com"],
);
expect(allowedHosts(policy)).toEqual(["api.anthropic.com"]);
});
describe("credential brokering", () => {
it("injects Authorization on the claude AI host when a token is provided", () => {
const policy = buildWorkerNetworkPolicy(
{ ANTHROPIC_UPSTREAM_BASE_URL: "https://ai-gateway.vercel.sh" },
"claude-agent-sdk",
{ anthropicToken: "vck_realtoken" },
);
expect(bearerFor(policy, "ai-gateway.vercel.sh")).toBe("Bearer vck_realtoken");
});
it("injects Authorization on the codex AI host when an OpenAI token is provided", () => {
const policy = buildWorkerNetworkPolicy(
{ OPENAI_BASE_URL: "https://ai-gateway.vercel.sh/v1" },
"codex",
{ openaiToken: "vck_realtoken" },
);
expect(bearerFor(policy, "ai-gateway.vercel.sh")).toBe("Bearer vck_realtoken");
});
it("emits no transform when no matching credential is provided", () => {
const policy = buildWorkerNetworkPolicy(
{ ANTHROPIC_UPSTREAM_BASE_URL: "https://ai-gateway.vercel.sh" },
"claude-agent-sdk",
{},
);
expect(policyRecord(policy)["ai-gateway.vercel.sh"]).toEqual([]);
});
it("does not inject the anthropic token when running codex without an openai token", () => {
// The anthropic-as-openai fallback happens at resolveBrokeredCredentials,
// not here — this layer only sees what was passed in.
const policy = buildWorkerNetworkPolicy(
{ OPENAI_BASE_URL: "https://api.openai.com" },
"codex",
{ anthropicToken: "vck_realtoken" },
);
expect(bearerFor(policy, "api.openai.com")).toBeNull();
});
it("does not attach the transform to extra allowed hosts", () => {
const policy = buildWorkerNetworkPolicy(
{ ANTHROPIC_UPSTREAM_BASE_URL: "https://ai-gateway.vercel.sh" },
"claude-agent-sdk",
{ anthropicToken: "vck_realtoken" },
["telemetry.example.com"],
);
expect(bearerFor(policy, "ai-gateway.vercel.sh")).toBe("Bearer vck_realtoken");
expect(bearerFor(policy, "telemetry.example.com")).toBeNull();
});
});
});
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { ensureProject, writeFileRecord, writeRunMeta } from "@deepsec/core";
import { afterEach, describe, expect, it } from "vitest";
import { renderPrComment } from "../pr-comment.js";
let cleanup: (() => void) | null = null;
afterEach(() => {
cleanup?.();
cleanup = null;
delete process.env.DEEPSEC_DATA_ROOT;
});
function setupProject(): { projectId: string } {
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-comment-"));
const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-root-"));
process.env.DEEPSEC_DATA_ROOT = dataRoot;
cleanup = () => {
fs.rmSync(dataRoot, { recursive: true, force: true });
fs.rmSync(root, { recursive: true, force: true });
};
const projectId = `c-${Date.now().toString(36)}`;
ensureProject(projectId, root);
return { projectId };
}
describe("renderPrComment()", () => {
it("returns null when the run had no findings", () => {
const { projectId } = setupProject();
const md = renderPrComment({ projectId, runId: "r1" });
expect(md).toBeNull();
});
it("renders only net-new findings from the specified run", () => {
const { projectId } = setupProject();
writeRunMeta({
runId: "r1",
projectId,
rootPath: "/tmp/x",
createdAt: new Date().toISOString(),
type: "process",
phase: "done",
stats: {},
});
writeFileRecord({
filePath: "src/a.ts",
projectId,
candidates: [],
lastScannedAt: new Date().toISOString(),
lastScannedRunId: "r0",
fileHash: "x",
findings: [
{
severity: "HIGH",
vulnSlug: "sql-injection",
title: "Concatenated query",
description: "User-controlled input flows into a string-concatenated SQL query.",
lineNumbers: [12],
recommendation: "Use parameterized queries.",
confidence: "high",
producedByRunId: "r1", // net-new in this run
},
{
severity: "LOW",
vulnSlug: "old",
title: "Pre-existing finding",
description: "Carried over from an earlier run.",
lineNumbers: [99],
recommendation: "n/a",
confidence: "low",
producedByRunId: "r-old", // attributable to a prior run
},
{
severity: "MEDIUM",
vulnSlug: "legacy",
title: "Legacy finding without producedByRunId",
description: "Predates the producedByRunId field.",
lineNumbers: [42],
recommendation: "n/a",
confidence: "medium",
// producedByRunId intentionally omitted — must not appear.
},
],
analysisHistory: [
{
runId: "r1",
investigatedAt: new Date().toISOString(),
durationMs: 100,
agentType: "claude-agent-sdk",
model: "test",
modelConfig: {},
findingCount: 1,
},
],
status: "analyzed",
});
// A different file with a finding from another run — must not leak in.
writeFileRecord({
filePath: "src/other.ts",
projectId,
candidates: [],
lastScannedAt: new Date().toISOString(),
lastScannedRunId: "r0",
fileHash: "y",
findings: [
{
severity: "CRITICAL",
vulnSlug: "xss",
title: "Stale finding from older run",
description: "Should not appear.",
lineNumbers: [1],
recommendation: "irrelevant",
confidence: "high",
producedByRunId: "r-old",
},
],
analysisHistory: [
{
runId: "r-old",
investigatedAt: new Date().toISOString(),
durationMs: 100,
agentType: "claude-agent-sdk",
model: "test",
modelConfig: {},
findingCount: 1,
},
],
status: "analyzed",
});
const md = renderPrComment({ projectId, runId: "r1", source: "git-diff:HEAD~1" });
expect(md).not.toBeNull();
expect(md!).toContain("deepsec found 1 finding");
expect(md!).toContain("src/a.ts:L12");
expect(md!).toContain("Concatenated query");
// Pre-existing findings from prior runs (or with no run id) are excluded.
expect(md!).not.toContain("Pre-existing finding");
expect(md!).not.toContain("Legacy finding without producedByRunId");
expect(md!).not.toContain("Stale finding from older run");
expect(md!).toContain("git-diff:HEAD~1");
});
});
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Stub @vercel/oidc so the preflight suite is hermetic: the real
// implementation decodes VERCEL_OIDC_TOKEN as a JWT and, on failure,
// falls back to reading `.vercel/project.json` and hitting Vercel. That
// would leak the dev's local project state into the test and 401 on CI
// (and the synthetic strings we use below aren't valid JWTs). Treating
// the env var as the source of truth matches what applyAiGatewayDefaults
// relies on: a token populated by `vercel env pull`.
vi.mock("@vercel/oidc", () => ({
getVercelOidcToken: async () => {
const token = process.env.VERCEL_OIDC_TOKEN;
if (!token) throw new Error("no VERCEL_OIDC_TOKEN");
return token;
},
}));
import {
applyAiGatewayDefaults,
assertAgentCredential,
assertSandboxCredential,
} from "../preflight.js";
describe("assertAgentCredential", () => {
let saved: Record<string, string | undefined>;
let emptyClaudeHome: string;
let emptyCodexHome: string;
let emptyPathDir: string;
beforeEach(() => {
saved = {
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
CLAUDE_HOME: process.env.CLAUDE_HOME,
CODEX_HOME: process.env.CODEX_HOME,
PATH: process.env.PATH,
};
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.OPENAI_API_KEY;
// Point CLAUDE_HOME / CODEX_HOME and PATH at empty tmp dirs so the
// suite is hermetic — the dev running tests may have a real
// ~/.codex/auth.json or `claude` on $PATH, which would cause
// "no token" tests to incorrectly pass.
emptyClaudeHome = mkdtempSync(join(tmpdir(), "deepsec-claude-home-"));
emptyCodexHome = mkdtempSync(join(tmpdir(), "deepsec-codex-home-"));
emptyPathDir = mkdtempSync(join(tmpdir(), "deepsec-empty-path-"));
process.env.CLAUDE_HOME = emptyClaudeHome;
process.env.CODEX_HOME = emptyCodexHome;
process.env.PATH = emptyPathDir;
});
afterEach(() => {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
rmSync(emptyClaudeHome, { recursive: true, force: true });
rmSync(emptyCodexHome, { recursive: true, force: true });
rmSync(emptyPathDir, { recursive: true, force: true });
});
it("passes for claude-agent-sdk when ANTHROPIC_AUTH_TOKEN is set", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "x";
expect(() => assertAgentCredential("claude-agent-sdk")).not.toThrow();
});
it("throws actionable message for claude-agent-sdk when no token and no claude CLI", () => {
expect(() => assertAgentCredential("claude-agent-sdk")).toThrow(/--agent claude/);
expect(() => assertAgentCredential("claude-agent-sdk")).toThrow(/ANTHROPIC_AUTH_TOKEN/);
expect(() => assertAgentCredential("claude-agent-sdk")).toThrow(/AI_GATEWAY_API_KEY/);
expect(() => assertAgentCredential("claude-agent-sdk")).toThrow(
/https:\/\/github\.com\/vercel-labs\/deepsec\/blob\/main\/docs\/vercel-setup\.md/,
);
});
it("passes for claude-agent-sdk when `claude` is on PATH (subscription mode)", () => {
writeFileSync(join(emptyPathDir, "claude"), "#!/bin/sh\n", { mode: 0o755 });
expect(() => assertAgentCredential("claude-agent-sdk")).not.toThrow();
});
it("ignores claude subscription auth in sandbox mode", () => {
writeFileSync(join(emptyPathDir, "claude"), "#!/bin/sh\n", { mode: 0o755 });
expect(() => assertAgentCredential("claude-agent-sdk", { inSandbox: true })).toThrow(
/AI_GATEWAY_API_KEY/,
);
});
it("passes for codex when OPENAI_API_KEY is set", () => {
process.env.OPENAI_API_KEY = "x";
expect(() => assertAgentCredential("codex")).not.toThrow();
});
it("passes for codex when only ANTHROPIC token is set (gateway fallback)", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "x";
expect(() => assertAgentCredential("codex")).not.toThrow();
});
it("passes for codex when ~/.codex/auth.json exists (subscription mode)", () => {
writeFileSync(join(emptyCodexHome, "auth.json"), "{}");
expect(() => assertAgentCredential("codex")).not.toThrow();
});
it("ignores codex subscription auth in sandbox mode", () => {
writeFileSync(join(emptyCodexHome, "auth.json"), "{}");
// With auth.json present, non-sandbox passes — sandbox still throws.
expect(() => assertAgentCredential("codex")).not.toThrow();
expect(() => assertAgentCredential("codex", { inSandbox: true })).toThrow(/OPENAI_API_KEY/);
});
it("does not let claude subscription unlock codex", () => {
writeFileSync(join(emptyPathDir, "claude"), "#!/bin/sh\n", { mode: 0o755 });
// No codex auth.json → still throws.
expect(() => assertAgentCredential("codex")).toThrow(/OPENAI_API_KEY/);
expect(() => assertAgentCredential("codex")).toThrow(/AI_GATEWAY_API_KEY/);
});
it("skips the credential check for custom plugin agents", () => {
// Tests use this so a stub agent registered via plugins[] doesn't
// require fake ANTHROPIC_AUTH_TOKEN env vars.
expect(() => assertAgentCredential("stub")).not.toThrow();
expect(() => assertAgentCredential("anything-else")).not.toThrow();
});
});
describe("assertSandboxCredential", () => {
let saved: Record<string, string | undefined>;
beforeEach(() => {
saved = {
VERCEL_OIDC_TOKEN: process.env.VERCEL_OIDC_TOKEN,
VERCEL_TOKEN: process.env.VERCEL_TOKEN,
VERCEL_TEAM_ID: process.env.VERCEL_TEAM_ID,
VERCEL_PROJECT_ID: process.env.VERCEL_PROJECT_ID,
};
for (const k of Object.keys(saved)) delete process.env[k];
});
afterEach(() => {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
it("passes when OIDC token is set", () => {
process.env.VERCEL_OIDC_TOKEN = "x";
expect(() => assertSandboxCredential()).not.toThrow();
});
it("passes when access-token triple is set", () => {
process.env.VERCEL_TOKEN = "x";
process.env.VERCEL_TEAM_ID = "team_x";
process.env.VERCEL_PROJECT_ID = "prj_x";
expect(() => assertSandboxCredential()).not.toThrow();
});
it("throws actionable message when nothing is set", () => {
expect(() => assertSandboxCredential()).toThrow(/vercel link/);
expect(() => assertSandboxCredential()).toThrow(/VERCEL_OIDC_TOKEN/);
expect(() => assertSandboxCredential()).toThrow(
/https:\/\/github\.com\/vercel-labs\/deepsec\/blob\/main\/docs\/vercel-setup\.md/,
);
});
it("names every missing access-token piece", () => {
process.env.VERCEL_TOKEN = "x";
expect(() => assertSandboxCredential()).toThrow(/VERCEL_TEAM_ID, VERCEL_PROJECT_ID/);
});
});
describe("applyAiGatewayDefaults", () => {
let saved: Record<string, string | undefined>;
beforeEach(() => {
saved = {
AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY,
VERCEL_OIDC_TOKEN: process.env.VERCEL_OIDC_TOKEN,
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
};
for (const k of Object.keys(saved)) delete process.env[k];
});
afterEach(() => {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
it("does nothing when AI_GATEWAY_API_KEY is unset", async () => {
await applyAiGatewayDefaults();
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(process.env.OPENAI_API_KEY).toBeUndefined();
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined();
expect(process.env.OPENAI_BASE_URL).toBeUndefined();
});
it("populates all four vars from AI_GATEWAY_API_KEY", async () => {
process.env.AI_GATEWAY_API_KEY = "gw-key";
await applyAiGatewayDefaults();
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBe("gw-key");
expect(process.env.OPENAI_API_KEY).toBe("gw-key");
expect(process.env.ANTHROPIC_BASE_URL).toBe("https://ai-gateway.vercel.sh");
expect(process.env.OPENAI_BASE_URL).toBe("https://ai-gateway.vercel.sh/v1");
});
it("does not overwrite explicit ANTHROPIC_AUTH_TOKEN", async () => {
process.env.AI_GATEWAY_API_KEY = "gw-key";
process.env.ANTHROPIC_AUTH_TOKEN = "explicit-anthropic";
await applyAiGatewayDefaults();
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBe("explicit-anthropic");
expect(process.env.OPENAI_API_KEY).toBe("gw-key");
});
it("does not overwrite an explicit ANTHROPIC_BASE_URL pointing direct-to-provider", async () => {
process.env.AI_GATEWAY_API_KEY = "gw-key";
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
await applyAiGatewayDefaults();
expect(process.env.ANTHROPIC_BASE_URL).toBe("https://api.anthropic.com");
expect(process.env.OPENAI_BASE_URL).toBe("https://ai-gateway.vercel.sh/v1");
});
it("falls back to VERCEL_OIDC_TOKEN when AI_GATEWAY_API_KEY is unset", async () => {
process.env.VERCEL_OIDC_TOKEN = "oidc-tok";
await applyAiGatewayDefaults();
expect(process.env.AI_GATEWAY_API_KEY).toBe("oidc-tok");
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBe("oidc-tok");
expect(process.env.OPENAI_API_KEY).toBe("oidc-tok");
expect(process.env.ANTHROPIC_BASE_URL).toBe("https://ai-gateway.vercel.sh");
expect(process.env.OPENAI_BASE_URL).toBe("https://ai-gateway.vercel.sh/v1");
});
it("does not invoke @vercel/oidc when no VERCEL_OIDC_TOKEN is in env", async () => {
// Guard against the library walking up from cwd looking for a parent
// `.vercel/project.json`. Without VERCEL_OIDC_TOKEN as the explicit
// opt-in, we leave AI_GATEWAY_API_KEY unset and fall through to the
// claude/codex subscription path.
let invoked = false;
const oidcModule = await import("@vercel/oidc");
const spy = vi.spyOn(oidcModule, "getVercelOidcToken").mockImplementation(async () => {
invoked = true;
return "should-not-be-used";
});
await applyAiGatewayDefaults();
expect(invoked).toBe(false);
expect(process.env.AI_GATEWAY_API_KEY).toBeUndefined();
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined();
spy.mockRestore();
});
it("prefers an explicit AI_GATEWAY_API_KEY over VERCEL_OIDC_TOKEN", async () => {
process.env.AI_GATEWAY_API_KEY = "gw-key";
process.env.VERCEL_OIDC_TOKEN = "oidc-tok";
await applyAiGatewayDefaults();
expect(process.env.AI_GATEWAY_API_KEY).toBe("gw-key");
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBe("gw-key");
expect(process.env.OPENAI_API_KEY).toBe("gw-key");
});
});
import { defineConfig, setLoadedConfig } from "@deepsec/core";
import { afterEach, describe, expect, it } from "vitest";
import { resolveAgentType } from "../resolve-agent-type.js";
describe("resolveAgentType", () => {
afterEach(() => {
setLoadedConfig(defineConfig({ projects: [] }));
});
it("accepts the legacy claude-agent-sdk value", () => {
setLoadedConfig(defineConfig({ projects: [], defaultAgent: "codex" }));
expect(resolveAgentType("claude-agent-sdk")).toBe("claude-agent-sdk");
});
it("aliases claude to claude-agent-sdk", () => {
expect(resolveAgentType("claude")).toBe("claude-agent-sdk");
});
it("aliases claude from defaultAgent config", () => {
setLoadedConfig(defineConfig({ projects: [], defaultAgent: "claude" }));
expect(resolveAgentType(undefined)).toBe("claude-agent-sdk");
});
it("falls back to defaultAgent from config when not provided", () => {
setLoadedConfig(defineConfig({ projects: [], defaultAgent: "codex" }));
expect(resolveAgentType(undefined)).toBe("codex");
});
it("falls back to codex when neither is set", () => {
setLoadedConfig(defineConfig({ projects: [] }));
expect(resolveAgentType(undefined)).toBe("codex");
});
});
import { defineConfig, setLoadedConfig } from "@deepsec/core";
import { afterEach, describe, expect, it } from "vitest";
import { resolveProjectId } from "../resolve-project-id.js";
describe("resolveProjectId", () => {
afterEach(() => {
setLoadedConfig(defineConfig({ projects: [] }));
});
it("returns the user-provided id when given", () => {
setLoadedConfig(
defineConfig({
projects: [
{ id: "alpha", root: "/tmp/a" },
{ id: "beta", root: "/tmp/b" },
],
}),
);
expect(resolveProjectId("beta")).toBe("beta");
expect(resolveProjectId("anything")).toBe("anything");
});
it("auto-resolves to the only project when config has exactly one", () => {
setLoadedConfig(defineConfig({ projects: [{ id: "lone", root: "/tmp/l" }] }));
expect(resolveProjectId(undefined)).toBe("lone");
});
it("throws with all ids listed when config has multiple projects", () => {
setLoadedConfig(
defineConfig({
projects: [
{ id: "alpha", root: "/a" },
{ id: "beta", root: "/b" },
{ id: "gamma", root: "/g" },
],
}),
);
expect(() => resolveProjectId(undefined)).toThrow(/alpha, beta, gamma/);
expect(() => resolveProjectId(undefined)).toThrow(/Pass --project-id/);
});
it("throws with init guidance when config has no projects", () => {
setLoadedConfig(defineConfig({ projects: [] }));
expect(() => resolveProjectId(undefined)).toThrow(/no projects found/);
expect(() => resolveProjectId(undefined)).toThrow(/deepsec init/);
});
});
import { execSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { extractTarballLocally } from "../sandbox/download.js";
import { makeTarball } from "../sandbox/upload.js";
describe("makeTarball — symlink filtering (git branch)", () => {
let tmp: string;
const createdTars: string[] = [];
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-upload-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
for (const p of createdTars.splice(0)) {
try {
fs.unlinkSync(p);
} catch {}
}
});
it("git: skips symlinks while archiving regular files", async () => {
// `git init` is enough to put makeTarball on the git branch.
// No commit is required: `git ls-files --others --exclude-standard`
// reports untracked-not-ignored files, which is what the upload
// path actually relies on. Avoiding `git commit` also avoids
// triggering GPG-signing prompts on dev machines that have
// `commit.gpgsign=true` set globally.
execSync("git init -q", { cwd: tmp });
fs.writeFileSync(path.join(tmp, "ok.json"), '{"v":1}');
fs.symlinkSync("/etc/passwd", path.join(tmp, "evil.link"));
const stats = await makeTarball(tmp, []);
createdTars.push(stats.tarPath);
const entries = await listTarballEntriesFromFile(stats.tarPath);
expect(entries.some((e) => e.path.endsWith("ok.json") && e.type === "File")).toBe(true);
expect(entries.some((e) => e.path.includes("evil.link"))).toBe(false);
expect(entries.some((e) => e.type === "SymbolicLink")).toBe(false);
});
it("returns bytes matching the on-disk tarball size", async () => {
execSync("git init -q", { cwd: tmp });
fs.writeFileSync(path.join(tmp, "a.json"), "{}");
const stats = await makeTarball(tmp, []);
createdTars.push(stats.tarPath);
expect(fs.statSync(stats.tarPath).size).toBe(stats.bytes);
});
});
describe("extractTarballLocally — strict allowlist", () => {
let tarballDir: string;
let destDir: string;
beforeEach(() => {
tarballDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-extract-src-"));
destDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-extract-dst-"));
});
afterEach(() => {
fs.rmSync(tarballDir, { recursive: true, force: true });
fs.rmSync(destDir, { recursive: true, force: true });
});
it("accepts a tarball of allowed extensions in legitimate namespaces", async () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-tar-good-"));
fs.mkdirSync(path.join(src, "files"));
fs.mkdirSync(path.join(src, "reports"));
// Valid FileRecord — the post-extract merge validates against schema
// and would otherwise drop the record as spoofed.
fs.writeFileSync(
path.join(src, "files", "record.ts.json"),
JSON.stringify(validRecord(path.basename(destDir), "record.ts")),
);
fs.writeFileSync(path.join(src, "reports", "report.md"), "# r");
const stats = await makeTarball(src, []);
fs.rmSync(src, { recursive: true, force: true });
const count = await extractTarballLocally(stats.tarPath, destDir);
fs.unlinkSync(stats.tarPath);
expect(count).toBe(2);
expect(fs.existsSync(path.join(destDir, "files", "record.ts.json"))).toBe(true);
expect(fs.existsSync(path.join(destDir, "reports", "report.md"))).toBe(true);
});
it("accepts parse-failure debug dumps under debug/*.txt", async () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-tar-debug-"));
fs.mkdirSync(path.join(src, "debug"));
fs.writeFileSync(
path.join(src, "debug", "parse-error-investigate-2026-01-01T00-00-00-000Z.txt"),
"not actually json {",
);
const stats = await makeTarball(src, []);
fs.rmSync(src, { recursive: true, force: true });
const count = await extractTarballLocally(stats.tarPath, destDir);
fs.unlinkSync(stats.tarPath);
expect(count).toBe(1);
expect(
fs.existsSync(
path.join(destDir, "debug", "parse-error-investigate-2026-01-01T00-00-00-000Z.txt"),
),
).toBe(true);
});
it("refuses a tarball with a disallowed extension and writes nothing", async () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-tar-bad-"));
fs.mkdirSync(path.join(src, "files"));
fs.writeFileSync(
path.join(src, "files", "record.ts.json"),
JSON.stringify(validRecord(path.basename(destDir), "record.ts")),
);
// `.sh` is outside the allowlist (.json/.md/.csv/.txt). Plain `.txt`
// is now allowed for parse-failure debug dumps, so we pick an
// extension that's still rejected for the extension check itself.
fs.writeFileSync(path.join(src, "files", "secret.sh"), "uh oh");
const stats = await makeTarball(src, []);
fs.rmSync(src, { recursive: true, force: true });
await expect(extractTarballLocally(stats.tarPath, destDir)).rejects.toThrow(/extension/);
fs.unlinkSync(stats.tarPath);
// All-or-nothing: even the otherwise-allowed record must not land.
expect(fs.readdirSync(destDir)).toEqual([]);
});
it("refuses a tarball whose entry sits outside files/ runs/ reports/", async () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-tar-ns-"));
// top-level project.json is the canonical attack — overwriting it
// poisons rootPath for the next CLI run.
fs.writeFileSync(path.join(src, "project.json"), '{"rootPath":"/etc"}');
const stats = await makeTarball(src, []);
fs.rmSync(src, { recursive: true, force: true });
await expect(extractTarballLocally(stats.tarPath, destDir)).rejects.toThrow(
/outside files\/, runs\/, reports\//,
);
fs.unlinkSync(stats.tarPath);
expect(fs.readdirSync(destDir)).toEqual([]);
});
it("accepts paths with framework-special characters (Next.js dynamic routes, etc.)", async () => {
// Repro for the path-validator bug: `[A-Za-z0-9._-]` rejected
// `[...slug]`, `(group)`, `@modal`, parens, spaces, plus signs —
// any path that valid Next.js / SvelteKit / Astro repos routinely
// use. The allowed char class is now `[^/\\\0]+`, which covers
// these while tar.strict + the explicit segment check still block
// traversal.
const projectId = path.basename(destDir);
const fixtures: { rel: string; nested: string[] }[] = [
{ rel: "files/app/v4/[...slug]/route.ts.json", nested: ["app", "v4", "[...slug]"] },
{ rel: "files/app/[id]/page.tsx.json", nested: ["app", "[id]"] },
{ rel: "files/app/[[...slug]]/route.ts.json", nested: ["app", "[[...slug]]"] },
{ rel: "files/app/(public)/about/page.tsx.json", nested: ["app", "(public)", "about"] },
{ rel: "files/app/@modal/page.tsx.json", nested: ["app", "@modal"] },
];
const src = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-tar-special-"));
fs.mkdirSync(path.join(src, "files"));
for (const f of fixtures) {
fs.mkdirSync(path.join(src, "files", ...f.nested), { recursive: true });
const filename = f.rel.split("/").pop()!;
fs.writeFileSync(
path.join(src, "files", ...f.nested, filename),
JSON.stringify(
validRecord(projectId, f.rel.replace(/^files\//, "").replace(/\.json$/, "")),
),
);
}
const stats = await makeTarball(src, []);
fs.rmSync(src, { recursive: true, force: true });
const count = await extractTarballLocally(stats.tarPath, destDir);
fs.unlinkSync(stats.tarPath);
expect(count).toBe(fixtures.length);
for (const f of fixtures) {
expect(fs.existsSync(path.join(destDir, f.rel))).toBe(true);
}
});
it("rejects entries containing '..' segments even though the char class is permissive", async () => {
// The relaxed char class `[^/\\\0]+` would textually permit a literal
// ".." segment. tar.strict catches it — but we also have an explicit
// segment-level reject so this remains belt-and-suspenders if tar's
// default strictness ever changes. We can't easily produce a tarball
// with a `..` entry (tar refuses to write those), so we exercise the
// path directly via the extractor by handcrafting an entry — covered
// implicitly by the existing strict-namespace test which would also
// catch the same shape. This test documents the intent.
expect("files/../etc/passwd.json".split("/").includes("..")).toBe(true);
});
it("refuses a tarball containing a symlink entry", async () => {
// Build a tarball directly via tar.create that intentionally
// includes a symlink — bypassing makeTarball's own filter — so
// we exercise the download-side strict filter in isolation.
const src = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-tar-sym-"));
fs.mkdirSync(path.join(src, "files"));
fs.writeFileSync(path.join(src, "files", "record.ts.json"), '{"v":1}');
fs.symlinkSync("/etc/passwd", path.join(src, "files", "leak.ts.json"));
const tarPath = path.join(tarballDir, "in.tgz");
await tar.create({ gzip: true, cwd: src, file: tarPath, portable: true }, [
"files/record.ts.json",
"files/leak.ts.json",
]);
fs.rmSync(src, { recursive: true, force: true });
await expect(extractTarballLocally(tarPath, destDir)).rejects.toThrow(/SymbolicLink|type/);
});
});
async function listTarballEntriesFromFile(
tarPath: string,
): Promise<{ path: string; type: string }[]> {
const entries: { path: string; type: string }[] = [];
await tar.list({
file: tarPath,
onentry: (e) => entries.push({ path: e.path, type: e.type as string }),
});
return entries;
}
function validRecord(projectId: string, filePath: string): unknown {
return {
filePath,
projectId,
candidates: [],
lastScannedAt: "2026-05-06T00:00:00.000Z",
lastScannedRunId: "scan1",
fileHash: "h",
findings: [],
analysisHistory: [],
status: "pending",
};
}
/**
* Per-backend default models. Used when --model is not explicitly set.
* Keep in sync with the DEFAULT_MODEL constants in each agent plugin.
*/
export function defaultModelForAgent(agentType: string): string {
switch (agentType) {
case "codex":
return "gpt-5.5";
default:
return "claude-opus-4-8";
}
}
import { config as dotenvConfig } from "dotenv";
dotenvConfig({ path: ".env.local" });
dotenvConfig(); // also load .env as fallback
import { getRegistry } from "@deepsec/core";
import { Command } from "commander";
import { enrichCommand } from "./commands/enrich.js";
import { exportCommand } from "./commands/export.js";
import { initCommand } from "./commands/init.js";
import { initProjectCommand } from "./commands/init-project.js";
import { metricsCommand } from "./commands/metrics.js";
import { processCommand } from "./commands/process.js";
import { reportCommand } from "./commands/report.js";
import { revalidateCommand } from "./commands/revalidate.js";
import { sandboxAllCommand } from "./commands/sandbox-all.js";
import { sandboxCommand } from "./commands/sandbox-process.js";
import { scanCommand } from "./commands/scan.js";
import { statusCommand } from "./commands/status.js";
import { triageCommand } from "./commands/triage.js";
import { loadConfig } from "./load-config.js";
import { applyAiGatewayDefaults } from "./preflight.js";
import { getDeepsecVersion } from "./version.js";
const program = new Command();
program
.name("deepsec")
.description("AI-powered vulnerability scanner for any codebase")
.version(getDeepsecVersion())
.addHelpText(
"after",
`
Quickstart:
cd <your-repo> first, in the codebase you want to scan
npx deepsec init scaffold .deepsec/ + register this repo
cd .deepsec && pnpm install
pnpm deepsec scan --project-id <id>
pnpm deepsec process --project-id <id>
See \`deepsec init --help\` and the docs at:
https://github.com/vercel/deepsec`,
);
program
.command("init [workspace] [target-root]")
.description("Scaffold .deepsec/ in your repo and register the first project")
.option("--id <project-id>", "Override the project id (default: basename of <target-root>)")
.option("--force", "Allow writing into a non-empty workspace directory")
.addHelpText(
"after",
`
Defaults:
workspace .deepsec
target-root . (the codebase you ran init from)
project id derived from the target's directory basename
Examples:
$ npx deepsec init # most common — from your repo root
$ npx deepsec init audits ../my-app # custom workspace + target
$ npx deepsec init .deepsec . --id my-app # override the auto-derived id`,
)
.action(
(
workspace: string | undefined,
targetRoot: string | undefined,
opts: { id?: string; force?: boolean },
) =>
initCommand({
workspace,
targetRoot,
id: opts.id,
force: opts.force,
}),
);
program
.command("init-project <target-root>")
.description("Register an additional project in the current .deepsec workspace")
.option("--id <project-id>", "Override the project id (default: basename of <target-root>)")
.option("--force", "Overwrite an existing project of the same id")
.addHelpText(
"after",
`
Run from inside a .deepsec/ workspace. Appends an entry to
deepsec.config.ts (above the marker comment) and writes a fresh
data/<id>/{INFO.md,SETUP.md,project.json}.
Examples:
$ pnpm deepsec init-project ../another-app
$ pnpm deepsec init-project ./packages/api --id api`,
)
.action((targetRoot: string | undefined, opts: { id?: string; force?: boolean }) =>
initProjectCommand({ targetRoot, id: opts.id, force: opts.force }),
);
program
.command("scan")
.description("Run regex matchers across a project to find candidate vulnerability sites")
.option(
"--project-id <id>",
"Project identifier (default: the only project in deepsec.config.ts; required if there are multiple)",
)
.option(
"--root <path>",
"Override the project's root (rare — use only for sandbox runs or one-off scans against a different checkout)",
)
.option(
"--matchers <slugs>",
"Comma-separated matcher slugs to run (default: all registered matchers)",
)
.addHelpText(
"after",
`
The root is resolved from deepsec.config.ts (or data/<id>/project.json
once a project has been scanned). Pass --root only when overriding.
Examples:
$ pnpm deepsec scan --project-id my-app
$ pnpm deepsec scan --project-id my-app --matchers auth-bypass,xss
$ pnpm deepsec scan --project-id my-app --root ../checkout-on-pr-branch`,
)
.action(scanCommand);
program
.command("process")
.description("Investigate candidates with an AI agent")
.option(
"--project-id <id>",
"Project identifier (default: the only project in deepsec.config.ts; required if there are multiple)",
)
.option("--run-id <id>", "Resume a specific processing run")
.option(
"--agent <type>",
"Agent plugin type: codex or claude (default: defaultAgent in deepsec.config.ts, else codex)",
)
.option(
"--model <model>",
"Model to use (default: claude-opus-4-8 for claude, gpt-5.5 for codex)",
)
.option("--max-turns <n>", "Max conversation turns per batch (default: 150)", parseInt)
.option(
"--reinvestigate [n]",
"Re-investigate files. No arg = all files. Pass N as a wave marker — productive analyses are tagged with N, and re-running with the same N skips already-done files. Bump N to request another pass.",
)
.option("--limit <n>", "Max number of files to process", parseInt)
.option("--concurrency <n>", "Batches to process in parallel (default: cores - 1)", parseInt)
.option("--filter <prefix>", "Only process files matching this path prefix")
.option("--batch-size <n>", "Files per batch (default: 5)", parseInt)
.option("--root <path>", "Override rootPath from project.json (for sandbox execution)")
.option(
"--manifest <path>",
"JSON file with array of file paths to process (instead of all pending)",
)
.option("--only-slugs <csv>", "Only process files that have a candidate with one of these slugs")
.option("--skip-slugs <csv>", "Skip files whose candidate slugs are all in this set")
.option(
"--diff <ref>",
"Direct mode: investigate files changed between <ref> and HEAD (e.g. origin/main, HEAD~1..HEAD). Auto-creates the project if needed. Exits 1 if any finding is produced.",
)
.option("--diff-staged", "Direct mode: investigate files in the git index (vs HEAD)")
.option("--diff-working", "Direct mode: investigate uncommitted + untracked files")
.option("--files <csv>", "Direct mode: investigate this comma-separated path list")
.option(
"--files-from <path>",
"Direct mode: read newline-delimited paths from <path> (or '-' for stdin)",
)
.option("--no-ignore", "In direct mode, skip the default ignore filter (test files, dist, etc.)")
.option(
"--comment-out <path>",
"Write a PR-comment-shaped markdown summary to <path> (only when findings exist)",
)
.action(processCommand);
program
.command("report")
.description("Generate a markdown + JSON report from current analysis state.")
.option(
"--project-id <id>",
"Project identifier (default: the only project in deepsec.config.ts; required if there are multiple)",
)
.option("--run-id <id>", "Filter to a specific run's results")
.action(reportCommand);
program
.command("revalidate")
.description("Re-check existing findings for false positives")
.option(
"--project-id <id>",
"Project identifier (default: the only project in deepsec.config.ts; required if there are multiple)",
)
.option("--run-id <id>", "Resume a specific revalidation run")
.option(
"--agent <type>",
"Agent plugin type: codex or claude (default: defaultAgent in deepsec.config.ts, else codex)",
)
.option(
"--model <model>",
"Model to use (default: claude-opus-4-8 for claude, gpt-5.5 for codex)",
)
.option("--max-turns <n>", "Max conversation turns per batch (default: 150)", parseInt)
.option(
"--min-severity <sev>",
"Only revalidate findings at this severity or above (CRITICAL, HIGH, MEDIUM, HIGH_BUG, BUG)",
)
.option("--force", "Re-check already-validated findings")
.option("--limit <n>", "Max files to revalidate", parseInt)
.option("--concurrency <n>", "Parallel batches (default: cores - 1)", parseInt)
.option("--batch-size <n>", "Files per revalidation batch (default: 5)", parseInt)
.option("--filter <prefix>", "Only revalidate files matching path prefix")
.option("--root <path>", "Override rootPath from project.json (for sandbox execution)")
.option("--manifest <path>", "JSON file with array of file paths to revalidate")
.option("--only-slugs <csv>", "Only revalidate findings with one of these vulnSlugs")
.option("--skip-slugs <csv>", "Skip findings with any of these vulnSlugs")
.action(revalidateCommand);
program
.command("enrich")
.description("Enrich files with git history + ownership oracle")
.option(
"--project-id <id>",
"Project identifier (default: the only project in deepsec.config.ts; required if there are multiple)",
)
.option("--filter <prefix>", "Only enrich files matching path prefix")
.option(
"--min-severity <sev>",
"Only enrich files with a finding at this severity or above (CRITICAL, HIGH, MEDIUM, HIGH_BUG, BUG, LOW)",
)
.option("--force", "Re-enrich already-enriched files")
.option("--concurrency <n>", "Parallel ownership oracle requests (default: cores - 1)", parseInt)
.action(enrichCommand);
program
.command("triage")
.description("Classify findings by priority (P0/P1/P2/skip) — lightweight, no code reading")
.option(
"--project-id <id>",
"Project identifier (default: the only project in deepsec.config.ts; required if there are multiple)",
)
.option("--severity <sev>", "Severity to triage (default: MEDIUM)", "MEDIUM")
.option("--model <model>", "Model to use (default: claude-sonnet-4-6 — cheaper)")
.option("--force", "Re-triage already-triaged findings")
.option("--limit <n>", "Max findings to triage", parseInt)
.option("--concurrency <n>", "Parallel triage batches (default: cores - 1)", parseInt)
.action(triageCommand);
program
.command("status")
.description("Show current state of the project mirror")
.option(
"--project-id <id>",
"Project identifier (default: the only project in deepsec.config.ts; required if there are multiple)",
)
.action(statusCommand);
program
.command("export")
.description("Export findings as JSON or as a directory of per-finding markdown files")
.option("--format <kind>", "Output format: json (default) or md-dir", "json")
.option("--project-id <csv>", "Comma-separated project IDs (omit for all)")
.option(
"--min-severity <sev>",
"Only export findings at this severity or above (CRITICAL, HIGH, MEDIUM, HIGH_BUG, BUG, LOW)",
)
.option(
"--only-severity <sev>",
"Only export findings at this exact severity (CRITICAL, HIGH, MEDIUM, HIGH_BUG, BUG, LOW)",
)
.option("--discovered-today", "Only findings whose most recent analysis was today (local time)")
.option(
"--since <iso>",
"Only findings whose most recent analysis was on/after this ISO timestamp",
)
.option("--only-true-positive", "Only findings revalidated as true-positive")
.option(
"--include-resolved",
"Include findings revalidated as fixed / false-positive / accepted-risk (hidden by default)",
)
.option(
"--exclude-false-positive",
"Deprecated — false-positive is now hidden by default; this flag is a no-op",
)
.option("--only-slugs <csv>", "Only export findings with these vulnSlugs")
.option("--skip-slugs <csv>", "Drop findings with these vulnSlugs")
.option("--require-owner", "Drop findings that have no ownership data (no assignee, no teams)")
.option(
"--only-agent <type>",
"Only export findings produced by this agent backend (e.g. codex, claude)",
)
.option(
"--only-marker <n>",
"Only export findings produced under this --reinvestigate wave marker",
)
.option(
"--out <path>",
"Output path. JSON format: file (default: stdout). md-dir format: directory (required).",
)
.action(exportCommand);
program
.command("metrics")
.description("Report findings metrics across all projects (or one project)")
.option("--project-id <id>", "Project identifier (omit for all projects)")
.option("--min-severity <sev>", "Minimum severity to include (default: LOW)")
.action(metricsCommand);
const sandboxCmd = program
.command("sandbox <command>")
.description(
"Run a deepsec command on Vercel Sandbox microVMs. Sandbox-level options (--sandboxes, --vcpus, --detach, etc.) are parsed; all other options are passed through to the subcommand.",
)
.allowUnknownOption()
.allowExcessArguments(true)
.option(
"--project-id <id>",
"Project identifier (default: the only project in deepsec.config.ts; required if there are multiple)",
)
.option("--sandboxes <n>", "Number of parallel sandboxes (default: 1)", parseInt)
.option("--vcpus <n>", "vCPUs per sandbox (default: 2, max: 8)", parseInt)
.option("--detach", "Launch sandboxes and exit immediately (collect results later)")
.option("--run-id <id>", "Run ID for status/collect commands")
.option("--snapshot-id <id>", "Restore from existing snapshot")
.option("--save-snapshot", "Snapshot after setup for future reuse")
.option("--keep-alive", "Don't stop sandboxes after completion")
.option("--timeout <ms>", "Sandbox timeout in ms (default: 5 hours)", parseInt)
.action((subcommand: string, opts: Record<string, unknown>) => {
// Commander puts unknown options into .args on the Command object
const unknownArgs = sandboxCmd.args.slice(1); // skip the subcommand itself
return sandboxCommand(subcommand, { ...opts, args: unknownArgs } as Parameters<
typeof sandboxCommand
>[1]);
});
const sandboxAllCmd = program
.command("sandbox-all <command>")
.description(
"Run a deepsec command across ALL projects on Vercel Sandbox microVMs, allocating sandboxes proportionally",
)
.allowUnknownOption()
.allowExcessArguments(true)
.option("--sandboxes <n>", "Total sandboxes to distribute (default: 10)", parseInt)
.option("--vcpus <n>", "vCPUs per sandbox (default: auto from concurrency, max: 8)", parseInt)
.option("--timeout <ms>", "Sandbox timeout in ms (default: 5 hours)", parseInt)
.action((subcommand: string, opts: Record<string, unknown>) => {
const unknownArgs = sandboxAllCmd.args.slice(1);
return sandboxAllCommand(subcommand, { ...opts, args: unknownArgs } as Parameters<
typeof sandboxAllCommand
>[1]);
});
/**
* Surface error messages cleanly. Stack traces are noise for user-facing
* failures (bad input, missing config, network errors). Set
* `DEEPSEC_DEBUG=1` to see them when debugging.
*/
function printFatal(err: unknown): never {
const verbose = process.env.DEEPSEC_DEBUG === "1";
console.error(`\n${err instanceof Error ? err.message : err}`);
if (verbose && err instanceof Error && err.stack) {
console.error(err.stack);
} else if (!verbose) {
console.error("\n(set DEEPSEC_DEBUG=1 for a stack trace)");
}
process.exit(1);
}
process.on("unhandledRejection", printFatal);
process.on("uncaughtException", printFatal);
async function main() {
// Expand AI_GATEWAY_API_KEY (or fall back to a Vercel OIDC token) into
// the per-SDK env vars before any command handler instantiates an agent.
// Must run before loadConfig in case the user's deepsec.config.ts reads
// these vars at module load.
await applyAiGatewayDefaults();
await loadConfig();
// Plugins may register their own subcommands.
for (const register of getRegistry().commands) {
register(program);
}
await program.parseAsync();
}
main();
import type { Severity } from "@deepsec/core";
import { enrich } from "@deepsec/processor";
import { commitAndPushData } from "../data-commit.js";
import { BOLD, DIM, GREEN, RESET } from "../formatters.js";
import { resolveProjectId } from "../resolve-project-id.js";
export async function enrichCommand(opts: {
projectId?: string;
filter?: string;
force?: boolean;
concurrency?: number;
minSeverity?: string;
}) {
const projectId = resolveProjectId(opts.projectId);
const minSeverity = opts.minSeverity as Severity | undefined;
console.log(
`${BOLD}Enriching${RESET} files with git history for project ${BOLD}${projectId}${RESET}`,
);
if (opts.filter) console.log(` Filter: ${opts.filter}`);
if (minSeverity) console.log(` Min severity: ${minSeverity}`);
if (opts.concurrency) console.log(` Concurrency: ${opts.concurrency}`);
console.log();
const result = await enrich({
projectId,
filter: opts.filter,
force: opts.force,
concurrency: opts.concurrency,
minSeverity,
onProgress(progress) {
if (progress.type === "file") {
console.log(` ${DIM}[${progress.current}/${progress.total}]${RESET} ${progress.message}`);
} else {
console.log(`\n${GREEN}${progress.message}${RESET}`);
}
},
});
if (result.enriched === 0) {
console.log("No files to enrich (no findings, or already enriched — use --force).");
} else {
commitAndPushData(`enrich: ${projectId} (${result.enriched} files)`);
}
}
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { FileRecord, Finding, Severity } from "@deepsec/core";
import { dataDir, getDataRoot, loadAllFileRecords } from "@deepsec/core";
import { BOLD, DIM, GREEN, RESET, YELLOW } from "../formatters.js";
import { resolveAgentType } from "../resolve-agent-type.js";
const SEVERITY_ORDER: Record<Severity, number> = {
CRITICAL: 0,
HIGH: 1,
HIGH_BUG: 2,
MEDIUM: 3,
BUG: 4,
LOW: 5,
};
interface OwnerSummary {
assignee?: string;
assigneeSource?: "oncall" | "manager" | "top-contributor" | "last-committer";
teams: { name: string; slug: string }[];
oncall: { name: string; email: string; slack_user_id?: string; github_username?: string }[];
managers: { email: string; slack_user_id?: string }[];
contributors: { name: string; email: string; github_username?: string; score: number }[];
recentCommitters: { name: string; email: string; date: string }[];
}
interface ExportedFinding {
title: string;
description: string;
severity: Severity;
labels: string[];
/** Best-guess owner email, suitable for downstream issue-tracker assignment. */
assignee?: string;
metadata: {
projectId: string;
filePath: string;
lineNumbers: number[];
severity: Severity;
vulnSlug: string;
confidence: string;
discoveredAt: string;
runId: string;
revalidation?: {
verdict: string;
reasoning: string;
};
githubUrl?: string;
owners: OwnerSummary;
};
}
function summarizeOwners(record: FileRecord): OwnerSummary {
const teams = (record.gitInfo?.ownership?.escalationTeams ?? []).map((t) => ({
name: t.name,
slug: t.slug,
}));
const oncall = (record.gitInfo?.ownership?.escalationTeams ?? [])
.map((t) => t.current_oncall)
.filter((o) => o?.email)
.map((o) => ({
name: o.name,
email: o.email,
slack_user_id: o.slack_user_id,
github_username: o.github_username,
}));
const managers = (record.gitInfo?.ownership?.escalationTeams ?? [])
.map((t) => t.manager)
.filter((m) => m?.email)
.map((m) => ({ email: m.email, slack_user_id: m.slack_user_id }));
const contributors = (record.gitInfo?.ownership?.contributors ?? []).slice(0, 5).map((c) => ({
name: c.name,
email: c.email,
github_username: c.github_username,
score: c.score,
}));
const recentCommitters = (record.gitInfo?.recentCommitters ?? []).slice(0, 5);
let assignee: string | undefined;
let assigneeSource: OwnerSummary["assigneeSource"];
if (oncall[0]?.email) {
assignee = oncall[0].email;
assigneeSource = "oncall";
} else if (managers[0]?.email) {
assignee = managers[0].email;
assigneeSource = "manager";
} else if (contributors[0]?.email) {
assignee = contributors[0].email;
assigneeSource = "top-contributor";
} else if (recentCommitters[0]?.email) {
assignee = recentCommitters[0].email;
assigneeSource = "last-committer";
}
return { assignee, assigneeSource, teams, oncall, managers, contributors, recentCommitters };
}
function projectRepoUrl(projectId: string): string | undefined {
try {
const p = JSON.parse(fs.readFileSync(path.join(dataDir(projectId), "project.json"), "utf-8"));
return p.githubUrl;
} catch {
return undefined;
}
}
function makeGithubLink(
repoUrl: string | undefined,
filePath: string,
lines: number[],
): string | undefined {
if (!repoUrl) return undefined;
const base = repoUrl.replace(/\/+$/, "").replace(/\/blob\/[^/]+$/, "");
const firstLine = lines[0];
const lastLine = lines[lines.length - 1];
const anchor = firstLine === lastLine ? `#L${firstLine}` : `#L${firstLine}-L${lastLine}`;
const branch = repoUrl.match(/\/blob\/([^/]+)/)?.[1] ?? "main";
return `${base}/blob/${branch}/${filePath}${anchor}`;
}
function buildDescription(
finding: Finding,
record: FileRecord,
projectId: string,
owners: OwnerSummary,
githubUrl?: string,
): string {
const head = githubUrl
? `**File:** [\`${record.filePath}\`](${githubUrl}) (lines ${finding.lineNumbers.join(", ")})`
: `**File:** \`${record.filePath}\` (lines ${finding.lineNumbers.join(", ")})`;
const parts: string[] = [
head,
`**Project:** ${projectId}`,
`**Severity:** ${finding.severity} • **Confidence:** ${finding.confidence} • **Slug:** \`${finding.vulnSlug}\``,
];
if (owners.assignee || owners.teams.length > 0 || owners.oncall.length > 0) {
parts.push("", "## Owners");
if (owners.assignee) {
parts.push(
"",
`**Suggested assignee:** \`${owners.assignee}\` _(via ${owners.assigneeSource})_`,
);
}
if (owners.teams.length > 0) {
parts.push(
"",
"**Teams:**",
...owners.teams.slice(0, 3).map((t) => `- ${t.name} (\`${t.slug}\`)`),
);
}
if (owners.oncall.length > 0) {
parts.push(
"",
"**Current on-call:**",
...owners.oncall.slice(0, 3).map((o) => {
const gh = o.github_username
? ` • [@${o.github_username}](https://github.com/${o.github_username})`
: "";
return `- ${o.name} <${o.email}>${gh}`;
}),
);
}
if (owners.managers.length > 0) {
parts.push("", "**Managers:**", ...owners.managers.slice(0, 3).map((m) => `- <${m.email}>`));
}
}
parts.push(
"",
"## Finding",
"",
finding.description,
"",
"## Recommendation",
"",
finding.recommendation,
);
if (finding.revalidation) {
parts.push(
"",
"## Revalidation",
"",
`**Verdict:** ${finding.revalidation.verdict}`,
"",
finding.revalidation.reasoning,
);
}
if (owners.contributors.length > 0) {
parts.push(
"",
"## Top contributors",
"",
...owners.contributors.map((c) => `- ${c.name} <${c.email}> (score: ${c.score.toFixed(2)})`),
);
}
if (owners.recentCommitters.length > 0) {
parts.push(
"",
"## Recent committers (`git log`)",
"",
...owners.recentCommitters.map((c) => `- ${c.name} <${c.email}> (${c.date.slice(0, 10)})`),
);
}
return parts.join("\n");
}
function inDay(iso: string, dayStart: number, dayEnd: number): boolean {
const t = Date.parse(iso);
return !Number.isNaN(t) && t >= dayStart && t < dayEnd;
}
function startOfTodayLocal(): number {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function listProjectIds(): string[] {
const dataDirPath = path.resolve(getDataRoot());
if (!fs.existsSync(dataDirPath)) return [];
return fs
.readdirSync(dataDirPath, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((p) => fs.existsSync(path.join(dataDirPath, p, "project.json")));
}
/** Stable, filesystem-safe filename for a finding in md-dir mode. */
function findingFilename(f: ExportedFinding): string {
const hash = crypto
.createHash("sha1")
.update(
`${f.metadata.projectId}\0${f.metadata.filePath}\0${f.metadata.lineNumbers.join(",")}\0${f.metadata.vulnSlug}`,
)
.digest("hex")
.slice(0, 10);
const safeSlug = f.metadata.vulnSlug.replace(/[^a-zA-Z0-9-]/g, "-");
const safeProject = f.metadata.projectId.replace(/[^a-zA-Z0-9-]/g, "-");
return `${safeProject}-${safeSlug}-${hash}.md`;
}
function writeJson(findings: ExportedFinding[], out: string | undefined) {
const json = JSON.stringify(findings, null, 2);
if (out) {
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
fs.writeFileSync(out, json + "\n");
console.log(`\n${GREEN}Exported ${findings.length} finding(s)${RESET} → ${BOLD}${out}${RESET}`);
} else {
process.stdout.write(json + "\n");
}
}
function writeMdDir(findings: ExportedFinding[], out: string) {
const root = path.resolve(out);
fs.mkdirSync(root, { recursive: true });
// The set of files this export is authoritative for. Anything else in
// the severity subdirs is left over from a prior run — most often a
// finding that has since been revalidated as fixed/false-positive/
// accepted-risk and is now filtered out of the export. Without this
// sweep, those orphans linger forever and make the export directory
// misleading (the user thinks they still have unresolved findings on a
// file we've already patched).
const wantedFiles = new Set<string>();
for (const f of findings) {
wantedFiles.add(path.join(root, f.metadata.severity, findingFilename(f)));
}
// Only sweep severity subdirs we recognize — keeps an accidental
// `--out ~/Documents` from nuking unrelated files. Severity values are
// a closed enum (see `Severity` in @deepsec/core), so this list IS the
// namespace md-dir mode owns.
const ourSeverityDirs = Object.keys(SEVERITY_ORDER) as Severity[];
let droppedStale = 0;
for (const sev of ourSeverityDirs) {
const dir = path.join(root, sev);
if (!fs.existsSync(dir)) continue;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const full = path.join(dir, entry.name);
if (!wantedFiles.has(full)) {
fs.unlinkSync(full);
droppedStale++;
}
}
// Drop now-empty severity dirs so a 0-finding export leaves a clean
// root rather than a forest of empty directories.
try {
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
} catch {}
}
for (const f of findings) {
const dir = path.join(root, f.metadata.severity);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, findingFilename(f));
const body = `# ${f.title}\n\n${f.description}\n`;
fs.writeFileSync(file, body);
}
const staleNote = droppedStale > 0 ? ` (removed ${droppedStale} stale file(s))` : "";
console.log(
`\n${GREEN}Exported ${findings.length} finding(s)${RESET} → ${BOLD}${root}/${RESET}${staleNote}`,
);
}
export async function exportCommand(opts: {
projectId?: string;
minSeverity?: string;
onlySeverity?: string;
discoveredToday?: boolean;
since?: string;
onlyTruePositive?: boolean;
/** Deprecated: false-positive is now hidden by default. Kept as a no-op for back-compat. */
excludeFalsePositive?: boolean;
/**
* Restore old behavior of including resolved verdicts (fixed,
* false-positive, accepted-risk) in the output. Off by default.
*/
includeResolved?: boolean;
onlySlugs?: string;
skipSlugs?: string;
out?: string;
format?: string;
/** Drop findings without any ownership data (no assignee, no teams) */
requireOwner?: boolean;
/** Only include findings produced by this agent backend (e.g. `codex`) */
onlyAgent?: string;
/** Only include findings produced under this --reinvestigate wave marker */
onlyMarker?: string;
}) {
const projectIds = opts.projectId
? opts.projectId
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: listProjectIds();
const format = opts.format ?? "json";
if (format !== "json" && format !== "md-dir") {
throw new Error(`--format must be "json" or "md-dir", got "${format}"`);
}
if (format === "md-dir" && !opts.out) {
throw new Error(`--format md-dir requires --out <dir>`);
}
const minSeverity = opts.minSeverity as Severity | undefined;
const onlySeverity = opts.onlySeverity as Severity | undefined;
if (onlySeverity && !(onlySeverity in SEVERITY_ORDER)) {
throw new Error(`--only-severity: not a valid severity: ${opts.onlySeverity}`);
}
let sinceMs: number | undefined;
let untilMs = Number.POSITIVE_INFINITY;
if (opts.discoveredToday) {
sinceMs = startOfTodayLocal();
untilMs = sinceMs + 24 * 60 * 60 * 1000;
} else if (opts.since) {
const t = Date.parse(opts.since);
if (Number.isNaN(t)) throw new Error(`--since: not a valid ISO timestamp: ${opts.since}`);
sinceMs = t;
}
const onlySlugs = opts.onlySlugs
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const skipSlugs = opts.skipSlugs
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const onlySlugSet = onlySlugs?.length ? new Set(onlySlugs) : undefined;
const skipSlugSet = skipSlugs?.length ? new Set(skipSlugs) : undefined;
console.log(`${BOLD}Exporting findings (${format})${RESET}`);
console.log(` Projects: ${projectIds.join(", ") || "(none)"}`);
if (minSeverity) console.log(` Min severity: ${minSeverity}`);
if (onlySeverity) console.log(` Only severity: ${onlySeverity}`);
if (opts.discoveredToday) console.log(` ${YELLOW}Filter: discovered today only${RESET}`);
if (opts.since) console.log(` Filter: discovered since ${opts.since}`);
if (opts.onlyTruePositive) console.log(` Filter: only revalidated true-positive`);
if (opts.includeResolved) {
console.log(` Filter: including resolved verdicts (fixed/false-positive/accepted-risk)`);
} else {
console.log(` Filter: hiding resolved verdicts (fixed/false-positive/accepted-risk)`);
}
if (opts.excludeFalsePositive) {
console.log(
` ${YELLOW}Note: --exclude-false-positive is now the default; flag is a no-op.${RESET}`,
);
}
if (onlySlugs) console.log(` Only slugs: ${onlySlugs.join(", ")}`);
if (skipSlugs) console.log(` Skip slugs: ${skipSlugs.join(", ")}`);
if (opts.requireOwner)
console.log(` ${YELLOW}Filter: only findings with ownership data${RESET}`);
const onlyMarker = opts.onlyMarker !== undefined ? Number(opts.onlyMarker) : undefined;
if (onlyMarker !== undefined && !Number.isFinite(onlyMarker)) {
throw new Error(`--only-marker must be a number, got "${opts.onlyMarker}"`);
}
const onlyAgent = opts.onlyAgent ? resolveAgentType(opts.onlyAgent) : undefined;
if (opts.onlyAgent) console.log(` Only agent: ${opts.onlyAgent}`);
if (onlyMarker !== undefined) console.log(` Only marker: ${onlyMarker}`);
const findings: ExportedFinding[] = [];
let droppedNoOwner = 0;
let withAssignee = 0;
let withTeam = 0;
for (const projectId of projectIds) {
let records: FileRecord[];
try {
records = loadAllFileRecords(projectId);
} catch (err) {
console.error(
` ${DIM}[${projectId}] skipped: ${err instanceof Error ? err.message : err}${RESET}`,
);
continue;
}
const repoUrl = projectRepoUrl(projectId);
let emitted = 0;
for (const record of records) {
const latest = record.analysisHistory?.[record.analysisHistory.length - 1];
if (!latest) continue;
if (sinceMs !== undefined && !inDay(latest.investigatedAt, sinceMs, untilMs)) continue;
// Build a map: finding-index → analysisHistory entry that produced it.
// Findings are appended in analysisHistory order, so the i-th finding
// belongs to whichever entry's findingCount range covers i.
const findingSource: Array<(typeof record.analysisHistory)[number] | undefined> = [];
let cursor = 0;
for (const h of record.analysisHistory ?? []) {
const fc = h.findingCount ?? 0;
for (let k = 0; k < fc; k++) findingSource[cursor++] = h;
}
let findingIndex = -1;
for (const finding of record.findings ?? []) {
findingIndex++;
if (minSeverity && SEVERITY_ORDER[finding.severity] > SEVERITY_ORDER[minSeverity]) continue;
if (onlySeverity && finding.severity !== onlySeverity) continue;
if (onlySlugSet && !onlySlugSet.has(finding.vulnSlug)) continue;
if (skipSlugSet?.has(finding.vulnSlug)) continue;
if (opts.onlyTruePositive && finding.revalidation?.verdict !== "true-positive") continue;
// Default behavior: hide every "resolved" verdict. fixed = patched,
// false-positive = not real, accepted-risk = real but consciously
// accepted, duplicate = same issue as another finding in the file
// (the primary carries the canonical signal). None of these are
// work the export consumer should action. Pass --include-resolved
// to surface them anyway (audit / history use cases). The legacy
// --exclude-false-positive flag is now a no-op — preserved so
// existing scripts don't break.
if (
!opts.includeResolved &&
(finding.revalidation?.verdict === "fixed" ||
finding.revalidation?.verdict === "false-positive" ||
finding.revalidation?.verdict === "accepted-risk" ||
finding.revalidation?.verdict === "duplicate")
) {
continue;
}
const source = findingSource[findingIndex];
if (onlyAgent && source?.agentType !== onlyAgent) continue;
if (onlyMarker !== undefined && source?.reinvestigateMarker !== onlyMarker) continue;
const githubUrl = makeGithubLink(repoUrl, record.filePath, finding.lineNumbers);
const owners = summarizeOwners(record);
const hasOwner = !!owners.assignee || owners.teams.length > 0 || owners.oncall.length > 0;
if (opts.requireOwner && !hasOwner) {
droppedNoOwner++;
continue;
}
const labels = [
"security",
`project:${projectId}`,
`severity:${finding.severity}`,
`slug:${finding.vulnSlug}`,
`confidence:${finding.confidence}`,
];
if (finding.revalidation?.verdict)
labels.push(`revalidation:${finding.revalidation.verdict}`);
for (const t of owners.teams.slice(0, 3)) labels.push(`owning-team:${t.slug}`);
if (!hasOwner) labels.push("missing-owner");
if (owners.assignee) withAssignee++;
if (owners.teams.length > 0) withTeam++;
findings.push({
title: `[${finding.severity}] ${finding.title}`,
description: buildDescription(finding, record, projectId, owners, githubUrl),
severity: finding.severity,
labels,
assignee: owners.assignee,
metadata: {
projectId,
filePath: record.filePath,
lineNumbers: finding.lineNumbers,
severity: finding.severity,
vulnSlug: finding.vulnSlug,
confidence: finding.confidence,
discoveredAt: latest.investigatedAt,
runId: latest.runId,
revalidation: finding.revalidation
? { verdict: finding.revalidation.verdict, reasoning: finding.revalidation.reasoning }
: undefined,
githubUrl,
owners,
},
});
emitted++;
}
}
console.log(` [${projectId}] ${emitted} finding(s)`);
}
// Sort: severity ascending (CRITICAL first), then project, then file
findings.sort((a, b) => {
const sa = SEVERITY_ORDER[a.metadata.severity];
const sb = SEVERITY_ORDER[b.metadata.severity];
if (sa !== sb) return sa - sb;
if (a.metadata.projectId !== b.metadata.projectId)
return a.metadata.projectId.localeCompare(b.metadata.projectId);
return a.metadata.filePath.localeCompare(b.metadata.filePath);
});
if (format === "md-dir") {
writeMdDir(findings, opts.out!);
} else {
writeJson(findings, opts.out);
}
if (findings.length > 0) {
const pct = (n: number) => `${((n / findings.length) * 100).toFixed(0)}%`;
console.log();
console.log(`${BOLD}Ownership coverage:${RESET}`);
console.log(` with assignee: ${withAssignee}/${findings.length} (${pct(withAssignee)})`);
console.log(` with owning team: ${withTeam}/${findings.length} (${pct(withTeam)})`);
if (droppedNoOwner > 0) {
console.log(` ${YELLOW}dropped (--require-owner): ${droppedNoOwner}${RESET}`);
}
}
}
import fs from "node:fs";
import path from "node:path";
import { dataDir, ensureProject } from "@deepsec/core";
import { BOLD, CYAN, DIM, GREEN, RESET, YELLOW } from "../formatters.js";
import { requireExistingDir } from "../require-dir.js";
import { validateProjectId } from "../resolve-project-id.js";
export const PROJECTS_INSERT_MARKER = "// <deepsec:projects-insert-above>";
const CONFIG_FILENAMES = [
"deepsec.config.ts",
"deepsec.config.mjs",
"deepsec.config.js",
"deepsec.config.cjs",
];
/** Walk up from `start` looking for a deepsec config file. */
function findWorkspaceRoot(start: string): string | undefined {
let dir = path.resolve(start);
while (true) {
for (const name of CONFIG_FILENAMES) {
if (fs.existsSync(path.join(dir, name))) return dir;
}
const parent = path.dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
interface RegisterResult {
id: string;
targetRel: string;
targetAbs: string;
configPath: string;
setupMdPath: string;
infoMdPath: string;
}
/**
* Register a project in an existing deepsec workspace. Shared by `init`
* (called once for the first project, after the workspace skeleton is in
* place) and `init-project` (called against an existing workspace).
*
* Writes:
* - data/<id>/project.json (via ensureProject — also auto-detects githubUrl)
* - data/<id>/INFO.md (placeholder template)
* - data/<id>/SETUP.md (per-project agent setup prompt)
* - appends `{ id, root }` to projects[] in deepsec.config.ts
*/
export function registerProject(opts: {
workspaceDir: string;
targetRoot: string;
id?: string;
force?: boolean;
}): RegisterResult {
const workspaceDir = fs.realpathSync(path.resolve(opts.workspaceDir));
const targetAbs = requireExistingDir(opts.targetRoot, "<target-root>");
const id = validateProjectId(opts.id ?? path.basename(targetAbs));
// Normalize to POSIX separators: `targetRel` gets written into
// deepsec.config.ts (committed to VCS) and SETUP.md, so a Windows
// contributor adding a project would otherwise produce `..\foo\bar`
// that's ugly cross-platform and noisy in diffs. Both Node path APIs
// accept "/" on Windows.
const targetRel = path.relative(workspaceDir, targetAbs).split(path.sep).join("/");
const configPath = findConfigInWorkspace(workspaceDir);
if (!configPath) {
throw new Error(
`Could not find deepsec.config.ts in ${workspaceDir}.\n` +
` init-project must run inside a workspace created by \`deepsec init\`.`,
);
}
const projectDataDir = path.join(workspaceDir, dataDir(id));
const dataExists = fs.existsSync(projectDataDir) && fs.readdirSync(projectDataDir).length > 0;
const inConfig = configIncludesProjectId(configPath, id);
if ((dataExists || inConfig) && !opts.force) {
throw new Error(
`Project "${id}" already exists in this workspace ` +
`(${dataExists ? "data dir" : "config"} occupied).\n` +
` Pass --force to overwrite, or pick a different --id.`,
);
}
// Run all writes from the workspace root so DEEPSEC_DATA_ROOT-relative
// paths via `dataDir(id)` land correctly. Restore on exit.
const originalCwd = process.cwd();
try {
process.chdir(workspaceDir);
ensureProject(id, targetAbs);
const projectDir = dataDir(id);
fs.mkdirSync(projectDir, { recursive: true });
const infoMdPath = path.join(projectDir, "INFO.md");
if (!fs.existsSync(infoMdPath) || opts.force) {
fs.writeFileSync(infoMdPath, infoMdTemplate(id));
}
const setupMdPath = path.join(projectDir, "SETUP.md");
fs.writeFileSync(setupMdPath, setupMdTemplate(id, targetRel));
insertProjectIntoConfig(configPath, id, targetRel);
return {
id,
targetRel,
targetAbs,
configPath,
setupMdPath: path.resolve(setupMdPath),
infoMdPath: path.resolve(infoMdPath),
};
} finally {
process.chdir(originalCwd);
}
}
function findConfigInWorkspace(workspaceDir: string): string | undefined {
for (const name of CONFIG_FILENAMES) {
const p = path.join(workspaceDir, name);
if (fs.existsSync(p)) return p;
}
return undefined;
}
function configIncludesProjectId(configPath: string, id: string): boolean {
const src = fs.readFileSync(configPath, "utf-8");
const re = new RegExp(`id:\\s*["'\`]${escapeRegex(id)}["'\`]`);
return re.test(src);
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function insertProjectIntoConfig(configPath: string, id: string, root: string): void {
const src = fs.readFileSync(configPath, "utf-8");
if (!src.includes(PROJECTS_INSERT_MARKER)) {
throw new Error(
`Marker "${PROJECTS_INSERT_MARKER}" not found in ${configPath}.\n` +
` init-project relies on this marker to know where to add the new project.\n` +
` Either add it back inside the projects[] array, or add the project entry by hand:\n` +
` { id: "${id}", root: ${JSON.stringify(root)} },`,
);
}
// Preserve the marker's leading indent on the inserted line so the
// appended entry sits at the same indent level.
const replacer = (match: string) => {
const m = match.match(/^([\t ]*)(.*)$/m);
const indent = m?.[1] ?? " ";
return `${indent}{ id: ${JSON.stringify(id)}, root: ${JSON.stringify(root)} },\n${match}`;
};
const re = new RegExp(`^[\\t ]*${escapeRegex(PROJECTS_INSERT_MARKER)}.*$`, "m");
const updated = src.replace(re, replacer);
fs.writeFileSync(configPath, updated);
}
function infoMdTemplate(id: string): string {
return `# ${id}
> Replace each section. Target 50–100 lines total. INFO.md is injected
> into every AI scan batch — verbose context dilutes signal.
> See \`SETUP.md\` for the rubric + a coding-agent prompt.
## What this codebase does
<one paragraph: what the app does, what stack, what users it serves>
## Auth shape
<the 3–5 most important auth primitives BY NAME. The scanner doesn't
need every helper — just enough to recognize when one is missing>
## Threat model
<2–4 sentences: what an attacker would want, ranked by impact.
Skip generic security boilerplate>
## Project-specific patterns to flag
<3–5 patterns unique to THIS codebase, one example each. Avoid
generic CWE categories — built-in matchers cover those>
## Known false-positives
<3–5 paths/patterns that look risky but are intentional —
fork-specific stubs, dev fixtures, intended-public endpoints>
`;
}
function setupMdTemplate(id: string, targetRel: string): string {
return `# Agent setup for \`${id}\`
This is a deepsec scanning workspace. Project \`${id}\` was just registered
(target: \`${targetRel}\`). Setup is incomplete — \`data/${id}/INFO.md\`
still has placeholder sections.
## What to do
1. **Read the deepsec skill.** After \`pnpm install\`, the file is at
\`node_modules/deepsec/SKILL.md\`. It maps every doc topic to a file
under \`node_modules/deepsec/dist/docs/\`. Read \`getting-started.md\`,
\`configuration.md\`, and \`writing-matchers.md\` (skim the rest).
2. **Fill in \`data/${id}/INFO.md\`.** It's auto-injected into the AI
prompt for every batch — keep it short and selective.
**Length budget: 50–100 lines total.** Verbose context dilutes
signal in the scanner's prompt window. The goal is "what would a
reviewer miss if they didn't read this?", not exhaustive enumeration.
**Per-section rubric**:
- Pick 3–5 representative items per section. **Don't list every
file, helper, or callsite** — pick the patterns.
- Name primitives by their public name (e.g. \`withAuthentication\`,
\`auth.can()\`, \`isTeamAdmin\`). **No line numbers.** Don't enumerate
more than 5 paths in any list.
- Skip generic CWE categories — built-in matchers already cover
"SSRF", "SQL injection", "XSS". Cover what's *project-specific*:
internal auth helpers, custom middleware names, fork-specific
stubs, intended-public endpoints.
- One short paragraph or 3–5 short bullets per section. Not both.
Source material (read in this order, stop when you have enough):
- \`${targetRel}/README.md\`
- any \`AGENTS.md\` / \`CLAUDE.md\` in \`${targetRel}\`
- \`${targetRel}/package.json\` (or \`go.mod\`, \`pyproject.toml\`, etc.)
- 5–10 representative code files (entry points, auth helpers) — not
a full code tour.
3. **(Optional) Add custom matchers** for repo-specific patterns the
built-in matchers won't catch. Read
\`node_modules/deepsec/dist/docs/writing-matchers.md\` first; the
workflow there starts from a confirmed finding and grows the matcher
from it. Don't add matchers speculatively — wait for a real TP.
## When you're done
The user will run:
\`\`\`bash
pnpm deepsec scan --project-id ${id}
pnpm deepsec process --project-id ${id}
\`\`\`
You can delete this file once setup is complete.
`;
}
/* CLI entry point — commander enforces <target-root> presence via the
command spec, so we don't re-validate it here. */
export function initProjectCommand(opts: {
targetRoot?: string;
id?: string;
force?: boolean;
}): void {
const workspaceDir = findWorkspaceRoot(process.cwd());
if (!workspaceDir) {
console.error(
`No .deepsec/ workspace found in or above ${process.cwd()}.\n` +
` Run \`deepsec init\` from your repo root first, then cd into .deepsec/\n` +
` before adding more projects.`,
);
process.exit(1);
}
if (!opts.targetRoot) {
// Defensive: commander should have caught this. Keeps the type checker happy.
process.exit(1);
}
let result: RegisterResult;
try {
result = registerProject({
workspaceDir,
targetRoot: opts.targetRoot,
id: opts.id,
force: opts.force,
});
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
console.log(
`${GREEN}✓${RESET} Added project ${BOLD}${result.id}${RESET} → ${result.targetRel}\n`,
);
console.log(
` ${YELLOW}Paste this into your coding agent${RESET} ${DIM}(Claude Code, Cursor, Codex, OpenCode, Pi, etc.):${RESET}`,
);
console.log();
printAgentPrompt(result.id, result.targetRel);
console.log();
console.log(` Then run: ${DIM}pnpm deepsec scan --project-id ${result.id}${RESET}`);
}
function printAgentPrompt(id: string, targetRel: string): void {
const lines = [
`Read node_modules/deepsec/SKILL.md to understand the tool. Then`,
`read data/${id}/SETUP.md and follow it: open ${targetRel}, skim`,
`its README + AGENTS.md/CLAUDE.md + a handful of representative`,
`code files, then replace each section of data/${id}/INFO.md.`,
``,
`Keep it SHORT — target 50–100 lines total. Pick 3–5 examples per`,
`section, not exhaustive enumeration. Name primitives (auth`,
`helpers, middleware) but no line numbers. Skip generic CWE`,
`categories — built-in matchers cover those. Cover only what's`,
`project-specific. INFO.md is injected into every scan batch;`,
`verbose context dilutes signal.`,
];
for (const l of lines) console.log(` ${CYAN}${l}${RESET}`);
}
import fs from "node:fs";
import path from "node:path";
import { BOLD, CYAN, DIM, GREEN, RESET, YELLOW } from "../formatters.js";
import { requireExistingDir } from "../require-dir.js";
import { getDeepsecVersion } from "../version.js";
import { PROJECTS_INSERT_MARKER, registerProject } from "./init-project.js";
const IGNORED_WORKSPACE_ENTRIES = new Set([".git", ".DS_Store"]);
interface InitOpts {
workspace?: string;
targetRoot?: string;
id?: string;
force?: boolean;
}
export function initCommand(opts: InitOpts) {
// Defaults: scaffold `.deepsec/` inside the current codebase, with the
// codebase itself as the first project. Override either by passing
// explicit positional args.
const workspaceArg = opts.workspace ?? ".deepsec";
const targetArg = opts.targetRoot ?? ".";
const workspaceDir = path.resolve(process.cwd(), workspaceArg);
let targetAbs: string;
try {
targetAbs = requireExistingDir(targetArg, "<target-root>");
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
if (fs.existsSync(workspaceDir)) {
const meaningful = fs
.readdirSync(workspaceDir)
.filter((e) => !IGNORED_WORKSPACE_ENTRIES.has(e));
if (meaningful.length > 0 && !opts.force) {
console.error(
`Workspace directory is not empty: ${workspaceDir}\n` +
`Use --force to write into a non-empty directory.`,
);
process.exit(1);
}
}
// Workspace skeleton: empty config (with marker), README, AGENTS, env.
fs.mkdirSync(workspaceDir, { recursive: true });
writeFile(workspaceDir, "package.json", packageJson(workspacePackageName(workspaceDir)));
// Sever .deepsec/ from any ancestor monorepo. npm and yarn walk up looking
// for a `package.json` with `workspaces` defined; pnpm walks up looking
// for a `pnpm-workspace.yaml`. Both stop at the first hit, so dropping a
// pnpm-workspace.yaml here (and an empty `workspaces: []` in package.json,
// see packageJson()) makes `.deepsec/` its own root regardless of what
// the parent repo defines. Lets `npm/yarn/pnpm install` from `.deepsec/`
// manage only this directory's deps.
writeFile(workspaceDir, "pnpm-workspace.yaml", pnpmWorkspaceYaml());
writeFile(workspaceDir, "deepsec.config.ts", emptyConfigTs());
writeFile(workspaceDir, "AGENTS.md", workspaceAgentsMd());
writeFile(workspaceDir, ".gitignore", gitignore());
// Register the first project via the shared code path. Same writes
// `init-project` would do: data/<id>/{project.json,INFO.md,SETUP.md}
// and append to projects[].
let registered: ReturnType<typeof registerProject>;
try {
registered = registerProject({
workspaceDir,
targetRoot: targetAbs,
id: opts.id,
force: opts.force,
});
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
// README references the project, so write it AFTER registration.
writeFile(workspaceDir, "README.md", readmeMd(registered.id, registered.targetRel));
const wsRel = path.relative(process.cwd(), workspaceDir) || ".";
console.log(`${GREEN}✓${RESET} Created ${BOLD}${workspaceDir}${RESET}`);
console.log(
` ${DIM}First project:${RESET} ${BOLD}${registered.id}${RESET} → ${registered.targetRel}\n`,
);
console.log("Next:\n");
if (wsRel !== ".") console.log(` cd ${wsRel}`);
console.log(` pnpm install ${DIM}# installs deepsec${RESET}`);
console.log(
` ${DIM}# Set AI_GATEWAY_API_KEY in .env.local (or skip if claude/codex CLI is logged in)${RESET}`,
);
console.log();
console.log(
` ${YELLOW}Paste this into your coding agent${RESET} ${DIM}(Claude Code, Cursor, Codex, OpenCode, Pi, etc.):${RESET}`,
);
console.log();
printAgentPrompt(registered.id, registered.targetRel);
console.log();
console.log(` Then run:`);
console.log(` pnpm deepsec scan`);
console.log(` pnpm deepsec process`);
console.log();
console.log(` ${DIM}# --project-id is auto-resolved while there's only one project.${RESET}`);
console.log(` ${DIM}# Register another codebase later: deepsec init-project <root>${RESET}`);
}
function printAgentPrompt(id: string, targetRel: string): void {
const lines = [
`Read node_modules/deepsec/SKILL.md to understand the tool. Then`,
`read data/${id}/SETUP.md and follow it: open ${targetRel}, skim`,
`its README + AGENTS.md/CLAUDE.md + a handful of representative`,
`code files, then replace each section of data/${id}/INFO.md.`,
``,
`Keep it SHORT — target 50–100 lines total. Pick 3–5 examples per`,
`section, not exhaustive enumeration. Name primitives (auth`,
`helpers, middleware) but no line numbers. Skip generic CWE`,
`categories — built-in matchers cover those. Cover only what's`,
`project-specific. INFO.md is injected into every scan batch;`,
`verbose context dilutes signal.`,
];
for (const l of lines) console.log(` ${CYAN}${l}${RESET}`);
}
function writeFile(dir: string, name: string, content: string) {
const p = path.join(dir, name);
if (fs.existsSync(p)) return;
fs.writeFileSync(p, content);
}
/**
* npm rejects package names starting with a dot. `.deepsec` (the default
* workspace dir) would land that way; sanitize it.
*/
function workspacePackageName(workspaceDir: string): string {
const base = path.basename(workspaceDir);
return base.startsWith(".") ? "deepsec-workspace" : base;
}
function packageJson(name: string): string {
// Pin the scaffolded dep to the version of deepsec running this
// `init`. Hardcoding a semver caret here silently rots every time
// we publish — the scaffolded dep would resolve against npm to
// whatever happens to match the literal string, which is not what
// a user typing `npx deepsec@latest init` expects.
const deepsecVersion = `^${getDeepsecVersion()}`;
return `${JSON.stringify(
{
name,
version: "0.1.0",
private: true,
description: "deepsec scanning workspace",
type: "module",
// Empty workspaces array marks this package.json as a workspace root
// for npm and yarn — both walk up looking for a package.json that
// declares `workspaces`, and stop at the first hit. Without this, a
// parent monorepo's workspace would absorb `.deepsec/`.
workspaces: [],
// Pin the package manager for this workspace. Without this, pnpm
// walks up looking for a `packageManager` field and adopts whatever
// it finds in an ancestor — so a parent repo declaring
// `"packageManager": "yarn@..."` makes `pnpm install` from
// `.deepsec/` refuse to run. Setting it here stops the walk at the
// workspace root.
packageManager: detectPackageManager(),
dependencies: { deepsec: deepsecVersion },
},
null,
2,
)}\n`;
}
/**
* Best-effort `packageManager` value for the scaffolded package.json.
*
* Tries the running pnpm/npm/yarn version (via `npm_config_user_agent`,
* which all three set when they spawn child processes). Falls back to a
* known-stable pnpm version so the resulting package.json is always
* valid even when init is run from a bare shell. The README / printed
* Next steps assume pnpm, so we always emit a `pnpm@*` value.
*/
function detectPackageManager(): string {
const ua = process.env.npm_config_user_agent;
if (ua) {
const m = ua.match(/pnpm\/(\d+\.\d+\.\d+)/);
if (m) return `pnpm@${m[1]}`;
}
// Recent pnpm 9 LTS — kept conservative; users with a newer pnpm
// installed will see a Corepack mismatch warning but installs work.
return "pnpm@9.15.4";
}
function pnpmWorkspaceYaml(): string {
// Mirror of the `workspaces: []` in package.json, for pnpm. pnpm reads
// this file (not package.json:workspaces) — empty `packages` list means
// ".deepsec/ is its own workspace root with no members beyond itself."
return `packages: []\n`;
}
/**
* Empty config with the insert marker. `init-project` (and the same code
* path called from `init`) appends new project entries above the marker.
*/
function emptyConfigTs(): string {
return `import { defineConfig } from "deepsec/config";
export default defineConfig({
projects: [
${PROJECTS_INSERT_MARKER}
],
});
`;
}
function readmeMd(id: string, targetRel: string): string {
return `# deepsec
This directory holds the [deepsec](https://www.npmjs.com/package/deepsec)
config for the parent repo. Checked into git so teammates inherit
project context (auth shape, threat model, custom matchers); generated
scan output is gitignored.
Currently configured project: \`${id}\` (target: \`${targetRel}\`).
## Setup
1. \`pnpm install\` — installs deepsec.
2. Add an AI Gateway / Anthropic / OpenAI token to \`.env.local\`. If
you already have \`claude\` or \`codex\` CLI logged in on this
machine, you can skip the token for non-sandbox runs (\`process\` /
\`revalidate\` / \`triage\`); deepsec auto-detects and reuses the
subscription. See
\`node_modules/deepsec/dist/docs/vercel-setup.md\` after install.
3. Open the parent repo in your coding agent (Claude Code, Cursor, …)
and have it follow \`data/${id}/SETUP.md\` to fill in
\`data/${id}/INFO.md\`.
## Daily commands
\`\`\`bash
pnpm deepsec scan
pnpm deepsec process --concurrency 5
pnpm deepsec revalidate --concurrency 5 # cuts FP rate
pnpm deepsec export --format md-dir --out ./findings
\`\`\`
\`--project-id\` is auto-resolved while there's only one project in
\`deepsec.config.ts\`. Once you've added a second project, pass
\`--project-id ${id}\` (or whichever id you want) explicitly.
\`scan\` is free (regex only). \`process\` is the AI stage (≈$0.30/file
on Opus by default). Run state goes to \`data/${id}/\`.
## Adding another project
To scan another codebase from this same \`.deepsec/\`:
\`\`\`bash
pnpm deepsec init-project ../some-other-package # path relative to .deepsec/
\`\`\`
Appends an entry to \`deepsec.config.ts\` and writes
\`data/<id>/{INFO.md,SETUP.md,project.json}\`. Open the new SETUP.md
in your agent to fill in INFO.md.
## Layout
\`\`\`
deepsec.config.ts Project list (one entry per scanned repo)
data/${id}/
INFO.md Repo context — checked into git, hand-curated
SETUP.md Agent setup prompt — checked in, deletable
project.json Generated (gitignored)
files/ One JSON per scanned source file (gitignored)
runs/ Run metadata (gitignored)
reports/ Generated markdown reports (gitignored)
AGENTS.md Pointer for coding agents
.env.local Tokens (gitignored)
\`\`\`
## Docs
After \`pnpm install\`:
- Skill: \`node_modules/deepsec/SKILL.md\`
- Full docs: \`node_modules/deepsec/dist/docs/{getting-started,configuration,models,writing-matchers,plugins,architecture,data-layout,vercel-setup,faq}.md\`
Or browse on
[GitHub](https://github.com/vercel/deepsec/tree/main/docs).
`;
}
/**
* Workspace-level AGENTS.md — generic pointer to per-project SETUP.md
* files and to the deepsec skill. Per-project setup prompts now live at
* `data/<id>/SETUP.md` (written by `init-project` / `init`).
*/
function workspaceAgentsMd(): string {
return `# Agent setup
This is a deepsec scanning workspace. Each registered project has its
own setup prompt at \`data/<id>/SETUP.md\` — open the relevant one when
asked to set a project up.
## Common tasks
- **Set up a project for scanning**: read \`data/<id>/SETUP.md\` and
follow it (read \`node_modules/deepsec/SKILL.md\`, then fill
\`data/<id>/INFO.md\` from the target codebase).
- **Add a new project**: run \`deepsec init-project <root>\` — it
scaffolds \`data/<id>/\` and prints/writes the setup prompt for the
new project.
- **Write a custom matcher** (only after a real true-positive shows you
a pattern worth keeping): read
\`node_modules/deepsec/dist/docs/writing-matchers.md\`.
## Reference
The deepsec skill is at \`node_modules/deepsec/SKILL.md\` (after
\`pnpm install\`). The full docs ship at
\`node_modules/deepsec/dist/docs/\`.
`;
}
function gitignore(): string {
// Keep curated files (INFO.md, SETUP.md) tracked so teammates inherit
// project context. Ignore the regenerable / heavy / sensitive bits.
return `node_modules/
.env*.local
# Scan output — regenerated by \`deepsec scan\` / \`process\`. INFO.md
# and SETUP.md (manually edited) stay tracked.
data/*/files/
data/*/runs/
data/*/reports/
data/*/project.json
`;
}
import type { Severity } from "@deepsec/core";
import { readProjectConfig } from "@deepsec/core";
import { triage } from "@deepsec/processor";
import { BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW } from "../formatters.js";
import { assertAgentCredential } from "../preflight.js";
import { resolveProjectId } from "../resolve-project-id.js";
export async function triageCommand(opts: {
projectId?: string;
severity?: string;
force?: boolean;
limit?: number;
concurrency?: number;
model?: string;
}) {
const projectId = resolveProjectId(opts.projectId);
readProjectConfig(projectId);
const severity = (opts.severity ?? "MEDIUM") as Severity;
const model = opts.model ?? "claude-sonnet-4-6";
// Triage uses Anthropic directly — no codex path here.
assertAgentCredential("claude-agent-sdk");
console.log(
`${BOLD}Triaging${RESET} ${severity} findings for project ${BOLD}${projectId}${RESET}`,
);
console.log(` Model: ${model} (lightweight — no code reading)`);
if (opts.force) console.log(` ${YELLOW}Force re-triaging already-triaged findings${RESET}`);
console.log();
const result = await triage({
projectId,
severity,
force: opts.force,
limit: opts.limit,
concurrency: opts.concurrency,
model,
onProgress(progress) {
switch (progress.type) {
case "batch_started":
console.log(`${BOLD}${progress.message}${RESET}`);
break;
case "batch_complete":
console.log(` ${DIM}${progress.message}${RESET}`);
break;
case "all_complete":
console.log(`\n${DIM}${progress.message}${RESET}`);
break;
}
},
});
console.log();
console.log(`${GREEN}Triage complete.${RESET}`);
console.log(
` ${RED}P0: ${result.p0}${RESET} ${YELLOW}P1: ${result.p1}${RESET} ${CYAN}P2: ${result.p2}${RESET} ${DIM}skip: ${result.skip}${RESET}`,
);
}
/**
* Public SDK surface for `deepsec` configuration files and plugin authors.
*
* Users write:
* import { defineConfig } from "deepsec/config";
*
* Plugin authors writing matchers can also import `regexMatcher` and the
* matcher-related types from here.
*/
export type {
AgentPluginRef,
AnalysisEntry,
CandidateMatch,
Confidence,
// Config
DeepsecConfig,
// Plugin contract
DeepsecPlugin,
ExecutorLaunchRequest,
ExecutorProvider,
ExecutorStatus,
FileRecord,
FileStatus,
// Domain types
Finding,
FindingNotification,
MatcherPlugin,
NoiseTier,
NotifierPlugin,
NotifyParams,
OwnershipApprover,
OwnershipContributor,
OwnershipData,
OwnershipEscalationTeam,
OwnershipProvider,
PeopleProvider,
Person,
ProjectConfig,
ProjectDeclaration,
RefusalReport,
Revalidation,
RevalidationVerdict,
RunMeta,
Severity,
Triage,
TriagePriority,
} from "@deepsec/core";
export {
defineConfig,
findProject,
getConfig,
getConfigPath,
getRegistry,
PluginRegistry,
setLoadedConfig,
} from "@deepsec/core";
export { createDefaultRegistry, MatcherRegistry, regexMatcher } from "@deepsec/scanner";
import type { Severity } from "@deepsec/core";
export function severityColor(severity: Severity): string {
switch (severity) {
case "CRITICAL":
return "\x1b[31m"; // red
case "HIGH":
return "\x1b[33m"; // yellow
case "MEDIUM":
return "\x1b[36m"; // cyan
case "HIGH_BUG":
return "\x1b[35m"; // magenta
case "BUG":
return "\x1b[35m"; // magenta
case "LOW":
return "\x1b[90m"; // bright black
}
}
export const RESET = "\x1b[0m";
export const BOLD = "\x1b[1m";
export const DIM = "\x1b[2m";
export const GREEN = "\x1b[32m";
export const RED = "\x1b[31m";
export const YELLOW = "\x1b[33m";
export const CYAN = "\x1b[36m";
export function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return `${m}m ${rs}s`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h ${rm}m`;
}
export function formatCount(n: number, label: string): string {
return `${n} ${label}${n === 1 ? "" : "s"}`;
}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"noEmit": true
},
"include": ["src"]
}
Related skills
FAQ
Where are deepsec docs located?
node_modules/deepsec/dist/docs/ or docs/ from a deepsec clone; read before answering.
What is the reference project layout?
samples/webapp has deepsec.config.ts, matchers/, INFO.md, and config.json as a complete example.
How do I add a custom matcher?
See writing-matchers.md and samples/webapp/matchers/*.ts for patterns.