feat: add security report receipts

This commit is contained in:
artale 2026-06-27 16:13:12 +02:00
parent 2bf9fb6467
commit 21d75d10c9
3 changed files with 108 additions and 0 deletions

View File

@ -81,6 +81,9 @@ export type { AgentBenchOptions, AgentBenchReceipt, BenchContenderId, BenchConte
export { createDistillationReceipt, writeDistillationReceipt } from "./distillation-receipt.js"; export { createDistillationReceipt, writeDistillationReceipt } from "./distillation-receipt.js";
export type { DistillationProvider, DistillationReceipt, DistillationReceiptOptions, DistillationTeacherKind } 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 { auditGoalCompletion, writeGoalAuditReceipt } from "./goal-audit.js";
export type { GoalAuditCriterion, GoalAuditReceipt } from "./goal-audit.js"; export type { GoalAuditCriterion, GoalAuditReceipt } from "./goal-audit.js";

View File

@ -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");
});
});

View File

@ -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;
}