@xaccefy/pi-casefile 0.9.4 → 0.10.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/README.md +31 -67
- package/package.json +13 -16
- package/src/confirmation.ts +729 -0
- package/src/evidence.ts +4 -4
- package/src/index.ts +189 -293
- package/src/ledger-internal.ts +321 -0
- package/src/ledger.ts +75 -1142
- package/src/oob-oracle.ts +279 -0
- package/src/poc-runner.ts +10 -0
- package/src/scratchpad.ts +5 -6
- package/src/workflow.ts +46 -327
- package/skills/casefile/SKILL.md +0 -44
- package/src/ledger-worker-entry.ts +0 -35
- package/src/ledger-worker.ts +0 -77
- package/src/pipeline-submit.ts +0 -797
package/src/pipeline-submit.ts
DELETED
|
@@ -1,797 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PipelineSubmit — the stage-output gate.
|
|
3
|
-
*
|
|
4
|
-
* The coordinator (model) dispatches stage subagents and submits their output
|
|
5
|
-
* here. This module is where the pipeline stops trusting prose: it validates
|
|
6
|
-
* stage output against the field specs mirrored from schemas/*.json, applies
|
|
7
|
-
* the deterministic pre-filter (test paths, hallucinated files, trivial dedup),
|
|
8
|
-
* and counts repair attempts. A stage cannot advance on an invalid output —
|
|
9
|
-
* the answer is REPAIR (with field-level errors) or REJECTED, in code.
|
|
10
|
-
*
|
|
11
|
-
* KEEP IN SYNC with schemas/*.json at the repo root. The JSON schemas are the
|
|
12
|
-
* canonical data contract for documentation; the SPECS table here is the
|
|
13
|
-
* executable gate (a focused validator for exactly these six shapes — no
|
|
14
|
-
* general JSON Schema engine).
|
|
15
|
-
*
|
|
16
|
-
* Persistence: .scratchpad/{run_id}/pipeline-submit.json
|
|
17
|
-
* { repairs: { "<stage>:<key>": n }, accepted_findings: FindingRef[] }
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { createHash } from "node:crypto";
|
|
21
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
22
|
-
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
|
-
import { KILL_REASON_VALUES } from "./ledger.ts";
|
|
24
|
-
import {
|
|
25
|
-
assertSafeRegularFile,
|
|
26
|
-
assertSafeStateDirectory,
|
|
27
|
-
readSafeFile,
|
|
28
|
-
writeSafeFileAtomic,
|
|
29
|
-
} from "./safe-state.ts";
|
|
30
|
-
import { getRunDir, getScratchpadRoot, scratchpad_write } from "./scratchpad.ts";
|
|
31
|
-
|
|
32
|
-
// ── Types ────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
export const SUBMIT_STAGES = ["hunt", "trace", "skeptic", "validate", "chain", "report"] as const;
|
|
35
|
-
export type SubmitStage = (typeof SUBMIT_STAGES)[number];
|
|
36
|
-
|
|
37
|
-
export type SubmitVerdict = "accepted" | "repair" | "rejected";
|
|
38
|
-
|
|
39
|
-
export type SubmitResult = {
|
|
40
|
-
verdict: SubmitVerdict;
|
|
41
|
-
stage: SubmitStage;
|
|
42
|
-
/** Field-level validation errors (repair) or rejection reason (rejected). */
|
|
43
|
-
errors: string[];
|
|
44
|
-
/** Repair attempt number (1-based) when verdict is repair. */
|
|
45
|
-
repair_attempt?: number;
|
|
46
|
-
/** Stable key identifying this finding's repair bucket. */
|
|
47
|
-
key?: string;
|
|
48
|
-
/** Set when hunt-stage dedup matched an accepted finding. */
|
|
49
|
-
duplicate_of?: string;
|
|
50
|
-
/** Scratchpad path the accepted output was written to. */
|
|
51
|
-
artifact?: string;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
type StageSpec = {
|
|
55
|
-
/** Exact top-level field allowlist; mirrors additionalProperties:false. */
|
|
56
|
-
allowed: readonly string[];
|
|
57
|
-
/** Fields that must be present and non-empty. */
|
|
58
|
-
required: {
|
|
59
|
-
name: string;
|
|
60
|
-
type: "string" | "integer" | "array" | "object";
|
|
61
|
-
enum?: readonly string[];
|
|
62
|
-
minItems?: number;
|
|
63
|
-
}[];
|
|
64
|
-
/** Exactly one of these locator field-sets must be fully present. */
|
|
65
|
-
locatorXor?: [string[], string[]];
|
|
66
|
-
/** Conditional requirements: when field equals value, these must be non-empty. */
|
|
67
|
-
conditional?: { when: { field: string; equals: string }; require: string[] }[];
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// ── Stage specs (mirror of schemas/*.json semantics) ─────────────────
|
|
71
|
-
|
|
72
|
-
// Exported for test/pipeline-submit-schema-parity.test.ts (drift guard
|
|
73
|
-
// against schemas/*.json — the two are kept as mirrors of each other).
|
|
74
|
-
export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
75
|
-
// schemas/stage-finding.json
|
|
76
|
-
hunt: {
|
|
77
|
-
allowed: [
|
|
78
|
-
"vuln_class",
|
|
79
|
-
"file",
|
|
80
|
-
"line",
|
|
81
|
-
"endpoint",
|
|
82
|
-
"sink",
|
|
83
|
-
"entry_point",
|
|
84
|
-
"confidence",
|
|
85
|
-
"evidence",
|
|
86
|
-
"attacker_model",
|
|
87
|
-
"subsystem",
|
|
88
|
-
],
|
|
89
|
-
required: [
|
|
90
|
-
{ name: "vuln_class", type: "string" },
|
|
91
|
-
{ name: "sink", type: "string" },
|
|
92
|
-
{ name: "entry_point", type: "string" },
|
|
93
|
-
{ name: "confidence", type: "string", enum: ["low", "medium", "high"] },
|
|
94
|
-
{ name: "evidence", type: "string" },
|
|
95
|
-
],
|
|
96
|
-
// Source targets: file + line. Live targets: endpoint.
|
|
97
|
-
locatorXor: [["file", "line"], ["endpoint"]],
|
|
98
|
-
},
|
|
99
|
-
// schemas/stage-trace.json
|
|
100
|
-
trace: {
|
|
101
|
-
allowed: [
|
|
102
|
-
"trace_result",
|
|
103
|
-
"entry_point",
|
|
104
|
-
"call_chain",
|
|
105
|
-
"defenses_checked",
|
|
106
|
-
"attacker_model",
|
|
107
|
-
"impact_if_reachable",
|
|
108
|
-
"unreachable_reason",
|
|
109
|
-
"uncertainty_reason",
|
|
110
|
-
],
|
|
111
|
-
required: [
|
|
112
|
-
{ name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE", "UNDETERMINED"] },
|
|
113
|
-
{ name: "entry_point", type: "string" },
|
|
114
|
-
{ name: "call_chain", type: "array", minItems: 1 },
|
|
115
|
-
{ name: "defenses_checked", type: "array" },
|
|
116
|
-
{ name: "attacker_model", type: "string" },
|
|
117
|
-
],
|
|
118
|
-
conditional: [
|
|
119
|
-
{ when: { field: "trace_result", equals: "REACHABLE" }, require: ["impact_if_reachable"] },
|
|
120
|
-
{ when: { field: "trace_result", equals: "UNREACHABLE" }, require: ["unreachable_reason"] },
|
|
121
|
-
{ when: { field: "trace_result", equals: "UNDETERMINED" }, require: ["uncertainty_reason"] },
|
|
122
|
-
],
|
|
123
|
-
},
|
|
124
|
-
// schemas/stage-skeptic.json
|
|
125
|
-
skeptic: {
|
|
126
|
-
allowed: [
|
|
127
|
-
"finding_id",
|
|
128
|
-
"verdict",
|
|
129
|
-
"reasoning",
|
|
130
|
-
"evidence_reviewed",
|
|
131
|
-
"disconfirmation_attempt",
|
|
132
|
-
"disproval_reason",
|
|
133
|
-
"uncertainty_reason",
|
|
134
|
-
],
|
|
135
|
-
required: [
|
|
136
|
-
{ name: "finding_id", type: "string" },
|
|
137
|
-
{ name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN", "UNDETERMINED"] },
|
|
138
|
-
{ name: "reasoning", type: "string" },
|
|
139
|
-
{ name: "evidence_reviewed", type: "array", minItems: 1 },
|
|
140
|
-
],
|
|
141
|
-
conditional: [
|
|
142
|
-
{ when: { field: "verdict", equals: "DISPROVEN" }, require: ["disproval_reason"] },
|
|
143
|
-
{
|
|
144
|
-
// A CONFIRMED verdict must carry the skeptic's own failed disproof —
|
|
145
|
-
// the workflow makes it the case's disconfirmation. "Could not
|
|
146
|
-
// disprove" alone is not an attempt.
|
|
147
|
-
when: { field: "verdict", equals: "CONFIRMED" },
|
|
148
|
-
require: ["disconfirmation_attempt"],
|
|
149
|
-
},
|
|
150
|
-
{ when: { field: "verdict", equals: "UNDETERMINED" }, require: ["uncertainty_reason"] },
|
|
151
|
-
],
|
|
152
|
-
},
|
|
153
|
-
// schemas/stage-validation.json
|
|
154
|
-
validate: {
|
|
155
|
-
allowed: [
|
|
156
|
-
"finding_id",
|
|
157
|
-
"status",
|
|
158
|
-
"technique_used",
|
|
159
|
-
"detection_method",
|
|
160
|
-
"poc_path",
|
|
161
|
-
"run_log",
|
|
162
|
-
"evidence_extracted",
|
|
163
|
-
"kill_reason",
|
|
164
|
-
"refinement_attempts",
|
|
165
|
-
],
|
|
166
|
-
required: [
|
|
167
|
-
{ name: "finding_id", type: "string" },
|
|
168
|
-
{
|
|
169
|
-
name: "status",
|
|
170
|
-
type: "string",
|
|
171
|
-
enum: ["pending_confirmation", "killed", "reported"],
|
|
172
|
-
},
|
|
173
|
-
{ name: "technique_used", type: "string" },
|
|
174
|
-
{ name: "detection_method", type: "string" },
|
|
175
|
-
],
|
|
176
|
-
conditional: [
|
|
177
|
-
{
|
|
178
|
-
when: { field: "status", equals: "pending_confirmation" },
|
|
179
|
-
require: ["poc_path", "run_log", "evidence_extracted"],
|
|
180
|
-
},
|
|
181
|
-
{ when: { field: "status", equals: "killed" }, require: ["kill_reason"] },
|
|
182
|
-
],
|
|
183
|
-
},
|
|
184
|
-
// schemas/stage-chain.json
|
|
185
|
-
chain: {
|
|
186
|
-
allowed: ["chains", "summary", "tokens_input", "tokens_output"],
|
|
187
|
-
required: [
|
|
188
|
-
{ name: "chains", type: "array" },
|
|
189
|
-
{ name: "summary", type: "string" },
|
|
190
|
-
],
|
|
191
|
-
},
|
|
192
|
-
// schemas/stage-report.json
|
|
193
|
-
report: {
|
|
194
|
-
allowed: [
|
|
195
|
-
"target",
|
|
196
|
-
"pipeline_status",
|
|
197
|
-
"total_tokens",
|
|
198
|
-
"findings",
|
|
199
|
-
"chains",
|
|
200
|
-
"coverage",
|
|
201
|
-
"summary",
|
|
202
|
-
"patches_applied",
|
|
203
|
-
],
|
|
204
|
-
required: [
|
|
205
|
-
{ name: "target", type: "string" },
|
|
206
|
-
{ name: "pipeline_status", type: "string", enum: ["complete", "partial", "aborted"] },
|
|
207
|
-
{ name: "findings", type: "array" },
|
|
208
|
-
{ name: "coverage", type: "object" }, // patternProperties object, not array
|
|
209
|
-
{ name: "summary", type: "string" },
|
|
210
|
-
],
|
|
211
|
-
},
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
const MAX_REPAIR_ATTEMPTS = 2;
|
|
215
|
-
|
|
216
|
-
// Segment-based test-path detection: matches "test", "__tests__", "specs",
|
|
217
|
-
// "e2e", "test-utils", "fixtures", ... anchored per path segment so
|
|
218
|
-
// "latest"/"contest"/"attest" do NOT match. A regex-only version missed
|
|
219
|
-
// leading underscores ("__tests__").
|
|
220
|
-
const TEST_SEGMENT_RE =
|
|
221
|
-
/^[._-]*(tests?|specs?|e2e|fixtures?|mocks?|stubs?|examples?|samples?|test[-_]?data|test[-_]?utils)[._-]*$/i;
|
|
222
|
-
const TEST_FILE_RE =
|
|
223
|
-
/([._-](test|spec|mock|fixture|stub|example|sample)\.[a-z0-9]+$|^test[-_]utils\.[a-z0-9]+$)/i;
|
|
224
|
-
|
|
225
|
-
/** Chain items: each must have title, severity, steps (≥2), narrative. */
|
|
226
|
-
const CHAIN_SEVERITIES = ["low", "medium", "high", "critical"] as const;
|
|
227
|
-
|
|
228
|
-
// ── Pre-filter constants (hunt stage only) ───────────────────────────
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Test/mock/example paths carry no real findings (mirrors VVAH S5). Exception
|
|
232
|
-
* from VVAH deliberately not copied: hardcoded-creds-in-test-files — the
|
|
233
|
-
* auditor can submit those under the precise class it decides fits the issue;
|
|
234
|
-
* the gate errs on filtering noise.
|
|
235
|
-
*/
|
|
236
|
-
|
|
237
|
-
/** Trivial dedup: same file + vuln_class + line within this tolerance. */
|
|
238
|
-
const DEDUP_LINE_TOLERANCE = 10;
|
|
239
|
-
|
|
240
|
-
// ── Persistence ─────────────────────────────────────────────────────
|
|
241
|
-
|
|
242
|
-
type FindingRef = {
|
|
243
|
-
key: string;
|
|
244
|
-
file: string;
|
|
245
|
-
line?: number;
|
|
246
|
-
endpoint?: string;
|
|
247
|
-
vuln_class: string;
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
type SubmitState = {
|
|
251
|
-
repairs: Record<string, number>;
|
|
252
|
-
accepted_findings: FindingRef[];
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
function statePath(runId: string): string {
|
|
256
|
-
return join(getRunDir(runId), "pipeline-submit.json");
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function readState(runId: string): SubmitState {
|
|
260
|
-
const p = statePath(runId);
|
|
261
|
-
if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
|
|
262
|
-
assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
|
|
263
|
-
assertSafeRegularFile(p, "Pipeline state");
|
|
264
|
-
const raw = JSON.parse(
|
|
265
|
-
readSafeFile(p, "Pipeline state").toString("utf8"),
|
|
266
|
-
) as Partial<SubmitState>;
|
|
267
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
268
|
-
throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
|
|
269
|
-
}
|
|
270
|
-
const repairs = raw.repairs ?? {};
|
|
271
|
-
if (typeof repairs !== "object" || repairs === null || Array.isArray(repairs)) {
|
|
272
|
-
throw new Error(`Corrupt pipeline-submit state for ${runId}: repairs must be an object`);
|
|
273
|
-
}
|
|
274
|
-
for (const [k, v] of Object.entries(repairs)) {
|
|
275
|
-
if (typeof k !== "string" || typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
276
|
-
throw new Error(`Corrupt pipeline-submit state for ${runId}: invalid repair counter`);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
const accepted = raw.accepted_findings ?? [];
|
|
280
|
-
if (!Array.isArray(accepted)) {
|
|
281
|
-
throw new Error(
|
|
282
|
-
`Corrupt pipeline-submit state for ${runId}: accepted_findings must be an array`,
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
for (const [i, item] of accepted.entries()) {
|
|
286
|
-
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
287
|
-
throw new Error(
|
|
288
|
-
`Corrupt pipeline-submit state for ${runId}: accepted_findings[${i}] invalid`,
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return {
|
|
293
|
-
repairs: repairs as Record<string, number>,
|
|
294
|
-
accepted_findings: accepted as FindingRef[],
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function writeState(runId: string, state: SubmitState): void {
|
|
299
|
-
const p = statePath(runId);
|
|
300
|
-
assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
|
|
301
|
-
writeSafeFileAtomic(p, JSON.stringify(state, null, 2));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** Project root containing the scratchpad (file-existence checks resolve here). */
|
|
305
|
-
function projectRoot(): string {
|
|
306
|
-
return dirname(getScratchpadRoot());
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ── Parsing ──────────────────────────────────────────────────────────
|
|
310
|
-
|
|
311
|
-
function parseOutput(output: unknown): { obj?: Record<string, unknown>; error?: string } {
|
|
312
|
-
if (typeof output === "object" && output !== null && !Array.isArray(output)) {
|
|
313
|
-
return { obj: output as Record<string, unknown> };
|
|
314
|
-
}
|
|
315
|
-
if (typeof output !== "string") {
|
|
316
|
-
return { error: "output must be a JSON object or a JSON string" };
|
|
317
|
-
}
|
|
318
|
-
let text = output.trim();
|
|
319
|
-
// Tolerate markdown code fences around the payload.
|
|
320
|
-
text = text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
|
|
321
|
-
try {
|
|
322
|
-
const parsed = JSON.parse(text);
|
|
323
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
324
|
-
return { error: "output must parse to a JSON object" };
|
|
325
|
-
}
|
|
326
|
-
return { obj: parsed as Record<string, unknown> };
|
|
327
|
-
} catch (e) {
|
|
328
|
-
return { error: `output is not valid JSON: ${(e as Error).message.slice(0, 120)}` };
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/** Stable repair-bucket key for a submission. */
|
|
333
|
-
/** Placeholder-y ids that carry no identity (observed in the wild: every
|
|
334
|
-
* submission in a run keyed "false"). Trusting them makes distinct findings
|
|
335
|
-
* share one key — one artifact name, one repair-budget bucket — so the last
|
|
336
|
-
* write clobbers the rest. Fall back to the content hash instead. */
|
|
337
|
-
const JUNK_ID_RE =
|
|
338
|
-
/^(false|true|null|none|undefined|n\/?a|na|unknown|missing|empty|todo|tbd|pending|not-set)$/i;
|
|
339
|
-
|
|
340
|
-
function submissionKey(stage: SubmitStage, obj: Record<string, unknown>): string {
|
|
341
|
-
const candidate =
|
|
342
|
-
(typeof obj.finding_id === "string" && obj.finding_id.trim()) ||
|
|
343
|
-
(typeof obj.title === "string" && obj.title.trim()) ||
|
|
344
|
-
(typeof obj.id === "string" && obj.id.trim());
|
|
345
|
-
const id =
|
|
346
|
-
candidate && candidate.length >= 3 && !JUNK_ID_RE.test(candidate) ? candidate : undefined;
|
|
347
|
-
const tail = id ?? createHash("sha1").update(JSON.stringify(obj)).digest("hex").slice(0, 8);
|
|
348
|
-
return `${stage}:${tail}`;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// ── Validation ──────────────────────────────────────────────────────
|
|
352
|
-
|
|
353
|
-
function isNonEmptyString(v: unknown): v is string {
|
|
354
|
-
return typeof v === "string" && v.trim().length > 0;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function requireStringArray(errors: string[], path: string, value: unknown, minItems = 0): void {
|
|
358
|
-
if (!Array.isArray(value)) {
|
|
359
|
-
errors.push(`${path}: missing or not an array`);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
if (value.length < minItems) errors.push(`${path}: needs at least ${minItems} item(s)`);
|
|
363
|
-
value.forEach((item, i) => {
|
|
364
|
-
if (!isNonEmptyString(item)) errors.push(`${path}[${i}]: missing or empty string`);
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
369
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
370
|
-
? (value as Record<string, unknown>)
|
|
371
|
-
: undefined;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function validateReport(errors: string[], obj: Record<string, unknown>): void {
|
|
375
|
-
const findingSeverities = ["info", "low", "medium", "high", "critical"];
|
|
376
|
-
if (Array.isArray(obj.findings)) {
|
|
377
|
-
obj.findings.forEach((item, i) => {
|
|
378
|
-
const finding = asObject(item);
|
|
379
|
-
if (!finding) {
|
|
380
|
-
errors.push(`findings[${i}]: not an object`);
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
for (const field of ["id", "vuln_class", "severity", "status"] as const) {
|
|
384
|
-
if (!isNonEmptyString(finding[field]))
|
|
385
|
-
errors.push(`findings[${i}].${field}: missing or empty`);
|
|
386
|
-
}
|
|
387
|
-
if (!isNonEmptyString(finding.file) && !isNonEmptyString(finding.endpoint)) {
|
|
388
|
-
errors.push(`findings[${i}]: provide file or endpoint`);
|
|
389
|
-
}
|
|
390
|
-
if (isNonEmptyString(finding.severity) && !findingSeverities.includes(finding.severity)) {
|
|
391
|
-
errors.push(`findings[${i}].severity: invalid value`);
|
|
392
|
-
}
|
|
393
|
-
if (isNonEmptyString(finding.status) && !["confirmed", "reported"].includes(finding.status)) {
|
|
394
|
-
errors.push(`findings[${i}].status: invalid value`);
|
|
395
|
-
}
|
|
396
|
-
if (finding.chain_with !== undefined)
|
|
397
|
-
requireStringArray(errors, `findings[${i}].chain_with`, finding.chain_with);
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const coverage = asObject(obj.coverage);
|
|
402
|
-
if (coverage) {
|
|
403
|
-
const allowed = ["COVERED", "SKIPPED", "NOT_FOUND", "INCOMPLETE"];
|
|
404
|
-
for (const [key, value] of Object.entries(coverage)) {
|
|
405
|
-
if (!key.trim() || /[\r\n]/.test(key)) errors.push(`coverage.${key}: invalid class key`);
|
|
406
|
-
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
407
|
-
errors.push(`coverage.${key}: must be one of { ${allowed.join(" | ")} }`);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (obj.chains !== undefined) {
|
|
413
|
-
if (!Array.isArray(obj.chains)) {
|
|
414
|
-
errors.push("chains: not an array");
|
|
415
|
-
} else {
|
|
416
|
-
obj.chains.forEach((item, i) => {
|
|
417
|
-
const chain = asObject(item);
|
|
418
|
-
if (!chain) {
|
|
419
|
-
errors.push(`chains[${i}]: not an object`);
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
if (chain.title !== undefined && !isNonEmptyString(chain.title))
|
|
423
|
-
errors.push(`chains[${i}].title: missing or empty`);
|
|
424
|
-
if (chain.steps !== undefined)
|
|
425
|
-
requireStringArray(errors, `chains[${i}].steps`, chain.steps);
|
|
426
|
-
if (
|
|
427
|
-
chain.severity !== undefined &&
|
|
428
|
-
(!isNonEmptyString(chain.severity) ||
|
|
429
|
-
!(CHAIN_SEVERITIES as readonly string[]).includes(chain.severity))
|
|
430
|
-
) {
|
|
431
|
-
errors.push(`chains[${i}].severity: invalid value`);
|
|
432
|
-
}
|
|
433
|
-
if (chain.blocked_by_controls !== undefined) {
|
|
434
|
-
requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
|
|
435
|
-
}
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (obj.patches_applied !== undefined) {
|
|
441
|
-
if (!Array.isArray(obj.patches_applied)) {
|
|
442
|
-
errors.push("patches_applied: not an array");
|
|
443
|
-
} else {
|
|
444
|
-
obj.patches_applied.forEach((item, i) => {
|
|
445
|
-
const patch = asObject(item);
|
|
446
|
-
if (!patch) {
|
|
447
|
-
errors.push(`patches_applied[${i}]: not an object`);
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
for (const field of ["finding_id", "diff_summary", "re_attack_result"] as const) {
|
|
451
|
-
if (patch[field] !== undefined && !isNonEmptyString(patch[field])) {
|
|
452
|
-
errors.push(`patches_applied[${i}].${field}: missing or empty`);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
function resolveProjectPath(input: string): { abs: string; rel: string } {
|
|
461
|
-
const root = projectRoot();
|
|
462
|
-
const trimmed = input.trim();
|
|
463
|
-
const relativeInput = trimmed.replace(/^\.\//, "");
|
|
464
|
-
const abs = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, relativeInput);
|
|
465
|
-
return { abs, rel: relative(root, abs) };
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
|
|
469
|
-
const spec = SPECS[stage];
|
|
470
|
-
const errors: string[] = [];
|
|
471
|
-
|
|
472
|
-
const allowedFields = new Set(spec.allowed);
|
|
473
|
-
for (const name of Object.keys(obj)) {
|
|
474
|
-
if (!allowedFields.has(name)) errors.push(`${name}: unknown top-level field`);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
for (const field of spec.required) {
|
|
478
|
-
const v = obj[field.name];
|
|
479
|
-
if (field.type === "string") {
|
|
480
|
-
if (!isNonEmptyString(v)) {
|
|
481
|
-
errors.push(`${field.name}: missing or empty string`);
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
} else if (field.type === "object") {
|
|
485
|
-
if (typeof v !== "object" || v === null || Array.isArray(v)) {
|
|
486
|
-
errors.push(`${field.name}: missing or not an object`);
|
|
487
|
-
continue;
|
|
488
|
-
}
|
|
489
|
-
} else if (field.type === "integer") {
|
|
490
|
-
if (typeof v !== "number" || !Number.isInteger(v)) {
|
|
491
|
-
errors.push(`${field.name}: missing or not an integer`);
|
|
492
|
-
continue;
|
|
493
|
-
}
|
|
494
|
-
} else {
|
|
495
|
-
if (!Array.isArray(v)) {
|
|
496
|
-
errors.push(`${field.name}: missing or not an array`);
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
if (field.minItems !== undefined && v.length < field.minItems) {
|
|
500
|
-
errors.push(`${field.name}: needs at least ${field.minItems} item(s), got ${v.length}`);
|
|
501
|
-
continue;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
if (field.enum && !field.enum.includes(v as never)) {
|
|
505
|
-
errors.push(`${field.name}: "${String(v)}" not in { ${field.enum.join(" | ")} }`);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
if (spec.locatorXor) {
|
|
510
|
-
const [a, b] = spec.locatorXor;
|
|
511
|
-
const hasSet = (set: string[]) =>
|
|
512
|
-
set.every((f) => (f === "line" ? Number.isInteger(obj[f]) : isNonEmptyString(obj[f])));
|
|
513
|
-
const hasA = hasSet(a);
|
|
514
|
-
const hasB = hasSet(b);
|
|
515
|
-
if (hasA === hasB) {
|
|
516
|
-
errors.push(
|
|
517
|
-
`locator: provide exactly one of { ${a.join("+")} } (source) or { ${b.join("+")} } (live)`,
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
if (hasA && typeof obj.line === "number" && obj.line < 1) {
|
|
521
|
-
errors.push("line: must be >= 1");
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
for (const cond of spec.conditional ?? []) {
|
|
526
|
-
if (obj[cond.when.field] === cond.when.equals) {
|
|
527
|
-
for (const name of cond.require) {
|
|
528
|
-
if (!isNonEmptyString(obj[name])) {
|
|
529
|
-
errors.push(`${name}: required when ${cond.when.field} = ${cond.when.equals}`);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if (stage === "trace") {
|
|
536
|
-
requireStringArray(errors, "call_chain", obj.call_chain, 1);
|
|
537
|
-
if (Array.isArray(obj.defenses_checked)) {
|
|
538
|
-
obj.defenses_checked.forEach((item, i) => {
|
|
539
|
-
const defense = asObject(item);
|
|
540
|
-
if (!defense) {
|
|
541
|
-
errors.push(`defenses_checked[${i}]: not an object`);
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
if (!isNonEmptyString(defense.defense))
|
|
545
|
-
errors.push(`defenses_checked[${i}].defense: missing or empty`);
|
|
546
|
-
if (!isNonEmptyString(defense.location))
|
|
547
|
-
errors.push(`defenses_checked[${i}].location: missing or empty`);
|
|
548
|
-
if (
|
|
549
|
-
!isNonEmptyString(defense.verdict) ||
|
|
550
|
-
!["bypassed", "blocked", "not-present"].includes(defense.verdict)
|
|
551
|
-
) {
|
|
552
|
-
errors.push(`defenses_checked[${i}].verdict: must be bypassed, blocked, or not-present`);
|
|
553
|
-
}
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
if (stage === "skeptic") {
|
|
559
|
-
requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
|
|
560
|
-
if (obj.verdict === "DISPROVEN" && isNonEmptyString(obj.disproval_reason)) {
|
|
561
|
-
if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.disproval_reason)) {
|
|
562
|
-
errors.push("disproval_reason: invalid value");
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
if (stage === "validate") {
|
|
568
|
-
if (obj.status === "killed" && isNonEmptyString(obj.kill_reason)) {
|
|
569
|
-
if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.kill_reason)) {
|
|
570
|
-
errors.push("kill_reason: invalid value");
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
if (obj.status === "pending_confirmation" && isNonEmptyString(obj.poc_path)) {
|
|
574
|
-
// A phase-1 validation submission must point at a PoC file
|
|
575
|
-
// that actually exists in the project — same file-existence filter hunt
|
|
576
|
-
// findings get. Otherwise fabricated run logs pass the stage gate.
|
|
577
|
-
const raw = obj.poc_path as string;
|
|
578
|
-
const { abs, rel } = resolveProjectPath(raw);
|
|
579
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
580
|
-
errors.push("poc_path: must resolve inside the project root");
|
|
581
|
-
} else if (!existsSync(abs)) {
|
|
582
|
-
errors.push(`poc_path: "${raw}" does not exist under the project root`);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
if (
|
|
586
|
-
obj.refinement_attempts !== undefined &&
|
|
587
|
-
(!Number.isInteger(obj.refinement_attempts) ||
|
|
588
|
-
(obj.refinement_attempts as number) < 1 ||
|
|
589
|
-
(obj.refinement_attempts as number) > 3)
|
|
590
|
-
) {
|
|
591
|
-
errors.push("refinement_attempts: must be an integer from 1 to 3");
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
if (stage === "chain" && Array.isArray(obj.chains)) {
|
|
596
|
-
obj.chains.forEach((c, i) => {
|
|
597
|
-
const chain = asObject(c);
|
|
598
|
-
if (!chain) {
|
|
599
|
-
errors.push(`chains[${i}]: not an object`);
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
if (!isNonEmptyString(chain.title)) errors.push(`chains[${i}].title: missing or empty`);
|
|
603
|
-
if (
|
|
604
|
-
!isNonEmptyString(chain.severity) ||
|
|
605
|
-
!(CHAIN_SEVERITIES as readonly string[]).includes(chain.severity)
|
|
606
|
-
) {
|
|
607
|
-
errors.push(`chains[${i}].severity: must be one of { ${CHAIN_SEVERITIES.join(" | ")} }`);
|
|
608
|
-
}
|
|
609
|
-
requireStringArray(errors, `chains[${i}].steps`, chain.steps, 2);
|
|
610
|
-
if (chain.blocked_by_controls !== undefined) {
|
|
611
|
-
requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
|
|
612
|
-
}
|
|
613
|
-
if (!isNonEmptyString(chain.narrative))
|
|
614
|
-
errors.push(`chains[${i}].narrative: missing or empty`);
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
if (stage === "report") validateReport(errors, obj);
|
|
619
|
-
|
|
620
|
-
return errors;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// ── Pre-filter + dedup (hunt only) ──────────────────────────────────
|
|
624
|
-
|
|
625
|
-
function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
626
|
-
const file = typeof obj.file === "string" ? obj.file : undefined;
|
|
627
|
-
if (!file) return null; // live target: endpoint locator, nothing to filter
|
|
628
|
-
const root = projectRoot();
|
|
629
|
-
const { abs, rel } = resolveProjectPath(file);
|
|
630
|
-
// Containment: resolved path must stay inside the project, otherwise a
|
|
631
|
-
// "finding" can point at ../ or absolute files outside the target repo.
|
|
632
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
633
|
-
return (
|
|
634
|
-
`containment filter: "${file}" resolves outside the project root (${root}). ` +
|
|
635
|
-
`Findings must reference files inside the target repository.`
|
|
636
|
-
);
|
|
637
|
-
}
|
|
638
|
-
const segments = rel.split("/");
|
|
639
|
-
if (segments.some((s) => TEST_SEGMENT_RE.test(s)) || TEST_FILE_RE.test(rel)) {
|
|
640
|
-
return (
|
|
641
|
-
`test-path filter: "${file}" matches test/fixture/mock paths — findings in ` +
|
|
642
|
-
`test code are noise. If this is a deliberately-shipped test credential, ` +
|
|
643
|
-
`re-submit documenting why it ships to production.`
|
|
644
|
-
);
|
|
645
|
-
}
|
|
646
|
-
// Symlink containment (same defense as the PoC runner): resolve() is
|
|
647
|
-
// lexical, and existsSync() dereferences symlinks — a workspace symlink to
|
|
648
|
-
// /etc (ln -s /etc etc-link) would otherwise pass both checks and let a
|
|
649
|
-
// "finding" point at host paths outside the project.
|
|
650
|
-
let real: string;
|
|
651
|
-
try {
|
|
652
|
-
real = realpathSync(abs);
|
|
653
|
-
} catch {
|
|
654
|
-
return `file-existence filter: "${file}" cannot be resolved under the project root (${root}).`;
|
|
655
|
-
}
|
|
656
|
-
const realRel = relative(root, real);
|
|
657
|
-
if (realRel.startsWith("..") || isAbsolute(realRel)) {
|
|
658
|
-
return (
|
|
659
|
-
`containment filter: "${file}" resolves through a symlink to outside the project root ` +
|
|
660
|
-
`(${real}). Symlinked files outside ${root} are rejected.`
|
|
661
|
-
);
|
|
662
|
-
}
|
|
663
|
-
if (!existsSync(abs)) {
|
|
664
|
-
return (
|
|
665
|
-
`file-existence filter: "${file}" does not exist under the project root ` +
|
|
666
|
-
`(${root}). Hallucinated paths are rejected outright.`
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
return null;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
|
|
673
|
-
const file =
|
|
674
|
-
typeof obj.file === "string"
|
|
675
|
-
? resolveProjectPath(obj.file).rel.replace(/^\.\//, "")
|
|
676
|
-
: undefined;
|
|
677
|
-
const endpoint = typeof obj.endpoint === "string" ? obj.endpoint.trim() : undefined;
|
|
678
|
-
const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
|
|
679
|
-
const line = typeof obj.line === "number" ? obj.line : undefined;
|
|
680
|
-
if (!vulnClass) return {};
|
|
681
|
-
for (const accepted of state.accepted_findings) {
|
|
682
|
-
if (accepted.vuln_class !== vulnClass) continue;
|
|
683
|
-
// Live locator: same endpoint + class is the same finding (re-submissions
|
|
684
|
-
// after a repair must not be accepted repeatedly).
|
|
685
|
-
if (!file && endpoint !== undefined) {
|
|
686
|
-
if (accepted.endpoint === endpoint) return { duplicateOf: accepted.key };
|
|
687
|
-
continue;
|
|
688
|
-
}
|
|
689
|
-
if (!file || accepted.file !== file) continue;
|
|
690
|
-
if (
|
|
691
|
-
line !== undefined &&
|
|
692
|
-
accepted.line !== undefined &&
|
|
693
|
-
Math.abs(line - accepted.line) > DEDUP_LINE_TOLERANCE
|
|
694
|
-
) {
|
|
695
|
-
continue;
|
|
696
|
-
}
|
|
697
|
-
return { duplicateOf: accepted.key };
|
|
698
|
-
}
|
|
699
|
-
return {};
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// ── Public API ───────────────────────────────────────────────────────
|
|
703
|
-
|
|
704
|
-
export function pipeline_submit(runId: string, stage: SubmitStage, output: unknown): SubmitResult {
|
|
705
|
-
const parsed = parseOutput(output);
|
|
706
|
-
if (parsed.error || !parsed.obj) {
|
|
707
|
-
const state = readState(runId);
|
|
708
|
-
const key = `${stage}:unparseable`;
|
|
709
|
-
state.repairs[key] = (state.repairs[key] ?? 0) + 1;
|
|
710
|
-
const attempt = state.repairs[key];
|
|
711
|
-
// Persist before BOTH returns — otherwise unparseable output bypasses the
|
|
712
|
-
// repair budget forever (counter never hits disk on the rejected path).
|
|
713
|
-
writeState(runId, state);
|
|
714
|
-
if (attempt > MAX_REPAIR_ATTEMPTS) {
|
|
715
|
-
return { verdict: "rejected", stage, errors: [parsed.error ?? "unparseable"], key };
|
|
716
|
-
}
|
|
717
|
-
return {
|
|
718
|
-
verdict: "repair",
|
|
719
|
-
stage,
|
|
720
|
-
errors: [parsed.error ?? "unparseable"],
|
|
721
|
-
repair_attempt: attempt,
|
|
722
|
-
key,
|
|
723
|
-
};
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
const obj = parsed.obj;
|
|
727
|
-
const key = submissionKey(stage, obj);
|
|
728
|
-
|
|
729
|
-
const errors = validateStage(stage, obj);
|
|
730
|
-
if (errors.length > 0) {
|
|
731
|
-
const state = readState(runId);
|
|
732
|
-
state.repairs[key] = (state.repairs[key] ?? 0) + 1;
|
|
733
|
-
const attempt = state.repairs[key];
|
|
734
|
-
if (attempt > MAX_REPAIR_ATTEMPTS) {
|
|
735
|
-
writeState(runId, state);
|
|
736
|
-
return {
|
|
737
|
-
verdict: "rejected",
|
|
738
|
-
stage,
|
|
739
|
-
errors: [...errors, `repair budget exhausted (${MAX_REPAIR_ATTEMPTS} attempts)`],
|
|
740
|
-
key,
|
|
741
|
-
};
|
|
742
|
-
}
|
|
743
|
-
writeState(runId, state);
|
|
744
|
-
return { verdict: "repair", stage, errors, repair_attempt: attempt, key };
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// Hunt stage: deterministic noise gates before acceptance.
|
|
748
|
-
if (stage === "hunt") {
|
|
749
|
-
const filtered = prefilterHunt(obj);
|
|
750
|
-
if (filtered) {
|
|
751
|
-
return { verdict: "rejected", stage, errors: [filtered], key };
|
|
752
|
-
}
|
|
753
|
-
const state = readState(runId);
|
|
754
|
-
const { duplicateOf } = dedupHunt(state, obj);
|
|
755
|
-
if (duplicateOf) {
|
|
756
|
-
return {
|
|
757
|
-
verdict: "rejected",
|
|
758
|
-
stage,
|
|
759
|
-
errors: [
|
|
760
|
-
`trivial dedup: same file + vuln_class within ${DEDUP_LINE_TOLERANCE} lines of accepted finding ${duplicateOf}`,
|
|
761
|
-
],
|
|
762
|
-
key,
|
|
763
|
-
duplicate_of: duplicateOf,
|
|
764
|
-
};
|
|
765
|
-
}
|
|
766
|
-
if (typeof obj.vuln_class === "string") {
|
|
767
|
-
const isFileFinding = typeof obj.file === "string";
|
|
768
|
-
const isEndpointFinding = !isFileFinding && typeof obj.endpoint === "string";
|
|
769
|
-
if (isFileFinding || isEndpointFinding) {
|
|
770
|
-
state.accepted_findings.push({
|
|
771
|
-
key,
|
|
772
|
-
file: isFileFinding
|
|
773
|
-
? resolveProjectPath(obj.file as string).rel.replace(/^\.\//, "")
|
|
774
|
-
: "",
|
|
775
|
-
line: typeof obj.line === "number" ? obj.line : undefined,
|
|
776
|
-
endpoint: isEndpointFinding ? (obj.endpoint as string).trim() : undefined,
|
|
777
|
-
vuln_class: obj.vuln_class,
|
|
778
|
-
});
|
|
779
|
-
}
|
|
780
|
-
writeState(runId, state);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Submit stages are a subset of scratchpad phases, so the stage name IS
|
|
785
|
-
// the phase directory. The filename gets a content hash: distinct findings
|
|
786
|
-
// sharing one plausible id (the stable repair-bucket key) must not clobber
|
|
787
|
-
// each other's accepted artifact.
|
|
788
|
-
const json = JSON.stringify(obj, null, 2);
|
|
789
|
-
const contentHash = createHash("sha1").update(json).digest("hex").slice(0, 8);
|
|
790
|
-
const artifact = scratchpad_write(
|
|
791
|
-
runId,
|
|
792
|
-
stage,
|
|
793
|
-
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}-${contentHash}.json`,
|
|
794
|
-
json,
|
|
795
|
-
);
|
|
796
|
-
return { verdict: "accepted", stage, errors: [], key, artifact };
|
|
797
|
-
}
|