import { parametersSchema as z, defineCustomTool, CustomToolContext } from "@roo-code/types" // @ts-ignore - Node built-ins import { spawn } from "child_process" // @ts-ignore - Node built-ins import { existsSync, mkdirSync, renameSync, createWriteStream, readFileSync, writeFileSync, WriteStream } from "fs" // @ts-ignore - Node built-ins import path from "path" // @ts-ignore - Node built-ins import { spawnSync } from "child_process" export default defineCustomTool({ name: "snyk_scan", description: "Run Snyk security scans (SCA + SAST) on a project and produce a Zusammenfassung JSON compatible with Sven's Copilot prompt chain. Requires `snyk` CLI authenticated locally.", parameters: z.object({ projectRoot: z.string().describe("Absolute or relative path to the project root (must be a git repo)"), mode: z.enum(["sca", "code", "both"]).optional().describe("Which scan(s) to run (default: both)"), severityThreshold: z.enum(["low", "medium", "high", "critical"]).optional().describe("Severity threshold for SAST scan (default: low)"), outputDir: z.string().optional().describe("Output directory for reports (default: /.snyk-reports/)"), includeIgnores: z.boolean().optional().describe("Pass --include-ignores to snyk (default: true)"), timeoutSec: z.number().optional().describe("Overall timeout in seconds (default: 600)"), }), async execute( { projectRoot, mode = "both", severityThreshold = "low", outputDir, includeIgnores = true, timeoutSec = 600 }, context: CustomToolContext ) { try { // --- Resolve projectRoot --- // @ts-ignore - task.cwd exists at runtime const cwd = context?.task?.cwd ?? process.cwd() const resolvedRoot = path.isAbsolute(projectRoot) ? projectRoot : path.resolve(cwd, projectRoot) if (!existsSync(resolvedRoot)) { return JSON.stringify({ ok: false, error: `Project root does not exist: ${resolvedRoot}` }, null, 2) } // --- Auth check --- const whoami = spawnSync("snyk", ["whoami"], { encoding: "utf-8", timeout: 15_000 }) if (whoami.status !== 0) { return JSON.stringify({ ok: false, error: "Snyk not authenticated. Run 'snyk auth' first." }, null, 2) } // --- Git branch --- const gitBranch = spawnSync("git", ["-C", resolvedRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8", timeout: 10_000, }) if (gitBranch.status !== 0) { return JSON.stringify({ ok: false, error: `Not a git repository: ${resolvedRoot}` }, null, 2) } const branch = (gitBranch.stdout || "").trim() const branchSafe = branch.replace(/[/\\:*?"<>|]/g, "_") // --- Timestamp --- const now = new Date() const pad = (n: number) => String(n).padStart(2, "0") const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}` // --- OutputDir resolution --- const baseName = path.basename(resolvedRoot) const resolvedOutputDir = outputDir ? (path.isAbsolute(outputDir) ? outputDir : path.resolve(cwd, outputDir)) : path.join(resolvedRoot, ".snyk-reports", baseName) if (!existsSync(resolvedOutputDir)) { mkdirSync(resolvedOutputDir, { recursive: true }) } // --- File paths --- const scaJsonPath = path.join(resolvedOutputDir, `snyk-sca_${branchSafe}_${timestamp}.json`) const codeJsonPath = path.join(resolvedOutputDir, `snyk-code_${branchSafe}_${timestamp}.json`) const summaryPath = path.join(resolvedOutputDir, `snyk-zusammenfassung_${branchSafe}_${timestamp}.json`) const warnings: string[] = [] const commands: { sca: string | null; code: string | null } = { sca: null, code: null } const exitCodes: { sca: number | null; code: number | null } = { sca: null, code: null } // --- Maven Wrapper hack --- const hasPom = existsSync(path.join(resolvedRoot, "pom.xml")) const wrapperFiles: { original: string; backup: string }[] = [] function disableMavenWrapper() { if (!hasPom) return for (const candidate of ["mvnw", "mvnw.cmd"]) { const original = path.join(resolvedRoot, candidate) if (existsSync(original)) { const backup = `${original}.snyk-disabled` renameSync(original, backup) wrapperFiles.push({ original, backup }) } } if (wrapperFiles.length > 0) { warnings.push("Maven Wrapper temporarily disabled during scan") } } function restoreMavenWrapper() { for (const { original, backup } of wrapperFiles) { if (existsSync(backup)) { renameSync(backup, original) } } } // --- Spawn helper: stream stdout to file, capture for parsing --- function runSnyk(args: string[], outPath: string, abortSignal: AbortSignal): Promise<{ exitCode: number; outputPath: string }> { return new Promise((resolve, reject) => { if (abortSignal.aborted) { return reject(new Error("Timeout")) } const proc = spawn("snyk", args, { cwd: resolvedRoot, stdio: ["ignore", "pipe", "pipe"], }) const ws: WriteStream = createWriteStream(outPath, { encoding: "utf-8" }) proc.stdout.pipe(ws) // Also capture stderr into the same file (snyk mixes channels) proc.stderr.pipe(ws, { end: false }) const onAbort = () => { proc.kill("SIGTERM") reject(new Error("Timeout")) } abortSignal.addEventListener("abort", onAbort, { once: true }) proc.on("error", (err: Error) => { abortSignal.removeEventListener("abort", onAbort) reject(err) }) proc.on("close", (code: number | null) => { abortSignal.removeEventListener("abort", onAbort) ws.end(() => { resolve({ exitCode: code ?? 1, outputPath: outPath }) }) }) }) } // --- Exit code logic (port of Test-IsSnykFailure) --- function isSnykFailure(exitCode: number, outputPath: string, scanMode: "SCA" | "CODE"): boolean { if (exitCode === 0) return false if (exitCode > 1) return true // Exit 1 — usually means "findings detected". Parse to confirm. let text: string try { text = readFileSync(outputPath, "utf-8") } catch { return true // Can't read output → treat as failure } let parsed: any try { parsed = JSON.parse(text) } catch { return true // Not valid JSON → failure } if (scanMode === "SCA") { if (Array.isArray(parsed)) return false if (parsed && typeof parsed === "object" && "error" in parsed) return true return false } // CODE mode — SARIF format if (parsed && typeof parsed === "object" && "runs" in parsed) return false if (parsed && typeof parsed === "object" && "error" in parsed) return true return true } // --- Run scans --- const controller = new AbortController() const timeoutHandle = setTimeout(() => controller.abort(), timeoutSec * 1000) try { disableMavenWrapper() // SCA scan if (mode === "sca" || mode === "both") { const scaArgs = ["test", "--all-projects", "--json"] if (includeIgnores) scaArgs.push("--include-ignores") if (hasPom) scaArgs.push("--command=mvn") commands.sca = `snyk ${scaArgs.join(" ")}` const scaResult = await runSnyk(scaArgs, scaJsonPath, controller.signal) exitCodes.sca = scaResult.exitCode if (isSnykFailure(scaResult.exitCode, scaJsonPath, "SCA")) { warnings.push(`SCA scan failed (exit ${scaResult.exitCode}). Check ${scaJsonPath}`) } } // SAST scan if (mode === "code" || mode === "both") { const codeArgs = ["code", "test", ".", `--severity-threshold=${severityThreshold}`, "--json"] if (includeIgnores) codeArgs.push("--include-ignores") commands.code = `snyk ${codeArgs.join(" ")}` const codeResult = await runSnyk(codeArgs, codeJsonPath, controller.signal) exitCodes.code = codeResult.exitCode if (isSnykFailure(codeResult.exitCode, codeJsonPath, "CODE")) { warnings.push(`SAST scan failed (exit ${codeResult.exitCode}). Check ${codeJsonPath}`) } } } finally { clearTimeout(timeoutHandle) restoreMavenWrapper() } // --- Build Zusammenfassung --- function getGroupedSCA(jsonPath: string): any[] { if (!existsSync(jsonPath)) return [] let raw: string try { raw = readFileSync(jsonPath, "utf-8") } catch { return [] } let data: any try { data = JSON.parse(raw) } catch { return [] } // Collect all vulnerabilities let vulnerabilities: any[] = [] if (Array.isArray(data)) { for (const project of data) { if (project && Array.isArray(project.vulnerabilities)) { vulnerabilities.push(...project.vulnerabilities) } } } else if (data && Array.isArray(data.vulnerabilities)) { vulnerabilities = data.vulnerabilities } if (vulnerabilities.length === 0) return [] // Group by id const groups: Record = {} for (const vuln of vulnerabilities) { const id = vuln.id || "unknown" if (!groups[id]) groups[id] = [] groups[id].push(vuln) } const result: any[] = [] for (const [id, group] of Object.entries(groups)) { const first = group[0] result.push({ id, title: first.title || "", severity: first.severity || "unknown", packageName: first.packageName || "", version: first.version || "", count: group.length, type: "SCA", }) } // Sort by severity then id const sevOrder: Record = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 } result.sort((a, b) => { const sa = sevOrder[a.severity] ?? 4 const sb = sevOrder[b.severity] ?? 4 if (sa !== sb) return sa - sb return a.id.localeCompare(b.id) }) return result } function getGroupedSAST(jsonPath: string): any[] { if (!existsSync(jsonPath)) return [] let raw: string try { raw = readFileSync(jsonPath, "utf-8") } catch { return [] } let data: any try { data = JSON.parse(raw) } catch { return [] } if (!data || !data.runs) return [] // Collect all results from all runs let allResults: any[] = [] for (const run of data.runs) { if (run && Array.isArray(run.results)) { allResults.push(...run.results) } } if (allResults.length === 0) return [] // Group by ruleId const groups: Record = {} for (const result of allResults) { const ruleId = result.ruleId || "unknown" if (!groups[ruleId]) groups[ruleId] = [] groups[ruleId].push(result) } const output: any[] = [] for (const [ruleId, group] of Object.entries(groups)) { const first = group[0] const findings = group.map((r) => { const loc = r.locations?.[0]?.physicalLocation return { file: loc?.artifactLocation?.uri || "", line: loc?.region?.startLine || 0, } }) output.push({ id: ruleId, title: first.message?.text || "", severity: first.level || "warning", count: group.length, type: "SAST", findings, }) } // Sort by severity then id const sevOrder: Record = { error: 0, warning: 1, note: 2 } output.sort((a, b) => { const sa = sevOrder[a.severity] ?? 3 const sb = sevOrder[b.severity] ?? 3 if (sa !== sb) return sa - sb return a.id.localeCompare(b.id) }) return output } const scaFindings = (mode === "sca" || mode === "both") ? getGroupedSCA(scaJsonPath) : [] const codeFindings = (mode === "code" || mode === "both") ? getGroupedSAST(codeJsonPath) : [] const summaryObj = { branch, timestamp, baseDir: resolvedRoot, project: { project: baseName, sca: scaFindings, code: codeFindings, }, } // Write Zusammenfassung to disk const summaryJson = JSON.stringify(summaryObj, null, 2) writeFileSync(summaryPath, summaryJson, "utf-8") // --- Return shape --- return JSON.stringify({ ok: true, command: commands, branch, timestamp, outputDir: resolvedOutputDir, files: { sca: (mode === "sca" || mode === "both") ? scaJsonPath : null, code: (mode === "code" || mode === "both") ? codeJsonPath : null, summary: summaryPath, }, summary: summaryObj, exitCodes, warnings, }, null, 2) } catch (err: any) { return JSON.stringify({ ok: false, error: err.message ?? String(err) }, null, 2) } }, })