feat: archive zoo_backup for home sync
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
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: <projectRoot>/.snyk-reports/<basename>)"),
|
||||
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<string, any[]> = {}
|
||||
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<string, number> = { 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<string, any[]> = {}
|
||||
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<string, number> = { 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)
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user