// Copyright (C) 2025 Keygraph, Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License version 3 // as published by the Free Software Foundation. /** * Deterministic exploit collector → markdown renderer. * * Single entry point renderExploitDeliverable(vulnClass, state, idToType) * covers every exploitation agent, including the conditional `miscellaneous` class. The * per-class deltas are limited to title and ID prefix; every section, label, * and sort rule is class-agnostic. Section headers and bolded field labels * mirror the prescribed-Markdown skeleton from the existing exploit-*.txt * prompts so downstream report-executive — which reads prose with bolded * labels — sees the same structure it sees today. * * Field-label drift across the 5 prompts ("Evidence of Vulnerability" vs * "Why We Believe This Is Vulnerable"; "Attempted Exploitation" vs * "What We Tried") is canonicalized here to a single label per field across * all classes. * * Sort order is owned by the renderer: * - Successfully Exploited: severity desc (critical → low), then ID asc. * - Potential / Validation Blocked: confidence desc (high → low), then ID asc. * * ## Unprocessed Vulnerabilities surfaces queue IDs the collector did not see — * the v1 stand-in for required-call enforcement. The activity passes idToType * (queue ID → vulnerability_type, built from queue.json) so each entry renders * as `- {ID} ({vulnerability_type})`. Omitted when every queue ID was emitted. */ import type { AddExploitInput, ExploitClass } from '../collectors/exploit-collector.js'; // ============================================================================ // PER-CLASS CONSTANTS // ============================================================================ const TITLES: Record = { injection: 'Injection Exploitation Evidence', xss: 'Cross-Site Scripting (XSS) Exploitation Evidence', auth: 'Authentication Exploitation Evidence', ssrf: 'SSRF Exploitation Evidence', authz: 'Authorization Exploitation Evidence', miscellaneous: 'Miscellaneous Exploitation Evidence', }; // ============================================================================ // SORT ORDER // ============================================================================ const SEVERITY_ORDER: Record<'critical' | 'high' | 'medium' | 'low', number> = { critical: 0, high: 1, medium: 2, low: 3, }; const CONFIDENCE_ORDER: Record<'high' | 'medium' | 'low', number> = { high: 0, medium: 1, low: 2, }; type ExploitedEntry = Extract; type BlockedEntry = Extract; function sortExploited(entries: readonly ExploitedEntry[]): ExploitedEntry[] { return [...entries].sort((a, b) => { const sevDiff = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]; if (sevDiff !== 0) return sevDiff; return a.vulnerability_id.localeCompare(b.vulnerability_id); }); } function sortBlocked(entries: readonly BlockedEntry[]): BlockedEntry[] { return [...entries].sort((a, b) => { const confDiff = CONFIDENCE_ORDER[a.confidence] - CONFIDENCE_ORDER[b.confidence]; if (confDiff !== 0) return confDiff; return a.vulnerability_id.localeCompare(b.vulnerability_id); }); } // ============================================================================ // FIELD FORMATTERS // ============================================================================ function capitalize(value: string): string { if (value.length === 0) return value; return value[0]!.toUpperCase() + value.slice(1); } function renderNumberedList(steps: readonly string[]): string { return steps.map((step, idx) => `${idx + 1}. ${step}`).join('\n\n'); } // ============================================================================ // PER-FINDING RENDERERS // ============================================================================ function renderExploitedFinding(entry: ExploitedEntry): string { const lines: string[] = []; lines.push(`### ${entry.vulnerability_id}: ${entry.title}`); lines.push(''); lines.push('**Summary:**'); lines.push(`- **Vulnerable location:** ${entry.vulnerable_location}`); lines.push(`- **Overview:** ${entry.overview}`); lines.push(`- **Impact:** ${entry.impact}`); lines.push(`- **Severity:** ${capitalize(entry.severity)}`); lines.push(''); if (entry.prerequisites != null && entry.prerequisites.length > 0) { lines.push('**Prerequisites:**'); lines.push(entry.prerequisites); lines.push(''); } lines.push('**Exploitation Steps:**'); lines.push(renderNumberedList(entry.exploitation_steps)); lines.push(''); lines.push('**Proof of Impact:**'); lines.push(entry.proof_of_impact); if (entry.notes != null && entry.notes.length > 0) { lines.push(''); lines.push('**Notes:**'); lines.push(entry.notes); } return lines.join('\n'); } function renderBlockedFinding(entry: BlockedEntry): string { const lines: string[] = []; lines.push(`### ${entry.vulnerability_id}: ${entry.title}`); lines.push(''); lines.push('**Summary:**'); lines.push(`- **Vulnerable location:** ${entry.vulnerable_location}`); lines.push(`- **Overview:** ${entry.overview}`); lines.push(`- **Current Blocker:** ${entry.current_blocker}`); lines.push(`- **Potential Impact:** ${entry.potential_impact}`); lines.push(`- **Confidence:** ${entry.confidence.toUpperCase()}`); lines.push(''); if (entry.prerequisites != null && entry.prerequisites.length > 0) { lines.push('**Prerequisites:**'); lines.push(entry.prerequisites); lines.push(''); } lines.push('**Evidence of Vulnerability:**'); lines.push(entry.evidence_of_vulnerability); lines.push(''); lines.push('**What We Tried:**'); lines.push(entry.what_we_tried); lines.push(''); lines.push('**How This Would Be Exploited:**'); lines.push(renderNumberedList(entry.how_this_would_be_exploited)); lines.push(''); lines.push('**Expected Impact:**'); lines.push(entry.expected_impact); if (entry.notes != null && entry.notes.length > 0) { lines.push(''); lines.push('**Notes:**'); lines.push(entry.notes); } return lines.join('\n'); } // ============================================================================ // SECTION RENDERERS // ============================================================================ function renderExploitedSection(entries: readonly ExploitedEntry[]): string { const heading = '## Successfully Exploited Vulnerabilities'; if (entries.length === 0) { return [heading, '', '*No findings reached a definitive verdict in this category.*'].join('\n'); } const blocks = sortExploited(entries).map(renderExploitedFinding); return [heading, '', blocks.join('\n\n')].join('\n'); } function renderBlockedSection(entries: readonly BlockedEntry[]): string { const heading = '## Potential Vulnerabilities (Validation Blocked)'; if (entries.length === 0) { return [heading, '', '*No findings reached a definitive verdict in this category.*'].join('\n'); } const blocks = sortBlocked(entries).map(renderBlockedFinding); return [heading, '', blocks.join('\n\n')].join('\n'); } function renderUnprocessedSection(missingIds: readonly string[], idToType: ReadonlyMap): string { const heading = '## Unprocessed Vulnerabilities'; const sortedIds = [...missingIds].sort((a, b) => a.localeCompare(b)); const lines = sortedIds.map((id) => { const type = idToType.get(id); return type ? `- ${id} (${type})` : `- ${id}`; }); return [ heading, '', 'The following queue vulnerabilities did not receive a definitive verdict during this run:', '', lines.join('\n'), ].join('\n'); } // ============================================================================ // PUBLIC ENTRY POINT // ============================================================================ export function renderExploitDeliverable( vulnClass: ExploitClass, state: readonly AddExploitInput[], idToType: ReadonlyMap, ): string { const title = `# ${TITLES[vulnClass]}`; if (state.length === 0 && idToType.size === 0) { const body = '*No vulnerabilities were available in the queue for exploitation.*'; return `${title}\n\n${body}\n`; } const exploited = state.filter((e): e is ExploitedEntry => e.status === 'exploited'); const blocked = state.filter((e): e is BlockedEntry => e.status === 'blocked'); const emittedIds = new Set(state.map((e) => e.vulnerability_id)); const missingIds = [...idToType.keys()].filter((id) => !emittedIds.has(id)); const sections: string[] = [title, '', renderExploitedSection(exploited), '', renderBlockedSection(blocked)]; if (missingIds.length > 0) { sections.push(''); sections.push(renderUnprocessedSection(missingIds, idToType)); } return `${sections.join('\n').trimEnd()}\n`; }