mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-22 17:50:50 +02:00
feat(worker): structure intermediate deliverables via MCP collectors (#350)
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Deterministic recon collector → markdown renderer.
|
||||
*
|
||||
* Converts the typed payload bag harvested from the recon-collector MCP server
|
||||
* into the recon_deliverable.md Markdown layout. No LLM in the loop; section
|
||||
* ordering, headings, sort, and the Section 0 boilerplate are owned here.
|
||||
*
|
||||
* Any tool the agent skips becomes a `[Section X: not provided]` placeholder
|
||||
* rather than an activity failure. Every section renderer accepts its input as
|
||||
* optional.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AuthenticationInput,
|
||||
AuthzCandidatesInput,
|
||||
ContextCandidate,
|
||||
Endpoint,
|
||||
Entity,
|
||||
ExecutiveSummaryInput,
|
||||
Flow,
|
||||
Guard,
|
||||
HorizontalCandidate,
|
||||
InjectionSourcesInput,
|
||||
InputVectorsInput,
|
||||
NetworkMapInput,
|
||||
Priority,
|
||||
ReconData,
|
||||
Role,
|
||||
RoleArchitectureInput,
|
||||
SinkRef,
|
||||
TechnologyStackInput,
|
||||
VerticalCandidate,
|
||||
} from '../mcp-server/recon-collector.js';
|
||||
|
||||
type RoleSwitchingImpersonation = AuthenticationInput['role_switching_impersonation'];
|
||||
type EntityZone = Entity['zone'];
|
||||
|
||||
// ============================================================================
|
||||
// STATIC PROSE
|
||||
// ============================================================================
|
||||
|
||||
const HOW_TO_READ_THIS = `## 0) HOW TO READ THIS
|
||||
This reconnaissance report provides a comprehensive map of the application's attack surface, with special emphasis on authorization and privilege escalation opportunities for the Authorization Analysis Specialist.
|
||||
|
||||
**Key Sections for Authorization Analysis:**
|
||||
- **Section 4 (API Endpoint Inventory):** Contains authorization details for each endpoint - focus on "Required Role" and "Object ID Parameters" columns to identify IDOR candidates.
|
||||
- **Section 6.4 (Guards Directory):** Catalog of authorization controls - understand what each guard means before analyzing vulnerabilities.
|
||||
- **Section 7 (Role & Privilege Architecture):** Complete role hierarchy and privilege mapping - use this to understand the privilege lattice and identify escalation targets.
|
||||
- **Section 8 (Authorization Vulnerability Candidates):** Pre-prioritized lists of endpoints for horizontal, vertical, and context-based authorization testing.
|
||||
|
||||
**How to Use the Network Mapping (Section 6):** The entity/flow mapping shows system boundaries and data sensitivity levels. Pay special attention to flows marked with authorization guards and entities handling PII/sensitive data.
|
||||
|
||||
**Priority Order for Testing:** Start with Section 8's High-priority horizontal candidates, then vertical escalation endpoints for each role level, finally context-based workflow bypasses.`;
|
||||
|
||||
// ============================================================================
|
||||
// SORT ORDER CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
// Zones are sorted by exposure (Internet → Edge → ... → ThirdParty), not alphabetically,
|
||||
// per the design doc's "clusters by zone" requirement. A reader scanning the entities
|
||||
// table sees external surface first, internal trust core last.
|
||||
const ZONE_ORDER: Record<EntityZone, number> = {
|
||||
Internet: 0,
|
||||
Edge: 1,
|
||||
App: 2,
|
||||
Data: 3,
|
||||
Admin: 4,
|
||||
BuildCI: 5,
|
||||
ThirdParty: 6,
|
||||
};
|
||||
|
||||
const PRIORITY_ORDER: Record<Priority, number> = {
|
||||
High: 0,
|
||||
Medium: 1,
|
||||
Low: 2,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SHARED HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function placeholder(sectionLabel: string, toolName: string): string {
|
||||
return `_[${sectionLabel}: not provided — \`${toolName}\` was not called]_`;
|
||||
}
|
||||
|
||||
function bulletField(label: string, value: string): string {
|
||||
return `- **${label}:** ${value}`;
|
||||
}
|
||||
|
||||
function bulletList(label: string, items: readonly string[]): string {
|
||||
if (items.length === 0) {
|
||||
return `- **${label}:** *(none identified)*`;
|
||||
}
|
||||
return `- **${label}:**\n${items.map((entry) => ` - ${entry}`).join('\n')}`;
|
||||
}
|
||||
|
||||
function escapePipe(value: string): string {
|
||||
return value.replace(/\|/g, '\\|');
|
||||
}
|
||||
|
||||
function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
|
||||
const headerRow = `| ${headers.map(escapePipe).join(' | ')} |`;
|
||||
const separator = `| ${headers.map(() => '---').join(' | ')} |`;
|
||||
const body = rows.map((row) => `| ${row.map(escapePipe).join(' | ')} |`).join('\n');
|
||||
return [headerRow, separator, body].filter((line) => line.length > 0).join('\n');
|
||||
}
|
||||
|
||||
function renderSinkList(sinks: readonly SinkRef[]): string {
|
||||
if (sinks.length === 0) {
|
||||
return '*(scanned, no sources of this kind found)*';
|
||||
}
|
||||
return sinks
|
||||
.map((sink) => {
|
||||
const head = `- **${sink.sink_function}** at \`${sink.location}\``;
|
||||
if (sink.notes && sink.notes.trim() !== '') {
|
||||
return `${head} — ${sink.notes.trim()}`;
|
||||
}
|
||||
return head;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SECTION RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
function renderHowToReadThis(): string {
|
||||
return HOW_TO_READ_THIS;
|
||||
}
|
||||
|
||||
function renderExecutiveSummary(data: ExecutiveSummaryInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 1. Executive Summary', '', placeholder('Section 1', 'set_executive_summary')].join('\n');
|
||||
}
|
||||
return ['## 1. Executive Summary', '', data.text].join('\n');
|
||||
}
|
||||
|
||||
function renderTechnologyStack(data: TechnologyStackInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 2. Technology & Service Map', '', placeholder('Section 2', 'set_technology_stack')].join('\n');
|
||||
}
|
||||
return [
|
||||
'## 2. Technology & Service Map',
|
||||
'',
|
||||
bulletField('Frontend', data.frontend),
|
||||
bulletField('Backend', data.backend),
|
||||
bulletField('Infrastructure', data.infrastructure),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderRoleSwitching(rs: RoleSwitchingImpersonation): string {
|
||||
if (!rs.applicable) {
|
||||
return [
|
||||
'### 3.3 Role Switching & Impersonation',
|
||||
'',
|
||||
'*(Not applicable — no impersonation, sudo mode, or role-switching features were identified.)*',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
'### 3.3 Role Switching & Impersonation',
|
||||
'',
|
||||
bulletField('Impersonation Features', rs.impersonation_features ?? '*(not specified)*'),
|
||||
bulletField('Role Switching', rs.role_switching ?? '*(not specified)*'),
|
||||
bulletField('Audit Trail', rs.audit_trail ?? '*(not specified)*'),
|
||||
bulletField('Code Implementation', rs.code_implementation ?? '*(not specified)*'),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderAuthentication(data: AuthenticationInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 3. Authentication & Session Management Flow', '', placeholder('Section 3', 'set_authentication')].join(
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
const { session_flow: sf, role_assignment: ra, privilege_storage: ps } = data;
|
||||
return [
|
||||
'## 3. Authentication & Session Management Flow',
|
||||
'',
|
||||
bulletField('Entry Points', sf.entry_points),
|
||||
bulletField('Mechanism', sf.mechanism),
|
||||
bulletField('Code Pointers', sf.code_pointers),
|
||||
'',
|
||||
'### 3.1 Role Assignment Process',
|
||||
'',
|
||||
bulletField('Role Determination', ra.role_determination),
|
||||
bulletField('Default Role', ra.default_role),
|
||||
bulletField('Role Upgrade Path', ra.role_upgrade_path),
|
||||
bulletField('Code Implementation', ra.code_implementation),
|
||||
'',
|
||||
'### 3.2 Privilege Storage & Validation',
|
||||
'',
|
||||
bulletField('Storage Location', ps.storage_location),
|
||||
bulletField('Validation Points', ps.validation_points),
|
||||
bulletField('Cache/Session Persistence', ps.cache_session_persistence),
|
||||
bulletField('Code Pointers', ps.code_pointers),
|
||||
'',
|
||||
renderRoleSwitching(data.role_switching_impersonation),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sortEndpoints(endpoints: readonly Endpoint[]): Endpoint[] {
|
||||
return [...endpoints].sort((a, b) => {
|
||||
if (a.path !== b.path) return a.path.localeCompare(b.path);
|
||||
return a.method.localeCompare(b.method);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEndpoints(endpoints: readonly Endpoint[] | undefined): string {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
return ['## 4. API Endpoint Inventory', '', placeholder('Section 4', 'add_endpoints')].join('\n');
|
||||
}
|
||||
const sorted = sortEndpoints(endpoints);
|
||||
const rows = sorted.map((e) => [
|
||||
e.method,
|
||||
e.path,
|
||||
e.required_role,
|
||||
e.object_id_parameters.length > 0 ? e.object_id_parameters.join(', ') : 'None',
|
||||
e.authorization_mechanism,
|
||||
`${e.description} (${e.code_pointer})`,
|
||||
]);
|
||||
return [
|
||||
'## 4. API Endpoint Inventory',
|
||||
'',
|
||||
renderTable(
|
||||
[
|
||||
'Method',
|
||||
'Endpoint Path',
|
||||
'Required Role',
|
||||
'Object ID Parameters',
|
||||
'Authorization Mechanism',
|
||||
'Description & Code Pointer',
|
||||
],
|
||||
rows,
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderInputVectors(data: InputVectorsInput | undefined): string {
|
||||
if (!data) {
|
||||
return [
|
||||
'## 5. Potential Input Vectors for Vulnerability Analysis',
|
||||
'',
|
||||
placeholder('Section 5', 'set_input_vectors'),
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
'## 5. Potential Input Vectors for Vulnerability Analysis',
|
||||
'',
|
||||
bulletList('URL Parameters', data.url_parameters),
|
||||
bulletList('POST Body Fields (JSON/Form)', data.post_body_fields),
|
||||
bulletList('HTTP Headers', data.http_headers),
|
||||
bulletList('Cookie Values', data.cookie_values),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sortEntities(entities: readonly Entity[]): Entity[] {
|
||||
return [...entities].sort((a, b) => {
|
||||
const zoneDiff = ZONE_ORDER[a.zone] - ZONE_ORDER[b.zone];
|
||||
if (zoneDiff !== 0) return zoneDiff;
|
||||
if (a.type !== b.type) return a.type.localeCompare(b.type);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
function sortFlows(flows: readonly Flow[]): Flow[] {
|
||||
return [...flows].sort((a, b) => {
|
||||
if (a.from !== b.from) return a.from.localeCompare(b.from);
|
||||
if (a.to !== b.to) return a.to.localeCompare(b.to);
|
||||
return a.path_port.localeCompare(b.path_port);
|
||||
});
|
||||
}
|
||||
|
||||
function sortGuards(guards: readonly Guard[]): Guard[] {
|
||||
return [...guards].sort((a, b) => {
|
||||
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEntitiesTable(entities: readonly Entity[]): string {
|
||||
const rows = entities.map((e) => [e.title, e.type, e.zone, e.tech, e.data.join(', '), e.notes]);
|
||||
return renderTable(['Title', 'Type', 'Zone', 'Tech', 'Data', 'Notes'], rows);
|
||||
}
|
||||
|
||||
function renderEntityMetadataTable(entities: readonly Entity[]): string {
|
||||
const rows = entities.map((e) => {
|
||||
const metadataLine =
|
||||
e.metadata.length > 0 ? e.metadata.map(({ key, value }) => `${key}: ${value}`).join('; ') : '*(none)*';
|
||||
return [e.title, metadataLine];
|
||||
});
|
||||
return renderTable(['Title', 'Metadata'], rows);
|
||||
}
|
||||
|
||||
function renderFlowsTable(flows: readonly Flow[]): string {
|
||||
const rows = flows.map((f) => [
|
||||
`${f.from} → ${f.to}`,
|
||||
f.channel,
|
||||
f.path_port,
|
||||
f.guards.length > 0 ? f.guards.join(', ') : 'None',
|
||||
f.touches.length > 0 ? f.touches.join(', ') : 'Public',
|
||||
]);
|
||||
return renderTable(['FROM → TO', 'Channel', 'Path/Port', 'Guards', 'Touches'], rows);
|
||||
}
|
||||
|
||||
function renderGuardsTable(guards: readonly Guard[]): string {
|
||||
const rows = guards.map((g) => [g.name, g.category, g.statement]);
|
||||
return renderTable(['Guard Name', 'Category', 'Statement'], rows);
|
||||
}
|
||||
|
||||
function renderNetworkMap(data: NetworkMapInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 6. Network & Interaction Map', '', placeholder('Section 6', 'set_network_map')].join('\n');
|
||||
}
|
||||
const entities = sortEntities(data.entities);
|
||||
const flows = sortFlows(data.flows);
|
||||
const guards = sortGuards(data.guards);
|
||||
return [
|
||||
'## 6. Network & Interaction Map',
|
||||
'',
|
||||
'### 6.1 Entities',
|
||||
'',
|
||||
entities.length > 0 ? renderEntitiesTable(entities) : '*(no entities recorded)*',
|
||||
'',
|
||||
'### 6.2 Entity Metadata',
|
||||
'',
|
||||
entities.length > 0 ? renderEntityMetadataTable(entities) : '*(no entities recorded)*',
|
||||
'',
|
||||
'### 6.3 Flows (Connections)',
|
||||
'',
|
||||
flows.length > 0 ? renderFlowsTable(flows) : '*(no flows recorded)*',
|
||||
'',
|
||||
'### 6.4 Guards Directory',
|
||||
'',
|
||||
guards.length > 0 ? renderGuardsTable(guards) : '*(no guards recorded)*',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sortRoles(roles: readonly Role[]): Role[] {
|
||||
return [...roles].sort((a, b) => {
|
||||
if (a.privilege_level !== b.privilege_level) return a.privilege_level - b.privilege_level;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRoleArchitecture(data: RoleArchitectureInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 7. Role & Privilege Architecture', '', placeholder('Section 7', 'set_role_architecture')].join('\n');
|
||||
}
|
||||
const roles = sortRoles(data.roles);
|
||||
const discoveredRows = roles.map((r) => [r.name, String(r.privilege_level), r.scope_domain, r.code_implementation]);
|
||||
const entryPointRows = roles.map((r) => [
|
||||
r.name,
|
||||
r.default_landing_page,
|
||||
r.accessible_route_patterns.length > 0 ? r.accessible_route_patterns.join(', ') : 'None',
|
||||
r.authentication_method,
|
||||
]);
|
||||
const codeMappingRows = roles.map((r) => [r.name, r.middleware_guards, r.permission_checks, r.storage_location]);
|
||||
const lattice = data.privilege_lattice;
|
||||
const latticeBlock = [
|
||||
'```',
|
||||
`Privilege Ordering (→ means "can access resources of"):`,
|
||||
lattice.ordering_diagram,
|
||||
'',
|
||||
`Parallel Isolation (|| means "not ordered relative to each other"):`,
|
||||
lattice.parallel_isolation_notes,
|
||||
'```',
|
||||
].join('\n');
|
||||
const sections = [
|
||||
'## 7. Role & Privilege Architecture',
|
||||
'',
|
||||
'### 7.1 Discovered Roles',
|
||||
'',
|
||||
roles.length > 0
|
||||
? renderTable(['Role Name', 'Privilege Level', 'Scope/Domain', 'Code Implementation'], discoveredRows)
|
||||
: '*(no roles recorded)*',
|
||||
'',
|
||||
'### 7.2 Privilege Lattice',
|
||||
'',
|
||||
latticeBlock,
|
||||
];
|
||||
if (lattice.role_switching_notes && lattice.role_switching_notes.trim() !== '') {
|
||||
sections.push('', `**Note:** ${lattice.role_switching_notes.trim()}`);
|
||||
}
|
||||
sections.push(
|
||||
'',
|
||||
'### 7.3 Role Entry Points',
|
||||
'',
|
||||
roles.length > 0
|
||||
? renderTable(
|
||||
['Role', 'Default Landing Page', 'Accessible Route Patterns', 'Authentication Method'],
|
||||
entryPointRows,
|
||||
)
|
||||
: '*(no roles recorded)*',
|
||||
'',
|
||||
'### 7.4 Role-to-Code Mapping',
|
||||
'',
|
||||
roles.length > 0
|
||||
? renderTable(['Role', 'Middleware/Guards', 'Permission Checks', 'Storage Location'], codeMappingRows)
|
||||
: '*(no roles recorded)*',
|
||||
);
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function sortHorizontal(items: readonly HorizontalCandidate[]): HorizontalCandidate[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const pri = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
|
||||
if (pri !== 0) return pri;
|
||||
return a.endpoint_pattern.localeCompare(b.endpoint_pattern);
|
||||
});
|
||||
}
|
||||
|
||||
function sortVertical(items: readonly VerticalCandidate[]): VerticalCandidate[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const pri = PRIORITY_ORDER[a.risk_level] - PRIORITY_ORDER[b.risk_level];
|
||||
if (pri !== 0) return pri;
|
||||
return a.endpoint_pattern.localeCompare(b.endpoint_pattern);
|
||||
});
|
||||
}
|
||||
|
||||
function sortContext(items: readonly ContextCandidate[]): ContextCandidate[] {
|
||||
return [...items].sort((a, b) => a.endpoint.localeCompare(b.endpoint));
|
||||
}
|
||||
|
||||
function renderAuthzCandidates(data: AuthzCandidatesInput | undefined): string {
|
||||
if (!data) {
|
||||
return ['## 8. Authorization Vulnerability Candidates', '', placeholder('Section 8', 'set_authz_candidates')].join(
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
const horizontal = sortHorizontal(data.horizontal);
|
||||
const vertical = sortVertical(data.vertical);
|
||||
const context = sortContext(data.context);
|
||||
|
||||
let idCounter = 0;
|
||||
const nextId = (): string => {
|
||||
idCounter += 1;
|
||||
return `AUTHZ-CAND-${String(idCounter).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const horizontalRows = horizontal.map((c) => [
|
||||
nextId(),
|
||||
c.priority,
|
||||
c.endpoint_pattern,
|
||||
c.object_id_parameter,
|
||||
c.data_type,
|
||||
c.sensitivity,
|
||||
]);
|
||||
const verticalRows = vertical.map((c) => [
|
||||
nextId(),
|
||||
c.target_role,
|
||||
c.endpoint_pattern,
|
||||
c.functionality,
|
||||
c.risk_level,
|
||||
]);
|
||||
const contextRows = context.map((c) => [
|
||||
nextId(),
|
||||
c.workflow,
|
||||
c.endpoint,
|
||||
c.expected_prior_state,
|
||||
c.bypass_potential,
|
||||
]);
|
||||
|
||||
return [
|
||||
'## 8. Authorization Vulnerability Candidates',
|
||||
'',
|
||||
'### 8.1 Horizontal Privilege Escalation Candidates',
|
||||
'',
|
||||
horizontal.length > 0
|
||||
? renderTable(
|
||||
['ID', 'Priority', 'Endpoint Pattern', 'Object ID Parameter', 'Data Type', 'Sensitivity'],
|
||||
horizontalRows,
|
||||
)
|
||||
: '*(no horizontal candidates identified)*',
|
||||
'',
|
||||
'### 8.2 Vertical Privilege Escalation Candidates',
|
||||
'',
|
||||
vertical.length > 0
|
||||
? renderTable(['ID', 'Target Role', 'Endpoint Pattern', 'Functionality', 'Risk Level'], verticalRows)
|
||||
: '*(no vertical candidates identified)*',
|
||||
'',
|
||||
'### 8.3 Context-Based Authorization Candidates',
|
||||
'',
|
||||
context.length > 0
|
||||
? renderTable(['ID', 'Workflow', 'Endpoint', 'Expected Prior State', 'Bypass Potential'], contextRows)
|
||||
: '*(no context-based candidates identified)*',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderInjectionSources(data: InjectionSourcesInput | undefined): string {
|
||||
const heading =
|
||||
'## 9. Injection Sources (Command Injection, SQL Injection, LFI/RFI, SSTI, Path Traversal, Deserialization)';
|
||||
if (!data) {
|
||||
return [heading, '', placeholder('Section 9', 'set_injection_sources')].join('\n');
|
||||
}
|
||||
if (!data.applicable) {
|
||||
return [
|
||||
heading,
|
||||
'',
|
||||
'*(Not applicable — this application has no network-accessible code paths to dangerous sinks.)*',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
heading,
|
||||
'',
|
||||
'### Command Injection',
|
||||
renderSinkList(data.command_injection),
|
||||
'',
|
||||
'### SQL Injection',
|
||||
renderSinkList(data.sql_injection),
|
||||
'',
|
||||
'### LFI/RFI',
|
||||
renderSinkList(data.lfi_rfi),
|
||||
'',
|
||||
'### Path Traversal',
|
||||
renderSinkList(data.path_traversal),
|
||||
'',
|
||||
'### SSTI',
|
||||
renderSinkList(data.ssti),
|
||||
'',
|
||||
'### Deserialization',
|
||||
renderSinkList(data.deserialization),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC ENTRY POINT
|
||||
// ============================================================================
|
||||
|
||||
export function renderRecon(data: ReconData): string {
|
||||
const sections: string[] = [
|
||||
'# Reconnaissance Deliverable:',
|
||||
'',
|
||||
renderHowToReadThis(),
|
||||
'',
|
||||
renderExecutiveSummary(data.executive_summary),
|
||||
'',
|
||||
renderTechnologyStack(data.technology_stack),
|
||||
'',
|
||||
renderAuthentication(data.authentication),
|
||||
'',
|
||||
renderEndpoints(data.endpoints),
|
||||
'',
|
||||
renderInputVectors(data.input_vectors),
|
||||
'',
|
||||
renderNetworkMap(data.network_map),
|
||||
'',
|
||||
renderRoleArchitecture(data.role_architecture),
|
||||
'',
|
||||
renderAuthzCandidates(data.authz_candidates),
|
||||
'',
|
||||
renderInjectionSources(data.injection_sources),
|
||||
'',
|
||||
];
|
||||
return `${sections.join('\n').trimEnd()}\n`;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Deterministic vuln collector → markdown renderer.
|
||||
*
|
||||
* Single entry point renderVulnDeliverable(vulnClass, data) covers all 5
|
||||
* vulnerability classes (injection, xss, auth, ssrf, authz). Per-class title,
|
||||
* §3 sub-header set, §4 column shape, and §4 section heading are selected by
|
||||
* branching on vulnClass.
|
||||
*
|
||||
* Missing tools surface as placeholder sections, not activity failures.
|
||||
* Required tools (set_findings_summary, set_strategic_intelligence) produce
|
||||
* loud `[Section X: not provided]` placeholders; recommended tools
|
||||
* (set_safe_vectors, set_blind_spots) produce quiet "None identified" prose.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BlindSpotsInput,
|
||||
FindingsSummaryInput,
|
||||
SafeVectorsInput,
|
||||
StrategicIntelligenceInput,
|
||||
VulnClass,
|
||||
VulnCollectorData,
|
||||
} from '../mcp-server/vuln-collector.js';
|
||||
import { BLIND_SPOTS_CLASSES } from '../mcp-server/vuln-collector.js';
|
||||
|
||||
// ============================================================================
|
||||
// PER-CLASS CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
const TITLES: Record<VulnClass, string> = {
|
||||
injection: 'Injection Analysis Report',
|
||||
xss: 'Cross-Site Scripting (XSS) Analysis Report',
|
||||
auth: 'Authentication Analysis Report',
|
||||
ssrf: 'SSRF Analysis Report',
|
||||
authz: 'Authorization Analysis Report',
|
||||
};
|
||||
|
||||
const SECTION_FOUR_HEADING: Record<VulnClass, string> = {
|
||||
injection: '4. Vectors Analyzed and Confirmed Secure',
|
||||
xss: '4. Vectors Analyzed and Confirmed Secure',
|
||||
auth: '4. Secure by Design: Validated Components',
|
||||
ssrf: '4. Secure by Design: Validated Components',
|
||||
authz: '4. Vectors Analyzed and Confirmed Secure',
|
||||
};
|
||||
|
||||
const STRATEGIC_INTEL_SUBHEADERS: Record<VulnClass, ReadonlyArray<readonly [string, string]>> = {
|
||||
injection: [
|
||||
['defensive_evasion_waf', 'Defensive Evasion (WAF Analysis)'],
|
||||
['error_based_potential', 'Error-Based Injection Potential'],
|
||||
['confirmed_database_technology', 'Confirmed Database Technology'],
|
||||
],
|
||||
xss: [
|
||||
['csp_analysis', 'Content Security Policy (CSP) Analysis'],
|
||||
['cookie_security', 'Cookie Security'],
|
||||
],
|
||||
auth: [
|
||||
['authentication_method', 'Authentication Method'],
|
||||
['session_token_details', 'Session Token Details'],
|
||||
['password_policy', 'Password Policy'],
|
||||
],
|
||||
ssrf: [
|
||||
['http_client_library', 'HTTP Client Library'],
|
||||
['request_architecture', 'Request Architecture'],
|
||||
['internal_services', 'Internal Services'],
|
||||
],
|
||||
authz: [
|
||||
['session_management_architecture', 'Session Management Architecture'],
|
||||
['role_permission_model', 'Role/Permission Model'],
|
||||
['resource_access_patterns', 'Resource Access Patterns'],
|
||||
['workflow_implementation', 'Workflow Implementation'],
|
||||
],
|
||||
};
|
||||
|
||||
// Per-class column shape for §4. The first label is the subject column name
|
||||
// (varies by class — "Source" vs "Component/Flow" vs "Endpoint"); the location
|
||||
// column name also varies for authz ("Guard Location"); XSS gets an extra
|
||||
// "Render Context" column between defense and verdict.
|
||||
interface ColumnSpec {
|
||||
readonly subject: string;
|
||||
readonly location: string;
|
||||
readonly includeRenderContext: boolean;
|
||||
}
|
||||
|
||||
const SECTION_FOUR_COLUMNS: Record<VulnClass, ColumnSpec> = {
|
||||
injection: { subject: 'Source', location: 'Endpoint/File Location', includeRenderContext: false },
|
||||
xss: { subject: 'Source', location: 'Endpoint/File Location', includeRenderContext: true },
|
||||
auth: { subject: 'Component/Flow', location: 'Endpoint/File Location', includeRenderContext: false },
|
||||
ssrf: { subject: 'Component/Flow', location: 'Endpoint/File Location', includeRenderContext: false },
|
||||
authz: { subject: 'Endpoint', location: 'Guard Location', includeRenderContext: false },
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SHARED HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function placeholder(sectionLabel: string, toolName: string): string {
|
||||
return `_[${sectionLabel}: not provided — \`${toolName}\` was not called]_`;
|
||||
}
|
||||
|
||||
function escapePipe(value: string): string {
|
||||
return value.replace(/\|/g, '\\|');
|
||||
}
|
||||
|
||||
function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
|
||||
const headerRow = `| ${headers.map(escapePipe).join(' | ')} |`;
|
||||
const separator = `| ${headers.map(() => '---').join(' | ')} |`;
|
||||
const body = rows.map((row) => `| ${row.map(escapePipe).join(' | ')} |`).join('\n');
|
||||
return [headerRow, separator, body].filter((line) => line.length > 0).join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SECTION RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
function renderTitle(vulnClass: VulnClass): string {
|
||||
return `# ${TITLES[vulnClass]}`;
|
||||
}
|
||||
|
||||
function renderExecutiveSummary(summary: FindingsSummaryInput | undefined): string {
|
||||
if (!summary) {
|
||||
return ['## 1. Executive Summary', '', placeholder('Section 1', 'set_findings_summary')].join('\n');
|
||||
}
|
||||
return ['## 1. Executive Summary', '', summary.key_outcome].join('\n');
|
||||
}
|
||||
|
||||
function renderDominantPatterns(summary: FindingsSummaryInput | undefined): string {
|
||||
if (!summary) {
|
||||
return ['## 2. Dominant Vulnerability Patterns', '', placeholder('Section 2', 'set_findings_summary')].join('\n');
|
||||
}
|
||||
if (summary.patterns.length === 0) {
|
||||
return ['## 2. Dominant Vulnerability Patterns', '', '*No dominant patterns identified.*'].join('\n');
|
||||
}
|
||||
const blocks = summary.patterns.map((p, index) => {
|
||||
const ids = p.representative_finding_ids.map((id) => `\`${id}\``).join(', ');
|
||||
return [
|
||||
`### Pattern ${index + 1}: ${p.name}`,
|
||||
`- **Description:** ${p.description}`,
|
||||
`- **Implication:** ${p.implication}`,
|
||||
`- **Representative Findings:** ${ids}`,
|
||||
].join('\n');
|
||||
});
|
||||
return ['## 2. Dominant Vulnerability Patterns', '', blocks.join('\n\n')].join('\n');
|
||||
}
|
||||
|
||||
function renderStrategicIntelligence(vulnClass: VulnClass, intel: StrategicIntelligenceInput | undefined): string {
|
||||
if (!intel) {
|
||||
return [
|
||||
'## 3. Strategic Intelligence for Exploitation',
|
||||
'',
|
||||
placeholder('Section 3', 'set_strategic_intelligence'),
|
||||
].join('\n');
|
||||
}
|
||||
const subheaders = STRATEGIC_INTEL_SUBHEADERS[vulnClass];
|
||||
const intelRecord = intel as unknown as Record<string, string>;
|
||||
const blocks = subheaders.map(([fieldName, header]) => {
|
||||
const value = intelRecord[fieldName] ?? '*(not provided)*';
|
||||
return [`### ${header}`, value].join('\n');
|
||||
});
|
||||
return ['## 3. Strategic Intelligence for Exploitation', '', blocks.join('\n\n')].join('\n');
|
||||
}
|
||||
|
||||
function sortSafeVectors(vectors: SafeVectorsInput['vectors']): SafeVectorsInput['vectors'] {
|
||||
return [...vectors].sort((a, b) => {
|
||||
if (a.subject !== b.subject) return a.subject.localeCompare(b.subject);
|
||||
return a.location.localeCompare(b.location);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSafeVectors(vulnClass: VulnClass, data: SafeVectorsInput | undefined): string {
|
||||
const heading = `## ${SECTION_FOUR_HEADING[vulnClass]}`;
|
||||
if (!data || data.vectors.length === 0) {
|
||||
return [heading, '', '*No vectors confirmed secure during analysis.*'].join('\n');
|
||||
}
|
||||
const cols = SECTION_FOUR_COLUMNS[vulnClass];
|
||||
const headers: string[] = [cols.subject, cols.location, 'Defense Mechanism'];
|
||||
if (cols.includeRenderContext) {
|
||||
headers.push('Render Context');
|
||||
}
|
||||
headers.push('Verdict');
|
||||
|
||||
const sorted = sortSafeVectors(data.vectors);
|
||||
const rows = sorted.map((v) => {
|
||||
const row: string[] = [v.subject, v.location, v.defense_mechanism];
|
||||
if (cols.includeRenderContext) {
|
||||
row.push(v.render_context ?? '');
|
||||
}
|
||||
row.push('SAFE');
|
||||
return row;
|
||||
});
|
||||
|
||||
return [heading, '', renderTable(headers, rows)].join('\n');
|
||||
}
|
||||
|
||||
function renderBlindSpots(data: BlindSpotsInput | undefined): string {
|
||||
const heading = '## 5. Analysis Constraints and Blind Spots';
|
||||
if (!data || data.items.length === 0) {
|
||||
return [heading, '', '*No analysis constraints or blind spots identified.*'].join('\n');
|
||||
}
|
||||
const blocks = data.items.map((item) => [`### ${item.heading}`, item.description].join('\n'));
|
||||
return [heading, '', blocks.join('\n\n')].join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC ENTRY POINT
|
||||
// ============================================================================
|
||||
|
||||
export function renderVulnDeliverable(vulnClass: VulnClass, data: VulnCollectorData): string {
|
||||
const sections: string[] = [
|
||||
renderTitle(vulnClass),
|
||||
'',
|
||||
renderExecutiveSummary(data.findings_summary),
|
||||
'',
|
||||
renderDominantPatterns(data.findings_summary),
|
||||
'',
|
||||
renderStrategicIntelligence(vulnClass, data.strategic_intelligence),
|
||||
'',
|
||||
renderSafeVectors(vulnClass, data.safe_vectors),
|
||||
'',
|
||||
];
|
||||
if (BLIND_SPOTS_CLASSES.has(vulnClass)) {
|
||||
sections.push(renderBlindSpots(data.blind_spots), '');
|
||||
}
|
||||
return `${sections.join('\n').trimEnd()}\n`;
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user