@enwithai/product-check 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +28 -0
- package/README.md +29 -16
- package/SECURITY.md +5 -1
- package/dashboard/assurance.css +71 -0
- package/dashboard/assurance.js +21 -0
- package/dist/assurance/adapters.d.ts +32 -0
- package/dist/assurance/adapters.js +76 -0
- package/dist/assurance/approvals.d.ts +34 -0
- package/dist/assurance/approvals.js +133 -0
- package/dist/assurance/cli.d.ts +13 -0
- package/dist/assurance/cli.js +863 -0
- package/dist/assurance/dashboard.d.ts +18 -0
- package/dist/assurance/dashboard.js +267 -0
- package/dist/assurance/diff.d.ts +20 -0
- package/dist/assurance/diff.js +99 -0
- package/dist/assurance/evidence-log.d.ts +20 -0
- package/dist/assurance/evidence-log.js +100 -0
- package/dist/assurance/fix-packets.d.ts +12 -0
- package/dist/assurance/fix-packets.js +178 -0
- package/dist/assurance/gate.d.ts +37 -0
- package/dist/assurance/gate.js +324 -0
- package/dist/assurance/lanes.d.ts +61 -0
- package/dist/assurance/lanes.js +380 -0
- package/dist/assurance/policy.d.ts +10 -0
- package/dist/assurance/policy.js +85 -0
- package/dist/assurance/proof.d.ts +35 -0
- package/dist/assurance/proof.js +146 -0
- package/dist/assurance/register.d.ts +41 -0
- package/dist/assurance/register.js +480 -0
- package/dist/assurance/render-md.d.ts +17 -0
- package/dist/assurance/render-md.js +212 -0
- package/dist/assurance/repo-state.d.ts +15 -0
- package/dist/assurance/repo-state.js +79 -0
- package/dist/assurance/review.d.ts +52 -0
- package/dist/assurance/review.js +408 -0
- package/dist/assurance/risk.d.ts +21 -0
- package/dist/assurance/risk.js +184 -0
- package/dist/assurance/types.d.ts +573 -0
- package/dist/assurance/types.js +6 -0
- package/dist/assurance/util.d.ts +24 -0
- package/dist/assurance/util.js +89 -0
- package/dist/assurance/verification.d.ts +30 -0
- package/dist/assurance/verification.js +212 -0
- package/dist/assurance/verify.d.ts +26 -0
- package/dist/assurance/verify.js +304 -0
- package/dist/cli/run.js +8 -1
- package/dist/exec/process.d.ts +27 -0
- package/dist/exec/process.js +175 -0
- package/dist/reporting/markdown.d.ts +1 -0
- package/dist/reporting/markdown.js +1 -1
- package/dist/schema/registry.js +9 -0
- package/package.json +1 -1
- package/schemas/approval.schema.json +98 -0
- package/schemas/assurance-common.schema.json +291 -0
- package/schemas/assurance-policy.schema.json +185 -0
- package/schemas/change-review.schema.json +711 -0
- package/schemas/closure-register.schema.json +499 -0
- package/schemas/evidence-record.schema.json +94 -0
- package/schemas/fix-packet.schema.json +272 -0
- package/schemas/release-gate.schema.json +308 -0
- package/schemas/reviewer-findings.schema.json +49 -0
- package/schemas/verification-run.schema.json +199 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Results, Severity } from "../types.js";
|
|
2
|
+
import { type Actor, type Approval, type AssurancePolicy, type ChangeReview, type ClosureRegister, type ClosureState, type CommandSpec, type FixRecord, type RegisterEntry, type RiskAcceptanceV32, type Transition, type VerificationRun } from "./types.js";
|
|
3
|
+
/** Allowed transitions (CT3 v3.2), plus documented out-edges for REGRESSED. */
|
|
4
|
+
export declare const TRANSITIONS: Record<ClosureState, ClosureState[]>;
|
|
5
|
+
/** Risk tier of a finding for approval counts. */
|
|
6
|
+
export declare const findingRisk: (s: Severity) => "LOW" | "MEDIUM" | "HIGH";
|
|
7
|
+
/** Creates a register from a baseline results document. */
|
|
8
|
+
export declare function createRegister(results: Results, resultsSha: string, repo: string | null, policy: AssurancePolicy, now: Date): ClosureRegister;
|
|
9
|
+
/** Adds findings that are new in a results document and marks reappearing verified/closed ones REGRESSED. */
|
|
10
|
+
export declare function syncResults(reg: ClosureRegister, results: Results, repo: string | null, now: Date): {
|
|
11
|
+
added: string[];
|
|
12
|
+
regressed: string[];
|
|
13
|
+
};
|
|
14
|
+
/** Adds non-disproven findings of a change review. */
|
|
15
|
+
export declare function syncReview(reg: ClosureRegister, review: ChangeReview, now: Date): string[];
|
|
16
|
+
/** A direct transition by a person or agent (ACKNOWLEDGED, IN_PROGRESS, DEFERRED, OPEN). */
|
|
17
|
+
export declare function manualTransition(reg: ClosureRegister, id: string, to: ClosureState, actor: Actor, reason: string, now: Date, extra?: {
|
|
18
|
+
owner?: string;
|
|
19
|
+
revisit?: string;
|
|
20
|
+
}): Transition;
|
|
21
|
+
/** Records a fix commit: IN_PROGRESS → FIX_PROPOSED → RETEST_REQUIRED. Never marks anything fixed. */
|
|
22
|
+
export declare function recordFix(reg: ClosureRegister, ids: string[], changeSha: string, actor: Actor, reproduction: CommandSpec[], policy: AssurancePolicy, now: Date): FixRecord;
|
|
23
|
+
/** Applies a verification run's outcome. */
|
|
24
|
+
export declare function applyVerification(reg: ClosureRegister, run: VerificationRun, now: Date, maxCycles?: number): {
|
|
25
|
+
verified: string[];
|
|
26
|
+
failed: string[];
|
|
27
|
+
escalated: string[];
|
|
28
|
+
unchanged: string[];
|
|
29
|
+
};
|
|
30
|
+
/** Adds signed approvals (deduplicated by ID). */
|
|
31
|
+
export declare function addApproval(reg: ClosureRegister, a: Approval): void;
|
|
32
|
+
/** Closes a verified finding (invariant 3). */
|
|
33
|
+
export declare function closeFinding(reg: ClosureRegister, id: string, verificationId: string, actorId: string, policy: AssurancePolicy, now: Date): RegisterEntry;
|
|
34
|
+
/** Records a v3.2 risk acceptance (invariant 4). */
|
|
35
|
+
export declare function acceptRisk(reg: ClosureRegister, input: Omit<RiskAcceptanceV32, "acceptance_id" | "approval_ids" | "created_at">, policy: AssurancePolicy, sha: string | null, now: Date): RiskAcceptanceV32;
|
|
36
|
+
/** Rejects a finding as a false positive; P0/P1 need signed human approval (secondary support). */
|
|
37
|
+
export declare function rejectFalsePositive(reg: ClosureRegister, id: string, rationale: string, actor: Actor, policy: AssurancePolicy, now: Date): RegisterEntry;
|
|
38
|
+
/** Re-opens accepted risks whose expiry passed (an expired acceptance never stays green). */
|
|
39
|
+
export declare function expireAcceptances(reg: ClosureRegister, now: Date): string[];
|
|
40
|
+
/** Integrity: chain, transition legality, guarded-state invariants and referential integrity. */
|
|
41
|
+
export declare function checkRegister(reg: ClosureRegister): string[];
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 EnWithAI Technologies
|
|
3
|
+
/**
|
|
4
|
+
* The closure register: every finding's verified-closure state machine, with hash-chained transitions.
|
|
5
|
+
*
|
|
6
|
+
* OPEN → ACKNOWLEDGED → IN_PROGRESS → FIX_PROPOSED → RETEST_REQUIRED → VERIFIED_FIXED → CLOSED
|
|
7
|
+
* plus REJECTED_FALSE_POSITIVE, DEFERRED, ACCEPTED_RISK and REGRESSED.
|
|
8
|
+
*
|
|
9
|
+
* Invariants enforced here (and re-checked by `checkRegister` on every load):
|
|
10
|
+
* 1. FIX_PROPOSED is not fixed: a fix only moves the finding to RETEST_REQUIRED.
|
|
11
|
+
* 2. VERIFIED_FIXED only through a PASS verification run against the exact fix commit.
|
|
12
|
+
* 3. CLOSED needs that verification, satisfied acceptance checks, no failing associated gate, signed human
|
|
13
|
+
* approvals for the risk tier from people other than the implementer, an accountable closing human, a time.
|
|
14
|
+
* 4. ACCEPTED_RISK needs owner, scope, rationale, compensating controls, review date, expiry, closure condition
|
|
15
|
+
* and signed approvals; never for P0; it is never shown as fixed or closed.
|
|
16
|
+
* 5. A finding that reappears after VERIFIED_FIXED/CLOSED becomes REGRESSED, keeping its history.
|
|
17
|
+
* 6. Agents and bots cannot approve; a typed approver name proves nothing.
|
|
18
|
+
* 7. After the cycle limit, autonomous fix loops stop until a human signs a "continue" decision.
|
|
19
|
+
* 8. Missing verification never leads to CLOSED.
|
|
20
|
+
* Only the guarded functions below can reach FIX_PROPOSED, RETEST_REQUIRED, VERIFIED_FIXED, CLOSED, ACCEPTED_RISK,
|
|
21
|
+
* REJECTED_FALSE_POSITIVE or REGRESSED; `manualTransition` covers the rest.
|
|
22
|
+
*/
|
|
23
|
+
import { usageError } from "../errors.js";
|
|
24
|
+
import { isOpen } from "../scoring/score.js";
|
|
25
|
+
import { stableId } from "../util/hash.js";
|
|
26
|
+
import { tallyApprovals } from "./approvals.js";
|
|
27
|
+
import { ASSURANCE_SCHEMA_VERSION, METHODOLOGY_VERSION, } from "./types.js";
|
|
28
|
+
import { daysUntil, digestOf, isIsoDate, seqId } from "./util.js";
|
|
29
|
+
import { verifyResultsFinding } from "./verification.js";
|
|
30
|
+
/** Allowed transitions (CT3 v3.2), plus documented out-edges for REGRESSED. */
|
|
31
|
+
export const TRANSITIONS = {
|
|
32
|
+
OPEN: ["ACKNOWLEDGED", "REJECTED_FALSE_POSITIVE", "ACCEPTED_RISK", "DEFERRED"],
|
|
33
|
+
ACKNOWLEDGED: ["IN_PROGRESS", "ACCEPTED_RISK", "DEFERRED", "REJECTED_FALSE_POSITIVE"],
|
|
34
|
+
IN_PROGRESS: ["FIX_PROPOSED"],
|
|
35
|
+
FIX_PROPOSED: ["RETEST_REQUIRED"],
|
|
36
|
+
RETEST_REQUIRED: ["VERIFIED_FIXED", "IN_PROGRESS", "REGRESSED"],
|
|
37
|
+
VERIFIED_FIXED: ["CLOSED", "REGRESSED"],
|
|
38
|
+
CLOSED: ["REGRESSED"],
|
|
39
|
+
ACCEPTED_RISK: ["RETEST_REQUIRED", "OPEN"],
|
|
40
|
+
DEFERRED: ["OPEN"],
|
|
41
|
+
REJECTED_FALSE_POSITIVE: [],
|
|
42
|
+
REGRESSED: ["ACKNOWLEDGED", "IN_PROGRESS", "ACCEPTED_RISK"],
|
|
43
|
+
};
|
|
44
|
+
/** Targets a person or agent may set directly; every other target needs its guarded command. */
|
|
45
|
+
const MANUAL = ["ACKNOWLEDGED", "IN_PROGRESS", "DEFERRED", "OPEN"];
|
|
46
|
+
/** Risk tier of a finding for approval counts. */
|
|
47
|
+
export const findingRisk = (s) => s === "P0" || s === "P1" ? "HIGH" : s === "P2" ? "MEDIUM" : "LOW";
|
|
48
|
+
const SYSTEM = { id: "product-check", type: "system" };
|
|
49
|
+
const noRefs = () => ({
|
|
50
|
+
evidence_ids: [],
|
|
51
|
+
verification_id: null,
|
|
52
|
+
approval_ids: [],
|
|
53
|
+
fix_id: null,
|
|
54
|
+
run_id: null,
|
|
55
|
+
});
|
|
56
|
+
function entry(reg, id) {
|
|
57
|
+
const e = reg.entries.find((x) => x.finding_id === id);
|
|
58
|
+
if (!e)
|
|
59
|
+
throw usageError(`Unknown finding ${id}. Known: ${reg.entries
|
|
60
|
+
.slice(0, 10)
|
|
61
|
+
.map((x) => x.finding_id)
|
|
62
|
+
.join(", ")}${reg.entries.length > 10 ? ", …" : ""}`);
|
|
63
|
+
return e;
|
|
64
|
+
}
|
|
65
|
+
/** Appends a transition (table-checked) to an entry and the chain. */
|
|
66
|
+
function apply(reg, e, to, actor, reason, refs, now) {
|
|
67
|
+
const from = e.transitions.length === 0 ? null : e.state;
|
|
68
|
+
if (from !== null && !TRANSITIONS[from].includes(to))
|
|
69
|
+
throw usageError(`${e.finding_id}: ${from} → ${to} is not an allowed transition (allowed: ${TRANSITIONS[from].join(", ") || "none"}).`);
|
|
70
|
+
const body = {
|
|
71
|
+
seq: reg.chain.length + 1,
|
|
72
|
+
from,
|
|
73
|
+
to,
|
|
74
|
+
at: now.toISOString(),
|
|
75
|
+
actor,
|
|
76
|
+
reason,
|
|
77
|
+
refs: { ...noRefs(), ...refs },
|
|
78
|
+
prev_digest: reg.chain.head_digest,
|
|
79
|
+
};
|
|
80
|
+
const t = { ...body, digest: digestOf({ finding_id: e.finding_id, ...body }) };
|
|
81
|
+
e.transitions.push(t);
|
|
82
|
+
e.state = to;
|
|
83
|
+
reg.chain = { length: t.seq, head_digest: t.digest };
|
|
84
|
+
reg.updated_at = now.toISOString();
|
|
85
|
+
return t;
|
|
86
|
+
}
|
|
87
|
+
function newEntry(reg, fields, now, reason) {
|
|
88
|
+
const e = {
|
|
89
|
+
finding_id: seqId("F", reg.entries.length + 1),
|
|
90
|
+
...fields,
|
|
91
|
+
state: "OPEN",
|
|
92
|
+
cycles: 0,
|
|
93
|
+
escalated: false,
|
|
94
|
+
fix_ids: [],
|
|
95
|
+
verification_ids: [],
|
|
96
|
+
approval_ids: [],
|
|
97
|
+
acceptance_id: null,
|
|
98
|
+
regressions: [],
|
|
99
|
+
closure: null,
|
|
100
|
+
transitions: [],
|
|
101
|
+
};
|
|
102
|
+
reg.entries.push(e);
|
|
103
|
+
apply(reg, e, "OPEN", SYSTEM, reason, { run_id: fields.opened_in }, now);
|
|
104
|
+
return e;
|
|
105
|
+
}
|
|
106
|
+
/** Creates a register from a baseline results document. */
|
|
107
|
+
export function createRegister(results, resultsSha, repo, policy, now) {
|
|
108
|
+
const reg = {
|
|
109
|
+
schema_version: ASSURANCE_SCHEMA_VERSION,
|
|
110
|
+
kind: "closure-register",
|
|
111
|
+
methodology_version: METHODOLOGY_VERSION,
|
|
112
|
+
register_id: stableId("REG", [results.run.run_id, resultsSha]),
|
|
113
|
+
target: {
|
|
114
|
+
name: results.contract.target.name,
|
|
115
|
+
repository: results.run.target_revision.repository?.label ?? null,
|
|
116
|
+
},
|
|
117
|
+
created_at: now.toISOString(),
|
|
118
|
+
updated_at: now.toISOString(),
|
|
119
|
+
baseline: {
|
|
120
|
+
run_id: results.run.run_id,
|
|
121
|
+
results_sha256: resultsSha,
|
|
122
|
+
baseline_sha: results.run.target_revision.repository?.commit ?? null,
|
|
123
|
+
},
|
|
124
|
+
settings: { max_cycles: policy.review.max_cycles },
|
|
125
|
+
entries: [],
|
|
126
|
+
fixes: [],
|
|
127
|
+
verification_runs: [],
|
|
128
|
+
approvals: [],
|
|
129
|
+
risk_acceptances: [],
|
|
130
|
+
regression_corpus: [],
|
|
131
|
+
chain: { length: 0, head_digest: null },
|
|
132
|
+
};
|
|
133
|
+
syncResults(reg, results, repo, now);
|
|
134
|
+
return reg;
|
|
135
|
+
}
|
|
136
|
+
/** Adds findings that are new in a results document and marks reappearing verified/closed ones REGRESSED. */
|
|
137
|
+
export function syncResults(reg, results, repo, now) {
|
|
138
|
+
const added = [];
|
|
139
|
+
const regressed = [];
|
|
140
|
+
const order = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
141
|
+
const open = results.findings
|
|
142
|
+
.filter((f) => isOpen(f) || f.status === "ACCEPTED_RISK")
|
|
143
|
+
.sort((a, b) => order[a.severity] - order[b.severity] || (a.id < b.id ? -1 : 1));
|
|
144
|
+
for (const f of open) {
|
|
145
|
+
const existing = reg.entries.find((e) => e.source_finding_id === f.id);
|
|
146
|
+
if (existing) {
|
|
147
|
+
if (existing.state === "VERIFIED_FIXED" || existing.state === "CLOSED") {
|
|
148
|
+
const prev = existing.state;
|
|
149
|
+
apply(reg, existing, "REGRESSED", SYSTEM, `Observed again in run ${results.run.run_id} after ${prev}; history kept.`, { run_id: results.run.run_id }, now);
|
|
150
|
+
existing.regressions.push({
|
|
151
|
+
at: now.toISOString(),
|
|
152
|
+
run_id: results.run.run_id,
|
|
153
|
+
previous_state: prev,
|
|
154
|
+
});
|
|
155
|
+
regressed.push(existing.finding_id);
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const e = newEntry(reg, {
|
|
160
|
+
source: "baseline-scan",
|
|
161
|
+
source_finding_id: f.id,
|
|
162
|
+
rule_or_control: f.control_id,
|
|
163
|
+
title: f.title,
|
|
164
|
+
severity: f.severity,
|
|
165
|
+
pillar: f.pillar,
|
|
166
|
+
epistemic: f.epistemic,
|
|
167
|
+
location: f.location,
|
|
168
|
+
dedupe_key: digestOf([f.control_id, f.location, f.pop]).slice(0, 20),
|
|
169
|
+
verification: verifyResultsFinding(f, results, repo),
|
|
170
|
+
opened_in: results.run.run_id,
|
|
171
|
+
}, now, `Opened from scan ${results.run.run_id}${f.status === "ACCEPTED_RISK" ? " (a CT3 contract risk acceptance exists; v3.2 requires a signed acceptance via accept-risk)" : ""}.`);
|
|
172
|
+
added.push(e.finding_id);
|
|
173
|
+
}
|
|
174
|
+
return { added, regressed };
|
|
175
|
+
}
|
|
176
|
+
/** Adds non-disproven findings of a change review. */
|
|
177
|
+
export function syncReview(reg, review, now) {
|
|
178
|
+
const added = [];
|
|
179
|
+
for (const f of review.findings) {
|
|
180
|
+
const existing = reg.entries.find((e) => e.source === "change-review" && e.dedupe_key === f.dedupe_key);
|
|
181
|
+
if (existing) {
|
|
182
|
+
if (existing.state === "VERIFIED_FIXED" || existing.state === "CLOSED") {
|
|
183
|
+
const prev = existing.state;
|
|
184
|
+
apply(reg, existing, "REGRESSED", SYSTEM, `Raised again by review ${review.review_id}.`, { run_id: review.review_id }, now);
|
|
185
|
+
existing.regressions.push({ at: now.toISOString(), run_id: review.review_id, previous_state: prev });
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const e = newEntry(reg, {
|
|
190
|
+
source: "change-review",
|
|
191
|
+
source_finding_id: f.id,
|
|
192
|
+
rule_or_control: f.rule_id,
|
|
193
|
+
title: f.title,
|
|
194
|
+
severity: f.severity,
|
|
195
|
+
pillar: f.pillars[0] ?? "technology",
|
|
196
|
+
epistemic: f.epistemic,
|
|
197
|
+
location: `${f.location.path}${f.location.line !== null ? `:${f.location.line}` : ""}`,
|
|
198
|
+
dedupe_key: f.dedupe_key,
|
|
199
|
+
verification: f.verification,
|
|
200
|
+
opened_in: review.review_id,
|
|
201
|
+
}, now, `Opened from change review ${review.review_id} (${f.verification.state}).`);
|
|
202
|
+
added.push(e.finding_id);
|
|
203
|
+
}
|
|
204
|
+
return added;
|
|
205
|
+
}
|
|
206
|
+
/** A direct transition by a person or agent (ACKNOWLEDGED, IN_PROGRESS, DEFERRED, OPEN). */
|
|
207
|
+
export function manualTransition(reg, id, to, actor, reason, now, extra = {}) {
|
|
208
|
+
if (!MANUAL.includes(to))
|
|
209
|
+
throw usageError(`${to} cannot be set directly. Use: fix (FIX_PROPOSED/RETEST_REQUIRED), verify (VERIFIED_FIXED), close (CLOSED), accept-risk (ACCEPTED_RISK), reject (REJECTED_FALSE_POSITIVE).`);
|
|
210
|
+
if (!reason.trim())
|
|
211
|
+
throw usageError("A transition needs --reason.");
|
|
212
|
+
const e = entry(reg, id);
|
|
213
|
+
if (to === "DEFERRED") {
|
|
214
|
+
if (e.severity === "P0")
|
|
215
|
+
throw usageError(`${id} is P0; a P0 cannot be deferred (it blocks release under CT3).`);
|
|
216
|
+
if (!extra.owner || !extra.revisit || !isIsoDate(extra.revisit))
|
|
217
|
+
throw usageError("DEFERRED needs --owner and --revisit YYYY-MM-DD.");
|
|
218
|
+
}
|
|
219
|
+
if (to === "IN_PROGRESS" && e.escalated && e.state !== "REGRESSED")
|
|
220
|
+
throw usageError(`${id} reached the review-cycle limit and is escalated; a human must sign a "continue" decision (approve --scope continue:${id}) and then record the next fix.`);
|
|
221
|
+
return apply(reg, e, to, actor, to === "DEFERRED" ? `${reason} (owner ${extra.owner}, revisit ${extra.revisit})` : reason, {}, now);
|
|
222
|
+
}
|
|
223
|
+
/** Records a fix commit: IN_PROGRESS → FIX_PROPOSED → RETEST_REQUIRED. Never marks anything fixed. */
|
|
224
|
+
export function recordFix(reg, ids, changeSha, actor, reproduction, policy, now) {
|
|
225
|
+
if (!/^[0-9a-f]{40,64}$/.test(changeSha))
|
|
226
|
+
throw usageError("fix needs the full commit SHA of the change (--sha).");
|
|
227
|
+
if (ids.length === 0)
|
|
228
|
+
throw usageError("fix needs at least one --finding.");
|
|
229
|
+
const entries = ids.map((id) => entry(reg, id));
|
|
230
|
+
for (const e of entries) {
|
|
231
|
+
if (e.state !== "IN_PROGRESS")
|
|
232
|
+
throw usageError(`${e.finding_id} is ${e.state}; move it to IN_PROGRESS first (transition --to IN_PROGRESS).`);
|
|
233
|
+
if (e.escalated) {
|
|
234
|
+
const t = tallyApprovals(reg.approvals, policy, {
|
|
235
|
+
scopeType: "continue",
|
|
236
|
+
ref: e.finding_id,
|
|
237
|
+
sha: null,
|
|
238
|
+
excluded: actor.type === "human" ? [] : [actor.id],
|
|
239
|
+
}, 1);
|
|
240
|
+
if (!t.satisfied)
|
|
241
|
+
throw usageError(`${e.finding_id} is escalated after ${e.cycles} failed cycle(s); a signed human "continue" approval is required before another fix attempt.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const fix = {
|
|
245
|
+
fix_id: seqId("FX", reg.fixes.length + 1),
|
|
246
|
+
finding_ids: ids,
|
|
247
|
+
change_sha: changeSha,
|
|
248
|
+
implemented_by: actor,
|
|
249
|
+
status: "IMPLEMENTED_UNVERIFIED",
|
|
250
|
+
reproduction,
|
|
251
|
+
recorded_at: now.toISOString(),
|
|
252
|
+
};
|
|
253
|
+
reg.fixes.push(fix);
|
|
254
|
+
for (const e of entries) {
|
|
255
|
+
e.fix_ids.push(fix.fix_id);
|
|
256
|
+
apply(reg, e, "FIX_PROPOSED", actor, `Fix ${fix.fix_id} submitted at ${changeSha.slice(0, 12)} (proposed, not verified).`, { fix_id: fix.fix_id }, now);
|
|
257
|
+
apply(reg, e, "RETEST_REQUIRED", SYSTEM, "A proposed fix needs targeted reproduction and regression against the exact commit.", { fix_id: fix.fix_id }, now);
|
|
258
|
+
}
|
|
259
|
+
return fix;
|
|
260
|
+
}
|
|
261
|
+
/** Applies a verification run's outcome. */
|
|
262
|
+
export function applyVerification(reg, run, now, maxCycles = reg.settings.max_cycles) {
|
|
263
|
+
reg.settings.max_cycles = maxCycles;
|
|
264
|
+
reg.verification_runs.push(run);
|
|
265
|
+
const out = {
|
|
266
|
+
verified: [],
|
|
267
|
+
failed: [],
|
|
268
|
+
escalated: [],
|
|
269
|
+
unchanged: [],
|
|
270
|
+
};
|
|
271
|
+
const fix = reg.fixes.find((f) => f.fix_id === run.fix_id);
|
|
272
|
+
for (const id of run.finding_ids) {
|
|
273
|
+
const e = entry(reg, id);
|
|
274
|
+
e.verification_ids.push(run.verification_id);
|
|
275
|
+
const refs = {
|
|
276
|
+
verification_id: run.verification_id,
|
|
277
|
+
fix_id: run.fix_id,
|
|
278
|
+
evidence_ids: run.after_evidence_ids,
|
|
279
|
+
};
|
|
280
|
+
if (run.result === "PASS" && e.state === "RETEST_REQUIRED") {
|
|
281
|
+
apply(reg, e, "VERIFIED_FIXED", SYSTEM, `Verification ${run.verification_id} passed at ${run.exact_sha?.slice(0, 12) ?? "?"} (targeted + regression).`, refs, now);
|
|
282
|
+
out.verified.push(id);
|
|
283
|
+
}
|
|
284
|
+
else if (run.result === "FAIL" && e.state === "RETEST_REQUIRED") {
|
|
285
|
+
e.cycles++;
|
|
286
|
+
apply(reg, e, "IN_PROGRESS", SYSTEM, `Verification ${run.verification_id} failed (cycle ${e.cycles}/${reg.settings.max_cycles}): ${run.reasons.join(" ")}`, refs, now);
|
|
287
|
+
out.failed.push(id);
|
|
288
|
+
if (e.cycles >= reg.settings.max_cycles) {
|
|
289
|
+
e.escalated = true;
|
|
290
|
+
out.escalated.push(id);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else
|
|
294
|
+
out.unchanged.push(id);
|
|
295
|
+
}
|
|
296
|
+
if (fix)
|
|
297
|
+
fix.status =
|
|
298
|
+
run.result === "PASS" ? "VERIFIED" : run.result === "FAIL" ? "IMPLEMENTED_UNVERIFIED" : fix.status;
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
/** Adds signed approvals (deduplicated by ID). */
|
|
302
|
+
export function addApproval(reg, a) {
|
|
303
|
+
if (!reg.approvals.some((x) => x.approval_id === a.approval_id))
|
|
304
|
+
reg.approvals.push(a);
|
|
305
|
+
}
|
|
306
|
+
/** Closes a verified finding (invariant 3). */
|
|
307
|
+
export function closeFinding(reg, id, verificationId, actorId, policy, now) {
|
|
308
|
+
const e = entry(reg, id);
|
|
309
|
+
if (e.state !== "VERIFIED_FIXED")
|
|
310
|
+
throw usageError(`${id} is ${e.state}; only VERIFIED_FIXED findings can be closed (missing verification never closes).`);
|
|
311
|
+
const run = reg.verification_runs.find((r) => r.verification_id === verificationId);
|
|
312
|
+
if (!run || !e.verification_ids.includes(verificationId))
|
|
313
|
+
throw usageError(`${verificationId} is not a verification run of ${id}.`);
|
|
314
|
+
const latest = e.verification_ids.at(-1);
|
|
315
|
+
if (latest !== verificationId)
|
|
316
|
+
throw usageError(`${verificationId} is not the latest verification of ${id} (${latest ?? "none"}).`);
|
|
317
|
+
if (run.result !== "PASS" || run.exact_sha === null)
|
|
318
|
+
throw usageError(`${verificationId} did not pass against an exact commit.`);
|
|
319
|
+
const failedChecks = run.checks.filter((c) => c.role === "targeted" && c.status !== "PASS");
|
|
320
|
+
if (failedChecks.length > 0)
|
|
321
|
+
throw usageError(`Acceptance checks not satisfied: ${failedChecks.map((c) => c.check_id).join(", ")}.`);
|
|
322
|
+
const gate = run.checks.find((c) => c.kind === "gate-recheck");
|
|
323
|
+
if (gate && gate.status === "FAIL")
|
|
324
|
+
throw usageError(`An associated hard gate still fails: ${gate.detail}`);
|
|
325
|
+
const fix = reg.fixes.find((f) => f.fix_id === run.fix_id);
|
|
326
|
+
const excluded = fix ? [fix.implemented_by.id] : [];
|
|
327
|
+
const required = policy.risk.approvals[findingRisk(e.severity)];
|
|
328
|
+
const tally = tallyApprovals(reg.approvals, policy, { scopeType: "closure", ref: id, sha: run.exact_sha, excluded }, Math.max(1, required));
|
|
329
|
+
if (!tally.satisfied)
|
|
330
|
+
throw usageError(`${id} needs ${Math.max(1, required)} valid signed human approval(s) for closure at ${run.exact_sha.slice(0, 12)}; found ${tally.validApprovers.length}.`, tally.evaluations.map((x) => `${x.approval_id} ${x.status}: ${x.reason}`).join("\n") ||
|
|
331
|
+
`Sign one: product-check approve --scope closure:${id} --sha ${run.exact_sha} --approver <id> --key <private.pem>`);
|
|
332
|
+
if (!tally.validApprovers.includes(actorId))
|
|
333
|
+
throw usageError(`The closing actor (${actorId}) must be one of the accountable approvers: ${tally.validApprovers.join(", ")}.`);
|
|
334
|
+
const approvalIds = tally.evaluations.filter((x) => x.status === "VALID").map((x) => x.approval_id);
|
|
335
|
+
const actor = { id: actorId, type: "human" };
|
|
336
|
+
const closureId = seqId("CL", reg.entries.filter((x) => x.closure !== null).length + 1);
|
|
337
|
+
e.approval_ids.push(...approvalIds.filter((a) => !e.approval_ids.includes(a)));
|
|
338
|
+
apply(reg, e, "CLOSED", actor, `Closed as ${closureId} on verification ${verificationId} with ${approvalIds.length} signed approval(s).`, {
|
|
339
|
+
verification_id: verificationId,
|
|
340
|
+
approval_ids: approvalIds,
|
|
341
|
+
fix_id: run.fix_id,
|
|
342
|
+
evidence_ids: run.after_evidence_ids,
|
|
343
|
+
}, now);
|
|
344
|
+
e.closure = {
|
|
345
|
+
closure_id: closureId,
|
|
346
|
+
actor,
|
|
347
|
+
at: now.toISOString(),
|
|
348
|
+
verification_id: verificationId,
|
|
349
|
+
approval_ids: approvalIds,
|
|
350
|
+
};
|
|
351
|
+
return e;
|
|
352
|
+
}
|
|
353
|
+
/** Records a v3.2 risk acceptance (invariant 4). */
|
|
354
|
+
export function acceptRisk(reg, input, policy, sha, now) {
|
|
355
|
+
const e = entry(reg, input.finding_id);
|
|
356
|
+
if (e.severity === "P0")
|
|
357
|
+
throw usageError(`${e.finding_id} is P0; CT3 does not allow accepting P0 risk (it always blocks release).`);
|
|
358
|
+
const missing = ["owner", "scope", "rationale", "closure_condition"].filter((k) => !input[k].trim());
|
|
359
|
+
if (input.compensating_controls.length === 0)
|
|
360
|
+
missing.push("compensating_controls");
|
|
361
|
+
if (missing.length > 0)
|
|
362
|
+
throw usageError(`Risk acceptance is missing: ${missing.join(", ")}.`);
|
|
363
|
+
if (!isIsoDate(input.review_date) || !isIsoDate(input.expires))
|
|
364
|
+
throw usageError("--review-date and --expires must be YYYY-MM-DD.");
|
|
365
|
+
if (daysUntil(input.expires, now) < 0)
|
|
366
|
+
throw usageError("The expiry date is in the past.");
|
|
367
|
+
if (input.review_date > input.expires)
|
|
368
|
+
throw usageError("The review date must not be after the expiry date.");
|
|
369
|
+
const tally = tallyApprovals(reg.approvals, policy, { scopeType: "risk-acceptance", ref: e.finding_id, sha, excluded: [] }, policy.risk.approvals[findingRisk(e.severity)]);
|
|
370
|
+
if (!tally.satisfied)
|
|
371
|
+
throw usageError(`Accepting ${e.finding_id} needs ${policy.risk.approvals[findingRisk(e.severity)]} signed human approval(s) (scope risk-acceptance:${e.finding_id}${sha ? `, sha ${sha.slice(0, 12)}` : ""}); found ${tally.validApprovers.length}.`);
|
|
372
|
+
const acc = {
|
|
373
|
+
...input,
|
|
374
|
+
acceptance_id: seqId("RA", reg.risk_acceptances.length + 1),
|
|
375
|
+
approval_ids: tally.evaluations.filter((x) => x.status === "VALID").map((x) => x.approval_id),
|
|
376
|
+
created_at: now.toISOString(),
|
|
377
|
+
};
|
|
378
|
+
reg.risk_acceptances.push(acc);
|
|
379
|
+
e.acceptance_id = acc.acceptance_id;
|
|
380
|
+
apply(reg, e, "ACCEPTED_RISK", { id: input.owner, type: "human" }, `Risk accepted as ${acc.acceptance_id} until ${acc.expires} (review ${acc.review_date}); not fixed, not closed.`, { approval_ids: acc.approval_ids }, now);
|
|
381
|
+
return acc;
|
|
382
|
+
}
|
|
383
|
+
/** Rejects a finding as a false positive; P0/P1 need signed human approval (secondary support). */
|
|
384
|
+
export function rejectFalsePositive(reg, id, rationale, actor, policy, now) {
|
|
385
|
+
const e = entry(reg, id);
|
|
386
|
+
if (!rationale.trim())
|
|
387
|
+
throw usageError("A false-positive rejection needs --reason.");
|
|
388
|
+
let approvalIds = [];
|
|
389
|
+
if (e.severity === "P0" || e.severity === "P1") {
|
|
390
|
+
const tally = tallyApprovals(reg.approvals, policy, { scopeType: "false-positive", ref: id, sha: null, excluded: [] }, 1);
|
|
391
|
+
if (!tally.satisfied && e.verification.state !== "DISPROVEN")
|
|
392
|
+
throw usageError(`${id} is ${e.severity}; rejecting it as a false positive needs a signed human approval (scope false-positive:${id}) or a DISPROVEN verification.`);
|
|
393
|
+
approvalIds = tally.evaluations.filter((x) => x.status === "VALID").map((x) => x.approval_id);
|
|
394
|
+
}
|
|
395
|
+
apply(reg, e, "REJECTED_FALSE_POSITIVE", actor, rationale, { approval_ids: approvalIds }, now);
|
|
396
|
+
return e;
|
|
397
|
+
}
|
|
398
|
+
/** Re-opens accepted risks whose expiry passed (an expired acceptance never stays green). */
|
|
399
|
+
export function expireAcceptances(reg, now) {
|
|
400
|
+
const reopened = [];
|
|
401
|
+
for (const e of reg.entries.filter((x) => x.state === "ACCEPTED_RISK")) {
|
|
402
|
+
const acc = reg.risk_acceptances.find((a) => a.acceptance_id === e.acceptance_id);
|
|
403
|
+
if (acc && daysUntil(acc.expires, now) < 0) {
|
|
404
|
+
apply(reg, e, "OPEN", SYSTEM, `Risk acceptance ${acc.acceptance_id} expired on ${acc.expires}.`, {}, now);
|
|
405
|
+
reopened.push(e.finding_id);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return reopened;
|
|
409
|
+
}
|
|
410
|
+
/** Integrity: chain, transition legality, guarded-state invariants and referential integrity. */
|
|
411
|
+
export function checkRegister(reg) {
|
|
412
|
+
const errors = [];
|
|
413
|
+
const all = reg.entries
|
|
414
|
+
.flatMap((e) => e.transitions.map((t) => ({ e, t })))
|
|
415
|
+
.sort((a, b) => a.t.seq - b.t.seq);
|
|
416
|
+
let prev = null;
|
|
417
|
+
all.forEach(({ e, t }, i) => {
|
|
418
|
+
if (t.seq !== i + 1)
|
|
419
|
+
errors.push(`transition seq ${t.seq} out of order (expected ${i + 1})`);
|
|
420
|
+
if (t.prev_digest !== prev)
|
|
421
|
+
errors.push(`${e.finding_id} seq ${t.seq}: broken chain link`);
|
|
422
|
+
const { digest, ...body } = t;
|
|
423
|
+
if (digestOf({ finding_id: e.finding_id, ...body }) !== digest)
|
|
424
|
+
errors.push(`${e.finding_id} seq ${t.seq}: transition edited (digest mismatch)`);
|
|
425
|
+
prev = t.digest;
|
|
426
|
+
});
|
|
427
|
+
if (reg.chain.length !== all.length || reg.chain.head_digest !== prev)
|
|
428
|
+
errors.push("chain head does not match the last transition");
|
|
429
|
+
const runs = new Map(reg.verification_runs.map((r) => [r.verification_id, r]));
|
|
430
|
+
const fixes = new Map(reg.fixes.map((f) => [f.fix_id, f]));
|
|
431
|
+
const approvals = new Set(reg.approvals.map((a) => a.approval_id));
|
|
432
|
+
for (const e of reg.entries) {
|
|
433
|
+
let state = null;
|
|
434
|
+
for (const t of e.transitions) {
|
|
435
|
+
const cur = state;
|
|
436
|
+
if (t.from !== cur)
|
|
437
|
+
errors.push(`${e.finding_id} seq ${t.seq}: from ${t.from} but state was ${cur}`);
|
|
438
|
+
if (cur !== null && !TRANSITIONS[cur].includes(t.to))
|
|
439
|
+
errors.push(`${e.finding_id} seq ${t.seq}: ${cur} → ${t.to} is not allowed`);
|
|
440
|
+
if (t.to === "VERIFIED_FIXED") {
|
|
441
|
+
const run = t.refs.verification_id ? runs.get(t.refs.verification_id) : undefined;
|
|
442
|
+
const fix = t.refs.fix_id ? fixes.get(t.refs.fix_id) : undefined;
|
|
443
|
+
if (!run || run.result !== "PASS")
|
|
444
|
+
errors.push(`${e.finding_id}: VERIFIED_FIXED without a passing verification run`);
|
|
445
|
+
else if (!fix || run.exact_sha !== fix.change_sha)
|
|
446
|
+
errors.push(`${e.finding_id}: VERIFIED_FIXED not verified against the exact fix commit`);
|
|
447
|
+
if (t.actor.type !== "system")
|
|
448
|
+
errors.push(`${e.finding_id}: VERIFIED_FIXED set by ${t.actor.type}, not by a verification run`);
|
|
449
|
+
}
|
|
450
|
+
if (t.to === "CLOSED") {
|
|
451
|
+
if (t.actor.type !== "human")
|
|
452
|
+
errors.push(`${e.finding_id}: CLOSED by a non-human actor`);
|
|
453
|
+
if (!t.refs.verification_id || !runs.has(t.refs.verification_id))
|
|
454
|
+
errors.push(`${e.finding_id}: CLOSED without verification`);
|
|
455
|
+
if (t.refs.approval_ids.length === 0)
|
|
456
|
+
errors.push(`${e.finding_id}: CLOSED without approvals`);
|
|
457
|
+
}
|
|
458
|
+
if (t.to === "ACCEPTED_RISK" &&
|
|
459
|
+
(!e.acceptance_id || !reg.risk_acceptances.some((a) => a.acceptance_id === e.acceptance_id)))
|
|
460
|
+
errors.push(`${e.finding_id}: ACCEPTED_RISK without a recorded acceptance`);
|
|
461
|
+
for (const a of t.refs.approval_ids)
|
|
462
|
+
if (!approvals.has(a))
|
|
463
|
+
errors.push(`${e.finding_id}: unknown approval ${a}`);
|
|
464
|
+
state = t.to;
|
|
465
|
+
}
|
|
466
|
+
if (state !== e.state)
|
|
467
|
+
errors.push(`${e.finding_id}: state ${e.state} does not match its transitions (${state})`);
|
|
468
|
+
for (const f of e.fix_ids)
|
|
469
|
+
if (!fixes.has(f))
|
|
470
|
+
errors.push(`${e.finding_id}: unknown fix ${f}`);
|
|
471
|
+
for (const v of e.verification_ids)
|
|
472
|
+
if (!runs.has(v))
|
|
473
|
+
errors.push(`${e.finding_id}: unknown verification ${v}`);
|
|
474
|
+
}
|
|
475
|
+
for (const f of reg.fixes)
|
|
476
|
+
for (const id of f.finding_ids)
|
|
477
|
+
if (!reg.entries.some((e) => e.finding_id === id))
|
|
478
|
+
errors.push(`${f.fix_id}: unknown finding ${id}`);
|
|
479
|
+
return errors;
|
|
480
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Results } from "../types.js";
|
|
2
|
+
import type { ChangeReview, ClosureRegister, FixPacket, RegisterEntry, ReleaseGate } from "./types.js";
|
|
3
|
+
/** Appends the Engineering Commons footer used by every Product Check rendering (omitted with --no-branding). */
|
|
4
|
+
export declare const withFooter: (text: string, branding: boolean) => string;
|
|
5
|
+
/** PRODUCT_CHECK_REVIEW.md */
|
|
6
|
+
export declare function renderReviewMd(review: ChangeReview | null, reg: ClosureRegister | null, results: Results): string;
|
|
7
|
+
/** As-is and to-be Mermaid diagrams. */
|
|
8
|
+
export declare function mermaidViews(results: Results, packets: FixPacket[], reg: ClosureRegister | null): {
|
|
9
|
+
asIs: string;
|
|
10
|
+
toBe: string;
|
|
11
|
+
};
|
|
12
|
+
/** PRODUCT_CHECK_BLUEPRINT.md */
|
|
13
|
+
export declare function renderBlueprintMd(results: Results, packets: FixPacket[], reg: ClosureRegister | null, gate: ReleaseGate | null): string;
|
|
14
|
+
/** PRODUCT_CHECK_RELEASE_GATE.md */
|
|
15
|
+
export declare function renderGateMd(gate: ReleaseGate): string;
|
|
16
|
+
/** Human review packet for an escalated finding (cycle limit reached). */
|
|
17
|
+
export declare function renderHumanPacket(e: RegisterEntry, reg: ClosureRegister): string;
|