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,1084 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// proof_mutation_check.mjs
|
|
4
|
+
//
|
|
5
|
+
// Who watches the watchmen.
|
|
6
|
+
//
|
|
7
|
+
// A proof suite that passes tells you nothing on its own — a suite that has
|
|
8
|
+
// quietly stopped being able to fail passes too. This script deliberately
|
|
9
|
+
// re-opens each vulnerability the baseline proofs exist to catch, confirms the
|
|
10
|
+
// corresponding proof turns RED, and then reverts. If a proof stays green while
|
|
11
|
+
// its vulnerability is live, that proof is decorative and this script fails.
|
|
12
|
+
//
|
|
13
|
+
// Mutations are applied to the DATABASE, not the source tree: they are precise,
|
|
14
|
+
// instant and reversible, where patching SQL files would require a full
|
|
15
|
+
// migration cycle per mutation.
|
|
16
|
+
//
|
|
17
|
+
// The mutation list is also the honest answer to "what does the baseline suite
|
|
18
|
+
// actually detect?" — each entry names a finding, the hole it re-opens, and the
|
|
19
|
+
// proof expected to notice.
|
|
20
|
+
//
|
|
21
|
+
// Usage:
|
|
22
|
+
// node scripts/proof_mutation_check.mjs # every mutation
|
|
23
|
+
// node scripts/proof_mutation_check.mjs --inventory # validate, change nothing
|
|
24
|
+
// node scripts/proof_mutation_check.mjs --list # describe, change nothing
|
|
25
|
+
// node scripts/proof_mutation_check.mjs --only C1-self-join
|
|
26
|
+
//
|
|
27
|
+
// Requires: local Supabase running (Docker). REFUSES to run against any
|
|
28
|
+
// database that is not on localhost.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { createRequire } from "node:module";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import nextEnv from "@next/env";
|
|
37
|
+
import {
|
|
38
|
+
deriveAutomaticRlsMutations,
|
|
39
|
+
evaluateMutationClaimCoverage,
|
|
40
|
+
mutationCoversClaim,
|
|
41
|
+
} from "./proof_mutation_inventory.mjs";
|
|
42
|
+
import {
|
|
43
|
+
applySourceMutation,
|
|
44
|
+
restoreSourceMutation,
|
|
45
|
+
snapshotSourceMutation,
|
|
46
|
+
} from "./proof_source_mutation.mjs";
|
|
47
|
+
import { loadMutationCatalog, loadProofConfig } from "../config.mjs";
|
|
48
|
+
|
|
49
|
+
const CONFIG = await loadProofConfig();
|
|
50
|
+
process.chdir(CONFIG.rootDir);
|
|
51
|
+
|
|
52
|
+
const { loadEnvConfig } = nextEnv;
|
|
53
|
+
loadEnvConfig(CONFIG.rootDir);
|
|
54
|
+
const require = createRequire(import.meta.url);
|
|
55
|
+
|
|
56
|
+
const CONFIG_TOML = CONFIG.repository.supabaseConfig;
|
|
57
|
+
const TRACE_DIR = CONFIG.artifacts.traces;
|
|
58
|
+
const SCHEMA_PATH = CONFIG.artifacts.schema;
|
|
59
|
+
const MUTATION_POLICY_PATH = CONFIG.policies.mutation;
|
|
60
|
+
const MUTATION_ARTIFACT_DIR = CONFIG.artifacts.mutations;
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Mutations
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
const MUTATIONS = await loadMutationCatalog(CONFIG);
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Database access
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function readProjectId() {
|
|
73
|
+
if (!fs.existsSync(CONFIG_TOML)) {
|
|
74
|
+
console.error(`[mutation] ${CONFIG_TOML} not found`);
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
const id = fs
|
|
78
|
+
.readFileSync(CONFIG_TOML, "utf8")
|
|
79
|
+
.match(/^project_id\s*=\s*"(.*)"$/m)?.[1];
|
|
80
|
+
if (!id) {
|
|
81
|
+
console.error(`[mutation] could not read project_id from ${CONFIG_TOML}`);
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Refuse to touch anything that is not unmistakably a local dev database.
|
|
89
|
+
* This script's whole job is to break security policies on purpose; pointing it
|
|
90
|
+
* at a shared or hosted database would be catastrophic, so the check is a hard
|
|
91
|
+
* abort rather than a warning.
|
|
92
|
+
*/
|
|
93
|
+
function assertLocalDatabase() {
|
|
94
|
+
const res = spawnSync(
|
|
95
|
+
"pnpm",
|
|
96
|
+
["exec", "supabase", "status", "--output", "env"],
|
|
97
|
+
{
|
|
98
|
+
encoding: "utf8",
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
if (res.status !== 0) {
|
|
102
|
+
console.error(
|
|
103
|
+
"[mutation] could not read `supabase status`; is local Supabase running?\n" +
|
|
104
|
+
" hint: bash scripts/ensure-supabase.sh",
|
|
105
|
+
);
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
const dbUrl = res.stdout.match(/^DB_URL="?([^"\n]+)"?$/m)?.[1] ?? "";
|
|
109
|
+
const isLocal = /@(127\.0\.0\.1|localhost|\[::1\]):/.test(dbUrl);
|
|
110
|
+
if (!isLocal) {
|
|
111
|
+
console.error(
|
|
112
|
+
`[mutation] REFUSING to run: DB_URL is not local (${dbUrl || "<unreadable>"}).\n` +
|
|
113
|
+
" This script intentionally disables security policies and must only ever\n" +
|
|
114
|
+
" target a local throwaway database.",
|
|
115
|
+
);
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
return dbUrl;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function runSql(container, sql) {
|
|
122
|
+
const res = spawnSync(
|
|
123
|
+
"docker",
|
|
124
|
+
[
|
|
125
|
+
"exec",
|
|
126
|
+
"-i",
|
|
127
|
+
container,
|
|
128
|
+
"psql",
|
|
129
|
+
"-U",
|
|
130
|
+
"postgres",
|
|
131
|
+
"-d",
|
|
132
|
+
"postgres",
|
|
133
|
+
"-v",
|
|
134
|
+
"ON_ERROR_STOP=1",
|
|
135
|
+
"-q",
|
|
136
|
+
],
|
|
137
|
+
{ input: sql, encoding: "utf8" },
|
|
138
|
+
);
|
|
139
|
+
if (res.status !== 0) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`SQL failed (exit ${res.status}):\n${sql}\n${res.stderr || res.stdout}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return res.stdout;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Run a query and return its single scalar result as a trimmed string. */
|
|
148
|
+
function queryScalar(container, sql) {
|
|
149
|
+
const res = spawnSync(
|
|
150
|
+
"docker",
|
|
151
|
+
[
|
|
152
|
+
"exec",
|
|
153
|
+
"-i",
|
|
154
|
+
container,
|
|
155
|
+
"psql",
|
|
156
|
+
"-U",
|
|
157
|
+
"postgres",
|
|
158
|
+
"-d",
|
|
159
|
+
"postgres",
|
|
160
|
+
"-tA",
|
|
161
|
+
"-v",
|
|
162
|
+
"ON_ERROR_STOP=1",
|
|
163
|
+
"-c",
|
|
164
|
+
sql,
|
|
165
|
+
],
|
|
166
|
+
{ encoding: "utf8" },
|
|
167
|
+
);
|
|
168
|
+
if (res.status !== 0) {
|
|
169
|
+
throw new Error(`query failed:\n${sql}\n${res.stderr || res.stdout}`);
|
|
170
|
+
}
|
|
171
|
+
return res.stdout.trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function quoteLiteral(value) {
|
|
175
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Snapshot / restore
|
|
180
|
+
//
|
|
181
|
+
// Each subject kind knows how to read its own current definition and how to put
|
|
182
|
+
// that exact definition back. `restore` is therefore always derived from what was
|
|
183
|
+
// really there a moment ago, never from an assumption baked into this file.
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const SUBJECTS = {
|
|
187
|
+
sourceFile: {
|
|
188
|
+
describe: (s) => `source file ${s.file}`,
|
|
189
|
+
snapshot: (_container, s) => snapshotSourceMutation(s),
|
|
190
|
+
restore: (s, snapshot) => {
|
|
191
|
+
restoreSourceMutation(s, snapshot);
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
trigger: {
|
|
196
|
+
describe: (s) => `trigger ${s.name} on ${s.table}`,
|
|
197
|
+
snapshot: (container, s) =>
|
|
198
|
+
queryScalar(
|
|
199
|
+
container,
|
|
200
|
+
`SELECT coalesce(pg_get_triggerdef(oid), '') FROM pg_trigger
|
|
201
|
+
WHERE tgrelid = ${quoteLiteral(s.table)}::regclass
|
|
202
|
+
AND tgname = ${quoteLiteral(s.name)} AND NOT tgisinternal`,
|
|
203
|
+
),
|
|
204
|
+
restore: (s, snapshot) =>
|
|
205
|
+
`DROP TRIGGER IF EXISTS ${s.name} ON ${s.table};` +
|
|
206
|
+
(snapshot ? `\n${snapshot};` : ""),
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
policy: {
|
|
210
|
+
describe: (s) => `policy "${s.name}" on ${s.table}`,
|
|
211
|
+
snapshot: (container, s) => {
|
|
212
|
+
const raw = queryScalar(
|
|
213
|
+
container,
|
|
214
|
+
`SELECT coalesce(pg_get_expr(polqual, polrelid), '') || '~~~' ||
|
|
215
|
+
coalesce(pg_get_expr(polwithcheck, polrelid), '')
|
|
216
|
+
FROM pg_policy
|
|
217
|
+
WHERE polrelid = ${quoteLiteral(s.table)}::regclass
|
|
218
|
+
AND polname = ${quoteLiteral(s.name)}`,
|
|
219
|
+
);
|
|
220
|
+
// A policy with neither USING nor WITH CHECK cannot be reconstructed.
|
|
221
|
+
return raw === "~~~" ? "" : raw;
|
|
222
|
+
},
|
|
223
|
+
restore: (s, snapshot) => {
|
|
224
|
+
const [using, check] = snapshot.split("~~~");
|
|
225
|
+
const clauses = [
|
|
226
|
+
using ? `USING (${using})` : "",
|
|
227
|
+
check ? `WITH CHECK (${check})` : "",
|
|
228
|
+
].filter(Boolean);
|
|
229
|
+
return `ALTER POLICY "${s.name}" ON ${s.table} ${clauses.join(" ")};`;
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
rowLevelSecurity: {
|
|
234
|
+
describe: (s) => `row-level security on ${s.table}`,
|
|
235
|
+
snapshot: (container, s) =>
|
|
236
|
+
queryScalar(
|
|
237
|
+
container,
|
|
238
|
+
`SELECT relrowsecurity::text || '~~~' || relforcerowsecurity::text
|
|
239
|
+
FROM pg_class
|
|
240
|
+
WHERE oid = ${quoteLiteral(s.table)}::regclass`,
|
|
241
|
+
),
|
|
242
|
+
restore: (s, snapshot) => {
|
|
243
|
+
const [enabled, forced] = snapshot.split("~~~");
|
|
244
|
+
return (
|
|
245
|
+
`ALTER TABLE ${s.table} ${enabled === "true" ? "ENABLE" : "DISABLE"} ROW LEVEL SECURITY;\n` +
|
|
246
|
+
`ALTER TABLE ${s.table} ${forced === "true" ? "FORCE" : "NO FORCE"} ROW LEVEL SECURITY;`
|
|
247
|
+
);
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
tablePrivilege: {
|
|
252
|
+
describe: (s) => `${s.privilege} on ${s.table} for ${s.role}`,
|
|
253
|
+
snapshot: (container, s) =>
|
|
254
|
+
queryScalar(
|
|
255
|
+
container,
|
|
256
|
+
`SELECT has_table_privilege(${quoteLiteral(s.role)}, ${quoteLiteral(s.table)}, ${quoteLiteral(s.privilege)})`,
|
|
257
|
+
),
|
|
258
|
+
restore: (s, snapshot) =>
|
|
259
|
+
snapshot === "t"
|
|
260
|
+
? `GRANT ${s.privilege} ON ${s.table} TO ${s.role};`
|
|
261
|
+
: `REVOKE ${s.privilege} ON ${s.table} FROM ${s.role};`,
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
functionPrivilege: {
|
|
265
|
+
describe: (s) => `${s.privilege} on ${s.signature} for ${s.role}`,
|
|
266
|
+
snapshot: (container, s) =>
|
|
267
|
+
queryScalar(
|
|
268
|
+
container,
|
|
269
|
+
`SELECT has_function_privilege(${quoteLiteral(s.role)}, ${quoteLiteral(s.signature)}, ${quoteLiteral(s.privilege)})`,
|
|
270
|
+
),
|
|
271
|
+
restore: (s, snapshot) =>
|
|
272
|
+
snapshot === "t"
|
|
273
|
+
? `GRANT ${s.privilege} ON FUNCTION ${s.signature} TO ${s.role};`
|
|
274
|
+
: `REVOKE ${s.privilege} ON FUNCTION ${s.signature} FROM ${s.role};`,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
function subjectHandler(subject) {
|
|
279
|
+
const handler = SUBJECTS[subject.kind];
|
|
280
|
+
if (!handler) throw new Error(`unknown subject kind "${subject.kind}"`);
|
|
281
|
+
return handler;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isSourceMutation(mutation) {
|
|
285
|
+
return mutation.subject?.kind === "sourceFile";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function applyMutation(container, mutation, snapshot) {
|
|
289
|
+
if (!isSourceMutation(mutation)) {
|
|
290
|
+
runSql(container, mutation.apply);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
applySourceMutation(mutation.id, mutation.subject, snapshot);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function restoreMutation(container, mutation, handler, snapshot) {
|
|
298
|
+
if (isSourceMutation(mutation)) {
|
|
299
|
+
handler.restore(mutation.subject, snapshot);
|
|
300
|
+
const after = handler.snapshot(container, mutation.subject);
|
|
301
|
+
if (after !== snapshot) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`${handler.describe(mutation.subject)} did not return to its exact original contents`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (mutation.cleanup) runSql(container, mutation.cleanup);
|
|
310
|
+
runSql(container, handler.restore(mutation.subject, snapshot));
|
|
311
|
+
|
|
312
|
+
const after = handler.snapshot(container, mutation.subject);
|
|
313
|
+
if (after !== snapshot) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`${handler.describe(mutation.subject)} did not return to its original state\n` +
|
|
316
|
+
` before: ${snapshot}\n after: ${after}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Spec coverage
|
|
323
|
+
//
|
|
324
|
+
// The harness is only as honest as its own inventory: a proof nobody planted a
|
|
325
|
+
// defect against is a proof nobody has seen fail.
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
const PROOFS_DIR = CONFIG.roots.proofs;
|
|
329
|
+
|
|
330
|
+
function listSpecs() {
|
|
331
|
+
if (!fs.existsSync(PROOFS_DIR)) return [];
|
|
332
|
+
return fs
|
|
333
|
+
.readdirSync(PROOFS_DIR)
|
|
334
|
+
.filter((f) => f.endsWith(".proof.ts"))
|
|
335
|
+
.map((f) => path.join(PROOFS_DIR, f))
|
|
336
|
+
.sort();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function readMutationPolicy() {
|
|
340
|
+
try {
|
|
341
|
+
const policy = JSON.parse(fs.readFileSync(MUTATION_POLICY_PATH, "utf8"));
|
|
342
|
+
if (policy.schemaVersion !== 1) {
|
|
343
|
+
return {
|
|
344
|
+
policy: { acceptedClaims: [] },
|
|
345
|
+
problems: [
|
|
346
|
+
`${MUTATION_POLICY_PATH} has unsupported schemaVersion ${policy.schemaVersion ?? "<missing>"}; expected 1`,
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return { policy, problems: [] };
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return {
|
|
353
|
+
policy: { acceptedClaims: [] },
|
|
354
|
+
problems: [
|
|
355
|
+
`cannot read ${MUTATION_POLICY_PATH}: ${error instanceof Error ? error.message : String(error)}`,
|
|
356
|
+
],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function buildMutationInventory() {
|
|
362
|
+
const derived = deriveAutomaticRlsMutations({
|
|
363
|
+
tracesDir: TRACE_DIR,
|
|
364
|
+
schemaPath: SCHEMA_PATH,
|
|
365
|
+
explicitMutations: MUTATIONS,
|
|
366
|
+
});
|
|
367
|
+
const mutations = [...MUTATIONS, ...derived.mutations];
|
|
368
|
+
const ids = new Set();
|
|
369
|
+
const problems = [...derived.problems];
|
|
370
|
+
|
|
371
|
+
for (const mutation of mutations) {
|
|
372
|
+
if (ids.has(mutation.id)) {
|
|
373
|
+
problems.push(`duplicate mutation id "${mutation.id}"`);
|
|
374
|
+
}
|
|
375
|
+
ids.add(mutation.id);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const loadedPolicy = readMutationPolicy();
|
|
379
|
+
const claimCoverage = evaluateMutationClaimCoverage({
|
|
380
|
+
claims: derived.claims,
|
|
381
|
+
evidence: derived.evidence,
|
|
382
|
+
mutations,
|
|
383
|
+
policy: loadedPolicy.policy,
|
|
384
|
+
});
|
|
385
|
+
problems.push(...loadedPolicy.problems, ...claimCoverage.problems);
|
|
386
|
+
for (const claim of claimCoverage.uncoveredClaims) {
|
|
387
|
+
problems.push(
|
|
388
|
+
`${claim.spec} has no mutation or accepted policy entry for ${claim.kind}:${claim.target}:${claim.operation}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const mutationsWithClaims = mutations.map((mutation) => ({
|
|
393
|
+
...mutation,
|
|
394
|
+
resolvedClaims: derived.claims
|
|
395
|
+
.filter((claim) => mutationCoversClaim(mutation, claim))
|
|
396
|
+
.map((claim) => ({
|
|
397
|
+
kind: claim.kind,
|
|
398
|
+
target: claim.target,
|
|
399
|
+
operation: claim.operation,
|
|
400
|
+
})),
|
|
401
|
+
}));
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
mutations: mutationsWithClaims,
|
|
405
|
+
problems,
|
|
406
|
+
claims: derived.claims,
|
|
407
|
+
acceptedClaims: claimCoverage.acceptedClaims,
|
|
408
|
+
uncoveredClaims: claimCoverage.uncoveredClaims,
|
|
409
|
+
uncoveredActionClaims: claimCoverage.uncoveredClaims.filter((claim) =>
|
|
410
|
+
/^[A-Za-z][\w-]*:[A-Za-z][\w-]*$/.test(claim.target),
|
|
411
|
+
),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Returns inventory problems plus mutations pointing at specs that no longer
|
|
417
|
+
* exist. Claim-level mutation/policy coverage is computed in buildMutationInventory.
|
|
418
|
+
*/
|
|
419
|
+
function checkSpecCoverage(mutations, derivationProblems = []) {
|
|
420
|
+
const specs = new Set(listSpecs());
|
|
421
|
+
const problems = [...derivationProblems];
|
|
422
|
+
|
|
423
|
+
for (const ref of new Set(mutations.map((mutation) => mutation.spec))) {
|
|
424
|
+
if (!specs.has(ref)) {
|
|
425
|
+
problems.push(
|
|
426
|
+
`${ref} is referenced by a mutation but does not exist — the reference is stale.`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return problems;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function printUncoveredActionClaims(claims) {
|
|
434
|
+
if (claims.length === 0) return;
|
|
435
|
+
console.warn(
|
|
436
|
+
`\n[mutation] ${claims.length} action-layer claim(s) have passing proof evidence but no mutation known to reach them:`,
|
|
437
|
+
);
|
|
438
|
+
for (const claim of claims) {
|
|
439
|
+
console.warn(` - ${claim.kind}:${claim.target} (${claim.spec})`);
|
|
440
|
+
}
|
|
441
|
+
console.warn(
|
|
442
|
+
" These proofs are useful, but they have not independently demonstrated that their action assertion can turn red.",
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// Proof execution
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
const DEV_SERVER_URL = process.env.DEV_SERVER ?? "http://localhost:3000";
|
|
451
|
+
const DEV_SERVER_TIMEOUT_MS = 120_000;
|
|
452
|
+
|
|
453
|
+
async function serverIsReachable(url) {
|
|
454
|
+
try {
|
|
455
|
+
await fetch(url, {
|
|
456
|
+
redirect: "manual",
|
|
457
|
+
signal: AbortSignal.timeout(2_000),
|
|
458
|
+
});
|
|
459
|
+
return true;
|
|
460
|
+
} catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function proofServerIssue(url) {
|
|
466
|
+
const secret = process.env.API_SECRET_KEY;
|
|
467
|
+
if (!secret) {
|
|
468
|
+
return "API_SECRET_KEY is not set after loading .env.local";
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const response = await fetch(new URL("/api/proof/health", url), {
|
|
473
|
+
headers: {
|
|
474
|
+
"x-proof-secret": secret,
|
|
475
|
+
...(process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
476
|
+
? {
|
|
477
|
+
"x-proof-supabase-url": process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
478
|
+
}
|
|
479
|
+
: {}),
|
|
480
|
+
},
|
|
481
|
+
signal: AbortSignal.timeout(5_000),
|
|
482
|
+
});
|
|
483
|
+
let body = null;
|
|
484
|
+
try {
|
|
485
|
+
body = await response.json();
|
|
486
|
+
} catch {
|
|
487
|
+
// An HTML/empty response means this is not a compatible proof server.
|
|
488
|
+
}
|
|
489
|
+
if (response.ok) {
|
|
490
|
+
if (body?.protocolVersion !== 1) {
|
|
491
|
+
return (
|
|
492
|
+
`incompatible proof health protocol (expected 1, received ` +
|
|
493
|
+
`${body?.protocolVersion ?? "<missing>"})`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
let detail = `status ${response.status}`;
|
|
499
|
+
if (body) {
|
|
500
|
+
detail = body.suggestion ?? body.error ?? detail;
|
|
501
|
+
}
|
|
502
|
+
return detail;
|
|
503
|
+
} catch (error) {
|
|
504
|
+
return error instanceof Error ? error.message : String(error);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function signalProcessTree(child, signal) {
|
|
509
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
510
|
+
|
|
511
|
+
// `pnpm dev` launches Next as a child. On Unix, killing only pnpm can leave
|
|
512
|
+
// Next alive and make the next CI job fail on the port or `.next/dev/lock`.
|
|
513
|
+
if (process.platform !== "win32" && child.pid) {
|
|
514
|
+
try {
|
|
515
|
+
process.kill(-child.pid, signal);
|
|
516
|
+
return;
|
|
517
|
+
} catch {
|
|
518
|
+
// Fall through to the cross-platform single-process signal.
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
child.kill(signal);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function waitForExit(child, timeoutMs) {
|
|
525
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
526
|
+
return Promise.resolve(true);
|
|
527
|
+
}
|
|
528
|
+
return new Promise((resolve) => {
|
|
529
|
+
let settled = false;
|
|
530
|
+
const finish = (exited) => {
|
|
531
|
+
if (settled) return;
|
|
532
|
+
settled = true;
|
|
533
|
+
clearTimeout(timer);
|
|
534
|
+
child.removeListener("exit", onExit);
|
|
535
|
+
resolve(exited);
|
|
536
|
+
};
|
|
537
|
+
const onExit = () => finish(true);
|
|
538
|
+
const timer = setTimeout(() => {
|
|
539
|
+
finish(false);
|
|
540
|
+
}, timeoutMs);
|
|
541
|
+
child.once("exit", onExit);
|
|
542
|
+
// Close the tiny race between the first check and installing the listener.
|
|
543
|
+
if (child.exitCode !== null || child.signalCode !== null) finish(true);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Start one Next.js server for the whole mutation run.
|
|
549
|
+
*
|
|
550
|
+
* Every mutation still gets its own isolated Playwright process and its own
|
|
551
|
+
* planted database defect. The only thing shared is the immutable app server,
|
|
552
|
+
* which used to be compiled and started again for every mutation. Locally, an
|
|
553
|
+
* already-running server is reused and never stopped by this script.
|
|
554
|
+
*/
|
|
555
|
+
async function sharedDevServer() {
|
|
556
|
+
if (await serverIsReachable(DEV_SERVER_URL)) {
|
|
557
|
+
const issue = await proofServerIssue(DEV_SERVER_URL);
|
|
558
|
+
if (issue) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
`[PROOF_FAIL] proof_server_stale: the process at ${DEV_SERVER_URL} failed /api/proof/health: ${issue}. ` +
|
|
561
|
+
"Restart it after updating .env.local; mutation testing will not reuse an unverified server.",
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
console.log(
|
|
565
|
+
`[mutation] reusing existing dev server at ${DEV_SERVER_URL}\n`,
|
|
566
|
+
);
|
|
567
|
+
return { stop: async () => {} };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const url = new URL(DEV_SERVER_URL);
|
|
571
|
+
const output = [];
|
|
572
|
+
const child = spawn("pnpm", ["dev"], {
|
|
573
|
+
detached: process.platform !== "win32",
|
|
574
|
+
env: {
|
|
575
|
+
...process.env,
|
|
576
|
+
...(url.port ? { PORT: url.port } : {}),
|
|
577
|
+
PROOF_MODE: "true",
|
|
578
|
+
},
|
|
579
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
580
|
+
});
|
|
581
|
+
const remember = (chunk) => {
|
|
582
|
+
output.push(chunk.toString());
|
|
583
|
+
// Enough context for startup failures without retaining an entire CI log.
|
|
584
|
+
if (output.length > 200) output.shift();
|
|
585
|
+
};
|
|
586
|
+
child.stdout.on("data", remember);
|
|
587
|
+
child.stderr.on("data", remember);
|
|
588
|
+
child.once("error", (error) => remember(`spawn error: ${error.message}\n`));
|
|
589
|
+
|
|
590
|
+
const deadline = Date.now() + DEV_SERVER_TIMEOUT_MS;
|
|
591
|
+
while (Date.now() < deadline) {
|
|
592
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`shared dev server exited before becoming ready:\n${output.join("")}`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
if (await serverIsReachable(DEV_SERVER_URL)) {
|
|
598
|
+
const issue = await proofServerIssue(DEV_SERVER_URL);
|
|
599
|
+
if (issue) {
|
|
600
|
+
signalProcessTree(child, "SIGTERM");
|
|
601
|
+
await waitForExit(child, 5_000);
|
|
602
|
+
throw new Error(
|
|
603
|
+
`shared dev server failed /api/proof/health after startup: ${issue}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
console.log(`[mutation] shared dev server ready at ${DEV_SERVER_URL}\n`);
|
|
607
|
+
return {
|
|
608
|
+
stop: async () => {
|
|
609
|
+
signalProcessTree(child, "SIGTERM");
|
|
610
|
+
if (!(await waitForExit(child, 5_000))) {
|
|
611
|
+
signalProcessTree(child, "SIGKILL");
|
|
612
|
+
await waitForExit(child, 2_000);
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
signalProcessTree(child, "SIGTERM");
|
|
621
|
+
await waitForExit(child, 5_000);
|
|
622
|
+
throw new Error(
|
|
623
|
+
`shared dev server did not become ready at ${DEV_SERVER_URL} within ${DEV_SERVER_TIMEOUT_MS / 1_000}s:\n` +
|
|
624
|
+
output.join(""),
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function snapshotTraceFiles() {
|
|
629
|
+
const snapshot = new Map();
|
|
630
|
+
if (!fs.existsSync(TRACE_DIR)) return snapshot;
|
|
631
|
+
for (const entry of fs.readdirSync(TRACE_DIR, { withFileTypes: true })) {
|
|
632
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
633
|
+
const file = path.join(TRACE_DIR, entry.name);
|
|
634
|
+
snapshot.set(entry.name, fs.readFileSync(file, "utf8"));
|
|
635
|
+
}
|
|
636
|
+
return snapshot;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function changedTraceFiles(before) {
|
|
640
|
+
if (!fs.existsSync(TRACE_DIR)) return [];
|
|
641
|
+
const changed = [];
|
|
642
|
+
for (const entry of fs.readdirSync(TRACE_DIR, { withFileTypes: true })) {
|
|
643
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
644
|
+
const file = path.join(TRACE_DIR, entry.name);
|
|
645
|
+
const content = fs.readFileSync(file, "utf8");
|
|
646
|
+
if (before.get(entry.name) === content) continue;
|
|
647
|
+
const trace = JSON.parse(content);
|
|
648
|
+
// Aggregated mission artifacts also live here, but mutation Playwright runs
|
|
649
|
+
// only write per-proof traces with a steps array.
|
|
650
|
+
if (trace && Array.isArray(trace.steps)) {
|
|
651
|
+
changed.push({ name: entry.name, content, trace });
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return changed;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function failedClaimKeys(changed) {
|
|
658
|
+
const keys = new Set();
|
|
659
|
+
for (const item of changed) {
|
|
660
|
+
for (const step of item.trace.steps ?? []) {
|
|
661
|
+
for (const assertion of step.assertions ?? []) {
|
|
662
|
+
if (
|
|
663
|
+
assertion.passed !== false ||
|
|
664
|
+
assertion.status === "skipped" ||
|
|
665
|
+
assertion.status === "incomplete" ||
|
|
666
|
+
typeof assertion.operation !== "string"
|
|
667
|
+
) {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
keys.add(
|
|
671
|
+
`${assertion.kind}\0${assertion.target}\0${assertion.operation}`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return keys;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function archiveMutationTraces(mutation, before, playwrightFailed, reasonOk) {
|
|
680
|
+
const changed = changedTraceFiles(before);
|
|
681
|
+
if (changed.length === 0) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
`mutation ${mutation.id} produced no fresh trace artifact; a non-zero Playwright exit without trace evidence cannot demonstrate detection`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const destination = path.join(MUTATION_ARTIFACT_DIR, mutation.id);
|
|
688
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
689
|
+
const traceTurnedRed = changed.some((item) => item.trace.passed === false);
|
|
690
|
+
const requiredClaims = Array.isArray(mutation.claims)
|
|
691
|
+
? mutation.claims
|
|
692
|
+
: (mutation.resolvedClaims ?? []);
|
|
693
|
+
const redClaims = failedClaimKeys(changed);
|
|
694
|
+
const claimsTurnedRed = requiredClaims.every((claim) =>
|
|
695
|
+
redClaims.has(`${claim.kind}\0${claim.target}\0${claim.operation}`),
|
|
696
|
+
);
|
|
697
|
+
const detected = playwrightFailed && traceTurnedRed && claimsTurnedRed;
|
|
698
|
+
|
|
699
|
+
for (const item of changed) {
|
|
700
|
+
if (
|
|
701
|
+
item.trace.mutation?.id !== mutation.id ||
|
|
702
|
+
item.trace.mutation?.planted !== true
|
|
703
|
+
) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`trace ${item.name} is missing mutation provenance for ${mutation.id}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
fs.writeFileSync(path.join(destination, item.name), item.content, "utf8");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
fs.writeFileSync(
|
|
712
|
+
path.join(destination, "mutation.json"),
|
|
713
|
+
JSON.stringify(
|
|
714
|
+
{
|
|
715
|
+
id: mutation.id,
|
|
716
|
+
finding: mutation.finding,
|
|
717
|
+
breaks: mutation.breaks,
|
|
718
|
+
spec: mutation.spec,
|
|
719
|
+
claims: requiredClaims,
|
|
720
|
+
detected,
|
|
721
|
+
claimsTurnedRed,
|
|
722
|
+
reasonMatched: reasonOk,
|
|
723
|
+
traces: changed.map((item) => item.name),
|
|
724
|
+
},
|
|
725
|
+
null,
|
|
726
|
+
2,
|
|
727
|
+
),
|
|
728
|
+
"utf8",
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
names: changed.map((item) => item.name),
|
|
733
|
+
detected,
|
|
734
|
+
traceTurnedRed,
|
|
735
|
+
claimsTurnedRed,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function writeMutationSummary(results, inventory) {
|
|
740
|
+
fs.mkdirSync(MUTATION_ARTIFACT_DIR, { recursive: true });
|
|
741
|
+
fs.writeFileSync(
|
|
742
|
+
path.join(MUTATION_ARTIFACT_DIR, "summary.json"),
|
|
743
|
+
JSON.stringify(
|
|
744
|
+
{
|
|
745
|
+
generatedAt: new Date().toISOString(),
|
|
746
|
+
mutations: results,
|
|
747
|
+
claims: inventory.claims,
|
|
748
|
+
acceptedClaims: inventory.acceptedClaims,
|
|
749
|
+
uncoveredClaims: inventory.uncoveredClaims,
|
|
750
|
+
},
|
|
751
|
+
null,
|
|
752
|
+
2,
|
|
753
|
+
),
|
|
754
|
+
"utf8",
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function runProof(spec, mutationId) {
|
|
759
|
+
return new Promise((resolve) => {
|
|
760
|
+
const playwrightCli = require.resolve("@playwright/test/cli");
|
|
761
|
+
const child = spawn(
|
|
762
|
+
process.execPath,
|
|
763
|
+
[
|
|
764
|
+
playwrightCli,
|
|
765
|
+
"test",
|
|
766
|
+
"--project=proofs",
|
|
767
|
+
spec,
|
|
768
|
+
"--reporter=line",
|
|
769
|
+
// A planted defect is expected to turn the proof red. Retrying that
|
|
770
|
+
// deliberate failure only reruns the same proof against the same
|
|
771
|
+
// broken database, multiplying mutation time without adding evidence.
|
|
772
|
+
"--retries=0",
|
|
773
|
+
"--workers=1",
|
|
774
|
+
],
|
|
775
|
+
{
|
|
776
|
+
env: {
|
|
777
|
+
...process.env,
|
|
778
|
+
CI: process.env.CI ?? "",
|
|
779
|
+
PLAYWRIGHT_REUSE_EXISTING_SERVER: "true",
|
|
780
|
+
PROOF_MUTATION_ID: mutationId,
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
);
|
|
784
|
+
let output = "";
|
|
785
|
+
child.stdout.on("data", (c) => (output += c.toString()));
|
|
786
|
+
child.stderr.on("data", (c) => (output += c.toString()));
|
|
787
|
+
child.on("error", (err) => resolve({ code: 1, output: String(err) }));
|
|
788
|
+
child.on("close", (code) => resolve({ code, output }));
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
// Main
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
function parseArgs(argv) {
|
|
797
|
+
const args = { inventory: false, list: false };
|
|
798
|
+
for (let i = 0; i < argv.length; i++) {
|
|
799
|
+
const v = argv[i];
|
|
800
|
+
if (v === "--inventory") args.inventory = true;
|
|
801
|
+
else if (v === "--list") args.list = true;
|
|
802
|
+
else if (v === "--only") args.only = argv[++i];
|
|
803
|
+
else if (v === "--help" || v === "-h") args.help = true;
|
|
804
|
+
else {
|
|
805
|
+
console.error(`[mutation] unknown flag: ${v}`);
|
|
806
|
+
process.exit(2);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return args;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export async function main() {
|
|
813
|
+
const args = parseArgs(process.argv.slice(2));
|
|
814
|
+
const inventory = buildMutationInventory();
|
|
815
|
+
const mutations = inventory.mutations;
|
|
816
|
+
|
|
817
|
+
if (args.help) {
|
|
818
|
+
console.log(`Usage: node scripts/proof_mutation_check.mjs [--inventory] [--list] [--only <id>]
|
|
819
|
+
|
|
820
|
+
Re-opens each known vulnerability, asserts the matching proof fails, reverts.
|
|
821
|
+
Exits non-zero if any proof failed to notice its vulnerability.`);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (args.inventory) {
|
|
826
|
+
const problems = checkSpecCoverage(mutations, inventory.problems);
|
|
827
|
+
for (const problem of problems) {
|
|
828
|
+
console.error(`[mutation] ${problem}`);
|
|
829
|
+
}
|
|
830
|
+
if (problems.length > 0) {
|
|
831
|
+
console.error(
|
|
832
|
+
`\n[PROOF_FAIL] mutation_inventory: ${problems.length} inventory problem(s) found.`,
|
|
833
|
+
);
|
|
834
|
+
process.exit(1);
|
|
835
|
+
}
|
|
836
|
+
printUncoveredActionClaims(inventory.uncoveredActionClaims);
|
|
837
|
+
console.log(
|
|
838
|
+
`[mutation] inventory complete: ${mutations.length} mutation(s), ` +
|
|
839
|
+
`${mutations.filter((mutation) => mutation.automatic).length} automatic RLS mutation(s), ` +
|
|
840
|
+
`${inventory.claims.length} primary claim(s), ` +
|
|
841
|
+
`${inventory.acceptedClaims.length} accepted claim exception(s).`,
|
|
842
|
+
);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (args.list) {
|
|
847
|
+
console.log("Mutations (each must be detected by its proof):\n");
|
|
848
|
+
for (const m of mutations) {
|
|
849
|
+
console.log(
|
|
850
|
+
` ${m.id} [${m.finding}]${m.automatic ? " (automatic)" : ""}`,
|
|
851
|
+
);
|
|
852
|
+
console.log(` breaks: ${m.breaks}`);
|
|
853
|
+
console.log(` caught by: ${m.spec}`);
|
|
854
|
+
console.log(
|
|
855
|
+
` restores: ${subjectHandler(m.subject).describe(m.subject)}\n`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
console.log("Claims deliberately left unmutated:\n");
|
|
859
|
+
for (const claim of inventory.acceptedClaims) {
|
|
860
|
+
console.log(
|
|
861
|
+
` ${claim.kind}:${claim.target}:${claim.operation} (${claim.spec})`,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
const problems = checkSpecCoverage(mutations, inventory.problems);
|
|
865
|
+
for (const p of problems) console.log(` ! ${p}`);
|
|
866
|
+
if (problems.length > 0) process.exit(1);
|
|
867
|
+
printUncoveredActionClaims(inventory.uncoveredActionClaims);
|
|
868
|
+
console.log(
|
|
869
|
+
`Inventory complete: ${mutations.length} mutation(s), including ` +
|
|
870
|
+
`${mutations.filter((mutation) => mutation.automatic).length} automatic RLS mutation(s).`,
|
|
871
|
+
);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Checked before touching the database: an incomplete inventory is a finding
|
|
876
|
+
// in its own right, and it costs nothing to report up front.
|
|
877
|
+
const coverageProblems = checkSpecCoverage(mutations, inventory.problems);
|
|
878
|
+
for (const p of coverageProblems) {
|
|
879
|
+
console.error(`[mutation] ${p}`);
|
|
880
|
+
}
|
|
881
|
+
printUncoveredActionClaims(inventory.uncoveredActionClaims);
|
|
882
|
+
|
|
883
|
+
const requested = args.only
|
|
884
|
+
? mutations.filter((m) => m.id === args.only)
|
|
885
|
+
: mutations;
|
|
886
|
+
// Keep source mutations last so database mutations still share one server.
|
|
887
|
+
// Each source mutation gets a fresh server started after the file is patched.
|
|
888
|
+
const selected = [
|
|
889
|
+
...requested.filter((mutation) => !isSourceMutation(mutation)),
|
|
890
|
+
...requested.filter((mutation) => isSourceMutation(mutation)),
|
|
891
|
+
];
|
|
892
|
+
|
|
893
|
+
if (selected.length === 0) {
|
|
894
|
+
console.error(
|
|
895
|
+
`[mutation] no mutation with id "${args.only}"; run --list to see them`,
|
|
896
|
+
);
|
|
897
|
+
process.exit(2);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Mutation artifacts describe exactly one run. Keeping an earlier run here
|
|
901
|
+
// would let stale planted failures masquerade as evidence from this one.
|
|
902
|
+
fs.rmSync(MUTATION_ARTIFACT_DIR, { recursive: true, force: true });
|
|
903
|
+
|
|
904
|
+
assertLocalDatabase();
|
|
905
|
+
const container = `supabase_db_${readProjectId()}`;
|
|
906
|
+
|
|
907
|
+
console.log(
|
|
908
|
+
`[mutation] verifying that ${selected.length} planted defect(s) are detected by the proof suite`,
|
|
909
|
+
);
|
|
910
|
+
console.log(`[mutation] database container: ${container}\n`);
|
|
911
|
+
|
|
912
|
+
const results = [];
|
|
913
|
+
let devServer = null;
|
|
914
|
+
|
|
915
|
+
try {
|
|
916
|
+
for (const m of selected) {
|
|
917
|
+
console.log(`── ${m.id} ──────────────────────────────────────────`);
|
|
918
|
+
console.log(` planting: ${m.breaks}`);
|
|
919
|
+
|
|
920
|
+
const handler = subjectHandler(m.subject);
|
|
921
|
+
let snapshot = null;
|
|
922
|
+
let applied = false;
|
|
923
|
+
let sourceServer = null;
|
|
924
|
+
try {
|
|
925
|
+
snapshot = handler.snapshot(container, m.subject);
|
|
926
|
+
if (snapshot === "") {
|
|
927
|
+
throw new Error(
|
|
928
|
+
`nothing to snapshot for ${handler.describe(m.subject)} — the object this ` +
|
|
929
|
+
`mutation depends on does not exist, so the mutation would prove nothing`,
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
applyMutation(container, m, snapshot);
|
|
934
|
+
applied = true;
|
|
935
|
+
|
|
936
|
+
if (isSourceMutation(m)) {
|
|
937
|
+
if (devServer) {
|
|
938
|
+
await devServer.stop();
|
|
939
|
+
devServer = null;
|
|
940
|
+
}
|
|
941
|
+
sourceServer = await sharedDevServer();
|
|
942
|
+
} else if (!devServer) {
|
|
943
|
+
devServer = await sharedDevServer();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const tracesBefore = snapshotTraceFiles();
|
|
947
|
+
const { code, output } = await runProof(m.spec, m.id);
|
|
948
|
+
const playwrightFailed = code !== 0;
|
|
949
|
+
|
|
950
|
+
// A proof can fail for the wrong reason (a crash, a timeout, a missing
|
|
951
|
+
// browser). Where the reason matters — as with the deny-all mutation that
|
|
952
|
+
// only a positive control can catch — require it in the output.
|
|
953
|
+
const reasonOk =
|
|
954
|
+
!m.expectFailureContains || output.includes(m.expectFailureContains);
|
|
955
|
+
const archived = archiveMutationTraces(
|
|
956
|
+
m,
|
|
957
|
+
tracesBefore,
|
|
958
|
+
playwrightFailed,
|
|
959
|
+
reasonOk,
|
|
960
|
+
);
|
|
961
|
+
const detected = archived.detected;
|
|
962
|
+
|
|
963
|
+
results.push({
|
|
964
|
+
id: m.id,
|
|
965
|
+
detected,
|
|
966
|
+
reasonOk,
|
|
967
|
+
spec: m.spec,
|
|
968
|
+
expectedReason: m.expectFailureContains,
|
|
969
|
+
traces: archived.names,
|
|
970
|
+
traceTurnedRed: archived.traceTurnedRed,
|
|
971
|
+
claimsTurnedRed: archived.claimsTurnedRed,
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
if (detected && reasonOk) {
|
|
975
|
+
console.log(` ✓ DETECTED — ${path.basename(m.spec)} turned red`);
|
|
976
|
+
if (m.expectFailureContains) {
|
|
977
|
+
console.log(
|
|
978
|
+
` (failed via ${m.expectFailureContains}, as required)`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
} else if (detected && !reasonOk) {
|
|
982
|
+
console.log(
|
|
983
|
+
` ✗ WRONG REASON — the proof failed, but not via "${m.expectFailureContains}".`,
|
|
984
|
+
);
|
|
985
|
+
} else if (!archived.claimsTurnedRed) {
|
|
986
|
+
console.log(
|
|
987
|
+
" ✗ WRONG CLAIM — the proof failed, but the mutation's mapped claim did not turn red.",
|
|
988
|
+
);
|
|
989
|
+
} else {
|
|
990
|
+
console.log(
|
|
991
|
+
` ✗ MISSED — the vulnerability is live and ${path.basename(m.spec)} still passed.`,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
} catch (err) {
|
|
995
|
+
results.push({ id: m.id, error: String(err?.message ?? err) });
|
|
996
|
+
console.log(` ! ERROR — ${err?.message ?? err}`);
|
|
997
|
+
} finally {
|
|
998
|
+
let stopError = null;
|
|
999
|
+
if (sourceServer) {
|
|
1000
|
+
try {
|
|
1001
|
+
await sourceServer.stop();
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
stopError = error;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (applied) {
|
|
1007
|
+
try {
|
|
1008
|
+
// Trust nothing: restore from the exact snapshot and read back.
|
|
1009
|
+
// Source mutations preserve dirty working-tree contents byte-for-byte;
|
|
1010
|
+
// database mutations preserve the live object's catalog definition.
|
|
1011
|
+
restoreMutation(container, m, handler, snapshot);
|
|
1012
|
+
console.log(
|
|
1013
|
+
` reverted (${handler.describe(m.subject)} verified)\n`,
|
|
1014
|
+
);
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
const recovery = isSourceMutation(m)
|
|
1017
|
+
? `Restore ${m.subject.file} before continuing.`
|
|
1018
|
+
: "Restore it with:\n pnpm exec supabase db reset";
|
|
1019
|
+
console.error(
|
|
1020
|
+
`\n[mutation] FAILED TO REVERT "${m.id}". The local checkout is still vulnerable. ` +
|
|
1021
|
+
`${recovery}\n${err?.message ?? err}\n`,
|
|
1022
|
+
);
|
|
1023
|
+
process.exit(3);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (stopError) throw stopError;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
} finally {
|
|
1030
|
+
if (devServer) await devServer.stop();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
writeMutationSummary(results, inventory);
|
|
1034
|
+
|
|
1035
|
+
const missed = results.filter((r) => r.error || !r.detected || !r.reasonOk);
|
|
1036
|
+
// With --only the run is a deliberate subset, so an incomplete inventory is
|
|
1037
|
+
// reported but not fatal; a full run treats it as a failure.
|
|
1038
|
+
const inventoryFatal = !args.only && coverageProblems.length > 0;
|
|
1039
|
+
|
|
1040
|
+
console.log("── summary ─────────────────────────────────────────");
|
|
1041
|
+
for (const r of results) {
|
|
1042
|
+
const verdict = r.error
|
|
1043
|
+
? `ERROR (${r.error.split("\n")[0]})`
|
|
1044
|
+
: r.claimsTurnedRed === false
|
|
1045
|
+
? "WRONG CLAIM"
|
|
1046
|
+
: !r.detected
|
|
1047
|
+
? "MISSED"
|
|
1048
|
+
: !r.reasonOk
|
|
1049
|
+
? `WRONG REASON (expected ${r.expectedReason})`
|
|
1050
|
+
: "detected";
|
|
1051
|
+
console.log(` ${r.id}: ${verdict}`);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (missed.length > 0) {
|
|
1055
|
+
console.error(
|
|
1056
|
+
`\n[PROOF_FAIL] mutation_check: ${missed.length} of ${results.length} planted defect(s) were not properly detected. ` +
|
|
1057
|
+
`The affected proofs cannot fail, so their green status means nothing.`,
|
|
1058
|
+
);
|
|
1059
|
+
process.exit(1);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (inventoryFatal) {
|
|
1063
|
+
console.error(
|
|
1064
|
+
`\n[PROOF_FAIL] mutation_inventory: ${coverageProblems.length} proof spec(s) have never been shown to fail (listed above).`,
|
|
1065
|
+
);
|
|
1066
|
+
process.exit(1);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
console.log(
|
|
1070
|
+
`\n[mutation] all ${results.length} planted defect(s) were detected; the proof suite can still fail.` +
|
|
1071
|
+
` ${inventory.acceptedClaims.length} claim(s) are unmutated by declared choice.`,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const invokedDirectly =
|
|
1076
|
+
process.argv[1] !== undefined &&
|
|
1077
|
+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
1078
|
+
|
|
1079
|
+
if (invokedDirectly) {
|
|
1080
|
+
main().catch((err) => {
|
|
1081
|
+
console.error(`[mutation] unexpected error: ${err?.stack ?? err}`);
|
|
1082
|
+
process.exit(2);
|
|
1083
|
+
});
|
|
1084
|
+
}
|