mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-04-29 15:47:48 +02:00
dd18f4629b
* chore: initialize TypeScript configuration and build setup - Add tsconfig.json for root and mcp-server with strict type checking - Install typescript and @types/node as devDependencies - Add npm build script for TypeScript compilation - Update main entrypoint to compiled dist/shannon.js - Update Dockerfile to build TypeScript before running - Configure output directory and module resolution for Node.js * refactor: migrate codebase from JavaScript to TypeScript - Convert all 37 JavaScript files to TypeScript (.js -> .ts) - Add type definitions in src/types/ for agents, config, errors, session - Update mcp-server with proper TypeScript types - Move entry point from shannon.mjs to src/shannon.ts - Update tsconfig.json with rootDir: "./src" for cleaner dist output - Update Dockerfile to build TypeScript before runtime - Update package.json paths to use compiled dist/shannon.js No runtime behavior changes - pure type safety migration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: update CLI references from ./shannon.mjs to shannon - Update help text in src/cli/ui.ts - Update usage examples in src/cli/command-handler.ts - Update setup message in src/shannon.ts - Update CLAUDE.md documentation with TypeScript file structure - Replace all ./shannon.mjs references with shannon command 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: remove unnecessary eslint-disable comments ESLint is not configured in this project, making these comments redundant. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
936 lines
32 KiB
TypeScript
936 lines
32 KiB
TypeScript
// Copyright (C) 2025 Keygraph, Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License version 3
|
|
// as published by the Free Software Foundation.
|
|
|
|
import { fs, path } from 'zx';
|
|
import chalk, { type ChalkInstance } from 'chalk';
|
|
import { PentestError } from './error-handling.js';
|
|
import { parseConfig, distributeConfig } from './config-parser.js';
|
|
import { executeGitCommandWithRetry } from './utils/git-manager.js';
|
|
import { formatDuration } from './audit/utils.js';
|
|
import {
|
|
AGENTS,
|
|
PHASES,
|
|
validateAgent,
|
|
validateAgentRange,
|
|
validatePhase,
|
|
checkPrerequisites,
|
|
getNextAgent,
|
|
markAgentCompleted,
|
|
markAgentFailed,
|
|
getSessionStatus,
|
|
rollbackToAgent,
|
|
getSession
|
|
} from './session-manager.js';
|
|
import type { Session, AgentDefinition } from './session-manager.js';
|
|
import type { AgentName, PhaseName, PromptName } from './types/index.js';
|
|
import type { DistributedConfig } from './types/config.js';
|
|
import type { SessionMetadata } from './audit/utils.js';
|
|
|
|
// Types for callback functions
|
|
type RunClaudePromptWithRetry = (
|
|
prompt: string,
|
|
sourceDir: string,
|
|
allowedTools: string,
|
|
context: string,
|
|
description: string,
|
|
agentName: string | null,
|
|
colorFn: ChalkInstance,
|
|
sessionMetadata: SessionMetadata | null
|
|
) => Promise<AgentResult>;
|
|
|
|
type LoadPrompt = (
|
|
promptName: string,
|
|
variables: { webUrl: string; repoPath: string; sourceDir?: string },
|
|
config: DistributedConfig | null,
|
|
pipelineTestingMode: boolean
|
|
) => Promise<string>;
|
|
|
|
export interface AgentResult {
|
|
success: boolean;
|
|
duration: number;
|
|
cost?: number;
|
|
partialCost?: number;
|
|
error?: string;
|
|
retryable?: boolean;
|
|
logFile?: string;
|
|
}
|
|
|
|
interface ValidationData {
|
|
shouldExploit: boolean;
|
|
vulnerabilityCount: number;
|
|
}
|
|
|
|
interface SingleAgentResult {
|
|
success: boolean;
|
|
agentName: string;
|
|
result?: AgentResult;
|
|
validation?: ValidationData | null;
|
|
timing?: number | null;
|
|
cost?: number | null;
|
|
checkpoint?: string;
|
|
completedAt?: string;
|
|
attempts?: number;
|
|
logFile?: string;
|
|
error?: {
|
|
message: string;
|
|
type: string;
|
|
retryable: boolean;
|
|
originalError?: Error;
|
|
};
|
|
failedAt?: string;
|
|
context?: {
|
|
targetRepo: string;
|
|
promptName: PromptName;
|
|
sessionId: string;
|
|
};
|
|
}
|
|
|
|
interface ParallelResult {
|
|
completed: AgentName[];
|
|
failed: Array<{ agent: AgentName; error: string }>;
|
|
}
|
|
|
|
// Check if target repository exists and is accessible
|
|
const validateTargetRepo = async (targetRepo: string): Promise<boolean> => {
|
|
if (!targetRepo || !await fs.pathExists(targetRepo)) {
|
|
throw new PentestError(
|
|
`Target repository '${targetRepo}' not found or not accessible`,
|
|
'filesystem',
|
|
false,
|
|
{ targetRepo }
|
|
);
|
|
}
|
|
|
|
// Check if it's a git repository
|
|
const gitDir = path.join(targetRepo, '.git');
|
|
if (!await fs.pathExists(gitDir)) {
|
|
throw new PentestError(
|
|
`Target repository '${targetRepo}' is not a git repository`,
|
|
'validation',
|
|
false,
|
|
{ targetRepo }
|
|
);
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
// Get git commit hash for checkpoint
|
|
export const getGitCommitHash = async (targetRepo: string): Promise<string> => {
|
|
try {
|
|
const result = await executeGitCommandWithRetry(['git', 'rev-parse', 'HEAD'], targetRepo, 'getting commit hash');
|
|
return result.stdout.trim();
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
throw new PentestError(
|
|
`Failed to get git commit hash: ${errMsg}`,
|
|
'validation',
|
|
false,
|
|
{ targetRepo, originalError: errMsg }
|
|
);
|
|
}
|
|
};
|
|
|
|
// Rollback git workspace to specific commit
|
|
const rollbackGitToCommit = async (targetRepo: string, commitHash: string): Promise<void> => {
|
|
try {
|
|
await executeGitCommandWithRetry(['git', 'reset', '--hard', commitHash], targetRepo, 'rollback to commit');
|
|
await executeGitCommandWithRetry(['git', 'clean', '-fd'], targetRepo, 'cleaning after rollback');
|
|
console.log(chalk.green(`✅ Git workspace rolled back to commit ${commitHash.substring(0, 8)}`));
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
throw new PentestError(
|
|
`Failed to rollback git workspace: ${errMsg}`,
|
|
'validation',
|
|
false,
|
|
{ targetRepo, commitHash, originalError: errMsg }
|
|
);
|
|
}
|
|
};
|
|
|
|
// Helper function to get prompt name from agent name
|
|
const getPromptName = (agentName: AgentName): PromptName => {
|
|
const mappings: Record<AgentName, PromptName> = {
|
|
'pre-recon': 'pre-recon-code',
|
|
'recon': 'recon',
|
|
'injection-vuln': 'vuln-injection',
|
|
'xss-vuln': 'vuln-xss',
|
|
'auth-vuln': 'vuln-auth',
|
|
'ssrf-vuln': 'vuln-ssrf',
|
|
'authz-vuln': 'vuln-authz',
|
|
'injection-exploit': 'exploit-injection',
|
|
'xss-exploit': 'exploit-xss',
|
|
'auth-exploit': 'exploit-auth',
|
|
'ssrf-exploit': 'exploit-ssrf',
|
|
'authz-exploit': 'exploit-authz',
|
|
'report': 'report-executive'
|
|
};
|
|
|
|
return mappings[agentName] || agentName as PromptName;
|
|
};
|
|
|
|
// Get color function for agent
|
|
const getAgentColor = (agentName: AgentName): ChalkInstance => {
|
|
const colorMap: Partial<Record<AgentName, ChalkInstance>> = {
|
|
'injection-vuln': chalk.red,
|
|
'injection-exploit': chalk.red,
|
|
'xss-vuln': chalk.yellow,
|
|
'xss-exploit': chalk.yellow,
|
|
'auth-vuln': chalk.blue,
|
|
'auth-exploit': chalk.blue,
|
|
'ssrf-vuln': chalk.magenta,
|
|
'ssrf-exploit': chalk.magenta,
|
|
'authz-vuln': chalk.green,
|
|
'authz-exploit': chalk.green
|
|
};
|
|
return colorMap[agentName] || chalk.cyan;
|
|
};
|
|
|
|
// Run a single agent with retry logic and checkpointing
|
|
const runSingleAgent = async (
|
|
agentName: AgentName,
|
|
session: Session,
|
|
pipelineTestingMode: boolean,
|
|
runClaudePromptWithRetry: RunClaudePromptWithRetry,
|
|
loadPrompt: LoadPrompt,
|
|
allowRerun: boolean = false,
|
|
skipWorkspaceClean: boolean = false
|
|
): Promise<SingleAgentResult> => {
|
|
// Validate agent first
|
|
const agent = validateAgent(agentName);
|
|
|
|
console.log(chalk.cyan(`\n🤖 Running agent: ${agent.displayName}`));
|
|
|
|
// Reload session to get latest state (important for agent ranges)
|
|
const freshSession = await getSession(session.id);
|
|
if (!freshSession) {
|
|
throw new PentestError(`Session ${session.id} not found`, 'validation', false);
|
|
}
|
|
|
|
// Use fresh session for all subsequent checks
|
|
const currentSession = freshSession;
|
|
|
|
// Warn if session is completed
|
|
if (currentSession.status === 'completed') {
|
|
console.log(chalk.yellow('⚠️ This session is already completed. Re-running will modify completed results.'));
|
|
}
|
|
|
|
// Block re-running completed agents unless explicitly allowed
|
|
if (!allowRerun && currentSession.completedAgents.includes(agentName)) {
|
|
throw new PentestError(
|
|
`Agent '${agentName}' has already been completed. Use --rerun ${agentName} for explicit rollback and re-execution.`,
|
|
'validation',
|
|
false,
|
|
{
|
|
agentName,
|
|
suggestion: `--rerun ${agentName}`,
|
|
completedAgents: currentSession.completedAgents
|
|
}
|
|
);
|
|
}
|
|
|
|
const targetRepo = currentSession.targetRepo;
|
|
await validateTargetRepo(targetRepo);
|
|
|
|
// Check prerequisites
|
|
checkPrerequisites(currentSession, agentName);
|
|
|
|
// Clean workspace if needed
|
|
if (!currentSession.completedAgents.includes(agentName) && !allowRerun && !skipWorkspaceClean) {
|
|
try {
|
|
const status = await executeGitCommandWithRetry(['git', 'status', '--porcelain'], targetRepo, 'checking workspace status');
|
|
const hasUncommittedChanges = status.stdout.trim().length > 0;
|
|
|
|
if (hasUncommittedChanges) {
|
|
console.log(chalk.yellow(` ⚠️ Detected uncommitted changes before running ${agentName}`));
|
|
console.log(chalk.yellow(` 🧹 Cleaning workspace to ensure clean agent execution`));
|
|
await executeGitCommandWithRetry(['git', 'reset', '--hard', 'HEAD'], targetRepo, 'cleaning workspace');
|
|
await executeGitCommandWithRetry(['git', 'clean', '-fd'], targetRepo, 'removing untracked files');
|
|
console.log(chalk.green(` ✅ Workspace cleaned successfully`));
|
|
}
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
console.log(chalk.yellow(` ⚠️ Could not check/clean workspace: ${errMsg}`));
|
|
}
|
|
}
|
|
|
|
// Create variables for prompt
|
|
const variables = {
|
|
webUrl: currentSession.webUrl,
|
|
repoPath: currentSession.repoPath,
|
|
sourceDir: targetRepo
|
|
};
|
|
|
|
// Handle relative config paths
|
|
let configPath: string | null = null;
|
|
if (currentSession.configFile) {
|
|
configPath = path.isAbsolute(currentSession.configFile) || currentSession.configFile.startsWith('configs/')
|
|
? currentSession.configFile
|
|
: path.join('configs', currentSession.configFile);
|
|
}
|
|
|
|
const config = configPath ? await parseConfig(configPath) : null;
|
|
const distributedConfig = config ? distributeConfig(config) : null;
|
|
|
|
// Initialize variables for result
|
|
let validationData: ValidationData | null = null;
|
|
let timingData: number | null = null;
|
|
let costData: number | null = null;
|
|
|
|
try {
|
|
// Load and run the appropriate prompt
|
|
const promptName = getPromptName(agentName);
|
|
const prompt = await loadPrompt(promptName, variables, distributedConfig, pipelineTestingMode);
|
|
|
|
const result = await runClaudePromptWithRetry(
|
|
prompt,
|
|
targetRepo,
|
|
'*',
|
|
'',
|
|
AGENTS[agentName]!.displayName,
|
|
agentName,
|
|
getAgentColor(agentName),
|
|
{ id: currentSession.id, webUrl: currentSession.webUrl, repoPath: currentSession.repoPath }
|
|
);
|
|
|
|
if (!result.success) {
|
|
throw new PentestError(
|
|
`Agent execution failed: ${result.error}`,
|
|
'validation',
|
|
result.retryable || false,
|
|
{ agentName, result }
|
|
);
|
|
}
|
|
|
|
// Get commit hash for checkpoint
|
|
const commitHash = await getGitCommitHash(targetRepo);
|
|
|
|
// Extract timing and cost data
|
|
timingData = result.duration;
|
|
costData = result.cost || 0;
|
|
|
|
if (agentName.includes('-vuln')) {
|
|
// Validate vulnerability analysis results
|
|
const vulnType = agentName.replace('-vuln', '');
|
|
try {
|
|
const { safeValidateQueueAndDeliverable } = await import('./queue-validation.js');
|
|
const validation = await safeValidateQueueAndDeliverable(vulnType as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz', targetRepo);
|
|
|
|
if (validation.success && validation.data) {
|
|
console.log(chalk.blue(`📋 Validation: ${validation.data.shouldExploit ? `Ready for exploitation (${validation.data.vulnerabilityCount} vulnerabilities)` : 'No vulnerabilities found'}`));
|
|
validationData = {
|
|
shouldExploit: validation.data.shouldExploit,
|
|
vulnerabilityCount: validation.data.vulnerabilityCount
|
|
};
|
|
} else if (validation.error) {
|
|
console.log(chalk.yellow(`⚠️ Validation failed: ${validation.error.message}`));
|
|
}
|
|
} catch (validationError) {
|
|
const errMsg = validationError instanceof Error ? validationError.message : String(validationError);
|
|
console.log(chalk.yellow(`⚠️ Could not validate ${vulnType}: ${errMsg}`));
|
|
}
|
|
}
|
|
|
|
// Mark agent as completed
|
|
await markAgentCompleted(currentSession.id, agentName, commitHash);
|
|
|
|
// Only show completion message for sequential execution
|
|
if (!skipWorkspaceClean) {
|
|
console.log(chalk.green(`✅ Agent '${agentName}' completed successfully`));
|
|
}
|
|
|
|
// Return immutable result object
|
|
return Object.freeze({
|
|
success: true,
|
|
agentName,
|
|
result,
|
|
validation: validationData,
|
|
timing: timingData,
|
|
cost: costData,
|
|
checkpoint: commitHash,
|
|
completedAt: new Date().toISOString()
|
|
});
|
|
|
|
} catch (error) {
|
|
// Mark agent as failed
|
|
await markAgentFailed(currentSession.id, agentName);
|
|
|
|
const err = error as Error & { retryable?: boolean };
|
|
|
|
// Only show failure message for sequential execution
|
|
if (!skipWorkspaceClean) {
|
|
console.log(chalk.red(`❌ Agent '${agentName}' failed: ${err.message}`));
|
|
}
|
|
|
|
// Return immutable error object
|
|
const errorResult: SingleAgentResult = Object.freeze({
|
|
success: false,
|
|
agentName,
|
|
error: {
|
|
message: err.message,
|
|
type: err.constructor.name,
|
|
retryable: err.retryable || false,
|
|
originalError: err
|
|
},
|
|
validation: validationData,
|
|
timing: timingData,
|
|
failedAt: new Date().toISOString(),
|
|
context: {
|
|
targetRepo,
|
|
promptName: getPromptName(agentName),
|
|
sessionId: currentSession.id
|
|
}
|
|
});
|
|
|
|
// Throw enhanced error
|
|
const enhancedError = new PentestError(
|
|
`Agent '${agentName}' execution failed: ${err.message}`,
|
|
'validation',
|
|
err.retryable || false,
|
|
{
|
|
agentName,
|
|
sessionId: currentSession.id,
|
|
originalError: err.message,
|
|
errorResult
|
|
}
|
|
);
|
|
|
|
throw enhancedError;
|
|
}
|
|
};
|
|
|
|
// Run vulnerability agents in parallel
|
|
const runParallelVuln = async (
|
|
session: Session,
|
|
pipelineTestingMode: boolean,
|
|
runClaudePromptWithRetry: RunClaudePromptWithRetry,
|
|
loadPrompt: LoadPrompt
|
|
): Promise<ParallelResult> => {
|
|
const vulnAgents: AgentName[] = ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln'];
|
|
const activeAgents = vulnAgents.filter(agent => !session.completedAgents.includes(agent));
|
|
|
|
if (activeAgents.length === 0) {
|
|
console.log(chalk.gray('⏭️ All vulnerability agents already completed'));
|
|
return { completed: vulnAgents, failed: [] };
|
|
}
|
|
|
|
console.log(chalk.cyan(`\n🚀 Starting ${activeAgents.length} vulnerability analysis specialists in parallel...`));
|
|
console.log(chalk.gray(' Specialists: ' + activeAgents.join(', ')));
|
|
console.log();
|
|
|
|
const startTime = Date.now();
|
|
|
|
// Collect all results without logging individual completions
|
|
const results = await Promise.allSettled(
|
|
activeAgents.map(async (agentName, index) => {
|
|
// Add 2-second stagger to prevent API overwhelm
|
|
await new Promise(resolve => setTimeout(resolve, index * 2000));
|
|
|
|
let lastError: Error | undefined;
|
|
let attempts = 0;
|
|
const maxAttempts = 3;
|
|
|
|
while (attempts < maxAttempts) {
|
|
attempts++;
|
|
try {
|
|
const result = await runSingleAgent(agentName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, false, true);
|
|
return { ...result, attempts };
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
if (attempts < maxAttempts) {
|
|
console.log(chalk.yellow(`⚠️ ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`));
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
}
|
|
}
|
|
}
|
|
throw { agentName, error: lastError, attempts };
|
|
})
|
|
);
|
|
|
|
const totalDuration = Date.now() - startTime;
|
|
|
|
// Process and display results
|
|
console.log(chalk.cyan('\n📊 Vulnerability Analysis Results'));
|
|
console.log(chalk.gray('─'.repeat(80)));
|
|
console.log(chalk.bold('Agent Status Vulns Attempt Duration Cost'));
|
|
console.log(chalk.gray('─'.repeat(80)));
|
|
|
|
const completed: AgentName[] = [];
|
|
const failed: Array<{ agent: AgentName; error: string }> = [];
|
|
|
|
results.forEach((result, index) => {
|
|
const agentName = activeAgents[index]!;
|
|
const agentDisplay = agentName.padEnd(22);
|
|
|
|
if (result.status === 'fulfilled') {
|
|
const data = result.value;
|
|
completed.push(agentName);
|
|
|
|
const vulnCount = data.validation?.vulnerabilityCount || 0;
|
|
const duration = formatDuration(data.timing || 0);
|
|
const cost = `$${(data.cost || 0).toFixed(4)}`;
|
|
|
|
console.log(
|
|
`${chalk.green(agentDisplay)} ${chalk.green('✓ Success')} ${vulnCount.toString().padStart(5)} ` +
|
|
`${data.attempts}/3 ${duration.padEnd(11)} ${cost}`
|
|
);
|
|
|
|
if (data.logFile) {
|
|
const relativePath = path.relative(process.cwd(), data.logFile);
|
|
console.log(chalk.gray(` └─ Detailed log: ${relativePath}`));
|
|
}
|
|
} else {
|
|
const reason = result.reason as { error?: Error; attempts?: number };
|
|
const error = reason.error || result.reason;
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
failed.push({ agent: agentName, error: errMsg });
|
|
|
|
const attempts = reason.attempts || 3;
|
|
|
|
console.log(
|
|
`${chalk.red(agentDisplay)} ${chalk.red('✗ Failed ')} - ` +
|
|
`${attempts}/3 - -`
|
|
);
|
|
console.log(chalk.gray(` └─ ${errMsg.substring(0, 60)}...`));
|
|
}
|
|
});
|
|
|
|
console.log(chalk.gray('─'.repeat(80)));
|
|
console.log(chalk.cyan(`Summary: ${completed.length}/${activeAgents.length} succeeded in ${formatDuration(totalDuration)}`));
|
|
|
|
return { completed, failed };
|
|
};
|
|
|
|
// Run exploitation agents in parallel
|
|
const runParallelExploit = async (
|
|
session: Session,
|
|
pipelineTestingMode: boolean,
|
|
runClaudePromptWithRetry: RunClaudePromptWithRetry,
|
|
loadPrompt: LoadPrompt
|
|
): Promise<ParallelResult> => {
|
|
const exploitAgents: AgentName[] = ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'];
|
|
|
|
// Get fresh session data
|
|
const freshSession = await getSession(session.id);
|
|
if (!freshSession) {
|
|
throw new PentestError(`Session ${session.id} not found`, 'validation', false);
|
|
}
|
|
|
|
// Load validation module
|
|
const { safeValidateQueueAndDeliverable } = await import('./queue-validation.js');
|
|
|
|
// Check eligibility
|
|
const eligibilityChecks = await Promise.all(
|
|
exploitAgents.map(async (agentName) => {
|
|
const vulnAgentName = agentName.replace('-exploit', '-vuln') as AgentName;
|
|
|
|
if (!freshSession.completedAgents.includes(vulnAgentName)) {
|
|
return { agentName, eligible: false };
|
|
}
|
|
|
|
const vulnType = vulnAgentName.replace('-vuln', '') as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz';
|
|
const validation = await safeValidateQueueAndDeliverable(vulnType, freshSession.targetRepo);
|
|
|
|
if (!validation.success || !validation.data?.shouldExploit) {
|
|
console.log(chalk.gray(`⏭️ Skipping ${agentName} (no vulnerabilities found in ${vulnAgentName})`));
|
|
return { agentName, eligible: false };
|
|
}
|
|
|
|
console.log(chalk.blue(`✓ ${agentName} eligible (${validation.data.vulnerabilityCount} vulnerabilities from ${vulnAgentName})`));
|
|
return { agentName, eligible: true };
|
|
})
|
|
);
|
|
|
|
const eligibleAgents = eligibilityChecks
|
|
.filter(check => check.eligible)
|
|
.map(check => check.agentName);
|
|
|
|
const activeAgents = eligibleAgents.filter(agent => !freshSession.completedAgents.includes(agent));
|
|
|
|
if (activeAgents.length === 0) {
|
|
if (eligibleAgents.length === 0) {
|
|
console.log(chalk.gray('⏭️ No exploitation agents eligible (no vulnerabilities found)'));
|
|
} else {
|
|
console.log(chalk.gray('⏭️ All eligible exploitation agents already completed'));
|
|
}
|
|
return { completed: eligibleAgents, failed: [] };
|
|
}
|
|
|
|
console.log(chalk.cyan(`\n🎯 Starting ${activeAgents.length} exploitation specialists in parallel...`));
|
|
console.log(chalk.gray(' Specialists: ' + activeAgents.join(', ')));
|
|
console.log();
|
|
|
|
const startTime = Date.now();
|
|
|
|
const results = await Promise.allSettled(
|
|
activeAgents.map(async (agentName, index) => {
|
|
await new Promise(resolve => setTimeout(resolve, index * 2000));
|
|
|
|
let lastError: Error | undefined;
|
|
let attempts = 0;
|
|
const maxAttempts = 3;
|
|
|
|
while (attempts < maxAttempts) {
|
|
attempts++;
|
|
try {
|
|
const result = await runSingleAgent(agentName, freshSession, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, false, true);
|
|
return { ...result, attempts };
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
if (attempts < maxAttempts) {
|
|
console.log(chalk.yellow(`⚠️ ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`));
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
}
|
|
}
|
|
}
|
|
throw { agentName, error: lastError, attempts };
|
|
})
|
|
);
|
|
|
|
const totalDuration = Date.now() - startTime;
|
|
|
|
console.log(chalk.cyan('\n🎯 Exploitation Results'));
|
|
console.log(chalk.gray('─'.repeat(80)));
|
|
console.log(chalk.bold('Agent Status Result Attempt Duration Cost'));
|
|
console.log(chalk.gray('─'.repeat(80)));
|
|
|
|
const completed: AgentName[] = [];
|
|
const failed: Array<{ agent: AgentName; error: string }> = [];
|
|
|
|
results.forEach((result, index) => {
|
|
const agentName = activeAgents[index]!;
|
|
const agentDisplay = agentName.padEnd(22);
|
|
|
|
if (result.status === 'fulfilled') {
|
|
const data = result.value;
|
|
completed.push(agentName);
|
|
|
|
const exploitResult = 'Success';
|
|
const duration = formatDuration(data.timing || 0);
|
|
const cost = `$${(data.cost || 0).toFixed(4)}`;
|
|
|
|
console.log(
|
|
`${chalk.green(agentDisplay)} ${chalk.green('✓ Success')} ${exploitResult.padEnd(6)} ` +
|
|
`${data.attempts}/3 ${duration.padEnd(11)} ${cost}`
|
|
);
|
|
|
|
if (data.logFile) {
|
|
const relativePath = path.relative(process.cwd(), data.logFile);
|
|
console.log(chalk.gray(` └─ Detailed log: ${relativePath}`));
|
|
}
|
|
} else {
|
|
const reason = result.reason as { error?: Error; attempts?: number };
|
|
const error = reason.error || result.reason;
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
failed.push({ agent: agentName, error: errMsg });
|
|
|
|
const attempts = reason.attempts || 3;
|
|
|
|
console.log(
|
|
`${chalk.red(agentDisplay)} ${chalk.red('✗ Failed ')} - ` +
|
|
`${attempts}/3 - -`
|
|
);
|
|
console.log(chalk.gray(` └─ ${errMsg.substring(0, 60)}...`));
|
|
}
|
|
});
|
|
|
|
console.log(chalk.gray('─'.repeat(80)));
|
|
console.log(chalk.cyan(`Summary: ${completed.length}/${activeAgents.length} succeeded in ${formatDuration(totalDuration)}`));
|
|
|
|
return { completed, failed };
|
|
};
|
|
|
|
// Run all agents in a phase
|
|
export const runPhase = async (
|
|
phaseName: string,
|
|
session: Session,
|
|
pipelineTestingMode: boolean,
|
|
runClaudePromptWithRetry: RunClaudePromptWithRetry,
|
|
loadPrompt: LoadPrompt
|
|
): Promise<void> => {
|
|
console.log(chalk.cyan(`\n📋 Running phase: ${phaseName} (parallel execution)`));
|
|
|
|
if (phaseName === 'vulnerability-analysis') {
|
|
console.log(chalk.cyan('🚀 Using parallel execution for 5x faster vulnerability analysis'));
|
|
const results = await runParallelVuln(session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
|
|
|
|
if (results.failed.length > 0) {
|
|
console.log(chalk.yellow(`⚠️ ${results.failed.length} agents failed, but phase continues`));
|
|
results.failed.forEach(failure => {
|
|
console.log(chalk.red(` - ${failure.agent}: ${failure.error}`));
|
|
});
|
|
}
|
|
|
|
console.log(chalk.green(`✅ Phase '${phaseName}' completed: ${results.completed.length} succeeded, ${results.failed.length} failed`));
|
|
return;
|
|
}
|
|
|
|
if (phaseName === 'exploitation') {
|
|
console.log(chalk.cyan('🎯 Using parallel execution for 5x faster exploitation'));
|
|
const results = await runParallelExploit(session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
|
|
|
|
if (results.failed.length > 0) {
|
|
console.log(chalk.yellow(`⚠️ ${results.failed.length} agents failed, but phase continues`));
|
|
results.failed.forEach(failure => {
|
|
console.log(chalk.red(` - ${failure.agent}: ${failure.error}`));
|
|
});
|
|
}
|
|
|
|
console.log(chalk.green(`✅ Phase '${phaseName}' completed: ${results.completed.length} succeeded, ${results.failed.length} failed`));
|
|
return;
|
|
}
|
|
|
|
// For other phases, run single agent
|
|
const agents = validatePhase(phaseName);
|
|
if (agents.length === 1) {
|
|
const agent = agents[0]!;
|
|
if (session.completedAgents.includes(agent.name)) {
|
|
console.log(chalk.gray(`⏭️ Agent '${agent.name}' already completed, skipping`));
|
|
return;
|
|
}
|
|
|
|
await runSingleAgent(agent.name, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
|
|
console.log(chalk.green(`✅ Phase '${phaseName}' completed successfully`));
|
|
} else {
|
|
throw new PentestError(`Phase '${phaseName}' has multiple agents but no parallel execution defined`, 'validation', false);
|
|
}
|
|
};
|
|
|
|
// Rollback to specific agent checkpoint
|
|
export const rollbackTo = async (targetAgent: string, session: Session): Promise<void> => {
|
|
console.log(chalk.yellow(`🔄 Rolling back to agent: ${targetAgent}`));
|
|
|
|
await validateTargetRepo(session.targetRepo);
|
|
validateAgent(targetAgent);
|
|
|
|
const agentName = targetAgent as AgentName;
|
|
if (!session.checkpoints[agentName]) {
|
|
throw new PentestError(
|
|
`No checkpoint found for agent '${targetAgent}' in session history`,
|
|
'validation',
|
|
false,
|
|
{ targetAgent, availableCheckpoints: Object.keys(session.checkpoints) }
|
|
);
|
|
}
|
|
|
|
const commitHash = session.checkpoints[agentName]!;
|
|
|
|
await rollbackGitToCommit(session.targetRepo, commitHash);
|
|
await rollbackToAgent(session.id, targetAgent);
|
|
|
|
// Mark rolled-back agents in audit system
|
|
try {
|
|
const { AuditSession } = await import('./audit/index.js');
|
|
const sessionMetadata: SessionMetadata = {
|
|
id: session.id,
|
|
webUrl: session.webUrl,
|
|
repoPath: session.repoPath
|
|
};
|
|
const auditSession = new AuditSession(sessionMetadata);
|
|
await auditSession.initialize();
|
|
|
|
const targetOrder = AGENTS[agentName]!.order;
|
|
const rolledBackAgents = Object.values(AGENTS)
|
|
.filter(agent => agent.order > targetOrder)
|
|
.map(agent => agent.name);
|
|
|
|
if (rolledBackAgents.length > 0) {
|
|
await auditSession.markMultipleRolledBack(rolledBackAgents);
|
|
console.log(chalk.gray(` Marked ${rolledBackAgents.length} agents as rolled-back in audit logs`));
|
|
}
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
console.log(chalk.yellow(` ⚠️ Failed to update audit logs: ${errMsg}`));
|
|
}
|
|
|
|
console.log(chalk.green(`✅ Successfully rolled back to agent '${targetAgent}'`));
|
|
};
|
|
|
|
// Rerun specific agent
|
|
export const rerunAgent = async (
|
|
agentName: string,
|
|
session: Session,
|
|
pipelineTestingMode: boolean,
|
|
runClaudePromptWithRetry: RunClaudePromptWithRetry,
|
|
loadPrompt: LoadPrompt
|
|
): Promise<void> => {
|
|
console.log(chalk.cyan(`🔁 Rerunning agent: ${agentName}`));
|
|
|
|
const agent = validateAgent(agentName);
|
|
|
|
// Find previous agent checkpoint
|
|
let rollbackTarget: AgentName | null = null;
|
|
if (agent.prerequisites.length > 0) {
|
|
const completedPrereqs = agent.prerequisites.filter(prereq =>
|
|
session.completedAgents.includes(prereq)
|
|
);
|
|
if (completedPrereqs.length > 0) {
|
|
rollbackTarget = completedPrereqs.reduce((latest, current) =>
|
|
AGENTS[current]!.order > AGENTS[latest]!.order ? current : latest
|
|
);
|
|
}
|
|
}
|
|
|
|
if (rollbackTarget) {
|
|
console.log(chalk.blue(`📍 Rolling back to prerequisite: ${rollbackTarget}`));
|
|
await rollbackTo(rollbackTarget, session);
|
|
} else if (agent.name === 'pre-recon') {
|
|
console.log(chalk.blue(`📍 Rolling back to initial repository state`));
|
|
try {
|
|
const initialCommit = await executeGitCommandWithRetry(['git', 'log', '--reverse', '--format=%H'], session.targetRepo, 'finding initial commit');
|
|
const firstCommit = initialCommit.stdout.trim().split('\n')[0];
|
|
if (firstCommit) {
|
|
await rollbackGitToCommit(session.targetRepo, firstCommit);
|
|
}
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
console.log(chalk.yellow(`⚠️ Could not find initial commit, using HEAD: ${errMsg}`));
|
|
}
|
|
}
|
|
|
|
await runSingleAgent(agent.name, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, true);
|
|
|
|
console.log(chalk.green(`✅ Agent '${agentName}' rerun completed successfully`));
|
|
};
|
|
|
|
// Run all remaining agents
|
|
export const runAll = async (
|
|
session: Session,
|
|
pipelineTestingMode: boolean,
|
|
runClaudePromptWithRetry: RunClaudePromptWithRetry,
|
|
loadPrompt: LoadPrompt
|
|
): Promise<void> => {
|
|
const allAgentNames = Object.keys(AGENTS) as AgentName[];
|
|
|
|
console.log(chalk.cyan(`\n🚀 Running all remaining agents to completion`));
|
|
console.log(chalk.gray(`Current progress: ${session.completedAgents.length}/${allAgentNames.length} agents completed`));
|
|
|
|
const remainingAgents = allAgentNames.filter(agentName =>
|
|
!session.completedAgents.includes(agentName)
|
|
);
|
|
|
|
if (remainingAgents.length === 0) {
|
|
console.log(chalk.green('✅ All agents already completed!'));
|
|
return;
|
|
}
|
|
|
|
console.log(chalk.blue(`📋 Remaining agents: ${remainingAgents.join(', ')}`));
|
|
console.log();
|
|
|
|
for (const agentName of remainingAgents) {
|
|
await runSingleAgent(agentName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
|
|
}
|
|
|
|
console.log(chalk.green(`\n🎉 All agents completed successfully! Session marked as completed.`));
|
|
};
|
|
|
|
// Helper for time ago calculation
|
|
const getTimeAgo = (timestamp: string): string => {
|
|
const now = new Date();
|
|
const past = new Date(timestamp);
|
|
const diffMs = now.getTime() - past.getTime();
|
|
|
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffMins < 60) {
|
|
return `${diffMins}m ago`;
|
|
} else if (diffHours < 24) {
|
|
return `${diffHours}h ago`;
|
|
} else {
|
|
return `${diffDays}d ago`;
|
|
}
|
|
};
|
|
|
|
// Display session status
|
|
export const displayStatus = async (session: Session): Promise<void> => {
|
|
const status = getSessionStatus(session);
|
|
const timeAgo = getTimeAgo(session.lastActivity);
|
|
|
|
console.log(chalk.cyan(`Session: ${new URL(session.webUrl).hostname} + ${path.basename(session.repoPath)}`));
|
|
console.log(chalk.gray(`Session ID: ${session.id}`));
|
|
console.log(chalk.gray(`Source Directory: ${session.targetRepo}`));
|
|
|
|
// Check if final deliverable exists
|
|
if (session.targetRepo) {
|
|
const finalReportPath = path.join(session.targetRepo, 'deliverables', 'comprehensive_security_assessment_report.md');
|
|
try {
|
|
if (await fs.pathExists(finalReportPath)) {
|
|
console.log(chalk.gray(`Final Deliverable Available: ${finalReportPath}`));
|
|
}
|
|
} catch {
|
|
// Silently ignore
|
|
}
|
|
}
|
|
|
|
const statusColor = status.status === 'completed' ? chalk.green : status.status === 'failed' ? chalk.red : chalk.blue;
|
|
console.log(statusColor(`Status: ${status.status} (${status.completedCount}/${status.totalAgents} agents completed)`));
|
|
console.log(chalk.gray(`Last Activity: ${timeAgo}`));
|
|
|
|
if (session.configFile) {
|
|
console.log(chalk.gray(`Config: ${session.configFile}`));
|
|
}
|
|
|
|
console.log();
|
|
|
|
// Display agent status
|
|
const agentList = Object.values(AGENTS).sort((a, b) => a.order - b.order);
|
|
|
|
for (const agent of agentList) {
|
|
let statusIcon: string, statusText: string, statusColorFn: ChalkInstance;
|
|
|
|
if (session.completedAgents.includes(agent.name)) {
|
|
statusIcon = '✅';
|
|
statusText = `completed ${getTimeAgo(session.lastActivity)}`;
|
|
statusColorFn = chalk.green;
|
|
} else if (session.failedAgents.includes(agent.name)) {
|
|
statusIcon = '❌';
|
|
statusText = `failed ${getTimeAgo(session.lastActivity)}`;
|
|
statusColorFn = chalk.red;
|
|
} else {
|
|
statusIcon = '⏸️';
|
|
statusText = 'pending';
|
|
statusColorFn = chalk.gray;
|
|
}
|
|
|
|
const displayName = agent.name.replace(/-/g, ' ');
|
|
console.log(`${statusIcon} ${statusColorFn(displayName.padEnd(20))} (${statusText})`);
|
|
}
|
|
|
|
// Show next action
|
|
const nextAgent = getNextAgent(session);
|
|
if (nextAgent) {
|
|
console.log(chalk.cyan(`\nNext: Run --run-agent ${nextAgent.name}`));
|
|
} else if (status.failedCount > 0) {
|
|
const failedAgent = session.failedAgents[0];
|
|
console.log(chalk.yellow(`\nNext: Fix ${failedAgent} failure or run --rerun ${failedAgent}`));
|
|
} else if (status.status === 'completed') {
|
|
console.log(chalk.green('\nAll agents completed successfully! 🎉'));
|
|
}
|
|
};
|
|
|
|
// List all available agents
|
|
export const listAgents = (): void => {
|
|
console.log(chalk.cyan('Available Agents:'));
|
|
|
|
const phaseNames = Object.keys(PHASES) as PhaseName[];
|
|
|
|
phaseNames.forEach((phaseName, phaseIndex) => {
|
|
const phaseAgents = PHASES[phaseName];
|
|
const phaseDisplayName = phaseName.split('-').map(word =>
|
|
word.charAt(0).toUpperCase() + word.slice(1)
|
|
).join(' ');
|
|
|
|
console.log(chalk.yellow(`\nPhase ${phaseIndex + 1} - ${phaseDisplayName}:`));
|
|
|
|
phaseAgents.forEach(agentName => {
|
|
const agent = AGENTS[agentName]!;
|
|
console.log(chalk.white(` ${agent.name.padEnd(18)} ${agent.displayName}`));
|
|
});
|
|
});
|
|
};
|