/** * apps/worker — automation run log writer (TASK-0113 §3/§7/§Security). * * Every run gets ≥1 `automation_log`. This module is the SINGLE place * `AutomationLog` rows are written, so two things are applied centrally and * no executor can bypass them: * - REDACTION: a fixed set of sensitive key NAMES (Authorization, * X-Api-Key, Cookie, Set-Cookie, token, password, webhook-secret, … — see * SENSITIVE_KEYS) matched case-insensitively by EXACT name (not substring, * so a legitimate `keyboard` field is never masked). A matching key's value * is replaced with `***` before storage, so webhook headers, secrets, and * any credential that lands in a log's `data` are scrubbed here. * - TRUNCATION: a `data` blob larger than `LOG_DATA_MAX_BYTES` (32 KB) is * truncated to a JSON-safe prefix + `{ truncated: true }` so one runaway * log row cannot bloat the table. * * `runLog` never throws — a failed log write is a WARN, not a run failure * (the run outcome is what matters; the log is observability). */ import type { PrismaClient } from "@prisma/client"; import { asJson } from "../../lib/prisma.js"; import { getLogger } from "../../logger.js"; const log = getLogger("automation-log"); /** Hard cap on a single log row's `data` JSON size. */ export const LOG_DATA_MAX_BYTES = 32 * 1024; /** Sensitive key names (matched case-insensitively) → value becomes "***". */ const SENSITIVE_KEYS = new Set([ "authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "api-key", "apikey", "x-auth-token", "authorization-header", "secret", "secret-hash", "secret-encrypted", "token", "access-token", "refresh-token", "password", "pass", "passwd", "private-key", "signing-key", "webhook-secret", "x-hub-signature", "x-webhook-signature", "x-authorization" ]); /** * Recursively redact sensitive keys in a (plain) object. Arrays and scalars * pass through; objects are walked. Keys are compared lowercase. */ export function redact(value: unknown): unknown { if (Array.isArray(value)) return value.map(redact); if (value && typeof value === "object") { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? "***" : redact(v); } return out; } return value; } /** * Truncate a `data` blob to LOG_DATA_MAX_BYTES while preserving structure: * string leaves are shortened (with a `…[truncated]` marker) until the whole * blob fits. A top-level `truncated: true` flag is set whenever anything was * cut (the spec's "truncated: true" flag). Pure (no DB). */ export function truncateLogData(data: unknown): unknown { if (data === undefined || data === null) return data; const json = JSON.stringify(data); if (json === undefined) return data; if (Buffer.byteLength(json, "utf8") <= LOG_DATA_MAX_BYTES) return data; let truncated = false; const shorten = (v: unknown): unknown => { if (typeof v === "string") { if (v.length > 500) { truncated = true; return `${v.slice(0, 500)}…[truncated ${v.length - 500} chars]`; } return v; } if (Array.isArray(v)) return v.map(shorten); if (v && typeof v === "object") { const out: Record = {}; for (const [k, val] of Object.entries(v as Record)) out[k] = shorten(val); return out; } return v; }; const result = shorten(data); if (result && typeof result === "object" && !Array.isArray(result)) { (result as Record).truncated = true; } // Final guard: if still over (pathologically many medium strings), collapse // to a marker object so a row can never exceed the cap. if (JSON.stringify(result) && Buffer.byteLength(JSON.stringify(result), "utf8") > LOG_DATA_MAX_BYTES) { return { truncated: true, note: "automation log data exceeded the 32 KB cap and was truncated" }; } void truncated; return result; } /** * Write one automation_log row. Redacts + truncates `data` centrally. Never * throws (a log failure is a WARN, not a run failure). */ export async function runLog( prisma: PrismaClient, runId: string, level: "info" | "warn" | "error", message: string, data?: Record ): Promise { try { const safeData = data ? (truncateLogData(redact(data)) as Record) : undefined; await prisma.automationLog.create({ data: { runId, level, message, data: asJson(safeData ?? undefined) } }); } catch (err) { log.warn({ err: (err as Error).message, runId, level }, "automation log write failed (non-fatal)"); } }