@xaccefy/pi-casefile 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/package.json +63 -0
- package/skills/casefile/SKILL.md +31 -0
- package/src/index.ts +1148 -0
- package/src/ledger.ts +957 -0
- package/src/poc-runner.ts +86 -0
- package/src/sqlite-compat.ts +33 -0
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Casefile SQLite Ledger — SQLite-backed storage engine for offensive security cases.
|
|
3
|
+
*
|
|
4
|
+
* Uses Node.js built-in `node:sqlite` (DatabaseSync) for synchronous,
|
|
5
|
+
* fast, zero-dependency SQLite interactions, perfectly matching Pi Agent's runtime.
|
|
6
|
+
*
|
|
7
|
+
* - Unified schema with structured JSON arrays for tags, blockers, references, assumptions.
|
|
8
|
+
* - Exploit chains stored in a junction table (`case_links`) instead of JSON string arrays.
|
|
9
|
+
* - Simple transaction boundaries for updates, links, promotions.
|
|
10
|
+
* - Auto-indexing on target, status, priority, severity.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DatabaseSync } from "./sqlite-compat.ts";
|
|
14
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join, resolve } from "path";
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
|
|
19
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export const STATUS_VALUES = ["hypothesis", "investigating", "confirmed", "blocked", "killed", "reported"] as const;
|
|
22
|
+
export type CaseStatus = (typeof STATUS_VALUES)[number];
|
|
23
|
+
|
|
24
|
+
export const CONFIDENCE_VALUES = ["low", "medium", "high"] as const;
|
|
25
|
+
export type CaseConfidence = (typeof CONFIDENCE_VALUES)[number];
|
|
26
|
+
|
|
27
|
+
export const SEVERITY_VALUES = ["info", "low", "medium", "high", "critical"] as const;
|
|
28
|
+
export type CaseSeverity = (typeof SEVERITY_VALUES)[number];
|
|
29
|
+
|
|
30
|
+
export const PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "P4"] as const;
|
|
31
|
+
export type CasePriority = (typeof PRIORITY_VALUES)[number];
|
|
32
|
+
|
|
33
|
+
export const SEARCH_FIELD_VALUES = ["title", "summary", "evidence", "impact", "target", "endpoint", "bugClass", "poc"] as const;
|
|
34
|
+
export type CaseSearchField = (typeof SEARCH_FIELD_VALUES)[number];
|
|
35
|
+
|
|
36
|
+
export type CaseRecord = {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
status: CaseStatus;
|
|
40
|
+
confidence: CaseConfidence;
|
|
41
|
+
severity?: CaseSeverity;
|
|
42
|
+
priority?: CasePriority;
|
|
43
|
+
target?: string;
|
|
44
|
+
endpoint?: string;
|
|
45
|
+
bugClass?: string;
|
|
46
|
+
summary?: string;
|
|
47
|
+
evidence?: string;
|
|
48
|
+
impact?: string;
|
|
49
|
+
nextStep?: string;
|
|
50
|
+
poc?: string;
|
|
51
|
+
remediation?: string;
|
|
52
|
+
references?: string[];
|
|
53
|
+
blockers?: string[];
|
|
54
|
+
tags?: string[];
|
|
55
|
+
/** Explicit assumptions or unknowns to avoid overstating exploitability. */
|
|
56
|
+
assumptions?: string[];
|
|
57
|
+
/** Verification of an on-disk PoC run (set only by promoteFindingResult). */
|
|
58
|
+
pocVerified?: { path: string; exitCode: number; ranAt: string; output?: string; sandbox: boolean };
|
|
59
|
+
/** ISO timestamp when CaseReport first wrote the markdown report. */
|
|
60
|
+
reportedAt?: string;
|
|
61
|
+
/** Path to the generated markdown report (set only by writeCaseReport). */
|
|
62
|
+
reportPath?: string;
|
|
63
|
+
linkedCaseIds: string[];
|
|
64
|
+
createdAt: string;
|
|
65
|
+
updatedAt: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type CaseInput = {
|
|
69
|
+
title: string;
|
|
70
|
+
status?: CaseStatus;
|
|
71
|
+
confidence?: CaseConfidence;
|
|
72
|
+
severity?: CaseSeverity;
|
|
73
|
+
priority?: CasePriority;
|
|
74
|
+
target?: string;
|
|
75
|
+
endpoint?: string;
|
|
76
|
+
bugClass?: string;
|
|
77
|
+
summary?: string;
|
|
78
|
+
evidence?: string;
|
|
79
|
+
impact?: string;
|
|
80
|
+
nextStep?: string;
|
|
81
|
+
poc?: string;
|
|
82
|
+
remediation?: string;
|
|
83
|
+
references?: string[];
|
|
84
|
+
blockers?: string[];
|
|
85
|
+
tags?: string[];
|
|
86
|
+
assumptions?: string[];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type NormalizedCaseInput = Partial<CaseInput> & {
|
|
90
|
+
linkedCaseIds?: string[];
|
|
91
|
+
pocVerified?: CaseRecord["pocVerified"];
|
|
92
|
+
reportedAt?: string;
|
|
93
|
+
reportPath?: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type CaseUpdate = Partial<CaseInput>;
|
|
97
|
+
|
|
98
|
+
export type CaseUpdateResult = {
|
|
99
|
+
record: CaseRecord;
|
|
100
|
+
changed: boolean;
|
|
101
|
+
reason?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type CaseAddResult = {
|
|
105
|
+
record: CaseRecord;
|
|
106
|
+
created: boolean;
|
|
107
|
+
reason?: string;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type CaseLinkResult = {
|
|
111
|
+
source: CaseRecord;
|
|
112
|
+
target: CaseRecord;
|
|
113
|
+
changed: boolean;
|
|
114
|
+
reason?: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type CaseSearchOptions = {
|
|
118
|
+
query?: string;
|
|
119
|
+
field?: CaseSearchField;
|
|
120
|
+
status?: CaseStatus;
|
|
121
|
+
confidence?: CaseConfidence;
|
|
122
|
+
severity?: CaseSeverity;
|
|
123
|
+
priority?: CasePriority;
|
|
124
|
+
tag?: string;
|
|
125
|
+
limit?: number;
|
|
126
|
+
offset?: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ── Globals & Environment ─────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
let ledgerPathOverride: string | undefined;
|
|
132
|
+
let dbInstance: DatabaseSync | undefined;
|
|
133
|
+
|
|
134
|
+
function nowIso(): string {
|
|
135
|
+
return new Date().toISOString();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeList(values: string[] | undefined): string[] {
|
|
139
|
+
return Array.from(
|
|
140
|
+
new Set((values ?? []).map((v) => v.trim()).filter(Boolean)),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeText(value: string | undefined): string | undefined {
|
|
145
|
+
const trimmed = value?.trim();
|
|
146
|
+
return trimmed || undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeMatchText(value: string | undefined): string {
|
|
150
|
+
return normalizeText(value)?.toLowerCase().replace(/\s+/g, " ") ?? "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stableShortId(input: string): string {
|
|
154
|
+
return createHash("sha1").update(input).digest("hex").slice(0, 10);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function firstEnv(...names: string[]): { name: string; value: string } | undefined {
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
const value = process.env[name]?.trim();
|
|
160
|
+
if (value) return { name, value };
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function detectWorkspaceRoot(): string {
|
|
166
|
+
const envs = ["CASEFILE_WORKSPACE_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE", "PWD"];
|
|
167
|
+
for (const e of envs) if (process.env[e]) return resolve(process.env[e]!);
|
|
168
|
+
|
|
169
|
+
let curr = resolve(process.cwd());
|
|
170
|
+
for (let i = 0; i < 20; i++) {
|
|
171
|
+
if (existsSync(join(curr, ".git"))) return curr;
|
|
172
|
+
const parent = dirname(curr);
|
|
173
|
+
if (parent === curr) break;
|
|
174
|
+
curr = parent;
|
|
175
|
+
}
|
|
176
|
+
return resolve(process.cwd());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getCasefilePath(): string {
|
|
180
|
+
if (ledgerPathOverride) return ledgerPathOverride;
|
|
181
|
+
|
|
182
|
+
const explicitPath = firstEnv("CASEFILE_PATH", "PI_CASEFILE_PATH");
|
|
183
|
+
if (explicitPath) return resolve(explicitPath.value);
|
|
184
|
+
|
|
185
|
+
const scopeConfig = firstEnv("CASEFILE_SCOPE", "PI_CASEFILE_SCOPE");
|
|
186
|
+
const scope = (scopeConfig?.value.toLowerCase() || "project");
|
|
187
|
+
|
|
188
|
+
if (scope === "global" && scopeConfig?.name === "PI_CASEFILE_SCOPE") {
|
|
189
|
+
return join(homedir(), ".pi", "casefile", "casefile.db");
|
|
190
|
+
}
|
|
191
|
+
if (scope === "global") {
|
|
192
|
+
return join(homedir(), ".casefile", "casefile.db");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const projectDir = scopeConfig?.name === "CASEFILE_SCOPE"
|
|
196
|
+
? ".casefile"
|
|
197
|
+
: ".pi";
|
|
198
|
+
|
|
199
|
+
return join(detectWorkspaceRoot(), projectDir, "casefile.db");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function setCasefilePath(path: string | undefined): void {
|
|
203
|
+
ledgerPathOverride = path;
|
|
204
|
+
if (dbInstance) {
|
|
205
|
+
dbInstance = undefined; // Force reconnection on next getDb
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── SQLite Schema Init ────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
function getDb(): DatabaseSync {
|
|
212
|
+
if (dbInstance) return dbInstance;
|
|
213
|
+
|
|
214
|
+
const dbPath = getCasefilePath();
|
|
215
|
+
const dbDir = dirname(dbPath);
|
|
216
|
+
if (!existsSync(dbDir)) {
|
|
217
|
+
try {
|
|
218
|
+
mkdirSync(dbDir, { recursive: true });
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const db = new DatabaseSync(dbPath);
|
|
223
|
+
|
|
224
|
+
// Create tables
|
|
225
|
+
db.exec(`
|
|
226
|
+
CREATE TABLE IF NOT EXISTS cases (
|
|
227
|
+
id TEXT PRIMARY KEY,
|
|
228
|
+
title TEXT NOT NULL,
|
|
229
|
+
status TEXT NOT NULL,
|
|
230
|
+
confidence TEXT NOT NULL,
|
|
231
|
+
severity TEXT,
|
|
232
|
+
priority TEXT,
|
|
233
|
+
target TEXT,
|
|
234
|
+
endpoint TEXT,
|
|
235
|
+
bugClass TEXT,
|
|
236
|
+
summary TEXT,
|
|
237
|
+
evidence TEXT,
|
|
238
|
+
impact TEXT,
|
|
239
|
+
nextStep TEXT,
|
|
240
|
+
poc TEXT,
|
|
241
|
+
remediation TEXT,
|
|
242
|
+
references_json TEXT, -- JSON string array
|
|
243
|
+
blockers_json TEXT, -- JSON string array
|
|
244
|
+
tags_json TEXT, -- JSON string array
|
|
245
|
+
assumptions_json TEXT, -- JSON string array
|
|
246
|
+
poc_verified_json TEXT, -- JSON object
|
|
247
|
+
reported_at TEXT,
|
|
248
|
+
report_path TEXT,
|
|
249
|
+
created_at TEXT NOT NULL,
|
|
250
|
+
updated_at TEXT NOT NULL
|
|
251
|
+
)
|
|
252
|
+
`);
|
|
253
|
+
|
|
254
|
+
db.exec(`
|
|
255
|
+
CREATE TABLE IF NOT EXISTS case_links (
|
|
256
|
+
source_id TEXT,
|
|
257
|
+
target_id TEXT,
|
|
258
|
+
PRIMARY KEY (source_id, target_id),
|
|
259
|
+
FOREIGN KEY (source_id) REFERENCES cases(id) ON DELETE CASCADE,
|
|
260
|
+
FOREIGN KEY (target_id) REFERENCES cases(id) ON DELETE CASCADE
|
|
261
|
+
)
|
|
262
|
+
`);
|
|
263
|
+
|
|
264
|
+
// Indexes
|
|
265
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status)`);
|
|
266
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_target ON cases(target)`);
|
|
267
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_severity ON cases(severity)`);
|
|
268
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_priority ON cases(priority)`);
|
|
269
|
+
|
|
270
|
+
dbInstance = db;
|
|
271
|
+
return db;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Helper to map DB row to CaseRecord
|
|
275
|
+
function mapRow(row: any, linkedCaseIds: string[] = []): CaseRecord {
|
|
276
|
+
/** Safely parse a JSON column; returns [] for arrays, undefined for objects. */
|
|
277
|
+
const safeParseArray = (raw: unknown): string[] => {
|
|
278
|
+
if (!raw) return [];
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(raw as string);
|
|
281
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
282
|
+
} catch {
|
|
283
|
+
// Corrupted JSON — return empty rather than crashing the entire read
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const safeParseObject = <T>(raw: unknown): T | undefined => {
|
|
288
|
+
if (!raw) return undefined;
|
|
289
|
+
try {
|
|
290
|
+
return JSON.parse(raw as string) as T;
|
|
291
|
+
} catch {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
id: row.id,
|
|
298
|
+
title: row.title,
|
|
299
|
+
status: row.status as CaseStatus,
|
|
300
|
+
confidence: row.confidence as CaseConfidence,
|
|
301
|
+
severity: row.severity as CaseSeverity | undefined,
|
|
302
|
+
priority: row.priority as CasePriority | undefined,
|
|
303
|
+
target: row.target || undefined,
|
|
304
|
+
endpoint: row.endpoint || undefined,
|
|
305
|
+
bugClass: row.bugClass || undefined,
|
|
306
|
+
summary: row.summary || undefined,
|
|
307
|
+
evidence: row.evidence || undefined,
|
|
308
|
+
impact: row.impact || undefined,
|
|
309
|
+
nextStep: row.nextStep || undefined,
|
|
310
|
+
poc: row.poc || undefined,
|
|
311
|
+
remediation: row.remediation || undefined,
|
|
312
|
+
references: safeParseArray(row.references_json),
|
|
313
|
+
blockers: safeParseArray(row.blockers_json),
|
|
314
|
+
tags: safeParseArray(row.tags_json),
|
|
315
|
+
assumptions: safeParseArray(row.assumptions_json),
|
|
316
|
+
pocVerified: safeParseObject(row.poc_verified_json),
|
|
317
|
+
reportedAt: row.reported_at || undefined,
|
|
318
|
+
reportPath: row.report_path || undefined,
|
|
319
|
+
linkedCaseIds,
|
|
320
|
+
createdAt: row.created_at,
|
|
321
|
+
updatedAt: row.updated_at,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Read operations ──────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
export function readCasefile(): CaseRecord[] {
|
|
328
|
+
const db = getDb();
|
|
329
|
+
|
|
330
|
+
// Read all cases
|
|
331
|
+
const stmt = db.prepare("SELECT * FROM cases");
|
|
332
|
+
const rows = stmt.all();
|
|
333
|
+
|
|
334
|
+
// Read all links to construct linkedCaseIds map
|
|
335
|
+
const linkStmt = db.prepare("SELECT source_id, target_id FROM case_links");
|
|
336
|
+
const links = linkStmt.all() as { source_id: string; target_id: string }[];
|
|
337
|
+
|
|
338
|
+
const linkMap = new Map<string, string[]>();
|
|
339
|
+
for (const link of links) {
|
|
340
|
+
if (!linkMap.has(link.source_id)) linkMap.set(link.source_id, []);
|
|
341
|
+
linkMap.get(link.source_id)!.push(link.target_id);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return rows.map((row: any) => mapRow(row, linkMap.get(row.id) ?? []));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function getCaseById(id: string): CaseRecord | undefined {
|
|
348
|
+
const db = getDb();
|
|
349
|
+
const stmt = db.prepare("SELECT * FROM cases WHERE id = ?");
|
|
350
|
+
const row = stmt.get(id);
|
|
351
|
+
if (!row) return undefined;
|
|
352
|
+
|
|
353
|
+
const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
|
|
354
|
+
const links = linkStmt.all(id) as { target_id: string }[];
|
|
355
|
+
|
|
356
|
+
return mapRow(row, links.map(l => l.target_id));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Validation ────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
function validateCase(record: CaseRecord): void {
|
|
362
|
+
if (!record.title.trim()) throw new Error("Case title cannot be empty");
|
|
363
|
+
if (record.status === "confirmed" && (!record.evidence || !record.poc)) {
|
|
364
|
+
throw new Error("Confirmed cases require both evidence and poc");
|
|
365
|
+
}
|
|
366
|
+
if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
|
|
367
|
+
throw new Error("Blocked cases require at least one blocker");
|
|
368
|
+
}
|
|
369
|
+
if (
|
|
370
|
+
record.status === "killed" &&
|
|
371
|
+
!record.evidence &&
|
|
372
|
+
!record.nextStep &&
|
|
373
|
+
(record.blockers ?? []).length === 0 &&
|
|
374
|
+
(record.assumptions ?? []).length === 0
|
|
375
|
+
) {
|
|
376
|
+
throw new Error("Killed cases require evidence, next step, blockers, or assumptions explaining why");
|
|
377
|
+
}
|
|
378
|
+
if (record.status === "reported" && !record.poc && !record.remediation && (record.references ?? []).length === 0) {
|
|
379
|
+
throw new Error("Reported cases require poc, remediation, or references");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function validateTransition(
|
|
384
|
+
from: CaseStatus,
|
|
385
|
+
to: CaseStatus,
|
|
386
|
+
update: CaseUpdate,
|
|
387
|
+
current?: CaseRecord,
|
|
388
|
+
): void {
|
|
389
|
+
if (from === to) return;
|
|
390
|
+
|
|
391
|
+
if (from === "killed") {
|
|
392
|
+
throw new Error(`Cannot revive a killed case; open a new case if the lead is revived (was ${from} → ${to})`);
|
|
393
|
+
}
|
|
394
|
+
if (from === "reported") {
|
|
395
|
+
throw new Error(`Cannot mutate a reported case; file a follow-up case instead (was ${from} → ${to})`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (to === "killed") return;
|
|
399
|
+
if (to === "blocked") return;
|
|
400
|
+
|
|
401
|
+
type Rule = (u: CaseUpdate, current?: CaseRecord) => string | null;
|
|
402
|
+
const transitions: Partial<Record<CaseStatus, Partial<Record<CaseStatus, Rule>>>> = {
|
|
403
|
+
hypothesis: {
|
|
404
|
+
investigating: (u) =>
|
|
405
|
+
!u.evidence ? "INVESTIGATING requires evidence (source→sink trace)" :
|
|
406
|
+
!u.confidence ? "INVESTIGATING requires confidence level" :
|
|
407
|
+
null,
|
|
408
|
+
confirmed: () => "Cannot jump hypothesis → confirmed; promote to investigating first",
|
|
409
|
+
reported: () => "Cannot jump hypothesis → reported; confirm first",
|
|
410
|
+
},
|
|
411
|
+
investigating: {
|
|
412
|
+
confirmed: () => "investigating → confirmed requires a verified PoC run; use the promote_finding tool",
|
|
413
|
+
hypothesis: () => null,
|
|
414
|
+
},
|
|
415
|
+
confirmed: {
|
|
416
|
+
reported: (_, current) =>
|
|
417
|
+
!current?.reportPath
|
|
418
|
+
? "confirmed → reported requires a report; run CaseReport first"
|
|
419
|
+
: null,
|
|
420
|
+
investigating: () => null,
|
|
421
|
+
},
|
|
422
|
+
blocked: {
|
|
423
|
+
investigating: (u) =>
|
|
424
|
+
!u.evidence ? "INVESTIGATING requires evidence (source→sink trace)" :
|
|
425
|
+
!u.confidence ? "INVESTIGATING requires confidence level" :
|
|
426
|
+
null,
|
|
427
|
+
hypothesis: () => null,
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const rule = transitions[from]?.[to];
|
|
432
|
+
if (rule === undefined) {
|
|
433
|
+
throw new Error(`Invalid transition: ${from} → ${to}`);
|
|
434
|
+
}
|
|
435
|
+
const reason = rule(update, current);
|
|
436
|
+
if (reason) {
|
|
437
|
+
throw new Error(`Cannot transition ${from} → ${to}: ${reason}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function validateNewCaseInput(input: CaseInput): void {
|
|
442
|
+
if (input.status && input.status !== "hypothesis" && input.status !== "investigating") {
|
|
443
|
+
throw new Error("New cases must start as hypothesis or investigating; promote with CaseUpdate after validation");
|
|
444
|
+
}
|
|
445
|
+
if (input.status === "investigating") {
|
|
446
|
+
if (!input.evidence) {
|
|
447
|
+
throw new Error("New investigating cases require evidence (source→sink trace)");
|
|
448
|
+
}
|
|
449
|
+
if (!input.confidence) {
|
|
450
|
+
throw new Error("New investigating cases require a confidence level");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function buildRecord(
|
|
456
|
+
input: NormalizedCaseInput,
|
|
457
|
+
existing?: CaseRecord,
|
|
458
|
+
): CaseRecord {
|
|
459
|
+
const timestamp = nowIso();
|
|
460
|
+
const title = ("title" in input ? input.title : existing?.title)?.trim() ?? "";
|
|
461
|
+
const id =
|
|
462
|
+
existing?.id ??
|
|
463
|
+
`case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
id,
|
|
467
|
+
title,
|
|
468
|
+
status: input.status ?? existing?.status ?? "hypothesis",
|
|
469
|
+
confidence: input.confidence ?? existing?.confidence ?? "low",
|
|
470
|
+
severity: input.severity ?? existing?.severity,
|
|
471
|
+
priority: input.priority ?? existing?.priority,
|
|
472
|
+
target: input.target !== undefined ? normalizeText(input.target) : existing?.target,
|
|
473
|
+
endpoint: input.endpoint !== undefined ? normalizeText(input.endpoint) : existing?.endpoint,
|
|
474
|
+
bugClass: input.bugClass !== undefined ? normalizeText(input.bugClass) : existing?.bugClass,
|
|
475
|
+
summary: input.summary !== undefined ? normalizeText(input.summary) : existing?.summary,
|
|
476
|
+
evidence: input.evidence !== undefined ? normalizeText(input.evidence) : existing?.evidence,
|
|
477
|
+
impact: input.impact !== undefined ? normalizeText(input.impact) : existing?.impact,
|
|
478
|
+
nextStep: input.nextStep !== undefined ? normalizeText(input.nextStep) : existing?.nextStep,
|
|
479
|
+
poc: input.poc !== undefined ? normalizeText(input.poc) : existing?.poc,
|
|
480
|
+
remediation: input.remediation !== undefined ? normalizeText(input.remediation) : existing?.remediation,
|
|
481
|
+
references: normalizeList(input.references ?? existing?.references),
|
|
482
|
+
blockers: normalizeList(input.blockers ?? existing?.blockers),
|
|
483
|
+
tags: normalizeList(input.tags ?? existing?.tags),
|
|
484
|
+
assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
|
|
485
|
+
pocVerified: input.pocVerified ?? existing?.pocVerified,
|
|
486
|
+
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
487
|
+
reportPath: input.reportPath ?? existing?.reportPath,
|
|
488
|
+
linkedCaseIds: existing?.linkedCaseIds ?? [],
|
|
489
|
+
createdAt: existing?.createdAt ?? timestamp,
|
|
490
|
+
updatedAt: timestamp,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRecord | undefined {
|
|
495
|
+
const title = normalizeMatchText(candidate.title);
|
|
496
|
+
if (!title) return undefined;
|
|
497
|
+
|
|
498
|
+
const target = normalizeMatchText(candidate.target);
|
|
499
|
+
const endpoint = normalizeMatchText(candidate.endpoint);
|
|
500
|
+
const bugClass = normalizeMatchText(candidate.bugClass);
|
|
501
|
+
|
|
502
|
+
// We query all cases where status is not killed, then match in JS for normalized forms
|
|
503
|
+
const stmt = db.prepare("SELECT * FROM cases WHERE status != 'killed'");
|
|
504
|
+
const rows = stmt.all();
|
|
505
|
+
|
|
506
|
+
for (const row of rows) {
|
|
507
|
+
if (
|
|
508
|
+
normalizeMatchText(row.title as string) === title &&
|
|
509
|
+
normalizeMatchText(row.target as string) === target &&
|
|
510
|
+
normalizeMatchText(row.endpoint as string) === endpoint &&
|
|
511
|
+
normalizeMatchText(row.bugClass as string) === bugClass
|
|
512
|
+
) {
|
|
513
|
+
// Find links
|
|
514
|
+
const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
|
|
515
|
+
const links = linkStmt.all(row.id) as { target_id: string }[];
|
|
516
|
+
return mapRow(row, links.map(l => l.target_id));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ── SQLite Mutation Actions ───────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
function insertOrReplaceCase(db: DatabaseSync, record: CaseRecord) {
|
|
525
|
+
const stmt = db.prepare(`
|
|
526
|
+
INSERT OR REPLACE INTO cases (
|
|
527
|
+
id, title, status, confidence, severity, priority, target, endpoint, bugClass,
|
|
528
|
+
summary, evidence, impact, nextStep, poc, remediation,
|
|
529
|
+
references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
|
|
530
|
+
reported_at, report_path, created_at, updated_at
|
|
531
|
+
) VALUES (
|
|
532
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
533
|
+
?, ?, ?, ?, ?, ?,
|
|
534
|
+
?, ?, ?, ?, ?,
|
|
535
|
+
?, ?, ?, ?
|
|
536
|
+
)
|
|
537
|
+
`);
|
|
538
|
+
|
|
539
|
+
stmt.run(
|
|
540
|
+
record.id,
|
|
541
|
+
record.title,
|
|
542
|
+
record.status,
|
|
543
|
+
record.confidence,
|
|
544
|
+
record.severity || null,
|
|
545
|
+
record.priority || null,
|
|
546
|
+
record.target || null,
|
|
547
|
+
record.endpoint || null,
|
|
548
|
+
record.bugClass || null,
|
|
549
|
+
record.summary || null,
|
|
550
|
+
record.evidence || null,
|
|
551
|
+
record.impact || null,
|
|
552
|
+
record.nextStep || null,
|
|
553
|
+
record.poc || null,
|
|
554
|
+
record.remediation || null,
|
|
555
|
+
JSON.stringify(record.references),
|
|
556
|
+
JSON.stringify(record.blockers),
|
|
557
|
+
JSON.stringify(record.tags),
|
|
558
|
+
JSON.stringify(record.assumptions),
|
|
559
|
+
record.pocVerified ? JSON.stringify(record.pocVerified) : null,
|
|
560
|
+
record.reportedAt || null,
|
|
561
|
+
record.reportPath || null,
|
|
562
|
+
record.createdAt,
|
|
563
|
+
record.updatedAt
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function addCaseResult(input: CaseInput): CaseAddResult {
|
|
568
|
+
const db = getDb();
|
|
569
|
+
validateNewCaseInput(input);
|
|
570
|
+
const record = buildRecord(input, undefined);
|
|
571
|
+
validateCase(record);
|
|
572
|
+
|
|
573
|
+
// Check duplicates
|
|
574
|
+
const duplicate = findDuplicateCaseInDb(db, record);
|
|
575
|
+
if (duplicate) {
|
|
576
|
+
return {
|
|
577
|
+
record: duplicate,
|
|
578
|
+
created: false,
|
|
579
|
+
reason: `Duplicate case exists: ${duplicate.id}`,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
insertOrReplaceCase(db, record);
|
|
584
|
+
return { record, created: true };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function updateCaseResult(
|
|
588
|
+
id: string,
|
|
589
|
+
update: CaseUpdate,
|
|
590
|
+
): CaseUpdateResult {
|
|
591
|
+
const db = getDb();
|
|
592
|
+
const current = getCaseById(id);
|
|
593
|
+
if (!current) {
|
|
594
|
+
throw new Error(`Case not found: ${id}`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const optionalFields = ["title", "target", "endpoint", "bugClass", "summary", "evidence", "impact", "nextStep", "poc", "remediation"] as const;
|
|
598
|
+
const optionalPatch: Record<string, unknown> = {};
|
|
599
|
+
for (const field of optionalFields) {
|
|
600
|
+
if (field in update && update[field] !== undefined) {
|
|
601
|
+
optionalPatch[field] = update[field];
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const next = buildRecord(
|
|
606
|
+
{
|
|
607
|
+
...optionalPatch,
|
|
608
|
+
status: update.status ?? current.status,
|
|
609
|
+
confidence: update.confidence ?? current.confidence,
|
|
610
|
+
severity: update.severity ?? current.severity,
|
|
611
|
+
priority: update.priority ?? current.priority,
|
|
612
|
+
references: update.references ?? current.references,
|
|
613
|
+
blockers: update.blockers ?? current.blockers,
|
|
614
|
+
tags: update.tags ?? current.tags,
|
|
615
|
+
assumptions: update.assumptions ?? current.assumptions,
|
|
616
|
+
},
|
|
617
|
+
current
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
if (update.status && update.status !== current.status) {
|
|
621
|
+
validateTransition(current.status, next.status, update, current);
|
|
622
|
+
}
|
|
623
|
+
validateCase(next);
|
|
624
|
+
|
|
625
|
+
// Check material equality (we ignore links since links are mutated via CaseLink)
|
|
626
|
+
const norm = (r: CaseRecord) => JSON.stringify({ ...r, updatedAt: "", createdAt: "", linkedCaseIds: [] });
|
|
627
|
+
if (norm(current) === norm(next)) {
|
|
628
|
+
const reason = update.status && update.status === current.status
|
|
629
|
+
? `Case is already ${current.status}; no material fields changed.`
|
|
630
|
+
: "No material fields changed.";
|
|
631
|
+
return { record: current, changed: false, reason };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Duplicate checks excluding current
|
|
635
|
+
const title = normalizeMatchText(next.title);
|
|
636
|
+
const target = normalizeMatchText(next.target);
|
|
637
|
+
const endpoint = normalizeMatchText(next.endpoint);
|
|
638
|
+
const bugClass = normalizeMatchText(next.bugClass);
|
|
639
|
+
|
|
640
|
+
const stmt = db.prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?");
|
|
641
|
+
const rows = stmt.all(id);
|
|
642
|
+
let duplicate: CaseRecord | undefined;
|
|
643
|
+
for (const row of rows) {
|
|
644
|
+
if (
|
|
645
|
+
normalizeMatchText(row.title as string) === title &&
|
|
646
|
+
normalizeMatchText(row.target as string) === target &&
|
|
647
|
+
normalizeMatchText(row.endpoint as string) === endpoint &&
|
|
648
|
+
normalizeMatchText(row.bugClass as string) === bugClass
|
|
649
|
+
) {
|
|
650
|
+
const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
|
|
651
|
+
const links = linkStmt.all(row.id) as { target_id: string }[];
|
|
652
|
+
duplicate = mapRow(row, links.map(l => l.target_id));
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (duplicate) {
|
|
658
|
+
return {
|
|
659
|
+
record: current,
|
|
660
|
+
changed: false,
|
|
661
|
+
reason: `Update would create a duplicate of case ${duplicate.id}`,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
insertOrReplaceCase(db, next);
|
|
666
|
+
return { record: next, changed: true };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
type PocVerification = {
|
|
670
|
+
path: string;
|
|
671
|
+
exitCode: number;
|
|
672
|
+
ranAt: string;
|
|
673
|
+
output?: string;
|
|
674
|
+
sandbox: boolean;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
export function promoteFindingResult(
|
|
678
|
+
id: string,
|
|
679
|
+
verification: PocVerification,
|
|
680
|
+
): CaseUpdateResult {
|
|
681
|
+
const db = getDb();
|
|
682
|
+
const current = getCaseById(id);
|
|
683
|
+
if (!current) {
|
|
684
|
+
throw new Error(`Case not found: ${id}`);
|
|
685
|
+
}
|
|
686
|
+
if (current.status !== "investigating") {
|
|
687
|
+
throw new Error(`promote_finding requires an investigating case (current: ${current.status})`);
|
|
688
|
+
}
|
|
689
|
+
if (!current.poc) {
|
|
690
|
+
throw new Error("CONFIRMED requires poc; set poc on the case first");
|
|
691
|
+
}
|
|
692
|
+
if (!current.evidence) {
|
|
693
|
+
throw new Error("CONFIRMED requires evidence; set evidence on the case first");
|
|
694
|
+
}
|
|
695
|
+
if (!current.impact) {
|
|
696
|
+
throw new Error("CONFIRMED requires impact; set impact on the case first");
|
|
697
|
+
}
|
|
698
|
+
if (!current.severity) {
|
|
699
|
+
throw new Error("CONFIRMED requires severity; set severity on the case first");
|
|
700
|
+
}
|
|
701
|
+
if (verification.exitCode !== 0) {
|
|
702
|
+
throw new Error(`PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const next = buildRecord(
|
|
706
|
+
{
|
|
707
|
+
status: "confirmed",
|
|
708
|
+
pocVerified: verification,
|
|
709
|
+
},
|
|
710
|
+
current
|
|
711
|
+
);
|
|
712
|
+
validateCase(next);
|
|
713
|
+
|
|
714
|
+
insertOrReplaceCase(db, next);
|
|
715
|
+
return { record: next, changed: true };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── Link operations ──────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
export function linkCasesResult(
|
|
721
|
+
sourceId: string,
|
|
722
|
+
targetId: string,
|
|
723
|
+
): CaseLinkResult {
|
|
724
|
+
const db = getDb();
|
|
725
|
+
if (sourceId === targetId) {
|
|
726
|
+
throw new Error("Cannot link a case to itself");
|
|
727
|
+
}
|
|
728
|
+
const source = getCaseById(sourceId);
|
|
729
|
+
const target = getCaseById(targetId);
|
|
730
|
+
if (!source) throw new Error(`Case not found: ${sourceId}`);
|
|
731
|
+
if (!target) throw new Error(`Case not found: ${targetId}`);
|
|
732
|
+
|
|
733
|
+
const checkStmt = db.prepare("SELECT 1 FROM case_links WHERE source_id = ? AND target_id = ?");
|
|
734
|
+
const exists = checkStmt.get(sourceId, targetId);
|
|
735
|
+
|
|
736
|
+
if (exists) {
|
|
737
|
+
return { source, target, changed: false, reason: "Cases are already linked" };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Atomic insert both directions into junction table
|
|
741
|
+
const linkStmt = db.prepare("INSERT INTO case_links (source_id, target_id) VALUES (?, ?)");
|
|
742
|
+
linkStmt.run(sourceId, targetId);
|
|
743
|
+
linkStmt.run(targetId, sourceId);
|
|
744
|
+
|
|
745
|
+
const now = nowIso();
|
|
746
|
+
const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
|
|
747
|
+
updateTimeStmt.run(now, sourceId);
|
|
748
|
+
updateTimeStmt.run(now, targetId);
|
|
749
|
+
|
|
750
|
+
const finalSource = getCaseById(sourceId)!;
|
|
751
|
+
const finalTarget = getCaseById(targetId)!;
|
|
752
|
+
return { source: finalSource, target: finalTarget, changed: true };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export function unlinkCasesResult(
|
|
756
|
+
sourceId: string,
|
|
757
|
+
targetId: string,
|
|
758
|
+
): CaseLinkResult {
|
|
759
|
+
const db = getDb();
|
|
760
|
+
const source = getCaseById(sourceId);
|
|
761
|
+
const target = getCaseById(targetId);
|
|
762
|
+
if (!source) throw new Error(`Case not found: ${sourceId}`);
|
|
763
|
+
if (!target) throw new Error(`Case not found: ${targetId}`);
|
|
764
|
+
|
|
765
|
+
const checkStmt = db.prepare("SELECT 1 FROM case_links WHERE source_id = ? AND target_id = ?");
|
|
766
|
+
const exists = checkStmt.get(sourceId, targetId);
|
|
767
|
+
|
|
768
|
+
if (!exists) {
|
|
769
|
+
return { source, target, changed: false, reason: "Cases are not linked" };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const unlinkStmt = db.prepare("DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)");
|
|
773
|
+
unlinkStmt.run(sourceId, targetId, targetId, sourceId);
|
|
774
|
+
|
|
775
|
+
const now = nowIso();
|
|
776
|
+
const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
|
|
777
|
+
updateTimeStmt.run(now, sourceId);
|
|
778
|
+
updateTimeStmt.run(now, targetId);
|
|
779
|
+
|
|
780
|
+
const finalSource = getCaseById(sourceId)!;
|
|
781
|
+
const finalTarget = getCaseById(targetId)!;
|
|
782
|
+
return { source: finalSource, target: finalTarget, changed: true };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── Search & Queries ─────────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
function caseHaystack(
|
|
788
|
+
record: CaseRecord,
|
|
789
|
+
field?: CaseSearchField,
|
|
790
|
+
): string {
|
|
791
|
+
if (field) {
|
|
792
|
+
const val = record[field];
|
|
793
|
+
if (Array.isArray(val)) return val.join(" ").toLowerCase();
|
|
794
|
+
return (typeof val === "string" ? val : String(val ?? "")).toLowerCase();
|
|
795
|
+
}
|
|
796
|
+
return Object.entries(record)
|
|
797
|
+
.filter(([k]) => !["id", "createdAt", "updatedAt", "reportedAt", "reportPath"].includes(k))
|
|
798
|
+
.map(([, v]) => (Array.isArray(v) ? v.join(" ") : String(v ?? "")))
|
|
799
|
+
.filter(Boolean)
|
|
800
|
+
.join("\n")
|
|
801
|
+
.toLowerCase();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export function searchCases(
|
|
805
|
+
options: CaseSearchOptions = {},
|
|
806
|
+
): { cases: CaseRecord[]; total: number } {
|
|
807
|
+
const query = options.query?.trim().toLowerCase();
|
|
808
|
+
const field = options.field;
|
|
809
|
+
const tag = options.tag?.trim().toLowerCase();
|
|
810
|
+
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
811
|
+
const offset = Math.max(0, options.offset ?? 0);
|
|
812
|
+
|
|
813
|
+
const STATUS_ORDER: CaseStatus[] = [
|
|
814
|
+
"hypothesis",
|
|
815
|
+
"investigating",
|
|
816
|
+
"confirmed",
|
|
817
|
+
"blocked",
|
|
818
|
+
"killed",
|
|
819
|
+
"reported",
|
|
820
|
+
];
|
|
821
|
+
|
|
822
|
+
const filtered = readCasefile()
|
|
823
|
+
.filter((r) => !options.status || r.status === options.status)
|
|
824
|
+
.filter((r) => !options.confidence || r.confidence === options.confidence)
|
|
825
|
+
.filter((r) => !options.severity || r.severity === options.severity)
|
|
826
|
+
.filter((r) => !options.priority || r.priority === options.priority)
|
|
827
|
+
.filter(
|
|
828
|
+
(r) => !tag || r.tags?.some((t) => t.toLowerCase() === tag),
|
|
829
|
+
)
|
|
830
|
+
.filter((r) => !query || caseHaystack(r, field).includes(query))
|
|
831
|
+
.sort((a, b) => {
|
|
832
|
+
const aStatus = STATUS_ORDER.indexOf(a.status);
|
|
833
|
+
const bStatus = STATUS_ORDER.indexOf(b.status);
|
|
834
|
+
if (aStatus !== bStatus) return aStatus - bStatus;
|
|
835
|
+
return b.updatedAt.localeCompare(a.updatedAt);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
total: filtered.length,
|
|
840
|
+
cases: filtered.slice(offset, offset + limit),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export function countCases(): {
|
|
845
|
+
total: number;
|
|
846
|
+
byStatus: Record<string, number>;
|
|
847
|
+
bySeverity: Record<string, number>;
|
|
848
|
+
} {
|
|
849
|
+
const records = readCasefile();
|
|
850
|
+
const byStatus: Record<string, number> = {};
|
|
851
|
+
const bySeverity: Record<string, number> = {};
|
|
852
|
+
for (const r of records) {
|
|
853
|
+
byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
|
|
854
|
+
if (r.severity) bySeverity[r.severity] = (bySeverity[r.severity] ?? 0) + 1;
|
|
855
|
+
}
|
|
856
|
+
return { total: records.length, byStatus, bySeverity };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ── Format helpers ───────────────────────────────────────────────────
|
|
860
|
+
|
|
861
|
+
export function formatCase(record: CaseRecord): string {
|
|
862
|
+
const bits = [
|
|
863
|
+
`${record.id} [${record.status}/${record.confidence}] ${record.title}`,
|
|
864
|
+
record.priority ? `priority=${record.priority}` : undefined,
|
|
865
|
+
record.severity ? `severity=${record.severity}` : undefined,
|
|
866
|
+
record.bugClass ? `class=${record.bugClass}` : undefined,
|
|
867
|
+
record.summary ? `summary=${record.summary}` : undefined,
|
|
868
|
+
record.endpoint ? `endpoint=${record.endpoint}` : undefined,
|
|
869
|
+
record.target ? `target=${record.target}` : undefined,
|
|
870
|
+
record.tags?.length ? `tags=${record.tags.join(",")}` : undefined,
|
|
871
|
+
record.linkedCaseIds.length
|
|
872
|
+
? `links=${record.linkedCaseIds.join(",")}`
|
|
873
|
+
: undefined,
|
|
874
|
+
record.nextStep ? `next=${record.nextStep}` : undefined,
|
|
875
|
+
].filter(Boolean);
|
|
876
|
+
return bits.join(" | ");
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
export function formatCases(records: CaseRecord[]): string {
|
|
880
|
+
if (records.length === 0) return "No cases recorded.";
|
|
881
|
+
return records.map(formatCase).join("\n");
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export function formatCaseDetail(record: CaseRecord): string {
|
|
885
|
+
const lines = [`═══ ${record.id} ═══`];
|
|
886
|
+
for (const [key, val] of Object.entries(record)) {
|
|
887
|
+
if (!val || (Array.isArray(val) && !val.length) || ["id", "createdAt", "updatedAt"].includes(key)) continue;
|
|
888
|
+
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, " $1");
|
|
889
|
+
const display = Array.isArray(val)
|
|
890
|
+
? val.join(", ")
|
|
891
|
+
: typeof val === "object"
|
|
892
|
+
? JSON.stringify(val)
|
|
893
|
+
: val;
|
|
894
|
+
lines.push(`${label.padEnd(12)} ${display}`);
|
|
895
|
+
}
|
|
896
|
+
return lines.concat([`Created: ${record.createdAt}`, `Updated: ${record.updatedAt}`]).join("\n");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function slugify(value: string): string {
|
|
900
|
+
return value
|
|
901
|
+
.toLowerCase()
|
|
902
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
903
|
+
.replace(/^-+|-+$/g, "")
|
|
904
|
+
.slice(0, 70) || "case";
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function mdSection(title: string, body?: string): string {
|
|
908
|
+
return `## ${title}\n\n${body?.trim() || "Not recorded."}\n`;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export function writeCaseReport(id: string): { path: string; record: CaseRecord } {
|
|
912
|
+
const current = getCaseById(id);
|
|
913
|
+
if (!current) throw new Error(`Case not found: ${id}`);
|
|
914
|
+
if (current.status !== "confirmed" && current.status !== "reported") {
|
|
915
|
+
throw new Error("Case reports require a confirmed or reported case");
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const db = getDb();
|
|
919
|
+
const dbPath = getCasefilePath();
|
|
920
|
+
|
|
921
|
+
const reportDir = join(dirname(dbPath), "report");
|
|
922
|
+
mkdirSync(reportDir, { recursive: true });
|
|
923
|
+
|
|
924
|
+
const reportPath = join(reportDir, `${slugify(current.title)}-${current.id}.md`);
|
|
925
|
+
const references = current.references?.length ? current.references.map((r) => `- ${r}`).join("\n") : undefined;
|
|
926
|
+
const assumptions = current.assumptions?.length ? current.assumptions.map((a) => `- ${a}`).join("\n") : undefined;
|
|
927
|
+
const body = [
|
|
928
|
+
`# ${current.title}`,
|
|
929
|
+
`**Severity:** ${current.severity ?? "Not assessed"}`,
|
|
930
|
+
`**Status:** ${current.status}`,
|
|
931
|
+
`**Confidence:** ${current.confidence}`,
|
|
932
|
+
current.priority ? `**Priority:** ${current.priority}` : undefined,
|
|
933
|
+
current.target ? `**Target:** ${current.target}` : undefined,
|
|
934
|
+
current.endpoint ? `**Endpoint:** ${current.endpoint}` : undefined,
|
|
935
|
+
current.bugClass ? `**Bug class:** ${current.bugClass}` : undefined,
|
|
936
|
+
"",
|
|
937
|
+
mdSection("Summary", current.summary),
|
|
938
|
+
mdSection("Steps to Reproduce / Evidence", current.evidence),
|
|
939
|
+
mdSection("Proof of Concept", current.poc),
|
|
940
|
+
mdSection("Impact", current.impact),
|
|
941
|
+
mdSection("Remediation", current.remediation),
|
|
942
|
+
mdSection("Assumptions and Uncertainty", assumptions),
|
|
943
|
+
mdSection("References", references),
|
|
944
|
+
].filter(Boolean).join("\n");
|
|
945
|
+
|
|
946
|
+
writeFileSync(reportPath, body, "utf8");
|
|
947
|
+
|
|
948
|
+
const next: CaseRecord = {
|
|
949
|
+
...current,
|
|
950
|
+
reportPath,
|
|
951
|
+
reportedAt: current.reportedAt ?? nowIso(),
|
|
952
|
+
updatedAt: nowIso(),
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
insertOrReplaceCase(db, next);
|
|
956
|
+
return { path: reportPath, record: next };
|
|
957
|
+
}
|