server_evidence
Collect forensic evidence from a server including firewall rules, logs, ports, and Docker info. Returns a manifest with SHA256 checksums.
Instructions
Collect forensic evidence package from a server. Gathers firewall rules, auth.log, listening ports, system logs, and optionally Docker info. Writes to ~/.kastell/evidence/{server}/{date}/. Returns manifest with SHA256 checksums per file.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| server | No | Server name or IP. Auto-selected if only one server exists. | |
| name | No | Label for the evidence directory (e.g. 'pre-incident'). | |
| lines | No | Number of log lines to collect per file (default: 500). | |
| no_docker | No | Skip Docker data collection. | |
| no_sysinfo | No | Skip system information collection. | |
| force | No | Overwrite existing evidence directory. |
Implementation Reference
- src/mcp/tools/serverEvidence.ts:44-104 (handler)MCP tool handler for server_evidence. Resolves target server, calls collectEvidence() core function, and returns evidence manifest with SHA256 checksums.
export async function handleServerEvidence(params: { server?: string; name?: string; lines?: number; no_docker?: boolean; no_sysinfo?: boolean; force?: boolean; }): Promise<McpResponse> { try { const servers = getServers(); if (servers.length === 0) { return mcpError("No servers found"); } const server = resolveServerForMcp(params, servers); if (!server) { if (params.server) { return mcpError( `Server not found: ${params.server}`, `Available servers: ${servers.map((s) => s.name).join(", ")}`, ); } return mcpError( "Multiple servers found. Specify which server to collect evidence from.", `Available: ${servers.map((s) => s.name).join(", ")}`, ); } const platform = server.platform ?? server.mode ?? "bare"; const result = await collectEvidence(server.name, server.ip, platform, { name: params.name, lines: params.lines ?? 500, noDocker: params.no_docker ?? false, noSysinfo: params.no_sysinfo ?? false, force: params.force ?? false, json: false, quiet: true, }); if (!result.success || !result.data) { return mcpError(result.error ?? "Evidence collection failed"); } const { evidenceDir, serverName, serverIp, totalFiles, skippedFiles, collectedAt, manifestPath } = result.data; return mcpSuccess({ evidenceDir, serverName, serverIp, platform, collectedAt, totalFiles, skippedFiles, manifestPath, }, { largeResult: true }); } catch (error: unknown) { return mcpError(sanitizeStderr(getErrorMessage(error))); } } - Zod schema defining the 6 optional input parameters for server_evidence: server (name/IP), name (label), lines (log count), no_docker, no_sysinfo, force.
export const serverEvidenceSchema = { server: z .string() .optional() .describe("Server name or IP. Auto-selected if only one server exists."), name: z .string() .optional() .describe("Label for the evidence directory (e.g. 'pre-incident')."), lines: z .number() .default(500) .describe("Number of log lines to collect per file (default: 500)."), no_docker: z .boolean() .default(false) .describe("Skip Docker data collection."), no_sysinfo: z .boolean() .default(false) .describe("Skip system information collection."), force: z .boolean() .default(false) .describe("Overwrite existing evidence directory."), }; - src/mcp/server.ts:181-194 (registration)Registration of the server_evidence tool in the MCP server, wiring the schema and handler together with description and annotations.
server.registerTool("server_evidence", { description: "Collect forensic evidence package from a server. Gathers firewall rules, auth.log, listening ports, system logs, and optionally Docker info. Writes to ~/.kastell/evidence/{server}/{date}/. Returns manifest with SHA256 checksums per file.", inputSchema: serverEvidenceSchema, annotations: { title: "Evidence Collection", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, async (params) => { return handleServerEvidence(params); }); - src/core/evidence.ts:140-258 (helper)Core evidence collection logic: connects via SSH, runs batched commands, writes per-file evidence with SHA256 checksums, builds MANIFEST.json and SHA256SUMS atomically.
export async function collectEvidence( serverName: string, ip: string, platform: string, opts: EvidenceOptions, ): Promise<KastellResult<EvidenceResult>> { const collectedAt = new Date().toISOString(); const dirName = buildDirName(opts); // Guard against path traversal via crafted server names if (/[/\\]|\.\./.test(serverName)) { return { success: false, error: "Invalid server name: contains path separator or traversal" }; } // Resolve evidence directory let evidenceDir: string; if (opts.output) { evidenceDir = resolve(opts.output, dirName); } else { evidenceDir = join(KASTELL_DIR, "evidence", serverName, dirName); } // Check for existing directory if (existsSync(evidenceDir)) { if (!opts.force) { return { success: false, error: `Evidence '${dirName}' already exists. Use --force to overwrite.`, }; } rmSync(evidenceDir, { recursive: true, force: true }); } // Create evidence directory secureMkdirSync(evidenceDir); // Build SSH batch command and matching filename list const buildOpts = { noDocker: opts.noDocker, noSysinfo: opts.noSysinfo }; const batchCommand = buildEvidenceBatchCommand(platform, opts.lines, buildOpts); const sectionFilenames = getEvidenceSectionFilenames(platform, buildOpts); // Execute SSH (exactly one call) let sshResult: Awaited<ReturnType<typeof sshExec>>; try { sshResult = await sshExec(ip, batchCommand, { timeoutMs: EVIDENCE_TIMEOUT_MS }); } catch (sshErr: unknown) { rmSync(evidenceDir, { recursive: true, force: true }); return { success: false, error: `SSH error: ${getErrorMessage(sshErr)}`, }; } if (sshResult.code !== 0) { rmSync(evidenceDir, { recursive: true, force: true }); return { success: false, error: `SSH failed: ${sshResult.stderr || "non-zero exit code"}`, }; } // Parse sections (trim each section to remove surrounding newlines from separator) const sections = sshResult.stdout.split("---SEPARATOR---").map((s) => s.trim()); const entries: EvidenceFileEntry[] = []; const manifestPath = join(evidenceDir, "MANIFEST.json"); try { for (let i = 0; i < sections.length; i++) { const filename = sectionFilenames[i]; if (!filename) continue; // Beyond expected sections — skip silently writeEvidenceFile(evidenceDir, filename, sections[i], collectedAt, entries); } // Build manifest const manifest: EvidenceManifest = { schemaVersion: 1, server: serverName, ip, platform, collectedAt, evidenceDir, files: entries, }; const sha256SumsContent = buildSha256Sums(entries); const sha256SumsPath = join(evidenceDir, "SHA256SUMS"); // Write manifest and SHA256SUMS atomically under file lock await withFileLock(manifestPath, () => { const manifestTmp = manifestPath + ".tmp"; const sha256SumsTmp = sha256SumsPath + ".tmp"; secureWriteFileSync(manifestTmp, JSON.stringify(manifest, null, 2)); renameSync(manifestTmp, manifestPath); secureWriteFileSync(sha256SumsTmp, sha256SumsContent); renameSync(sha256SumsTmp, sha256SumsPath); }); } catch (err: unknown) { rmSync(evidenceDir, { recursive: true, force: true }); return { success: false, error: `Failed to write evidence files: ${getErrorMessage(err)}`, }; } const collectedCount = entries.filter((e) => e.status === "collected").length; return { success: true, data: { evidenceDir, serverName, serverIp: ip, platform, collectedAt, totalFiles: collectedCount, skippedFiles: entries.length - collectedCount, manifestPath, }, }; } - src/core/evidenceCommands.ts:135-163 (helper)Builds the batched SSH command for evidence collection, composing firewall, auth log, ports, syslog, sysinfo, and optional Docker sections separated by ---SEPARATOR---.
export function buildEvidenceBatchCommand( platform: string, lines: number, options?: EvidenceBuildOptions, ): SshCommand { const noDocker = options?.noDocker ?? false; const noSysinfo = options?.noSysinfo ?? false; const includeDocker = !noDocker && (platform === "coolify" || platform === "dokploy"); const safeLines = sanitizeLines(lines); const sections: string[] = [ firewallSection(), authLogSection(safeLines), portsSection(), syslogSection(safeLines), ]; if (!noSysinfo) { sections.push(sysinfoSection()); } if (includeDocker) { sections.push(dockerPsSection()); sections.push(dockerLogsSection(platform, safeLines)); } return raw(sections.join(`\n${SEPARATOR}\n`)); }