@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.
@@ -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
- try {
208
- const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
209
- return {
210
- repairs: raw.repairs ?? {},
211
- accepted_findings: raw.accepted_findings ?? [],
212
- };
213
- } catch {
214
- return { repairs: {}, accepted_findings: [] };
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
- writeFileSync(statePath(runId), JSON.stringify(state, null, 2), "utf8");
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
- // Chain items have their own inner contract (≥2 steps, severity enum).
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 as Record<string, unknown>;
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
- if (!Array.isArray(chain.steps) || chain.steps.length < 2) {
349
- errors.push(`chains[${i}].steps: needs at least 2 case IDs`);
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 (!file || !vulnClass) return {};
635
+ if (!vulnClass) return {};
398
636
  for (const accepted of state.accepted_findings) {
399
637
  if (accepted.vuln_class !== vulnClass) continue;
400
- if (accepted.file !== file) continue;
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.file === "string" && typeof obj.vuln_class === "string") {
478
- state.accepted_findings.push({
479
- key,
480
- file: obj.file.replace(/^\.?\//, ""),
481
- line: typeof obj.line === "number" ? obj.line : undefined,
482
- vuln_class: obj.vuln_class,
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
- JSON.stringify(obj, null, 2),
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
  }