diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4e99ff9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md + +Pointer file for Codex working in this repo. User-facing docs are +in [README.md](./README.md); contributor docs in +[CONTRIBUTING.md](./CONTRIBUTING.md). Read those first. + +## Repo shape + +``` +packages/ + core/ Types, schemas, plugin contracts, config loader (defineConfig) + scanner/ Regex matchers + scanning engine + processor/ AI agent integration (Codex Agent SDK, Codex SDK), enrich, triage, revalidate + deepsec/ Publishable package: bundled CLI + the `deepsec/config` sub-export + the @vercel/sandbox executor +e2e/ End-to-end tests +``` + +## Commands + +```bash +pnpm install +pnpm test # all packages, including e2e +pnpm test:unit # excludes e2e +pnpm -r build # tsc across all workspaces (typecheck) +pnpm bundle # esbuild bundle for distribution +pnpm deepsec ... # the CLI (runs via tsx) +``` + +## Patterns to keep in mind + +- Plugin contracts live in `packages/core/src/plugin.ts`. Internals route + through `getRegistry()` from `deepsec/config` rather than calling + organization-specific code directly. +- The CLI auto-loads `deepsec.config.{ts,mjs,js,cjs}` from cwd upward + (via `packages/deepsec/src/load-config.ts`, jiti). +- New matchers go in `packages/scanner/src/matchers/` and register in + `matchers/index.ts`. Org-specific matchers belong in a separate + plugin package, not in this tree. +- The AI prompt template lives in `packages/processor/src/index.ts`. It + is intentionally generic. Don't add organization-specific context + there; use `data//INFO.md` or `config.json:promptAppend`. diff --git a/docs/configuration.md b/docs/configuration.md index e0cb6ba..f549d12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -89,8 +89,13 @@ Some legacy fields still live in `data//config.json`: } ``` -This is read by `scan` and by the AI agents. It overrides the same fields -on the project declaration if both are present. +This is read by `scan` and by the AI agents. The project declaration in +`deepsec.config.ts` takes precedence over these legacy files when the same +field is present in both. The fallback order is: + +1. `deepsec.config.ts` project declaration (`infoMarkdown`, `promptAppend`, `priorityPaths`) +2. `data//config.json` / `data//INFO.md` +3. Defaults / omitted ## Environment variables @@ -115,7 +120,7 @@ backend you're using. | `OPENAI_API_KEY` | `--agent codex` | Codex SDK token. Unset is fine if `AI_GATEWAY_API_KEY` is set, or if Codex routes through AI Gateway with the Anthropic token. | | `OPENAI_BASE_URL` | `--agent codex` | Default (when `AI_GATEWAY_API_KEY` is set): `https://ai-gateway.vercel.sh/v1`. | | `DEEPSEC_AGENT_DEBUG` | both backends | Set to `1` to enable verbose agent logging. | -| `DEEPSEC_DATA_ROOT` | core | Override the data directory location. Equivalent to `dataDir` in config. | +| `DEEPSEC_DATA_ROOT` | core | Override the data directory location. Takes precedence over `dataDir` in config. | ### Plugin-specific diff --git a/packages/core/src/__tests__/paths.test.ts b/packages/core/src/__tests__/paths.test.ts index c81edc3..88d60b7 100644 --- a/packages/core/src/__tests__/paths.test.ts +++ b/packages/core/src/__tests__/paths.test.ts @@ -1,6 +1,7 @@ import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { dataDir, fileRecordPath, filesDir, runMetaPath, runsDir } from "../paths.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { defineConfig, setLoadedConfig } from "../config.js"; +import { dataDir, fileRecordPath, filesDir, getDataRoot, runMetaPath, runsDir } from "../paths.js"; describe("paths", () => { // path.join uses native separators (\\ on Windows, / elsewhere). Build @@ -26,6 +27,33 @@ describe("paths", () => { // Path-traversal protection — any segment that could escape the per-project // mirror (`..`, absolute paths, separators, null bytes) must throw, since // these are the documented sandbox-round-trip and CLI-flag attack vectors. + describe("getDataRoot", () => { + afterEach(() => { + delete process.env.DEEPSEC_DATA_ROOT; + setLoadedConfig(defineConfig({ projects: [] })); + }); + + it("defaults to 'data'", () => { + expect(getDataRoot()).toBe("data"); + }); + + it("respects DEEPSEC_DATA_ROOT env var", () => { + process.env.DEEPSEC_DATA_ROOT = "/custom/data"; + expect(getDataRoot()).toBe("/custom/data"); + }); + + it("respects config dataDir when env var is absent", () => { + setLoadedConfig(defineConfig({ projects: [], dataDir: "/cfg/data" })); + expect(getDataRoot()).toBe("/cfg/data"); + }); + + it("prefers env var over config dataDir", () => { + process.env.DEEPSEC_DATA_ROOT = "/env/data"; + setLoadedConfig(defineConfig({ projects: [], dataDir: "/cfg/data" })); + expect(getDataRoot()).toBe("/env/data"); + }); + }); + describe("path traversal", () => { it("dataDir rejects '..' projectId", () => { expect(() => dataDir("..")).toThrow(/Invalid projectId/); diff --git a/packages/core/src/paths.ts b/packages/core/src/paths.ts index 64692d3..5c87512 100644 --- a/packages/core/src/paths.ts +++ b/packages/core/src/paths.ts @@ -1,7 +1,8 @@ import path from "node:path"; +import { getConfig } from "./config.js"; export function getDataRoot(): string { - return process.env.DEEPSEC_DATA_ROOT || "data"; + return process.env.DEEPSEC_DATA_ROOT || getConfig()?.dataDir || "data"; } // Reject empty, '.', '..', absolute paths, null bytes, and any path diff --git a/packages/processor/package.json b/packages/processor/package.json index b674c58..c1019aa 100644 --- a/packages/processor/package.json +++ b/packages/processor/package.json @@ -13,6 +13,7 @@ "@openai/codex": "^0.125.0", "@openai/codex-sdk": "^0.125.0", "@deepsec/core": "workspace:*", - "@deepsec/scanner": "workspace:*" + "@deepsec/scanner": "workspace:*", + "zod": "^3.24.0" } } diff --git a/packages/processor/src/__tests__/process-revalidate.test.ts b/packages/processor/src/__tests__/process-revalidate.test.ts index 8c7dfe4..d3474b3 100644 --- a/packages/processor/src/__tests__/process-revalidate.test.ts +++ b/packages/processor/src/__tests__/process-revalidate.test.ts @@ -123,6 +123,54 @@ describe("processor with stub agent", () => { expect(rec.lockedByRunId).toBeFalsy(); }); + it("process() prefers config project declaration over legacy data files", async () => { + const fx = setupProject({ files: ["app.ts"] }); + fx.writeRecord(pendingRecord(fx.projectId, "app.ts")); + + fs.writeFileSync(path.join(fx.dataRoot, fx.projectId, "INFO.md"), "from info.md"); + fs.writeFileSync( + path.join(fx.dataRoot, fx.projectId, "config.json"), + JSON.stringify({ promptAppend: "from config.json" }), + ); + + const stub = new StubAgent({ + investigateImpl: async function* (params) { + return { + results: params.batch.map((rec) => ({ + filePath: rec.filePath, + findings: [], + })), + meta: { durationMs: 1 }, + }; + }, + }); + + setLoadedConfig( + defineConfig({ + projects: [ + { + id: fx.projectId, + root: fx.targetRoot, + infoMarkdown: "from config ts", + promptAppend: "from config ts", + }, + ], + plugins: [{ name: "stub", agents: [stub] }], + }), + ); + + await processProject({ + projectId: fx.projectId, + agentType: "stub", + concurrency: 1, + }); + + const prompt = stub.calls.investigateCalls[0].promptTemplate; + expect(prompt).toContain("from config ts"); + expect(prompt).not.toContain("from info.md"); + expect(prompt).not.toContain("from config.json"); + }); + it("process() respects --limit", async () => { const fx = setupProject({ files: ["a.ts", "b.ts", "c.ts"] }); fx.writeRecord(pendingRecord(fx.projectId, "a.ts")); diff --git a/packages/processor/src/__tests__/shared.test.ts b/packages/processor/src/__tests__/shared.test.ts index 30c9161..973c834 100644 --- a/packages/processor/src/__tests__/shared.test.ts +++ b/packages/processor/src/__tests__/shared.test.ts @@ -262,7 +262,8 @@ describe("parseInvestigateResults", () => { const batch = [{ filePath: "a.ts" } as any, { filePath: "b.ts" } as any]; it("matches results to batch files; fills missing with empty findings", () => { - const text = '```json\n[{"filePath":"a.ts","findings":[{"severity":"HIGH"}]}]\n```'; + const text = + '```json\n[{"filePath":"a.ts","findings":[{"severity":"HIGH","vulnSlug":"x","title":"t","description":"d","lineNumbers":[1],"recommendation":"r","confidence":"high"}]}]\n```'; const out = parseInvestigateResults(text, batch); expect(out.find((r) => r.filePath === "a.ts")?.findings.length).toBe(1); expect(out.find((r) => r.filePath === "b.ts")?.findings).toEqual([]); @@ -284,6 +285,24 @@ describe("parseInvestigateResults", () => { /not an array/, ); }); + + it("throws when a finding has invalid severity", () => { + const text = + '```json\n[{"filePath":"a.ts","findings":[{"severity":"INVALID","vulnSlug":"x","title":"t","description":"d","lineNumbers":[1],"recommendation":"r","confidence":"high"}]}]\n```'; + expect(() => parseInvestigateResults(text, batch)).toThrow(/schema validation/); + }); + + it("throws when a finding has invalid confidence", () => { + const text = + '```json\n[{"filePath":"a.ts","findings":[{"severity":"HIGH","vulnSlug":"x","title":"t","description":"d","lineNumbers":[1],"recommendation":"r","confidence":"INVALID"}]}]\n```'; + expect(() => parseInvestigateResults(text, batch)).toThrow(/schema validation/); + }); + + it("throws when a finding is missing a required field", () => { + const text = + '```json\n[{"filePath":"a.ts","findings":[{"severity":"HIGH","vulnSlug":"x"}]}]\n```'; + expect(() => parseInvestigateResults(text, batch)).toThrow(/schema validation/); + }); }); describe("parseRevalidateVerdicts", () => { @@ -298,4 +317,10 @@ describe("parseRevalidateVerdicts", () => { it("throws on parse failure", () => { expect(() => parseRevalidateVerdicts("garbage")).toThrow(/wasn't parseable JSON/); }); + + it("throws when verdict is invalid", () => { + const text = + '```json\n[{"filePath":"a.ts","title":"x","verdict":"INVALID","reasoning":"r"}]\n```'; + expect(() => parseRevalidateVerdicts(text)).toThrow(/schema validation/); + }); }); diff --git a/packages/processor/src/agents/shared.ts b/packages/processor/src/agents/shared.ts index f457c1f..b47bd90 100644 --- a/packages/processor/src/agents/shared.ts +++ b/packages/processor/src/agents/shared.ts @@ -1,5 +1,7 @@ import { spawnSync } from "node:child_process"; -import type { FileRecord, Finding, RefusalReport } from "@deepsec/core"; +import type { FileRecord, RefusalReport } from "@deepsec/core"; +import { findingSchema } from "@deepsec/core"; +import { z } from "zod"; import type { InvestigateResult, RevalidateVerdict } from "./types.js"; // --- Retry / backoff ------------------------------------------------------- @@ -418,15 +420,23 @@ export function parseInvestigateResults( throw new Error(`Agent produced JSON but not an array of file findings. Got: ${typeof parsed}`); } - const typedParsed = parsed as Array<{ filePath: string; findings: Finding[] }>; + const investigateResultSchema = z.object({ + filePath: z.string(), + findings: z.array(findingSchema), + }); + const validation = z.array(investigateResultSchema).safeParse(parsed); + if (!validation.success) { + throw new Error(`Agent findings failed schema validation: ${validation.error.message}`); + } + const results: InvestigateResult[] = []; const batchPaths = new Set(batch.map((r) => r.filePath)); - for (const entry of typedParsed) { + for (const entry of validation.data) { if (batchPaths.has(entry.filePath)) { results.push({ filePath: entry.filePath, - findings: entry.findings || [], + findings: entry.findings, }); batchPaths.delete(entry.filePath); } @@ -568,5 +578,19 @@ export function parseRevalidateVerdicts(resultText: string): RevalidateVerdict[] if (!Array.isArray(parsed)) { throw new Error(`Agent produced revalidation JSON but not an array. Got: ${typeof parsed}`); } - return parsed as RevalidateVerdict[]; + + const revalidateVerdictSchema = z.object({ + filePath: z.string(), + title: z.string(), + verdict: z.enum(["true-positive", "false-positive", "fixed", "uncertain"]), + reasoning: z.string(), + adjustedSeverity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "HIGH_BUG", "BUG"]).optional(), + }); + const validation = z.array(revalidateVerdictSchema).safeParse(parsed); + if (!validation.success) { + throw new Error( + `Agent revalidation verdicts failed schema validation: ${validation.error.message}`, + ); + } + return validation.data as RevalidateVerdict[]; } diff --git a/packages/processor/src/index.ts b/packages/processor/src/index.ts index 160b714..526e683 100644 --- a/packages/processor/src/index.ts +++ b/packages/processor/src/index.ts @@ -7,6 +7,7 @@ import { createRunMeta, dataDir, defaultConcurrency, + findProject, getRegistry, loadAllFileRecords, readFileRecord, @@ -179,7 +180,11 @@ export async function process(params: { manifestFilePaths = new Set(raw as string[]); } - // Load project INFO.md if it exists + // Resolve project context with clear precedence: + // 1. deepsec.config.ts project declaration (modern source of truth) + // 2. Legacy data//INFO.md and data//config.json + const projectDecl = findProject(projectId); + const infoPath = path.join(dataDir(projectId), "INFO.md"); let projectInfo = ""; try { @@ -187,6 +192,9 @@ export async function process(params: { } catch { // No INFO.md — that's fine } + if (projectDecl?.infoMarkdown !== undefined) { + projectInfo = projectDecl.infoMarkdown; + } // Load project config.json for prompt customization and priority const projectConfigJsonPath = path.join(dataDir(projectId), "config.json"); @@ -199,6 +207,12 @@ export async function process(params: { } catch { // No config.json — that's fine } + if (projectDecl?.promptAppend !== undefined) { + projectConfig.promptAppend = projectDecl.promptAppend; + } + if (projectDecl?.priorityPaths !== undefined) { + projectConfig.priorityPaths = projectDecl.priorityPaths; + } // Tech detection result drives per-batch threat highlights. Read once // from `data//tech.json` (written by `scan()`); empty list when the @@ -837,11 +851,16 @@ export async function revalidate(params: { manifestFilePaths = new Set(raw as string[]); } + const projectDecl = findProject(projectId); + const infoPath = path.join(dataDir(projectId), "INFO.md"); let projectInfo = ""; try { projectInfo = fs.readFileSync(infoPath, "utf-8"); } catch {} + if (projectDecl?.infoMarkdown !== undefined) { + projectInfo = projectDecl.infoMarkdown; + } const model = (config.model as string) ?? "claude-opus-4-7"; diff --git a/packages/scanner/src/__tests__/scan-files.test.ts b/packages/scanner/src/__tests__/scan-files.test.ts index 7f85987..f1a25dd 100644 --- a/packages/scanner/src/__tests__/scan-files.test.ts +++ b/packages/scanner/src/__tests__/scan-files.test.ts @@ -1,7 +1,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { loadAllFileRecords, readFileRecord, readRunMeta } from "@deepsec/core"; +import { + defineConfig, + loadAllFileRecords, + readFileRecord, + readRunMeta, + setLoadedConfig, + writeFileRecord, +} from "@deepsec/core"; import { afterEach, describe, expect, it } from "vitest"; import { scanFiles } from "../index.js"; @@ -103,4 +110,66 @@ describe("scanFiles()", () => { const after = readFileRecord(projectId, "src/x.ts")!; expect(after.candidates.length).toBe(candCountBefore); }); + + it("resets findings and status when file content changes", async () => { + const { root, projectId } = makeProject({ + "src/x.ts": 'const q = "SELECT * FROM users WHERE id = " + req.query.id;\n', + }); + + await scanFiles({ projectId, root, filePaths: ["src/x.ts"] }); + const first = readFileRecord(projectId, "src/x.ts")!; + first.status = "analyzed"; + first.findings = [ + { + severity: "HIGH", + vulnSlug: "sql-injection", + title: "t", + description: "d", + lineNumbers: [1], + recommendation: "r", + confidence: "high", + }, + ]; + writeFileRecord(first); + + // Change file content + fs.writeFileSync(path.join(root, "src/x.ts"), "const x = 1;\n"); + + await scanFiles({ projectId, root, filePaths: ["src/x.ts"] }); + const second = readFileRecord(projectId, "src/x.ts")!; + expect(second.status).toBe("pending"); + expect(second.findings).toEqual([]); + }); + + it("applies config matchers.only", async () => { + const { root, projectId } = makeProject({ + "src/x.ts": 'const q = "SELECT * FROM users WHERE id = " + req.query.id;\n', + }); + setLoadedConfig( + defineConfig({ + projects: [{ id: projectId, root }], + matchers: { only: ["sql-injection"] }, + }), + ); + + await scanFiles({ projectId, root, filePaths: ["src/x.ts"] }); + const rec = readFileRecord(projectId, "src/x.ts")!; + expect(rec.candidates.some((c) => c.vulnSlug === "sql-injection")).toBe(true); + }); + + it("applies config matchers.exclude", async () => { + const { root, projectId } = makeProject({ + "src/x.ts": 'const q = "SELECT * FROM users WHERE id = " + req.query.id;\n', + }); + setLoadedConfig( + defineConfig({ + projects: [{ id: projectId, root }], + matchers: { exclude: ["sql-injection"] }, + }), + ); + + await scanFiles({ projectId, root, filePaths: ["src/x.ts"] }); + const rec = readFileRecord(projectId, "src/x.ts")!; + expect(rec.candidates).toEqual([]); + }); }); diff --git a/packages/scanner/src/__tests__/scan.test.ts b/packages/scanner/src/__tests__/scan.test.ts new file mode 100644 index 0000000..08f5e5d --- /dev/null +++ b/packages/scanner/src/__tests__/scan.test.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { readFileRecord, writeFileRecord } from "@deepsec/core"; +import { afterEach, describe, expect, it } from "vitest"; +import { scan } from "../index.js"; + +let cleanups: Array<() => void> = []; + +afterEach(() => { + for (const c of cleanups.reverse()) c(); + cleanups = []; +}); + +function makeProject(files: Record): { root: string; projectId: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-scan-")); + const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-data-")); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + process.env.DEEPSEC_DATA_ROOT = dataRoot; + const projectId = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + cleanups.push(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(dataRoot, { recursive: true, force: true }); + delete process.env.DEEPSEC_DATA_ROOT; + }); + return { root, projectId }; +} + +describe("scan()", () => { + it("resets findings and status when file content changes", async () => { + const { root, projectId } = makeProject({ + "src/x.ts": 'const q = "SELECT * FROM users WHERE id = " + req.query.id;\n', + }); + + await scan({ projectId, root }); + const first = readFileRecord(projectId, "src/x.ts")!; + expect(first.status).toBe("pending"); + expect(first.candidates.length).toBeGreaterThan(0); + + // Mark as analyzed with a finding + first.status = "analyzed"; + first.findings = [ + { + severity: "HIGH", + vulnSlug: "sql-injection", + title: "t", + description: "d", + lineNumbers: [1], + recommendation: "r", + confidence: "high", + }, + ]; + writeFileRecord(first); + + // Change content + fs.writeFileSync(path.join(root, "src/x.ts"), "const x = 1;\n"); + + await scan({ projectId, root }); + const second = readFileRecord(projectId, "src/x.ts")!; + expect(second.status).toBe("pending"); + expect(second.findings).toEqual([]); + }); + + it("clears candidates and findings for deleted files", async () => { + const { root, projectId } = makeProject({ + "src/x.ts": 'const q = "SELECT * FROM users WHERE id = " + req.query.id;\n', + }); + + await scan({ projectId, root }); + const first = readFileRecord(projectId, "src/x.ts")!; + expect(first.candidates.length).toBeGreaterThan(0); + + // Delete the file + fs.rmSync(path.join(root, "src/x.ts")); + + await scan({ projectId, root }); + const second = readFileRecord(projectId, "src/x.ts")!; + expect(second.candidates).toEqual([]); + expect(second.findings).toEqual([]); + expect(second.status).toBe("pending"); + }); + + it("clears candidates and findings for files that no longer match", async () => { + const { root, projectId } = makeProject({ + "src/x.ts": 'const q = "SELECT * FROM users WHERE id = " + req.query.id;\n', + }); + + await scan({ projectId, root }); + const first = readFileRecord(projectId, "src/x.ts")!; + expect(first.candidates.length).toBeGreaterThan(0); + + // Change content so it no longer matches + fs.writeFileSync(path.join(root, "src/x.ts"), "const x = 1;\n"); + + await scan({ projectId, root }); + const second = readFileRecord(projectId, "src/x.ts")!; + expect(second.candidates).toEqual([]); + expect(second.findings).toEqual([]); + expect(second.status).toBe("pending"); + }); +}); diff --git a/packages/scanner/src/index.ts b/packages/scanner/src/index.ts index d953f08..087f02f 100644 --- a/packages/scanner/src/index.ts +++ b/packages/scanner/src/index.ts @@ -7,7 +7,9 @@ import { createRunMeta, dataDir, ensureProject, + getConfig, getRegistry, + loadAllFileRecords, readFileRecord, writeFileRecord, writeRunMeta, @@ -267,13 +269,18 @@ export class RegexScannerDriver implements ScannerDriver { const _stat = fs.statSync(path.join(root, relPath)); const hash = crypto.createHash("sha256").update(content).digest("hex"); + // If content changed since the last scan, previous findings are stale. + // Preserve analysisHistory but reset current findings so the file + // gets reprocessed. + if (record.fileHash && record.fileHash !== hash) { + record.findings = []; + record.status = "pending"; + } + record.lastScannedAt = new Date().toISOString(); record.lastScannedRunId = runId; record.fileHash = hash; - // Only reset to pending if not already analyzed - // (re-scanning doesn't invalidate previous analysis) - yield { type: "file_scanned", message: `Found ${matches.length} match(es) in ${relPath}`, @@ -292,6 +299,36 @@ export class RegexScannerDriver implements ScannerDriver { }; } + // Handle files that were previously recorded but not touched this scan: + // either deleted from disk or no longer matching any pattern. Clear their + // candidates and findings so they don't continue to appear as active. + const allExisting = loadAllFileRecords(projectId); + for (const existing of allExisting) { + if (upserted.has(existing.filePath)) continue; + + const absPath = path.join(root, existing.filePath); + const stillExists = fs.existsSync(absPath) && fs.statSync(absPath).isFile(); + let newHash = ""; + if (stillExists) { + try { + newHash = crypto + .createHash("sha256") + .update(fs.readFileSync(absPath, "utf-8").replaceAll("\r\n", "\n")) + .digest("hex"); + } catch { + // unreadable + } + } + + existing.candidates = []; + existing.findings = []; + existing.status = "pending"; + existing.fileHash = newHash; + existing.lastScannedAt = new Date().toISOString(); + existing.lastScannedRunId = runId; + writeFileRecord(existing); + } + // Write all upserted records to disk for (const record of upserted.values()) { writeFileRecord(record); @@ -367,10 +404,20 @@ export async function scan(params: { languageStats: LanguageStats[]; }> { const registry = buildMergedRegistry(); - const allSelected = params.matcherSlugs + let allSelected = params.matcherSlugs ? registry.getBySlugs(params.matcherSlugs) : registry.getAll(); + if (!params.matcherSlugs) { + const cfg = getConfig()?.matchers; + if (cfg?.only && cfg.only.length > 0) { + allSelected = registry.getBySlugs(cfg.only); + } else if (cfg?.exclude && cfg.exclude.length > 0) { + const excludeSet = new Set(cfg.exclude); + allSelected = allSelected.filter((m) => !excludeSet.has(m.slug)); + } + } + if (allSelected.length === 0) { throw new Error("No matchers selected"); } @@ -550,10 +597,20 @@ export async function scanFiles(params: { skippedMatchers: string[]; }> { const registry = buildMergedRegistry(); - const allSelected = params.matcherSlugs + let allSelected = params.matcherSlugs ? registry.getBySlugs(params.matcherSlugs) : registry.getAll(); + if (!params.matcherSlugs) { + const cfg = getConfig()?.matchers; + if (cfg?.only && cfg.only.length > 0) { + allSelected = registry.getBySlugs(cfg.only); + } else if (cfg?.exclude && cfg.exclude.length > 0) { + const excludeSet = new Set(cfg.exclude); + allSelected = allSelected.filter((m) => !excludeSet.has(m.slug)); + } + } + if (allSelected.length === 0) { throw new Error("No matchers selected"); } @@ -634,8 +691,9 @@ export async function scanFiles(params: { // Load-or-create the FileRecord. Existing candidates from prior scans // are preserved — we merge new matches in, never overwrite. + const existing = readFileRecord(params.projectId, relPath); const record = - readFileRecord(params.projectId, relPath) ?? + existing ?? ({ filePath: relPath, projectId: params.projectId, @@ -667,6 +725,12 @@ export async function scanFiles(params: { } } + // If content changed since the last scan, previous findings are stale. + if (existing && existing.fileHash !== hash) { + record.findings = []; + record.status = "pending"; + } + record.lastScannedAt = new Date().toISOString(); record.lastScannedRunId = meta.runId; record.fileHash = hash; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b5495e..334633d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,7 +90,7 @@ importers: dependencies: '@anthropic-ai/claude-agent-sdk': specifier: ^0.2.119 - version: 0.2.123(zod@4.4.1) + version: 0.2.123(zod@3.25.76) '@deepsec/core': specifier: workspace:* version: link:../core @@ -103,6 +103,9 @@ importers: '@openai/codex-sdk': specifier: ^0.125.0 version: 0.125.0 + zod: + specifier: ^3.24.0 + version: 3.25.76 packages/scanner: dependencies: @@ -182,6 +185,29 @@ packages: dev: false optional: true + /@anthropic-ai/claude-agent-sdk@0.2.123(zod@3.25.76): + resolution: {integrity: sha512-a4TysYoR9DBdkM9Uwh4J5ub7TwKmRPe5hFiWh4En+IKC+qkk5UFkxFM22c//cZjYZKynHX0ah2t6LUqb+najYA==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^4.0.0 + dependencies: + '@anthropic-ai/sdk': 0.81.0(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + zod: 3.25.76 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.2.123 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.2.123 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.2.123 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.2.123 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.2.123 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.2.123 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.2.123 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.2.123 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + dev: false + /@anthropic-ai/claude-agent-sdk@0.2.123(zod@4.4.1): resolution: {integrity: sha512-a4TysYoR9DBdkM9Uwh4J5ub7TwKmRPe5hFiWh4En+IKC+qkk5UFkxFM22c//cZjYZKynHX0ah2t6LUqb+najYA==} engines: {node: '>=18.0.0'} @@ -205,6 +231,19 @@ packages: - supports-color dev: false + /@anthropic-ai/sdk@0.81.0(zod@3.25.76): + resolution: {integrity: sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + dependencies: + json-schema-to-ts: 3.1.1 + zod: 3.25.76 + dev: false + /@anthropic-ai/sdk@0.81.0(zod@4.4.1): resolution: {integrity: sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==} hasBin: true @@ -826,6 +865,37 @@ packages: resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} dev: true + /@modelcontextprotocol/sdk@1.29.0(zod@3.25.76): + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.16) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.4.1(express@5.2.1) + hono: 4.12.16 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + dev: false + /@modelcontextprotocol/sdk@1.29.0(zod@4.4.1): resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -3156,6 +3226,14 @@ packages: yargs-parser: 21.1.1 dev: true + /zod-to-json-schema@3.25.2(zod@3.25.76): + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + dependencies: + zod: 3.25.76 + dev: false + /zod-to-json-schema@3.25.2(zod@4.4.1): resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1aa1c8e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: ["packages/*/vitest.config.ts", "plugins/*/vitest.config.ts", "e2e/vitest.config.ts"], + }, +}); diff --git a/vitest.workspace.ts b/vitest.workspace.ts deleted file mode 100644 index 352f138..0000000 --- a/vitest.workspace.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default [ - "packages/*/vitest.config.ts", - "plugins/*/vitest.config.ts", - "e2e/vitest.config.ts", -];