@xaccefy/pi-casefile 0.5.7 → 0.6.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/package.json +1 -1
- package/src/index.ts +67 -25
- package/src/ledger.ts +132 -20
- package/src/poc-runner.ts +14 -9
- package/src/workflow.ts +174 -128
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
linkCasesResult,
|
|
36
36
|
PRIORITY_VALUES,
|
|
37
37
|
promoteFindingResult,
|
|
38
|
+
readActiveCases,
|
|
38
39
|
readCasefile,
|
|
39
40
|
SEARCH_FIELD_VALUES,
|
|
40
41
|
SEVERITY_VALUES,
|
|
@@ -44,7 +45,7 @@ import {
|
|
|
44
45
|
updateCaseResult,
|
|
45
46
|
writeCaseReport,
|
|
46
47
|
} from "./ledger.ts";
|
|
47
|
-
import { runPoc } from "./poc-runner.ts";
|
|
48
|
+
import { type PocRun, runPoc } from "./poc-runner.ts";
|
|
48
49
|
import { STATIC_CYBER_WORKFLOW } from "./workflow.ts";
|
|
49
50
|
|
|
50
51
|
// ── Schemas ───────────────────────────────────────────────────────────
|
|
@@ -110,6 +111,12 @@ const PromoteSchema = Type.Object(
|
|
|
110
111
|
poc_path: Type.String({
|
|
111
112
|
description: "Absolute path to the PoC script on disk",
|
|
112
113
|
}),
|
|
114
|
+
disconfirmation_path: Type.Optional(
|
|
115
|
+
Type.String({
|
|
116
|
+
description:
|
|
117
|
+
"Absolute path to a disconfirmation script that tries to disprove the finding; must exit non-zero (failure to disprove)",
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
113
120
|
local: Type.Optional(Type.Boolean({ description: "Run locally instead of in Docker sandbox" })),
|
|
114
121
|
},
|
|
115
122
|
{ additionalProperties: false },
|
|
@@ -405,7 +412,8 @@ function buildCaseListContext(records: CaseRecord[]): string {
|
|
|
405
412
|
/** Always includes cyber workflow; attaches case list when active cases exist. */
|
|
406
413
|
function buildAgentInjection(active: CaseRecord[]): string {
|
|
407
414
|
const caseList = buildCaseListContext(active);
|
|
408
|
-
|
|
415
|
+
// Workflow FIRST for prominence, then case list as reference data.
|
|
416
|
+
return caseList ? `${STATIC_CYBER_WORKFLOW}\n\n${caseList}` : STATIC_CYBER_WORKFLOW;
|
|
409
417
|
}
|
|
410
418
|
|
|
411
419
|
// ── XP (offensive / exploit) mode toggle ─────────────────────────────
|
|
@@ -623,13 +631,14 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
623
631
|
name: "PromoteFinding",
|
|
624
632
|
label: "Promote Finding",
|
|
625
633
|
description:
|
|
626
|
-
"Run an on-disk PoC script (Docker sandbox or local) and, on exit 0, promote an investigating case to confirmed.",
|
|
634
|
+
"Run an on-disk PoC script (Docker sandbox or local) and, on exit 0, promote an investigating case to confirmed. Optionally run a disconfirmation script that must exit non-0 (finding survived the attempt to disprove).",
|
|
627
635
|
promptSnippet: "Run a PoC and promote an investigating case to confirmed",
|
|
628
636
|
promptGuidelines: [
|
|
629
637
|
"Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
|
|
630
|
-
"The case must already have status='investigating' and non-empty poc, evidence, impact, and
|
|
638
|
+
"The case must already have status='investigating' and non-empty poc, evidence, impact, severity, target, and disconfirmation fields.",
|
|
631
639
|
"By default, the PoC runs in `docker run --rm --network none`. Use local:true to run on the host (e.g. for network-dependent bugs).",
|
|
632
640
|
"Only exit code 0 promotes the case to confirmed.",
|
|
641
|
+
"Optionally provide disconfirmation_path to a script that tries to disprove the finding. If the disconfirmation script exits 0, the finding is considered disproven and promotion is blocked.",
|
|
633
642
|
"Do not use CaseUpdate to set status='confirmed' directly — it is rejected. Always use PromoteFinding.",
|
|
634
643
|
],
|
|
635
644
|
parameters: PromoteSchema,
|
|
@@ -656,13 +665,46 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
656
665
|
};
|
|
657
666
|
}
|
|
658
667
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
668
|
+
// Run disconfirmation script if provided — must exit NON-0 (finding survived the attempt to disprove).
|
|
669
|
+
let disconfirmationRun: PocRun | undefined;
|
|
670
|
+
if (params.disconfirmation_path) {
|
|
671
|
+
disconfirmationRun = runPoc(params.disconfirmation_path as string, params.local !== true);
|
|
672
|
+
if (disconfirmationRun.exitCode === 0) {
|
|
673
|
+
const record = getCaseById(params.id as string);
|
|
674
|
+
return {
|
|
675
|
+
content: [
|
|
676
|
+
{
|
|
677
|
+
type: "text",
|
|
678
|
+
text:
|
|
679
|
+
`Disconfirmation script exited 0 (finding was disproven). ` +
|
|
680
|
+
`Case remains investigating.\nOutput:\n${disconfirmationRun.output}`,
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
isError: true,
|
|
684
|
+
details: { record, run, disconfirmationRun },
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const result = promoteFindingResult(
|
|
690
|
+
params.id as string,
|
|
691
|
+
{
|
|
692
|
+
path: run.path,
|
|
693
|
+
exitCode: run.exitCode,
|
|
694
|
+
ranAt: run.ranAt,
|
|
695
|
+
output: run.output,
|
|
696
|
+
sandbox: run.sandbox,
|
|
697
|
+
},
|
|
698
|
+
disconfirmationRun
|
|
699
|
+
? {
|
|
700
|
+
path: disconfirmationRun.path,
|
|
701
|
+
exitCode: disconfirmationRun.exitCode,
|
|
702
|
+
ranAt: disconfirmationRun.ranAt,
|
|
703
|
+
output: disconfirmationRun.output,
|
|
704
|
+
sandbox: disconfirmationRun.sandbox,
|
|
705
|
+
}
|
|
706
|
+
: undefined,
|
|
707
|
+
);
|
|
666
708
|
const record = result.record;
|
|
667
709
|
return {
|
|
668
710
|
content: [
|
|
@@ -969,7 +1011,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
969
1011
|
pi.registerTool({
|
|
970
1012
|
name: "CaseReport",
|
|
971
1013
|
label: "Write Case Report",
|
|
972
|
-
description:
|
|
1014
|
+
description:
|
|
1015
|
+
"Generate a markdown report from a confirmed or reported case under the project report directory. Hypothesis/investigating/blocked/killed cases are rejected — promote to confirmed first.",
|
|
973
1016
|
promptSnippet: "Generate a bounty-style markdown report from a case",
|
|
974
1017
|
promptGuidelines: [
|
|
975
1018
|
"Use CaseReport only for confirmed or already reported cases. Keep hypotheses and investigating cases in the ledger until proof is captured.",
|
|
@@ -1057,30 +1100,29 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1057
1100
|
}
|
|
1058
1101
|
});
|
|
1059
1102
|
|
|
1060
|
-
// ── Event: Inject
|
|
1103
|
+
// ── Event: Inject cyber workflow into system prompt ──
|
|
1061
1104
|
// XP (offensive) mode is OFF by default so normal dev work stays quiet.
|
|
1062
|
-
// Only when enabled do we inject the cyber workflow (and case list)
|
|
1063
|
-
// prompt
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1105
|
+
// Only when enabled do we inject the cyber workflow (and case list) into
|
|
1106
|
+
// the system prompt each turn. Injecting into event.systemPrompt (not as a
|
|
1107
|
+
// conversation message) makes the attacker mindset immediate and avoids
|
|
1108
|
+
// session bloat from repeated message entries.
|
|
1066
1109
|
|
|
1067
|
-
pi.on("before_agent_start", async () => {
|
|
1110
|
+
pi.on("before_agent_start", async (event) => {
|
|
1068
1111
|
if (readXpMode() === "off") return;
|
|
1069
1112
|
|
|
1070
1113
|
let active: CaseRecord[] = [];
|
|
1071
1114
|
try {
|
|
1072
|
-
|
|
1073
|
-
active = records.filter((r) => r.status !== "killed" && r.status !== "reported");
|
|
1115
|
+
active = readActiveCases();
|
|
1074
1116
|
} catch {
|
|
1075
1117
|
// No database yet — still inject workflow.
|
|
1076
1118
|
}
|
|
1077
1119
|
|
|
1120
|
+
const injection = buildAgentInjection(active);
|
|
1121
|
+
|
|
1122
|
+
// Inject workflow FIRST (before skills) so the attacker mindset is
|
|
1123
|
+
// prominent, not buried at the end of a long system prompt.
|
|
1078
1124
|
return {
|
|
1079
|
-
|
|
1080
|
-
customType: "casefile_summary",
|
|
1081
|
-
content: buildAgentInjection(active),
|
|
1082
|
-
display: false,
|
|
1083
|
-
},
|
|
1125
|
+
systemPrompt: `${injection}\n\n${event.systemPrompt ?? ""}`,
|
|
1084
1126
|
};
|
|
1085
1127
|
});
|
|
1086
1128
|
|
package/src/ledger.ts
CHANGED
|
@@ -101,6 +101,8 @@ export type CaseRecord = {
|
|
|
101
101
|
tags?: string[];
|
|
102
102
|
/** Explicit assumptions or unknowns to avoid overstating exploitability. */
|
|
103
103
|
assumptions?: string[];
|
|
104
|
+
/** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
|
|
105
|
+
disconfirmation?: string;
|
|
104
106
|
/** Verification of an on-disk PoC run (set only by promoteFindingResult). */
|
|
105
107
|
pocVerified?: {
|
|
106
108
|
path: string;
|
|
@@ -109,6 +111,14 @@ export type CaseRecord = {
|
|
|
109
111
|
output?: string;
|
|
110
112
|
sandbox: boolean;
|
|
111
113
|
};
|
|
114
|
+
/** Verification of a disconfirmation run (set only by promoteFindingResult). */
|
|
115
|
+
disconfirmationVerified?: {
|
|
116
|
+
path: string;
|
|
117
|
+
exitCode: number;
|
|
118
|
+
ranAt: string;
|
|
119
|
+
output?: string;
|
|
120
|
+
sandbox: boolean;
|
|
121
|
+
};
|
|
112
122
|
/** ISO timestamp when CaseReport first wrote the markdown report. */
|
|
113
123
|
reportedAt?: string;
|
|
114
124
|
/** Path to the generated markdown report (set only by writeCaseReport). */
|
|
@@ -140,11 +150,14 @@ export type CaseInput = {
|
|
|
140
150
|
blockers?: string[];
|
|
141
151
|
tags?: string[];
|
|
142
152
|
assumptions?: string[];
|
|
153
|
+
/** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
|
|
154
|
+
disconfirmation?: string;
|
|
143
155
|
};
|
|
144
156
|
|
|
145
157
|
type NormalizedCaseInput = Partial<CaseInput> & {
|
|
146
158
|
linkedCaseIds?: string[];
|
|
147
159
|
pocVerified?: CaseRecord["pocVerified"];
|
|
160
|
+
disconfirmationVerified?: CaseRecord["disconfirmationVerified"];
|
|
148
161
|
reportedAt?: string;
|
|
149
162
|
reportPath?: string;
|
|
150
163
|
};
|
|
@@ -285,6 +298,8 @@ function getDb(): DatabaseSync {
|
|
|
285
298
|
tags_json TEXT, -- JSON string array
|
|
286
299
|
assumptions_json TEXT, -- JSON string array
|
|
287
300
|
poc_verified_json TEXT, -- JSON object
|
|
301
|
+
disconfirmation TEXT,
|
|
302
|
+
disconfirmation_verified_json TEXT, -- JSON object
|
|
288
303
|
reported_at TEXT,
|
|
289
304
|
report_path TEXT,
|
|
290
305
|
created_at TEXT NOT NULL,
|
|
@@ -309,6 +324,15 @@ function getDb(): DatabaseSync {
|
|
|
309
324
|
db.exec("ALTER TABLE case_links ADD COLUMN kind TEXT NOT NULL DEFAULT 'related'");
|
|
310
325
|
}
|
|
311
326
|
|
|
327
|
+
// Idempotent migration for new columns on existing databases
|
|
328
|
+
const caseCols = db.prepare("PRAGMA table_info(cases)").all() as { name: string }[];
|
|
329
|
+
if (!caseCols.some((c) => c.name === "disconfirmation")) {
|
|
330
|
+
db.exec("ALTER TABLE cases ADD COLUMN disconfirmation TEXT");
|
|
331
|
+
}
|
|
332
|
+
if (!caseCols.some((c) => c.name === "disconfirmation_verified_json")) {
|
|
333
|
+
db.exec("ALTER TABLE cases ADD COLUMN disconfirmation_verified_json TEXT");
|
|
334
|
+
}
|
|
335
|
+
|
|
312
336
|
// Indexes
|
|
313
337
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status)`);
|
|
314
338
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_target ON cases(target)`);
|
|
@@ -361,7 +385,9 @@ function mapRow(row: any, linkedCases: { id: string; kind: string }[] = []): Cas
|
|
|
361
385
|
blockers: safeParseArray(row.blockers_json),
|
|
362
386
|
tags: safeParseArray(row.tags_json),
|
|
363
387
|
assumptions: safeParseArray(row.assumptions_json),
|
|
388
|
+
disconfirmation: row.disconfirmation || undefined,
|
|
364
389
|
pocVerified: safeParseObject(row.poc_verified_json),
|
|
390
|
+
disconfirmationVerified: safeParseObject(row.disconfirmation_verified_json),
|
|
365
391
|
reportedAt: row.reported_at || undefined,
|
|
366
392
|
reportPath: row.report_path || undefined,
|
|
367
393
|
linkedCases,
|
|
@@ -393,6 +419,19 @@ export function readCasefile(): CaseRecord[] {
|
|
|
393
419
|
return rows.map((row: any) => mapRow(row, linkMap.get(row.id) ?? []));
|
|
394
420
|
}
|
|
395
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Read only non-terminal cases (hypothesis, investigating, confirmed, blocked).
|
|
424
|
+
* Used for per-prompt context injection so we never load killed/reported rows
|
|
425
|
+
* (which grow without bound over a long engagement) into memory each turn.
|
|
426
|
+
*/
|
|
427
|
+
export function readActiveCases(): CaseRecord[] {
|
|
428
|
+
const db = getDb();
|
|
429
|
+
const rows = db
|
|
430
|
+
.prepare("SELECT * FROM cases WHERE status NOT IN ('killed', 'reported')")
|
|
431
|
+
.all() as any[];
|
|
432
|
+
return mapRowsWithLinks(db, rows);
|
|
433
|
+
}
|
|
434
|
+
|
|
396
435
|
export function getCaseById(id: string): CaseRecord | undefined {
|
|
397
436
|
const db = getDb();
|
|
398
437
|
const stmt = db.prepare("SELECT * FROM cases WHERE id = ?");
|
|
@@ -416,9 +455,13 @@ function validateCase(record: CaseRecord): void {
|
|
|
416
455
|
// CONFIRMED when it has evidence, a PoC, demonstrated impact, and a severity.
|
|
417
456
|
if (
|
|
418
457
|
record.status === "confirmed" &&
|
|
419
|
-
(!record.evidence ||
|
|
458
|
+
(!record.evidence ||
|
|
459
|
+
!record.poc ||
|
|
460
|
+
!record.impact ||
|
|
461
|
+
!record.severity ||
|
|
462
|
+
!record.disconfirmation)
|
|
420
463
|
) {
|
|
421
|
-
throw new Error("Confirmed cases require evidence, poc, impact, and
|
|
464
|
+
throw new Error("Confirmed cases require evidence, poc, impact, severity, and disconfirmation");
|
|
422
465
|
}
|
|
423
466
|
if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
|
|
424
467
|
throw new Error("Blocked cases require at least one blocker");
|
|
@@ -553,6 +596,11 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
553
596
|
tags: normalizeList(input.tags ?? existing?.tags),
|
|
554
597
|
assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
|
|
555
598
|
pocVerified: input.pocVerified ?? existing?.pocVerified,
|
|
599
|
+
disconfirmation:
|
|
600
|
+
input.disconfirmation !== undefined
|
|
601
|
+
? normalizeText(input.disconfirmation)
|
|
602
|
+
: existing?.disconfirmation,
|
|
603
|
+
disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
|
|
556
604
|
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
557
605
|
reportPath: input.reportPath ?? existing?.reportPath,
|
|
558
606
|
linkedCases: existing?.linkedCases ?? [],
|
|
@@ -574,12 +622,19 @@ function findDuplicateCaseInDb(
|
|
|
574
622
|
const endpoint = normalizeMatchText(candidate.endpoint);
|
|
575
623
|
const bugClass = normalizeMatchText(candidate.bugClass);
|
|
576
624
|
|
|
577
|
-
//
|
|
625
|
+
// Pre-filter by normalized title in SQL so we don't load the whole ledger into
|
|
626
|
+
// JS just to find a duplicate. normalizeMatchText lowercases + collapses
|
|
627
|
+
// whitespace, so we match against lower(title) with the same normalization.
|
|
628
|
+
// The full title/target/endpoint/bugClass match still runs in JS below to catch
|
|
629
|
+
// whitespace/case differences the SQL LIKE can't express exactly.
|
|
630
|
+
const sqlTitle = `%${title}%`;
|
|
578
631
|
const rows = excludeId
|
|
579
632
|
? (db
|
|
580
|
-
.prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ?")
|
|
581
|
-
.all(excludeId) as any[])
|
|
582
|
-
: (db
|
|
633
|
+
.prepare("SELECT * FROM cases WHERE status != 'killed' AND id != ? AND lower(title) LIKE ?")
|
|
634
|
+
.all(excludeId, sqlTitle) as any[])
|
|
635
|
+
: (db
|
|
636
|
+
.prepare("SELECT * FROM cases WHERE status != 'killed' AND lower(title) LIKE ?")
|
|
637
|
+
.all(sqlTitle) as any[]);
|
|
583
638
|
|
|
584
639
|
for (const row of rows) {
|
|
585
640
|
if (
|
|
@@ -610,11 +665,13 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
610
665
|
id, title, status, confidence, severity, priority, target, endpoint, bugClass,
|
|
611
666
|
summary, evidence, impact, nextStep, poc, remediation,
|
|
612
667
|
references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
|
|
668
|
+
disconfirmation, disconfirmation_verified_json,
|
|
613
669
|
reported_at, report_path, created_at, updated_at
|
|
614
670
|
) VALUES (
|
|
615
671
|
?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
616
672
|
?, ?, ?, ?, ?, ?,
|
|
617
673
|
?, ?, ?, ?, ?,
|
|
674
|
+
?, ?,
|
|
618
675
|
?, ?, ?, ?
|
|
619
676
|
)
|
|
620
677
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -637,6 +694,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
637
694
|
tags_json = excluded.tags_json,
|
|
638
695
|
assumptions_json = excluded.assumptions_json,
|
|
639
696
|
poc_verified_json = excluded.poc_verified_json,
|
|
697
|
+
disconfirmation = excluded.disconfirmation,
|
|
698
|
+
disconfirmation_verified_json = excluded.disconfirmation_verified_json,
|
|
640
699
|
reported_at = excluded.reported_at,
|
|
641
700
|
report_path = excluded.report_path,
|
|
642
701
|
created_at = excluded.created_at,
|
|
@@ -664,6 +723,8 @@ function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
664
723
|
JSON.stringify(record.tags),
|
|
665
724
|
JSON.stringify(record.assumptions),
|
|
666
725
|
record.pocVerified ? JSON.stringify(record.pocVerified) : null,
|
|
726
|
+
record.disconfirmation || null,
|
|
727
|
+
record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
|
|
667
728
|
record.reportedAt || null,
|
|
668
729
|
record.reportPath || null,
|
|
669
730
|
record.createdAt,
|
|
@@ -719,6 +780,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
719
780
|
"nextStep",
|
|
720
781
|
"poc",
|
|
721
782
|
"remediation",
|
|
783
|
+
"disconfirmation",
|
|
722
784
|
] as const;
|
|
723
785
|
const optionalPatch: Record<string, unknown> = {};
|
|
724
786
|
for (const field of optionalFields) {
|
|
@@ -746,16 +808,38 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
746
808
|
validateTransition(current.status, next.status, update, current);
|
|
747
809
|
}
|
|
748
810
|
|
|
749
|
-
// Demoting off confirmed invalidates prior PoC verification —
|
|
811
|
+
// Demoting off confirmed invalidates prior PoC + disconfirmation verification —
|
|
812
|
+
// re-promote required. Both verification artifacts must be re-earned together.
|
|
750
813
|
if (current.status === "confirmed" && next.status === "investigating") {
|
|
751
|
-
next = { ...next, pocVerified: undefined };
|
|
814
|
+
next = { ...next, pocVerified: undefined, disconfirmationVerified: undefined };
|
|
752
815
|
}
|
|
753
816
|
|
|
754
817
|
validateCase(next);
|
|
755
818
|
|
|
756
|
-
// Check material equality (we ignore links since links are mutated via CaseLink)
|
|
819
|
+
// Check material equality (we ignore links since links are mutated via CaseLink).
|
|
820
|
+
// Keys are sorted before stringify because mapRow (DB read) and buildRecord
|
|
821
|
+
// (write) emit CaseRecord keys in different orders — a plain JSON.stringify({...r})
|
|
822
|
+
// would report false "changed" on no-op updates whenever a field is undefined on
|
|
823
|
+
// one side and absent on the other. Sorting makes the comparison order-independent.
|
|
824
|
+
// Do NOT simplify back to JSON.stringify({...r}) — it reintroduces the bug.
|
|
757
825
|
const norm = (r: CaseRecord) =>
|
|
758
|
-
JSON.stringify(
|
|
826
|
+
JSON.stringify(
|
|
827
|
+
Object.keys(r)
|
|
828
|
+
.sort()
|
|
829
|
+
.reduce<Record<string, unknown>>((acc, k) => {
|
|
830
|
+
if (
|
|
831
|
+
k === "updatedAt" ||
|
|
832
|
+
k === "createdAt" ||
|
|
833
|
+
k === "linkedCaseIds" ||
|
|
834
|
+
k === "linkedCases"
|
|
835
|
+
) {
|
|
836
|
+
acc[k] = "";
|
|
837
|
+
} else {
|
|
838
|
+
acc[k] = (r as Record<string, unknown>)[k];
|
|
839
|
+
}
|
|
840
|
+
return acc;
|
|
841
|
+
}, {}),
|
|
842
|
+
);
|
|
759
843
|
if (norm(current) === norm(next)) {
|
|
760
844
|
const reason =
|
|
761
845
|
update.status && update.status === current.status
|
|
@@ -777,7 +861,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
777
861
|
return { record: next, changed: true };
|
|
778
862
|
}
|
|
779
863
|
|
|
780
|
-
type PocVerification = {
|
|
864
|
+
export type PocVerification = {
|
|
781
865
|
path: string;
|
|
782
866
|
exitCode: number;
|
|
783
867
|
ranAt: string;
|
|
@@ -811,10 +895,24 @@ export function assertPromotable(id: string): CaseRecord {
|
|
|
811
895
|
if (!current.severity) {
|
|
812
896
|
throw new Error("CONFIRMED requires severity; set severity on the case first");
|
|
813
897
|
}
|
|
898
|
+
if (!current.target) {
|
|
899
|
+
throw new Error(
|
|
900
|
+
"CONFIRMED requires target (what host/repo/scope this affects); set target on the case first",
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
if (!current.disconfirmation) {
|
|
904
|
+
throw new Error(
|
|
905
|
+
"CONFIRMED requires disconfirmation (your attempt to disprove the finding); set disconfirmation on the case first",
|
|
906
|
+
);
|
|
907
|
+
}
|
|
814
908
|
return current;
|
|
815
909
|
}
|
|
816
910
|
|
|
817
|
-
export function promoteFindingResult(
|
|
911
|
+
export function promoteFindingResult(
|
|
912
|
+
id: string,
|
|
913
|
+
verification: PocVerification,
|
|
914
|
+
disconfirmationVerification?: PocVerification,
|
|
915
|
+
): CaseUpdateResult {
|
|
818
916
|
const db = getDb();
|
|
819
917
|
const current = assertPromotable(id);
|
|
820
918
|
if (verification.exitCode !== 0) {
|
|
@@ -830,14 +928,16 @@ export function promoteFindingResult(id: string, verification: PocVerification):
|
|
|
830
928
|
`- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
|
|
831
929
|
`#### Execution Output\n\`\`\`\n${verification.output ?? ""}\n\`\`\``;
|
|
832
930
|
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
931
|
+
const update: NormalizedCaseInput = {
|
|
932
|
+
status: "confirmed",
|
|
933
|
+
pocVerified: verification,
|
|
934
|
+
evidence: newEvidence,
|
|
935
|
+
};
|
|
936
|
+
if (disconfirmationVerification) {
|
|
937
|
+
update.disconfirmationVerified = disconfirmationVerification;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const next = buildRecord(update, current);
|
|
841
941
|
validateCase(next);
|
|
842
942
|
|
|
843
943
|
upsertCase(db, next);
|
|
@@ -1226,6 +1326,13 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1226
1326
|
`### 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\`\`\``,
|
|
1227
1327
|
)
|
|
1228
1328
|
: undefined,
|
|
1329
|
+
mdSection("Disconfirmation Attempt", current.disconfirmation),
|
|
1330
|
+
current.disconfirmationVerified
|
|
1331
|
+
? mdSection(
|
|
1332
|
+
"Disconfirmation Verification Log",
|
|
1333
|
+
`### Disconfirmation Run Verification\n- **Timestamp:** ${current.disconfirmationVerified.ranAt}\n- **Path:** \`${current.disconfirmationVerified.path}\`\n- **Sandbox:** ${current.disconfirmationVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.disconfirmationVerified.exitCode} (non-zero = finding survived the attempt to disprove)\n\n#### Output\n\`\`\`\n${current.disconfirmationVerified.output ?? ""}\n\`\`\``,
|
|
1334
|
+
)
|
|
1335
|
+
: undefined,
|
|
1229
1336
|
mdSection("Impact", current.impact),
|
|
1230
1337
|
mdSection("Remediation", current.remediation),
|
|
1231
1338
|
mdSection("Assumptions and Uncertainty", assumptions),
|
|
@@ -1243,6 +1350,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1243
1350
|
updatedAt: new Date().toISOString(),
|
|
1244
1351
|
};
|
|
1245
1352
|
|
|
1353
|
+
// Enforce the same field invariants as every other write path. writeCaseReport
|
|
1354
|
+
// never changes status (confirmed stays confirmed; the caller flips to reported
|
|
1355
|
+
// via CaseUpdate, which runs validateTransition), but it does set reportPath —
|
|
1356
|
+
// validateCase ensures the resulting record is internally consistent.
|
|
1357
|
+
validateCase(next);
|
|
1246
1358
|
upsertCase(db, next);
|
|
1247
1359
|
return { path: reportPath, record: next };
|
|
1248
1360
|
}
|
package/src/poc-runner.ts
CHANGED
|
@@ -421,15 +421,20 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
421
421
|
throw new Error("Language config has no run command");
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
// The run template is `<interpreter>
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
const args =
|
|
424
|
+
// The run template is `<interpreter> [flags...] {{file}}`. Split the static
|
|
425
|
+
// template on whitespace FIRST (the template is trusted config, not user input),
|
|
426
|
+
// then render placeholders within each token. Passing the tokens to spawnSync
|
|
427
|
+
// with NO shell keeps a space-containing PoC path as one arg and keeps extra
|
|
428
|
+
// flags (e.g. `node --experimental-vm-modules {{file}}`) as separate args.
|
|
429
|
+
// Splitting after rendering would re-split a space-containing path.
|
|
430
|
+
const tokens = language.run.trim().split(/\s+/).filter(Boolean);
|
|
431
|
+
const interpreter = tokens.shift() ?? language.run.trim();
|
|
432
|
+
const args = tokens.map((tok) =>
|
|
433
|
+
tok
|
|
434
|
+
.replace(/{{file}}/g, pocPath)
|
|
435
|
+
.replace(/{{bin}}/g, join(dirname(pocPath), "poc"))
|
|
436
|
+
.replace(/{{class}}/g, basename(pocPath).replace(/\.[^.]+$/i, "")),
|
|
437
|
+
);
|
|
433
438
|
|
|
434
439
|
const result = spawnSync(interpreter, args, {
|
|
435
440
|
encoding: "utf8",
|
package/src/workflow.ts
CHANGED
|
@@ -1,187 +1,233 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cyber workflow injected into agent context when XP mode is ON.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Skills (pipeline, web-pentest) already cover tool usage and methodology.
|
|
5
|
+
* This file adds the unique attacker discipline: state machine with
|
|
6
|
+
* preconditions, attacker model, impact validation, adversarial review,
|
|
7
|
+
* kill checklist, and report-readiness criteria.
|
|
8
|
+
*
|
|
9
|
+
* The case lifecycle (HYPOTHESIS -> INVESTIGATING -> CONFIRMED -> REPORTED)
|
|
10
|
+
* maps to the pipeline's discovery stages. This file explains the gate
|
|
11
|
+
* discipline applied at each transition.
|
|
4
12
|
*/
|
|
5
13
|
export const STATIC_CYBER_WORKFLOW = `
|
|
6
14
|
# Cyber Workflow (Attacker-Oriented)
|
|
7
15
|
|
|
8
16
|
Think like a real external attacker, not a code reviewer. Technical bugs are cheap; **reachable attacker impact** is what matters for bounty-valid findings.
|
|
9
17
|
|
|
10
|
-
Every lead starts HYPOTHESIS. Nothing reaches CONFIRMED without
|
|
11
|
-
1. a working PoC on disk,
|
|
12
|
-
2. a proven attacker path (who can trigger it, from where),
|
|
13
|
-
3. demonstrated C/I/A impact (not theoretical).
|
|
18
|
+
Every lead starts HYPOTHESIS. Nothing reaches CONFIRMED without a proven attacker path and demonstrated impact against a real production target or faithful replica.
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
## State Machine (CaseAdd → CaseUpdate → CaseReport)
|
|
20
|
+
## Case Lifecycle (State Machine)
|
|
18
21
|
|
|
19
22
|
\`\`\`
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
+--- KILLED (dead end, documented why)
|
|
24
|
+
|
|
|
25
|
+
RECON -> HYPOTHESIS --+
|
|
26
|
+
|
|
|
27
|
+
+--> INVESTIGATING --> CONFIRMED --> REPORTED
|
|
28
|
+
| ^ |
|
|
29
|
+
| | chain/primitive |
|
|
30
|
+
| +-----------------+
|
|
31
|
+
|
|
|
32
|
+
+--> KILLED (insufficient impact, duplicate, etc.)
|
|
24
33
|
\`\`\`
|
|
25
34
|
|
|
26
|
-
###
|
|
35
|
+
### Phase -> State map
|
|
36
|
+
|
|
37
|
+
| Phase | Case State | What happens |
|
|
38
|
+
|-------|-----------|-------------|
|
|
39
|
+
| RECON | (none yet) | Map attack surface, fingerprint, search CVEs. When something interesting appears -> HYPOTHESIS. |
|
|
40
|
+
| HUNT | HYPOTHESIS | Document the lead. Impact not required yet. If it's a clear intended-behavior or artifact -> KILLED. Otherwise -> INVESTIGATING. |
|
|
41
|
+
| CHAIN | INVESTIGATING | Test the hypothesis, chain primitives, build PoC. Explore combinations (open redirect + SSRF, leak + other endpoint, etc.). |
|
|
42
|
+
| VALIDATE | CONFIRMED | Prove impact against production target, adversarial review, root-cause trace. Survive the gates below, or fall back to INVESTIGATING / KILLED. |
|
|
43
|
+
| REPORT | REPORTED | Write up, report-readiness gate, submit. |
|
|
44
|
+
|
|
45
|
+
### Preconditions Per State Transition (MANDATORY)
|
|
27
46
|
|
|
28
47
|
| Advance To | Required Case Fields | Must Exist on Disk |
|
|
29
48
|
|-----------|---------------------|--------------------|
|
|
30
|
-
| INVESTIGATING |
|
|
31
|
-
| **CONFIRMED** |
|
|
32
|
-
| KILLED |
|
|
33
|
-
| REPORTED | Only after
|
|
49
|
+
| HYPOTHESIS -> INVESTIGATING | evidence (observations or initial findings), confidence | Notes on what was observed |
|
|
50
|
+
| INVESTIGATING -> **CONFIRMED** | evidence, poc (steps/script), **impact (see below for content requirements)**, severity, **target (host/repo/scope this affects)**, **disconfirmation (your documented attempt to disprove the finding)** | PoC script + run.log exit 0. Optionally, disconfirmation script run.log exit non-0 (finding survived the attempt to disprove). |
|
|
51
|
+
| Any -> KILLED | assumptions (why it died) | --- |
|
|
52
|
+
| CONFIRMED -> REPORTED | Only after CaseReport(id) succeeds | Report file |
|
|
53
|
+
|
|
54
|
+
**Rule: If a required field is empty, you cannot advance.** The fields are the gates.
|
|
55
|
+
|
|
56
|
+
### When to advance vs kill vs stay
|
|
57
|
+
|
|
58
|
+
Staying in HYPOTHESIS or INVESTIGATING is **fine** --- it means you're still working. Do not force a transition.
|
|
34
59
|
|
|
35
|
-
**
|
|
60
|
+
- **HYPOTHESIS -> KILLED only when**: it's documented intended behavior, duplicate, artifact/noise, or you proved no attack path exists after testing.
|
|
61
|
+
- **HYPOTHESIS -> INVESTIGATING**: you have something real and are actively testing. Source-sink not required yet.
|
|
62
|
+
- **INVESTIGATING -> KILLED**: you proved insufficient impact, environmental issue, unreliable exploit, or duplicate after investigation.
|
|
63
|
+
- **INVESTIGATING -> CONFIRMED**: strict gates below must pass.
|
|
36
64
|
|
|
37
65
|
---
|
|
38
66
|
|
|
39
|
-
##
|
|
67
|
+
## At HYPOTHESIS (just found something)
|
|
68
|
+
|
|
69
|
+
Document what you know without worrying about impact proof:
|
|
70
|
+
|
|
71
|
+
1. **What happened?** (behavior, error, timing, leak)
|
|
72
|
+
2. **Where?** (endpoint, parameter, component, line)
|
|
73
|
+
3. **Who can reach it?** (unauth, any user, admin only)
|
|
74
|
+
4. **What you don't know yet** -> next experiments
|
|
75
|
+
|
|
76
|
+
**Do not kill a hypothesis just because impact is unclear.** Impact may come from chaining.
|
|
77
|
+
|
|
78
|
+
**Kill a hypothesis only when:**
|
|
79
|
+
- It's clearly documented/intended behavior (after checking docs)
|
|
80
|
+
- It's a duplicate
|
|
81
|
+
- It's a test artifact, cache noise, browser quirk
|
|
82
|
+
- You tested and proved no attack path exists (not "I can't see one")
|
|
83
|
+
|
|
84
|
+
---
|
|
40
85
|
|
|
41
|
-
|
|
86
|
+
## At INVESTIGATING (chaining primitives)
|
|
87
|
+
|
|
88
|
+
Many findings start as primitives: open redirect, limited SSRF, info leak of non-sensitive data, reflected XSS on non-sensitive page, CSRF on public-only action.
|
|
89
|
+
|
|
90
|
+
For each primitive, ask:
|
|
91
|
+
1. **What can this combine with?** (SSRF + internal service, open redirect + OAuth callback, leak + other endpoint)
|
|
92
|
+
2. **Does the primitive cross a trust boundary?** Can an unauth user trigger it? Can a low-priv user reach an admin endpoint?
|
|
93
|
+
3. **What's the worst-case chain expressed in C/I/A?**
|
|
94
|
+
|
|
95
|
+
Record chains via CaseLink. Keep the primitive as INVESTIGATING while you explore. Only KILL if you prove no chain exists after testing.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## At VALIDATE (before advancing to CONFIRMED)
|
|
100
|
+
|
|
101
|
+
Before promoting to CONFIRMED, the following must be fully answered and documented in the case fields (evidence + impact). Incomplete answers = stay INVESTIGATING.
|
|
102
|
+
|
|
103
|
+
### 0. Attacker Model (must be in evidence or impact field)
|
|
42
104
|
|
|
43
105
|
1. **Who is the attacker?** (unauth internet, low-priv user, tenant peer, SSRF pivot, etc.)
|
|
44
106
|
2. **What can they already do without the bug?** (baseline privileges)
|
|
45
107
|
3. **What extra power does the bug grant beyond that baseline?**
|
|
46
|
-
4. **Is the path realistic in production?** (auth, CSRF, WAF, network, feature flags, admin-only)
|
|
108
|
+
4. **Is the path realistic in production?** (auth, CSRF, WAF, network, feature flags, admin-only required?)
|
|
47
109
|
|
|
48
|
-
If you cannot name a concrete attacker who gains something they should not have
|
|
110
|
+
If you cannot name a concrete attacker who gains something they should not have -> do **not** confirm. Stay INVESTIGATING or KILL with documented reason.
|
|
49
111
|
|
|
50
|
-
|
|
51
|
-
- Self-XSS / self-DoS only (attacker harms only their own session/account)
|
|
52
|
-
- Requires admin/root/already-trusted role that already has the same power
|
|
53
|
-
- Local-only, offline, or impossible deployment assumptions
|
|
54
|
-
- Spec-compliant / documented intentional behavior
|
|
55
|
-
- Needs physical access, victim to paste payload into their own console, or other social-engineering-only steps with no trust-boundary break
|
|
56
|
-
- "Interesting" logic quirks with **no confidentiality, integrity, availability, or financial effect**
|
|
57
|
-
- PoC proves a code path exists but **not** that a real victim asset is affected
|
|
112
|
+
### 1. Disconfirmation Attempt (mandatory field before CONFIRMED)
|
|
58
113
|
|
|
59
|
-
|
|
114
|
+
Before promoting, you must actively attempt to disprove your own finding.
|
|
115
|
+
This is not a formality --- the attempt is documented in the \`disconfirmation\`
|
|
116
|
+
field and verified by the optional \`disconfirmation_path\` in PromoteFinding.
|
|
60
117
|
|
|
61
|
-
|
|
118
|
+
**What a disconfirmation attempt looks like:**
|
|
62
119
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
-
|
|
66
|
-
|
|
67
|
-
-
|
|
68
|
-
|
|
69
|
-
|
|
120
|
+
- Reproduce the finding under different conditions (different auth, different
|
|
121
|
+
config, different network position). If it fails, you disproved the scope.
|
|
122
|
+
- Check if the behavior is intentional by testing against documentation or
|
|
123
|
+
by trying to get the same result on a known-baseline endpoint.
|
|
124
|
+
- Attempt to trigger protections (WAF, CSP, CSRF, rate limits) that would
|
|
125
|
+
block the path in production.
|
|
126
|
+
- Try to prove the root cause is wrong: can the same behavior be triggered
|
|
127
|
+
without the attacker-controlled input you identified?
|
|
70
128
|
|
|
71
|
-
|
|
129
|
+
**Document the attempt in \`disconfirmation\` field.** Must include:
|
|
130
|
+
1. What you tried to do to disprove the finding
|
|
131
|
+
2. How you did it (conditions, inputs, target)
|
|
132
|
+
3. What result you got (if it failed to disprove, that's the expected outcome)
|
|
133
|
+
4. Why you believe the disconfirmation attempt was valid
|
|
72
134
|
|
|
73
|
-
|
|
135
|
+
**Strong disconfirmation that passes the gate:**
|
|
136
|
+
"Attempted to read /api/users/123 as user B after confirming user A owns
|
|
137
|
+
record 123. The endpoint returned 403 for user B, confirming the IDOR
|
|
138
|
+
protection works as expected. However, when we modified the request to
|
|
139
|
+
include the X-Override-User header seen in admin traffic, the endpoint
|
|
140
|
+
returned user A's data. The protection is bypassed via the admin header."
|
|
74
141
|
|
|
75
|
-
|
|
142
|
+
**Weak disconfirmation:**
|
|
143
|
+
"Tried to disprove. Could not."
|
|
144
|
+
|
|
145
|
+
If the disconfirmation script (disconfirmation_path) exits 0, the finding
|
|
146
|
+
is considered disproven and promotion is blocked. If you cannot write a
|
|
147
|
+
meaningful disconfirmation script, you may not understand the finding well
|
|
148
|
+
enough to promote it.
|
|
149
|
+
|
|
150
|
+
### 2. Production Path Verification (must be in impact field)
|
|
151
|
+
|
|
152
|
+
The **impact** field for CONFIRMED must explicitly answer:
|
|
153
|
+
|
|
154
|
+
1. **Target environment:** Which host/repo/instance was this tested against? (prod, staging, dev, local?)
|
|
155
|
+
2. **Production protections:** What protections exist in production that could block this path? (WAF, CSRF tokens, CORS, CSP, rate limiting, network segmentation, auth, feature flags, admin-only access)
|
|
156
|
+
3. **Bypass verification:** For each protection, have you confirmed it is bypassed or absent?
|
|
157
|
+
4. **Target comparison:** If tested against dev/staging/local, what differs in production that could affect exploitability? Have you verified the path still works in the production configuration?
|
|
158
|
+
|
|
159
|
+
**Weak impact that fails this gate:**
|
|
160
|
+
- "Attacker can read files" without specifying which target and whether protections block it
|
|
161
|
+
- "This works on localhost" without verifying production differences
|
|
162
|
+
- "The code path exists" without proving a real victim asset is reachable
|
|
163
|
+
- "Could be dangerous" or "may lead to RCE" without a concrete production path
|
|
164
|
+
|
|
165
|
+
You must name the **specific target host/repo** in the target field. If the finding only works on a dev instance with non-default config, document that honestly and consider whether it's KILL-worthy.
|
|
166
|
+
|
|
167
|
+
### 2. KILL at Validate stage
|
|
168
|
+
|
|
169
|
+
Documented intended behavior
|
|
170
|
+
- Self-XSS / self-DoS only (attacker harms only their own session)
|
|
171
|
+
- Requires admin/root role that already has the same power
|
|
172
|
+
- Local-only, offline, or impossible deployment assumptions
|
|
173
|
+
- Needs physical access, social engineering with no trust-boundary break
|
|
174
|
+
- No C/I/A/financial effect for anyone but the attacker
|
|
175
|
+
- PoC proves a code path exists but not that any victim asset is affected
|
|
176
|
+
- Protections in production block the path and are not bypassed
|
|
177
|
+
|
|
178
|
+
### 3. Evidence-First Doctrine
|
|
179
|
+
|
|
180
|
+
Every claim must be traceable to observed/reproduced behavior, source code, or documented platform behavior. If evidence is insufficient: state uncertainty and propose the next experiment. Never assume success where verification is incomplete.
|
|
181
|
+
|
|
182
|
+
### 4. Impact Gate
|
|
183
|
+
|
|
184
|
+
Prove at least **one** real attacker-facing violation against a production-viable target:
|
|
76
185
|
|
|
77
186
|
| Category | Required proof |
|
|
78
187
|
|----------|----------------|
|
|
79
|
-
| **Confidentiality** | Attacker reads data they must not see
|
|
188
|
+
| **Confidentiality** | Attacker reads data they must not see |
|
|
80
189
|
| **Integrity** | Attacker changes data/state they must not control |
|
|
81
|
-
| **Availability** | Attacker degrades service for **others**
|
|
190
|
+
| **Availability** | Attacker degrades service for **others** |
|
|
82
191
|
| **Financial / authz** | Direct money, privilege, or account takeover path |
|
|
83
192
|
|
|
84
|
-
Impact text must answer: *who is hurt, what is lost, how the attacker reaches it.*
|
|
85
|
-
Vague impact like "could be dangerous" or "may lead to RCE" without a path is not impact_proof.
|
|
193
|
+
Impact text must answer: *who is hurt, what is lost, how the attacker reaches it from production.*
|
|
86
194
|
|
|
87
|
-
If impact is
|
|
195
|
+
If impact is theoretical, needs a second unproven bug, or is not yet reachable from the attacker's position -> stay INVESTIGATING (chain it) or KILL.
|
|
88
196
|
|
|
89
|
-
|
|
197
|
+
### 5. Adversarial Self-Review
|
|
90
198
|
|
|
91
|
-
|
|
92
|
-
Argue against yourself:
|
|
93
|
-
1. Why this might NOT be a vulnerability (intended, sandbox, misconfig, already authorized).
|
|
199
|
+
1. Why this might NOT be a vulnerability.
|
|
94
200
|
2. Alternative explanations for the observation.
|
|
95
201
|
3. Why each alternative was rejected **with evidence**.
|
|
96
|
-
4. What blocks a real attacker
|
|
97
|
-
5. Would a program triage
|
|
202
|
+
4. What blocks a real attacker in production today and whether each is bypassed.
|
|
203
|
+
5. Would a program triage reject this as informative/N/A?
|
|
98
204
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
## 4. False Positive / Non-Applicable Kill Checklist
|
|
102
|
-
KILL immediately when any apply:
|
|
103
|
-
- Matches documented/spec behavior (\`intended_behavior\`)
|
|
104
|
-
- Browser quirk, test artifact, or cache noise
|
|
105
|
-
- Framework/middleware/WAF blocks the path and is not bypassed (\`framework_protection\`)
|
|
106
|
-
- Requires privileges the attacker already has or cannot obtain (\`environmental_issue\`)
|
|
107
|
-
- No C/I/A/financial effect for anyone but the attacker themselves (\`insufficient_impact\`)
|
|
108
|
-
- Exploit unreliable / not reproducible twice (\`exploit_unreliable\`)
|
|
109
|
-
- Duplicate of an existing case (\`duplicate\`)
|
|
205
|
+
### 6. Root Cause -> Boundary -> Impact
|
|
110
206
|
|
|
111
|
-
---
|
|
112
|
-
|
|
113
|
-
## 5. Root Cause → Boundary → Impact (Not Behavior → Hype)
|
|
114
|
-
Trace:
|
|
115
207
|
\`\`\`
|
|
116
|
-
Entry (attacker-controlled)
|
|
208
|
+
Entry (attacker-controlled) -> Code path -> Trust boundary crossed -> Victim impact
|
|
117
209
|
\`\`\`
|
|
118
|
-
- Minimum: reproduce successfully at least twice or via two independent methods.
|
|
119
|
-
- Record: **Observed Facts**, **Assumptions**, **Unknowns**, **Experiments Remaining**.
|
|
120
|
-
- If the bug is only a **primitive** (e.g. open redirect, limited SSRF, info leak of non-sensitive data), either chain to high impact or keep severity honest — do not inflate.
|
|
121
|
-
|
|
122
|
-
---
|
|
123
210
|
|
|
124
|
-
|
|
125
|
-
Before CaseAdd:
|
|
126
|
-
- Is this new?
|
|
127
|
-
- Same root cause as an open case?
|
|
128
|
-
- Multiple endpoints, one bug?
|
|
129
|
-
Continue the existing case ID when scope matches.
|
|
211
|
+
Reproduce at least twice or via two methods.
|
|
130
212
|
|
|
131
213
|
---
|
|
132
214
|
|
|
133
|
-
##
|
|
134
|
-
|
|
215
|
+
## At REPORT (before advancing to REPORTED)
|
|
216
|
+
|
|
135
217
|
- Another researcher can reproduce deterministically
|
|
136
|
-
- Steps
|
|
137
|
-
- Impact
|
|
138
|
-
- Root cause
|
|
139
|
-
- Attacker model + victim impact
|
|
218
|
+
- Steps realistic in production
|
|
219
|
+
- Impact justified without inflation (would the vendor agree?)
|
|
220
|
+
- Root cause + fix guidance are concrete
|
|
221
|
+
- Attacker model + victim impact + target explicit
|
|
140
222
|
|
|
141
223
|
---
|
|
142
224
|
|
|
143
|
-
##
|
|
144
|
-
Keep killed reasons explicit in assumptions/blockers:
|
|
145
|
-
- \`intended_behavior\`
|
|
146
|
-
- \`duplicate\`
|
|
147
|
-
- \`framework_protection\`
|
|
148
|
-
- \`exploit_unreliable\`
|
|
149
|
-
- \`insufficient_impact\`
|
|
150
|
-
- \`environmental_issue\`
|
|
151
|
-
- \`not_applicable\` (true bug / interesting behavior, no realistic attacker value)
|
|
152
|
-
Documenting kills prevents re-opening dead ends.
|
|
225
|
+
## KILLED cataloging
|
|
153
226
|
|
|
154
|
-
|
|
227
|
+
When a case is definitively dead (not "I don't know yet"), record the reason:
|
|
228
|
+
- intended_behavior / duplicate / framework_protection
|
|
229
|
+
- exploit_unreliable / insufficient_impact / environmental_issue
|
|
230
|
+
- not_applicable (true bug / interesting behavior, no realistic attacker value)
|
|
155
231
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
You have offensive tools beyond casefile. Use them — do not rely on memory or guesswork.
|
|
159
|
-
|
|
160
|
-
| Tool | When to use | Do NOT skip it when... |
|
|
161
|
-
|------|------------|------------------------|
|
|
162
|
-
| **ExploitSearch** | Before writing any PoC. Search for known techniques, bypasses, and attack primitives relevant to the target stack/vuln class. | ...you are investigating a hypothesis or building a PoC. Ground your approach in real write-ups, not memory. |
|
|
163
|
-
| **web_search** | To find CVEs, advisories, prior bug reports, documentation, or any live information about the target. | ...you need to check if a vulnerability is known, find version-specific issues, or research a technology. |
|
|
164
|
-
| **web_fetch** | To read full page content from a URL you already have (advisory, write-up, target page). | ...you have a specific URL to inspect. |
|
|
165
|
-
| **context7** | To look up current library/framework API docs and behavior. | ...you need to understand how a framework feature works (auth, parsing, routing). |
|
|
166
|
-
| **deepwiki** | To ask questions about a public GitHub repository's architecture and internals. | ...the target is an open-source project and you need to understand its design. |
|
|
167
|
-
| **codebase-memory-mcp** | To index a codebase and trace source-to-sink paths. index_repository, get_architecture, search_graph, trace_path. | ...you have access to the target source code and need structural reachability analysis. |
|
|
168
|
-
|
|
169
|
-
**pdtm CLI tools** (run via bash; each takes auth/flags differently — read the flags, do not guess):
|
|
170
|
-
- subfinder -d host (-silent, -t threads) — passive subdomain enum from API sources; no target auth. Pipe to httpx, not straight to nuclei.
|
|
171
|
-
- httpx -u <url> / -l hosts.txt (-t threads, -td tech-detect, -mc match-status, -H "Name: Value") — fast probe of authed endpoints; supports Header/Cookie/Bearer.
|
|
172
|
-
- ffuf -u <url> -w wordlist (-t threads, -rate, -H "..." -b "c=v", -mc/-fs filters) — authed fuzzing / content discovery.
|
|
173
|
-
- whatweb <url> (-a aggression 1-4, -t threads, --cookie) — tech fingerprint; positional URL, no -u.
|
|
174
|
-
- naabu -host <ip> / -l (-p ports, -rate, -c top-ports) — port scan; hosts, not web-auth.
|
|
175
|
-
- katana -u <url> / -list (-d depth, -jc js-crawl, -H "...") — crawl.
|
|
176
|
-
- nuclei -l hosts.txt / -u <url> (-tags, -severity, -type http, -silent; -c threads -bs host-batch -rl rate-limit -timeout 5 -retries 0): FAST when filtered, slow only if naive.
|
|
177
|
-
- Do not run all 9000+ templates. Filter: -tags cve,exposure,rce -severity critical,high -type http -t http/misconfiguration/.
|
|
178
|
-
- Pre-filter targets: subfinder -> httpx -mc 200,403 -> nuclei (cuts ~80% of work).
|
|
179
|
-
- Tune: -c 100-200 -bs 50-100 -rl 300 (avoid Cloudflare tarpit) -timeout 5 -retries 0 -mhe 10.
|
|
180
|
-
- Many hosts: -scan-strategy host-spray (v3). Few hosts many templates: template-spray.
|
|
181
|
-
|
|
182
|
-
**Default behavior when XP mode is ON:**
|
|
183
|
-
1. Start recon with ExploitSearch + web_search before diving into code.
|
|
184
|
-
2. Use context7/deepwiki to understand framework internals before claiming a vuln.
|
|
185
|
-
3. Use codebase-memory-mcp (if available) to prove reachability structurally.
|
|
186
|
-
4. Log everything to casefile (CaseAdd/CaseUpdate). Do not skip the ledger.
|
|
232
|
+
Documenting kills prevents re-opening dead ends. Cases with unresolved unknowns should stay INVESTIGATING, not killed.
|
|
187
233
|
`.trim();
|