@xaccefy/pi-casefile 0.8.3 → 0.9.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.
@@ -18,8 +18,15 @@
18
18
  */
19
19
 
20
20
  import { createHash } from "node:crypto";
21
- import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
22
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
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";
23
30
  import { getRunDir, getScratchpadRoot, scratchpad_write } from "./scratchpad.ts";
24
31
 
25
32
  // ── Types ────────────────────────────────────────────────────────────
@@ -45,6 +52,8 @@ export type SubmitResult = {
45
52
  };
46
53
 
47
54
  type StageSpec = {
55
+ /** Exact top-level field allowlist; mirrors additionalProperties:false. */
56
+ allowed: readonly string[];
48
57
  /** Fields that must be present and non-empty. */
49
58
  required: {
50
59
  name: string;
@@ -86,6 +95,18 @@ const VULN_CLASSES = [
86
95
  export const SPECS: Record<SubmitStage, StageSpec> = {
87
96
  // schemas/stage-finding.json
88
97
  hunt: {
98
+ allowed: [
99
+ "vuln_class",
100
+ "file",
101
+ "line",
102
+ "endpoint",
103
+ "sink",
104
+ "entry_point",
105
+ "confidence",
106
+ "evidence",
107
+ "attacker_model",
108
+ "subsystem",
109
+ ],
89
110
  required: [
90
111
  { name: "vuln_class", type: "string", enum: VULN_CLASSES },
91
112
  { name: "sink", type: "string" },
@@ -98,6 +119,15 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
98
119
  },
99
120
  // schemas/stage-trace.json
100
121
  trace: {
122
+ allowed: [
123
+ "trace_result",
124
+ "entry_point",
125
+ "call_chain",
126
+ "defenses_checked",
127
+ "attacker_model",
128
+ "impact_if_reachable",
129
+ "unreachable_reason",
130
+ ],
101
131
  required: [
102
132
  { name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE"] },
103
133
  { name: "entry_point", type: "string" },
@@ -112,6 +142,14 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
112
142
  },
113
143
  // schemas/stage-skeptic.json
114
144
  skeptic: {
145
+ allowed: [
146
+ "finding_id",
147
+ "verdict",
148
+ "reasoning",
149
+ "evidence_reviewed",
150
+ "disconfirmation_attempt",
151
+ "disproval_reason",
152
+ ],
115
153
  required: [
116
154
  { name: "finding_id", type: "string" },
117
155
  { name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN"] },
@@ -120,19 +158,41 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
120
158
  ],
121
159
  conditional: [
122
160
  { when: { field: "verdict", equals: "DISPROVEN" }, require: ["disproval_reason"] },
161
+ {
162
+ // A CONFIRMED verdict must carry the skeptic's own failed disproof —
163
+ // the workflow makes it the case's disconfirmation. "Could not
164
+ // disprove" alone is not an attempt.
165
+ when: { field: "verdict", equals: "CONFIRMED" },
166
+ require: ["disconfirmation_attempt"],
167
+ },
123
168
  ],
124
169
  },
125
170
  // schemas/stage-validation.json
126
171
  validate: {
172
+ allowed: [
173
+ "finding_id",
174
+ "status",
175
+ "technique_used",
176
+ "detection_method",
177
+ "poc_path",
178
+ "run_log",
179
+ "evidence_extracted",
180
+ "kill_reason",
181
+ "refinement_attempts",
182
+ ],
127
183
  required: [
128
184
  { name: "finding_id", type: "string" },
129
- { name: "status", type: "string", enum: ["confirmed", "killed", "reported"] },
185
+ {
186
+ name: "status",
187
+ type: "string",
188
+ enum: ["pending_confirmation", "killed", "reported"],
189
+ },
130
190
  { name: "technique_used", type: "string" },
131
191
  { name: "detection_method", type: "string" },
132
192
  ],
133
193
  conditional: [
134
194
  {
135
- when: { field: "status", equals: "confirmed" },
195
+ when: { field: "status", equals: "pending_confirmation" },
136
196
  require: ["poc_path", "run_log", "evidence_extracted"],
137
197
  },
138
198
  { when: { field: "status", equals: "killed" }, require: ["kill_reason"] },
@@ -140,6 +200,7 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
140
200
  },
141
201
  // schemas/stage-chain.json
142
202
  chain: {
203
+ allowed: ["chains", "summary", "tokens_input", "tokens_output"],
143
204
  required: [
144
205
  { name: "chains", type: "array" },
145
206
  { name: "summary", type: "string" },
@@ -147,6 +208,16 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
147
208
  },
148
209
  // schemas/stage-report.json
149
210
  report: {
211
+ allowed: [
212
+ "target",
213
+ "pipeline_status",
214
+ "total_tokens",
215
+ "findings",
216
+ "chains",
217
+ "coverage",
218
+ "summary",
219
+ "patches_applied",
220
+ ],
150
221
  required: [
151
222
  { name: "target", type: "string" },
152
223
  { name: "pipeline_status", type: "string", enum: ["complete", "partial", "aborted"] },
@@ -189,6 +260,7 @@ type FindingRef = {
189
260
  key: string;
190
261
  file: string;
191
262
  line?: number;
263
+ endpoint?: string;
192
264
  vuln_class: string;
193
265
  };
194
266
 
@@ -204,7 +276,11 @@ function statePath(runId: string): string {
204
276
  function readState(runId: string): SubmitState {
205
277
  const p = statePath(runId);
206
278
  if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
207
- const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
279
+ assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
280
+ assertSafeRegularFile(p, "Pipeline state");
281
+ const raw = JSON.parse(
282
+ readSafeFile(p, "Pipeline state").toString("utf8"),
283
+ ) as Partial<SubmitState>;
208
284
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
209
285
  throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
210
286
  }
@@ -238,9 +314,8 @@ function readState(runId: string): SubmitState {
238
314
 
239
315
  function writeState(runId: string, state: SubmitState): void {
240
316
  const p = statePath(runId);
241
- const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
242
- writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
243
- renameSync(tmp, p);
317
+ assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
318
+ writeSafeFileAtomic(p, JSON.stringify(state, null, 2));
244
319
  }
245
320
 
246
321
  /** Project root containing the scratchpad (file-existence checks resolve here). */
@@ -403,6 +478,11 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
403
478
  const spec = SPECS[stage];
404
479
  const errors: string[] = [];
405
480
 
481
+ const allowedFields = new Set(spec.allowed);
482
+ for (const name of Object.keys(obj)) {
483
+ if (!allowedFields.has(name)) errors.push(`${name}: unknown top-level field`);
484
+ }
485
+
406
486
  for (const field of spec.required) {
407
487
  const v = obj[field.name];
408
488
  if (field.type === "string") {
@@ -487,34 +567,31 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
487
567
  if (stage === "skeptic") {
488
568
  requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
489
569
  if (obj.verdict === "DISPROVEN" && isNonEmptyString(obj.disproval_reason)) {
490
- const allowed = [
491
- "unreachable",
492
- "framework_protection",
493
- "input_validation_blocks",
494
- "requires_privilege_attacker_lacks",
495
- "intended_behavior",
496
- "overstated_impact",
497
- "duplicate",
498
- "test_artifact",
499
- "out_of_scope",
500
- ];
501
- if (!allowed.includes(obj.disproval_reason)) errors.push("disproval_reason: invalid value");
570
+ if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.disproval_reason)) {
571
+ errors.push("disproval_reason: invalid value");
572
+ }
502
573
  }
503
574
  }
504
575
 
505
576
  if (stage === "validate") {
506
577
  if (obj.status === "killed" && isNonEmptyString(obj.kill_reason)) {
507
- const allowed = [
508
- "unreachable",
509
- "framework_protection",
510
- "input_validation_blocks",
511
- "requires_privilege_attacker_lacks",
512
- "poc_failed_3x",
513
- "no_real_impact",
514
- "intended_behavior",
515
- "duplicate",
516
- ];
517
- if (!allowed.includes(obj.kill_reason)) errors.push("kill_reason: invalid value");
578
+ if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.kill_reason)) {
579
+ errors.push("kill_reason: invalid value");
580
+ }
581
+ }
582
+ if (obj.status === "pending_confirmation" && isNonEmptyString(obj.poc_path)) {
583
+ // A phase-1 validation submission must point at a PoC file
584
+ // that actually exists in the project — same file-existence filter hunt
585
+ // findings get. Otherwise fabricated run logs pass the stage gate.
586
+ const raw = obj.poc_path as string;
587
+ const normalized = raw.replace(/^\.?\//, "");
588
+ const abs = isAbsolute(normalized) ? resolve(normalized) : resolve(projectRoot(), normalized);
589
+ const rel = relative(projectRoot(), abs);
590
+ if (rel.startsWith("..") || isAbsolute(rel)) {
591
+ errors.push("poc_path: must resolve inside the project root");
592
+ } else if (!existsSync(abs)) {
593
+ errors.push(`poc_path: "${raw}" does not exist under the project root`);
594
+ }
518
595
  }
519
596
  if (
520
597
  obj.refinement_attempts !== undefined &&
@@ -579,6 +656,23 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
579
656
  `Findings must reference files inside the target repository.`
580
657
  );
581
658
  }
659
+ // Symlink containment (same defense as the PoC runner): resolve() is
660
+ // lexical, and existsSync() dereferences symlinks — a workspace symlink to
661
+ // /etc (ln -s /etc etc-link) would otherwise pass both checks and let a
662
+ // "finding" point at host paths outside the project.
663
+ let real: string;
664
+ try {
665
+ real = realpathSync(abs);
666
+ } catch {
667
+ return `file-existence filter: "${file}" cannot be resolved under the project root (${root}).`;
668
+ }
669
+ const realRel = relative(root, real);
670
+ if (realRel.startsWith("..") || isAbsolute(realRel)) {
671
+ return (
672
+ `containment filter: "${file}" resolves through a symlink to outside the project root ` +
673
+ `(${real}). Symlinked files outside ${root} are rejected.`
674
+ );
675
+ }
582
676
  if (!existsSync(abs)) {
583
677
  return (
584
678
  `file-existence filter: "${file}" does not exist under the project root ` +
@@ -590,12 +684,19 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
590
684
 
591
685
  function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
592
686
  const file = typeof obj.file === "string" ? obj.file.replace(/^\.?\//, "") : undefined;
687
+ const endpoint = typeof obj.endpoint === "string" ? obj.endpoint.trim() : undefined;
593
688
  const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
594
689
  const line = typeof obj.line === "number" ? obj.line : undefined;
595
- if (!file || !vulnClass) return {};
690
+ if (!vulnClass) return {};
596
691
  for (const accepted of state.accepted_findings) {
597
692
  if (accepted.vuln_class !== vulnClass) continue;
598
- if (accepted.file !== file) continue;
693
+ // Live locator: same endpoint + class is the same finding (re-submissions
694
+ // after a repair must not be accepted repeatedly).
695
+ if (!file && endpoint !== undefined) {
696
+ if (accepted.endpoint === endpoint) return { duplicateOf: accepted.key };
697
+ continue;
698
+ }
699
+ if (!file || accepted.file !== file) continue;
599
700
  if (
600
701
  line !== undefined &&
601
702
  accepted.line !== undefined &&
@@ -672,24 +773,33 @@ export function pipeline_submit(runId: string, stage: SubmitStage, output: unkno
672
773
  duplicate_of: duplicateOf,
673
774
  };
674
775
  }
675
- if (typeof obj.file === "string" && typeof obj.vuln_class === "string") {
676
- state.accepted_findings.push({
677
- key,
678
- file: obj.file.replace(/^\.?\//, ""),
679
- line: typeof obj.line === "number" ? obj.line : undefined,
680
- vuln_class: obj.vuln_class,
681
- });
776
+ if (typeof obj.vuln_class === "string") {
777
+ const isFileFinding = typeof obj.file === "string";
778
+ const isEndpointFinding = !isFileFinding && typeof obj.endpoint === "string";
779
+ if (isFileFinding || isEndpointFinding) {
780
+ state.accepted_findings.push({
781
+ key,
782
+ file: isFileFinding ? (obj.file as string).replace(/^\.?\//, "") : "",
783
+ line: typeof obj.line === "number" ? obj.line : undefined,
784
+ endpoint: isEndpointFinding ? (obj.endpoint as string).trim() : undefined,
785
+ vuln_class: obj.vuln_class,
786
+ });
787
+ }
682
788
  writeState(runId, state);
683
789
  }
684
790
  }
685
791
 
686
792
  // Submit stages are a subset of scratchpad phases, so the stage name IS
687
- // the phase directory.
793
+ // the phase directory. The filename gets a content hash: distinct findings
794
+ // sharing one plausible id (the stable repair-bucket key) must not clobber
795
+ // each other's accepted artifact.
796
+ const json = JSON.stringify(obj, null, 2);
797
+ const contentHash = createHash("sha1").update(json).digest("hex").slice(0, 8);
688
798
  const artifact = scratchpad_write(
689
799
  runId,
690
800
  stage,
691
- `${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}.json`,
692
- JSON.stringify(obj, null, 2),
801
+ `${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}-${contentHash}.json`,
802
+ json,
693
803
  );
694
804
  return { verdict: "accepted", stage, errors: [], key, artifact };
695
805
  }