mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-18 07:52:20 +02:00
Wire Agentic SAST and reconciliation into the main pipeline, persist their durable state, and add the Miscellaneous finding and exploitation lane. Make scan completion, cancellation, partial outcomes, resume identity, and report recovery use the integrated final workflow contract. Introduce the atomic finalization, ordering, renumbering, compaction, and output services that workflow calls. Keep completed Miscellaneous work and report drafts idempotent across resume, preserve public main's default-on exploit SARIF behavior, and describe stage-fallback candidates without claiming they were exported. BREAKING CHANGE: `vuln_classes` has been removed. Configs containing it now fail validation, and all five core pentest classes run on every scan. Workspaces created by Shannon 2.x cannot be resumed. Finish or discard in-flight scans before upgrading, then start a new workspace name.
233 lines
8.8 KiB
TypeScript
233 lines
8.8 KiB
TypeScript
// 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<ExploitClass, string> = {
|
|
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<AddExploitInput, { status: 'exploited' }>;
|
|
type BlockedEntry = Extract<AddExploitInput, { status: 'blocked' }>;
|
|
|
|
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, string>): 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, string>,
|
|
): 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`;
|
|
}
|