mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-26 19:42:07 +02:00
feat(worker): structure intermediate deliverables via MCP collectors (#350)
This commit is contained in:
@@ -54,6 +54,7 @@ export interface AgentExecutionInput {
|
||||
apiKey?: string | undefined;
|
||||
promptDir?: string | undefined;
|
||||
providerConfig?: import('../types/config.js').ProviderConfig | undefined;
|
||||
mcpServers?: Record<string, import('@anthropic-ai/claude-agent-sdk').McpServerConfig>;
|
||||
}
|
||||
|
||||
interface FailAgentOpts {
|
||||
@@ -108,6 +109,7 @@ export class AgentExecutionService {
|
||||
apiKey,
|
||||
promptDir,
|
||||
providerConfig,
|
||||
mcpServers,
|
||||
} = input;
|
||||
|
||||
// 1. Load config (pre-parsed configData → raw YAML → file path)
|
||||
@@ -176,6 +178,7 @@ export class AgentExecutionService {
|
||||
apiKey,
|
||||
path.relative(repoPath, deliverablesPath),
|
||||
providerConfig,
|
||||
mcpServers,
|
||||
);
|
||||
|
||||
// 6. Spending cap check - defense-in-depth
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// 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 all 5 exploitation agents (injection, xss, auth, ssrf, authz). 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
|
||||
* give downstream report-executive — which reads prose with bolded labels —
|
||||
* a consistent structure to parse, with a single canonical 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.
|
||||
*/
|
||||
|
||||
import type { AddExploitInput, VulnClass } from '../mcp-server/exploit-collector.js';
|
||||
|
||||
// ============================================================================
|
||||
// PER-CLASS CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
const TITLES: Record<VulnClass, string> = {
|
||||
injection: 'Injection Exploitation Evidence',
|
||||
xss: 'Cross-Site Scripting (XSS) Exploitation Evidence',
|
||||
auth: 'Authentication Exploitation Evidence',
|
||||
ssrf: 'SSRF Exploitation Evidence',
|
||||
authz: 'Authorization 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(`- **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');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC ENTRY POINT
|
||||
// ============================================================================
|
||||
|
||||
export function renderExploitDeliverable(
|
||||
vulnClass: VulnClass,
|
||||
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 sections: string[] = [title, '', renderExploitedSection(exploited), '', renderBlockedSection(blocked)];
|
||||
|
||||
return `${sections.join('\n').trimEnd()}\n`;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// 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 pre-recon collector → markdown renderer.
|
||||
*
|
||||
* Converts the typed payload bag harvested from the pre-recon-collector MCP
|
||||
* server into the pre_recon_deliverable.md Markdown layout. No LLM in the
|
||||
* loop; section ordering, headings, and template are owned here.
|
||||
*
|
||||
* Any tool the agent skips becomes a `[Section X: not provided]` placeholder
|
||||
* rather than an activity failure. Every section renderer accepts the
|
||||
* corresponding collected field as possibly undefined and falls back to the
|
||||
* placeholder helper when absent.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApplicationIntelligenceInput,
|
||||
AuthDeepDiveInput,
|
||||
CodebaseIndexingInput,
|
||||
CriticalFilePathsInput,
|
||||
ExecutiveSummaryInput,
|
||||
PreReconData,
|
||||
SinkRef,
|
||||
SsrfSinksInput,
|
||||
XssSinksInput,
|
||||
} from '../mcp-server/pre-recon-collector.js';
|
||||
|
||||
// ============================================================================
|
||||
// STATIC PROSE
|
||||
// ============================================================================
|
||||
|
||||
const SCOPE_AND_BOUNDARIES = `# Penetration Test Scope & Boundaries
|
||||
|
||||
**Primary Directive:** Your analysis is strictly limited to the **network-accessible attack surface** of the application. All subsequent tasks must adhere to this scope. Before reporting any finding (e.g., an entry point, a vulnerability sink), you must first verify it meets the "In-Scope" criteria.
|
||||
|
||||
### In-Scope: Network-Reachable Components
|
||||
A component is considered **in-scope** if its execution can be initiated, directly or indirectly, by a network request that the deployed application server is capable of receiving. This includes:
|
||||
- Publicly exposed web pages and API endpoints.
|
||||
- Endpoints requiring authentication via the application's standard login mechanisms.
|
||||
- Any developer utility, debug console, or script that has been mistakenly exposed through a route or is otherwise callable from other in-scope, network-reachable code.
|
||||
|
||||
### Out-of-Scope: Locally Executable Only
|
||||
A component is **out-of-scope** if it **cannot** be invoked through the running application's network interface and requires an execution context completely external to the application's request-response cycle. This includes tools that must be run via:
|
||||
- A command-line interface (e.g., \`go run ./cmd/...\`, \`python scripts/...\`).
|
||||
- A development environment's internal tooling (e.g., a "run script" button in an IDE).
|
||||
- CI/CD pipeline scripts or build tools (e.g., Dagger build definitions).
|
||||
- Database migration scripts, backup tools, or maintenance utilities.
|
||||
- Local development servers, test harnesses, or debugging utilities.
|
||||
- Static files or scripts that require manual opening in a browser (not served by the application).`;
|
||||
|
||||
// ============================================================================
|
||||
// SHARED HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function placeholder(sectionLabel: string, toolName: string): string {
|
||||
return `_[${sectionLabel}: not provided — \`${toolName}\` was not called]_`;
|
||||
}
|
||||
|
||||
function bulletField(label: string, value: string): string {
|
||||
return `- **${label}:** ${value}`;
|
||||
}
|
||||
|
||||
function bulletPaths(label: string, paths: readonly string[]): string {
|
||||
if (paths.length === 0) {
|
||||
return `- **${label}:** *(none identified)*`;
|
||||
}
|
||||
const formatted = paths.map((p) => `\`${p}\``).join(', ');
|
||||
return `- **${label}:** ${formatted}`;
|
||||
}
|
||||
|
||||
function renderSinkList(sinks: readonly SinkRef[]): string {
|
||||
if (sinks.length === 0) {
|
||||
return '*(scanned, no sinks of this kind found)*';
|
||||
}
|
||||
return sinks
|
||||
.map((sink) => {
|
||||
const head = `- **${sink.sink_function}** at \`${sink.location}\``;
|
||||
if (sink.notes && sink.notes.trim() !== '') {
|
||||
return `${head} — ${sink.notes.trim()}`;
|
||||
}
|
||||
return head;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SECTION RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
function renderExecutiveSummarySection(data: ExecutiveSummaryInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 1. Executive Summary', '', placeholder('Section 1', 'set_executive_summary')].join('\n');
|
||||
}
|
||||
return ['## 1. Executive Summary', '', data.text].join('\n');
|
||||
}
|
||||
|
||||
function renderArchitectureSection(intel: ApplicationIntelligenceInput | undefined): string {
|
||||
if (!intel) {
|
||||
return ['## 2. Architecture & Technology Stack', '', placeholder('Section 2', 'set_application_intelligence')].join(
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
const { architecture: a } = intel;
|
||||
return [
|
||||
'## 2. Architecture & Technology Stack',
|
||||
'',
|
||||
bulletField('Framework & Language', a.framework_and_language),
|
||||
bulletField('Architectural Pattern', a.architectural_pattern),
|
||||
bulletField('Critical Security Components', a.critical_security_components),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderAuthSection(auth: AuthDeepDiveInput | undefined): string {
|
||||
if (!auth) {
|
||||
return ['## 3. Authentication & Authorization Deep Dive', '', placeholder('Section 3', 'set_auth_deep_dive')].join(
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
const ssoLine = auth.sso_oauth_oidc
|
||||
? bulletField('SSO/OAuth/OIDC Flows', auth.sso_oauth_oidc)
|
||||
: bulletField('SSO/OAuth/OIDC Flows', 'Not applicable — no SSO/OAuth/OIDC integration detected.');
|
||||
return [
|
||||
'## 3. Authentication & Authorization Deep Dive',
|
||||
'',
|
||||
bulletField('Authentication Mechanisms', auth.authentication_mechanisms),
|
||||
bulletField('Session Management', auth.session_management),
|
||||
bulletField('Authorization Model', auth.authz_model),
|
||||
bulletField('Multi-tenancy', auth.multi_tenancy),
|
||||
ssoLine,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderDataSecuritySection(intel: ApplicationIntelligenceInput | undefined): string {
|
||||
if (!intel) {
|
||||
return ['## 4. Data Security & Storage', '', placeholder('Section 4', 'set_application_intelligence')].join('\n');
|
||||
}
|
||||
const { data_security: d } = intel;
|
||||
return [
|
||||
'## 4. Data Security & Storage',
|
||||
'',
|
||||
bulletField('Database Security', d.database_security),
|
||||
bulletField('Data Flow Security', d.data_flow_security),
|
||||
bulletField('Multi-tenant Data Isolation', d.multi_tenant_isolation),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderAttackSurfaceSection(intel: ApplicationIntelligenceInput | undefined): string {
|
||||
if (!intel) {
|
||||
return ['## 5. Attack Surface Analysis', '', placeholder('Section 5', 'set_application_intelligence')].join('\n');
|
||||
}
|
||||
const { attack_surface: s } = intel;
|
||||
return [
|
||||
'## 5. Attack Surface Analysis',
|
||||
'',
|
||||
bulletField('External Entry Points', s.external_entry_points),
|
||||
bulletField('Internal Service Communication', s.internal_service_communication),
|
||||
bulletField('Input Validation Patterns', s.input_validation_patterns),
|
||||
bulletField('Background Processing', s.background_processing),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderInfrastructureSection(intel: ApplicationIntelligenceInput | undefined): string {
|
||||
if (!intel) {
|
||||
return [
|
||||
'## 6. Infrastructure & Operational Security',
|
||||
'',
|
||||
placeholder('Section 6', 'set_application_intelligence'),
|
||||
].join('\n');
|
||||
}
|
||||
const { infrastructure: i } = intel;
|
||||
return [
|
||||
'## 6. Infrastructure & Operational Security',
|
||||
'',
|
||||
bulletField('Secrets Management', i.secrets_management),
|
||||
bulletField('Configuration Security', i.configuration_security),
|
||||
bulletField('External Dependencies', i.external_dependencies),
|
||||
bulletField('Monitoring & Logging', i.monitoring_and_logging),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderCodebaseIndexingSection(data: CodebaseIndexingInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 7. Overall Codebase Indexing', '', placeholder('Section 7', 'set_codebase_indexing')].join('\n');
|
||||
}
|
||||
return ['## 7. Overall Codebase Indexing', '', data.text].join('\n');
|
||||
}
|
||||
|
||||
function renderCriticalFilePathsSection(paths: CriticalFilePathsInput | undefined): string {
|
||||
if (!paths) {
|
||||
return ['## 8. Critical File Paths', '', placeholder('Section 8', 'set_critical_file_paths')].join('\n');
|
||||
}
|
||||
return [
|
||||
'## 8. Critical File Paths',
|
||||
'',
|
||||
bulletPaths('Configuration', paths.configuration),
|
||||
bulletPaths('Authentication & Authorization', paths.authentication_and_authorization),
|
||||
bulletPaths('API & Routing', paths.api_and_routing),
|
||||
bulletPaths('Data Models & DB Interaction', paths.data_models_and_db),
|
||||
bulletPaths('Dependency Manifests', paths.dependency_manifests),
|
||||
bulletPaths('Sensitive Data & Secrets Handling', paths.sensitive_data_and_secrets),
|
||||
bulletPaths('Middleware & Input Validation', paths.middleware_and_input_validation),
|
||||
bulletPaths('Logging & Monitoring', paths.logging_and_monitoring),
|
||||
bulletPaths('Infrastructure & Deployment', paths.infrastructure_and_deployment),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderXssSection(xss: XssSinksInput | undefined): string {
|
||||
if (!xss) {
|
||||
return ['## 9. XSS Sinks and Render Contexts', '', placeholder('Section 9', 'set_xss_sinks')].join('\n');
|
||||
}
|
||||
if (!xss.applicable) {
|
||||
return [
|
||||
'## 9. XSS Sinks and Render Contexts',
|
||||
'',
|
||||
'*(N/A — the application has no web frontend; XSS sink analysis does not apply.)*',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
'## 9. XSS Sinks and Render Contexts',
|
||||
'',
|
||||
'### HTML Body Context',
|
||||
renderSinkList(xss.html_body),
|
||||
'',
|
||||
'### HTML Attribute Context',
|
||||
renderSinkList(xss.html_attribute),
|
||||
'',
|
||||
'### JavaScript Context',
|
||||
renderSinkList(xss.javascript),
|
||||
'',
|
||||
'### CSS Context',
|
||||
renderSinkList(xss.css),
|
||||
'',
|
||||
'### URL Context',
|
||||
renderSinkList(xss.url),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderSsrfSection(ssrf: SsrfSinksInput | undefined): string {
|
||||
if (!ssrf) {
|
||||
return ['## 10. SSRF Sinks', '', placeholder('Section 10', 'set_ssrf_sinks')].join('\n');
|
||||
}
|
||||
if (!ssrf.applicable) {
|
||||
return [
|
||||
'## 10. SSRF Sinks',
|
||||
'',
|
||||
'*(N/A — the application makes no outbound requests; SSRF sink analysis does not apply.)*',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
'## 10. SSRF Sinks',
|
||||
'',
|
||||
'### HTTP(S) Clients',
|
||||
renderSinkList(ssrf.http_clients),
|
||||
'',
|
||||
'### Raw Sockets & Connect APIs',
|
||||
renderSinkList(ssrf.raw_sockets),
|
||||
'',
|
||||
'### URL Openers & File Includes',
|
||||
renderSinkList(ssrf.url_openers),
|
||||
'',
|
||||
'### Redirect & "Next URL" Handlers',
|
||||
renderSinkList(ssrf.redirect_handlers),
|
||||
'',
|
||||
'### Headless Browsers & Render Engines',
|
||||
renderSinkList(ssrf.headless_browsers),
|
||||
'',
|
||||
'### Media Processors',
|
||||
renderSinkList(ssrf.media_processors),
|
||||
'',
|
||||
'### Link Preview & Unfurlers',
|
||||
renderSinkList(ssrf.link_preview),
|
||||
'',
|
||||
'### Webhook Testers & Callback Verifiers',
|
||||
renderSinkList(ssrf.webhook_testers),
|
||||
'',
|
||||
'### SSO/OIDC Discovery & JWKS Fetchers',
|
||||
renderSinkList(ssrf.sso_oidc_discovery),
|
||||
'',
|
||||
'### Importers & Data Loaders',
|
||||
renderSinkList(ssrf.importers),
|
||||
'',
|
||||
'### Package/Plugin/Theme Installers',
|
||||
renderSinkList(ssrf.package_installers),
|
||||
'',
|
||||
'### Monitoring & Health Check Frameworks',
|
||||
renderSinkList(ssrf.monitoring_and_health),
|
||||
'',
|
||||
'### Cloud Metadata Helpers',
|
||||
renderSinkList(ssrf.cloud_metadata),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC ENTRY POINT
|
||||
// ============================================================================
|
||||
|
||||
export function renderPreRecon(data: PreReconData): string {
|
||||
const sections: string[] = [
|
||||
SCOPE_AND_BOUNDARIES,
|
||||
'---',
|
||||
'',
|
||||
renderExecutiveSummarySection(data.executive_summary),
|
||||
'',
|
||||
renderArchitectureSection(data.application_intelligence),
|
||||
'',
|
||||
renderAuthSection(data.auth_deep_dive),
|
||||
'',
|
||||
renderDataSecuritySection(data.application_intelligence),
|
||||
'',
|
||||
renderAttackSurfaceSection(data.application_intelligence),
|
||||
'',
|
||||
renderInfrastructureSection(data.application_intelligence),
|
||||
'',
|
||||
renderCodebaseIndexingSection(data.codebase_indexing),
|
||||
'',
|
||||
renderCriticalFilePathsSection(data.critical_file_paths),
|
||||
'',
|
||||
renderXssSection(data.xss_sinks),
|
||||
'',
|
||||
renderSsrfSection(data.ssrf_sinks),
|
||||
'',
|
||||
];
|
||||
return `${sections.join('\n').trimEnd()}\n`;
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
// 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 recon collector → markdown renderer.
|
||||
*
|
||||
* Converts the typed payload bag harvested from the recon-collector MCP server
|
||||
* into the recon_deliverable.md Markdown layout. No LLM in the loop; section
|
||||
* ordering, headings, sort, and the Section 0 boilerplate are owned here.
|
||||
*
|
||||
* Any tool the agent skips becomes a `[Section X: not provided]` placeholder
|
||||
* rather than an activity failure. Every section renderer accepts its input as
|
||||
* optional.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AuthenticationInput,
|
||||
AuthzCandidatesInput,
|
||||
ContextCandidate,
|
||||
Endpoint,
|
||||
Entity,
|
||||
ExecutiveSummaryInput,
|
||||
Flow,
|
||||
Guard,
|
||||
HorizontalCandidate,
|
||||
InjectionSourcesInput,
|
||||
InputVectorsInput,
|
||||
NetworkMapInput,
|
||||
Priority,
|
||||
ReconData,
|
||||
Role,
|
||||
RoleArchitectureInput,
|
||||
SinkRef,
|
||||
TechnologyStackInput,
|
||||
VerticalCandidate,
|
||||
} from '../mcp-server/recon-collector.js';
|
||||
|
||||
type RoleSwitchingImpersonation = AuthenticationInput['role_switching_impersonation'];
|
||||
type EntityZone = Entity['zone'];
|
||||
|
||||
// ============================================================================
|
||||
// STATIC PROSE
|
||||
// ============================================================================
|
||||
|
||||
const HOW_TO_READ_THIS = `## 0) HOW TO READ THIS
|
||||
This reconnaissance report provides a comprehensive map of the application's attack surface, with special emphasis on authorization and privilege escalation opportunities for the Authorization Analysis Specialist.
|
||||
|
||||
**Key Sections for Authorization Analysis:**
|
||||
- **Section 4 (API Endpoint Inventory):** Contains authorization details for each endpoint - focus on "Required Role" and "Object ID Parameters" columns to identify IDOR candidates.
|
||||
- **Section 6.4 (Guards Directory):** Catalog of authorization controls - understand what each guard means before analyzing vulnerabilities.
|
||||
- **Section 7 (Role & Privilege Architecture):** Complete role hierarchy and privilege mapping - use this to understand the privilege lattice and identify escalation targets.
|
||||
- **Section 8 (Authorization Vulnerability Candidates):** Pre-prioritized lists of endpoints for horizontal, vertical, and context-based authorization testing.
|
||||
|
||||
**How to Use the Network Mapping (Section 6):** The entity/flow mapping shows system boundaries and data sensitivity levels. Pay special attention to flows marked with authorization guards and entities handling PII/sensitive data.
|
||||
|
||||
**Priority Order for Testing:** Start with Section 8's High-priority horizontal candidates, then vertical escalation endpoints for each role level, finally context-based workflow bypasses.`;
|
||||
|
||||
// ============================================================================
|
||||
// SORT ORDER CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
// Zones are sorted by exposure (Internet → Edge → ... → ThirdParty), not alphabetically,
|
||||
// per the design doc's "clusters by zone" requirement. A reader scanning the entities
|
||||
// table sees external surface first, internal trust core last.
|
||||
const ZONE_ORDER: Record<EntityZone, number> = {
|
||||
Internet: 0,
|
||||
Edge: 1,
|
||||
App: 2,
|
||||
Data: 3,
|
||||
Admin: 4,
|
||||
BuildCI: 5,
|
||||
ThirdParty: 6,
|
||||
};
|
||||
|
||||
const PRIORITY_ORDER: Record<Priority, number> = {
|
||||
High: 0,
|
||||
Medium: 1,
|
||||
Low: 2,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SHARED HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function placeholder(sectionLabel: string, toolName: string): string {
|
||||
return `_[${sectionLabel}: not provided — \`${toolName}\` was not called]_`;
|
||||
}
|
||||
|
||||
function bulletField(label: string, value: string): string {
|
||||
return `- **${label}:** ${value}`;
|
||||
}
|
||||
|
||||
function bulletList(label: string, items: readonly string[]): string {
|
||||
if (items.length === 0) {
|
||||
return `- **${label}:** *(none identified)*`;
|
||||
}
|
||||
return `- **${label}:**\n${items.map((entry) => ` - ${entry}`).join('\n')}`;
|
||||
}
|
||||
|
||||
function escapePipe(value: string): string {
|
||||
return value.replace(/\|/g, '\\|');
|
||||
}
|
||||
|
||||
function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
|
||||
const headerRow = `| ${headers.map(escapePipe).join(' | ')} |`;
|
||||
const separator = `| ${headers.map(() => '---').join(' | ')} |`;
|
||||
const body = rows.map((row) => `| ${row.map(escapePipe).join(' | ')} |`).join('\n');
|
||||
return [headerRow, separator, body].filter((line) => line.length > 0).join('\n');
|
||||
}
|
||||
|
||||
function renderSinkList(sinks: readonly SinkRef[]): string {
|
||||
if (sinks.length === 0) {
|
||||
return '*(scanned, no sources of this kind found)*';
|
||||
}
|
||||
return sinks
|
||||
.map((sink) => {
|
||||
const head = `- **${sink.sink_function}** at \`${sink.location}\``;
|
||||
if (sink.notes && sink.notes.trim() !== '') {
|
||||
return `${head} — ${sink.notes.trim()}`;
|
||||
}
|
||||
return head;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SECTION RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
function renderHowToReadThis(): string {
|
||||
return HOW_TO_READ_THIS;
|
||||
}
|
||||
|
||||
function renderExecutiveSummary(data: ExecutiveSummaryInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 1. Executive Summary', '', placeholder('Section 1', 'set_executive_summary')].join('\n');
|
||||
}
|
||||
return ['## 1. Executive Summary', '', data.text].join('\n');
|
||||
}
|
||||
|
||||
function renderTechnologyStack(data: TechnologyStackInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 2. Technology & Service Map', '', placeholder('Section 2', 'set_technology_stack')].join('\n');
|
||||
}
|
||||
return [
|
||||
'## 2. Technology & Service Map',
|
||||
'',
|
||||
bulletField('Frontend', data.frontend),
|
||||
bulletField('Backend', data.backend),
|
||||
bulletField('Infrastructure', data.infrastructure),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderRoleSwitching(rs: RoleSwitchingImpersonation): string {
|
||||
if (!rs.applicable) {
|
||||
return [
|
||||
'### 3.3 Role Switching & Impersonation',
|
||||
'',
|
||||
'*(Not applicable — no impersonation, sudo mode, or role-switching features were identified.)*',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
'### 3.3 Role Switching & Impersonation',
|
||||
'',
|
||||
bulletField('Impersonation Features', rs.impersonation_features ?? '*(not specified)*'),
|
||||
bulletField('Role Switching', rs.role_switching ?? '*(not specified)*'),
|
||||
bulletField('Audit Trail', rs.audit_trail ?? '*(not specified)*'),
|
||||
bulletField('Code Implementation', rs.code_implementation ?? '*(not specified)*'),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderAuthentication(data: AuthenticationInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 3. Authentication & Session Management Flow', '', placeholder('Section 3', 'set_authentication')].join(
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
const { session_flow: sf, role_assignment: ra, privilege_storage: ps } = data;
|
||||
return [
|
||||
'## 3. Authentication & Session Management Flow',
|
||||
'',
|
||||
bulletField('Entry Points', sf.entry_points),
|
||||
bulletField('Mechanism', sf.mechanism),
|
||||
bulletField('Code Pointers', sf.code_pointers),
|
||||
'',
|
||||
'### 3.1 Role Assignment Process',
|
||||
'',
|
||||
bulletField('Role Determination', ra.role_determination),
|
||||
bulletField('Default Role', ra.default_role),
|
||||
bulletField('Role Upgrade Path', ra.role_upgrade_path),
|
||||
bulletField('Code Implementation', ra.code_implementation),
|
||||
'',
|
||||
'### 3.2 Privilege Storage & Validation',
|
||||
'',
|
||||
bulletField('Storage Location', ps.storage_location),
|
||||
bulletField('Validation Points', ps.validation_points),
|
||||
bulletField('Cache/Session Persistence', ps.cache_session_persistence),
|
||||
bulletField('Code Pointers', ps.code_pointers),
|
||||
'',
|
||||
renderRoleSwitching(data.role_switching_impersonation),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sortEndpoints(endpoints: readonly Endpoint[]): Endpoint[] {
|
||||
return [...endpoints].sort((a, b) => {
|
||||
if (a.path !== b.path) return a.path.localeCompare(b.path);
|
||||
return a.method.localeCompare(b.method);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEndpoints(endpoints: readonly Endpoint[] | undefined): string {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
return ['## 4. API Endpoint Inventory', '', placeholder('Section 4', 'add_endpoints')].join('\n');
|
||||
}
|
||||
const sorted = sortEndpoints(endpoints);
|
||||
const rows = sorted.map((e) => [
|
||||
e.method,
|
||||
e.path,
|
||||
e.required_role,
|
||||
e.object_id_parameters.length > 0 ? e.object_id_parameters.join(', ') : 'None',
|
||||
e.authorization_mechanism,
|
||||
`${e.description} (${e.code_pointer})`,
|
||||
]);
|
||||
return [
|
||||
'## 4. API Endpoint Inventory',
|
||||
'',
|
||||
renderTable(
|
||||
[
|
||||
'Method',
|
||||
'Endpoint Path',
|
||||
'Required Role',
|
||||
'Object ID Parameters',
|
||||
'Authorization Mechanism',
|
||||
'Description & Code Pointer',
|
||||
],
|
||||
rows,
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderInputVectors(data: InputVectorsInput | undefined): string {
|
||||
if (!data) {
|
||||
return [
|
||||
'## 5. Potential Input Vectors for Vulnerability Analysis',
|
||||
'',
|
||||
placeholder('Section 5', 'set_input_vectors'),
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
'## 5. Potential Input Vectors for Vulnerability Analysis',
|
||||
'',
|
||||
bulletList('URL Parameters', data.url_parameters),
|
||||
bulletList('POST Body Fields (JSON/Form)', data.post_body_fields),
|
||||
bulletList('HTTP Headers', data.http_headers),
|
||||
bulletList('Cookie Values', data.cookie_values),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sortEntities(entities: readonly Entity[]): Entity[] {
|
||||
return [...entities].sort((a, b) => {
|
||||
const zoneDiff = ZONE_ORDER[a.zone] - ZONE_ORDER[b.zone];
|
||||
if (zoneDiff !== 0) return zoneDiff;
|
||||
if (a.type !== b.type) return a.type.localeCompare(b.type);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
function sortFlows(flows: readonly Flow[]): Flow[] {
|
||||
return [...flows].sort((a, b) => {
|
||||
if (a.from !== b.from) return a.from.localeCompare(b.from);
|
||||
if (a.to !== b.to) return a.to.localeCompare(b.to);
|
||||
return a.path_port.localeCompare(b.path_port);
|
||||
});
|
||||
}
|
||||
|
||||
function sortGuards(guards: readonly Guard[]): Guard[] {
|
||||
return [...guards].sort((a, b) => {
|
||||
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEntitiesTable(entities: readonly Entity[]): string {
|
||||
const rows = entities.map((e) => [e.title, e.type, e.zone, e.tech, e.data.join(', '), e.notes]);
|
||||
return renderTable(['Title', 'Type', 'Zone', 'Tech', 'Data', 'Notes'], rows);
|
||||
}
|
||||
|
||||
function renderEntityMetadataTable(entities: readonly Entity[]): string {
|
||||
const rows = entities.map((e) => {
|
||||
const metadataLine =
|
||||
e.metadata.length > 0 ? e.metadata.map(({ key, value }) => `${key}: ${value}`).join('; ') : '*(none)*';
|
||||
return [e.title, metadataLine];
|
||||
});
|
||||
return renderTable(['Title', 'Metadata'], rows);
|
||||
}
|
||||
|
||||
function renderFlowsTable(flows: readonly Flow[]): string {
|
||||
const rows = flows.map((f) => [
|
||||
`${f.from} → ${f.to}`,
|
||||
f.channel,
|
||||
f.path_port,
|
||||
f.guards.length > 0 ? f.guards.join(', ') : 'None',
|
||||
f.touches.length > 0 ? f.touches.join(', ') : 'Public',
|
||||
]);
|
||||
return renderTable(['FROM → TO', 'Channel', 'Path/Port', 'Guards', 'Touches'], rows);
|
||||
}
|
||||
|
||||
function renderGuardsTable(guards: readonly Guard[]): string {
|
||||
const rows = guards.map((g) => [g.name, g.category, g.statement]);
|
||||
return renderTable(['Guard Name', 'Category', 'Statement'], rows);
|
||||
}
|
||||
|
||||
function renderNetworkMap(data: NetworkMapInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 6. Network & Interaction Map', '', placeholder('Section 6', 'set_network_map')].join('\n');
|
||||
}
|
||||
const entities = sortEntities(data.entities);
|
||||
const flows = sortFlows(data.flows);
|
||||
const guards = sortGuards(data.guards);
|
||||
return [
|
||||
'## 6. Network & Interaction Map',
|
||||
'',
|
||||
'### 6.1 Entities',
|
||||
'',
|
||||
entities.length > 0 ? renderEntitiesTable(entities) : '*(no entities recorded)*',
|
||||
'',
|
||||
'### 6.2 Entity Metadata',
|
||||
'',
|
||||
entities.length > 0 ? renderEntityMetadataTable(entities) : '*(no entities recorded)*',
|
||||
'',
|
||||
'### 6.3 Flows (Connections)',
|
||||
'',
|
||||
flows.length > 0 ? renderFlowsTable(flows) : '*(no flows recorded)*',
|
||||
'',
|
||||
'### 6.4 Guards Directory',
|
||||
'',
|
||||
guards.length > 0 ? renderGuardsTable(guards) : '*(no guards recorded)*',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sortRoles(roles: readonly Role[]): Role[] {
|
||||
return [...roles].sort((a, b) => {
|
||||
if (a.privilege_level !== b.privilege_level) return a.privilege_level - b.privilege_level;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRoleArchitecture(data: RoleArchitectureInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 7. Role & Privilege Architecture', '', placeholder('Section 7', 'set_role_architecture')].join('\n');
|
||||
}
|
||||
const roles = sortRoles(data.roles);
|
||||
const discoveredRows = roles.map((r) => [r.name, String(r.privilege_level), r.scope_domain, r.code_implementation]);
|
||||
const entryPointRows = roles.map((r) => [
|
||||
r.name,
|
||||
r.default_landing_page,
|
||||
r.accessible_route_patterns.length > 0 ? r.accessible_route_patterns.join(', ') : 'None',
|
||||
r.authentication_method,
|
||||
]);
|
||||
const codeMappingRows = roles.map((r) => [r.name, r.middleware_guards, r.permission_checks, r.storage_location]);
|
||||
const lattice = data.privilege_lattice;
|
||||
const latticeBlock = [
|
||||
'```',
|
||||
`Privilege Ordering (→ means "can access resources of"):`,
|
||||
lattice.ordering_diagram,
|
||||
'',
|
||||
`Parallel Isolation (|| means "not ordered relative to each other"):`,
|
||||
lattice.parallel_isolation_notes,
|
||||
'```',
|
||||
].join('\n');
|
||||
const sections = [
|
||||
'## 7. Role & Privilege Architecture',
|
||||
'',
|
||||
'### 7.1 Discovered Roles',
|
||||
'',
|
||||
roles.length > 0
|
||||
? renderTable(['Role Name', 'Privilege Level', 'Scope/Domain', 'Code Implementation'], discoveredRows)
|
||||
: '*(no roles recorded)*',
|
||||
'',
|
||||
'### 7.2 Privilege Lattice',
|
||||
'',
|
||||
latticeBlock,
|
||||
];
|
||||
if (lattice.role_switching_notes && lattice.role_switching_notes.trim() !== '') {
|
||||
sections.push('', `**Note:** ${lattice.role_switching_notes.trim()}`);
|
||||
}
|
||||
sections.push(
|
||||
'',
|
||||
'### 7.3 Role Entry Points',
|
||||
'',
|
||||
roles.length > 0
|
||||
? renderTable(
|
||||
['Role', 'Default Landing Page', 'Accessible Route Patterns', 'Authentication Method'],
|
||||
entryPointRows,
|
||||
)
|
||||
: '*(no roles recorded)*',
|
||||
'',
|
||||
'### 7.4 Role-to-Code Mapping',
|
||||
'',
|
||||
roles.length > 0
|
||||
? renderTable(['Role', 'Middleware/Guards', 'Permission Checks', 'Storage Location'], codeMappingRows)
|
||||
: '*(no roles recorded)*',
|
||||
);
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function sortHorizontal(items: readonly HorizontalCandidate[]): HorizontalCandidate[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const pri = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
|
||||
if (pri !== 0) return pri;
|
||||
return a.endpoint_pattern.localeCompare(b.endpoint_pattern);
|
||||
});
|
||||
}
|
||||
|
||||
function sortVertical(items: readonly VerticalCandidate[]): VerticalCandidate[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const pri = PRIORITY_ORDER[a.risk_level] - PRIORITY_ORDER[b.risk_level];
|
||||
if (pri !== 0) return pri;
|
||||
return a.endpoint_pattern.localeCompare(b.endpoint_pattern);
|
||||
});
|
||||
}
|
||||
|
||||
function sortContext(items: readonly ContextCandidate[]): ContextCandidate[] {
|
||||
return [...items].sort((a, b) => a.endpoint.localeCompare(b.endpoint));
|
||||
}
|
||||
|
||||
function renderAuthzCandidates(data: AuthzCandidatesInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 8. Authorization Vulnerability Candidates', '', placeholder('Section 8', 'set_authz_candidates')].join(
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
const horizontal = sortHorizontal(data.horizontal);
|
||||
const vertical = sortVertical(data.vertical);
|
||||
const context = sortContext(data.context);
|
||||
|
||||
let idCounter = 0;
|
||||
const nextId = (): string => {
|
||||
idCounter += 1;
|
||||
return `AUTHZ-CAND-${String(idCounter).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const horizontalRows = horizontal.map((c) => [
|
||||
nextId(),
|
||||
c.priority,
|
||||
c.endpoint_pattern,
|
||||
c.object_id_parameter,
|
||||
c.data_type,
|
||||
c.sensitivity,
|
||||
]);
|
||||
const verticalRows = vertical.map((c) => [
|
||||
nextId(),
|
||||
c.target_role,
|
||||
c.endpoint_pattern,
|
||||
c.functionality,
|
||||
c.risk_level,
|
||||
]);
|
||||
const contextRows = context.map((c) => [
|
||||
nextId(),
|
||||
c.workflow,
|
||||
c.endpoint,
|
||||
c.expected_prior_state,
|
||||
c.bypass_potential,
|
||||
]);
|
||||
|
||||
return [
|
||||
'## 8. Authorization Vulnerability Candidates',
|
||||
'',
|
||||
'### 8.1 Horizontal Privilege Escalation Candidates',
|
||||
'',
|
||||
horizontal.length > 0
|
||||
? renderTable(
|
||||
['ID', 'Priority', 'Endpoint Pattern', 'Object ID Parameter', 'Data Type', 'Sensitivity'],
|
||||
horizontalRows,
|
||||
)
|
||||
: '*(no horizontal candidates identified)*',
|
||||
'',
|
||||
'### 8.2 Vertical Privilege Escalation Candidates',
|
||||
'',
|
||||
vertical.length > 0
|
||||
? renderTable(['ID', 'Target Role', 'Endpoint Pattern', 'Functionality', 'Risk Level'], verticalRows)
|
||||
: '*(no vertical candidates identified)*',
|
||||
'',
|
||||
'### 8.3 Context-Based Authorization Candidates',
|
||||
'',
|
||||
context.length > 0
|
||||
? renderTable(['ID', 'Workflow', 'Endpoint', 'Expected Prior State', 'Bypass Potential'], contextRows)
|
||||
: '*(no context-based candidates identified)*',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderInjectionSources(data: InjectionSourcesInput | undefined): string {
|
||||
const heading =
|
||||
'## 9. Injection Sources (Command Injection, SQL Injection, LFI/RFI, SSTI, Path Traversal, Deserialization)';
|
||||
if (!data) {
|
||||
return [heading, '', placeholder('Section 9', 'set_injection_sources')].join('\n');
|
||||
}
|
||||
if (!data.applicable) {
|
||||
return [
|
||||
heading,
|
||||
'',
|
||||
'*(Not applicable — this application has no network-accessible code paths to dangerous sinks.)*',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
heading,
|
||||
'',
|
||||
'### Command Injection',
|
||||
renderSinkList(data.command_injection),
|
||||
'',
|
||||
'### SQL Injection',
|
||||
renderSinkList(data.sql_injection),
|
||||
'',
|
||||
'### LFI/RFI',
|
||||
renderSinkList(data.lfi_rfi),
|
||||
'',
|
||||
'### Path Traversal',
|
||||
renderSinkList(data.path_traversal),
|
||||
'',
|
||||
'### SSTI',
|
||||
renderSinkList(data.ssti),
|
||||
'',
|
||||
'### Deserialization',
|
||||
renderSinkList(data.deserialization),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC ENTRY POINT
|
||||
// ============================================================================
|
||||
|
||||
export function renderRecon(data: ReconData): string {
|
||||
const sections: string[] = [
|
||||
'# Reconnaissance Deliverable:',
|
||||
'',
|
||||
renderHowToReadThis(),
|
||||
'',
|
||||
renderExecutiveSummary(data.executive_summary),
|
||||
'',
|
||||
renderTechnologyStack(data.technology_stack),
|
||||
'',
|
||||
renderAuthentication(data.authentication),
|
||||
'',
|
||||
renderEndpoints(data.endpoints),
|
||||
'',
|
||||
renderInputVectors(data.input_vectors),
|
||||
'',
|
||||
renderNetworkMap(data.network_map),
|
||||
'',
|
||||
renderRoleArchitecture(data.role_architecture),
|
||||
'',
|
||||
renderAuthzCandidates(data.authz_candidates),
|
||||
'',
|
||||
renderInjectionSources(data.injection_sources),
|
||||
'',
|
||||
];
|
||||
return `${sections.join('\n').trimEnd()}\n`;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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 vuln collector → markdown renderer.
|
||||
*
|
||||
* Single entry point renderVulnDeliverable(vulnClass, data) covers all 5
|
||||
* vulnerability classes (injection, xss, auth, ssrf, authz). Per-class title,
|
||||
* §3 sub-header set, §4 column shape, and §4 section heading are selected by
|
||||
* branching on vulnClass.
|
||||
*
|
||||
* Missing tools surface as placeholder sections, not activity failures.
|
||||
* Required tools (set_findings_summary, set_strategic_intelligence) produce
|
||||
* loud `[Section X: not provided]` placeholders; recommended tools
|
||||
* (set_safe_vectors, set_blind_spots) produce quiet "None identified" prose.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BlindSpotsInput,
|
||||
FindingsSummaryInput,
|
||||
SafeVectorsInput,
|
||||
StrategicIntelligenceInput,
|
||||
VulnClass,
|
||||
VulnCollectorData,
|
||||
} from '../mcp-server/vuln-collector.js';
|
||||
import { BLIND_SPOTS_CLASSES } from '../mcp-server/vuln-collector.js';
|
||||
|
||||
// ============================================================================
|
||||
// PER-CLASS CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
const TITLES: Record<VulnClass, string> = {
|
||||
injection: 'Injection Analysis Report',
|
||||
xss: 'Cross-Site Scripting (XSS) Analysis Report',
|
||||
auth: 'Authentication Analysis Report',
|
||||
ssrf: 'SSRF Analysis Report',
|
||||
authz: 'Authorization Analysis Report',
|
||||
};
|
||||
|
||||
const SECTION_FOUR_HEADING: Record<VulnClass, string> = {
|
||||
injection: '4. Vectors Analyzed and Confirmed Secure',
|
||||
xss: '4. Vectors Analyzed and Confirmed Secure',
|
||||
auth: '4. Secure by Design: Validated Components',
|
||||
ssrf: '4. Secure by Design: Validated Components',
|
||||
authz: '4. Vectors Analyzed and Confirmed Secure',
|
||||
};
|
||||
|
||||
const STRATEGIC_INTEL_SUBHEADERS: Record<VulnClass, ReadonlyArray<readonly [string, string]>> = {
|
||||
injection: [
|
||||
['defensive_evasion_waf', 'Defensive Evasion (WAF Analysis)'],
|
||||
['error_based_potential', 'Error-Based Injection Potential'],
|
||||
['confirmed_database_technology', 'Confirmed Database Technology'],
|
||||
],
|
||||
xss: [
|
||||
['csp_analysis', 'Content Security Policy (CSP) Analysis'],
|
||||
['cookie_security', 'Cookie Security'],
|
||||
],
|
||||
auth: [
|
||||
['authentication_method', 'Authentication Method'],
|
||||
['session_token_details', 'Session Token Details'],
|
||||
['password_policy', 'Password Policy'],
|
||||
],
|
||||
ssrf: [
|
||||
['http_client_library', 'HTTP Client Library'],
|
||||
['request_architecture', 'Request Architecture'],
|
||||
['internal_services', 'Internal Services'],
|
||||
],
|
||||
authz: [
|
||||
['session_management_architecture', 'Session Management Architecture'],
|
||||
['role_permission_model', 'Role/Permission Model'],
|
||||
['resource_access_patterns', 'Resource Access Patterns'],
|
||||
['workflow_implementation', 'Workflow Implementation'],
|
||||
],
|
||||
};
|
||||
|
||||
// Per-class column shape for §4. The first label is the subject column name
|
||||
// (varies by class — "Source" vs "Component/Flow" vs "Endpoint"); the location
|
||||
// column name also varies for authz ("Guard Location"); XSS gets an extra
|
||||
// "Render Context" column between defense and verdict.
|
||||
interface ColumnSpec {
|
||||
readonly subject: string;
|
||||
readonly location: string;
|
||||
readonly includeRenderContext: boolean;
|
||||
}
|
||||
|
||||
const SECTION_FOUR_COLUMNS: Record<VulnClass, ColumnSpec> = {
|
||||
injection: { subject: 'Source', location: 'Endpoint/File Location', includeRenderContext: false },
|
||||
xss: { subject: 'Source', location: 'Endpoint/File Location', includeRenderContext: true },
|
||||
auth: { subject: 'Component/Flow', location: 'Endpoint/File Location', includeRenderContext: false },
|
||||
ssrf: { subject: 'Component/Flow', location: 'Endpoint/File Location', includeRenderContext: false },
|
||||
authz: { subject: 'Endpoint', location: 'Guard Location', includeRenderContext: false },
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SHARED HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function placeholder(sectionLabel: string, toolName: string): string {
|
||||
return `_[${sectionLabel}: not provided — \`${toolName}\` was not called]_`;
|
||||
}
|
||||
|
||||
function escapePipe(value: string): string {
|
||||
return value.replace(/\|/g, '\\|');
|
||||
}
|
||||
|
||||
function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
|
||||
const headerRow = `| ${headers.map(escapePipe).join(' | ')} |`;
|
||||
const separator = `| ${headers.map(() => '---').join(' | ')} |`;
|
||||
const body = rows.map((row) => `| ${row.map(escapePipe).join(' | ')} |`).join('\n');
|
||||
return [headerRow, separator, body].filter((line) => line.length > 0).join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SECTION RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
function renderTitle(vulnClass: VulnClass): string {
|
||||
return `# ${TITLES[vulnClass]}`;
|
||||
}
|
||||
|
||||
function renderExecutiveSummary(summary: FindingsSummaryInput | undefined): string {
|
||||
if (!summary) {
|
||||
return ['## 1. Executive Summary', '', placeholder('Section 1', 'set_findings_summary')].join('\n');
|
||||
}
|
||||
return ['## 1. Executive Summary', '', summary.key_outcome].join('\n');
|
||||
}
|
||||
|
||||
function renderDominantPatterns(summary: FindingsSummaryInput | undefined): string {
|
||||
if (!summary) {
|
||||
return ['## 2. Dominant Vulnerability Patterns', '', placeholder('Section 2', 'set_findings_summary')].join('\n');
|
||||
}
|
||||
if (summary.patterns.length === 0) {
|
||||
return ['## 2. Dominant Vulnerability Patterns', '', '*No dominant patterns identified.*'].join('\n');
|
||||
}
|
||||
const blocks = summary.patterns.map((p, index) => {
|
||||
const ids = p.representative_finding_ids.map((id) => `\`${id}\``).join(', ');
|
||||
return [
|
||||
`### Pattern ${index + 1}: ${p.name}`,
|
||||
`- **Description:** ${p.description}`,
|
||||
`- **Implication:** ${p.implication}`,
|
||||
`- **Representative Findings:** ${ids}`,
|
||||
].join('\n');
|
||||
});
|
||||
return ['## 2. Dominant Vulnerability Patterns', '', blocks.join('\n\n')].join('\n');
|
||||
}
|
||||
|
||||
function renderStrategicIntelligence(vulnClass: VulnClass, intel: StrategicIntelligenceInput | undefined): string {
|
||||
if (!intel) {
|
||||
return [
|
||||
'## 3. Strategic Intelligence for Exploitation',
|
||||
'',
|
||||
placeholder('Section 3', 'set_strategic_intelligence'),
|
||||
].join('\n');
|
||||
}
|
||||
const subheaders = STRATEGIC_INTEL_SUBHEADERS[vulnClass];
|
||||
const intelRecord = intel as unknown as Record<string, string>;
|
||||
const blocks = subheaders.map(([fieldName, header]) => {
|
||||
const value = intelRecord[fieldName] ?? '*(not provided)*';
|
||||
return [`### ${header}`, value].join('\n');
|
||||
});
|
||||
return ['## 3. Strategic Intelligence for Exploitation', '', blocks.join('\n\n')].join('\n');
|
||||
}
|
||||
|
||||
function sortSafeVectors(vectors: SafeVectorsInput['vectors']): SafeVectorsInput['vectors'] {
|
||||
return [...vectors].sort((a, b) => {
|
||||
if (a.subject !== b.subject) return a.subject.localeCompare(b.subject);
|
||||
return a.location.localeCompare(b.location);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSafeVectors(vulnClass: VulnClass, data: SafeVectorsInput | undefined): string {
|
||||
const heading = `## ${SECTION_FOUR_HEADING[vulnClass]}`;
|
||||
if (!data || data.vectors.length === 0) {
|
||||
return [heading, '', '*No vectors confirmed secure during analysis.*'].join('\n');
|
||||
}
|
||||
const cols = SECTION_FOUR_COLUMNS[vulnClass];
|
||||
const headers: string[] = [cols.subject, cols.location, 'Defense Mechanism'];
|
||||
if (cols.includeRenderContext) {
|
||||
headers.push('Render Context');
|
||||
}
|
||||
headers.push('Verdict');
|
||||
|
||||
const sorted = sortSafeVectors(data.vectors);
|
||||
const rows = sorted.map((v) => {
|
||||
const row: string[] = [v.subject, v.location, v.defense_mechanism];
|
||||
if (cols.includeRenderContext) {
|
||||
row.push(v.render_context ?? '');
|
||||
}
|
||||
row.push('SAFE');
|
||||
return row;
|
||||
});
|
||||
|
||||
return [heading, '', renderTable(headers, rows)].join('\n');
|
||||
}
|
||||
|
||||
function renderBlindSpots(data: BlindSpotsInput | undefined): string {
|
||||
const heading = '## 5. Analysis Constraints and Blind Spots';
|
||||
if (!data || data.items.length === 0) {
|
||||
return [heading, '', '*No analysis constraints or blind spots identified.*'].join('\n');
|
||||
}
|
||||
const blocks = data.items.map((item) => [`### ${item.heading}`, item.description].join('\n'));
|
||||
return [heading, '', blocks.join('\n\n')].join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC ENTRY POINT
|
||||
// ============================================================================
|
||||
|
||||
export function renderVulnDeliverable(vulnClass: VulnClass, data: VulnCollectorData): string {
|
||||
const sections: string[] = [
|
||||
renderTitle(vulnClass),
|
||||
'',
|
||||
renderExecutiveSummary(data.findings_summary),
|
||||
'',
|
||||
renderDominantPatterns(data.findings_summary),
|
||||
'',
|
||||
renderStrategicIntelligence(vulnClass, data.strategic_intelligence),
|
||||
'',
|
||||
renderSafeVectors(vulnClass, data.safe_vectors),
|
||||
'',
|
||||
];
|
||||
if (BLIND_SPOTS_CLASSES.has(vulnClass)) {
|
||||
sections.push(renderBlindSpots(data.blind_spots), '');
|
||||
}
|
||||
return `${sections.join('\n').trimEnd()}\n`;
|
||||
}
|
||||
Reference in New Issue
Block a user