@xaccefy/pi-casefile 0.1.9 → 0.2.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/package.json +1 -1
- package/src/index.ts +29 -11
- package/src/ledger.ts +176 -57
- package/src/poc-runner.ts +41 -8
- package/src/sqlite-compat/index.ts +30 -5
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -122,8 +122,15 @@ const ListSchema = Type.Object(
|
|
|
122
122
|
status: Type.Optional(CaseStatusSchema),
|
|
123
123
|
confidence: Type.Optional(CaseConfidenceSchema),
|
|
124
124
|
severity: Type.Optional(CaseSeveritySchema),
|
|
125
|
+
minSeverity: Type.Optional(CaseSeveritySchema),
|
|
125
126
|
priority: Type.Optional(CasePrioritySchema),
|
|
126
127
|
tag: Type.Optional(Type.String({ description: "Filter by tag" })),
|
|
128
|
+
since: Type.Optional(
|
|
129
|
+
Type.String({ description: "ISO timestamp; only cases created at/after this time" }),
|
|
130
|
+
),
|
|
131
|
+
until: Type.Optional(
|
|
132
|
+
Type.String({ description: "ISO timestamp; only cases created at/before this time" }),
|
|
133
|
+
),
|
|
127
134
|
limit: Type.Optional(Type.Number({ description: "Max results (default 50)" })),
|
|
128
135
|
offset: Type.Optional(Type.Number({ description: "Skip N results for pagination" })),
|
|
129
136
|
},
|
|
@@ -146,8 +153,15 @@ const SearchSchema = Type.Object(
|
|
|
146
153
|
status: Type.Optional(CaseStatusSchema),
|
|
147
154
|
confidence: Type.Optional(CaseConfidenceSchema),
|
|
148
155
|
severity: Type.Optional(CaseSeveritySchema),
|
|
156
|
+
minSeverity: Type.Optional(CaseSeveritySchema),
|
|
149
157
|
priority: Type.Optional(CasePrioritySchema),
|
|
150
|
-
tag: Type.Optional(Type.String()),
|
|
158
|
+
tag: Type.Optional(Type.String({ description: "Filter by tag" })),
|
|
159
|
+
since: Type.Optional(
|
|
160
|
+
Type.String({ description: "ISO timestamp; only cases created at/after this time" }),
|
|
161
|
+
),
|
|
162
|
+
until: Type.Optional(
|
|
163
|
+
Type.String({ description: "ISO timestamp; only cases created at/before this time" }),
|
|
164
|
+
),
|
|
151
165
|
limit: Type.Optional(Type.Number()),
|
|
152
166
|
offset: Type.Optional(Type.Number()),
|
|
153
167
|
},
|
|
@@ -241,15 +255,15 @@ function renderCaseResult(
|
|
|
241
255
|
theme: Theme,
|
|
242
256
|
successPrefix = "✓ ",
|
|
243
257
|
failPrefix = "✗ ",
|
|
244
|
-
):
|
|
258
|
+
): string {
|
|
245
259
|
const details = result.details as { record?: CaseRecord; changed?: boolean } | undefined;
|
|
246
260
|
if (!details?.record) {
|
|
247
|
-
return
|
|
261
|
+
return theme.fg("error", "✗ Failed");
|
|
248
262
|
}
|
|
249
263
|
const success = details.changed !== false;
|
|
250
264
|
const prefix = success ? successPrefix : failPrefix;
|
|
251
265
|
const color = success ? "success" : "warning";
|
|
252
|
-
return
|
|
266
|
+
return theme.fg(color, prefix) + renderOneLine(details.record, theme);
|
|
253
267
|
}
|
|
254
268
|
|
|
255
269
|
// ── Dashboard component ──────────────────────────────────────────────
|
|
@@ -409,7 +423,7 @@ function buildCaseContext(records: CaseRecord[]): string {
|
|
|
409
423
|
if (records.length === 0) return "";
|
|
410
424
|
|
|
411
425
|
const safe = (v?: string, max = 160) => {
|
|
412
|
-
const controlChars =
|
|
426
|
+
const controlChars = /[\r\n\t\u0000-\u001F\u007F\u2028\u2029]+/g;
|
|
413
427
|
const s = v
|
|
414
428
|
?.replace(controlChars, " ")
|
|
415
429
|
.replace(/[<>]/g, (c) => (c === "<" ? "‹" : "›"))
|
|
@@ -547,8 +561,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
547
561
|
renderResult(result, { expanded }, theme) {
|
|
548
562
|
const details = result.details as { created?: boolean; record?: CaseRecord };
|
|
549
563
|
const created = details?.created;
|
|
550
|
-
|
|
551
|
-
let line = baseText.toString();
|
|
564
|
+
let line = renderCaseResult(result, theme, created === false ? "↻ " : "✓ ");
|
|
552
565
|
if (expanded && details?.record) {
|
|
553
566
|
line += `\n${theme.fg("dim", ` ${details.record.id} → ${details.record.nextStep ?? "no next step"}`)}`;
|
|
554
567
|
}
|
|
@@ -603,8 +616,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
603
616
|
renderResult(result, { expanded }, theme) {
|
|
604
617
|
const details = result.details as { changed?: boolean; record?: CaseRecord; reason?: string };
|
|
605
618
|
const unchanged = details?.changed === false;
|
|
606
|
-
|
|
607
|
-
let line = baseText.toString();
|
|
619
|
+
let line = renderCaseResult(result, theme, unchanged ? "↷ " : "✓ ");
|
|
608
620
|
if (expanded && details?.record) {
|
|
609
621
|
line +=
|
|
610
622
|
"\n" +
|
|
@@ -672,7 +684,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
672
684
|
renderResult(result, _options, theme) {
|
|
673
685
|
const details = result.details as { run?: { exitCode: number } } | undefined;
|
|
674
686
|
const success = details?.run?.exitCode === 0;
|
|
675
|
-
return renderCaseResult(result, theme, success ? "✓ " : "✗ ", "✗ ");
|
|
687
|
+
return new Text(renderCaseResult(result, theme, success ? "✓ " : "✗ ", "✗ "), 0, 0);
|
|
676
688
|
},
|
|
677
689
|
});
|
|
678
690
|
|
|
@@ -705,7 +717,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
705
717
|
},
|
|
706
718
|
|
|
707
719
|
renderResult(result, _options, theme) {
|
|
708
|
-
return renderCaseResult(result, theme, "", "");
|
|
720
|
+
return new Text(renderCaseResult(result, theme, "", ""), 0, 0);
|
|
709
721
|
},
|
|
710
722
|
});
|
|
711
723
|
|
|
@@ -727,8 +739,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
727
739
|
status: params.status as CaseStatus | undefined,
|
|
728
740
|
confidence: params.confidence as CaseConfidence | undefined,
|
|
729
741
|
severity: params.severity as CaseSeverity | undefined,
|
|
742
|
+
minSeverity: params.minSeverity as CaseSeverity | undefined,
|
|
730
743
|
priority: params.priority as CasePriority | undefined,
|
|
731
744
|
tag: params.tag,
|
|
745
|
+
since: params.since as string | undefined,
|
|
746
|
+
until: params.until as string | undefined,
|
|
732
747
|
limit: params.limit,
|
|
733
748
|
offset: params.offset,
|
|
734
749
|
});
|
|
@@ -774,8 +789,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
774
789
|
status: params.status as CaseStatus | undefined,
|
|
775
790
|
confidence: params.confidence as CaseConfidence | undefined,
|
|
776
791
|
severity: params.severity as CaseSeverity | undefined,
|
|
792
|
+
minSeverity: params.minSeverity as CaseSeverity | undefined,
|
|
777
793
|
priority: params.priority as CasePriority | undefined,
|
|
778
794
|
tag: params.tag,
|
|
795
|
+
since: params.since as string | undefined,
|
|
796
|
+
until: params.until as string | undefined,
|
|
779
797
|
limit: params.limit,
|
|
780
798
|
offset: params.offset,
|
|
781
799
|
});
|
package/src/ledger.ts
CHANGED
|
@@ -141,8 +141,14 @@ export type CaseSearchOptions = {
|
|
|
141
141
|
status?: CaseStatus;
|
|
142
142
|
confidence?: CaseConfidence;
|
|
143
143
|
severity?: CaseSeverity;
|
|
144
|
+
/** Return only cases at or above this severity (info < low < medium < high < critical). */
|
|
145
|
+
minSeverity?: CaseSeverity;
|
|
144
146
|
priority?: CasePriority;
|
|
145
147
|
tag?: string;
|
|
148
|
+
/** ISO timestamp; only cases created at/after this time. */
|
|
149
|
+
since?: string;
|
|
150
|
+
/** ISO timestamp; only cases created at/before this time. */
|
|
151
|
+
until?: string;
|
|
146
152
|
limit?: number;
|
|
147
153
|
offset?: number;
|
|
148
154
|
};
|
|
@@ -190,10 +196,15 @@ export function getCasefilePath(): string {
|
|
|
190
196
|
}
|
|
191
197
|
|
|
192
198
|
export function setCasefilePath(path: string | undefined): void {
|
|
193
|
-
ledgerPathOverride = path;
|
|
194
199
|
if (dbInstance) {
|
|
195
|
-
|
|
200
|
+
try {
|
|
201
|
+
dbInstance.close();
|
|
202
|
+
} catch {
|
|
203
|
+
// Best-effort close.
|
|
204
|
+
}
|
|
196
205
|
}
|
|
206
|
+
ledgerPathOverride = path;
|
|
207
|
+
dbInstance = undefined; // Force reconnection on next getDb
|
|
197
208
|
}
|
|
198
209
|
|
|
199
210
|
// ── SQLite Schema Init ────────────────────────────────────────────────
|
|
@@ -210,6 +221,9 @@ function getDb(): DatabaseSync {
|
|
|
210
221
|
}
|
|
211
222
|
|
|
212
223
|
const db = new DatabaseSync(dbPath);
|
|
224
|
+
// Enable foreign-key enforcement so ON DELETE CASCADE actually fires
|
|
225
|
+
// (SQLite keeps FK off by default; bun:sqlite in particular defaults it off).
|
|
226
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
213
227
|
|
|
214
228
|
// Create tables
|
|
215
229
|
db.exec(`
|
|
@@ -353,8 +367,13 @@ export function getCaseById(id: string): CaseRecord | undefined {
|
|
|
353
367
|
|
|
354
368
|
function validateCase(record: CaseRecord): void {
|
|
355
369
|
if (!record.title.trim()) throw new Error("Case title cannot be empty");
|
|
356
|
-
|
|
357
|
-
|
|
370
|
+
// Keep this gate in lockstep with promoteFindingResult: a case may only be
|
|
371
|
+
// CONFIRMED when it has evidence, a PoC, demonstrated impact, and a severity.
|
|
372
|
+
if (
|
|
373
|
+
record.status === "confirmed" &&
|
|
374
|
+
(!record.evidence || !record.poc || !record.impact || !record.severity)
|
|
375
|
+
) {
|
|
376
|
+
throw new Error("Confirmed cases require evidence, poc, impact, and severity");
|
|
358
377
|
}
|
|
359
378
|
if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
|
|
360
379
|
throw new Error("Blocked cases require at least one blocker");
|
|
@@ -370,13 +389,10 @@ function validateCase(record: CaseRecord): void {
|
|
|
370
389
|
"Killed cases require evidence, next step, blockers, or assumptions explaining why",
|
|
371
390
|
);
|
|
372
391
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
(record.references ?? []).length === 0
|
|
378
|
-
) {
|
|
379
|
-
throw new Error("Reported cases require poc, remediation, or references");
|
|
392
|
+
// A case becomes REPORTED only via CaseReport, which records reportPath. Require it
|
|
393
|
+
// here so validation stays consistent with the confirmed→reported transition gate.
|
|
394
|
+
if (record.status === "reported" && !record.reportPath) {
|
|
395
|
+
throw new Error("Reported cases require a generated report (run CaseReport first)");
|
|
380
396
|
}
|
|
381
397
|
}
|
|
382
398
|
|
|
@@ -724,10 +740,18 @@ export function promoteFindingResult(id: string, verification: PocVerification):
|
|
|
724
740
|
);
|
|
725
741
|
}
|
|
726
742
|
|
|
743
|
+
const newEvidence =
|
|
744
|
+
(current.evidence ? current.evidence + "\n\n" : "") +
|
|
745
|
+
`### PoC Execution Capture (${verification.ranAt})\n` +
|
|
746
|
+
`- **Exit Code:** ${verification.exitCode}\n` +
|
|
747
|
+
`- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
|
|
748
|
+
`#### Execution Output\n\`\`\`\n${verification.output ?? ""}\n\`\`\``;
|
|
749
|
+
|
|
727
750
|
const next = buildRecord(
|
|
728
751
|
{
|
|
729
752
|
status: "confirmed",
|
|
730
753
|
pocVerified: verification,
|
|
754
|
+
evidence: newEvidence,
|
|
731
755
|
},
|
|
732
756
|
current,
|
|
733
757
|
);
|
|
@@ -802,57 +826,138 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
|
|
|
802
826
|
|
|
803
827
|
// ── Search & Queries ─────────────────────────────────────────────────
|
|
804
828
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
829
|
+
// Searchable text columns (excludes ids/timestamps/JSON arrays for performance + signal).
|
|
830
|
+
const SEARCH_COLUMNS = [
|
|
831
|
+
"title",
|
|
832
|
+
"summary",
|
|
833
|
+
"evidence",
|
|
834
|
+
"impact",
|
|
835
|
+
"target",
|
|
836
|
+
"endpoint",
|
|
837
|
+
"bugClass",
|
|
838
|
+
"poc",
|
|
839
|
+
] as const;
|
|
840
|
+
|
|
841
|
+
const FIELD_COLUMN: Record<CaseSearchField, string> = {
|
|
842
|
+
title: "title",
|
|
843
|
+
summary: "summary",
|
|
844
|
+
evidence: "evidence",
|
|
845
|
+
impact: "impact",
|
|
846
|
+
target: "target",
|
|
847
|
+
endpoint: "endpoint",
|
|
848
|
+
bugClass: "bugClass",
|
|
849
|
+
poc: "poc",
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
function severityRank(s: CaseSeverity): number {
|
|
853
|
+
return SEVERITY_VALUES.indexOf(s);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Build a parameterized WHERE clause + params for case queries. Pushes all
|
|
858
|
+
* structured filters (and free-text) into SQL so we never load the whole ledger
|
|
859
|
+
* into memory just to filter it in JS. Also returns a stable ORDER BY that keeps
|
|
860
|
+
* the original status precedence (hypothesis first) with updated_at as tiebreak.
|
|
861
|
+
*/
|
|
862
|
+
function buildCaseWhere(options: CaseSearchOptions): {
|
|
863
|
+
whereSql: string;
|
|
864
|
+
orderSql: string;
|
|
865
|
+
params: unknown[];
|
|
866
|
+
} {
|
|
867
|
+
const where: string[] = [];
|
|
868
|
+
const params: unknown[] = [];
|
|
869
|
+
|
|
870
|
+
if (options.status) {
|
|
871
|
+
where.push("status = ?");
|
|
872
|
+
params.push(options.status);
|
|
810
873
|
}
|
|
811
|
-
|
|
812
|
-
.
|
|
813
|
-
.
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
.
|
|
874
|
+
if (options.confidence) {
|
|
875
|
+
where.push("confidence = ?");
|
|
876
|
+
params.push(options.confidence);
|
|
877
|
+
}
|
|
878
|
+
if (options.severity) {
|
|
879
|
+
where.push("severity = ?");
|
|
880
|
+
params.push(options.severity);
|
|
881
|
+
}
|
|
882
|
+
if (options.minSeverity) {
|
|
883
|
+
where.push(
|
|
884
|
+
"severity IS NOT NULL AND (CASE severity WHEN 'info' THEN 0 WHEN 'low' THEN 1 WHEN 'medium' THEN 2 WHEN 'high' THEN 3 WHEN 'critical' THEN 4 ELSE -1 END) >= ?",
|
|
885
|
+
);
|
|
886
|
+
params.push(severityRank(options.minSeverity));
|
|
887
|
+
}
|
|
888
|
+
if (options.priority) {
|
|
889
|
+
where.push("priority = ?");
|
|
890
|
+
params.push(options.priority);
|
|
891
|
+
}
|
|
892
|
+
if (options.tag) {
|
|
893
|
+
where.push("EXISTS (SELECT 1 FROM json_each(tags_json) WHERE lower(value) = ?)");
|
|
894
|
+
params.push(options.tag.trim().toLowerCase());
|
|
895
|
+
}
|
|
896
|
+
if (options.since) {
|
|
897
|
+
where.push("created_at >= ?");
|
|
898
|
+
params.push(options.since);
|
|
899
|
+
}
|
|
900
|
+
if (options.until) {
|
|
901
|
+
where.push("created_at <= ?");
|
|
902
|
+
params.push(options.until);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const query = options.query?.trim().toLowerCase();
|
|
906
|
+
if (query) {
|
|
907
|
+
const likeParam = `%${query}%`;
|
|
908
|
+
if (options.field) {
|
|
909
|
+
where.push(`lower(${FIELD_COLUMN[options.field]}) LIKE ?`);
|
|
910
|
+
params.push(likeParam);
|
|
911
|
+
} else {
|
|
912
|
+
const ors = SEARCH_COLUMNS.map((c) => `lower(${c}) LIKE ?`).join(" OR ");
|
|
913
|
+
where.push(`(${ors})`);
|
|
914
|
+
for (let i = 0; i < SEARCH_COLUMNS.length; i++) params.push(likeParam);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const orderSql =
|
|
919
|
+
"CASE status WHEN 'hypothesis' THEN 0 WHEN 'investigating' THEN 1 WHEN 'confirmed' THEN 2 " +
|
|
920
|
+
"WHEN 'blocked' THEN 3 WHEN 'killed' THEN 4 WHEN 'reported' THEN 5 ELSE 6 END, updated_at DESC";
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
whereSql: where.length ? `WHERE ${where.join(" AND ")}` : "",
|
|
924
|
+
orderSql,
|
|
925
|
+
params,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Map DB rows to CaseRecords, attaching linkedCaseIds fetched in a single batch. */
|
|
930
|
+
function mapRowsWithLinks(db: DatabaseSync, rows: any[]): CaseRecord[] {
|
|
931
|
+
if (rows.length === 0) return [];
|
|
932
|
+
const ids = rows.map((r) => r.id);
|
|
933
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
934
|
+
const links = db
|
|
935
|
+
.prepare(`SELECT source_id, target_id FROM case_links WHERE source_id IN (${placeholders})`)
|
|
936
|
+
.all(...ids) as { source_id: string; target_id: string }[];
|
|
937
|
+
const linkMap = new Map<string, string[]>();
|
|
938
|
+
for (const l of links) {
|
|
939
|
+
if (!linkMap.has(l.source_id)) linkMap.set(l.source_id, []);
|
|
940
|
+
linkMap.get(l.source_id)!.push(l.target_id);
|
|
941
|
+
}
|
|
942
|
+
return rows.map((row) => mapRow(row, linkMap.get(row.id) ?? []));
|
|
817
943
|
}
|
|
818
944
|
|
|
819
945
|
export function searchCases(options: CaseSearchOptions = {}): {
|
|
820
946
|
cases: CaseRecord[];
|
|
821
947
|
total: number;
|
|
822
948
|
} {
|
|
823
|
-
const
|
|
824
|
-
const field = options.field;
|
|
825
|
-
const tag = options.tag?.trim().toLowerCase();
|
|
949
|
+
const db = getDb();
|
|
826
950
|
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
827
951
|
const offset = Math.max(0, options.offset ?? 0);
|
|
828
952
|
|
|
829
|
-
const
|
|
830
|
-
"hypothesis",
|
|
831
|
-
"investigating",
|
|
832
|
-
"confirmed",
|
|
833
|
-
"blocked",
|
|
834
|
-
"killed",
|
|
835
|
-
"reported",
|
|
836
|
-
];
|
|
837
|
-
|
|
838
|
-
const filtered = readCasefile()
|
|
839
|
-
.filter((r) => !options.status || r.status === options.status)
|
|
840
|
-
.filter((r) => !options.confidence || r.confidence === options.confidence)
|
|
841
|
-
.filter((r) => !options.severity || r.severity === options.severity)
|
|
842
|
-
.filter((r) => !options.priority || r.priority === options.priority)
|
|
843
|
-
.filter((r) => !tag || r.tags?.some((t) => t.toLowerCase() === tag))
|
|
844
|
-
.filter((r) => !query || caseHaystack(r, field).includes(query))
|
|
845
|
-
.sort((a, b) => {
|
|
846
|
-
const aStatus = STATUS_ORDER.indexOf(a.status);
|
|
847
|
-
const bStatus = STATUS_ORDER.indexOf(b.status);
|
|
848
|
-
if (aStatus !== bStatus) return aStatus - bStatus;
|
|
849
|
-
return b.updatedAt.localeCompare(a.updatedAt);
|
|
850
|
-
});
|
|
953
|
+
const { whereSql, orderSql, params } = buildCaseWhere(options);
|
|
851
954
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
955
|
+
const total = (db.prepare(`SELECT COUNT(*) as c FROM cases ${whereSql}`).get(...params) as any).c;
|
|
956
|
+
const rows = db
|
|
957
|
+
.prepare(`SELECT * FROM cases ${whereSql} ORDER BY ${orderSql} LIMIT ? OFFSET ?`)
|
|
958
|
+
.all(...params, limit, offset) as any[];
|
|
959
|
+
|
|
960
|
+
return { total, cases: mapRowsWithLinks(db, rows) };
|
|
856
961
|
}
|
|
857
962
|
|
|
858
963
|
export function countCases(): {
|
|
@@ -860,14 +965,22 @@ export function countCases(): {
|
|
|
860
965
|
byStatus: Record<string, number>;
|
|
861
966
|
bySeverity: Record<string, number>;
|
|
862
967
|
} {
|
|
863
|
-
const
|
|
968
|
+
const db = getDb();
|
|
969
|
+
const total = (db.prepare("SELECT COUNT(*) as c FROM cases").get() as any).c;
|
|
970
|
+
const statusRows = db
|
|
971
|
+
.prepare("SELECT status, COUNT(*) as n FROM cases GROUP BY status")
|
|
972
|
+
.all() as { status: string; n: number }[];
|
|
973
|
+
const severityRows = db
|
|
974
|
+
.prepare(
|
|
975
|
+
"SELECT severity, COUNT(*) as n FROM cases WHERE severity IS NOT NULL GROUP BY severity",
|
|
976
|
+
)
|
|
977
|
+
.all() as { severity: string; n: number }[];
|
|
978
|
+
|
|
864
979
|
const byStatus: Record<string, number> = {};
|
|
865
980
|
const bySeverity: Record<string, number> = {};
|
|
866
|
-
for (const r of
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
}
|
|
870
|
-
return { total: records.length, byStatus, bySeverity };
|
|
981
|
+
for (const r of statusRows) byStatus[r.status] = r.n;
|
|
982
|
+
for (const r of severityRows) bySeverity[r.severity] = r.n;
|
|
983
|
+
return { total, byStatus, bySeverity };
|
|
871
984
|
}
|
|
872
985
|
|
|
873
986
|
// ── Format helpers ───────────────────────────────────────────────────
|
|
@@ -958,6 +1071,12 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
958
1071
|
mdSection("Summary", current.summary),
|
|
959
1072
|
mdSection("Steps to Reproduce / Evidence", current.evidence),
|
|
960
1073
|
mdSection("Proof of Concept", current.poc),
|
|
1074
|
+
current.pocVerified
|
|
1075
|
+
? mdSection(
|
|
1076
|
+
"PoC Verification Log",
|
|
1077
|
+
`### PoC Run Verification\n- **Timestamp:** ${current.pocVerified.ranAt}\n- **Path:** \`${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\`\`\``,
|
|
1078
|
+
)
|
|
1079
|
+
: undefined,
|
|
961
1080
|
mdSection("Impact", current.impact),
|
|
962
1081
|
mdSection("Remediation", current.remediation),
|
|
963
1082
|
mdSection("Assumptions and Uncertainty", assumptions),
|
package/src/poc-runner.ts
CHANGED
|
@@ -239,6 +239,12 @@ function buildDockerArgs(image: string, command: string, workspaceDir: string):
|
|
|
239
239
|
"no-new-privileges",
|
|
240
240
|
"--user",
|
|
241
241
|
"1000:1000",
|
|
242
|
+
"--memory",
|
|
243
|
+
"256m",
|
|
244
|
+
"--pids-limit",
|
|
245
|
+
"128",
|
|
246
|
+
"--cpus",
|
|
247
|
+
"1.0",
|
|
242
248
|
"-v",
|
|
243
249
|
`${workspaceDir}:/workspace:rw`,
|
|
244
250
|
image,
|
|
@@ -260,6 +266,27 @@ function renderCommand(template: string, pocPath: string, inSandbox: boolean): s
|
|
|
260
266
|
.replace(/{{class}}/g, className);
|
|
261
267
|
}
|
|
262
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Translate a spawnSync result into a robust exit code.
|
|
271
|
+
*
|
|
272
|
+
* `spawnSync` returns `status: null` AND `signal: null` when it cannot even start
|
|
273
|
+
* the child (e.g. the binary is missing → ENOENT, or the docker daemon is
|
|
274
|
+
* unavailable). The previous `result.status ?? (result.signal ? 1 : 0)` then
|
|
275
|
+
* collapsed to `0`, making a never-executed PoC look successful — which let
|
|
276
|
+
* PromoteFinding promote an investigating case to CONFIRMED without the PoC
|
|
277
|
+
* ever running. We fail closed: a spawn error or a missing status/signal is
|
|
278
|
+
* always a non-zero exit.
|
|
279
|
+
*/
|
|
280
|
+
function spawnExitCode(result: {
|
|
281
|
+
status: number | null;
|
|
282
|
+
signal: string | null;
|
|
283
|
+
error?: Error;
|
|
284
|
+
}): number {
|
|
285
|
+
if (result.error) return 127;
|
|
286
|
+
if (result.status !== null) return result.status;
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
|
|
263
290
|
function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
264
291
|
const ranAt = new Date().toISOString();
|
|
265
292
|
const sourceName = basename(pocPath);
|
|
@@ -283,10 +310,11 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
283
310
|
maxBuffer: MAX_BUFFER,
|
|
284
311
|
});
|
|
285
312
|
|
|
286
|
-
const
|
|
313
|
+
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
314
|
+
const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
|
|
287
315
|
return {
|
|
288
316
|
path: pocPath,
|
|
289
|
-
exitCode:
|
|
317
|
+
exitCode: spawnExitCode(result),
|
|
290
318
|
output,
|
|
291
319
|
ranAt,
|
|
292
320
|
sandbox: true,
|
|
@@ -314,11 +342,15 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
314
342
|
throw new Error("Language config has no run command");
|
|
315
343
|
}
|
|
316
344
|
|
|
317
|
-
//
|
|
345
|
+
// The run template is `<interpreter> <file>`. Preserve multi-arg run commands for
|
|
346
|
+
// normal paths, but keep a space-containing PoC path as a single argument (no shell
|
|
347
|
+
// is used, so args are passed verbatim).
|
|
318
348
|
const command = renderCommand(language.run, pocPath, false);
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
349
|
+
const trimmed = command.trim();
|
|
350
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
351
|
+
const interpreter = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
|
|
352
|
+
const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1);
|
|
353
|
+
const args = pocPath.includes(" ") ? (rest ? [rest] : []) : rest ? rest.split(" ") : [];
|
|
322
354
|
|
|
323
355
|
const result = spawnSync(interpreter, args, {
|
|
324
356
|
encoding: "utf8",
|
|
@@ -326,10 +358,11 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
326
358
|
maxBuffer: MAX_BUFFER,
|
|
327
359
|
});
|
|
328
360
|
|
|
329
|
-
const
|
|
361
|
+
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
362
|
+
const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
|
|
330
363
|
return {
|
|
331
364
|
path: pocPath,
|
|
332
|
-
exitCode:
|
|
365
|
+
exitCode: spawnExitCode(result),
|
|
333
366
|
output,
|
|
334
367
|
ranAt,
|
|
335
368
|
sandbox: false,
|
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
|
|
2
3
|
const _require = createRequire(import.meta.url);
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
// biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability requires any casting
|
|
6
|
+
let DatabaseSyncConstructor: any;
|
|
5
7
|
try {
|
|
6
|
-
|
|
8
|
+
// biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
|
|
9
|
+
DatabaseSyncConstructor = _require("bun:sqlite").Database as any;
|
|
7
10
|
} catch {
|
|
8
|
-
|
|
11
|
+
try {
|
|
12
|
+
// biome-ignore lint/suspicious/noExplicitAny: Runtime module swappability
|
|
13
|
+
DatabaseSyncConstructor = (_require("node:sqlite") as any).DatabaseSync;
|
|
14
|
+
} catch (e) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"XPI requires bun:sqlite (Bun runtime) or node:sqlite (Node >= 22.5). " +
|
|
17
|
+
`Neither SQLite backend is available: ${(e as Error).message}`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
|
|
23
|
+
export interface StatementSync {
|
|
24
|
+
run(...args: unknown[]): { lastInsertRowid: number; changes: number };
|
|
25
|
+
// biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
|
|
26
|
+
get(...args: unknown[]): any;
|
|
27
|
+
// biome-ignore lint/suspicious/noExplicitAny: Standard SQLite API returns any
|
|
28
|
+
all(...args: unknown[]): any[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DatabaseSync {
|
|
32
|
+
prepare(sql: string): StatementSync;
|
|
33
|
+
exec(sql: string): void;
|
|
34
|
+
close(): void;
|
|
9
35
|
}
|
|
10
36
|
|
|
11
|
-
export
|
|
12
|
-
export type DatabaseSync = any;
|
|
37
|
+
export const DatabaseSync: new (path: string) => DatabaseSync = DatabaseSyncConstructor;
|