diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ea8cd96..d2231c0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,7 @@ jobs: node --test scripts/security/check-session-signature-parity.test.mjs node --test scripts/security/verify-secure-defi-claims.test.mjs node --test scripts/security/evidence-manifest.test.mjs + node --test scripts/security/spending-policy-evidence.test.mjs - name: Build packages run: pnpm run build diff --git a/.github/workflows/strict-security-proof.yml b/.github/workflows/strict-security-proof.yml index e9c1cb3b..a84a918b 100644 --- a/.github/workflows/strict-security-proof.yml +++ b/.github/workflows/strict-security-proof.yml @@ -31,6 +31,7 @@ jobs: set -euo pipefail node --test scripts/security/verify-secure-defi-claims.test.mjs node --test scripts/security/evidence-manifest.test.mjs + node --test scripts/security/spending-policy-evidence.test.mjs - name: Verify strict claims artifact env: diff --git a/docs/security/LAUNCH_READINESS_TRACKER.md b/docs/security/LAUNCH_READINESS_TRACKER.md index beee6305..c6706e6d 100644 --- a/docs/security/LAUNCH_READINESS_TRACKER.md +++ b/docs/security/LAUNCH_READINESS_TRACKER.md @@ -11,6 +11,7 @@ without ambiguous "done" claims. - Session-signature parity and conformance (`#256`) - SNIP-12 v2 tracker hygiene and closure evidence (`#255`) - Signer proxy auth hardening evidence linkage (`#219`) +- Spending-policy E2E/load/sign-off closure evidence (`#335`) ## P0 Closure Rules @@ -49,6 +50,15 @@ without ambiguous "done" claims. - Rotation and incident runbook: - `docs/security/SIGNER_PROXY_ROTATION_RUNBOOK.md` +### `#335` spending policy E2E/load/sign-off closure + +- Checklist and owner mapping: + - `docs/security/SPENDING_POLICY_AUDIT.md` +- Evidence schema + verifier: + - `scripts/security/spending-policy-evidence.mjs` + - `docs/security/evidence/spending-policy/README.md` + - `docs/security/evidence/spending-policy/execution-report.template.json` + ## Required Sign-off Comment Format Post this in each issue before closing: diff --git a/docs/security/SPENDING_POLICY_AUDIT.md b/docs/security/SPENDING_POLICY_AUDIT.md index d0e65f70..05693f47 100644 --- a/docs/security/SPENDING_POLICY_AUDIT.md +++ b/docs/security/SPENDING_POLICY_AUDIT.md @@ -630,6 +630,29 @@ fn test_zero_policy_disables_enforcement() { - [ ] Transfer with amount = max_per_call exactly - [ ] Non-spending call (balanceOf) → should not affect counter +### 7.5 No-Backend Launch Ownership Map (`#335`) + +Use the canonical evidence schema at: +- `docs/security/evidence/spending-policy/execution-report.template.json` + +Validate any run bundle with: +- `node scripts/security/spending-policy-evidence.mjs --report /execution-report.json --bundle-dir ` + +Required launch-blocking checks: + +| Check ID | Checklist Scope | Owner Role | +|----------|------------------|------------| +| `SP-01` | Sepolia SessionAccount deployment + funding setup evidence | contracts-maintainer | +| `SP-02` | Spending-policy baseline configuration evidence | contracts-maintainer | +| `SP-03` | Happy-path transfer acceptance evidence | runtime-maintainer | +| `SP-04` | Per-call rejection evidence | runtime-maintainer | +| `SP-05` | Window-limit rejection evidence | runtime-maintainer | +| `SP-06` | Session-key policy-mutation blocklist rejection evidence | runtime-maintainer | +| `SP-07` | Window-boundary behavior evidence (`now > boundary`) | contracts-maintainer | +| `SP-08` | Multicall cumulative enforcement evidence | runtime-maintainer | +| `SP-09` | Non-spending selector counter-invariance evidence | runtime-maintainer | +| `SP-10` | Load validation evidence (`100+ tx/hour`) | qa-maintainer | + --- ## 8. Formal Verification Candidates @@ -670,24 +693,37 @@ only_self_or_owner can call set_spending_policy ∧ remove_spending_policy **Testing:** - [x] 130/130 Cairo tests passing (123 original + 7 critical new) -- [ ] E2E testnet validation complete +- [ ] E2E testnet validation complete (`SP-01`..`SP-09`) - [x] Adversarial scenarios tested (window boundary, reentrancy, overflow) -- [ ] Load testing (100+ tx/hour) - pending E2E +- [ ] Load testing (100+ tx/hour) (`SP-10`) **Documentation:** -- [ ] Threat model published -- [ ] User guide with examples -- [ ] Known limitations documented +- [x] Threat model published (Section 1) +- [x] User guide with examples (`docs/E2E_TESTING_GUIDE.md`, `docs/QUICK_START_E2E.md`) +- [x] Known limitations documented (Section 3 + Conclusion) - [ ] Audit report finalized **Sign-Off:** -- [ ] Lead Developer: _______________ -- [ ] Security Reviewer: _______________ -- [ ] QA Engineer: _______________ +- [ ] Lead Developer approved (`signoff.leadDeveloper`) +- [ ] Security Reviewer approved (`signoff.securityReviewer`) +- [ ] QA Engineer approved (`signoff.qaEngineer`) + +--- + +## 10. `#335` Closure Procedure (No-Backend Profile) + +1. Create run bundle: + - `node scripts/security/spending-policy-evidence.mjs --init --report docs/security/evidence/spending-policy/runs//execution-report.json --run-id --network starknet-sepolia` +2. Execute required Sepolia checks (`SP-01`..`SP-10`) and attach tx/log evidence in the same run directory. +3. Validate structure: + - `node scripts/security/spending-policy-evidence.mjs --report docs/security/evidence/spending-policy/runs//execution-report.json --bundle-dir docs/security/evidence/spending-policy/runs/` +4. Validate closure readiness: + - `node scripts/security/spending-policy-evidence.mjs --report docs/security/evidence/spending-policy/runs//execution-report.json --bundle-dir docs/security/evidence/spending-policy/runs/ --require-closed` +5. Post run-directory links in `#335` and reference them from `#273`. --- -## 10. Conclusion +## 11. Conclusion **Current Status**: 🟢 READY FOR E2E TESTING diff --git a/docs/security/evidence/spending-policy/README.md b/docs/security/evidence/spending-policy/README.md new file mode 100644 index 00000000..a5bbcba0 --- /dev/null +++ b/docs/security/evidence/spending-policy/README.md @@ -0,0 +1,72 @@ +# Spending Policy Execution Evidence (`#335`) + +This directory stores reproducible evidence for the no-backend launch gate item: + +- `#335` Close `SPENDING_POLICY_AUDIT.md` E2E/load/sign-off checklist items + +The source-of-truth schema is validated by: + +- `scripts/security/spending-policy-evidence.mjs` + +## Canonical flow + +1. Create a run bundle from the template: + +```bash +RUN_ID="sp-$(date -u +%Y%m%d-%H%M%S)" +RUN_DIR="docs/security/evidence/spending-policy/runs/${RUN_ID}" + +node scripts/security/spending-policy-evidence.mjs \ + --init \ + --report "${RUN_DIR}/execution-report.json" \ + --run-id "${RUN_ID}" \ + --network "starknet-sepolia" +``` + +2. Execute the Sepolia E2E/load scenarios and attach evidence for each `SP-xx` check: + +- Transaction hash evidence (`type: "tx"`, `txHash`, explorer URL) +- Command logs (`type: "log"`, relative `path` inside the run directory) +- Optional reports/screenshots for load-test summaries + +3. Validate report structure before posting links: + +```bash +node scripts/security/spending-policy-evidence.mjs \ + --report "${RUN_DIR}/execution-report.json" \ + --bundle-dir "${RUN_DIR}" +``` + +4. Validate closure readiness (all required checks + all three sign-offs approved): + +```bash +node scripts/security/spending-policy-evidence.mjs \ + --report "${RUN_DIR}/execution-report.json" \ + --bundle-dir "${RUN_DIR}" \ + --require-closed +``` + +## Required check IDs (launch-blocking) + +- `SP-01` Deploy SessionAccount evidence +- `SP-02` Spending policy baseline evidence +- `SP-03` Happy-path transfer evidence +- `SP-04` Per-call limit rejection evidence +- `SP-05` Window-limit rejection evidence +- `SP-06` Selector blocklist rejection evidence +- `SP-07` Window-boundary behavior evidence +- `SP-08` Multicall cumulative enforcement evidence +- `SP-09` Non-spending selector behavior evidence +- `SP-10` Load validation evidence (`100+ tx/hour`) + +## Sign-off keys (required for `--require-closed`) + +- `signoff.leadDeveloper` +- `signoff.securityReviewer` +- `signoff.qaEngineer` + +## Notes + +- Evidence `path` values must be safe relative paths inside the run directory. +- `status: "pass"` checks must include at least one evidence entry. +- This process is backend-free and self-custodial: maintainers execute with local tooling/accounts and publish the resulting report links in `#335` and `#273`. diff --git a/docs/security/evidence/spending-policy/execution-report.template.json b/docs/security/evidence/spending-policy/execution-report.template.json new file mode 100644 index 00000000..a3f569ef --- /dev/null +++ b/docs/security/evidence/spending-policy/execution-report.template.json @@ -0,0 +1,108 @@ +{ + "schemaVersion": "1", + "issue": "#335", + "profile": "no-backend", + "network": "starknet-sepolia", + "runId": "sp-template", + "generatedAt": "2026-03-06T00:00:00.000Z", + "checks": [ + { + "checkId": "SP-01", + "title": "Deploy SessionAccount to Sepolia and capture deploy tx evidence", + "owner": "contracts-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + }, + { + "checkId": "SP-02", + "title": "Set spending policy baseline and capture policy-state evidence", + "owner": "contracts-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + }, + { + "checkId": "SP-03", + "title": "Happy path transfers within limits validated on Sepolia", + "owner": "runtime-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + }, + { + "checkId": "SP-04", + "title": "Per-call limit rejection validated", + "owner": "runtime-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + }, + { + "checkId": "SP-05", + "title": "Window-limit rejection validated", + "owner": "runtime-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + }, + { + "checkId": "SP-06", + "title": "Session key blocked from policy mutation selectors", + "owner": "runtime-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + }, + { + "checkId": "SP-07", + "title": "Window-boundary behavior validated (reset only when now > boundary)", + "owner": "contracts-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + }, + { + "checkId": "SP-08", + "title": "Multicall cumulative enforcement validated", + "owner": "runtime-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + }, + { + "checkId": "SP-09", + "title": "Non-spending selector validation (counter unchanged)", + "owner": "runtime-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + }, + { + "checkId": "SP-10", + "title": "Load validation (100+ tx/hour) completed with consistency evidence", + "owner": "qa-maintainer", + "status": "pending", + "evidence": [], + "notes": "" + } + ], + "signoff": { + "leadDeveloper": { + "name": "", + "status": "pending", + "signedAt": null + }, + "securityReviewer": { + "name": "", + "status": "pending", + "signedAt": null + }, + "qaEngineer": { + "name": "", + "status": "pending", + "signedAt": null + } + }, + "residualRisks": [] +} diff --git a/docs/security/evidence/spending-policy/runs/.gitkeep b/docs/security/evidence/spending-policy/runs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/package.json b/package.json index f4dfbcc6..84def91f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "test": "pnpm -r test", "lint": "pnpm -r lint", "verify:evidence": "node scripts/security/evidence-manifest.mjs --manifest examples/secure-defi-demo/artifacts/artifact-manifest.json --require-strict", + "spending:evidence:init": "node scripts/security/spending-policy-evidence.mjs --init --report docs/security/evidence/spending-policy/execution-report.template.json --run-id sp-template --generated-at 2026-03-06T00:00:00.000Z --network starknet-sepolia --force", + "spending:evidence:verify": "node scripts/security/spending-policy-evidence.mjs --report docs/security/evidence/spending-policy/execution-report.template.json --bundle-dir docs/security/evidence/spending-policy", "demo:hello-agent": "node examples/hello-agent/index.mjs", "ci:version": "changeset version", "ci:release": "pnpm build && changeset publish" @@ -39,6 +41,7 @@ "overrides": { "ajv@^6.0.0": "6.14.0", "ajv@^8.0.0": "8.18.0", + "express-rate-limit": "8.3.0", "hono": "4.12.2", "qs": "6.14.2", "minimatch": "10.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb027aa5..cbe3cf21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: ajv@^6.0.0: 6.14.0 ajv@^8.0.0: 8.18.0 + express-rate-limit: 8.3.0 hono: 4.12.2 qs: 6.14.2 minimatch: 10.2.3 @@ -2457,8 +2458,8 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + express-rate-limit@8.3.0: + resolution: {integrity: sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2743,8 +2744,8 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -5079,7 +5080,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) + express-rate-limit: 8.3.0(express@5.2.1) hono: 4.12.2 jose: 6.1.3 json-schema-typed: 8.0.2 @@ -6519,10 +6520,10 @@ snapshots: expect-type@1.3.0: {} - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.3.0(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.1.0 express@5.2.1: dependencies: @@ -6911,7 +6912,7 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 - ip-address@10.0.1: {} + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} diff --git a/scripts/security/spending-policy-evidence.mjs b/scripts/security/spending-policy-evidence.mjs new file mode 100644 index 00000000..c095d933 --- /dev/null +++ b/scripts/security/spending-policy-evidence.mjs @@ -0,0 +1,489 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export const REPORT_SCHEMA_VERSION = "1"; + +const REQUIRED_CHECK_DEFINITIONS = [ + { + id: "SP-01", + ownerRole: "contracts", + title: "Deploy SessionAccount to Sepolia and capture deploy tx evidence", + }, + { + id: "SP-02", + ownerRole: "contracts", + title: "Set spending policy baseline and capture policy-state evidence", + }, + { + id: "SP-03", + ownerRole: "runtime", + title: "Happy path transfers within limits validated on Sepolia", + }, + { + id: "SP-04", + ownerRole: "runtime", + title: "Per-call limit rejection validated", + }, + { + id: "SP-05", + ownerRole: "runtime", + title: "Window-limit rejection validated", + }, + { + id: "SP-06", + ownerRole: "runtime", + title: "Session key blocked from policy mutation selectors", + }, + { + id: "SP-07", + ownerRole: "contracts", + title: "Window-boundary behavior validated (reset only when now > boundary)", + }, + { + id: "SP-08", + ownerRole: "runtime", + title: "Multicall cumulative enforcement validated", + }, + { + id: "SP-09", + ownerRole: "runtime", + title: "Non-spending selector validation (counter unchanged)", + }, + { + id: "SP-10", + ownerRole: "qa", + title: "Load validation (100+ tx/hour) completed with consistency evidence", + }, +]; + +export const CHECK_IDS = REQUIRED_CHECK_DEFINITIONS.map((entry) => entry.id); + +const ALLOWED_CHECK_STATUSES = new Set([ + "pending", + "pass", + "fail", + "blocked", + "not_applicable", +]); + +const ALLOWED_SIGNOFF_STATUSES = new Set(["pending", "approved", "rejected"]); +const ALLOWED_EVIDENCE_TYPES = new Set(["tx", "log", "report", "screenshot", "other"]); +const REQUIRED_SIGNOFF_KEYS = ["leadDeveloper", "securityReviewer", "qaEngineer"]; +const STRICT_ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const BOOLEAN_FLAGS = new Set(["help", "init", "require-closed", "force"]); + +function fail(message) { + throw new Error(message); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function parseBooleanFlag(value) { + return value === true || value === "true"; +} + +function validateIsoDate(value, label) { + if (!isNonEmptyString(value)) { + fail(`${label} must be a non-empty ISO-8601 UTC string`); + } + if (!STRICT_ISO_UTC_RE.test(value)) { + fail(`${label} must be a valid ISO-8601 UTC string (YYYY-MM-DDTHH:mm:ss.sssZ)`); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) { + fail(`${label} must be a valid ISO-8601 UTC string (YYYY-MM-DDTHH:mm:ss.sssZ)`); + } +} + +function defaultRunId(generatedAt) { + const compact = generatedAt.replace(/[-:.TZ]/g, "").slice(0, 14); + return `sp-${compact}`; +} + +function toSafeRelativePath(relativePath) { + if (!isNonEmptyString(relativePath)) { + fail("Evidence path must be a non-empty string"); + } + + const normalized = relativePath.replace(/\\/g, "/"); + if ( + normalized.startsWith("/") + || /^[A-Za-z]:\//.test(normalized) + || normalized.includes("../") + || normalized === ".." + || normalized.includes("/..") + ) { + fail(`Evidence path must be a safe relative path: ${relativePath}`); + } + + return normalized; +} + +function resolveEvidencePath(bundleDir, relativePath) { + const normalized = toSafeRelativePath(relativePath); + const absoluteBase = path.resolve(bundleDir); + const absolutePath = path.resolve(absoluteBase, normalized); + const relative = path.relative(absoluteBase, absolutePath); + if ( + relative.startsWith("..") + || path.isAbsolute(relative) + || relative === "" + ) { + fail(`Evidence path escapes bundle directory: ${relativePath}`); + } + + return absolutePath; +} + +function validateEvidenceEntry(entry, options) { + const { bundleDir } = options; + if (!isPlainObject(entry)) { + fail("Evidence entries must be objects"); + } + + if (entry.txHash === undefined && entry.path === undefined && entry.url === undefined) { + fail("Evidence entry must include at least one of txHash, path, or url"); + } + + if (!ALLOWED_EVIDENCE_TYPES.has(String(entry.type))) { + fail(`Evidence type must be one of ${[...ALLOWED_EVIDENCE_TYPES].join(", ")}`); + } + + if (entry.txHash !== undefined && !/^0x[0-9a-fA-F]+$/.test(String(entry.txHash))) { + fail(`Evidence txHash must be a hex value: ${entry.txHash}`); + } + + if (entry.path !== undefined) { + if (!isNonEmptyString(entry.path)) { + fail("Evidence path must be a non-empty string when provided"); + } + const absolutePath = resolveEvidencePath(bundleDir, entry.path); + if (!fs.existsSync(absolutePath)) { + fail(`Evidence file does not exist: ${entry.path}`); + } + } + + if (entry.url !== undefined && !isNonEmptyString(entry.url)) { + fail("Evidence url must be a non-empty string when provided"); + } +} + +function makeDefaultOwners() { + return { + contracts: "contracts-maintainer", + runtime: "runtime-maintainer", + qa: "qa-maintainer", + }; +} + +export function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const key = argv[i]; + if (!key.startsWith("--")) continue; + const name = key.slice(2); + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + if (!BOOLEAN_FLAGS.has(name)) { + fail(`Flag --${name} requires a value`); + } + args[name] = "true"; + continue; + } + args[name] = value; + i += 1; + } + return args; +} + +export function createReportTemplate(options = {}) { + const generatedAt = options.generatedAt || new Date().toISOString(); + validateIsoDate(generatedAt, "generatedAt"); + + const runId = options.runId || defaultRunId(generatedAt); + if (!isNonEmptyString(runId)) { + fail("runId must be a non-empty string"); + } + + const owners = { + ...makeDefaultOwners(), + ...(isPlainObject(options.owners) ? options.owners : {}), + }; + + return { + schemaVersion: REPORT_SCHEMA_VERSION, + issue: "#335", + profile: "no-backend", + network: options.network || "starknet-sepolia", + runId, + generatedAt, + checks: REQUIRED_CHECK_DEFINITIONS.map((definition) => ({ + checkId: definition.id, + title: definition.title, + owner: owners[definition.ownerRole] || "unassigned", + status: "pending", + evidence: [], + notes: "", + })), + signoff: { + leadDeveloper: { + name: "", + status: "pending", + signedAt: null, + }, + securityReviewer: { + name: "", + status: "pending", + signedAt: null, + }, + qaEngineer: { + name: "", + status: "pending", + signedAt: null, + }, + }, + residualRisks: [], + }; +} + +function validateBaseShape(report) { + if (!isPlainObject(report)) { + fail("Report must be a JSON object"); + } + + if (report.schemaVersion !== REPORT_SCHEMA_VERSION) { + fail(`schemaVersion must be ${REPORT_SCHEMA_VERSION}`); + } + + if (report.issue !== "#335") { + fail("issue must be #335"); + } + + if (report.profile !== "no-backend") { + fail("profile must be no-backend"); + } + + if (!isNonEmptyString(report.network)) { + fail("network must be a non-empty string"); + } + + if (!isNonEmptyString(report.runId)) { + fail("runId must be a non-empty string"); + } + + validateIsoDate(report.generatedAt, "generatedAt"); + + if (!Array.isArray(report.checks)) { + fail("checks must be an array"); + } + + if (!isPlainObject(report.signoff)) { + fail("signoff must be an object"); + } + + if (!Array.isArray(report.residualRisks)) { + fail("residualRisks must be an array"); + } +} + +function validateSignoff(signoff, options) { + const { requireClosed } = options; + + for (const key of REQUIRED_SIGNOFF_KEYS) { + const entry = signoff[key]; + if (!isPlainObject(entry)) { + fail(`signoff.${key} must be an object`); + } + + if (!ALLOWED_SIGNOFF_STATUSES.has(String(entry.status))) { + fail(`signoff.${key}.status must be one of ${[...ALLOWED_SIGNOFF_STATUSES].join(", ")}`); + } + + if (entry.status === "approved") { + if (!isNonEmptyString(entry.name)) { + fail(`signoff.${key}.name must be set when approved`); + } + validateIsoDate(entry.signedAt, `signoff.${key}.signedAt`); + } + + if (requireClosed && entry.status !== "approved") { + fail(`signoff.${key}.status must be approved for closed verification`); + } + } +} + +function validateResidualRisks(residualRisks) { + for (const [index, entry] of residualRisks.entries()) { + if (!isPlainObject(entry)) { + fail(`residualRisks[${index}] must be an object`); + } + + if (!isNonEmptyString(entry.description)) { + fail(`residualRisks[${index}].description must be a non-empty string`); + } + + if (!isNonEmptyString(entry.owner)) { + fail(`residualRisks[${index}].owner must be a non-empty string`); + } + + if (entry.dueDate !== undefined) { + validateIsoDate(entry.dueDate, `residualRisks[${index}].dueDate`); + } + } +} + +export function verifySpendingPolicyReport(report, options = {}) { + const bundleDir = options.bundleDir || process.cwd(); + const requireClosed = options.requireClosed === true; + + validateBaseShape(report); + + const checkMap = new Map(); + for (const entry of report.checks) { + if (!isPlainObject(entry)) { + fail("checks entries must be objects"); + } + + if (!isNonEmptyString(entry.checkId)) { + fail("checkId must be a non-empty string"); + } + + if (checkMap.has(entry.checkId)) { + fail(`duplicate checkId: ${entry.checkId}`); + } + + if (!ALLOWED_CHECK_STATUSES.has(String(entry.status))) { + fail(`check ${entry.checkId} status must be one of ${[...ALLOWED_CHECK_STATUSES].join(", ")}`); + } + + if (!isNonEmptyString(entry.owner)) { + fail(`check ${entry.checkId} owner must be a non-empty string`); + } + + if (!Array.isArray(entry.evidence)) { + fail(`check ${entry.checkId} evidence must be an array`); + } + + if (entry.status === "pass" && entry.evidence.length === 0) { + fail("pass checks must include evidence"); + } + + for (const evidenceEntry of entry.evidence) { + validateEvidenceEntry(evidenceEntry, { bundleDir }); + } + + checkMap.set(entry.checkId, entry); + } + + const missingCheckIds = CHECK_IDS.filter((checkId) => !checkMap.has(checkId)); + if (missingCheckIds.length > 0) { + fail(`missing required check ids: ${missingCheckIds.join(", ")}`); + } + + const requiredEntries = CHECK_IDS.map((checkId) => checkMap.get(checkId)); + const passedChecks = requiredEntries.filter((entry) => entry.status === "pass").length; + const unresolved = requiredEntries.filter((entry) => entry.status !== "pass"); + + if (requireClosed && unresolved.length > 0) { + fail( + `closed verification requires all required checks to pass; unresolved: ${unresolved + .map((entry) => entry.checkId) + .join(", ")}`, + ); + } + + validateSignoff(report.signoff, { requireClosed }); + validateResidualRisks(report.residualRisks); + + return { + requiredChecks: CHECK_IDS.length, + passedChecks, + unresolvedChecks: unresolved.length, + runId: report.runId, + }; +} + +function printUsage() { + process.stderr.write( + "Usage: node scripts/security/spending-policy-evidence.mjs [--init] --report [--bundle-dir ] [--require-closed] [--run-id ] [--generated-at ] [--network