From 21d75d10c91c410606155a8523c1a5b767b55a67 Mon Sep 17 00:00:00 2001 From: artale Date: Sat, 27 Jun 2026 16:13:12 +0200 Subject: [PATCH] feat: add security report receipts --- src/fable5/index.ts | 3 ++ src/fable5/security-report.test.ts | 42 ++++++++++++++++++++ src/fable5/security-report.ts | 63 ++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 src/fable5/security-report.test.ts create mode 100644 src/fable5/security-report.ts diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 19ce090..513f3cd 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -81,6 +81,9 @@ export type { AgentBenchOptions, AgentBenchReceipt, BenchContenderId, BenchConte export { createDistillationReceipt, writeDistillationReceipt } from "./distillation-receipt.js"; export type { DistillationProvider, DistillationReceipt, DistillationReceiptOptions, DistillationTeacherKind } from "./distillation-receipt.js"; +export { createSecurityReportReceipt, writeSecurityReportReceipt } from "./security-report.js"; +export type { SecurityReportOptions, SecurityReportReceipt, SecurityReportSeverity, SecurityReportVerdict } from "./security-report.js"; + export { auditGoalCompletion, writeGoalAuditReceipt } from "./goal-audit.js"; export type { GoalAuditCriterion, GoalAuditReceipt } from "./goal-audit.js"; diff --git a/src/fable5/security-report.test.ts b/src/fable5/security-report.test.ts new file mode 100644 index 0000000..5e09371 --- /dev/null +++ b/src/fable5/security-report.test.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createSecurityReportReceipt, writeSecurityReportReceipt } from "./security-report.js"; + +describe("security report receipt", () => { + it("accepts confirmed findings only with repro and evidence", () => { + const receipt = createSecurityReportReceipt({ + affectedComponent: "security scan", + reproCommand: "node dist/index.js security scan .", + evidencePath: ".fable/security/report.json", + tracePath: ".fable/security/trace.log", + verdict: "confirmed", + severity: "high", + freshnessHours: 12, + now: new Date("2026-06-27T00:00:00.000Z"), + }); + + expect(receipt.schema).toBe("fable.security.report.v1"); + expect(receipt.decision).toBe("accepted"); + expect(receipt.freshness.expiresAt).toBe("2026-06-27T12:00:00.000Z"); + expect(receipt.reasons).toEqual([]); + }); + + it("sends weak reports to review", () => { + const receipt = createSecurityReportReceipt({ affectedComponent: "x", reproCommand: "", evidencePath: "", verdict: "needs_review" }); + + expect(receipt.decision).toBe("review"); + expect(receipt.reasons).toContain("missing repro command"); + expect(receipt.reasons).toContain("missing evidence path"); + }); + + it("writes a JSON receipt", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "security-report-")); + const file = path.join(dir, "receipt.json"); + + writeSecurityReportReceipt(file, createSecurityReportReceipt({ affectedComponent: "x", reproCommand: "echo ok", evidencePath: "evidence.json", verdict: "not_reproduced" })); + + expect(fs.readFileSync(file, "utf-8")).toContain("fable.security.report.v1"); + }); +}); diff --git a/src/fable5/security-report.ts b/src/fable5/security-report.ts new file mode 100644 index 0000000..fe487bd --- /dev/null +++ b/src/fable5/security-report.ts @@ -0,0 +1,63 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export type SecurityReportVerdict = "confirmed" | "not_reproduced" | "needs_review"; +export type SecurityReportSeverity = "low" | "medium" | "high" | "critical"; + +export interface SecurityReportOptions { + affectedComponent: string; + reproCommand: string; + evidencePath: string; + verdict: SecurityReportVerdict; + severity?: SecurityReportSeverity; + freshnessHours?: number; + tracePath?: string; + summary?: string; + now?: Date; +} + +export interface SecurityReportReceipt { + schema: "fable.security.report.v1"; + createdAt: string; + affectedComponent: string; + reproCommand: string; + evidencePath: string; + tracePath?: string; + verdict: SecurityReportVerdict; + severity: SecurityReportSeverity; + freshness: { hours: number; expiresAt: string }; + summary: string; + decision: "accepted" | "rejected" | "review"; + reasons: string[]; +} + +export function createSecurityReportReceipt(opts: SecurityReportOptions): SecurityReportReceipt { + const now = opts.now ?? new Date(); + const freshnessHours = opts.freshnessHours ?? 24; + const reasons: string[] = []; + if (!opts.reproCommand.trim()) reasons.push("missing repro command"); + if (!opts.evidencePath.trim()) reasons.push("missing evidence path"); + if (opts.verdict === "needs_review") reasons.push("verdict requires human/security review"); + + const decision = reasons.length ? "review" : opts.verdict === "confirmed" ? "accepted" : "rejected"; + return { + schema: "fable.security.report.v1", + createdAt: now.toISOString(), + affectedComponent: opts.affectedComponent, + reproCommand: opts.reproCommand, + evidencePath: opts.evidencePath, + tracePath: opts.tracePath, + verdict: opts.verdict, + severity: opts.severity ?? "medium", + freshness: { hours: freshnessHours, expiresAt: new Date(now.getTime() + freshnessHours * 60 * 60 * 1000).toISOString() }, + summary: opts.summary ?? "security finding report", + decision, + reasons, + }; +} + +export function writeSecurityReportReceipt(file: string, receipt: SecurityReportReceipt): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`); + return file; +}