proof-harness 0.1.0-next.1
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/COMPATIBILITY.md +26 -0
- package/LICENSE +201 -0
- package/PROOF_SDK_CONTRACT.md +1255 -0
- package/README.md +60 -0
- package/cli/config.mjs +116 -0
- package/cli/engines/generate_proof_action_registry.mjs +271 -0
- package/cli/engines/parse_proof_schema.mjs +457 -0
- package/cli/engines/proof_coverage.mjs +871 -0
- package/cli/engines/proof_drift.mjs +980 -0
- package/cli/engines/proof_module_check.mjs +498 -0
- package/cli/engines/proof_mutation_check.mjs +1084 -0
- package/cli/engines/proof_mutation_inventory.mjs +508 -0
- package/cli/engines/proof_source_mutation.mjs +35 -0
- package/cli/engines/proof_verify.mjs +620 -0
- package/cli/engines/scan_module_meta.mjs +352 -0
- package/cli/engines/scan_proof_capabilities.mjs +359 -0
- package/cli/proof-harness.mjs +107 -0
- package/dist/action-types-C3OmHRyH.d.ts +26 -0
- package/dist/fixture-B-dmnG5G.d.ts +105 -0
- package/dist/mission-types-Dr5rBHYO.d.ts +299 -0
- package/dist/node.d.ts +79 -0
- package/dist/node.js +553 -0
- package/dist/node.js.map +1 -0
- package/dist/playwright.d.ts +433 -0
- package/dist/playwright.js +1824 -0
- package/dist/playwright.js.map +1 -0
- package/dist/portable-vocabulary.d.ts +86 -0
- package/dist/portable-vocabulary.js +43 -0
- package/dist/portable-vocabulary.js.map +1 -0
- package/dist/server.d.ts +55 -0
- package/dist/server.js +376 -0
- package/dist/server.js.map +1 -0
- package/dist/shared.d.ts +169 -0
- package/dist/shared.js +121 -0
- package/dist/shared.js.map +1 -0
- package/dist/trace-types-DkIzq-l9.d.ts +333 -0
- package/package.json +109 -0
- package/proof-consumer-fixtures/README.md +60 -0
- package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-FAIL.json +112 -0
- package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-PASS.json +101 -0
- package/proof-consumer-fixtures/baseline/failing/widgets-tenant-isolation.json +46 -0
- package/proof-consumer-fixtures/baseline/passing/widgets-tenant-isolation.json +45 -0
- package/proof-consumer-fixtures/corpus.json +102 -0
- package/proof-consumer-fixtures/duplicates/first/widgets-tenant-isolation.json +12 -0
- package/proof-consumer-fixtures/duplicates/second/widgets-tenant-isolation.json +12 -0
- package/proof-consumer-fixtures/inputs/capabilities.json +4 -0
- package/proof-consumer-fixtures/inputs/mission.json +20 -0
- package/proof-consumer-fixtures/inputs/schema.json +4 -0
- package/proof-consumer-fixtures/malformed/invalid-trace.json.invalid +4 -0
- package/proof-consumer-fixtures/malformed/wrong-manifest-version.json +11 -0
- package/proof-consumer-fixtures/malformed/wrong-trace-shape.json +8 -0
- package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/mutation.json +17 -0
- package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/widgets-tenant-isolation.json +50 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function normalizePath(value) {
|
|
6
|
+
return value.split(path.sep).join("/");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readJson(file) {
|
|
10
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function specHash(file) {
|
|
14
|
+
return crypto
|
|
15
|
+
.createHash("sha256")
|
|
16
|
+
.update(fs.readFileSync(file))
|
|
17
|
+
.digest("hex")
|
|
18
|
+
.slice(0, 12);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isPrimary(assertion) {
|
|
22
|
+
return (
|
|
23
|
+
assertion.role === undefined ||
|
|
24
|
+
assertion.role === null ||
|
|
25
|
+
assertion.role === "primary" ||
|
|
26
|
+
assertion.role === "negative"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveTable(target, tableNames) {
|
|
31
|
+
if (typeof target !== "string") return null;
|
|
32
|
+
const table = target.split(".", 1)[0];
|
|
33
|
+
return tableNames.has(table) ? table : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isActionTarget(target) {
|
|
37
|
+
return (
|
|
38
|
+
typeof target === "string" && /^[A-Za-z][\w-]*:[A-Za-z][\w-]*$/.test(target)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CLAIM_OPERATIONS = new Set([
|
|
43
|
+
"select",
|
|
44
|
+
"insert",
|
|
45
|
+
"update",
|
|
46
|
+
"delete",
|
|
47
|
+
"invoke",
|
|
48
|
+
"request",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
export function mutationClaimKey(claim) {
|
|
52
|
+
return `${claim.spec}\0${claim.kind}\0${claim.target}\0${claim.operation}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function claimSelectorMatches(selector, claim) {
|
|
56
|
+
return (
|
|
57
|
+
selector.kind === claim.kind &&
|
|
58
|
+
selector.target === claim.target &&
|
|
59
|
+
(selector.operation === undefined || selector.operation === claim.operation)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function targetTable(target) {
|
|
64
|
+
if (typeof target !== "string") return null;
|
|
65
|
+
if (target.startsWith("/") || isActionTarget(target)) return null;
|
|
66
|
+
return target.split(".", 1)[0] || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function subjectFunctionName(signature) {
|
|
70
|
+
if (typeof signature !== "string") return null;
|
|
71
|
+
const withoutArgs = signature.split("(", 1)[0];
|
|
72
|
+
return withoutArgs.split(".").at(-1) ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function mutationCoversClaim(mutation, claim) {
|
|
76
|
+
if (mutation.spec !== claim.spec) return false;
|
|
77
|
+
|
|
78
|
+
if (Array.isArray(mutation.claims)) {
|
|
79
|
+
return mutation.claims.some((selector) =>
|
|
80
|
+
claimSelectorMatches(selector, claim),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const subject = mutation.subject;
|
|
85
|
+
if (!subject) return false;
|
|
86
|
+
|
|
87
|
+
if (subject.kind === "functionPrivilege") {
|
|
88
|
+
return subjectFunctionName(subject.signature) === claim.target;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof subject.table !== "string") return false;
|
|
92
|
+
const table = subject.table.replace(/^public\./, "");
|
|
93
|
+
if (targetTable(claim.target) !== table) return false;
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
subject.kind === "tablePrivilege" &&
|
|
97
|
+
typeof subject.privilege === "string"
|
|
98
|
+
) {
|
|
99
|
+
return subject.privilege.toLowerCase() === claim.operation;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function explicitMutationCoversClaim(mutation, claim) {
|
|
106
|
+
if (mutation.spec !== claim.spec) return false;
|
|
107
|
+
if (
|
|
108
|
+
mutation.subject?.kind !== "policy" &&
|
|
109
|
+
mutation.subject?.kind !== "rowLevelSecurity"
|
|
110
|
+
) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return mutation.subject.table === `public.${claim.table}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validObservedAt(value) {
|
|
117
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const parsed = Date.parse(`${value}T00:00:00.000Z`);
|
|
121
|
+
return Number.isFinite(parsed) && parsed <= Date.now();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Join fresh primary claims to mutations and committed policy exceptions.
|
|
126
|
+
* Policy entries are intentionally hash-pinned and control-backed: editing a
|
|
127
|
+
* proof or deleting the positive control makes the acceptance stale.
|
|
128
|
+
*/
|
|
129
|
+
export function evaluateMutationClaimCoverage({
|
|
130
|
+
claims,
|
|
131
|
+
evidence,
|
|
132
|
+
mutations,
|
|
133
|
+
policy,
|
|
134
|
+
}) {
|
|
135
|
+
const problems = [];
|
|
136
|
+
const covered = new Set();
|
|
137
|
+
|
|
138
|
+
for (const claim of claims) {
|
|
139
|
+
if (mutations.some((mutation) => mutationCoversClaim(mutation, claim))) {
|
|
140
|
+
covered.add(mutationClaimKey(claim));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const accepted = new Set();
|
|
145
|
+
const seenAcceptances = new Set();
|
|
146
|
+
for (const entry of policy?.acceptedClaims ?? []) {
|
|
147
|
+
const key = mutationClaimKey(entry);
|
|
148
|
+
if (seenAcceptances.has(key)) {
|
|
149
|
+
problems.push(
|
|
150
|
+
`duplicate accepted mutation claim ${entry.kind}:${entry.target}:${entry.operation} in ${entry.spec}`,
|
|
151
|
+
);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
seenAcceptances.add(key);
|
|
155
|
+
|
|
156
|
+
const claim = claims.find(
|
|
157
|
+
(candidate) => mutationClaimKey(candidate) === key,
|
|
158
|
+
);
|
|
159
|
+
if (!claim) {
|
|
160
|
+
problems.push(
|
|
161
|
+
`accepted mutation claim ${entry.kind}:${entry.target}:${entry.operation} in ${entry.spec} is stale — no fresh passing primary assertion has that claim`,
|
|
162
|
+
);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (covered.has(key)) {
|
|
166
|
+
problems.push(
|
|
167
|
+
`accepted mutation claim ${entry.kind}:${entry.target}:${entry.operation} in ${entry.spec} is stale — a mutation now covers it`,
|
|
168
|
+
);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (entry.specHash !== claim.specHash) {
|
|
172
|
+
problems.push(
|
|
173
|
+
`accepted mutation claim ${entry.kind}:${entry.target}:${entry.operation} in ${entry.spec} is stale — expected specHash ${claim.specHash}, found ${entry.specHash ?? "<missing>"}`,
|
|
174
|
+
);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!validObservedAt(entry.reviewedAt)) {
|
|
178
|
+
problems.push(
|
|
179
|
+
`accepted mutation claim ${entry.kind}:${entry.target}:${entry.operation} in ${entry.spec} needs a valid non-future reviewedAt date`,
|
|
180
|
+
);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
typeof entry.expectedFailureCategory !== "string" ||
|
|
185
|
+
entry.expectedFailureCategory.trim() === ""
|
|
186
|
+
) {
|
|
187
|
+
problems.push(
|
|
188
|
+
`accepted mutation claim ${entry.kind}:${entry.target}:${entry.operation} in ${entry.spec} needs expectedFailureCategory`,
|
|
189
|
+
);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (typeof entry.reason !== "string" || entry.reason.trim().length < 20) {
|
|
193
|
+
problems.push(
|
|
194
|
+
`accepted mutation claim ${entry.kind}:${entry.target}:${entry.operation} in ${entry.spec} needs a durable reason`,
|
|
195
|
+
);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const control = entry.survivingPositiveControl;
|
|
200
|
+
const controlFound =
|
|
201
|
+
control &&
|
|
202
|
+
evidence.some(
|
|
203
|
+
(item) =>
|
|
204
|
+
item.spec === entry.spec &&
|
|
205
|
+
item.kind === control.kind &&
|
|
206
|
+
item.target === control.target &&
|
|
207
|
+
(control.operation === undefined ||
|
|
208
|
+
item.operation === control.operation) &&
|
|
209
|
+
(item.role === "control" || item.kind === "happy_path"),
|
|
210
|
+
);
|
|
211
|
+
if (!controlFound) {
|
|
212
|
+
problems.push(
|
|
213
|
+
`accepted mutation claim ${entry.kind}:${entry.target}:${entry.operation} in ${entry.spec} has no fresh surviving positive control matching ${control?.kind ?? "<missing>"}:${control?.target ?? "<missing>"}`,
|
|
214
|
+
);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
accepted.add(key);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const uncoveredClaims = claims.filter((claim) => {
|
|
222
|
+
const key = mutationClaimKey(claim);
|
|
223
|
+
return !covered.has(key) && !accepted.has(key);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
coveredClaims: claims.filter((claim) =>
|
|
228
|
+
covered.has(mutationClaimKey(claim)),
|
|
229
|
+
),
|
|
230
|
+
acceptedClaims: claims.filter((claim) =>
|
|
231
|
+
accepted.has(mutationClaimKey(claim)),
|
|
232
|
+
),
|
|
233
|
+
uncoveredClaims,
|
|
234
|
+
problems,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function automaticMutationId(claim) {
|
|
239
|
+
const specName = path.basename(claim.spec, ".proof.ts");
|
|
240
|
+
return `AUTO-RLS-${specName}-${claim.table}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function traceFiles(tracesDir) {
|
|
244
|
+
if (!fs.existsSync(tracesDir)) return [];
|
|
245
|
+
return fs
|
|
246
|
+
.readdirSync(tracesDir)
|
|
247
|
+
.filter((file) => file.endsWith(".json"))
|
|
248
|
+
.map((file) => path.join(tracesDir, file))
|
|
249
|
+
.sort();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Derive broad, policy-name-independent RLS mutations from fresh green
|
|
254
|
+
* evidence. Only passing primary tenant-isolation assertions for classified
|
|
255
|
+
* scoped tables count. Existing narrow policy/RLS mutations remain
|
|
256
|
+
* authoritative and are not duplicated.
|
|
257
|
+
*/
|
|
258
|
+
export function deriveAutomaticRlsMutations({
|
|
259
|
+
tracesDir,
|
|
260
|
+
schemaPath,
|
|
261
|
+
explicitMutations,
|
|
262
|
+
rootDir = process.cwd(),
|
|
263
|
+
}) {
|
|
264
|
+
const problems = [];
|
|
265
|
+
let schema;
|
|
266
|
+
try {
|
|
267
|
+
schema = readJson(schemaPath);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return {
|
|
270
|
+
mutations: [],
|
|
271
|
+
problems: [
|
|
272
|
+
`cannot derive automatic RLS mutations because ${normalizePath(
|
|
273
|
+
path.relative(rootDir, schemaPath),
|
|
274
|
+
)} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
275
|
+
],
|
|
276
|
+
claims: [],
|
|
277
|
+
evidence: [],
|
|
278
|
+
uncoveredActionClaims: [],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const classifications = new Map(
|
|
283
|
+
(schema.tables ?? []).map((table) => [
|
|
284
|
+
table.name,
|
|
285
|
+
table.rls_classification,
|
|
286
|
+
]),
|
|
287
|
+
);
|
|
288
|
+
const tableNames = new Set(classifications.keys());
|
|
289
|
+
const tenantClaims = new Map();
|
|
290
|
+
const claims = new Map();
|
|
291
|
+
const evidence = new Map();
|
|
292
|
+
|
|
293
|
+
for (const traceFile of traceFiles(tracesDir)) {
|
|
294
|
+
let artifact;
|
|
295
|
+
try {
|
|
296
|
+
artifact = readJson(traceFile);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
problems.push(
|
|
299
|
+
`${normalizePath(path.relative(rootDir, traceFile))} is not valid JSON: ${
|
|
300
|
+
error instanceof Error ? error.message : String(error)
|
|
301
|
+
}`,
|
|
302
|
+
);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Aggregated mission traces and intentional red mutation traces are not
|
|
307
|
+
// baseline evidence from which new mutations may be derived.
|
|
308
|
+
if (
|
|
309
|
+
Array.isArray(artifact.traces) ||
|
|
310
|
+
artifact.mutation?.planted ||
|
|
311
|
+
artifact.passed !== true
|
|
312
|
+
) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const scopedTables = new Set();
|
|
317
|
+
const artifactClaims = new Map();
|
|
318
|
+
const artifactEvidence = new Map();
|
|
319
|
+
for (const step of artifact.steps ?? []) {
|
|
320
|
+
const stepAssertions = step.assertions ?? [];
|
|
321
|
+
const hasDecisiveAssertion = stepAssertions.some(
|
|
322
|
+
(assertion) =>
|
|
323
|
+
assertion.status !== "skipped" &&
|
|
324
|
+
assertion.status !== "incomplete" &&
|
|
325
|
+
assertion.passed === true,
|
|
326
|
+
);
|
|
327
|
+
if (
|
|
328
|
+
step.passed === true &&
|
|
329
|
+
(stepAssertions.length === 0 || hasDecisiveAssertion)
|
|
330
|
+
) {
|
|
331
|
+
const stepKey = `step\0${step.kind}\0${step.target}`;
|
|
332
|
+
artifactEvidence.set(stepKey, {
|
|
333
|
+
kind: step.kind,
|
|
334
|
+
target: step.target,
|
|
335
|
+
role: "step",
|
|
336
|
+
source: "step",
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
for (const assertion of stepAssertions) {
|
|
340
|
+
if (
|
|
341
|
+
assertion.passed !== true ||
|
|
342
|
+
assertion.status === "skipped" ||
|
|
343
|
+
assertion.status === "incomplete"
|
|
344
|
+
) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const role = isPrimary(assertion) ? "primary" : "control";
|
|
349
|
+
if (role === "control") {
|
|
350
|
+
const key = `${assertion.kind}\0${assertion.target}\0${assertion.operation ?? ""}`;
|
|
351
|
+
artifactEvidence.set(key, {
|
|
352
|
+
kind: assertion.kind,
|
|
353
|
+
target: assertion.target,
|
|
354
|
+
operation: assertion.operation,
|
|
355
|
+
role,
|
|
356
|
+
source: "assertion",
|
|
357
|
+
});
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!CLAIM_OPERATIONS.has(assertion.operation)) {
|
|
362
|
+
problems.push(
|
|
363
|
+
`${artifact.specFile ?? normalizePath(path.relative(rootDir, traceFile))} has a passing primary ${assertion.kind}:${assertion.target} assertion without a supported operation; run proof:verify again after updating the proof SDK`,
|
|
364
|
+
);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const claimKey = `${assertion.kind}\0${assertion.target}\0${assertion.operation}`;
|
|
369
|
+
artifactClaims.set(claimKey, {
|
|
370
|
+
kind: assertion.kind,
|
|
371
|
+
target: assertion.target,
|
|
372
|
+
operation: assertion.operation,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (assertion.kind !== "tenant_isolation") continue;
|
|
376
|
+
|
|
377
|
+
const table = resolveTable(assertion.target, tableNames);
|
|
378
|
+
if (!table) continue;
|
|
379
|
+
const classification = classifications.get(table);
|
|
380
|
+
if (
|
|
381
|
+
classification !== "workspace_scoped" &&
|
|
382
|
+
classification !== "user_scoped"
|
|
383
|
+
) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
scopedTables.add(table);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (
|
|
390
|
+
scopedTables.size === 0 &&
|
|
391
|
+
artifactClaims.size === 0 &&
|
|
392
|
+
artifactEvidence.size === 0
|
|
393
|
+
) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (typeof artifact.specFile !== "string") {
|
|
398
|
+
problems.push(
|
|
399
|
+
`${normalizePath(path.relative(rootDir, traceFile))} contains proof evidence but has no specFile provenance`,
|
|
400
|
+
);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const absoluteSpec = path.resolve(rootDir, artifact.specFile);
|
|
404
|
+
const relativeSpec = path.relative(rootDir, absoluteSpec);
|
|
405
|
+
if (
|
|
406
|
+
relativeSpec.startsWith(`..${path.sep}`) ||
|
|
407
|
+
path.isAbsolute(relativeSpec) ||
|
|
408
|
+
!relativeSpec.endsWith(".proof.ts")
|
|
409
|
+
) {
|
|
410
|
+
problems.push(
|
|
411
|
+
`${artifact.specFile} is not a proof spec inside the current repository`,
|
|
412
|
+
);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (!fs.existsSync(absoluteSpec)) {
|
|
416
|
+
problems.push(
|
|
417
|
+
`${artifact.specFile} is referenced by proof evidence but no longer exists`,
|
|
418
|
+
);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (typeof artifact.specHash !== "string") {
|
|
422
|
+
problems.push(
|
|
423
|
+
`${artifact.specFile} has proof evidence but no specHash freshness provenance; run proof:verify again`,
|
|
424
|
+
);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (artifact.specHash !== specHash(absoluteSpec)) {
|
|
428
|
+
problems.push(
|
|
429
|
+
`${artifact.specFile} changed after its proof trace was written; run proof:verify again`,
|
|
430
|
+
);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const spec = normalizePath(relativeSpec);
|
|
435
|
+
for (const table of scopedTables) {
|
|
436
|
+
tenantClaims.set(`${spec}\0${table}`, { spec, table });
|
|
437
|
+
}
|
|
438
|
+
for (const claim of artifactClaims.values()) {
|
|
439
|
+
claims.set(
|
|
440
|
+
`${spec}\0${claim.kind}\0${claim.target}\0${claim.operation}`,
|
|
441
|
+
{
|
|
442
|
+
spec,
|
|
443
|
+
specHash: artifact.specHash,
|
|
444
|
+
proofId: artifact.proofId,
|
|
445
|
+
...claim,
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
for (const item of artifactEvidence.values()) {
|
|
450
|
+
evidence.set(
|
|
451
|
+
`${spec}\0${item.source}\0${item.kind}\0${item.target}\0${item.operation ?? ""}`,
|
|
452
|
+
{
|
|
453
|
+
spec,
|
|
454
|
+
specHash: artifact.specHash,
|
|
455
|
+
proofId: artifact.proofId,
|
|
456
|
+
...item,
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const automaticMutations = [];
|
|
463
|
+
for (const claim of tenantClaims.values()) {
|
|
464
|
+
if (
|
|
465
|
+
explicitMutations.some((mutation) =>
|
|
466
|
+
explicitMutationCoversClaim(mutation, claim),
|
|
467
|
+
)
|
|
468
|
+
) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
automaticMutations.push({
|
|
472
|
+
id: automaticMutationId(claim),
|
|
473
|
+
finding: "automatic tenant isolation",
|
|
474
|
+
breaks: `disables row-level security on public.${claim.table}, exposing rows regardless of policy names`,
|
|
475
|
+
spec: claim.spec,
|
|
476
|
+
apply: `ALTER TABLE public.${claim.table} DISABLE ROW LEVEL SECURITY;`,
|
|
477
|
+
subject: {
|
|
478
|
+
kind: "rowLevelSecurity",
|
|
479
|
+
table: `public.${claim.table}`,
|
|
480
|
+
},
|
|
481
|
+
claims: [
|
|
482
|
+
{
|
|
483
|
+
kind: "tenant_isolation",
|
|
484
|
+
target: claim.table,
|
|
485
|
+
operation: "select",
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
expectFailureContains: "tenant_isolation",
|
|
489
|
+
automatic: true,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const uncoveredActionClaims = [...claims.values()].filter(
|
|
494
|
+
(claim) =>
|
|
495
|
+
isActionTarget(claim.target) &&
|
|
496
|
+
!explicitMutations.some((mutation) =>
|
|
497
|
+
mutationCoversClaim(mutation, claim),
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
mutations: automaticMutations,
|
|
503
|
+
problems,
|
|
504
|
+
claims: [...claims.values()],
|
|
505
|
+
evidence: [...evidence.values()],
|
|
506
|
+
uncoveredActionClaims,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function snapshotSourceMutation(subject) {
|
|
4
|
+
return fs.readFileSync(subject.file, "utf8");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function applySourceMutation(id, subject, snapshot) {
|
|
8
|
+
const { search, replacement } = subject;
|
|
9
|
+
const first = snapshot.indexOf(search);
|
|
10
|
+
if (first < 0) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`source mutation ${id} could not find its exact search text in ${subject.file}`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
if (snapshot.indexOf(search, first + search.length) >= 0) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`source mutation ${id} matched more than once in ${subject.file}`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fs.writeFileSync(
|
|
22
|
+
subject.file,
|
|
23
|
+
`${snapshot.slice(0, first)}${replacement}${snapshot.slice(first + search.length)}`,
|
|
24
|
+
"utf8",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function restoreSourceMutation(subject, snapshot) {
|
|
29
|
+
fs.writeFileSync(subject.file, snapshot, "utf8");
|
|
30
|
+
if (snapshotSourceMutation(subject) !== snapshot) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`source file ${subject.file} did not return to its exact original contents`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|