@xaccefy/pi-casefile 0.8.2 → 0.9.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 +4 -3
- package/package.json +3 -5
- package/skills/casefile/SKILL.md +4 -3
- package/src/index.ts +294 -225
- package/src/ledger.ts +591 -396
- package/src/pipeline-submit.ts +279 -26
- package/src/poc-runner.ts +186 -65
- package/src/scratchpad.ts +106 -16
- package/src/workflow.ts +79 -16
package/src/pipeline-submit.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
23
|
import { getRunDir, getScratchpadRoot, scratchpad_write } from "./scratchpad.ts";
|
|
24
24
|
|
|
@@ -120,6 +120,13 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
120
120
|
],
|
|
121
121
|
conditional: [
|
|
122
122
|
{ when: { field: "verdict", equals: "DISPROVEN" }, require: ["disproval_reason"] },
|
|
123
|
+
{
|
|
124
|
+
// A CONFIRMED verdict must carry the skeptic's own failed disproof —
|
|
125
|
+
// the workflow makes it the case's disconfirmation. "Could not
|
|
126
|
+
// disprove" alone is not an attempt.
|
|
127
|
+
when: { field: "verdict", equals: "CONFIRMED" },
|
|
128
|
+
require: ["disconfirmation_attempt"],
|
|
129
|
+
},
|
|
123
130
|
],
|
|
124
131
|
},
|
|
125
132
|
// schemas/stage-validation.json
|
|
@@ -189,6 +196,7 @@ type FindingRef = {
|
|
|
189
196
|
key: string;
|
|
190
197
|
file: string;
|
|
191
198
|
line?: number;
|
|
199
|
+
endpoint?: string;
|
|
192
200
|
vuln_class: string;
|
|
193
201
|
};
|
|
194
202
|
|
|
@@ -204,19 +212,43 @@ function statePath(runId: string): string {
|
|
|
204
212
|
function readState(runId: string): SubmitState {
|
|
205
213
|
const p = statePath(runId);
|
|
206
214
|
if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
|
|
216
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
217
|
+
throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
|
|
218
|
+
}
|
|
219
|
+
const repairs = raw.repairs ?? {};
|
|
220
|
+
if (typeof repairs !== "object" || repairs === null || Array.isArray(repairs)) {
|
|
221
|
+
throw new Error(`Corrupt pipeline-submit state for ${runId}: repairs must be an object`);
|
|
222
|
+
}
|
|
223
|
+
for (const [k, v] of Object.entries(repairs)) {
|
|
224
|
+
if (typeof k !== "string" || typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
225
|
+
throw new Error(`Corrupt pipeline-submit state for ${runId}: invalid repair counter`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const accepted = raw.accepted_findings ?? [];
|
|
229
|
+
if (!Array.isArray(accepted)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Corrupt pipeline-submit state for ${runId}: accepted_findings must be an array`,
|
|
232
|
+
);
|
|
215
233
|
}
|
|
234
|
+
for (const [i, item] of accepted.entries()) {
|
|
235
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Corrupt pipeline-submit state for ${runId}: accepted_findings[${i}] invalid`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
repairs: repairs as Record<string, number>,
|
|
243
|
+
accepted_findings: accepted as FindingRef[],
|
|
244
|
+
};
|
|
216
245
|
}
|
|
217
246
|
|
|
218
247
|
function writeState(runId: string, state: SubmitState): void {
|
|
219
|
-
|
|
248
|
+
const p = statePath(runId);
|
|
249
|
+
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
|
|
250
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
|
|
251
|
+
renameSync(tmp, p);
|
|
220
252
|
}
|
|
221
253
|
|
|
222
254
|
/** Project root containing the scratchpad (file-existence checks resolve here). */
|
|
@@ -272,6 +304,109 @@ function isNonEmptyString(v: unknown): v is string {
|
|
|
272
304
|
return typeof v === "string" && v.trim().length > 0;
|
|
273
305
|
}
|
|
274
306
|
|
|
307
|
+
function requireStringArray(errors: string[], path: string, value: unknown, minItems = 0): void {
|
|
308
|
+
if (!Array.isArray(value)) {
|
|
309
|
+
errors.push(`${path}: missing or not an array`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (value.length < minItems) errors.push(`${path}: needs at least ${minItems} item(s)`);
|
|
313
|
+
value.forEach((item, i) => {
|
|
314
|
+
if (!isNonEmptyString(item)) errors.push(`${path}[${i}]: missing or empty string`);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
319
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
320
|
+
? (value as Record<string, unknown>)
|
|
321
|
+
: undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function validateReport(errors: string[], obj: Record<string, unknown>): void {
|
|
325
|
+
const findingSeverities = ["info", "low", "medium", "high", "critical"];
|
|
326
|
+
if (Array.isArray(obj.findings)) {
|
|
327
|
+
obj.findings.forEach((item, i) => {
|
|
328
|
+
const finding = asObject(item);
|
|
329
|
+
if (!finding) {
|
|
330
|
+
errors.push(`findings[${i}]: not an object`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
for (const field of ["id", "vuln_class", "severity", "status"] as const) {
|
|
334
|
+
if (!isNonEmptyString(finding[field]))
|
|
335
|
+
errors.push(`findings[${i}].${field}: missing or empty`);
|
|
336
|
+
}
|
|
337
|
+
if (!isNonEmptyString(finding.file) && !isNonEmptyString(finding.endpoint)) {
|
|
338
|
+
errors.push(`findings[${i}]: provide file or endpoint`);
|
|
339
|
+
}
|
|
340
|
+
if (isNonEmptyString(finding.severity) && !findingSeverities.includes(finding.severity)) {
|
|
341
|
+
errors.push(`findings[${i}].severity: invalid value`);
|
|
342
|
+
}
|
|
343
|
+
if (isNonEmptyString(finding.status) && !["confirmed", "reported"].includes(finding.status)) {
|
|
344
|
+
errors.push(`findings[${i}].status: invalid value`);
|
|
345
|
+
}
|
|
346
|
+
if (finding.chain_with !== undefined)
|
|
347
|
+
requireStringArray(errors, `findings[${i}].chain_with`, finding.chain_with);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const coverage = asObject(obj.coverage);
|
|
352
|
+
if (coverage) {
|
|
353
|
+
const allowed = ["COVERED", "SKIPPED", "NOT_FOUND", "INCOMPLETE"];
|
|
354
|
+
for (const [key, value] of Object.entries(coverage)) {
|
|
355
|
+
if (!/^[a-z-]+$/.test(key)) errors.push(`coverage.${key}: invalid class key`);
|
|
356
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
357
|
+
errors.push(`coverage.${key}: must be one of { ${allowed.join(" | ")} }`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (obj.chains !== undefined) {
|
|
363
|
+
if (!Array.isArray(obj.chains)) {
|
|
364
|
+
errors.push("chains: not an array");
|
|
365
|
+
} else {
|
|
366
|
+
obj.chains.forEach((item, i) => {
|
|
367
|
+
const chain = asObject(item);
|
|
368
|
+
if (!chain) {
|
|
369
|
+
errors.push(`chains[${i}]: not an object`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (chain.title !== undefined && !isNonEmptyString(chain.title))
|
|
373
|
+
errors.push(`chains[${i}].title: missing or empty`);
|
|
374
|
+
if (chain.steps !== undefined)
|
|
375
|
+
requireStringArray(errors, `chains[${i}].steps`, chain.steps);
|
|
376
|
+
if (
|
|
377
|
+
chain.severity !== undefined &&
|
|
378
|
+
(!isNonEmptyString(chain.severity) ||
|
|
379
|
+
!(CHAIN_SEVERITIES as readonly string[]).includes(chain.severity))
|
|
380
|
+
) {
|
|
381
|
+
errors.push(`chains[${i}].severity: invalid value`);
|
|
382
|
+
}
|
|
383
|
+
if (chain.blocked_by_controls !== undefined) {
|
|
384
|
+
requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (obj.patches_applied !== undefined) {
|
|
391
|
+
if (!Array.isArray(obj.patches_applied)) {
|
|
392
|
+
errors.push("patches_applied: not an array");
|
|
393
|
+
} else {
|
|
394
|
+
obj.patches_applied.forEach((item, i) => {
|
|
395
|
+
const patch = asObject(item);
|
|
396
|
+
if (!patch) {
|
|
397
|
+
errors.push(`patches_applied[${i}]: not an object`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
for (const field of ["finding_id", "diff_summary", "re_attack_result"] as const) {
|
|
401
|
+
if (patch[field] !== undefined && !isNonEmptyString(patch[field])) {
|
|
402
|
+
errors.push(`patches_applied[${i}].${field}: missing or empty`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
275
410
|
function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
|
|
276
411
|
const spec = SPECS[stage];
|
|
277
412
|
const errors: string[] = [];
|
|
@@ -334,10 +469,92 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
334
469
|
}
|
|
335
470
|
}
|
|
336
471
|
|
|
337
|
-
|
|
472
|
+
if (stage === "trace") {
|
|
473
|
+
requireStringArray(errors, "call_chain", obj.call_chain, 1);
|
|
474
|
+
if (Array.isArray(obj.defenses_checked)) {
|
|
475
|
+
obj.defenses_checked.forEach((item, i) => {
|
|
476
|
+
const defense = asObject(item);
|
|
477
|
+
if (!defense) {
|
|
478
|
+
errors.push(`defenses_checked[${i}]: not an object`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (!isNonEmptyString(defense.defense))
|
|
482
|
+
errors.push(`defenses_checked[${i}].defense: missing or empty`);
|
|
483
|
+
if (!isNonEmptyString(defense.location))
|
|
484
|
+
errors.push(`defenses_checked[${i}].location: missing or empty`);
|
|
485
|
+
if (
|
|
486
|
+
!isNonEmptyString(defense.verdict) ||
|
|
487
|
+
!["bypassed", "blocked", "not-present"].includes(defense.verdict)
|
|
488
|
+
) {
|
|
489
|
+
errors.push(`defenses_checked[${i}].verdict: must be bypassed, blocked, or not-present`);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (stage === "skeptic") {
|
|
496
|
+
requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
|
|
497
|
+
if (obj.verdict === "DISPROVEN" && isNonEmptyString(obj.disproval_reason)) {
|
|
498
|
+
const allowed = [
|
|
499
|
+
"unreachable",
|
|
500
|
+
"framework_protection",
|
|
501
|
+
"input_validation_blocks",
|
|
502
|
+
"requires_privilege_attacker_lacks",
|
|
503
|
+
"intended_behavior",
|
|
504
|
+
"overstated_impact",
|
|
505
|
+
"duplicate",
|
|
506
|
+
"test_artifact",
|
|
507
|
+
"out_of_scope",
|
|
508
|
+
];
|
|
509
|
+
if (!allowed.includes(obj.disproval_reason)) errors.push("disproval_reason: invalid value");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (stage === "validate") {
|
|
514
|
+
if (obj.status === "killed" && isNonEmptyString(obj.kill_reason)) {
|
|
515
|
+
const allowed = [
|
|
516
|
+
"unreachable",
|
|
517
|
+
"framework_protection",
|
|
518
|
+
"input_validation_blocks",
|
|
519
|
+
"requires_privilege_attacker_lacks",
|
|
520
|
+
"poc_failed_3x",
|
|
521
|
+
"no_real_impact",
|
|
522
|
+
"intended_behavior",
|
|
523
|
+
"duplicate",
|
|
524
|
+
];
|
|
525
|
+
if (!allowed.includes(obj.kill_reason)) errors.push("kill_reason: invalid value");
|
|
526
|
+
}
|
|
527
|
+
if (obj.status === "confirmed" && isNonEmptyString(obj.poc_path)) {
|
|
528
|
+
// A validate submission asserting "confirmed" must point at a PoC file
|
|
529
|
+
// that actually exists in the project — same file-existence filter hunt
|
|
530
|
+
// findings get. Otherwise fabricated run logs pass the stage gate.
|
|
531
|
+
const raw = obj.poc_path as string;
|
|
532
|
+
const normalized = raw.replace(/^\.?\//, "");
|
|
533
|
+
const abs = isAbsolute(normalized) ? resolve(normalized) : resolve(projectRoot(), normalized);
|
|
534
|
+
const rel = relative(projectRoot(), abs);
|
|
535
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
536
|
+
errors.push("poc_path: must resolve inside the project root");
|
|
537
|
+
} else if (!existsSync(abs)) {
|
|
538
|
+
errors.push(`poc_path: "${raw}" does not exist under the project root`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (
|
|
542
|
+
obj.refinement_attempts !== undefined &&
|
|
543
|
+
(!Number.isInteger(obj.refinement_attempts) ||
|
|
544
|
+
(obj.refinement_attempts as number) < 1 ||
|
|
545
|
+
(obj.refinement_attempts as number) > 3)
|
|
546
|
+
) {
|
|
547
|
+
errors.push("refinement_attempts: must be an integer from 1 to 3");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
338
551
|
if (stage === "chain" && Array.isArray(obj.chains)) {
|
|
339
552
|
obj.chains.forEach((c, i) => {
|
|
340
|
-
const chain = c
|
|
553
|
+
const chain = asObject(c);
|
|
554
|
+
if (!chain) {
|
|
555
|
+
errors.push(`chains[${i}]: not an object`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
341
558
|
if (!isNonEmptyString(chain.title)) errors.push(`chains[${i}].title: missing or empty`);
|
|
342
559
|
if (
|
|
343
560
|
!isNonEmptyString(chain.severity) ||
|
|
@@ -345,14 +562,17 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
345
562
|
) {
|
|
346
563
|
errors.push(`chains[${i}].severity: must be one of { ${CHAIN_SEVERITIES.join(" | ")} }`);
|
|
347
564
|
}
|
|
348
|
-
|
|
349
|
-
|
|
565
|
+
requireStringArray(errors, `chains[${i}].steps`, chain.steps, 2);
|
|
566
|
+
if (chain.blocked_by_controls !== undefined) {
|
|
567
|
+
requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
|
|
350
568
|
}
|
|
351
569
|
if (!isNonEmptyString(chain.narrative))
|
|
352
570
|
errors.push(`chains[${i}].narrative: missing or empty`);
|
|
353
571
|
});
|
|
354
572
|
}
|
|
355
573
|
|
|
574
|
+
if (stage === "report") validateReport(errors, obj);
|
|
575
|
+
|
|
356
576
|
return errors;
|
|
357
577
|
}
|
|
358
578
|
|
|
@@ -381,6 +601,23 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
|
381
601
|
`Findings must reference files inside the target repository.`
|
|
382
602
|
);
|
|
383
603
|
}
|
|
604
|
+
// Symlink containment (same defense as the PoC runner): resolve() is
|
|
605
|
+
// lexical, and existsSync() dereferences symlinks — a workspace symlink to
|
|
606
|
+
// /etc (ln -s /etc etc-link) would otherwise pass both checks and let a
|
|
607
|
+
// "finding" point at host paths outside the project.
|
|
608
|
+
let real: string;
|
|
609
|
+
try {
|
|
610
|
+
real = realpathSync(abs);
|
|
611
|
+
} catch {
|
|
612
|
+
return `file-existence filter: "${file}" cannot be resolved under the project root (${root}).`;
|
|
613
|
+
}
|
|
614
|
+
const realRel = relative(root, real);
|
|
615
|
+
if (realRel.startsWith("..") || isAbsolute(realRel)) {
|
|
616
|
+
return (
|
|
617
|
+
`containment filter: "${file}" resolves through a symlink to outside the project root ` +
|
|
618
|
+
`(${real}). Symlinked files outside ${root} are rejected.`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
384
621
|
if (!existsSync(abs)) {
|
|
385
622
|
return (
|
|
386
623
|
`file-existence filter: "${file}" does not exist under the project root ` +
|
|
@@ -392,12 +629,19 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
|
392
629
|
|
|
393
630
|
function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
|
|
394
631
|
const file = typeof obj.file === "string" ? obj.file.replace(/^\.?\//, "") : undefined;
|
|
632
|
+
const endpoint = typeof obj.endpoint === "string" ? obj.endpoint.trim() : undefined;
|
|
395
633
|
const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
|
|
396
634
|
const line = typeof obj.line === "number" ? obj.line : undefined;
|
|
397
|
-
if (!
|
|
635
|
+
if (!vulnClass) return {};
|
|
398
636
|
for (const accepted of state.accepted_findings) {
|
|
399
637
|
if (accepted.vuln_class !== vulnClass) continue;
|
|
400
|
-
|
|
638
|
+
// Live locator: same endpoint + class is the same finding (re-submissions
|
|
639
|
+
// after a repair must not be accepted repeatedly).
|
|
640
|
+
if (!file && endpoint !== undefined) {
|
|
641
|
+
if (accepted.endpoint === endpoint) return { duplicateOf: accepted.key };
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (!file || accepted.file !== file) continue;
|
|
401
645
|
if (
|
|
402
646
|
line !== undefined &&
|
|
403
647
|
accepted.line !== undefined &&
|
|
@@ -474,24 +718,33 @@ export function pipeline_submit(runId: string, stage: SubmitStage, output: unkno
|
|
|
474
718
|
duplicate_of: duplicateOf,
|
|
475
719
|
};
|
|
476
720
|
}
|
|
477
|
-
if (typeof obj.
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
721
|
+
if (typeof obj.vuln_class === "string") {
|
|
722
|
+
const isFileFinding = typeof obj.file === "string";
|
|
723
|
+
const isEndpointFinding = !isFileFinding && typeof obj.endpoint === "string";
|
|
724
|
+
if (isFileFinding || isEndpointFinding) {
|
|
725
|
+
state.accepted_findings.push({
|
|
726
|
+
key,
|
|
727
|
+
file: isFileFinding ? (obj.file as string).replace(/^\.?\//, "") : "",
|
|
728
|
+
line: typeof obj.line === "number" ? obj.line : undefined,
|
|
729
|
+
endpoint: isEndpointFinding ? (obj.endpoint as string).trim() : undefined,
|
|
730
|
+
vuln_class: obj.vuln_class,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
484
733
|
writeState(runId, state);
|
|
485
734
|
}
|
|
486
735
|
}
|
|
487
736
|
|
|
488
737
|
// Submit stages are a subset of scratchpad phases, so the stage name IS
|
|
489
|
-
// the phase directory.
|
|
738
|
+
// the phase directory. The filename gets a content hash: distinct findings
|
|
739
|
+
// sharing one plausible id (the stable repair-bucket key) must not clobber
|
|
740
|
+
// each other's accepted artifact.
|
|
741
|
+
const json = JSON.stringify(obj, null, 2);
|
|
742
|
+
const contentHash = createHash("sha1").update(json).digest("hex").slice(0, 8);
|
|
490
743
|
const artifact = scratchpad_write(
|
|
491
744
|
runId,
|
|
492
745
|
stage,
|
|
493
|
-
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}.json`,
|
|
494
|
-
|
|
746
|
+
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}-${contentHash}.json`,
|
|
747
|
+
json,
|
|
495
748
|
);
|
|
496
749
|
return { verdict: "accepted", stage, errors: [], key, artifact };
|
|
497
750
|
}
|