feat(worker): structure intermediate deliverables via MCP collectors (#350)

This commit is contained in:
ezl-keygraph
2026-06-05 14:50:43 +05:30
committed by GitHub
parent a6f004cd25
commit 0a1a2eb1c1
26 changed files with 4139 additions and 1300 deletions
+2
View File
@@ -137,6 +137,7 @@ export async function runClaudePrompt(
apiKey?: string,
deliverablesSubdir?: string,
providerConfig?: import('../types/config.js').ProviderConfig,
mcpServers?: Record<string, import('@anthropic-ai/claude-agent-sdk').McpServerConfig>,
): Promise<ClaudePromptResult> {
// 1. Initialize timing and prompt
const timer = new Timer(`agent-${description.toLowerCase().replace(/\s+/g, '-')}`);
@@ -229,6 +230,7 @@ export async function runClaudePrompt(
env: sdkEnv,
...(adaptiveThinking && { thinking: { type: 'adaptive' as const } }),
...(outputFormat && { outputFormat }),
...(mcpServers && Object.keys(mcpServers).length > 0 && { mcpServers }),
};
if (!execContext.useCleanOutput) {
@@ -0,0 +1,473 @@
// 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.
/**
* Exploit Collector MCP Server (factory parameterized by vulnerability class
* and per-run valid-ID set).
*
* Exposes a single Zod-validated MCP tool `add_exploit`, called once per
* processed vulnerability by the 5 exploit-* agents (injection, xss, auth,
* ssrf, authz). After the agent terminates, the host harvests
* collector.getAll() and runs exploit-renderer to produce
* {class}_exploitation_evidence.md. The collector state is the structured
* output.
*
* Schema shape:
* - The SDK tool() helper consumes a ZodRawShape (flat object), not a
* top-level discriminated union. The visible shape is therefore a single
* z.object with common fields required, status as a string enum, and
* per-status fields marked optional at the SDK layer. Each field's
* `.describe()` text explains when it applies.
* - True per-status field enforcement runs inside the tool handler via a
* z.discriminatedUnion('status', ...). Missing-field errors come back to
* the agent as structured Zod issues with retryable=true so it can fix
* and retry the call.
*
* Strict queue-ID validation: vulnerability_id is refined against the per-run
* queue's known IDs at schema-build time. Hallucinated or typo'd IDs are
* rejected with a structured Zod error that includes the valid-ID list,
* letting the agent recover locally.
*
* Each Zod schema's field-level descriptions carry the bullet labels and
* reproducibility guidance, so the SDK injects it into the agent's tool
* catalog.
*/
import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
// ============================================================================
// CLASS DISCRIMINATOR
// ============================================================================
export const EXPLOIT_VULN_CLASSES = ['injection', 'xss', 'auth', 'ssrf', 'authz'] as const;
export type VulnClass = (typeof EXPLOIT_VULN_CLASSES)[number];
// ============================================================================
// SCHEMA CONSTANTS
// ============================================================================
const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'] as const;
const CONFIDENCE_VALUES = ['high', 'medium', 'low'] as const;
const VALID_IDS_PREVIEW_LIMIT = 8;
function formatValidIdsPreview(validIds: ReadonlySet<string>): string {
const list = [...validIds];
const head = list.slice(0, VALID_IDS_PREVIEW_LIMIT).join(', ');
return list.length > VALID_IDS_PREVIEW_LIMIT ? `${head}, … (${list.length} total)` : head;
}
// ============================================================================
// PUBLIC TYPES (discriminated union — what consumers see)
// ============================================================================
export type ExploitedExploit = {
status: 'exploited';
vulnerability_id: string;
title: string;
vulnerable_location: string;
overview: string;
prerequisites?: string | null;
severity: (typeof SEVERITY_VALUES)[number];
impact: string;
exploitation_steps: string[];
proof_of_impact: string;
notes?: string | null;
};
export type BlockedExploit = {
status: 'blocked';
vulnerability_id: string;
title: string;
vulnerable_location: string;
prerequisites?: string | null;
confidence: (typeof CONFIDENCE_VALUES)[number];
current_blocker: string;
potential_impact: string;
evidence_of_vulnerability: string;
what_we_tried: string;
how_this_would_be_exploited: string[];
expected_impact: string;
notes?: string | null;
};
export type AddExploitInput = ExploitedExploit | BlockedExploit;
// ============================================================================
// SCHEMA BUILDER
// ============================================================================
function buildSchemas(validIds: ReadonlySet<string>) {
const vulnerabilityIdField = z
.string()
.min(1)
.describe(
'Vulnerability identifier (e.g. "INJ-VULN-03"). Must match an ID from this run\'s ' +
'{class}_exploitation_queue.json exactly — the collector rejects IDs not in the queue. ' +
`Valid IDs for this run: ${formatValidIdsPreview(validIds)}.`,
)
.refine((id: string) => validIds.has(id), {
message:
`Vulnerability ID not in this run's queue. Valid IDs: ` +
`${formatValidIdsPreview(validIds)}. ` +
'Check the queue.json for the canonical ID — likely a typo or hallucinated ID.',
});
const titleField = z
.string()
.min(1)
.describe(
'Descriptive vulnerability title (e.g. "SQL Injection — User Search", "IDOR — Unauthorized ' +
'Access to User Orders"). Concise; encodes the vulnerability category and where it lives.',
);
const vulnerableLocationField = z
.string()
.min(1)
.describe(
'Endpoint or mechanism where the vulnerability exists (e.g. "GET /api/products?id=", ' +
'"POST /login", or a code location like "controllers/userController.js:42").',
);
const overviewField = z
.string()
.min(1)
.describe(
'Brief summary of the exploit itself — what the vulnerability is and how it was demonstrated ' +
'(or how it would be demonstrated, for blocked findings). 1-3 sentences.',
);
const prerequisitesField = z
.string()
.nullable()
.optional()
.describe(
'Required setup, tools, or conditions to reproduce the exploit (e.g. authentication, ' +
'specific role, prior application state). Omit or pass null when no prerequisites apply.',
);
const notesField = z
.string()
.nullable()
.optional()
.describe(
'Optional supplementary context — caveats, related findings, environmental observations. ' +
'Free-form Markdown. Omit or pass null when N/A.',
);
const statusField = z
.enum(['exploited', 'blocked'])
.describe(
'Verdict bucket. Set to "exploited" only after reaching Proof of Exploitation Level 3+ with ' +
'concrete impact evidence (extracted data, executed JavaScript, account takeover, internal ' +
'service access). Set to "blocked" only for real vulnerabilities where external factors ' +
'(NOT security defenses) prevented full exploitation. Findings where a security defense ' +
'successfully prevented exploitation after exhaustive bypass attempts are FALSE POSITIVE — ' +
'route those to your workspace tracking file, not this tool.',
);
// Per-status fields. All optional at the SDK shape layer because a single
// ZodRawShape cannot express a top-level discriminated union; the handler
// re-validates against the discriminated union below for true enforcement.
const severityField = z
.enum(SEVERITY_VALUES)
.nullable()
.optional()
.describe(
'REQUIRED when status="exploited". Severity of the demonstrated impact. Critical = Level 4 ' +
'(admin credentials extracted, sensitive data dumped, system commands executed, full account ' +
'takeover). High = Level 3 (data extraction proven, authentication bypass confirmed, ' +
'internal service access). Medium/Low based on impact narrowness or read-only access. Must ' +
'reflect demonstrated impact, not theoretical potential.',
);
const impactField = z
.string()
.min(1)
.nullable()
.optional()
.describe(
'REQUIRED when status="exploited". Business/security impact achieved by the exploit ' +
'(e.g. "Extracted full user table including bcrypt password hashes for 1,247 users", ' +
'"Achieved RCE as the application user; arbitrary shell commands executed"). Must describe ' +
'what was actually demonstrated, not what could theoretically happen.',
);
const exploitationStepsField = z
.array(z.string().min(1))
.min(1)
.nullable()
.optional()
.describe(
'REQUIRED when status="exploited". Ordered, reproducible exploitation steps — one Markdown ' +
'blob per numbered step. Each step must include full URLs (protocol + domain + port + path ' +
'+ params), complete payloads, and copy-paste-ready commands. Use clear placeholders for ' +
'variable values like [SESSION_TOKEN], [DATABASE_NAME], [TABLE_NAME], [TARGET_USER_ID]. ' +
'Write each step as natural Markdown — interleave prose with fenced code blocks (```bash, ' +
'```http, etc.) as you would in a write-up. Steps must be detailed enough that someone ' +
'unfamiliar with the application can follow without additional research.',
);
const proofOfImpactField = z
.string()
.min(1)
.nullable()
.optional()
.describe(
'REQUIRED when status="exploited". Concrete evidence of successful exploitation — extracted ' +
'data, achieved actions, captured request/response pairs, log excerpts. Markdown blob; ' +
'interleave prose with fenced code blocks. Must show what the exploit demonstrably achieved, ' +
'not theoretical impact.',
);
const confidenceField = z
.enum(CONFIDENCE_VALUES)
.nullable()
.optional()
.describe(
'REQUIRED when status="blocked". Confidence that this finding is a real vulnerability that ' +
'would be exploited if the external blocker were removed. High = code analysis strongly ' +
'confirms vulnerability and partial exploitation (Level 1-2) succeeded. Medium = code ' +
'analysis confirms but live evidence is partial. Low = signal-only; revisit if blocker is ' +
'removed in a future run.',
);
const currentBlockerField = z
.string()
.min(1)
.nullable()
.optional()
.describe(
'REQUIRED when status="blocked". What prevents full exploitation (e.g. "Server crashes after ' +
'5 requests, blocking enumeration", "OAuth callback requires verified third-party email ' +
'account we could not provision"). Must be an external operational constraint, not a ' +
'security defense.',
);
const potentialImpactField = z
.string()
.min(1)
.nullable()
.optional()
.describe(
'REQUIRED when status="blocked". What could be achieved if the blocker were removed (e.g. ' +
'"Full database read access", "Account takeover of arbitrary user via reset-token leak"). ' +
'Distinct from impact — this is the hypothetical outcome, not a demonstrated one.',
);
const evidenceOfVulnerabilityField = z
.string()
.min(1)
.nullable()
.optional()
.describe(
'REQUIRED when status="blocked". Code snippets, response excerpts, or observed behavior ' +
'proving the vulnerability is real. Markdown blob; interleave prose with fenced code blocks. ' +
'This is what convinces the reader the finding is not a false positive despite incomplete ' +
'exploitation.',
);
const whatWeTriedField = z
.string()
.min(1)
.nullable()
.optional()
.describe(
'REQUIRED when status="blocked". Log of attempted exploitation techniques and why each was ' +
'blocked. Each attempt should document the payload, the observed result, and the inferred ' +
'blocker. Markdown blob; multiple attempts as a list or distinct paragraphs. Demonstrates ' +
'exhaustive bypass effort per the Bypass Exhaustion Protocol.',
);
const howThisWouldBeExploitedField = z
.array(z.string().min(1))
.min(1)
.nullable()
.optional()
.describe(
'REQUIRED when status="blocked". Ordered hypothetical exploitation steps assuming the blocker ' +
'is removed — one Markdown blob per numbered step. Same reproducibility requirements as ' +
'exploitation_steps: full URLs, complete payloads, copy-paste-ready commands. Frame the ' +
'first step as "If [blocker] were removed: …".',
);
const expectedImpactField = z
.string()
.min(1)
.nullable()
.optional()
.describe(
'REQUIRED when status="blocked". Specific data or access that would be compromised if ' +
'exploitation succeeded (e.g. "Read access to all user profile data including PII; write ' +
'access to user-owned resources"). Markdown blob.',
);
// The flat shape passed to tool(). The SDK uses this to build the agent's
// tool catalog. Per-status enforcement happens in the handler via the
// discriminated union below.
const flatShape = {
status: statusField,
vulnerability_id: vulnerabilityIdField,
title: titleField,
vulnerable_location: vulnerableLocationField,
overview: overviewField,
prerequisites: prerequisitesField,
notes: notesField,
severity: severityField,
impact: impactField,
exploitation_steps: exploitationStepsField,
proof_of_impact: proofOfImpactField,
confidence: confidenceField,
current_blocker: currentBlockerField,
potential_impact: potentialImpactField,
evidence_of_vulnerability: evidenceOfVulnerabilityField,
what_we_tried: whatWeTriedField,
how_this_would_be_exploited: howThisWouldBeExploitedField,
expected_impact: expectedImpactField,
};
// Strict per-status validation. Re-runs in the handler so missing fields
// for the chosen status return a retryable Zod error to the agent.
const ExploitedSchema = z.object({
status: z.literal('exploited'),
vulnerability_id: vulnerabilityIdField,
title: titleField,
vulnerable_location: vulnerableLocationField,
overview: overviewField,
prerequisites: prerequisitesField,
severity: z.enum(SEVERITY_VALUES),
impact: z.string().min(1),
exploitation_steps: z.array(z.string().min(1)).min(1),
proof_of_impact: z.string().min(1),
notes: notesField,
});
const BlockedSchema = z.object({
status: z.literal('blocked'),
vulnerability_id: vulnerabilityIdField,
title: titleField,
vulnerable_location: vulnerableLocationField,
prerequisites: prerequisitesField,
confidence: z.enum(CONFIDENCE_VALUES),
current_blocker: z.string().min(1),
potential_impact: z.string().min(1),
evidence_of_vulnerability: z.string().min(1),
what_we_tried: z.string().min(1),
how_this_would_be_exploited: z.array(z.string().min(1)).min(1),
expected_impact: z.string().min(1),
notes: notesField,
});
const StrictSchema = z.discriminatedUnion('status', [ExploitedSchema, BlockedSchema]);
return { flatShape, StrictSchema };
}
// ============================================================================
// RESPONSE HELPERS
// ============================================================================
interface ToolResult {
[x: string]: unknown;
content: Array<{ type: 'text'; text: string }>;
isError: boolean;
}
function createToolResult(response: { status: string; [key: string]: unknown }): ToolResult {
return {
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
isError: response.status === 'error',
};
}
function successResult(data: Record<string, unknown>): ToolResult {
return createToolResult({ status: 'success', ...data });
}
function errorResult(message: string, errorType = 'ValidationError', retryable = true): ToolResult {
return createToolResult({ status: 'error', message, errorType, retryable });
}
function formatZodIssues(error: z.ZodError): string {
return error.issues
.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
return `- ${path}: ${issue.message}`;
})
.join('\n');
}
// ============================================================================
// SERVER FACTORY
// ============================================================================
export interface ExploitCollectorServer {
server: McpSdkServerConfigWithInstance;
getAll(): AddExploitInput[];
}
export interface CreateExploitCollectorOptions {
vulnClass: VulnClass;
validIds: ReadonlySet<string>;
}
export function createExploitCollector(options: CreateExploitCollectorOptions): ExploitCollectorServer {
const { vulnClass, validIds } = options;
const exploits: AddExploitInput[] = [];
const { flatShape, StrictSchema } = buildSchemas(validIds);
const addExploitTool = tool(
'add_exploit',
`Record a single processed ${vulnClass} vulnerability as structured exploitation evidence. ` +
'Call this once per vulnerability in your queue.json after reaching a definitive verdict ' +
'(either successfully exploited or potential-but-blocked). The status field discriminates the ' +
"two report buckets; required sub-fields differ per status (see each field's description for " +
'which status requires it). Duplicate vulnerability_id calls are rejected — each vuln may only ' +
'be recorded once. Vulnerability IDs not in the queue.json are rejected with a list of valid ' +
'IDs. FALSE POSITIVE findings do NOT use this tool — they go to your workspace tracking file. ' +
'After all queue vulnerabilities have been emitted, the host renderer assembles the ' +
'deliverable Markdown from your recorded calls.',
flatShape,
async (input): Promise<ToolResult> => {
// Re-validate against the strict discriminated union for per-status enforcement.
const parsed = StrictSchema.safeParse(input);
if (!parsed.success) {
return errorResult(
`Schema validation failed for status="${(input as { status?: string }).status}". ` +
'Required-field issues:\n' +
formatZodIssues(parsed.error),
'ValidationError',
true,
);
}
const typed = parsed.data as AddExploitInput;
const existing = exploits.find((e) => e.vulnerability_id === typed.vulnerability_id);
if (existing) {
return errorResult(
`Vulnerability ${typed.vulnerability_id} has already been recorded. Each vulnerability ` +
'may only be added once. Reach a final verdict before emitting.',
'DuplicateError',
false,
);
}
exploits.push(typed);
return successResult({ added: [typed.vulnerability_id], recorded_status: typed.status });
},
);
const server: McpSdkServerConfigWithInstance = createSdkMcpServer({
name: 'exploit-collector',
version: '1.0.0',
tools: [addExploitTool],
});
return {
server,
getAll: (): AddExploitInput[] => [...exploits],
};
}
@@ -0,0 +1,620 @@
// 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.
/**
* Pre-Recon Collector MCP Server
*
* Exposes seven Zod-validated MCP tools, one per section of the
* pre_recon_deliverable.md report. Every tool is one-shot (write-once;
* duplicate calls return DuplicateError). A skipped tool renders a placeholder
* rather than failing the activity. After the agent finishes, the host calls
* getAll() to harvest the typed payload bag, getCallStatus() to log the
* per-run call pattern, and runs the deterministic renderer to produce the
* deliverable Markdown.
*
* Each Zod schema's field-level descriptions carry the section guidance, so
* the SDK injects it into the agent's tool catalog.
*/
import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
// ============================================================================
// SHARED SCHEMA
// ============================================================================
export const SinkRefSchema = z.object({
location: z
.string()
.min(1)
.describe(
'File path with line number (e.g., "templates/render.js:34") or richer prose ' +
'(e.g., "innerHTML at templates/render.js:34", "lines 45-67"). Must contain enough ' +
'detail for a downstream agent to find the exact location.',
),
sink_function: z
.string()
.min(1)
.describe('The sink function or property name (e.g., "innerHTML", "axios.get", "eval", "document.write").'),
notes: z
.string()
.nullable()
.optional()
.describe(
'Optional context — render-context detail, attribute name, scope hints, or anything ' +
'a downstream agent needs to act on this sink. Omit when the location and sink_function ' +
'are sufficient on their own.',
),
});
export type SinkRef = z.infer<typeof SinkRefSchema>;
// ============================================================================
// PER-TOOL INPUT SCHEMAS
// ============================================================================
export const ExecutiveSummaryInputSchema = z.object({
text: z
.string()
.min(1)
.describe(
"Provide a 2-3 paragraph overview of the application's security posture, highlighting " +
'the most critical attack surfaces and architectural security decisions. Becomes ' +
'Section 1 of the rendered deliverable.',
),
});
const ArchitectureSchema = z.object({
framework_and_language: z
.string()
.min(1)
.describe('Framework and language details with their security implications.'),
architectural_pattern: z
.string()
.min(1)
.describe('Architectural pattern (monolith, microservices, hybrid) with trust boundary analysis.'),
critical_security_components: z
.string()
.min(1)
.describe('Critical security components with focus on auth, authz, and data protection.'),
});
const DataSecuritySchema = z.object({
database_security: z
.string()
.min(1)
.describe('Analyze encryption, access controls, and query safety in database interactions.'),
data_flow_security: z
.string()
.min(1)
.describe('Identify sensitive data paths and the protection mechanisms applied along them.'),
multi_tenant_isolation: z
.string()
.min(1)
.describe(
'Assess tenant separation effectiveness. If the application is single-tenant, state that ' +
'explicitly rather than leaving the field thin.',
),
});
const AttackSurfaceSchema = z.object({
external_entry_points: z
.string()
.min(1)
.describe('Detailed analysis of each public interface that is network-accessible.'),
internal_service_communication: z
.string()
.min(1)
.describe(
'Trust relationships and security assumptions between network-reachable services. ' +
'If the application is a single service with no internal RPC fabric, state that.',
),
input_validation_patterns: z
.string()
.min(1)
.describe('How user input is handled and validated in network-accessible endpoints.'),
background_processing: z
.string()
.min(1)
.describe(
'Async job security and privilege models for jobs triggered by network requests. ' +
'If no async/background processing exists, state that.',
),
});
const InfrastructureSchema = z.object({
secrets_management: z.string().min(1).describe('How secrets are stored, rotated, and accessed.'),
configuration_security: z
.string()
.min(1)
.describe(
'Environment separation and secret handling. Specifically search for infrastructure ' +
'configuration (e.g., Nginx, Kubernetes Ingress, CDN settings) that defines security ' +
'headers like Strict-Transport-Security (HSTS) and Cache-Control, and report what was found.',
),
external_dependencies: z.string().min(1).describe('Third-party services and their security implications.'),
monitoring_and_logging: z
.string()
.min(1)
.describe('Security event visibility — what is logged, where it goes, and who can see it.'),
});
export const ApplicationIntelligenceInputSchema = z.object({
architecture: ArchitectureSchema.describe(
'Architecture & Technology Stack — driven by the Architecture Scanner sub-agent. ' +
'Becomes Section 2 of the rendered deliverable.',
),
data_security: DataSecuritySchema.describe(
'Data Security & Storage — driven by the Data Security Auditor sub-agent. ' +
'Becomes Section 4 of the rendered deliverable.',
),
attack_surface: AttackSurfaceSchema.describe(
'Attack Surface Analysis — driven by Entry Point Mapper + Architecture Scanner sub-agents. ' +
'Only include entry points confirmed to be in-scope (network-reachable). ' +
'Becomes Section 5 of the rendered deliverable.',
),
infrastructure: InfrastructureSchema.describe(
'Infrastructure & Operational Security. Becomes Section 6 of the rendered deliverable.',
),
});
export const AuthDeepDiveInputSchema = z.object({
authentication_mechanisms: z
.string()
.min(1)
.describe(
'Authentication mechanisms and their security properties. MUST include an exhaustive list of ' +
'all API endpoints used for authentication (e.g., login, logout, token refresh, password reset).',
),
session_management: z
.string()
.min(1)
.describe(
'Session management and token security. Pinpoint the exact file and line(s) of code where ' +
'session cookie flags (HttpOnly, Secure, SameSite) are configured.',
),
authz_model: z.string().min(1).describe('Authorization model and potential bypass scenarios.'),
multi_tenancy: z
.string()
.min(1)
.describe('Multi-tenancy security implementation. If the application is single-tenant, state that explicitly.'),
sso_oauth_oidc: z
.string()
.nullable()
.describe(
'SSO/OAuth/OIDC flows: identify the callback endpoints and locate the specific code that ' +
'validates the state and nonce parameters. Set null only if the application has no SSO/OAuth/OIDC ' +
'integration at all.',
),
});
export const CodebaseIndexingInputSchema = z.object({
text: z
.string()
.min(1)
.describe(
"A detailed, multi-sentence paragraph describing the codebase's directory structure, " +
'organization, and significant tools or conventions used (e.g., build orchestration, code ' +
'generation, testing frameworks). Focus on how this structure impacts discoverability of ' +
'security-relevant components.',
),
});
export const CriticalFilePathsInputSchema = z.object({
configuration: z
.array(z.string().min(1))
.describe('Configuration files (e.g., config/server.yaml, Dockerfile, docker-compose.yml).'),
authentication_and_authorization: z
.array(z.string().min(1))
.describe(
'Auth/authz files (e.g., auth/jwt_middleware.go, internal/user/permissions.go, ' +
'config/initializers/session_store.rb, src/services/oauth_callback.js).',
),
api_and_routing: z
.array(z.string().min(1))
.describe(
'API and routing files (e.g., cmd/api/main.go, internal/handlers/user_routes.go, ' +
'ts/graphql/schema.graphql).',
),
data_models_and_db: z
.array(z.string().min(1))
.describe(
'Data model and DB interaction files (e.g., db/migrations/001_initial.sql, ' +
'internal/models/user.go, internal/repository/sql_queries.go).',
),
dependency_manifests: z
.array(z.string().min(1))
.describe('Dependency manifests (e.g., go.mod, package.json, requirements.txt).'),
sensitive_data_and_secrets: z
.array(z.string().min(1))
.describe(
'Sensitive data and secrets handling (e.g., internal/utils/encryption.go, ' + 'internal/secrets/manager.go).',
),
middleware_and_input_validation: z
.array(z.string().min(1))
.describe(
'Middleware and input validation (e.g., internal/middleware/validator.go, ' +
'internal/handlers/input_parsers.go).',
),
logging_and_monitoring: z
.array(z.string().min(1))
.describe('Logging and monitoring (e.g., internal/logging/logger.go, config/monitoring.yaml).'),
infrastructure_and_deployment: z
.array(z.string().min(1))
.describe(
'Infrastructure and deployment (e.g., infra/pulumi/main.go, kubernetes/deploy.yaml, ' +
'nginx.conf, gateway-ingress.yaml).',
),
});
export const XssSinksInputSchema = z.object({
applicable: z
.boolean()
.describe(
'False only if the application has no web frontend at all. Otherwise true, even if no ' +
'sinks were found in a given category — empty arrays mean "scanned this category, no sinks found".',
),
html_body: z
.array(SinkRefSchema)
.describe(
'HTML Body Context sinks: element.innerHTML, element.outerHTML, document.write(), ' +
'document.writeln(), element.insertAdjacentHTML(), Range.createContextualFragment(), ' +
'and jQuery sinks like add(), after(), append(), before(), html(), prepend(), replaceWith(), wrap().',
),
html_attribute: z
.array(SinkRefSchema)
.describe(
'HTML Attribute Context sinks: event handlers (onclick, onerror, onmouseover, onload, onfocus), ' +
'URL-based attributes (href, src, formaction, action, background, data), the style attribute, ' +
'iframe srcdoc, and general attributes (value, id, class, name, alt) when quotes are escaped.',
),
javascript: z
.array(SinkRefSchema)
.describe(
'JavaScript Context sinks: eval(), Function() constructor, setTimeout() / setInterval() ' +
'with string arguments, and direct writes of user data into a <script> tag.',
),
css: z
.array(SinkRefSchema)
.describe(
'CSS Context sinks: element.style properties (e.g., element.style.backgroundImage) and ' +
'direct writes of user data into a <style> tag.',
),
url: z
.array(SinkRefSchema)
.describe(
'URL Context sinks: location / window.location, location.href, location.replace(), ' +
'location.assign(), window.open(), history.pushState(), history.replaceState(), ' +
'URL.createObjectURL(), and jQuery selector $(userInput) in older versions.',
),
});
export const SsrfSinksInputSchema = z.object({
applicable: z
.boolean()
.describe(
'False only if the application makes no outbound requests at all. Otherwise true, even if ' +
'no sinks were found in a given category — empty arrays mean "scanned this category, no sinks found".',
),
http_clients: z
.array(SinkRefSchema)
.describe(
'HTTP(S) clients: curl, requests (Python), axios (Node.js), fetch (JavaScript/Node.js), ' +
'net/http (Go), HttpClient (Java/.NET), urllib (Python), RestTemplate, WebClient, OkHttp, Apache HttpClient.',
),
raw_sockets: z
.array(SinkRefSchema)
.describe(
'Raw sockets and connect APIs: Socket.connect, net.Dial (Go), socket.connect (Python), ' +
'TcpClient, UdpClient, NetworkStream, java.net.Socket, java.net.URL.openConnection().',
),
url_openers: z
.array(SinkRefSchema)
.describe(
'URL openers and file includes: file_get_contents (PHP), fopen, include_once, require_once, ' +
'new URL().openStream() (Java), urllib.urlopen (Python), fs.readFile with URLs, ' +
'import() with dynamic URLs, loadHTML / loadXML with external sources.',
),
redirect_handlers: z
.array(SinkRefSchema)
.describe(
'Redirect and "next URL" handlers: auto-follow redirects in HTTP clients, framework Location ' +
'handlers (response.redirect), URL validation in redirect chains, "Continue to" / "Return URL" parameters.',
),
headless_browsers: z
.array(SinkRefSchema)
.describe(
'Headless browsers and render engines: Puppeteer (page.goto, page.setContent), ' +
'Playwright (page.navigate, page.route), Selenium WebDriver navigation, html-to-pdf converters ' +
'(wkhtmltopdf, Puppeteer PDF), and SSR with external content.',
),
media_processors: z
.array(SinkRefSchema)
.describe(
'Media processors: ImageMagick (convert, identify with URLs), GraphicsMagick, FFmpeg with ' +
'network sources, wkhtmltopdf, Ghostscript with URL inputs, image optimization services with URL parameters.',
),
link_preview: z
.array(SinkRefSchema)
.describe(
'Link preview and unfurlers: chat application link expanders, CMS link preview generators, ' +
'oEmbed endpoint fetchers, social media card generators, URL metadata extractors.',
),
webhook_testers: z
.array(SinkRefSchema)
.describe(
'Webhook testers and callback verifiers: "ping my webhook" functionality, outbound callback ' +
'verification, health check notifications, event delivery confirmations, API endpoint validation tools.',
),
sso_oidc_discovery: z
.array(SinkRefSchema)
.describe(
'SSO/OIDC discovery and JWKS fetchers: OpenID Connect discovery endpoints, JWKS fetchers, ' +
'OAuth authorization server metadata, SAML metadata fetchers, federation metadata retrievers.',
),
importers: z
.array(SinkRefSchema)
.describe(
'Importers and data loaders: "import from URL" functionality, CSV/JSON/XML remote loaders, ' +
'RSS/Atom feed readers, API data synchronization, configuration file fetchers.',
),
package_installers: z
.array(SinkRefSchema)
.describe(
'Package/plugin/theme installers: "install from URL" features, package managers with remote ' +
'sources, plugin/theme downloaders, update mechanisms with remote checks, dependency resolution ' +
'with external repos.',
),
monitoring_and_health: z
.array(SinkRefSchema)
.describe(
'Monitoring and health check frameworks: URL pingers and uptime checkers, health check ' +
'endpoints, monitoring probe systems, alerting webhook senders, performance testing tools.',
),
cloud_metadata: z
.array(SinkRefSchema)
.describe(
'Cloud metadata helpers: AWS/GCP/Azure instance metadata callers, cloud service discovery ' +
'mechanisms, container orchestration API clients, infrastructure metadata fetchers, service mesh ' +
'configuration retrievers.',
),
});
// ============================================================================
// EXPORTED TYPES
// ============================================================================
export type ExecutiveSummaryInput = z.infer<typeof ExecutiveSummaryInputSchema>;
export type ApplicationIntelligenceInput = z.infer<typeof ApplicationIntelligenceInputSchema>;
export type AuthDeepDiveInput = z.infer<typeof AuthDeepDiveInputSchema>;
export type CodebaseIndexingInput = z.infer<typeof CodebaseIndexingInputSchema>;
export type CriticalFilePathsInput = z.infer<typeof CriticalFilePathsInputSchema>;
export type XssSinksInput = z.infer<typeof XssSinksInputSchema>;
export type SsrfSinksInput = z.infer<typeof SsrfSinksInputSchema>;
export interface PreReconData {
readonly executive_summary?: ExecutiveSummaryInput;
readonly application_intelligence?: ApplicationIntelligenceInput;
readonly auth_deep_dive?: AuthDeepDiveInput;
readonly codebase_indexing?: CodebaseIndexingInput;
readonly critical_file_paths?: CriticalFilePathsInput;
readonly xss_sinks?: XssSinksInput;
readonly ssrf_sinks?: SsrfSinksInput;
}
export const PRE_RECON_ONE_SHOT_TOOLS = [
'set_executive_summary',
'set_application_intelligence',
'set_auth_deep_dive',
'set_codebase_indexing',
'set_critical_file_paths',
'set_xss_sinks',
'set_ssrf_sinks',
] as const;
export type PreReconToolName = (typeof PRE_RECON_ONE_SHOT_TOOLS)[number];
export type PreReconToolStatus = 'called' | 'skipped';
export type PreReconCallStatus = Readonly<Record<PreReconToolName, PreReconToolStatus>>;
// ============================================================================
// RESPONSE HELPERS
// ============================================================================
interface ToolResult {
[x: string]: unknown;
content: Array<{ type: 'text'; text: string }>;
isError: boolean;
}
function createToolResult(response: { status: string; [key: string]: unknown }): ToolResult {
return {
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
isError: response.status === 'error',
};
}
function successResult(data: Record<string, unknown>): ToolResult {
return createToolResult({ status: 'success', ...data });
}
function errorResult(message: string, errorType = 'ValidationError', retryable = true): ToolResult {
return createToolResult({ status: 'error', message, errorType, retryable });
}
// ============================================================================
// SERVER FACTORY
// ============================================================================
export interface PreReconCollectorServer {
server: McpSdkServerConfigWithInstance;
getAll(): PreReconData;
getCallStatus(): PreReconCallStatus;
}
export function createPreReconCollectorServer(): PreReconCollectorServer {
const state: {
executive_summary?: ExecutiveSummaryInput;
application_intelligence?: ApplicationIntelligenceInput;
auth_deep_dive?: AuthDeepDiveInput;
codebase_indexing?: CodebaseIndexingInput;
critical_file_paths?: CriticalFilePathsInput;
xss_sinks?: XssSinksInput;
ssrf_sinks?: SsrfSinksInput;
} = {};
function alreadyCalled(toolName: PreReconToolName): ToolResult {
return errorResult(
`${toolName} has already been called. Each set_* tool may only be called once per run.`,
'DuplicateError',
false,
);
}
const setExecutiveSummary = tool(
'set_executive_summary',
"Record the application's overall security posture as a short executive summary. " +
'Call exactly once before terminating. Becomes Section 1 of the rendered deliverable. ' +
'Duplicate calls are rejected.',
ExecutiveSummaryInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.executive_summary) return alreadyCalled('set_executive_summary');
state.executive_summary = input;
return successResult({ set: 'set_executive_summary' });
},
);
const setApplicationIntelligence = tool(
'set_application_intelligence',
'Record the composite application intelligence — architecture, data security, attack surface, ' +
'and infrastructure — in a single call. Call exactly once before terminating. ' +
'Becomes Sections 2, 4, 5, and 6 of the rendered deliverable. Duplicate calls are rejected.',
ApplicationIntelligenceInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.application_intelligence) return alreadyCalled('set_application_intelligence');
state.application_intelligence = input;
return successResult({ set: 'set_application_intelligence' });
},
);
const setAuthDeepDive = tool(
'set_auth_deep_dive',
'Record the authentication & authorization deep dive. Call exactly once before terminating. ' +
'Becomes Section 3 of the rendered deliverable. Duplicate calls are rejected.',
AuthDeepDiveInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.auth_deep_dive) return alreadyCalled('set_auth_deep_dive');
state.auth_deep_dive = input;
return successResult({ set: 'set_auth_deep_dive' });
},
);
const setCodebaseIndexing = tool(
'set_codebase_indexing',
'Record the overall codebase indexing narrative. Call exactly once before terminating. ' +
'Becomes Section 7 of the rendered deliverable. Duplicate calls are rejected.',
CodebaseIndexingInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.codebase_indexing) return alreadyCalled('set_codebase_indexing');
state.codebase_indexing = input;
return successResult({ set: 'set_codebase_indexing' });
},
);
const setCriticalFilePaths = tool(
'set_critical_file_paths',
'Record the catalog of critical file paths grouped by security relevance. Call exactly once ' +
'before terminating. Becomes Section 8 of the rendered deliverable. The next agent uses this ' +
'as a starting point for manual review. Duplicate calls are rejected.',
CriticalFilePathsInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.critical_file_paths) return alreadyCalled('set_critical_file_paths');
state.critical_file_paths = input;
return successResult({ set: 'set_critical_file_paths' });
},
);
const setXssSinks = tool(
'set_xss_sinks',
'Record discovered XSS sinks grouped by render context. Call exactly once before terminating. ' +
'If the application has no web frontend at all, set applicable=false; otherwise populate each ' +
'render-context array (empty arrays mean "scanned, no sinks of this kind"). This list drives ' +
"the vuln-xss agent's testing todos downstream. Becomes Section 9 of the rendered deliverable. " +
'Duplicate calls are rejected.',
XssSinksInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.xss_sinks) return alreadyCalled('set_xss_sinks');
state.xss_sinks = input;
return successResult({ set: 'set_xss_sinks' });
},
);
const setSsrfSinks = tool(
'set_ssrf_sinks',
'Record discovered SSRF sinks grouped by sink category. Call exactly once before terminating. ' +
'If the application makes no outbound requests at all, set applicable=false; otherwise populate ' +
'each category array (empty arrays mean "scanned, no sinks of this kind"). This list drives ' +
"the vuln-ssrf agent's testing todos downstream. Becomes Section 10 of the rendered deliverable. " +
'Duplicate calls are rejected.',
SsrfSinksInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.ssrf_sinks) return alreadyCalled('set_ssrf_sinks');
state.ssrf_sinks = input;
return successResult({ set: 'set_ssrf_sinks' });
},
);
const server: McpSdkServerConfigWithInstance = createSdkMcpServer({
name: 'pre-recon-collector',
version: '1.0.0',
tools: [
setExecutiveSummary,
setApplicationIntelligence,
setAuthDeepDive,
setCodebaseIndexing,
setCriticalFilePaths,
setXssSinks,
setSsrfSinks,
],
});
function statusOf<K extends PreReconToolName>(key: K): PreReconToolStatus {
const flagMap: Record<PreReconToolName, unknown> = {
set_executive_summary: state.executive_summary,
set_application_intelligence: state.application_intelligence,
set_auth_deep_dive: state.auth_deep_dive,
set_codebase_indexing: state.codebase_indexing,
set_critical_file_paths: state.critical_file_paths,
set_xss_sinks: state.xss_sinks,
set_ssrf_sinks: state.ssrf_sinks,
};
return flagMap[key] ? 'called' : 'skipped';
}
return {
server,
getAll: (): PreReconData => ({
...(state.executive_summary && { executive_summary: state.executive_summary }),
...(state.application_intelligence && { application_intelligence: state.application_intelligence }),
...(state.auth_deep_dive && { auth_deep_dive: state.auth_deep_dive }),
...(state.codebase_indexing && { codebase_indexing: state.codebase_indexing }),
...(state.critical_file_paths && { critical_file_paths: state.critical_file_paths }),
...(state.xss_sinks && { xss_sinks: state.xss_sinks }),
...(state.ssrf_sinks && { ssrf_sinks: state.ssrf_sinks }),
}),
getCallStatus: (): PreReconCallStatus => ({
set_executive_summary: statusOf('set_executive_summary'),
set_application_intelligence: statusOf('set_application_intelligence'),
set_auth_deep_dive: statusOf('set_auth_deep_dive'),
set_codebase_indexing: statusOf('set_codebase_indexing'),
set_critical_file_paths: statusOf('set_critical_file_paths'),
set_xss_sinks: statusOf('set_xss_sinks'),
set_ssrf_sinks: statusOf('set_ssrf_sinks'),
}),
};
}
@@ -0,0 +1,818 @@
// 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.
/**
* Recon Collector MCP Server
*
* Exposes nine Zod-validated MCP tools that feed the recon_deliverable.md
* renderer — eight one-shot `set_*` tools, one per deliverable section, plus a
* multi-call `add_endpoints` tool that lets the agent split a large API
* inventory across calls (the only catalog whose realistic payload threatens
* the per-turn output cap).
*
* A skipped tool renders a "not provided" placeholder in that section rather
* than failing the activity. getCallStatus() exposes the per-run call pattern
* for logging. Each Zod schema's field-level descriptions carry the section
* guidance, so the SDK injects it into the agent's tool catalog.
*/
import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { type SinkRef, SinkRefSchema } from './pre-recon-collector.js';
// ============================================================================
// PER-TOOL INPUT SCHEMAS
// ============================================================================
export const ExecutiveSummaryInputSchema = z.object({
text: z
.string()
.min(1)
.describe(
"A brief overview of the application's purpose, core technology stack " +
'(e.g., Next.js, Cloudflare), and the primary user-facing components that ' +
'constitute the attack surface. Becomes Section 1 of the rendered deliverable.',
),
});
export const TechnologyStackInputSchema = z.object({
frontend: z.string().min(1).describe('Framework, key libraries, and authentication libraries used on the frontend.'),
backend: z.string().min(1).describe('Language, framework, and key dependencies used on the backend.'),
infrastructure: z
.string()
.min(1)
.describe('Hosting provider, CDN, database type, and other infrastructure components.'),
});
const SessionFlowSchema = z.object({
entry_points: z.string().min(1).describe('Authentication entry points (e.g., /login, /register, /auth/sso).'),
mechanism: z
.string()
.min(1)
.describe(
'Describe the step-by-step authentication process: credential submission, token generation, ' +
'cookie setting, redirects, etc.',
),
code_pointers: z
.string()
.min(1)
.describe(
'Pointers to the primary files and functions in the codebase that manage authentication and ' + 'session logic.',
),
});
const RoleAssignmentSchema = z.object({
role_determination: z
.string()
.min(1)
.describe('How roles are assigned post-authentication — database lookup, JWT claims, external service, etc.'),
default_role: z.string().min(1).describe('What role new users get by default.'),
role_upgrade_path: z
.string()
.min(1)
.describe(
'How users can gain higher privileges — admin approval, self-service, automatic, etc. ' +
'If no upgrade path exists, state that.',
),
code_implementation: z
.string()
.min(1)
.describe('Where role assignment logic is implemented (file paths and functions).'),
});
const PrivilegeStorageSchema = z.object({
storage_location: z
.string()
.min(1)
.describe('Where user privileges are stored — JWT claims, session data, database, external service.'),
validation_points: z.string().min(1).describe('Where role checks happen — middleware, decorators, inline checks.'),
cache_session_persistence: z.string().min(1).describe('How long privileges are cached, and when they are refreshed.'),
code_pointers: z.string().min(1).describe('Files that handle privilege validation.'),
});
const RoleSwitchingImpersonationSchema = z.object({
applicable: z
.boolean()
.describe(
'False only if the application has no impersonation, sudo-mode, or role-switching features ' +
'at all. When false, the other fields in this object may be null.',
),
impersonation_features: z
.string()
.nullable()
.describe(
'Any ability for admins or higher-privilege users to impersonate other users. Pass null when ' +
'applicable is false.',
),
role_switching: z
.string()
.nullable()
.describe('Temporary privilege elevation mechanisms like "sudo mode". Pass null when applicable is false.'),
audit_trail: z
.string()
.nullable()
.describe(
'Whether role switches or impersonation events are logged, and where. Pass null when applicable is false.',
),
code_implementation: z
.string()
.nullable()
.describe('Where these features are implemented (file paths and functions). Pass null when applicable is false.'),
});
export const AuthenticationInputSchema = z.object({
session_flow: SessionFlowSchema.describe(
'Authentication & Session Management Flow — overall entry points, mechanism, and code pointers. ' +
'Becomes Section 3 of the rendered deliverable.',
),
role_assignment: RoleAssignmentSchema.describe(
'Role Assignment Process — how roles are determined post-authentication. ' + 'Becomes Section 3.1.',
),
privilege_storage: PrivilegeStorageSchema.describe(
'Privilege Storage & Validation — where privileges live and where they are checked. ' + 'Becomes Section 3.2.',
),
role_switching_impersonation: RoleSwitchingImpersonationSchema.describe(
'Role Switching & Impersonation — impersonation, sudo mode, audit trails. Becomes Section 3.3. ' +
'Set applicable=false if no such features exist; the other fields may be null in that case.',
),
});
const HTTP_METHOD_VALUES = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'WS'] as const;
const EndpointSchema = z.object({
method: z.enum(HTTP_METHOD_VALUES).describe('HTTP method. Use WS for WebSocket upgrade endpoints.'),
path: z.string().min(1).describe('Endpoint path with parameter placeholders, e.g. "/api/users/{user_id}".'),
required_role: z.string().min(1).describe('Minimum role needed (anon, user, admin, etc.).'),
object_id_parameters: z
.array(z.string())
.describe('Parameters that identify specific objects (user_id, order_id, etc.). Empty array if none.'),
authorization_mechanism: z
.string()
.min(1)
.describe(
'How access is controlled — middleware, decorator, inline check. ' +
'E.g. "Bearer Token + ownership check", "requireAuth() + requireAdmin()", "None".',
),
description: z.string().min(1).describe("Brief description of the endpoint's purpose."),
code_pointer: z
.string()
.min(1)
.describe('File path and (where possible) line number of the handler. E.g. "auth.controller.ts:45".'),
});
export const AddEndpointsInputSchema = z.object({
endpoints: z
.array(EndpointSchema)
.describe(
'A batch of network-accessible API endpoints to append to the catalog. Include only endpoints ' +
'reachable through the deployed application — exclude CLI tools, dev-only routes, build scripts. ' +
'Duplicate (method, path) pairs across calls are skipped as no-ops; the response reports which ' +
'were added vs. skipped.',
),
});
export const InputVectorsInputSchema = z.object({
url_parameters: z
.array(z.string().min(1))
.describe(
'URL parameter input vectors — each entry should identify the parameter and (where possible) ' +
'the file:line of the handler. E.g. "?redirect_url= @ auth.controller.ts:88".',
),
post_body_fields: z
.array(z.string().min(1))
.describe(
'POST/PUT body field input vectors (JSON or form). E.g. "username @ login.handler.ts:34", ' +
'"profile.description @ users.controller.ts:120".',
),
http_headers: z
.array(z.string().min(1))
.describe(
'HTTP header input vectors. Include both standard headers consumed by app code (e.g., ' +
'X-Forwarded-For) and custom application headers.',
),
cookie_values: z
.array(z.string().min(1))
.describe('Cookie-based input vectors. E.g. "preferences_cookie @ middleware/prefs.ts:22".'),
});
const ENTITY_TYPE_VALUES = ['ExternAsset', 'Service', 'Identity', 'DataStore', 'AdminPlane', 'ThirdParty'] as const;
const ENTITY_ZONE_VALUES = ['Internet', 'Edge', 'App', 'Data', 'Admin', 'BuildCI', 'ThirdParty'] as const;
const DATA_LABEL_VALUES = ['PII', 'Tokens', 'Payments', 'Secrets', 'Public'] as const;
const FLOW_CHANNEL_VALUES = ['HTTP', 'HTTPS', 'TCP', 'Message', 'File', 'Token'] as const;
const GUARD_CATEGORY_VALUES = [
'Auth',
'Network',
'Protocol',
'Env',
'RateLimit',
'Authorization',
'ObjectOwnership',
] as const;
const EntityMetadataPairSchema = z.object({
key: z.string().min(1).describe('Metadata key (e.g., "Hosts", "Endpoints", "Engine", "Issuer").'),
value: z.string().min(1).describe('Metadata value for this key.'),
});
const EntitySchema = z.object({
title: z
.string()
.min(1)
.describe('Unique short name for the entity (e.g., "ExampleWebApp", "PostgreSQL-DB", "IdentityProvider").'),
type: z
.enum(ENTITY_TYPE_VALUES)
.describe(
'Entity type. ExternAsset = client-side asset; Service = backend service; Identity = identity ' +
'provider; DataStore = database / cache / object store; AdminPlane = admin/control surface; ' +
'ThirdParty = external integration.',
),
zone: z
.enum(ENTITY_ZONE_VALUES)
.describe(
'Trust zone. Internet = public; Edge = CDN/WAF/reverse-proxy tier; App = application/business logic; ' +
'Data = persistent storage; Admin = administrative surface; BuildCI = build/CI/CD infrastructure; ' +
'ThirdParty = external trust domain.',
),
tech: z
.string()
.min(1)
.describe('Short technology/framework description (e.g., "Node/Express", "Postgres 14", "AWS S3").'),
data: z
.array(z.enum(DATA_LABEL_VALUES))
.describe('Data labels handled by this entity. Empty array if the entity handles only Public data.'),
notes: z
.string()
.describe('Freeform context (e.g., "public-facing", "stores sensitive user data"). Empty string if none.'),
metadata: z
.array(EntityMetadataPairSchema)
.describe(
'Ordered key/value pairs of technical metadata for this entity. Becomes the Section 6.2 row ' +
'rendered as "Key: Value; Key: Value; …". Example pairs for a service: Hosts, Endpoints, Auth, ' +
'Dependencies; for a datastore: Engine, Exposure, Consumers, Credentials.',
),
});
const FlowSchema = z.object({
from: z.string().min(1).describe('Source entity title — must match a title from the entities array.'),
to: z.string().min(1).describe('Destination entity title — must match a title from the entities array.'),
channel: z.enum(FLOW_CHANNEL_VALUES).describe('Transport channel for this flow.'),
path_port: z
.string()
.min(1)
.describe('Path and/or port for this flow. E.g. ":443 /api/users/me", ":5432", "queue: orders".'),
guards: z
.array(z.string().min(1))
.describe(
'Guard names that gate this flow. Each should match a name from the guards array. Empty array ' +
'means no guards apply (publicly accessible).',
),
touches: z
.array(z.enum(DATA_LABEL_VALUES))
.describe('Data labels this flow carries. Empty array if only Public data flows.'),
});
const GuardSchema = z.object({
name: z.string().min(1).describe('Short guard identifier (e.g., "auth:user", "ownership:user", "vpc-only", "mtls").'),
category: z
.enum(GUARD_CATEGORY_VALUES)
.describe(
'Guard category. Auth = authentication identity; Authorization = role/scope check; ' +
'ObjectOwnership = ownership-based check; Network = network-level restriction; ' +
'Protocol = protocol-level requirement; Env = environment-bound restriction; ' +
'RateLimit = throttling.',
),
statement: z.string().min(1).describe('One-sentence description of what this guard enforces.'),
});
export const NetworkMapInputSchema = z.object({
entities: z
.array(EntitySchema)
.describe(
'All major components of the system. Becomes Section 6.1 (Entities) and Section 6.2 ' +
'(Entity Metadata, split per-entity from the metadata field).',
),
flows: z
.array(FlowSchema)
.describe(
'How entities communicate. Becomes Section 6.3. The from/to fields cross-reference entities ' +
'by title; the guards field cross-references guards by name.',
),
guards: z.array(GuardSchema).describe('Catalog of guards referenced by flows. Becomes Section 6.4.'),
});
const RoleSchema = z.object({
name: z.string().min(1).describe('Role name (e.g., "anon", "user", "admin", "team_admin").'),
privilege_level: z
.number()
.int()
.min(0)
.max(10)
.describe('Privilege rank from 0 (lowest, anonymous) to 10 (highest, full admin).'),
scope_domain: z.string().min(1).describe('Scope of this role: Global, Org, Team, Project, etc.'),
code_implementation: z
.string()
.min(1)
.describe('Where this role is defined or checked (middleware, decorator, file:line, etc.).'),
default_landing_page: z
.string()
.min(1)
.describe('Default landing page or route after authentication. Use "N/A" for roles without a UI.'),
accessible_route_patterns: z
.array(z.string().min(1))
.describe('Route patterns this role can access. Empty array if the role has no UI access.'),
authentication_method: z
.string()
.min(1)
.describe('How this role authenticates: "None" (anon), "Session/JWT", "Session/JWT + role claim", etc.'),
middleware_guards: z
.string()
.min(1)
.describe('Middleware and guards that enforce this role (e.g., "requireAuth() + requireAdmin()").'),
permission_checks: z
.string()
.min(1)
.describe('How permission checks are expressed in code (e.g., "req.user.role === \'admin\'").'),
storage_location: z
.string()
.min(1)
.describe('Where this role is stored at runtime (JWT claims, session data, etc.).'),
});
const PrivilegeLatticeSchema = z.object({
ordering_diagram: z
.string()
.min(1)
.describe(
'ASCII diagram showing role ordering. Use → for "can access resources of". ' + 'E.g. "anon → user → admin".',
),
parallel_isolation_notes: z
.string()
.describe(
'Notes on parallel isolation between roles using ||. E.g. "team_admin || dept_admin (both > user, ' +
'but isolated from each other)". Empty string if no parallel isolation exists.',
),
role_switching_notes: z
.string()
.nullable()
.optional()
.describe(
'Optional pointer to impersonation, sudo mode, or role-switching mechanisms documented in ' +
'set_authentication.role_switching_impersonation. Null/omitted if no such mechanisms exist.',
),
});
export const RoleArchitectureInputSchema = z.object({
roles: z
.array(RoleSchema)
.describe(
'All distinct privilege levels found in the application. Becomes Sections 7.1 (Discovered Roles), ' +
'7.3 (Role Entry Points), and 7.4 (Role-to-Code Mapping), split by the renderer per-role.',
),
privilege_lattice: PrivilegeLatticeSchema.describe(
'The role hierarchy showing dominance and parallel isolation. Becomes Section 7.2.',
),
});
const PRIORITY_VALUES = ['High', 'Medium', 'Low'] as const;
const HorizontalCandidateSchema = z.object({
priority: z
.enum(PRIORITY_VALUES)
.describe('Priority: High, Medium, or Low, based on data sensitivity (title-case literals).'),
endpoint_pattern: z
.string()
.min(1)
.describe('Endpoint pattern with the object identifier. E.g. "/api/orders/{order_id}".'),
object_id_parameter: z
.string()
.min(1)
.describe('The parameter name that identifies the target object (e.g., "order_id", "user_id").'),
data_type: z.string().min(1).describe('Type of data exposed: user_data, financial, admin_config, user_files, etc.'),
sensitivity: z
.string()
.min(1)
.describe('One-line description of what is at risk (e.g., "User can access other users\' orders").'),
});
const VerticalCandidateSchema = z.object({
target_role: z.string().min(1).describe('Role required to access this endpoint (the role being escalated to).'),
endpoint_pattern: z
.string()
.min(1)
.describe('Endpoint pattern that requires elevated privileges. E.g. "/admin/*", "/api/admin/users".'),
functionality: z
.string()
.min(1)
.describe('What the endpoint does (e.g., "Administrative functions", "User management").'),
risk_level: z.enum(PRIORITY_VALUES).describe('Risk level: High, Medium, or Low (title-case literals).'),
});
const ContextCandidateSchema = z.object({
workflow: z.string().min(1).describe('Multi-step workflow name (e.g., "Checkout", "Onboarding", "Password Reset").'),
endpoint: z.string().min(1).describe('Endpoint that assumes a prior workflow state. E.g. "/api/checkout/confirm".'),
expected_prior_state: z.string().min(1).describe('What state should already exist before this endpoint is called.'),
bypass_potential: z.string().min(1).describe('What an attacker could achieve by skipping the prior state.'),
});
export const AuthzCandidatesInputSchema = z.object({
horizontal: z
.array(HorizontalCandidateSchema)
.describe(
"Endpoints with object identifiers that could allow horizontal access to other users' " +
'resources. Becomes Section 8.1. The renderer assigns stable AUTHZ-CAND-NN IDs.',
),
vertical: z
.array(VerticalCandidateSchema)
.describe(
'Endpoints that require higher privileges and could be targets for vertical escalation. ' +
'Becomes Section 8.2. Exclude endpoints intentionally shared across roles.',
),
context: z
.array(ContextCandidateSchema)
.describe('Multi-step workflow endpoints that assume prior steps were completed. Becomes Section 8.3.'),
});
export const InjectionSourcesInputSchema = z.object({
applicable: z
.boolean()
.describe(
'False only if the application has no network-accessible code paths reaching dangerous sinks ' +
'at all. Otherwise true, even if no sources were found in a given category — empty arrays mean ' +
'"scanned this category, no sources found".',
),
command_injection: z
.array(SinkRefSchema)
.describe(
'Command injection sources: data flowing from a user-controlled origin into a program variable ' +
'that is eventually interpolated into a shell or system command string (within network-accessible ' +
'code paths).',
),
sql_injection: z
.array(SinkRefSchema)
.describe(
'SQL injection sources: user-controllable input that reaches a database query string (within ' +
'network-accessible code paths).',
),
lfi_rfi: z
.array(SinkRefSchema)
.describe(
'Local/Remote File Inclusion sources: user-controllable input passed to include/require/load ' +
'functions that resolve to filesystem or remote paths (within network-accessible code paths).',
),
path_traversal: z
.array(SinkRefSchema)
.describe(
'Path traversal sources: user-controllable input that influences file paths in read/write ' +
'operations (fopen, readFile, etc.) within network-accessible code paths.',
),
ssti: z
.array(SinkRefSchema)
.describe(
'Server-Side Template Injection sources: user-controllable input embedded in template ' +
'expressions or template content within network-accessible code paths.',
),
deserialization: z
.array(SinkRefSchema)
.describe(
'Insecure deserialization sources: user-controllable input passed to deserialization functions ' +
'within network-accessible code paths.',
),
});
// ============================================================================
// EXPORTED TYPES
// ============================================================================
export type ExecutiveSummaryInput = z.infer<typeof ExecutiveSummaryInputSchema>;
export type TechnologyStackInput = z.infer<typeof TechnologyStackInputSchema>;
export type AuthenticationInput = z.infer<typeof AuthenticationInputSchema>;
export type AddEndpointsInput = z.infer<typeof AddEndpointsInputSchema>;
export type Endpoint = z.infer<typeof EndpointSchema>;
export type InputVectorsInput = z.infer<typeof InputVectorsInputSchema>;
export type NetworkMapInput = z.infer<typeof NetworkMapInputSchema>;
export type Entity = z.infer<typeof EntitySchema>;
export type Flow = z.infer<typeof FlowSchema>;
export type Guard = z.infer<typeof GuardSchema>;
export type RoleArchitectureInput = z.infer<typeof RoleArchitectureInputSchema>;
export type Role = z.infer<typeof RoleSchema>;
export type PrivilegeLattice = z.infer<typeof PrivilegeLatticeSchema>;
export type AuthzCandidatesInput = z.infer<typeof AuthzCandidatesInputSchema>;
export type HorizontalCandidate = z.infer<typeof HorizontalCandidateSchema>;
export type VerticalCandidate = z.infer<typeof VerticalCandidateSchema>;
export type ContextCandidate = z.infer<typeof ContextCandidateSchema>;
export type InjectionSourcesInput = z.infer<typeof InjectionSourcesInputSchema>;
export type Priority = (typeof PRIORITY_VALUES)[number];
export interface ReconData {
readonly executive_summary?: ExecutiveSummaryInput;
readonly technology_stack?: TechnologyStackInput;
readonly authentication?: AuthenticationInput;
readonly endpoints?: readonly Endpoint[];
readonly input_vectors?: InputVectorsInput;
readonly network_map?: NetworkMapInput;
readonly role_architecture?: RoleArchitectureInput;
readonly authz_candidates?: AuthzCandidatesInput;
readonly injection_sources?: InjectionSourcesInput;
}
export const RECON_ONE_SHOT_TOOLS = [
'set_executive_summary',
'set_technology_stack',
'set_authentication',
'set_input_vectors',
'set_network_map',
'set_role_architecture',
'set_authz_candidates',
'set_injection_sources',
] as const;
export type ReconOneShotToolName = (typeof RECON_ONE_SHOT_TOOLS)[number];
export type ReconToolStatus = 'called' | 'skipped';
export interface ReconCallStatus {
readonly set_executive_summary: ReconToolStatus;
readonly set_technology_stack: ReconToolStatus;
readonly set_authentication: ReconToolStatus;
readonly add_endpoints: { readonly calls: number; readonly endpoints_seen: number };
readonly set_input_vectors: ReconToolStatus;
readonly set_network_map: ReconToolStatus;
readonly set_role_architecture: ReconToolStatus;
readonly set_authz_candidates: ReconToolStatus;
readonly set_injection_sources: ReconToolStatus;
}
// ============================================================================
// RESPONSE HELPERS
// ============================================================================
interface ToolResult {
[x: string]: unknown;
content: Array<{ type: 'text'; text: string }>;
isError: boolean;
}
function createToolResult(response: { status: string; [key: string]: unknown }): ToolResult {
return {
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
isError: response.status === 'error',
};
}
function successResult(data: Record<string, unknown>): ToolResult {
return createToolResult({ status: 'success', ...data });
}
function errorResult(message: string, errorType = 'ValidationError', retryable = true): ToolResult {
return createToolResult({ status: 'error', message, errorType, retryable });
}
function endpointKey(method: string, path: string): string {
return `${method} ${path}`;
}
// ============================================================================
// SERVER FACTORY
// ============================================================================
export interface ReconCollectorServer {
server: McpSdkServerConfigWithInstance;
getAll(): ReconData;
getCallStatus(): ReconCallStatus;
}
export function createReconCollectorServer(): ReconCollectorServer {
const state: {
executive_summary?: ExecutiveSummaryInput;
technology_stack?: TechnologyStackInput;
authentication?: AuthenticationInput;
input_vectors?: InputVectorsInput;
network_map?: NetworkMapInput;
role_architecture?: RoleArchitectureInput;
authz_candidates?: AuthzCandidatesInput;
injection_sources?: InjectionSourcesInput;
} = {};
const endpoints: Endpoint[] = [];
const seenEndpointKeys = new Set<string>();
let addEndpointsCalls = 0;
function alreadyCalled(toolName: ReconOneShotToolName): ToolResult {
return errorResult(
`${toolName} has already been called. Each set_* tool may only be called once per run.`,
'DuplicateError',
false,
);
}
const setExecutiveSummary = tool(
'set_executive_summary',
"Record the application's executive summary: purpose, core technology stack, and primary " +
'user-facing components. Call exactly once before terminating. Becomes Section 1 of the rendered ' +
'deliverable. Duplicate calls are rejected.',
ExecutiveSummaryInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.executive_summary) return alreadyCalled('set_executive_summary');
state.executive_summary = input;
return successResult({ set: 'set_executive_summary' });
},
);
const setTechnologyStack = tool(
'set_technology_stack',
'Record the technology and service map: frontend, backend, and infrastructure. Call exactly once ' +
'before terminating. Becomes Section 2 of the rendered deliverable. Duplicate calls are rejected.',
TechnologyStackInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.technology_stack) return alreadyCalled('set_technology_stack');
state.technology_stack = input;
return successResult({ set: 'set_technology_stack' });
},
);
const setAuthentication = tool(
'set_authentication',
'Record the authentication and session management architecture: session flow, role assignment, ' +
'privilege storage, and role switching/impersonation. Call exactly once before terminating. ' +
'Becomes Sections 3, 3.1, 3.2, and 3.3 of the rendered deliverable. Set ' +
'role_switching_impersonation.applicable=false (with the other fields null) if no such features ' +
'exist. Duplicate calls are rejected.',
AuthenticationInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.authentication) return alreadyCalled('set_authentication');
state.authentication = input;
return successResult({ set: 'set_authentication' });
},
);
const addEndpoints = tool(
'add_endpoints',
'Append a batch of network-accessible API endpoints to the catalog. May be called multiple times — ' +
'each call appends. Use a single call for small inventories, or split across 2-3 calls for large ' +
'inventories (50+ endpoints) to keep individual payloads comfortable. Duplicate (method, path) ' +
'pairs across calls are skipped as no-ops; the response reports added vs. skipped. Becomes ' +
'Section 4 of the rendered deliverable and drives vuln-authz / vuln-injection todos downstream. ' +
'The renderer sorts by (path, method) before rendering, so emission order does not affect output.',
AddEndpointsInputSchema.shape,
async (input): Promise<ToolResult> => {
addEndpointsCalls += 1;
const added: string[] = [];
const skipped: string[] = [];
for (const ep of input.endpoints) {
const key = endpointKey(ep.method, ep.path);
if (seenEndpointKeys.has(key)) {
skipped.push(key);
continue;
}
seenEndpointKeys.add(key);
endpoints.push(ep);
added.push(key);
}
return successResult({
set: 'add_endpoints',
added: added.length,
duplicates_skipped: skipped,
total_accumulated: endpoints.length,
});
},
);
const setInputVectors = tool(
'set_input_vectors',
'Record potential input vectors grouped by source: URL parameters, POST body fields, HTTP headers, ' +
'and cookie values. Call exactly once before terminating. Becomes Section 5 of the rendered ' +
'deliverable. Drives downstream vulnerability analysis. Duplicate calls are rejected.',
InputVectorsInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.input_vectors) return alreadyCalled('set_input_vectors');
state.input_vectors = input;
return successResult({ set: 'set_input_vectors' });
},
);
const setNetworkMap = tool(
'set_network_map',
'Record the network and interaction map: entities, flows, and guards. Call exactly once before ' +
'terminating. Becomes Sections 6.1 (Entities), 6.2 (Entity Metadata), 6.3 (Flows), and 6.4 ' +
'(Guards Directory) of the rendered deliverable. The renderer splits the entities array into ' +
'the 6.1 and 6.2 tables and sorts each array deterministically. Duplicate calls are rejected.',
NetworkMapInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.network_map) return alreadyCalled('set_network_map');
state.network_map = input;
return successResult({ set: 'set_network_map' });
},
);
const setRoleArchitecture = tool(
'set_role_architecture',
'Record the role and privilege architecture: discovered roles and the privilege lattice. Call ' +
'exactly once before terminating. Becomes Sections 7.1 (Discovered Roles), 7.2 (Privilege Lattice), ' +
'7.3 (Role Entry Points), and 7.4 (Role-to-Code Mapping) of the rendered deliverable. The renderer ' +
'splits the roles array into the per-section tables. Duplicate calls are rejected.',
RoleArchitectureInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.role_architecture) return alreadyCalled('set_role_architecture');
state.role_architecture = input;
return successResult({ set: 'set_role_architecture' });
},
);
const setAuthzCandidates = tool(
'set_authz_candidates',
'Record authorization vulnerability candidates: horizontal escalation, vertical escalation, and ' +
'context-based candidates. Call exactly once before terminating. Becomes Sections 8.1, 8.2, and ' +
'8.3 of the rendered deliverable. The renderer assigns stable AUTHZ-CAND-NN IDs across the three ' +
'sub-arrays in horizontal → vertical → context order, which vuln-authz reads as its todo list. ' +
'Duplicate calls are rejected.',
AuthzCandidatesInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.authz_candidates) return alreadyCalled('set_authz_candidates');
state.authz_candidates = input;
return successResult({ set: 'set_authz_candidates' });
},
);
const setInjectionSources = tool(
'set_injection_sources',
'Record discovered injection sources grouped by vulnerability class. Call exactly once before ' +
'terminating. If the application has no network-accessible code paths to dangerous sinks, set ' +
'applicable=false; otherwise populate each category array (empty arrays mean "scanned, no sources ' +
'of this kind"). Becomes Section 9 of the rendered deliverable. Drives the vuln-injection agent\'s ' +
'todos downstream. Duplicate calls are rejected.',
InjectionSourcesInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.injection_sources) return alreadyCalled('set_injection_sources');
state.injection_sources = input;
return successResult({ set: 'set_injection_sources' });
},
);
const server: McpSdkServerConfigWithInstance = createSdkMcpServer({
name: 'recon-collector',
version: '1.0.0',
tools: [
setExecutiveSummary,
setTechnologyStack,
setAuthentication,
addEndpoints,
setInputVectors,
setNetworkMap,
setRoleArchitecture,
setAuthzCandidates,
setInjectionSources,
],
});
function statusOf<K extends ReconOneShotToolName>(key: K): ReconToolStatus {
const flagMap: Record<ReconOneShotToolName, unknown> = {
set_executive_summary: state.executive_summary,
set_technology_stack: state.technology_stack,
set_authentication: state.authentication,
set_input_vectors: state.input_vectors,
set_network_map: state.network_map,
set_role_architecture: state.role_architecture,
set_authz_candidates: state.authz_candidates,
set_injection_sources: state.injection_sources,
};
return flagMap[key] ? 'called' : 'skipped';
}
return {
server,
getAll: (): ReconData => ({
...(state.executive_summary && { executive_summary: state.executive_summary }),
...(state.technology_stack && { technology_stack: state.technology_stack }),
...(state.authentication && { authentication: state.authentication }),
...(endpoints.length > 0 && { endpoints }),
...(state.input_vectors && { input_vectors: state.input_vectors }),
...(state.network_map && { network_map: state.network_map }),
...(state.role_architecture && { role_architecture: state.role_architecture }),
...(state.authz_candidates && { authz_candidates: state.authz_candidates }),
...(state.injection_sources && { injection_sources: state.injection_sources }),
}),
getCallStatus: (): ReconCallStatus => ({
set_executive_summary: statusOf('set_executive_summary'),
set_technology_stack: statusOf('set_technology_stack'),
set_authentication: statusOf('set_authentication'),
add_endpoints: { calls: addEndpointsCalls, endpoints_seen: endpoints.length },
set_input_vectors: statusOf('set_input_vectors'),
set_network_map: statusOf('set_network_map'),
set_role_architecture: statusOf('set_role_architecture'),
set_authz_candidates: statusOf('set_authz_candidates'),
set_injection_sources: statusOf('set_injection_sources'),
}),
};
}
// Re-exported here so the renderer can import the shared sink type without
// depending on pre-recon's collector by name.
export type { SinkRef };
@@ -0,0 +1,512 @@
// 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.
/**
* Vuln Collector MCP Server (factory parameterized by vulnerability class).
*
* Exposes 4 one-shot, Zod-validated MCP tools per vuln agent (injection, xss,
* auth, ssrf, authz) that feed a deterministic renderer producing
* {class}_analysis_deliverable.md:
* - set_findings_summary — §1 executive summary + §2 dominant patterns
* - set_strategic_intelligence — §3, per-class schema
* - set_safe_vectors — §4, shared schema across classes
* - set_blind_spots — §5, shared schema across classes
*
* Only set_strategic_intelligence varies by class; the collector branches on
* vulnClass to assemble the right schema. The other 3 tools are identical
* across classes.
*
* Skipped tools surface as renderer placeholders, not activity failures.
* getCallStatus() exposes the per-run call pattern for logging. Each Zod
* schema's field-level descriptions carry the section guidance, so the SDK
* injects it into the agent's tool catalog.
*/
import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { type ZodRawShape, z } from 'zod';
// ============================================================================
// CLASS DISCRIMINATOR
// ============================================================================
export const VULN_CLASSES = ['injection', 'xss', 'auth', 'ssrf', 'authz'] as const;
export type VulnClass = (typeof VULN_CLASSES)[number];
// Classes whose deliverables carry a Section 5 (blind spots). The auth and ssrf
// analyses have no blind-spots section, so the set_blind_spots tool is withheld
// from those agents and the renderer omits the section. Single source of truth
// for both the tool registration and the rendering gate.
export const BLIND_SPOTS_CLASSES: ReadonlySet<VulnClass> = new Set<VulnClass>(['injection', 'xss', 'authz']);
// ============================================================================
// SHARED SCHEMAS — set_findings_summary, set_safe_vectors, set_blind_spots
// ============================================================================
const PatternSchema = z.object({
name: z
.string()
.min(1)
.describe(
'Concise pattern name, e.g. "Weak Session Management", "Reflected XSS in Search Parameter", ' +
'"Insufficient URL Validation".',
),
description: z.string().min(1).describe('One- to two-sentence description of the pattern observed in the codebase.'),
implication: z
.string()
.min(1)
.describe('One- to two-sentence implication for exploitation — what does this pattern enable an attacker to do.'),
representative_finding_ids: z
.array(z.string().min(1))
.min(1)
.describe(
'IDs of findings that exhibit this pattern (e.g. ["AUTH-VULN-01", "AUTH-VULN-02"]). Must match ' +
'IDs the agent has assigned in the structured-output exploitation queue.',
),
});
export const FindingsSummaryInputSchema = z.object({
key_outcome: z
.string()
.min(1)
.describe(
'One to two sentences capturing the headline result of your analysis — what was found and its ' +
'severity profile (e.g. "Several high-confidence SQL injection vulnerabilities were identified; ' +
'all findings have been passed to the exploitation phase"). Becomes Section 1 of the rendered ' +
'deliverable.',
),
patterns: z
.array(PatternSchema)
.describe(
'Complete list of dominant patterns observed across findings. Pass all patterns in one call. ' +
'Empty array is acceptable if no recurring patterns were observed — the deliverable will render ' +
'"No dominant patterns identified" for Section 2 in that case.',
),
});
export const SafeVectorInputSchema = z.object({
subject: z
.string()
.min(1)
.describe(
'The specific subject of analysis. For injection/xss runs, the input parameter name (e.g. ' +
'"username", "redirect_url"). For auth/ssrf runs, the component or flow name (e.g. ' +
'"Password Hashing", "Webhook Configuration"). For authz runs, the endpoint (e.g. ' +
'"POST /api/auth/logout"). The renderer maps this to the class-appropriate column header.',
),
location: z
.string()
.min(1)
.describe(
'File path with line number (e.g. "controllers/authController.js:45") or endpoint URL (e.g. ' +
'"/profile"). For authz runs, this is the guard location specifically (e.g. ' +
'"middleware/auth.js:45"). The renderer maps this to the class-appropriate column header.',
),
defense_mechanism: z
.string()
.min(1)
.describe(
'The robust defense observed (e.g. "Prepared Statement (Parameter Binding)", "HTML Entity ' +
'Encoding", "Strict URL Whitelist Validation", "bcrypt.compare for constant-time check").',
),
render_context: z
.string()
.nullable()
.optional()
.describe(
'XSS-only: the DOM render context for the validated vector — one of HTML_BODY, HTML_ATTRIBUTE, ' +
'JAVASCRIPT_STRING, URL_PARAM, CSS_VALUE. Omit (or pass null) for non-XSS classes; the renderer ' +
'only emits this column for the XSS deliverable.',
),
});
export const SafeVectorsInputSchema = z.object({
vectors: z
.array(SafeVectorInputSchema)
.describe(
'All input vectors / components / endpoints that were analyzed and confirmed to have robust, ' +
'context-appropriate defenses. Empty array is acceptable but unusual — the deliverable will ' +
'render "No vectors confirmed secure during analysis" for Section 4 in that case. Becomes ' +
'Section 4 of the rendered deliverable. The renderer sorts by (subject, location) before ' +
'rendering, so emission order does not affect output.',
),
});
export const BlindSpotItemSchema = z.object({
heading: z
.string()
.min(1)
.describe(
'Short heading for the blind spot (e.g. "Untraced Asynchronous Flows", ' +
'"Limited Visibility into Stored Procedures", "Minified JavaScript Bundle").',
),
description: z
.string()
.min(1)
.describe(
'One to three sentences describing the analysis gap — what could not be traced, why, and what ' +
'the residual risk is.',
),
});
export const BlindSpotsInputSchema = z.object({
items: z
.array(BlindSpotItemSchema)
.describe(
'Analysis constraints, untraced code paths, or other coverage gaps that should be noted. ' +
'Empty array is acceptable on high-coverage runs — the deliverable will render "No analysis ' +
'constraints or blind spots identified" for Section 5 in that case. Becomes Section 5 of the ' +
'rendered deliverable.',
),
});
// ============================================================================
// PER-CLASS set_strategic_intelligence SCHEMAS (flat — no nesting)
// ============================================================================
const InjectionStrategicIntelSchema = z.object({
defensive_evasion_waf: z
.string()
.min(1)
.describe(
'WAF behavior observed during analysis: active rules, common payloads blocked, identified ' +
'bypasses (e.g. "WAF blocks UNION SELECT but not time-based blind injection"). Write ' +
'"Not applicable — no WAF observed" if none was detected.',
),
error_based_potential: z
.string()
.min(1)
.describe(
'Whether endpoints leak verbose database errors that enable error-based injection (e.g. ' +
'"/api/products returns verbose PostgreSQL error messages, prime target for error-based ' +
'exploitation"). Write "Not applicable" if no injection findings exist.',
),
confirmed_database_technology: z
.string()
.min(1)
.describe(
'Database engine(s) confirmed via error syntax or function calls (e.g. "PostgreSQL, confirmed ' +
'via pg_sleep() and verbose error syntax"). Drives payload selection downstream. Write ' +
'"Not applicable" if no DB sinks in scope.',
),
});
const XssStrategicIntelSchema = z.object({
csp_analysis: z
.string()
.min(1)
.describe(
'Content Security Policy observed and its bypassability: current policy text, critical bypasses ' +
"(e.g. \"script-src 'self' https://trusted-cdn.com — the trusted CDN hosts vulnerable AngularJS, " +
'enabling client-side template injection bypass"). Write "Not applicable — no CSP header served" ' +
'if none.',
),
cookie_security: z
.string()
.min(1)
.describe(
'Session cookie security observations: HttpOnly, Secure, SameSite flags, and storage mechanism ' +
'(e.g. "Primary session cookie `sessionid` is missing HttpOnly; tokens are also stored in ' +
'localStorage, both accessible to JavaScript"). Drives exfiltration strategy.',
),
});
const AuthStrategicIntelSchema = z.object({
authentication_method: z
.string()
.min(1)
.describe(
'How users authenticate: JWT, session cookie, OAuth, SAML, etc. Include any algorithm or library ' +
'details (e.g. "JWT (RS256) with hardcoded private key in lib/insecurity.ts:23").',
),
session_token_details: z
.string()
.min(1)
.describe(
'Where tokens live and how they are protected: cookie name, storage mechanism (cookie vs ' +
'localStorage), cookie flags, expiration (e.g. "JWT stored in localStorage under key `token`; ' +
'cookie copy lacks HttpOnly/Secure/SameSite; 6-hour TTL with no revocation").',
),
password_policy: z
.string()
.min(1)
.describe(
'Observed server-side password policy and storage: complexity rules, hashing algorithm, salt, ' +
'(e.g. "MD5 without salt via crypto.createHash; no server-side complexity policy; client-side ' +
'5-char minimum trivially bypassed").',
),
});
const SsrfStrategicIntelSchema = z.object({
http_client_library: z
.string()
.min(1)
.describe(
'HTTP client library/libraries used for outbound requests (e.g. "axios 1.6", "node-fetch", ' +
'"requests", "HttpClient (Spring)"). Include version where it informs known bypass techniques.',
),
request_architecture: z
.string()
.min(1)
.describe(
'How outbound requests are constructed and routed: proxy/middleware patterns, internal routing ' +
'rules (e.g. "Webhook URLs are POSTed directly without an outbound proxy; redirects are ' +
'followed by default with no maxRedirects limit").',
),
internal_services: z
.string()
.min(1)
.describe(
'Internal endpoints, services, or cloud-metadata addresses discovered during analysis that an ' +
'SSRF could reach (e.g. "169.254.169.254 (AWS IMDS), internal admin API at admin.internal:8443, ' +
'PostgreSQL on localhost:5432").',
),
});
const AuthzStrategicIntelSchema = z.object({
session_management_architecture: z
.string()
.min(1)
.describe(
'Session and authentication architecture relevant to authorization decisions: where user identity ' +
'comes from, whether the user ID is trusted by downstream guards (e.g. "JWT tokens in cookies; ' +
'user ID extracted from `req.user.id` and used directly in DB queries without ownership ' +
're-validation").',
),
role_permission_model: z
.string()
.min(1)
.describe(
'Roles, capabilities, and where they live: identified roles, their privilege levels, and where ' +
'role/permission data is stored (e.g. "Three roles: user, moderator, admin. Role embedded in ' +
'JWT and database; checks inconsistent — many admin routes only check `req.user` presence").',
),
resource_access_patterns: z
.string()
.min(1)
.describe(
'How resource IDs flow through the system and ownership patterns: e.g. "Most endpoints use path ' +
'parameters for resource IDs (/api/users/{id}); IDs are passed to DB queries without ownership ' +
'validation". Critical for IDOR exploitation.',
),
workflow_implementation: z
.string()
.min(1)
.describe(
'Multi-step processes and state transitions: how workflow stages are tracked, whether prior-state ' +
'checks are enforced (e.g. "Multi-step processes use status fields in database; status ' +
'transitions do not verify prior state completion"). Drives context-based authz exploitation.',
),
});
const STRATEGIC_INTEL_SCHEMAS: Record<VulnClass, z.ZodObject<ZodRawShape>> = {
injection: InjectionStrategicIntelSchema,
xss: XssStrategicIntelSchema,
auth: AuthStrategicIntelSchema,
ssrf: SsrfStrategicIntelSchema,
authz: AuthzStrategicIntelSchema,
};
// ============================================================================
// EXPORTED TYPES
// ============================================================================
export type Pattern = z.infer<typeof PatternSchema>;
export type FindingsSummaryInput = z.infer<typeof FindingsSummaryInputSchema>;
export type SafeVectorInput = z.infer<typeof SafeVectorInputSchema>;
export type SafeVectorsInput = z.infer<typeof SafeVectorsInputSchema>;
export type BlindSpotItem = z.infer<typeof BlindSpotItemSchema>;
export type BlindSpotsInput = z.infer<typeof BlindSpotsInputSchema>;
export type InjectionStrategicIntel = z.infer<typeof InjectionStrategicIntelSchema>;
export type XssStrategicIntel = z.infer<typeof XssStrategicIntelSchema>;
export type AuthStrategicIntel = z.infer<typeof AuthStrategicIntelSchema>;
export type SsrfStrategicIntel = z.infer<typeof SsrfStrategicIntelSchema>;
export type AuthzStrategicIntel = z.infer<typeof AuthzStrategicIntelSchema>;
// Discriminated by the agent class context — the renderer reads only the
// sub-fields that apply to the active class.
export type StrategicIntelligenceInput =
| InjectionStrategicIntel
| XssStrategicIntel
| AuthStrategicIntel
| SsrfStrategicIntel
| AuthzStrategicIntel;
export interface VulnCollectorData {
readonly findings_summary?: FindingsSummaryInput;
readonly strategic_intelligence?: StrategicIntelligenceInput;
readonly safe_vectors?: SafeVectorsInput;
readonly blind_spots?: BlindSpotsInput;
}
export const VULN_TOOLS = [
'set_findings_summary',
'set_strategic_intelligence',
'set_safe_vectors',
'set_blind_spots',
] as const;
export type VulnToolName = (typeof VULN_TOOLS)[number];
export type VulnToolStatus = 'called' | 'skipped';
export type VulnCallStatus = Readonly<Record<VulnToolName, VulnToolStatus>>;
// ============================================================================
// RESPONSE HELPERS
// ============================================================================
interface ToolResult {
[x: string]: unknown;
content: Array<{ type: 'text'; text: string }>;
isError: boolean;
}
function createToolResult(response: { status: string; [key: string]: unknown }): ToolResult {
return {
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
isError: response.status === 'error',
};
}
function successResult(data: Record<string, unknown>): ToolResult {
return createToolResult({ status: 'success', ...data });
}
function errorResult(message: string, errorType = 'ValidationError', retryable = true): ToolResult {
return createToolResult({ status: 'error', message, errorType, retryable });
}
// ============================================================================
// SERVER FACTORY
// ============================================================================
export interface VulnCollectorServer {
server: McpSdkServerConfigWithInstance;
getAll(): VulnCollectorData;
getCallStatus(): VulnCallStatus;
}
export function createVulnCollector(vulnClass: VulnClass): VulnCollectorServer {
const state: {
findings_summary?: FindingsSummaryInput;
strategic_intelligence?: StrategicIntelligenceInput;
safe_vectors?: SafeVectorsInput;
blind_spots?: BlindSpotsInput;
} = {};
function alreadyCalled(toolName: VulnToolName): ToolResult {
return errorResult(
`${toolName} has already been called. Each tool may only be called once per run.`,
'DuplicateError',
false,
);
}
const setFindingsSummary = tool(
'set_findings_summary',
'Record the executive summary headline and the dominant vulnerability patterns observed across ' +
'your findings. Call exactly once before terminating. Becomes Section 1 (key outcome) and ' +
'Section 2 (patterns) of the rendered deliverable — this is the load-bearing emission for the ' +
'narrative .md and is required. Duplicate calls return "already called" and are no-ops. Empty ' +
'patterns array is acceptable (renders as "No dominant patterns identified") but key_outcome ' +
'is always required.',
FindingsSummaryInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.findings_summary) return alreadyCalled('set_findings_summary');
state.findings_summary = input;
return successResult({ set: 'set_findings_summary' });
},
);
const intelSchema = STRATEGIC_INTEL_SCHEMAS[vulnClass];
const setStrategicIntelligence = tool(
'set_strategic_intelligence',
`Record the environmental and defensive intelligence relevant to exploiting the ${vulnClass} ` +
'findings. Call exactly once before terminating. Becomes Section 3 of the rendered deliverable ' +
`and is the section the downstream exploit-${vulnClass} agent reads for strategic context. ` +
'Required. Duplicate calls return "already called" and are no-ops. Write "Not applicable" as ' +
'the field value when a sub-field does not apply to this run (rather than omitting).',
intelSchema.shape,
async (input): Promise<ToolResult> => {
if (state.strategic_intelligence) return alreadyCalled('set_strategic_intelligence');
state.strategic_intelligence = input as unknown as StrategicIntelligenceInput;
return successResult({ set: 'set_strategic_intelligence' });
},
);
const setSafeVectors = tool(
'set_safe_vectors',
'Record the input vectors, components, or endpoints that were analyzed and confirmed to have ' +
'robust, context-appropriate defenses. Call exactly once before terminating. Becomes Section 4 ' +
'of the rendered deliverable. Recommended (empty array is acceptable on runs where no vectors ' +
'were validated as safe, but explicit emission is preferred). The renderer sorts by ' +
'(subject, location) before rendering, so emission order does not affect output. Duplicate ' +
'calls return "already called" and are no-ops.',
SafeVectorsInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.safe_vectors) return alreadyCalled('set_safe_vectors');
state.safe_vectors = input;
return successResult({ set: 'set_safe_vectors', count: input.vectors.length });
},
);
const setBlindSpots = tool(
'set_blind_spots',
'Record analysis constraints, untraced code paths, or other coverage gaps. Call exactly once ' +
'before terminating. Becomes Section 5 of the rendered deliverable. Recommended (empty array ' +
'is acceptable on high-coverage runs, but explicit emission is preferred — readers expect ' +
'either documented gaps or an explicit "no gaps" signal). Duplicate calls return "already ' +
'called" and are no-ops.',
BlindSpotsInputSchema.shape,
async (input): Promise<ToolResult> => {
if (state.blind_spots) return alreadyCalled('set_blind_spots');
state.blind_spots = input;
return successResult({ set: 'set_blind_spots', count: input.items.length });
},
);
// set_blind_spots is withheld from classes without a Section 5 (auth, ssrf).
const tools = [
setFindingsSummary,
setStrategicIntelligence,
setSafeVectors,
...(BLIND_SPOTS_CLASSES.has(vulnClass) ? [setBlindSpots] : []),
];
const server: McpSdkServerConfigWithInstance = createSdkMcpServer({
name: 'vuln-collector',
version: '1.0.0',
tools,
});
function statusOf<K extends VulnToolName>(key: K): VulnToolStatus {
const flagMap: Record<VulnToolName, unknown> = {
set_findings_summary: state.findings_summary,
set_strategic_intelligence: state.strategic_intelligence,
set_safe_vectors: state.safe_vectors,
set_blind_spots: state.blind_spots,
};
return flagMap[key] ? 'called' : 'skipped';
}
return {
server,
getAll: (): VulnCollectorData => ({
...(state.findings_summary && { findings_summary: state.findings_summary }),
...(state.strategic_intelligence && { strategic_intelligence: state.strategic_intelligence }),
...(state.safe_vectors && { safe_vectors: state.safe_vectors }),
...(state.blind_spots && { blind_spots: state.blind_spots }),
}),
getCallStatus: (): VulnCallStatus => ({
set_findings_summary: statusOf('set_findings_summary'),
set_strategic_intelligence: statusOf('set_strategic_intelligence'),
set_safe_vectors: statusOf('set_safe_vectors'),
set_blind_spots: statusOf('set_blind_spots'),
}),
};
}
@@ -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`;
}
+561
View File
@@ -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`;
}
+229
View File
@@ -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`;
}
+29 -24
View File
@@ -6,7 +6,6 @@
import { fs, path } from 'zx';
import { validateQueueAndDeliverable } from './services/queue-validation.js';
import type { ActivityLogger } from './types/activity-logger.js';
import type { AgentDefinition, AgentName, AgentValidator, PlaywrightSession, VulnType } from './types/index.js';
@@ -126,26 +125,34 @@ export const AGENT_PHASE_MAP: Readonly<Record<AgentName, PhaseName>> = Object.fr
report: 'reporting',
});
// Factory function for vulnerability queue validators
// Factory function for vulnerability queue validators.
//
// Post-MCP-migration, the analysis_deliverable.md is rendered by the activity
// wrapper after validateAgentOutput runs, so the previous "both files exist"
// check would race the renderer. The validator only checks the queue.json —
// that file is written by the SDK structured-output path in agent-execution.ts
// before this validator runs. The downstream checkExploitationQueue still
// renders the .md.
function createVulnValidator(vulnType: VulnType): AgentValidator {
return async (sourceDir: string, logger: ActivityLogger): Promise<boolean> => {
try {
await validateQueueAndDeliverable(vulnType, sourceDir);
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
logger.warn(`Queue validation failed for ${vulnType}: ${errMsg}`);
const queueFile = path.join(sourceDir, `${vulnType}_exploitation_queue.json`);
const queueExists = await fs.pathExists(queueFile);
if (!queueExists) {
logger.warn(`Queue validation failed for ${vulnType}: ${vulnType}_exploitation_queue.json missing`);
return false;
}
return true;
};
}
// Factory function for exploit deliverable validators
function createExploitValidator(vulnType: VulnType): AgentValidator {
return async (sourceDir: string): Promise<boolean> => {
const evidenceFile = path.join(sourceDir, `${vulnType}_exploitation_evidence.md`);
return await fs.pathExists(evidenceFile);
};
// Exploitation agents — validation lives in runExploitAgentWithCollector post-processing
// (collector harvest + renderer write). The deliverable file is written by the renderer
// after the agent succeeds, so a file-existence check here would race the renderer.
//
// VulnType is kept in the import surface for createVulnValidator above; this factory
// returns a no-op validator parameterized only for symmetry with the vuln-side factory.
function createExploitValidator(_vulnType: VulnType): AgentValidator {
return async (): Promise<boolean> => true;
}
// Playwright session mapping - assigns each agent to a specific session for browser isolation
@@ -180,17 +187,15 @@ export const PLAYWRIGHT_SESSION_MAPPING: Record<string, PlaywrightSession> = Obj
// Direct agent-to-validator mapping - much simpler than pattern matching
export const AGENT_VALIDATORS: Record<AgentName, AgentValidator> = Object.freeze({
// Pre-reconnaissance agent - validates the code analysis deliverable created by the agent
'pre-recon': async (sourceDir: string): Promise<boolean> => {
const codeAnalysisFile = path.join(sourceDir, 'pre_recon_deliverable.md');
return await fs.pathExists(codeAnalysisFile);
},
// Pre-reconnaissance agent — skipped tools surface as renderer placeholders, not
// activity failures. The deliverable file is written by the renderer after the agent
// succeeds, so a file-existence check here would race the renderer.
'pre-recon': async (): Promise<boolean> => true,
// Reconnaissance agent
recon: async (sourceDir: string): Promise<boolean> => {
const reconFile = path.join(sourceDir, 'recon_deliverable.md');
return await fs.pathExists(reconFile);
},
// Reconnaissance agent — validation lives in runReconAgent post-processing.
// The deliverable file is written by the renderer after the agent succeeds, so a
// file-existence check here would race the renderer.
recon: async (): Promise<boolean> => true,
// Vulnerability analysis agents
'injection-vuln': createVulnValidator('injection'),
+169 -14
View File
@@ -134,7 +134,11 @@ function buildContainerConfig(input: ActivityInput): ContainerConfig {
* 3. Service-based agent execution
* 4. Error classification for Temporal retry
*/
async function runAgentActivity(agentName: AgentName, input: ActivityInput): Promise<AgentMetrics> {
async function runAgentActivity(
agentName: AgentName,
input: ActivityInput,
mcpServers?: Record<string, import('@anthropic-ai/claude-agent-sdk').McpServerConfig>,
): Promise<AgentMetrics> {
const { repoPath, configPath, pipelineTestingMode = false, workflowId, webUrl } = input;
// Skip guard: the checkpoint provider decides whether to run the agent.
@@ -148,7 +152,7 @@ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Pro
input.deliverablesSubdir ?? DEFAULT_DELIVERABLES_SUBDIR,
);
if (decision.skip && decision.metrics) {
return decision.metrics;
return { ...decision.metrics, skipped: true };
}
const startTime = Date.now();
@@ -188,6 +192,7 @@ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Pro
...(input.providerConfig !== undefined && { providerConfig: input.providerConfig }),
...(input.promptDir !== undefined && { promptDir: input.promptDir }),
...(input.configYAML !== undefined && { configYAML: input.configYAML }),
...(mcpServers && { mcpServers }),
},
auditSession,
logger,
@@ -247,51 +252,201 @@ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Pro
}
export async function runPreReconAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('pre-recon', input);
const { createPreReconCollectorServer } = await import('../mcp-server/pre-recon-collector.js');
const { renderPreRecon } = await import('../services/pre-recon-renderer.js');
const collector = createPreReconCollectorServer();
const metrics = await runAgentActivity('pre-recon', input, { 'pre-recon-collector': collector.server });
// On resume, the agent is skipped and the collector is never populated.
// The cached deliverable from the prior run is the source of truth.
if (metrics.skipped) {
return metrics;
}
const logger = createActivityLogger();
const dir = deliverablesDir(input.repoPath, input.deliverablesSubdir);
// Skipped tools surface as renderer placeholders, not as activity failures.
const callStatus = collector.getCallStatus();
logger.info('Pre-recon tool call status', { callStatus });
const collected = collector.getAll();
const markdown = renderPreRecon(collected);
const mdPath = path.join(dir, 'pre_recon_deliverable.md');
await atomicWrite(mdPath, markdown);
logger.info(`Wrote pre_recon_deliverable.md from structured data (${markdown.length} bytes)`);
return metrics;
}
export async function runReconAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('recon', input);
const { createReconCollectorServer } = await import('../mcp-server/recon-collector.js');
const { renderRecon } = await import('../services/recon-renderer.js');
const collector = createReconCollectorServer();
const metrics = await runAgentActivity('recon', input, { 'recon-collector': collector.server });
// On resume, the agent is skipped and the collector is never populated.
// The cached deliverable from the prior run is the source of truth.
if (metrics.skipped) {
return metrics;
}
const logger = createActivityLogger();
const dir = deliverablesDir(input.repoPath, input.deliverablesSubdir);
// Skipped tools surface as renderer placeholders, not as activity failures.
const callStatus = collector.getCallStatus();
logger.info('Recon tool call status', { callStatus });
const collected = collector.getAll();
const markdown = renderRecon(collected);
const mdPath = path.join(dir, 'recon_deliverable.md');
await atomicWrite(mdPath, markdown);
logger.info(`Wrote recon_deliverable.md from structured data (${markdown.length} bytes)`);
return metrics;
}
async function runVulnAgentWithCollector(
agentName: 'injection-vuln' | 'xss-vuln' | 'auth-vuln' | 'ssrf-vuln' | 'authz-vuln',
vulnClass: 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz',
input: ActivityInput,
): Promise<AgentMetrics> {
const { createVulnCollector } = await import('../mcp-server/vuln-collector.js');
const { renderVulnDeliverable } = await import('../services/vuln-renderer.js');
const collector = createVulnCollector(vulnClass);
const metrics = await runAgentActivity(agentName, input, { 'vuln-collector': collector.server });
// On resume, the agent is skipped and the collector is never populated.
// The cached deliverable from the prior run is the source of truth.
if (metrics.skipped) {
return metrics;
}
const logger = createActivityLogger();
const dir = deliverablesDir(input.repoPath, input.deliverablesSubdir);
// Skipped tools surface as renderer placeholders, not as activity failures.
const callStatus = collector.getCallStatus();
logger.info(`${vulnClass} vuln tool call status`, { callStatus });
const collected = collector.getAll();
const markdown = renderVulnDeliverable(vulnClass, collected);
const mdPath = path.join(dir, `${vulnClass}_analysis_deliverable.md`);
await atomicWrite(mdPath, markdown);
logger.info(`Wrote ${vulnClass}_analysis_deliverable.md from structured data (${markdown.length} bytes)`);
return metrics;
}
export async function runInjectionVulnAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('injection-vuln', input);
return runVulnAgentWithCollector('injection-vuln', 'injection', input);
}
export async function runXssVulnAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('xss-vuln', input);
return runVulnAgentWithCollector('xss-vuln', 'xss', input);
}
export async function runAuthVulnAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('auth-vuln', input);
return runVulnAgentWithCollector('auth-vuln', 'auth', input);
}
export async function runSsrfVulnAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('ssrf-vuln', input);
return runVulnAgentWithCollector('ssrf-vuln', 'ssrf', input);
}
export async function runAuthzVulnAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('authz-vuln', input);
return runVulnAgentWithCollector('authz-vuln', 'authz', input);
}
interface ExploitQueueEntry {
ID?: string;
vulnerability_type?: string;
}
interface ExploitQueueDocument {
vulnerabilities?: ExploitQueueEntry[];
}
async function readExploitQueue(queuePath: string): Promise<{ validIds: Set<string>; idToType: Map<string, string> }> {
const validIds = new Set<string>();
const idToType = new Map<string, string>();
if (!(await fileExists(queuePath))) {
return { validIds, idToType };
}
const doc = await readJson<ExploitQueueDocument>(queuePath);
for (const entry of doc.vulnerabilities ?? []) {
if (!entry.ID) continue;
validIds.add(entry.ID);
idToType.set(entry.ID, entry.vulnerability_type ?? 'unknown');
}
return { validIds, idToType };
}
async function runExploitAgentWithCollector(
agentName: 'injection-exploit' | 'xss-exploit' | 'auth-exploit' | 'ssrf-exploit' | 'authz-exploit',
vulnClass: 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz',
input: ActivityInput,
): Promise<AgentMetrics> {
const { createExploitCollector } = await import('../mcp-server/exploit-collector.js');
const { renderExploitDeliverable } = await import('../services/exploit-renderer.js');
const dir = deliverablesDir(input.repoPath, input.deliverablesSubdir);
const queuePath = path.join(dir, `${vulnClass}_exploitation_queue.json`);
const { validIds, idToType } = await readExploitQueue(queuePath);
const collector = createExploitCollector({ vulnClass, validIds });
const metrics = await runAgentActivity(agentName, input, { 'exploit-collector': collector.server });
// On resume, the agent is skipped and the collector is never populated.
// The cached deliverable from the prior run is the source of truth.
if (metrics.skipped) {
return metrics;
}
const logger = createActivityLogger();
const collected = collector.getAll();
const emittedIds = new Set(collected.map((e) => e.vulnerability_id));
const missingIds = [...validIds].filter((id) => !emittedIds.has(id));
const exploitedCount = collected.filter((e) => e.status === 'exploited').length;
const blockedCount = collected.filter((e) => e.status === 'blocked').length;
logger.info(`${vulnClass} exploit tool call metrics`, {
queueSize: validIds.size,
exploited: exploitedCount,
blocked: blockedCount,
missing: missingIds.length,
});
const markdown = renderExploitDeliverable(vulnClass, collected, idToType);
const mdPath = path.join(dir, `${vulnClass}_exploitation_evidence.md`);
await atomicWrite(mdPath, markdown);
logger.info(`Wrote ${vulnClass}_exploitation_evidence.md from structured data (${markdown.length} bytes)`);
return metrics;
}
export async function runInjectionExploitAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('injection-exploit', input);
return runExploitAgentWithCollector('injection-exploit', 'injection', input);
}
export async function runXssExploitAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('xss-exploit', input);
return runExploitAgentWithCollector('xss-exploit', 'xss', input);
}
export async function runAuthExploitAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('auth-exploit', input);
return runExploitAgentWithCollector('auth-exploit', 'auth', input);
}
export async function runSsrfExploitAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('ssrf-exploit', input);
return runExploitAgentWithCollector('ssrf-exploit', 'ssrf', input);
}
export async function runAuthzExploitAgent(input: ActivityInput): Promise<AgentMetrics> {
return runAgentActivity('authz-exploit', input);
return runExploitAgentWithCollector('authz-exploit', 'authz', input);
}
export async function runReportAgent(input: ActivityInput): Promise<AgentMetrics> {
-12
View File
@@ -23,13 +23,6 @@ export enum DeliverableType {
AUTH_ANALYSIS = 'AUTH_ANALYSIS',
AUTHZ_ANALYSIS = 'AUTHZ_ANALYSIS',
SSRF_ANALYSIS = 'SSRF_ANALYSIS',
// Exploitation agents
INJECTION_EVIDENCE = 'INJECTION_EVIDENCE',
XSS_EVIDENCE = 'XSS_EVIDENCE',
AUTH_EVIDENCE = 'AUTH_EVIDENCE',
AUTHZ_EVIDENCE = 'AUTHZ_EVIDENCE',
SSRF_EVIDENCE = 'SSRF_EVIDENCE',
}
/**
@@ -43,9 +36,4 @@ export const DELIVERABLE_FILENAMES: Record<DeliverableType, string> = {
[DeliverableType.AUTH_ANALYSIS]: 'auth_analysis_deliverable.md',
[DeliverableType.AUTHZ_ANALYSIS]: 'authz_analysis_deliverable.md',
[DeliverableType.SSRF_ANALYSIS]: 'ssrf_analysis_deliverable.md',
[DeliverableType.INJECTION_EVIDENCE]: 'injection_exploitation_evidence.md',
[DeliverableType.XSS_EVIDENCE]: 'xss_exploitation_evidence.md',
[DeliverableType.AUTH_EVIDENCE]: 'auth_exploitation_evidence.md',
[DeliverableType.AUTHZ_EVIDENCE]: 'authz_exploitation_evidence.md',
[DeliverableType.SSRF_EVIDENCE]: 'ssrf_exploitation_evidence.md',
};
+4
View File
@@ -16,4 +16,8 @@ export interface AgentMetrics {
costUsd: number | null;
numTurns: number | null;
model?: string | undefined;
// True when the checkpoint provider skipped the agent (resume path).
// Callers that perform post-agent work on collected state should short-circuit
// when this is set, since no fresh state was produced this run.
skipped?: boolean;
}