@xaccefy/pi-casefile 0.7.6 → 0.8.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/README.md +13 -4
- package/package.json +1 -1
- package/src/index.ts +524 -305
- package/src/ledger.ts +910 -176
- package/src/pipeline-submit.ts +7 -17
- package/src/poc-runner.ts +63 -123
- package/src/scratchpad.ts +38 -39
- package/src/workflow.ts +36 -30
package/src/ledger.ts
CHANGED
|
@@ -11,11 +11,20 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type Dirent,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
statSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from "node:fs";
|
|
15
23
|
import { basename, dirname, join, resolve } from "node:path";
|
|
16
24
|
import {
|
|
25
|
+
findWorkspaceRoot,
|
|
17
26
|
getScratchpadRoot,
|
|
18
|
-
|
|
27
|
+
PHASE_ORDER,
|
|
19
28
|
scratchpad_read,
|
|
20
29
|
scratchpad_resume,
|
|
21
30
|
} from "./scratchpad.ts";
|
|
@@ -42,6 +51,65 @@ export type CaseSeverity = (typeof SEVERITY_VALUES)[number];
|
|
|
42
51
|
export const PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "P4"] as const;
|
|
43
52
|
export type CasePriority = (typeof PRIORITY_VALUES)[number];
|
|
44
53
|
|
|
54
|
+
/** Cap on hashed evidence artifacts (10 MiB) — keeps readFileSync bounded. */
|
|
55
|
+
const EVIDENCE_ARTIFACT_MAX_BYTES = 10 * 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
/** Role-typed evidence roles (Black-cat style). cleanup = engagement cleanup item. */
|
|
58
|
+
export const EVIDENCE_ROLE_VALUES = [
|
|
59
|
+
"observation",
|
|
60
|
+
"reproduction",
|
|
61
|
+
"impact",
|
|
62
|
+
"refutation",
|
|
63
|
+
"cleanup",
|
|
64
|
+
] as const;
|
|
65
|
+
export type EvidenceRole = (typeof EVIDENCE_ROLE_VALUES)[number];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One artifact-backed evidence record. The case's `evidence` prose is a
|
|
69
|
+
* summary; the load-bearing chain is these items: role + artifact SHA-256.
|
|
70
|
+
* A confirmed finding must trace back to a reproduction item recorded by the
|
|
71
|
+
* PoC gate itself (not agent prose).
|
|
72
|
+
*/
|
|
73
|
+
export type EvidenceItem = {
|
|
74
|
+
id: string;
|
|
75
|
+
caseId: string;
|
|
76
|
+
role: EvidenceRole;
|
|
77
|
+
/** Basename of the artifact backing this evidence (path-leak guard: full path never stored). */
|
|
78
|
+
artifactPath?: string;
|
|
79
|
+
/** SHA-256 of the artifact file. */
|
|
80
|
+
sha256?: string;
|
|
81
|
+
summary: string;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Coverage scope of a tested verdict:
|
|
87
|
+
* - `wide` — the verdict is a property of the whole deployment/account/host,
|
|
88
|
+
* recorded ONCE and applied to every asset of that deployment (do NOT
|
|
89
|
+
* re-test per asset; a wide cell covers all assets in the case).
|
|
90
|
+
* - `local` — specific to this one asset (endpoint, resource, service).
|
|
91
|
+
*/
|
|
92
|
+
export const COVERAGE_SCOPE_VALUES = ["wide", "local"] as const;
|
|
93
|
+
export type CoverageScope = (typeof COVERAGE_SCOPE_VALUES)[number];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One tested (asset × attack-class) cell. The note's existence marks the cell
|
|
97
|
+
* tested — for both outcomes (found or clean). Clean results are just as
|
|
98
|
+
* load-bearing: they are what make "every class is COVERED" machine-checkable.
|
|
99
|
+
*/
|
|
100
|
+
export type CoverageItem = {
|
|
101
|
+
id: string;
|
|
102
|
+
caseId: string;
|
|
103
|
+
asset: string;
|
|
104
|
+
/** Attack class tested (e.g. sql-injection, xss, idor, ssti, ...). */
|
|
105
|
+
class: string;
|
|
106
|
+
scope: CoverageScope;
|
|
107
|
+
/** Short note: techniques tried · result · key gap (injected into later context). */
|
|
108
|
+
note: string;
|
|
109
|
+
testedBy?: string;
|
|
110
|
+
createdAt: string;
|
|
111
|
+
};
|
|
112
|
+
|
|
45
113
|
/** Typed relationship kinds for CaseLink. Input values accepted by the tool. */
|
|
46
114
|
export const LINK_KIND_VALUES = [
|
|
47
115
|
"duplicate",
|
|
@@ -107,6 +175,8 @@ export type CaseRecord = {
|
|
|
107
175
|
tags?: string[];
|
|
108
176
|
/** Explicit assumptions or unknowns to avoid overstating exploitability. */
|
|
109
177
|
assumptions?: string[];
|
|
178
|
+
/** Falsification conditions — what would disprove this hypothesis (required on new cases). */
|
|
179
|
+
disproveIf?: string[];
|
|
110
180
|
/** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
|
|
111
181
|
disconfirmation?: string;
|
|
112
182
|
/** Verification of an on-disk PoC run (set only by promoteFindingResult). */
|
|
@@ -125,12 +195,22 @@ export type CaseRecord = {
|
|
|
125
195
|
output?: string;
|
|
126
196
|
sandbox: boolean;
|
|
127
197
|
};
|
|
198
|
+
/** Verification of a control-target run (set only by promoteFindingResult; anti-cheat gate). */
|
|
199
|
+
controlVerified?: {
|
|
200
|
+
path: string;
|
|
201
|
+
exitCode: number;
|
|
202
|
+
ranAt: string;
|
|
203
|
+
output?: string;
|
|
204
|
+
sandbox: boolean;
|
|
205
|
+
};
|
|
128
206
|
/** ISO timestamp when CaseContext first wrote the context bundle. */
|
|
129
207
|
reportedAt?: string;
|
|
130
208
|
/** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
|
|
131
209
|
reportPath?: string;
|
|
132
|
-
/**
|
|
133
|
-
|
|
210
|
+
/** Role-typed, artifact-backed evidence items (separate table). */
|
|
211
|
+
evidenceItems: EvidenceItem[];
|
|
212
|
+
/** Tested (asset × attack-class) coverage cells (separate table). */
|
|
213
|
+
coverageItems: CoverageItem[];
|
|
134
214
|
/** Linked cases with their relationship kind, from this case's perspective. */
|
|
135
215
|
linkedCases: { id: string; kind: string }[];
|
|
136
216
|
createdAt: string;
|
|
@@ -156,14 +236,16 @@ export type CaseInput = {
|
|
|
156
236
|
blockers?: string[];
|
|
157
237
|
tags?: string[];
|
|
158
238
|
assumptions?: string[];
|
|
239
|
+
/** Falsification conditions — what would disprove this hypothesis (required on new cases). */
|
|
240
|
+
disproveIf?: string[];
|
|
159
241
|
/** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
|
|
160
242
|
disconfirmation?: string;
|
|
161
243
|
};
|
|
162
244
|
|
|
163
245
|
type NormalizedCaseInput = Partial<CaseInput> & {
|
|
164
|
-
linkedCaseIds?: string[];
|
|
165
246
|
pocVerified?: CaseRecord["pocVerified"];
|
|
166
247
|
disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
|
|
248
|
+
controlVerified?: CaseRecord["controlVerified"];
|
|
167
249
|
reportedAt?: string;
|
|
168
250
|
reportPath?: string;
|
|
169
251
|
};
|
|
@@ -234,21 +316,12 @@ function stableShortId(input: string): string {
|
|
|
234
316
|
function detectWorkspaceRoot(): string {
|
|
235
317
|
// PWD is deliberately excluded: it is shell-set, can be stale or forged in
|
|
236
318
|
// spawned processes, and disagree with the real cwd. Explicit overrides only,
|
|
237
|
-
// then walk up from the actual cwd.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
let curr = resolve(process.cwd());
|
|
245
|
-
for (let i = 0; i < 20; i++) {
|
|
246
|
-
if (existsSync(join(curr, ".git"))) return curr;
|
|
247
|
-
const parent = dirname(curr);
|
|
248
|
-
if (parent === curr) break;
|
|
249
|
-
curr = parent;
|
|
250
|
-
}
|
|
251
|
-
return resolve(process.cwd());
|
|
319
|
+
// then walk up from the actual cwd (.git only — the ledger predates its
|
|
320
|
+
// package.json, unlike the scratchpad).
|
|
321
|
+
return findWorkspaceRoot(
|
|
322
|
+
["CASEFILE_WORKSPACE_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"],
|
|
323
|
+
[".git"],
|
|
324
|
+
);
|
|
252
325
|
}
|
|
253
326
|
|
|
254
327
|
export function getCasefilePath(): string {
|
|
@@ -347,6 +420,43 @@ function getDb(): DatabaseSync {
|
|
|
347
420
|
if (!caseCols.some((c) => c.name === "disconfirmation_verified_json")) {
|
|
348
421
|
db.exec("ALTER TABLE cases ADD COLUMN disconfirmation_verified_json TEXT");
|
|
349
422
|
}
|
|
423
|
+
if (!caseCols.some((c) => c.name === "disprove_if_json")) {
|
|
424
|
+
db.exec("ALTER TABLE cases ADD COLUMN disprove_if_json TEXT");
|
|
425
|
+
}
|
|
426
|
+
if (!caseCols.some((c) => c.name === "control_verified_json")) {
|
|
427
|
+
db.exec("ALTER TABLE cases ADD COLUMN control_verified_json TEXT");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Role-typed, artifact-backed evidence items (Black-cat style evidence chain).
|
|
431
|
+
db.exec(`
|
|
432
|
+
CREATE TABLE IF NOT EXISTS evidence_items (
|
|
433
|
+
id TEXT PRIMARY KEY,
|
|
434
|
+
case_id TEXT NOT NULL,
|
|
435
|
+
role TEXT NOT NULL,
|
|
436
|
+
artifact_path TEXT,
|
|
437
|
+
sha256 TEXT,
|
|
438
|
+
summary TEXT NOT NULL,
|
|
439
|
+
created_at TEXT NOT NULL,
|
|
440
|
+
FOREIGN KEY (case_id) REFERENCES cases(id) ON DELETE CASCADE
|
|
441
|
+
)
|
|
442
|
+
`);
|
|
443
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_evidence_items_case ON evidence_items(case_id)`);
|
|
444
|
+
|
|
445
|
+
// Coverage matrix: tested (asset × attack-class) cells with wide/local scope.
|
|
446
|
+
db.exec(`
|
|
447
|
+
CREATE TABLE IF NOT EXISTS coverage_items (
|
|
448
|
+
id TEXT PRIMARY KEY,
|
|
449
|
+
case_id TEXT NOT NULL,
|
|
450
|
+
asset TEXT NOT NULL,
|
|
451
|
+
class TEXT NOT NULL,
|
|
452
|
+
scope TEXT NOT NULL CHECK (scope IN ('wide', 'local')),
|
|
453
|
+
note TEXT NOT NULL,
|
|
454
|
+
tested_by TEXT,
|
|
455
|
+
created_at TEXT NOT NULL,
|
|
456
|
+
FOREIGN KEY (case_id) REFERENCES cases(id) ON DELETE CASCADE
|
|
457
|
+
)
|
|
458
|
+
`);
|
|
459
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_coverage_items_case ON coverage_items(case_id)`);
|
|
350
460
|
|
|
351
461
|
// Indexes
|
|
352
462
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status)`);
|
|
@@ -359,7 +469,12 @@ function getDb(): DatabaseSync {
|
|
|
359
469
|
}
|
|
360
470
|
|
|
361
471
|
// Helper to map DB row to CaseRecord
|
|
362
|
-
function mapRow(
|
|
472
|
+
function mapRow(
|
|
473
|
+
row: any,
|
|
474
|
+
linkedCases: { id: string; kind: string }[] = [],
|
|
475
|
+
evidenceItems: EvidenceItem[] = [],
|
|
476
|
+
coverageItems: CoverageItem[] = [],
|
|
477
|
+
): CaseRecord {
|
|
363
478
|
/** Safely parse a JSON column; returns [] for arrays, undefined for objects. */
|
|
364
479
|
const safeParseArray = (raw: unknown): string[] => {
|
|
365
480
|
if (!raw) return [];
|
|
@@ -400,18 +515,69 @@ function mapRow(row: any, linkedCases: { id: string; kind: string }[] = []): Cas
|
|
|
400
515
|
blockers: safeParseArray(row.blockers_json),
|
|
401
516
|
tags: safeParseArray(row.tags_json),
|
|
402
517
|
assumptions: safeParseArray(row.assumptions_json),
|
|
518
|
+
disproveIf: safeParseArray(row.disprove_if_json),
|
|
403
519
|
disconfirmation: row.disconfirmation || undefined,
|
|
404
520
|
pocVerified: safeParseObject(row.poc_verified_json),
|
|
405
521
|
disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
|
|
522
|
+
controlVerified: safeParseObject(row.control_verified_json),
|
|
406
523
|
reportedAt: row.reported_at || undefined,
|
|
407
524
|
reportPath: row.report_path || undefined,
|
|
525
|
+
evidenceItems,
|
|
526
|
+
coverageItems,
|
|
408
527
|
linkedCases,
|
|
409
|
-
linkedCaseIds: linkedCases.map((l) => l.id),
|
|
410
528
|
createdAt: row.created_at,
|
|
411
529
|
updatedAt: row.updated_at,
|
|
412
530
|
};
|
|
413
531
|
}
|
|
414
532
|
|
|
533
|
+
/** Map raw snake_case DB rows to their camelCase item types. */
|
|
534
|
+
function mapEvidenceRow(row: any): EvidenceItem {
|
|
535
|
+
return {
|
|
536
|
+
id: row.id,
|
|
537
|
+
caseId: row.case_id,
|
|
538
|
+
role: row.role,
|
|
539
|
+
artifactPath: row.artifact_path ?? undefined,
|
|
540
|
+
sha256: row.sha256 ?? undefined,
|
|
541
|
+
summary: row.summary,
|
|
542
|
+
createdAt: row.created_at,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function mapCoverageRow(row: any): CoverageItem {
|
|
547
|
+
return {
|
|
548
|
+
id: row.id,
|
|
549
|
+
caseId: row.case_id,
|
|
550
|
+
asset: row.asset,
|
|
551
|
+
class: row.class,
|
|
552
|
+
scope: row.scope,
|
|
553
|
+
note: row.note,
|
|
554
|
+
testedBy: row.tested_by ?? undefined,
|
|
555
|
+
createdAt: row.created_at,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Batch-fetch per-case item tables (evidence / coverage) for a set of ids. */
|
|
560
|
+
function fetchItemMap<T extends { caseId: string }>(
|
|
561
|
+
db: DatabaseSync,
|
|
562
|
+
table: "evidence_items" | "coverage_items",
|
|
563
|
+
ids: string[],
|
|
564
|
+
): Map<string, T[]> {
|
|
565
|
+
if (ids.length === 0) return new Map();
|
|
566
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
567
|
+
const rows = db
|
|
568
|
+
.prepare(`SELECT * FROM ${table} WHERE case_id IN (${placeholders}) ORDER BY created_at`)
|
|
569
|
+
.all(...ids) as any[];
|
|
570
|
+
const mapRow = table === "evidence_items" ? mapEvidenceRow : mapCoverageRow;
|
|
571
|
+
const map = new Map<string, T[]>();
|
|
572
|
+
for (const row of rows) {
|
|
573
|
+
const item = mapRow(row) as unknown as T;
|
|
574
|
+
const bucket = map.get(item.caseId);
|
|
575
|
+
if (bucket) bucket.push(item);
|
|
576
|
+
else map.set(item.caseId, [item]);
|
|
577
|
+
}
|
|
578
|
+
return map;
|
|
579
|
+
}
|
|
580
|
+
|
|
415
581
|
// ── Read operations ──────────────────────────────────────────────────
|
|
416
582
|
|
|
417
583
|
export function readCasefile(): CaseRecord[] {
|
|
@@ -431,7 +597,17 @@ export function readCasefile(): CaseRecord[] {
|
|
|
431
597
|
linkMap.get(link.source_id)?.push({ id: link.target_id, kind: link.kind });
|
|
432
598
|
}
|
|
433
599
|
|
|
434
|
-
|
|
600
|
+
const ids = rows.map((r: any) => r.id);
|
|
601
|
+
const evidenceMap = fetchItemMap<EvidenceItem>(db, "evidence_items", ids);
|
|
602
|
+
const coverageMap = fetchItemMap<CoverageItem>(db, "coverage_items", ids);
|
|
603
|
+
return rows.map((row: any) =>
|
|
604
|
+
mapRow(
|
|
605
|
+
row,
|
|
606
|
+
linkMap.get(row.id) ?? [],
|
|
607
|
+
evidenceMap.get(row.id) ?? [],
|
|
608
|
+
coverageMap.get(row.id) ?? [],
|
|
609
|
+
),
|
|
610
|
+
);
|
|
435
611
|
}
|
|
436
612
|
|
|
437
613
|
/**
|
|
@@ -455,10 +631,22 @@ export function getCaseById(id: string): CaseRecord | undefined {
|
|
|
455
631
|
|
|
456
632
|
const linkStmt = db.prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?");
|
|
457
633
|
const links = linkStmt.all(id) as { target_id: string; kind: string }[];
|
|
634
|
+
const evidence = (
|
|
635
|
+
db
|
|
636
|
+
.prepare("SELECT * FROM evidence_items WHERE case_id = ? ORDER BY created_at")
|
|
637
|
+
.all(id) as any[]
|
|
638
|
+
).map(mapEvidenceRow);
|
|
639
|
+
const coverage = (
|
|
640
|
+
db
|
|
641
|
+
.prepare("SELECT * FROM coverage_items WHERE case_id = ? ORDER BY created_at")
|
|
642
|
+
.all(id) as any[]
|
|
643
|
+
).map(mapCoverageRow);
|
|
458
644
|
|
|
459
645
|
return mapRow(
|
|
460
646
|
row,
|
|
461
647
|
links.map((l) => ({ id: l.target_id, kind: l.kind })),
|
|
648
|
+
evidence,
|
|
649
|
+
coverage,
|
|
462
650
|
);
|
|
463
651
|
}
|
|
464
652
|
|
|
@@ -466,6 +654,15 @@ export function getCaseById(id: string): CaseRecord | undefined {
|
|
|
466
654
|
|
|
467
655
|
function validateCase(record: CaseRecord): void {
|
|
468
656
|
if (!record.title.trim()) throw new Error("Case title cannot be empty");
|
|
657
|
+
// Falsification conditions are load-bearing: they are required at creation
|
|
658
|
+
// and must not be erasable later (CaseUpdate({ disproveIf: [] }) would wipe
|
|
659
|
+
// the hypothesis's falsifiability). Re-check on every write.
|
|
660
|
+
if (record.status !== "reported" && !(record.disproveIf ?? []).some((d) => d.trim())) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
"Cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
|
|
663
|
+
"They cannot be cleared once set.",
|
|
664
|
+
);
|
|
665
|
+
}
|
|
469
666
|
// Keep this gate in lockstep with promoteFindingResult: a case may only be
|
|
470
667
|
// CONFIRMED when it has evidence, a PoC, demonstrated impact, and a severity.
|
|
471
668
|
if (
|
|
@@ -502,6 +699,28 @@ function validateCase(record: CaseRecord): void {
|
|
|
502
699
|
}
|
|
503
700
|
}
|
|
504
701
|
|
|
702
|
+
/**
|
|
703
|
+
* Kill-reason vocabulary — a kill must name one of these (or carry refutation
|
|
704
|
+
* evidence). Single source of truth: the ledger gate AND the injected workflow
|
|
705
|
+
* text (workflow.ts imports this) must not drift apart.
|
|
706
|
+
*/
|
|
707
|
+
export const KILL_REASON_VALUES = [
|
|
708
|
+
"intended_behavior",
|
|
709
|
+
"duplicate",
|
|
710
|
+
"framework_protection",
|
|
711
|
+
"exploit_unreliable",
|
|
712
|
+
"insufficient_impact",
|
|
713
|
+
"environmental_issue",
|
|
714
|
+
"not_applicable",
|
|
715
|
+
"out_of_scope",
|
|
716
|
+
"skeptic-disproven",
|
|
717
|
+
"no_attack_path",
|
|
718
|
+
"refuted",
|
|
719
|
+
] as const;
|
|
720
|
+
export type KillReason = (typeof KILL_REASON_VALUES)[number];
|
|
721
|
+
|
|
722
|
+
const KILL_REASON_PATTERN = new RegExp(`\\b(${KILL_REASON_VALUES.join("|")})\\b`, "i");
|
|
723
|
+
|
|
505
724
|
function validateTransition(
|
|
506
725
|
from: CaseStatus,
|
|
507
726
|
to: CaseStatus,
|
|
@@ -521,9 +740,28 @@ function validateTransition(
|
|
|
521
740
|
);
|
|
522
741
|
}
|
|
523
742
|
|
|
524
|
-
if (to === "killed") return;
|
|
525
743
|
if (to === "blocked") return;
|
|
526
744
|
|
|
745
|
+
if (to === "killed") {
|
|
746
|
+
// Black-cat rule: a kill must be justified. Valid iff (a) a refutation
|
|
747
|
+
// evidence item exists for this case, or (b) the update states a kill
|
|
748
|
+
// reason from the KILLED catalog vocabulary (matches workflow.ts).
|
|
749
|
+
const items = current ? listEvidenceItems(current.id) : [];
|
|
750
|
+
if (!items.some((e) => e.role === "refutation")) {
|
|
751
|
+
const text = [update.nextStep, (update.assumptions ?? []).join(" "), update.evidence]
|
|
752
|
+
.filter(Boolean)
|
|
753
|
+
.join(" ");
|
|
754
|
+
if (!KILL_REASON_PATTERN.test(text)) {
|
|
755
|
+
throw new Error(
|
|
756
|
+
"Cannot kill without justification: add refutation evidence (EvidenceAdd role=refutation) " +
|
|
757
|
+
"or state a kill reason in assumptions/nextStep (intended_behavior, duplicate, " +
|
|
758
|
+
"framework_protection, out_of_scope, skeptic-disproven, no_attack_path, ...)",
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
527
765
|
type Rule = (u: CaseUpdate, current?: CaseRecord) => string | null;
|
|
528
766
|
|
|
529
767
|
// Transition rules must consult both the update payload AND the current record.
|
|
@@ -577,6 +815,14 @@ function validateNewCaseInput(input: CaseInput): void {
|
|
|
577
815
|
"New cases must start as hypothesis or investigating; promote with CaseUpdate after validation",
|
|
578
816
|
);
|
|
579
817
|
}
|
|
818
|
+
// Black-cat style falsification: a hypothesis that cannot name what would
|
|
819
|
+
// disprove it is not a hypothesis yet. Required at creation (update later).
|
|
820
|
+
if (!input.disproveIf?.length || !input.disproveIf.some((d) => d.trim())) {
|
|
821
|
+
throw new Error(
|
|
822
|
+
"New cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
|
|
823
|
+
"Update them later via CaseUpdate if the picture changes.",
|
|
824
|
+
);
|
|
825
|
+
}
|
|
580
826
|
if (input.status === "investigating") {
|
|
581
827
|
if (!input.evidence) {
|
|
582
828
|
throw new Error("New investigating cases require evidence (source→sink trace)");
|
|
@@ -613,16 +859,19 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
613
859
|
blockers: normalizeList(input.blockers ?? existing?.blockers),
|
|
614
860
|
tags: normalizeList(input.tags ?? existing?.tags),
|
|
615
861
|
assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
|
|
862
|
+
disproveIf: normalizeList(input.disproveIf ?? existing?.disproveIf),
|
|
616
863
|
pocVerified: input.pocVerified ?? existing?.pocVerified,
|
|
617
864
|
disconfirmation:
|
|
618
865
|
input.disconfirmation !== undefined
|
|
619
866
|
? normalizeText(input.disconfirmation)
|
|
620
867
|
: existing?.disconfirmation,
|
|
621
868
|
disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
|
|
869
|
+
controlVerified: input.controlVerified ?? existing?.controlVerified,
|
|
622
870
|
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
623
871
|
reportPath: input.reportPath ?? existing?.reportPath,
|
|
872
|
+
evidenceItems: existing?.evidenceItems ?? [],
|
|
873
|
+
coverageItems: existing?.coverageItems ?? [],
|
|
624
874
|
linkedCases: existing?.linkedCases ?? [],
|
|
625
|
-
linkedCaseIds: existing?.linkedCaseIds ?? [],
|
|
626
875
|
createdAt: existing?.createdAt ?? timestamp,
|
|
627
876
|
updatedAt: timestamp,
|
|
628
877
|
};
|
|
@@ -877,13 +1126,13 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
877
1126
|
id, title, status, confidence, severity, priority, target, endpoint, bugClass,
|
|
878
1127
|
summary, evidence, impact, nextStep, poc, remediation,
|
|
879
1128
|
references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
|
|
880
|
-
disconfirmation, disconfirmation_verified_json,
|
|
1129
|
+
disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
|
|
881
1130
|
reported_at, report_path, created_at, updated_at
|
|
882
1131
|
) VALUES (
|
|
883
1132
|
?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
884
1133
|
?, ?, ?, ?, ?, ?,
|
|
885
1134
|
?, ?, ?, ?, ?,
|
|
886
|
-
?, ?,
|
|
1135
|
+
?, ?, ?, ?,
|
|
887
1136
|
?, ?, ?, ?
|
|
888
1137
|
)
|
|
889
1138
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -908,6 +1157,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
908
1157
|
poc_verified_json = excluded.poc_verified_json,
|
|
909
1158
|
disconfirmation = excluded.disconfirmation,
|
|
910
1159
|
disconfirmation_verified_json = excluded.disconfirmation_verified_json,
|
|
1160
|
+
disprove_if_json = excluded.disprove_if_json,
|
|
1161
|
+
control_verified_json = excluded.control_verified_json,
|
|
911
1162
|
reported_at = excluded.reported_at,
|
|
912
1163
|
report_path = excluded.report_path,
|
|
913
1164
|
created_at = excluded.created_at,
|
|
@@ -937,6 +1188,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
937
1188
|
record.pocVerified ? JSON.stringify(record.pocVerified) : null,
|
|
938
1189
|
record.disconfirmation || null,
|
|
939
1190
|
record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
|
|
1191
|
+
JSON.stringify(record.disproveIf),
|
|
1192
|
+
record.controlVerified ? JSON.stringify(record.controlVerified) : null,
|
|
940
1193
|
record.reportedAt || null,
|
|
941
1194
|
record.reportPath || null,
|
|
942
1195
|
record.createdAt,
|
|
@@ -944,6 +1197,210 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
944
1197
|
);
|
|
945
1198
|
}
|
|
946
1199
|
|
|
1200
|
+
// ── Evidence items ──────────────────────────────────────────────────
|
|
1201
|
+
|
|
1202
|
+
function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
|
|
1203
|
+
db.prepare(
|
|
1204
|
+
`INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at)
|
|
1205
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
1206
|
+
).run(
|
|
1207
|
+
item.id,
|
|
1208
|
+
item.caseId,
|
|
1209
|
+
item.role,
|
|
1210
|
+
item.artifactPath ?? null,
|
|
1211
|
+
item.sha256 ?? null,
|
|
1212
|
+
item.summary,
|
|
1213
|
+
item.createdAt,
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Add a role-typed evidence item. Artifact path is hashed (SHA-256) and only
|
|
1219
|
+
* its basename is stored — the full path is never persisted (path-leak guard).
|
|
1220
|
+
*/
|
|
1221
|
+
export function addEvidenceItemResult(
|
|
1222
|
+
caseId: string,
|
|
1223
|
+
input: { role: EvidenceRole; summary: string; artifactPath?: string },
|
|
1224
|
+
): EvidenceItem {
|
|
1225
|
+
const db = getDb();
|
|
1226
|
+
const current = getCaseById(caseId);
|
|
1227
|
+
if (!current) throw new Error(`Case not found: ${caseId}`);
|
|
1228
|
+
if (current.status === "killed" || current.status === "reported") {
|
|
1229
|
+
throw new Error(`Cannot add evidence to terminal case ${caseId} (${current.status})`);
|
|
1230
|
+
}
|
|
1231
|
+
if (!(EVIDENCE_ROLE_VALUES as readonly string[]).includes(input.role)) {
|
|
1232
|
+
throw new Error(
|
|
1233
|
+
`Invalid evidence role: ${input.role}. Roles: ${EVIDENCE_ROLE_VALUES.join(", ")}`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
const summary = normalizeText(input.summary);
|
|
1237
|
+
if (!summary) throw new Error("Evidence summary must not be empty");
|
|
1238
|
+
|
|
1239
|
+
let artifactPath: string | undefined;
|
|
1240
|
+
let sha256: string | undefined;
|
|
1241
|
+
if (input.artifactPath) {
|
|
1242
|
+
if (!existsSync(input.artifactPath)) {
|
|
1243
|
+
throw new Error(`Evidence artifact not found on disk: ${input.artifactPath}`);
|
|
1244
|
+
}
|
|
1245
|
+
const stat = statSync(input.artifactPath);
|
|
1246
|
+
if (!stat.isFile()) {
|
|
1247
|
+
throw new Error(`Evidence artifact is not a regular file: ${input.artifactPath}`);
|
|
1248
|
+
}
|
|
1249
|
+
if (stat.size > EVIDENCE_ARTIFACT_MAX_BYTES) {
|
|
1250
|
+
throw new Error(
|
|
1251
|
+
`Evidence artifact too large (${stat.size} bytes; max ${EVIDENCE_ARTIFACT_MAX_BYTES}): ${input.artifactPath}`,
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
artifactPath = basename(input.artifactPath);
|
|
1255
|
+
sha256 = createHash("sha256").update(readFileSync(input.artifactPath)).digest("hex");
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const item: EvidenceItem = {
|
|
1259
|
+
id: `ev_${stableShortId(`${caseId}\n${summary}\n${randomUUID()}`)}`,
|
|
1260
|
+
caseId,
|
|
1261
|
+
role: input.role,
|
|
1262
|
+
artifactPath,
|
|
1263
|
+
sha256,
|
|
1264
|
+
summary,
|
|
1265
|
+
createdAt: new Date().toISOString(),
|
|
1266
|
+
};
|
|
1267
|
+
insertEvidenceItem(db, item);
|
|
1268
|
+
return item;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
export function listEvidenceItems(caseId: string): EvidenceItem[] {
|
|
1272
|
+
const db = getDb();
|
|
1273
|
+
return (
|
|
1274
|
+
db
|
|
1275
|
+
.prepare("SELECT * FROM evidence_items WHERE case_id = ? ORDER BY created_at")
|
|
1276
|
+
.all(caseId) as any[]
|
|
1277
|
+
).map(mapEvidenceRow);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// ── Coverage items ──────────────────────────────────────────────────
|
|
1281
|
+
|
|
1282
|
+
function insertCoverageItem(db: DatabaseSync, item: CoverageItem): void {
|
|
1283
|
+
db.prepare(
|
|
1284
|
+
`INSERT INTO coverage_items (id, case_id, asset, class, scope, note, tested_by, created_at)
|
|
1285
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1286
|
+
).run(
|
|
1287
|
+
item.id,
|
|
1288
|
+
item.caseId,
|
|
1289
|
+
item.asset,
|
|
1290
|
+
item.class,
|
|
1291
|
+
item.scope,
|
|
1292
|
+
item.note,
|
|
1293
|
+
item.testedBy ?? null,
|
|
1294
|
+
item.createdAt,
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Record a tested (asset × attack-class) cell. The cell's existence marks the
|
|
1300
|
+
* class tested for that asset — found OR clean. `scope: wide` means the verdict
|
|
1301
|
+
* is a property of the whole deployment: recorded once, applies to every asset
|
|
1302
|
+
* of the deployment (do NOT re-test per asset).
|
|
1303
|
+
*/
|
|
1304
|
+
export function recordCoverageResult(
|
|
1305
|
+
caseId: string,
|
|
1306
|
+
input: {
|
|
1307
|
+
asset: string;
|
|
1308
|
+
class: string;
|
|
1309
|
+
scope: CoverageScope;
|
|
1310
|
+
note: string;
|
|
1311
|
+
testedBy?: string;
|
|
1312
|
+
},
|
|
1313
|
+
): CoverageItem {
|
|
1314
|
+
const db = getDb();
|
|
1315
|
+
const current = getCaseById(caseId);
|
|
1316
|
+
if (!current) throw new Error(`Case not found: ${caseId}`);
|
|
1317
|
+
if (current.status === "killed" || current.status === "reported") {
|
|
1318
|
+
throw new Error(`Cannot record coverage on terminal case ${caseId} (${current.status})`);
|
|
1319
|
+
}
|
|
1320
|
+
if (!(COVERAGE_SCOPE_VALUES as readonly string[]).includes(input.scope)) {
|
|
1321
|
+
throw new Error(
|
|
1322
|
+
`Invalid coverage scope: ${input.scope}. Scope must be one of: ${COVERAGE_SCOPE_VALUES.join(", ")}`,
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
const asset = normalizeText(input.asset);
|
|
1326
|
+
const attackClass = normalizeText(input.class);
|
|
1327
|
+
const note = normalizeText(input.note);
|
|
1328
|
+
if (!asset) throw new Error("Coverage asset must not be empty");
|
|
1329
|
+
if (!attackClass) throw new Error("Coverage class must not be empty");
|
|
1330
|
+
if (!note) throw new Error("Coverage note must not be empty");
|
|
1331
|
+
|
|
1332
|
+
const item: CoverageItem = {
|
|
1333
|
+
id: `cov_${stableShortId(`${caseId}\n${asset}\n${attackClass}\n${input.scope}\n${randomUUID()}`)}`,
|
|
1334
|
+
caseId,
|
|
1335
|
+
asset,
|
|
1336
|
+
class: attackClass,
|
|
1337
|
+
scope: input.scope,
|
|
1338
|
+
note,
|
|
1339
|
+
testedBy: input.testedBy ? normalizeText(input.testedBy) : undefined,
|
|
1340
|
+
createdAt: new Date().toISOString(),
|
|
1341
|
+
};
|
|
1342
|
+
insertCoverageItem(db, item);
|
|
1343
|
+
return item;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
export function listCoverage(caseId: string): CoverageItem[] {
|
|
1347
|
+
const db = getDb();
|
|
1348
|
+
return (
|
|
1349
|
+
db
|
|
1350
|
+
.prepare("SELECT * FROM coverage_items WHERE case_id = ? ORDER BY created_at")
|
|
1351
|
+
.all(caseId) as any[]
|
|
1352
|
+
).map(mapCoverageRow);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
export type CoverageSummary = {
|
|
1356
|
+
items: CoverageItem[];
|
|
1357
|
+
/** Cells grouped per asset (wide cells repeated under every later asset they cover). */
|
|
1358
|
+
byAsset: Record<string, CoverageItem[]>;
|
|
1359
|
+
assets: string[];
|
|
1360
|
+
classes: string[];
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Machine-checkable coverage view: which (asset × class) cells are tested.
|
|
1365
|
+
* A `wide` cell covers every asset recorded after it — a class with a wide
|
|
1366
|
+
* clean verdict must NOT be re-tested per asset (that is the wide semantics).
|
|
1367
|
+
*/
|
|
1368
|
+
export function coverageSummary(caseId: string): CoverageSummary {
|
|
1369
|
+
const items = listCoverage(caseId);
|
|
1370
|
+
const byAsset: Record<string, CoverageItem[]> = {};
|
|
1371
|
+
const assets: string[] = [];
|
|
1372
|
+
const classes: string[] = [];
|
|
1373
|
+
|
|
1374
|
+
for (const item of items) {
|
|
1375
|
+
if (!assets.includes(item.asset)) assets.push(item.asset);
|
|
1376
|
+
if (!classes.includes(item.class)) classes.push(item.class);
|
|
1377
|
+
if (!byAsset[item.asset]) byAsset[item.asset] = [];
|
|
1378
|
+
byAsset[item.asset].push(item);
|
|
1379
|
+
}
|
|
1380
|
+
// Wide cells: a deployment-wide verdict covers every asset in the case. A
|
|
1381
|
+
// local cell for the same class on the same asset is more specific and wins
|
|
1382
|
+
// (the agent re-tested after the wide verdict — record shows both).
|
|
1383
|
+
const wideByClass = new Map<string, CoverageItem>();
|
|
1384
|
+
for (const item of items) {
|
|
1385
|
+
if (item.scope === "wide") {
|
|
1386
|
+
const prev = wideByClass.get(item.class);
|
|
1387
|
+
if (!prev || item.createdAt >= prev.createdAt) wideByClass.set(item.class, item);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
for (const [cls, wide] of wideByClass) {
|
|
1391
|
+
for (const asset of assets) {
|
|
1392
|
+
if (!byAsset[asset]) byAsset[asset] = [];
|
|
1393
|
+
const cells = byAsset[asset];
|
|
1394
|
+
const hasLocal = cells.some((c) => c.class === cls && c.scope === "local");
|
|
1395
|
+
const hasWide = cells.some((c) => c.class === cls && c.scope === "wide");
|
|
1396
|
+
if (!hasLocal && !hasWide) {
|
|
1397
|
+
cells.push({ ...wide, asset, note: `${wide.note} (wide verdict covers this asset)` });
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return { items, byAsset, assets, classes };
|
|
1402
|
+
}
|
|
1403
|
+
|
|
947
1404
|
export function addCaseResult(input: CaseInput): CaseAddResult {
|
|
948
1405
|
const db = getDb();
|
|
949
1406
|
validateNewCaseInput(input);
|
|
@@ -983,49 +1440,24 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
983
1440
|
throw new Error("Cannot mutate a reported case; file a follow-up case instead");
|
|
984
1441
|
}
|
|
985
1442
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
"endpoint",
|
|
990
|
-
"bugClass",
|
|
991
|
-
"summary",
|
|
992
|
-
"evidence",
|
|
993
|
-
"impact",
|
|
994
|
-
"nextStep",
|
|
995
|
-
"poc",
|
|
996
|
-
"remediation",
|
|
997
|
-
"disconfirmation",
|
|
998
|
-
] as const;
|
|
999
|
-
const optionalPatch: Record<string, unknown> = {};
|
|
1000
|
-
for (const field of optionalFields) {
|
|
1001
|
-
if (field in update && update[field] !== undefined) {
|
|
1002
|
-
optionalPatch[field] = update[field];
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
let next = buildRecord(
|
|
1007
|
-
{
|
|
1008
|
-
...optionalPatch,
|
|
1009
|
-
status: update.status ?? current.status,
|
|
1010
|
-
confidence: update.confidence ?? current.confidence,
|
|
1011
|
-
severity: update.severity ?? current.severity,
|
|
1012
|
-
priority: update.priority ?? current.priority,
|
|
1013
|
-
references: update.references ?? current.references,
|
|
1014
|
-
blockers: update.blockers ?? current.blockers,
|
|
1015
|
-
tags: update.tags ?? current.tags,
|
|
1016
|
-
assumptions: update.assumptions ?? current.assumptions,
|
|
1017
|
-
},
|
|
1018
|
-
current,
|
|
1019
|
-
);
|
|
1443
|
+
// buildRecord resolves every field as `update.x ?? current.x`; the patch
|
|
1444
|
+
// construction is the update itself.
|
|
1445
|
+
let next = buildRecord(update, current);
|
|
1020
1446
|
|
|
1021
1447
|
if (update.status && update.status !== current.status) {
|
|
1022
1448
|
validateTransition(current.status, next.status, update, current);
|
|
1023
1449
|
}
|
|
1024
1450
|
|
|
1025
|
-
// Demoting off confirmed invalidates prior PoC + disconfirmation
|
|
1026
|
-
// re-promote required.
|
|
1451
|
+
// Demoting off confirmed invalidates prior PoC + disconfirmation + control
|
|
1452
|
+
// verification — re-promote required. All three artifacts must be re-earned
|
|
1453
|
+
// together (a stale control run must not survive a demote/re-promote cycle).
|
|
1027
1454
|
if (current.status === "confirmed" && next.status === "investigating") {
|
|
1028
|
-
next = {
|
|
1455
|
+
next = {
|
|
1456
|
+
...next,
|
|
1457
|
+
pocVerified: undefined,
|
|
1458
|
+
disconfirmationVerified: undefined,
|
|
1459
|
+
controlVerified: undefined,
|
|
1460
|
+
};
|
|
1029
1461
|
}
|
|
1030
1462
|
|
|
1031
1463
|
validateCase(next);
|
|
@@ -1044,8 +1476,9 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
1044
1476
|
if (
|
|
1045
1477
|
k === "updatedAt" ||
|
|
1046
1478
|
k === "createdAt" ||
|
|
1047
|
-
k === "
|
|
1048
|
-
k === "
|
|
1479
|
+
k === "linkedCases" ||
|
|
1480
|
+
k === "evidenceItems" ||
|
|
1481
|
+
k === "coverageItems"
|
|
1049
1482
|
) {
|
|
1050
1483
|
acc[k] = "";
|
|
1051
1484
|
} else {
|
|
@@ -1083,6 +1516,8 @@ export type PocVerification = {
|
|
|
1083
1516
|
ranAt: string;
|
|
1084
1517
|
output?: string;
|
|
1085
1518
|
sandbox: boolean;
|
|
1519
|
+
/** True iff the script ran to completion (not a spawn error / signal kill / timeout). */
|
|
1520
|
+
completed?: boolean;
|
|
1086
1521
|
};
|
|
1087
1522
|
|
|
1088
1523
|
/**
|
|
@@ -1128,6 +1563,8 @@ export function promoteFindingResult(
|
|
|
1128
1563
|
id: string,
|
|
1129
1564
|
verification: PocVerification,
|
|
1130
1565
|
disconfirmationVerification?: PocVerification,
|
|
1566
|
+
controlVerification?: PocVerification,
|
|
1567
|
+
marker?: string,
|
|
1131
1568
|
): CaseUpdateResult {
|
|
1132
1569
|
const db = getDb();
|
|
1133
1570
|
const current = assertPromotable(id);
|
|
@@ -1137,6 +1574,62 @@ export function promoteFindingResult(
|
|
|
1137
1574
|
);
|
|
1138
1575
|
}
|
|
1139
1576
|
|
|
1577
|
+
// Anti-cheat, enforced at the ledger level (not just the tool): a live
|
|
1578
|
+
// finding (any non-sandboxed run — `sandbox` must be explicitly true to
|
|
1579
|
+
// skip the control; undefined/false from JS callers fails closed) must carry
|
|
1580
|
+
// a control-target verification that COMPLETED and, when the marker is known
|
|
1581
|
+
// to the caller, did NOT print the marker in its output. Checking presence
|
|
1582
|
+
// alone is not enough: a control run that crashed (completed: false) or that
|
|
1583
|
+
// printed the marker proves nothing about target-dependence.
|
|
1584
|
+
const isLive = verification.sandbox !== true;
|
|
1585
|
+
if (isLive) {
|
|
1586
|
+
const controlOk =
|
|
1587
|
+
controlVerification?.completed === true &&
|
|
1588
|
+
(!marker || !(controlVerification.output ?? "").includes(marker));
|
|
1589
|
+
if (!controlOk) {
|
|
1590
|
+
throw new Error(
|
|
1591
|
+
"Live findings (non-sandboxed PoC run) require a valid controlVerification: a control-target " +
|
|
1592
|
+
"run of the same PoC that COMPLETED (completed: true)" +
|
|
1593
|
+
(marker ? ` and whose output does not contain the marker "${marker}"` : "") +
|
|
1594
|
+
". PromoteFinding requires control_path for local:true findings.",
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Evidence-chain closure pre-check BEFORE any DB write: the observation item
|
|
1600
|
+
// must already exist. Checking first means a failed promote (missing
|
|
1601
|
+
// observation) writes nothing — no phantom reproduction item is left on an
|
|
1602
|
+
// investigating case, and a retry sees the real error, not a PK conflict.
|
|
1603
|
+
if (!current.evidenceItems.some((e) => e.role === "observation")) {
|
|
1604
|
+
throw new Error(
|
|
1605
|
+
"Evidence chain incomplete: CONFIRMED requires an observation evidence item " +
|
|
1606
|
+
"(EvidenceAdd role=observation — the initial signal, artifact-backed) in addition to " +
|
|
1607
|
+
"the auto-recorded reproduction item. Add the observation item and retry promotion.",
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// Machine-recorded reproduction evidence: the PoC gate itself writes the
|
|
1612
|
+
// artifact-backed evidence item — confirmation is anchored to a real file
|
|
1613
|
+
// with its SHA-256, not to agent prose in the evidence field.
|
|
1614
|
+
let pocSha256: string | undefined;
|
|
1615
|
+
try {
|
|
1616
|
+
pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
|
|
1617
|
+
} catch {
|
|
1618
|
+
pocSha256 = undefined;
|
|
1619
|
+
}
|
|
1620
|
+
const reproductionItem: EvidenceItem = {
|
|
1621
|
+
id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
|
|
1622
|
+
caseId: id,
|
|
1623
|
+
role: "reproduction",
|
|
1624
|
+
artifactPath: basename(verification.path),
|
|
1625
|
+
sha256: pocSha256,
|
|
1626
|
+
summary: `PoC run exit ${verification.exitCode} (sandbox: ${verification.sandbox}) — verification marker present in output`,
|
|
1627
|
+
createdAt: verification.ranAt,
|
|
1628
|
+
};
|
|
1629
|
+
insertEvidenceItem(db, reproductionItem);
|
|
1630
|
+
// Attach to the record being validated (current was fetched pre-insert).
|
|
1631
|
+
current.evidenceItems = [...(current.evidenceItems ?? []), reproductionItem];
|
|
1632
|
+
|
|
1140
1633
|
const newEvidence =
|
|
1141
1634
|
(current.evidence ? `${current.evidence}\n\n` : "") +
|
|
1142
1635
|
`### PoC Execution Capture (${verification.ranAt})\n` +
|
|
@@ -1152,6 +1645,9 @@ export function promoteFindingResult(
|
|
|
1152
1645
|
if (disconfirmationVerification) {
|
|
1153
1646
|
update.disconfirmationVerified = disconfirmationVerification;
|
|
1154
1647
|
}
|
|
1648
|
+
if (controlVerification) {
|
|
1649
|
+
update.controlVerified = controlVerification;
|
|
1650
|
+
}
|
|
1155
1651
|
|
|
1156
1652
|
const next = buildRecord(update, current);
|
|
1157
1653
|
validateCase(next);
|
|
@@ -1160,53 +1656,254 @@ export function promoteFindingResult(
|
|
|
1160
1656
|
return { record: next, changed: true };
|
|
1161
1657
|
}
|
|
1162
1658
|
|
|
1163
|
-
// ──
|
|
1659
|
+
// ── Chain suggestions ───────────────────────────────────────────────
|
|
1660
|
+
|
|
1661
|
+
/** Automated exploit-chain patterns (ported shape from CyberStrike chain.ts). */
|
|
1662
|
+
const CHAIN_PATTERN_VALUES = [
|
|
1663
|
+
"credential_endpoint",
|
|
1664
|
+
"info_disclosure_ssrf",
|
|
1665
|
+
"redirect_oauth",
|
|
1666
|
+
"idor_data_leak",
|
|
1667
|
+
"xss_csrf",
|
|
1668
|
+
"ssti_rce",
|
|
1669
|
+
"race_condition_business",
|
|
1670
|
+
] as const;
|
|
1671
|
+
export type ChainPattern = (typeof CHAIN_PATTERN_VALUES)[number];
|
|
1672
|
+
|
|
1673
|
+
export type ChainSuggestion = {
|
|
1674
|
+
pattern: ChainPattern;
|
|
1675
|
+
sourceId: string;
|
|
1676
|
+
targetId?: string;
|
|
1677
|
+
sourceTitle: string;
|
|
1678
|
+
targetTitle?: string;
|
|
1679
|
+
rationale: string;
|
|
1680
|
+
confidence: number;
|
|
1681
|
+
/** Suggested CaseLink kind when the agent links the pair. */
|
|
1682
|
+
suggestedKind?: CaseLinkKind;
|
|
1683
|
+
};
|
|
1164
1684
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1685
|
+
// Word-boundary anchored so "admin" does not match "administration" and
|
|
1686
|
+
// "update" does not match "updated" — substring matching over-mines pairs.
|
|
1687
|
+
const CHAIN_CLASS_RE = {
|
|
1688
|
+
credential: /\b(credential|password|api[ -]?key|token|secret|leak|dump|exposure)\b/i,
|
|
1689
|
+
authEndpoint: /\b(auth|login|sso|signup|account|admin|endpoint|api)\b/i,
|
|
1690
|
+
redirect: /\b(open redirect|redirect)\b/i,
|
|
1691
|
+
oauth: /\b(oauth|callback|redirect_uri|sso|saml|openid|authorize)\b/i,
|
|
1692
|
+
xss: /\b(xss|cross-?site.?script)\b/i,
|
|
1693
|
+
stateChange:
|
|
1694
|
+
/\b(POST|PUT|DELETE|PATCH|create|update|delete|transfer|payment|invite|admin|state.?chang)\b/i,
|
|
1695
|
+
idor: /\b(idor|bola|object reference|broken access)\b/i,
|
|
1696
|
+
userData:
|
|
1697
|
+
/\b(user|users|profile|account|accounts|email|phone|address|personal|private|settings|data)\b/i,
|
|
1698
|
+
ssti: /\b(ssti|template injection|template render)\b/i,
|
|
1699
|
+
race: /\b(race|toctou|concurrent)\b/i,
|
|
1700
|
+
payment: /\b(payment|transfer|order|checkout|cart|purchase|balance|credit|withdraw|deposit)\b/i,
|
|
1701
|
+
infoDisclosure: /\b(info disclosure|information disclosure|leak|exposure|debug)\b/i,
|
|
1702
|
+
ssrf: /\b(ssrf|server-?side request)\b/i,
|
|
1703
|
+
} satisfies Record<string, RegExp>;
|
|
1704
|
+
|
|
1705
|
+
/** Multi-label second-level suffixes — *.co.uk must not false-pair via last-2 labels. */
|
|
1706
|
+
const SECOND_LEVEL_SUFFIXES = new Set([
|
|
1707
|
+
"co",
|
|
1708
|
+
"com",
|
|
1709
|
+
"org",
|
|
1710
|
+
"net",
|
|
1711
|
+
"gov",
|
|
1712
|
+
"ac",
|
|
1713
|
+
"edu",
|
|
1714
|
+
"mil",
|
|
1715
|
+
"ltd",
|
|
1716
|
+
"me",
|
|
1717
|
+
"tv",
|
|
1718
|
+
"info",
|
|
1719
|
+
"biz",
|
|
1720
|
+
]);
|
|
1721
|
+
|
|
1722
|
+
function eTLDPlus1(host: string): string {
|
|
1723
|
+
const parts = host.split(".");
|
|
1724
|
+
if (parts.length >= 3 && SECOND_LEVEL_SUFFIXES.has(parts[parts.length - 2] ?? "")) {
|
|
1725
|
+
return parts.slice(-3).join(".");
|
|
1169
1726
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1727
|
+
return parts.slice(-2).join(".");
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function chainText(c: CaseRecord): string {
|
|
1731
|
+
return [c.title, c.bugClass ?? "", c.evidence ?? ""].join(" ");
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function hasChainClass(c: CaseRecord, re: RegExp): boolean {
|
|
1735
|
+
return re.test(chainText(c));
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/** Reduce a target string to a bare hostname (strip scheme, port, path). */
|
|
1739
|
+
function normalizeTargetHost(target: string): string {
|
|
1740
|
+
let h = target
|
|
1741
|
+
.toLowerCase()
|
|
1742
|
+
.trim()
|
|
1743
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
|
|
1744
|
+
h = h.split("?")[0].split("/")[0].split(":")[0];
|
|
1745
|
+
return h.trim();
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/** Same asset or related (same eTLD+1) — chains only pair cases on one target. */
|
|
1749
|
+
function sameAssetOrRelated(a: CaseRecord, b: CaseRecord): boolean {
|
|
1750
|
+
const ta = normalizeTargetHost(a.target ?? "");
|
|
1751
|
+
const tb = normalizeTargetHost(b.target ?? "");
|
|
1752
|
+
if (!ta || !tb) return false;
|
|
1753
|
+
if (ta === tb) return true;
|
|
1754
|
+
// Subdomain relation requires a label boundary: "api.example.com" vs
|
|
1755
|
+
// "example.com" pair, but "myshop.io" vs "shop.io" do NOT — a bare
|
|
1756
|
+
// substring check pairs unrelated targets whose names merely overlap.
|
|
1757
|
+
if (ta.endsWith(`.${tb}`) || tb.endsWith(`.${ta}`)) return true;
|
|
1758
|
+
return eTLDPlus1(ta) === eTLDPlus1(tb);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Scan non-terminal cases for exploitable chains (CyberStrike-style detection
|
|
1763
|
+
* over XPI's case records). Emits ranked suggestions; the agent decides
|
|
1764
|
+
* whether to CaseLink or open an escalation case.
|
|
1765
|
+
*/
|
|
1766
|
+
export function suggestChains(caseId?: string): ChainSuggestion[] {
|
|
1767
|
+
// Pair over ALL non-terminal cases; the caseId filter narrows the RESULTS
|
|
1768
|
+
// to suggestions involving that case (filtering the inputs first would drop
|
|
1769
|
+
// unlinked partner cases and kill cross-case pairing).
|
|
1770
|
+
const cases = readCasefile().filter((c) => c.status !== "killed" && c.status !== "reported");
|
|
1771
|
+
const suggestions: ChainSuggestion[] = [];
|
|
1772
|
+
const seen = new Set<string>();
|
|
1773
|
+
const confirmed = (c: CaseRecord) => c.status === "confirmed";
|
|
1774
|
+
const confidenceFor = (a: CaseRecord, b?: CaseRecord) => {
|
|
1775
|
+
const both = confirmed(a) && (!b || confirmed(b));
|
|
1776
|
+
const one = confirmed(a) || (b ? confirmed(b) : false);
|
|
1777
|
+
return both ? 90 : one ? 75 : 55;
|
|
1778
|
+
};
|
|
1779
|
+
const add = (
|
|
1780
|
+
pattern: ChainPattern,
|
|
1781
|
+
a: CaseRecord,
|
|
1782
|
+
b: CaseRecord | undefined,
|
|
1783
|
+
rationale: string,
|
|
1784
|
+
kind?: CaseLinkKind,
|
|
1785
|
+
) => {
|
|
1786
|
+
const key = b ? `${pattern}:${[a.id, b.id].sort().join("+")}` : `${pattern}:${a.id}`;
|
|
1787
|
+
if (seen.has(key)) return;
|
|
1788
|
+
seen.add(key);
|
|
1789
|
+
suggestions.push({
|
|
1790
|
+
pattern,
|
|
1791
|
+
sourceId: a.id,
|
|
1792
|
+
targetId: b?.id,
|
|
1793
|
+
sourceTitle: a.title,
|
|
1794
|
+
targetTitle: b?.title,
|
|
1795
|
+
rationale,
|
|
1796
|
+
confidence: confidenceFor(a, b),
|
|
1797
|
+
suggestedKind: kind,
|
|
1798
|
+
});
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
// Pair rules as data: (classifier A, classifier B, rationale, link kind).
|
|
1802
|
+
// One loop replaces seven copy-pasted pair loops.
|
|
1803
|
+
const PAIR_RULES: Array<{
|
|
1804
|
+
pattern: Exclude<ChainPattern, "ssti_rce">;
|
|
1805
|
+
a: RegExp;
|
|
1806
|
+
b: RegExp;
|
|
1807
|
+
rationale: (a: CaseRecord, b: CaseRecord) => string;
|
|
1808
|
+
kind?: CaseLinkKind;
|
|
1809
|
+
}> = [
|
|
1810
|
+
{
|
|
1811
|
+
pattern: "credential_endpoint",
|
|
1812
|
+
a: CHAIN_CLASS_RE.credential,
|
|
1813
|
+
b: CHAIN_CLASS_RE.authEndpoint,
|
|
1814
|
+
kind: "depends-on",
|
|
1815
|
+
rationale: (a, b) =>
|
|
1816
|
+
`Use leaked credential "${a.title}" to authenticate against "${b.title}" → account takeover`,
|
|
1817
|
+
},
|
|
1818
|
+
{
|
|
1819
|
+
pattern: "redirect_oauth",
|
|
1820
|
+
a: CHAIN_CLASS_RE.redirect,
|
|
1821
|
+
b: CHAIN_CLASS_RE.oauth,
|
|
1822
|
+
rationale: (a, b) =>
|
|
1823
|
+
`Chain open redirect "${a.title}" into OAuth flow "${b.title}" to steal access tokens`,
|
|
1824
|
+
},
|
|
1825
|
+
{
|
|
1826
|
+
pattern: "xss_csrf",
|
|
1827
|
+
a: CHAIN_CLASS_RE.xss,
|
|
1828
|
+
b: CHAIN_CLASS_RE.stateChange,
|
|
1829
|
+
rationale: (a, b) =>
|
|
1830
|
+
`Use XSS "${a.title}" to drive state-changing "${b.title}" (CSRF bypass / victim-action)`,
|
|
1831
|
+
},
|
|
1832
|
+
{
|
|
1833
|
+
pattern: "idor_data_leak",
|
|
1834
|
+
a: CHAIN_CLASS_RE.idor,
|
|
1835
|
+
b: CHAIN_CLASS_RE.userData,
|
|
1836
|
+
rationale: (a, b) => `Use IDOR "${a.title}" to enumerate user data via "${b.title}"`,
|
|
1837
|
+
},
|
|
1838
|
+
{
|
|
1839
|
+
pattern: "race_condition_business",
|
|
1840
|
+
a: CHAIN_CLASS_RE.race,
|
|
1841
|
+
b: CHAIN_CLASS_RE.payment,
|
|
1842
|
+
rationale: (a, b) =>
|
|
1843
|
+
`Use race condition "${a.title}" on financial endpoint "${b.title}" (double-spend / bypass)`,
|
|
1844
|
+
},
|
|
1845
|
+
{
|
|
1846
|
+
pattern: "info_disclosure_ssrf",
|
|
1847
|
+
a: CHAIN_CLASS_RE.infoDisclosure,
|
|
1848
|
+
b: CHAIN_CLASS_RE.ssrf,
|
|
1849
|
+
rationale: (a, b) =>
|
|
1850
|
+
`Use internal URL/config from "${a.title}" as SSRF target via "${b.title}"`,
|
|
1851
|
+
},
|
|
1852
|
+
];
|
|
1853
|
+
|
|
1854
|
+
for (const rule of PAIR_RULES) {
|
|
1855
|
+
const aCases = cases.filter((c) => rule.a.test(chainText(c)));
|
|
1856
|
+
const bCases = cases.filter((c) => rule.b.test(chainText(c)));
|
|
1857
|
+
for (const a of aCases) {
|
|
1858
|
+
for (const b of bCases) {
|
|
1859
|
+
if (a.id === b.id || !sameAssetOrRelated(a, b)) continue;
|
|
1860
|
+
add(rule.pattern, a, b, rule.rationale(a, b), rule.kind);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// SSTI → RCE (single-case escalation)
|
|
1866
|
+
for (const s of cases.filter((c) => hasChainClass(c, CHAIN_CLASS_RE.ssti))) {
|
|
1867
|
+
add("ssti_rce", s, undefined, `Escalate SSTI "${s.title}" to RCE via template-engine gadgets`);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
const scoped = caseId
|
|
1871
|
+
? suggestions.filter((s) => s.sourceId === caseId || s.targetId === caseId)
|
|
1872
|
+
: suggestions;
|
|
1873
|
+
return scoped.sort((a, b) => b.confidence - a.confidence);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// ── Link operations ──────────────────────────────────────────────────
|
|
1877
|
+
|
|
1878
|
+
/** Both cases must exist and be mutable (not killed/reported). */
|
|
1879
|
+
function assertMutablePair(
|
|
1880
|
+
sourceId: string,
|
|
1881
|
+
targetId: string,
|
|
1882
|
+
verb: "link" | "unlink",
|
|
1883
|
+
): { source: CaseRecord; target: CaseRecord } {
|
|
1174
1884
|
const source = getCaseById(sourceId);
|
|
1175
1885
|
const target = getCaseById(targetId);
|
|
1176
1886
|
if (!source) throw new Error(`Case not found: ${sourceId}`);
|
|
1177
1887
|
if (!target) throw new Error(`Case not found: ${targetId}`);
|
|
1178
1888
|
if (source.status === "killed" || source.status === "reported") {
|
|
1179
|
-
throw new Error(`Cannot
|
|
1889
|
+
throw new Error(`Cannot ${verb} terminal case ${sourceId} (${source.status})`);
|
|
1180
1890
|
}
|
|
1181
1891
|
if (target.status === "killed" || target.status === "reported") {
|
|
1182
|
-
throw new Error(`Cannot
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
const checkStmt = db.prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?");
|
|
1186
|
-
const existing = checkStmt.get(sourceId, targetId) as { kind: string } | undefined;
|
|
1187
|
-
|
|
1188
|
-
if (existing) {
|
|
1189
|
-
return {
|
|
1190
|
-
source,
|
|
1191
|
-
target,
|
|
1192
|
-
changed: false,
|
|
1193
|
-
reason: "Cases are already linked",
|
|
1194
|
-
kind: existing.kind,
|
|
1195
|
-
};
|
|
1892
|
+
throw new Error(`Cannot ${verb} terminal case ${targetId} (${target.status})`);
|
|
1196
1893
|
}
|
|
1894
|
+
return { source, target };
|
|
1895
|
+
}
|
|
1197
1896
|
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1897
|
+
/** Run a case_links mutation + updated_at touch inside one transaction. */
|
|
1898
|
+
function withLinkTx(
|
|
1899
|
+
db: DatabaseSync,
|
|
1900
|
+
sourceId: string,
|
|
1901
|
+
targetId: string,
|
|
1902
|
+
mutate: (db: DatabaseSync) => void,
|
|
1903
|
+
): void {
|
|
1202
1904
|
db.exec("BEGIN");
|
|
1203
1905
|
try {
|
|
1204
|
-
|
|
1205
|
-
"INSERT INTO case_links (source_id, target_id, kind) VALUES (?, ?, ?)",
|
|
1206
|
-
);
|
|
1207
|
-
linkStmt.run(sourceId, targetId, resolvedKind);
|
|
1208
|
-
linkStmt.run(targetId, sourceId, inverseKind);
|
|
1209
|
-
|
|
1906
|
+
mutate(db);
|
|
1210
1907
|
const now = new Date().toISOString();
|
|
1211
1908
|
const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
|
|
1212
1909
|
updateTimeStmt.run(now, sourceId);
|
|
@@ -1220,82 +1917,82 @@ export function linkCasesResult(sourceId: string, targetId: string, kind?: strin
|
|
|
1220
1917
|
}
|
|
1221
1918
|
throw err;
|
|
1222
1919
|
}
|
|
1920
|
+
}
|
|
1223
1921
|
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1922
|
+
function existingLinkKind(
|
|
1923
|
+
db: DatabaseSync,
|
|
1924
|
+
sourceId: string,
|
|
1925
|
+
targetId: string,
|
|
1926
|
+
): string | undefined {
|
|
1927
|
+
return (
|
|
1928
|
+
db
|
|
1929
|
+
.prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?")
|
|
1930
|
+
.get(sourceId, targetId) as { kind: string } | undefined
|
|
1931
|
+
)?.kind;
|
|
1227
1932
|
}
|
|
1228
1933
|
|
|
1229
|
-
export function
|
|
1934
|
+
export function linkCasesResult(sourceId: string, targetId: string, kind?: string): CaseLinkResult {
|
|
1230
1935
|
const db = getDb();
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
if (!source) throw new Error(`Case not found: ${sourceId}`);
|
|
1234
|
-
if (!target) throw new Error(`Case not found: ${targetId}`);
|
|
1235
|
-
if (source.status === "killed" || source.status === "reported") {
|
|
1236
|
-
throw new Error(`Cannot unlink terminal case ${sourceId} (${source.status})`);
|
|
1936
|
+
if (sourceId === targetId) {
|
|
1937
|
+
throw new Error("Cannot link a case to itself");
|
|
1237
1938
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1939
|
+
const resolvedKind: CaseLinkKind =
|
|
1940
|
+
kind && (LINK_KIND_VALUES as readonly string[]).includes(kind)
|
|
1941
|
+
? (kind as CaseLinkKind)
|
|
1942
|
+
: DEFAULT_LINK_KIND;
|
|
1943
|
+
const { source, target } = assertMutablePair(sourceId, targetId, "link");
|
|
1944
|
+
|
|
1945
|
+
const existing = existingLinkKind(db, sourceId, targetId);
|
|
1946
|
+
if (existing) {
|
|
1947
|
+
return { source, target, changed: false, reason: "Cases are already linked", kind: existing };
|
|
1240
1948
|
}
|
|
1241
1949
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1950
|
+
// Atomic insert both directions: source→target keeps the stated kind, the
|
|
1951
|
+
// reverse row stores the inverse so each case lists the edge from its own
|
|
1952
|
+
// perspective.
|
|
1953
|
+
const inverseKind = LINK_KIND_INVERSE[resolvedKind];
|
|
1954
|
+
withLinkTx(db, sourceId, targetId, (tx) => {
|
|
1955
|
+
const linkStmt = tx.prepare(
|
|
1956
|
+
"INSERT INTO case_links (source_id, target_id, kind) VALUES (?, ?, ?)",
|
|
1957
|
+
);
|
|
1958
|
+
linkStmt.run(sourceId, targetId, resolvedKind);
|
|
1959
|
+
linkStmt.run(targetId, sourceId, inverseKind);
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
return {
|
|
1963
|
+
source: getCaseById(sourceId)!,
|
|
1964
|
+
target: getCaseById(targetId)!,
|
|
1965
|
+
changed: true,
|
|
1966
|
+
kind: resolvedKind,
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
|
|
1971
|
+
const db = getDb();
|
|
1972
|
+
const { source, target } = assertMutablePair(sourceId, targetId, "unlink");
|
|
1244
1973
|
|
|
1974
|
+
const existing = existingLinkKind(db, sourceId, targetId);
|
|
1245
1975
|
if (!existing) {
|
|
1246
1976
|
return { source, target, changed: false, reason: "Cases are not linked", kind: "related" };
|
|
1247
1977
|
}
|
|
1248
1978
|
|
|
1249
|
-
db
|
|
1250
|
-
|
|
1251
|
-
const unlinkStmt = db.prepare(
|
|
1979
|
+
withLinkTx(db, sourceId, targetId, (tx) => {
|
|
1980
|
+
tx.prepare(
|
|
1252
1981
|
"DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)",
|
|
1253
|
-
);
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const now = new Date().toISOString();
|
|
1257
|
-
const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
|
|
1258
|
-
updateTimeStmt.run(now, sourceId);
|
|
1259
|
-
updateTimeStmt.run(now, targetId);
|
|
1260
|
-
db.exec("COMMIT");
|
|
1261
|
-
} catch (err) {
|
|
1262
|
-
try {
|
|
1263
|
-
db.exec("ROLLBACK");
|
|
1264
|
-
} catch {
|
|
1265
|
-
// ignore
|
|
1266
|
-
}
|
|
1267
|
-
throw err;
|
|
1268
|
-
}
|
|
1982
|
+
).run(sourceId, targetId, targetId, sourceId);
|
|
1983
|
+
});
|
|
1269
1984
|
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1985
|
+
return {
|
|
1986
|
+
source: getCaseById(sourceId)!,
|
|
1987
|
+
target: getCaseById(targetId)!,
|
|
1988
|
+
changed: true,
|
|
1989
|
+
kind: existing,
|
|
1990
|
+
};
|
|
1273
1991
|
}
|
|
1274
1992
|
|
|
1275
1993
|
// ── Search & Queries ─────────────────────────────────────────────────
|
|
1276
1994
|
|
|
1277
|
-
//
|
|
1278
|
-
const SEARCH_COLUMNS = [
|
|
1279
|
-
"title",
|
|
1280
|
-
"summary",
|
|
1281
|
-
"evidence",
|
|
1282
|
-
"impact",
|
|
1283
|
-
"target",
|
|
1284
|
-
"endpoint",
|
|
1285
|
-
"bugClass",
|
|
1286
|
-
"poc",
|
|
1287
|
-
] as const;
|
|
1288
|
-
|
|
1289
|
-
const FIELD_COLUMN: Record<CaseSearchField, string> = {
|
|
1290
|
-
title: "title",
|
|
1291
|
-
summary: "summary",
|
|
1292
|
-
evidence: "evidence",
|
|
1293
|
-
impact: "impact",
|
|
1294
|
-
target: "target",
|
|
1295
|
-
endpoint: "endpoint",
|
|
1296
|
-
bugClass: "bugClass",
|
|
1297
|
-
poc: "poc",
|
|
1298
|
-
};
|
|
1995
|
+
// Search field names double as their column names (SEARCH_FIELD_VALUES above).
|
|
1299
1996
|
|
|
1300
1997
|
function severityRank(s: CaseSeverity): number {
|
|
1301
1998
|
return SEVERITY_VALUES.indexOf(s);
|
|
@@ -1354,12 +2051,12 @@ function buildCaseWhere(options: CaseSearchOptions): {
|
|
|
1354
2051
|
if (query) {
|
|
1355
2052
|
const likeParam = `%${query}%`;
|
|
1356
2053
|
if (options.field) {
|
|
1357
|
-
where.push(`lower(${
|
|
2054
|
+
where.push(`lower(${options.field}) LIKE ?`);
|
|
1358
2055
|
params.push(likeParam);
|
|
1359
2056
|
} else {
|
|
1360
|
-
const ors =
|
|
2057
|
+
const ors = SEARCH_FIELD_VALUES.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
|
|
1361
2058
|
where.push(`(${ors})`);
|
|
1362
|
-
for (let i = 0; i <
|
|
2059
|
+
for (let i = 0; i < SEARCH_FIELD_VALUES.length; i++) params.push(likeParam);
|
|
1363
2060
|
}
|
|
1364
2061
|
}
|
|
1365
2062
|
|
|
@@ -1465,7 +2162,7 @@ export function formatCaseDetail(record: CaseRecord): string {
|
|
|
1465
2162
|
if (
|
|
1466
2163
|
!val ||
|
|
1467
2164
|
(Array.isArray(val) && !val.length) ||
|
|
1468
|
-
["id", "createdAt", "updatedAt"
|
|
2165
|
+
["id", "createdAt", "updatedAt"].includes(key)
|
|
1469
2166
|
)
|
|
1470
2167
|
continue;
|
|
1471
2168
|
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, " $1");
|
|
@@ -1474,6 +2171,17 @@ export function formatCaseDetail(record: CaseRecord): string {
|
|
|
1474
2171
|
display = (val as { id: string; kind: string }[])
|
|
1475
2172
|
.map((l) => `${l.id} (${l.kind})`)
|
|
1476
2173
|
.join(", ");
|
|
2174
|
+
} else if (key === "evidenceItems") {
|
|
2175
|
+
display = (val as EvidenceItem[])
|
|
2176
|
+
.map(
|
|
2177
|
+
(e) =>
|
|
2178
|
+
`[${e.role}] ${e.summary}${e.artifactPath ? ` — \`${e.artifactPath}\` sha256:\`${e.sha256?.slice(0, 12) ?? "?"}\`` : ""} (${e.createdAt})`,
|
|
2179
|
+
)
|
|
2180
|
+
.join("\n");
|
|
2181
|
+
} else if (key === "coverageItems") {
|
|
2182
|
+
display = (val as CoverageItem[])
|
|
2183
|
+
.map((c) => `[${c.scope}] ${c.asset} × ${c.class} — ${c.note}`)
|
|
2184
|
+
.join("\n");
|
|
1477
2185
|
} else if (Array.isArray(val)) {
|
|
1478
2186
|
display = val.join(", ");
|
|
1479
2187
|
} else if (typeof val === "object") {
|
|
@@ -1500,17 +2208,8 @@ function mdSection(title: string, body?: string): string {
|
|
|
1500
2208
|
// pipeline artifacts (recon entry points, traces, skeptic verdicts, PoC logs)
|
|
1501
2209
|
// from any scratchpad run that produced this case.
|
|
1502
2210
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
"hunt",
|
|
1506
|
-
"gapfil",
|
|
1507
|
-
"trace",
|
|
1508
|
-
"skeptic",
|
|
1509
|
-
"validate",
|
|
1510
|
-
"chain",
|
|
1511
|
-
"patch",
|
|
1512
|
-
"report",
|
|
1513
|
-
];
|
|
2211
|
+
// Context bundles cover every pipeline phase (imported from the scratchpad
|
|
2212
|
+
// where the canonical order lives).
|
|
1514
2213
|
|
|
1515
2214
|
/** Per-artifact content cap for the context bundle (generous; artifacts are small). */
|
|
1516
2215
|
const MAX_ARTIFACT_CHARS = 100_000;
|
|
@@ -1521,9 +2220,13 @@ function buildCompleteRecord(current: CaseRecord): string {
|
|
|
1521
2220
|
if (v === undefined || v === null || v === "") continue;
|
|
1522
2221
|
let display = typeof v === "object" ? JSON.stringify(v, null, 2) : String(v);
|
|
1523
2222
|
// Path-leak guard: the verification objects carry the researcher's local
|
|
1524
|
-
// PoC/disconfirmation script paths — show basenames only (the dedicated
|
|
2223
|
+
// PoC/disconfirmation/control script paths — show basenames only (the dedicated
|
|
1525
2224
|
// log sections below already render them as basenames).
|
|
1526
|
-
if (
|
|
2225
|
+
if (
|
|
2226
|
+
(k === "pocVerified" || k === "disconfirmationVerified" || k === "controlVerified") &&
|
|
2227
|
+
v &&
|
|
2228
|
+
typeof v === "object"
|
|
2229
|
+
) {
|
|
1527
2230
|
const redacted = {
|
|
1528
2231
|
...(v as Record<string, unknown>),
|
|
1529
2232
|
path: basename((v as { path?: string }).path ?? ""),
|
|
@@ -1586,7 +2289,7 @@ function buildScratchpadSection(caseId: string): string {
|
|
|
1586
2289
|
if (!allIds.includes(caseId) && !namedInArtifact) continue;
|
|
1587
2290
|
|
|
1588
2291
|
sections.push(`### Run: ${entry.name} (project root: ${resume.checkpoint.project_root})`);
|
|
1589
|
-
for (const phase of
|
|
2292
|
+
for (const phase of PHASE_ORDER) {
|
|
1590
2293
|
const names = resume.artifacts[phase];
|
|
1591
2294
|
if (!names?.length) continue;
|
|
1592
2295
|
sections.push(`#### ${phase}/`);
|
|
@@ -1617,6 +2320,20 @@ export function writeCaseContext(id: string): {
|
|
|
1617
2320
|
throw new Error("Case context requires a confirmed or reported case");
|
|
1618
2321
|
}
|
|
1619
2322
|
|
|
2323
|
+
// Report-time evidence-chain closure: a report bundle for a confirmed case
|
|
2324
|
+
// must carry the full observation → reproduction chain. A confirmed case
|
|
2325
|
+
// without it (e.g. promoted before the gate existed) is not reportable.
|
|
2326
|
+
if (
|
|
2327
|
+
current.status === "confirmed" &&
|
|
2328
|
+
(!current.evidenceItems?.some((e) => e.role === "observation") ||
|
|
2329
|
+
!current.evidenceItems?.some((e) => e.role === "reproduction"))
|
|
2330
|
+
) {
|
|
2331
|
+
throw new Error(
|
|
2332
|
+
`Case ${id} is confirmed but lacks the evidence chain (observation + reproduction items). ` +
|
|
2333
|
+
"Add the missing EvidenceAdd items before generating the report context.",
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
1620
2337
|
const db = getDb();
|
|
1621
2338
|
const dbPath = getCasefilePath();
|
|
1622
2339
|
|
|
@@ -1669,6 +2386,12 @@ export function writeCaseContext(id: string): {
|
|
|
1669
2386
|
`### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Script:** \`${basename(current.pocVerified.path)}\`\n- **Sandbox:** ${current.pocVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.pocVerified.exitCode}\n\n#### Output\n\`\`\`\n${current.pocVerified.output ?? ""}\n\`\`\``,
|
|
1670
2387
|
)
|
|
1671
2388
|
: undefined,
|
|
2389
|
+
current.controlVerified
|
|
2390
|
+
? mdSection(
|
|
2391
|
+
"Control-Target Check (anti-cheat)",
|
|
2392
|
+
`### Control Run Verification\n- **Timestamp:** ${current.controlVerified.ranAt}\n- **Script:** \`${basename(current.controlVerified.path)}\`\n- **Sandbox:** ${current.controlVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.controlVerified.exitCode}\n- **Marker on control:** absent (required) — same PoC against a target lacking the vuln did NOT print the verification marker.\n\n#### Output\n\`\`\`\n${current.controlVerified.output ?? ""}\n\`\`\``,
|
|
2393
|
+
)
|
|
2394
|
+
: undefined,
|
|
1672
2395
|
mdSection("Disconfirmation Attempt", current.disconfirmation),
|
|
1673
2396
|
current.disconfirmationVerified
|
|
1674
2397
|
? mdSection(
|
|
@@ -1677,6 +2400,17 @@ export function writeCaseContext(id: string): {
|
|
|
1677
2400
|
)
|
|
1678
2401
|
: undefined,
|
|
1679
2402
|
mdSection("Impact", current.impact),
|
|
2403
|
+
mdSection(
|
|
2404
|
+
"Evidence Items (role-typed, hashed)",
|
|
2405
|
+
current.evidenceItems.length
|
|
2406
|
+
? current.evidenceItems
|
|
2407
|
+
.map(
|
|
2408
|
+
(e) =>
|
|
2409
|
+
`- [${e.role}] ${e.summary}${e.artifactPath ? ` — artifact \`${e.artifactPath}\` sha256 \`${e.sha256 ?? "?"}\`` : ""} (${e.createdAt})`,
|
|
2410
|
+
)
|
|
2411
|
+
.join("\n")
|
|
2412
|
+
: "None recorded.",
|
|
2413
|
+
),
|
|
1680
2414
|
mdSection("Remediation", current.remediation),
|
|
1681
2415
|
mdSection("Assumptions and Uncertainty", assumptions),
|
|
1682
2416
|
mdSection("References", references),
|