@xaccefy/pi-casefile 0.9.4 → 0.10.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/src/evidence.ts CHANGED
@@ -385,9 +385,54 @@ export function normalizeEvidence(e: PoCEvidence): string {
385
385
  return JSON.stringify({ claim: e.claim, verify: e.verify, baseline: e.baseline });
386
386
  }
387
387
 
388
+ // ── Artifact secret scanning (defense in depth) ──────────────────────
389
+ //
390
+ // Evidence artifacts are raw target responses and logs — they routinely
391
+ // contain live credentials. The gate never blocks storage (an engaged
392
+ // finding must keep its proof), it FLAGS the item so every later view can
393
+ // redact or warn. Best-effort pattern matching only: labels are recorded,
394
+ // matched VALUES are never persisted by the scanner itself.
395
+
396
+ /** Label → pattern. Linear regexes only (artifacts reach 10 MiB). */
397
+ const SECRET_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [
398
+ { label: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
399
+ { label: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
400
+ { label: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g },
401
+ { label: "slack-token", pattern: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
402
+ {
403
+ label: "private-key-block",
404
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
405
+ },
406
+ { label: "bearer-token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi },
407
+ { label: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
408
+ {
409
+ label: "credential-assignment",
410
+ pattern:
411
+ /\b(?:api[_-]?key|apikey|secret|token|passwd|password)\b["']?\s*[:=]\s*["'][^"'\s]{12,}["']/gi,
412
+ },
413
+ ];
414
+
415
+ /**
416
+ * Scan artifact bytes for embedded secret material. Returns the LABELS of the
417
+ * patterns that matched (deduplicated, order of first match) — never the
418
+ * matched values themselves.
419
+ */
420
+ export function scanArtifactForSecrets(bytes: Buffer): string[] {
421
+ const text = bytes.toString("utf8");
422
+ const labels: string[] = [];
423
+ for (const { label, pattern } of SECRET_PATTERNS) {
424
+ pattern.lastIndex = 0;
425
+ if (pattern.test(text)) labels.push(label);
426
+ }
427
+ return labels;
428
+ }
429
+
388
430
  // ── Main-agent confirmation verdict ─────────────────────────────────
389
431
 
390
- export const CONFIRM_VERDICT_VALUES = ["CONFIRMED", "NOT_CONFIRMED"] as const;
432
+ // INCONCLUSIVE is the fail-safe verdict: the reviewer could neither reproduce
433
+ // the finding nor positively disprove it. It preserves the case for manual
434
+ // review instead of dropping it. NOT_CONFIRMED means positively disproved.
435
+ export const CONFIRM_VERDICT_VALUES = ["CONFIRMED", "NOT_CONFIRMED", "INCONCLUSIVE"] as const;
391
436
  export type ConfirmVerdict = (typeof CONFIRM_VERDICT_VALUES)[number];
392
437
 
393
438
  export const CONFIRM_DIFFERENTIAL_VALUES = [
@@ -401,6 +446,82 @@ export type ConfirmDifferential = (typeof CONFIRM_DIFFERENTIAL_VALUES)[number];
401
446
  export const SEVERITY_MATCH_VALUES = ["under", "over", "ok"] as const;
402
447
  export const CANARY_ASSESSMENT_VALUES = ["verified", "not_applicable"] as const;
403
448
 
449
+ // ── Quorum panel votes (advisory, CONFIRMED-blocking) ───────────────
450
+
451
+ export const PANEL_VERDICT_VALUES = ["exploit", "not_exploit", "inconclusive"] as const;
452
+ export type PanelVote = {
453
+ verdict: (typeof PANEL_VERDICT_VALUES)[number];
454
+ rationale: string;
455
+ model: string;
456
+ at?: string;
457
+ };
458
+
459
+ /** Bounded panel: enough voices for 2/3 quorum, small enough to stay cheap. */
460
+ const MAX_PANEL_VOTES = 5;
461
+
462
+ /**
463
+ * Validate panel votes recorded on a promotion bundle. Votes are advisory —
464
+ * they gate only the CONFIRMED commit (quorum or explicit override note) —
465
+ * but their SHAPE is machine-checked so a malformed panel cannot silently
466
+ * count as a quorum.
467
+ */
468
+ export function validatePanelVotes(
469
+ raw: unknown,
470
+ ): { ok: true; votes: PanelVote[] } | { ok: false; error: string } {
471
+ if (!Array.isArray(raw)) return { ok: false, error: "panel_votes must be an array" };
472
+ if (raw.length === 0) return { ok: false, error: "panel_votes must not be empty when provided" };
473
+ if (raw.length > MAX_PANEL_VOTES) {
474
+ return { ok: false, error: `panel_votes exceeds ${MAX_PANEL_VOTES} entries` };
475
+ }
476
+ for (const [index, v] of raw.entries()) {
477
+ if (!isRecord(v)) return { ok: false, error: `panel_votes[${index}] must be an object` };
478
+ if (
479
+ !nonEmptyString(v.verdict) ||
480
+ !(PANEL_VERDICT_VALUES as readonly string[]).includes(v.verdict)
481
+ ) {
482
+ return {
483
+ ok: false,
484
+ error: `panel_votes[${index}].verdict must be one of ${PANEL_VERDICT_VALUES.join(" | ")}`,
485
+ };
486
+ }
487
+ if (!nonEmptyString(v.rationale)) {
488
+ return { ok: false, error: `panel_votes[${index}].rationale must be a non-empty string` };
489
+ }
490
+ if (!nonEmptyString(v.model)) {
491
+ return { ok: false, error: `panel_votes[${index}].model must be a non-empty string` };
492
+ }
493
+ if (v.at !== undefined) {
494
+ if (!nonEmptyString(v.at) || !Number.isFinite(Date.parse(v.at))) {
495
+ return { ok: false, error: `panel_votes[${index}].at must be a parseable timestamp` };
496
+ }
497
+ }
498
+ }
499
+ return { ok: true, votes: raw as unknown as PanelVote[] };
500
+ }
501
+
502
+ /**
503
+ * Quorum rule: a panel of at least 3 votes with at least 2 exploit verdicts
504
+ * and exploit strictly ahead of not_exploit. Anything else — no panel, a tied
505
+ * panel, or a dissenting majority — requires the main agent's explicit
506
+ * override note to CONFIRM.
507
+ */
508
+ export function panelQuorumReached(votes: PanelVote[] | undefined): {
509
+ quorum: boolean;
510
+ exploit: number;
511
+ notExploit: number;
512
+ total: number;
513
+ } {
514
+ const list = votes ?? [];
515
+ const exploit = list.filter((v) => v.verdict === "exploit").length;
516
+ const notExploit = list.filter((v) => v.verdict === "not_exploit").length;
517
+ return {
518
+ quorum: list.length >= 3 && exploit >= 2 && exploit > notExploit,
519
+ exploit,
520
+ notExploit,
521
+ total: list.length,
522
+ };
523
+ }
524
+
404
525
  export type MainAgentVerdict = {
405
526
  verdict: ConfirmVerdict;
406
527
  reasoning: string;
@@ -418,6 +539,12 @@ export type MainAgentVerdict = {
418
539
  canary_assessment?: (typeof CANARY_ASSESSMENT_VALUES)[number];
419
540
  /** Why no meaningful canary oracle exists for this exploit class. */
420
541
  canary_reason?: string;
542
+ /**
543
+ * Why CONFIRMED proceeds without a 2/3 exploit panel quorum (no panel
544
+ * provisioned, panel unavailable, or documented disagreement). Required for
545
+ * CONFIRMED whenever quorum was not reached.
546
+ */
547
+ panel_override_note?: string;
421
548
  /** Which model judged (recorded for the accuracy ledger). */
422
549
  model?: string;
423
550
  };
@@ -469,6 +596,9 @@ export function validateMainAgentVerdict(
469
596
  if (raw.canary_reason !== undefined && !nonEmptyString(raw.canary_reason)) {
470
597
  return { ok: false, error: "verdict canary_reason must be a non-empty string" };
471
598
  }
599
+ if (raw.panel_override_note !== undefined && !nonEmptyString(raw.panel_override_note)) {
600
+ return { ok: false, error: "verdict panel_override_note must be a non-empty string" };
601
+ }
472
602
  if (
473
603
  raw.severity_match !== undefined &&
474
604
  !SEVERITY_MATCH_VALUES.includes(raw.severity_match as never)
@@ -518,6 +648,3 @@ export function validateMainAgentVerdict(
518
648
 
519
649
  /** @deprecated Compatibility alias for integrations built before phase 2 became main-agent-only. */
520
650
  export type ConfirmerVerdict = MainAgentVerdict;
521
-
522
- /** @deprecated Use validateMainAgentVerdict. */
523
- export const validateConfirmerVerdict = validateMainAgentVerdict;
@@ -76,8 +76,16 @@ const REGEX_TIMEOUT_MS = 250;
76
76
 
77
77
  type ResolvedAddress = { address: string; family: 4 | 6 };
78
78
 
79
+ /** Comparison-normalize a hostname: lowercase, strip IPv6 brackets and any trailing root dot. */
80
+ function normHost(hostname: string): string {
81
+ return hostname
82
+ .toLowerCase()
83
+ .replace(/^\[|\]$/g, "")
84
+ .replace(/\.$/, "");
85
+ }
86
+
79
87
  async function resolveHost(hostname: string): Promise<ResolvedAddress[]> {
80
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
88
+ const host = normHost(hostname);
81
89
  const literalFamily = isIP(host);
82
90
  if (literalFamily) return [{ address: host, family: literalFamily as 4 | 6 }];
83
91
  const lookup = dnsLookup(host, { all: true, verbatim: true }) as Promise<ResolvedAddress[]>;
@@ -116,10 +124,7 @@ function sameTargetIdentity(left: string, right: string): boolean {
116
124
  const a = parseNetworkTarget(left);
117
125
  const b = parseNetworkTarget(right);
118
126
  if (!a || !b) return false;
119
- if (
120
- a.url.hostname.toLowerCase().replace(/\.$/, "") !==
121
- b.url.hostname.toLowerCase().replace(/\.$/, "")
122
- ) {
127
+ if (normHost(a.url.hostname) !== normHost(b.url.hostname)) {
123
128
  return false;
124
129
  }
125
130
  if ((a.url.port || b.url.port) && effectivePort(a.url) !== effectivePort(b.url)) return false;
@@ -161,8 +166,8 @@ export function verifyUrlBindingError(verifyUrl: string, target: string): string
161
166
  return `verify.url is not parseable: ${verifyUrl}`;
162
167
  }
163
168
  if (!declared) return `target is not an HTTP network target: ${target}`;
164
- const declaredHost = declared.url.hostname.toLowerCase().replace(/\.$/, "");
165
- const observedHost = observed.hostname.toLowerCase().replace(/\.$/, "");
169
+ const declaredHost = normHost(declared.url.hostname);
170
+ const observedHost = normHost(observed.hostname);
166
171
  if (declaredHost !== observedHost) {
167
172
  return `verify.url host ${observedHost} does not match run target ${declaredHost}`;
168
173
  }
@@ -383,7 +388,7 @@ async function replayRequest(
383
388
  };
384
389
  }
385
390
  const signal = AbortSignal.timeout(opts?.timeoutMs ?? TIMEOUT_MS);
386
- const lockedHostname = url.hostname.toLowerCase().replace(/\.$/, "");
391
+ const lockedHostname = normHost(url.hostname);
387
392
  const fetchImpl = opts?.fetchImpl ?? harnessFetchForTest;
388
393
 
389
394
  for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
@@ -415,13 +420,11 @@ async function replayRequest(
415
420
  note: `request errored (DNS): ${url.hostname} resolved to no addresses`,
416
421
  };
417
422
  }
418
- } else if (isIP(url.hostname.replace(/^\[|\]$/g, ""))) {
419
- addresses = [
420
- {
421
- address: url.hostname.replace(/^\[|\]$/g, ""),
422
- family: isIP(url.hostname.replace(/^\[|\]$/g, "")) as 4 | 6,
423
- },
424
- ];
423
+ } else {
424
+ const host = normHost(url.hostname);
425
+ if (isIP(host)) {
426
+ addresses = [{ address: host, family: isIP(host) as 4 | 6 }];
427
+ }
425
428
  }
426
429
  if (!opts?.allowPrivate && addresses.some((address) => !isPublicIpAddress(address.address))) {
427
430
  return {
@@ -470,7 +473,7 @@ async function replayRequest(
470
473
  note: `redirected to disallowed protocol ${next.protocol}`,
471
474
  };
472
475
  }
473
- if (next.hostname.toLowerCase().replace(/\.$/, "") !== lockedHostname) {
476
+ if (normHost(next.hostname) !== lockedHostname) {
474
477
  await res.body?.cancel().catch(() => undefined);
475
478
  await fetched.close().catch(() => undefined);
476
479
  closeFetched = undefined;
@@ -609,32 +612,6 @@ function canaryResult(
609
612
  };
610
613
  }
611
614
 
612
- /**
613
- * Re-send the evidence's verify request with the harness's own client and
614
- * judge the response against verify.expect. Never throws — the outcome is a
615
- * structured result the ledger gate interprets.
616
- */
617
- export async function replayVerify(
618
- evidence: PoCEvidence,
619
- opts?: ReplayOptions,
620
- ): Promise<HarnessVerifyResult> {
621
- const token = evidence.verify.canary
622
- ? `poc_canary_${randomBytes(24).toString("hex")}`
623
- : undefined;
624
- const verify = injectCanary(evidence.verify, token);
625
- const target = await replayRequest(verify, verify.expect, token, opts);
626
- const canary = canaryResult(token, target);
627
- return {
628
- attempted: target.attempted,
629
- pass: target.matched === true,
630
- status: target.status,
631
- target,
632
- canary,
633
- proofStrength: canary?.pass ? "canary_differential" : "predicate_differential",
634
- note: `harness replay: ${target.note}${canary ? `; ${canary.note}` : ""}`,
635
- };
636
- }
637
-
638
615
  /**
639
616
  * Combine the two observations into the differential verdict. Shared by the
640
617
  * inter-host (target vs control host) and intra-target (attack vs same-host