diff --git a/CLAUDE.md b/CLAUDE.md index 75ad253..72a6582 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,15 @@ shannon "https://example.com" "/path/to/repo" --output /path/to/reports npm start --config ``` +### Options +```bash +--config YAML configuration file for authentication and testing parameters +--output Custom output directory for session folder (default: ./audit-logs/) +--pipeline-testing Use minimal prompts for fast pipeline testing (creates minimal deliverables) +--disable-loader Disable the animated progress loader (useful when logs interfere with spinner) +--help Show help message +``` + ### Configuration Validation ```bash # Configuration validation is built into the main script @@ -43,58 +52,7 @@ TOTP generation is now handled automatically via the `generate_totp` MCP tool du ```bash # No linting or testing commands available in this project # Development is done by running the agent in pipeline-testing mode -shannon --pipeline-testing -``` - -### Session Management Commands -```bash -# Setup session without running -shannon --setup-only --config - -# Check session status (shows progress, timing, costs) -shannon --status - -# List all available agents by phase -shannon --list-agents - -# Show help -shannon --help -``` - -### Execution Commands -```bash -# Run all remaining agents to completion -shannon --run-all [--pipeline-testing] - -# Run a specific agent -shannon --run-agent [--pipeline-testing] - -# Run a range of agents -shannon --run-agents : [--pipeline-testing] - -# Run a specific phase -shannon --run-phase [--pipeline-testing] - -# Pipeline testing mode (minimal prompts for fast testing) -shannon --pipeline-testing -``` - -### Rollback & Recovery Commands -```bash -# Rollback to specific checkpoint -shannon --rollback-to - -# Rollback and re-execute specific agent -shannon --rerun [--pipeline-testing] -``` - -### Session Cleanup Commands -```bash -# Delete all sessions (with confirmation) -shannon --cleanup - -# Delete specific session by ID -shannon --cleanup +shannon --pipeline-testing ``` ## Architecture & Components @@ -106,22 +64,20 @@ shannon --cleanup - `src/config-parser.ts` - Handles YAML configuration parsing, validation, and distribution to agents - `src/error-handling.ts` - Comprehensive error handling with retry logic and categorized error types - `src/tool-checker.ts` - Validates availability of external security tools before execution -- `src/session-manager.ts` - Manages persistent session state and agent lifecycle -- `src/checkpoint-manager.ts` - Git-based checkpointing system for rollback capabilities -- Pipeline orchestration is built into the main `src/shannon.ts` script +- `src/session-manager.ts` - Agent definitions, execution order, and parallel groups - `src/queue-validation.ts` - Validates deliverables and agent prerequisites ### Five-Phase Testing Workflow 1. **Pre-Reconnaissance** (`pre-recon`) - External tool scans (nmap, subfinder, whatweb) + source code analysis 2. **Reconnaissance** (`recon`) - Analysis of initial findings and attack surface mapping -3. **Vulnerability Analysis** (5 agents) +3. **Vulnerability Analysis** (5 agents run in parallel) - `injection-vuln` - SQL injection, command injection - `xss-vuln` - Cross-site scripting - `auth-vuln` - Authentication bypasses - `authz-vuln` - Authorization flaws - `ssrf-vuln` - Server-side request forgery -4. **Exploitation** (5 agents) +4. **Exploitation** (5 agents run in parallel, only if vulnerabilities found) - `injection-exploit` - Exploit injection vulnerabilities - `xss-exploit` - Exploit XSS vulnerabilities - `auth-exploit` - Exploit authentication issues @@ -182,45 +138,25 @@ The agent integrates with external security tools: Tools are validated for availability before execution using the tool-checker module. -### Git-Based Checkpointing System -The agent implements a sophisticated checkpoint system using git: -- Every agent creates a git checkpoint before execution -- Rollback to any previous agent state using `--rollback-to` or `--rerun` -- Failed agents don't affect completed work -- Rolled-back agents marked in audit system with status: "rolled-back" -- Reconciliation automatically syncs Shannon store with audit logs after rollback -- Fail-fast safety prevents accidental re-execution of completed agents - -### Unified Audit & Metrics System -The agent implements a crash-safe, self-healing audit system (v3.0) with the following guarantees: +### Audit & Metrics System +The agent implements a crash-safe audit system with the following features: **Architecture:** -- **audit-logs/** (or custom `--output` path): Centralized metrics and forensic logs (source of truth) +- **audit-logs/** (or custom `--output` path): Centralized metrics and forensic logs - `{hostname}_{sessionId}/session.json` - Comprehensive metrics with attempt-level detail - `{hostname}_{sessionId}/prompts/` - Exact prompts used for reproducibility - `{hostname}_{sessionId}/agents/` - Turn-by-turn execution logs - `{hostname}_{sessionId}/deliverables/` - Security reports and findings -- **.shannon-store.json**: Minimal orchestration state (completedAgents, checkpoints) +- **.shannon-store.json**: Minimal session lock file (prevents concurrent runs) **Crash Safety:** - Append-only logging with immediate flush (survives kill -9) - Atomic writes for session.json (no partial writes) -- Event-based logging (tool_start, tool_end, llm_response) closes data loss windows - -**Self-Healing:** -- Automatic reconciliation before every CLI command -- Recovers from crashes during rollback -- Audit logs are source of truth; Shannon store follows - -**Forensic Completeness:** -- All retry attempts logged with errors, costs, durations -- Rolled-back agents preserved with status: "rolled-back" -- Partial cost capture for failed attempts -- Complete event trail for debugging +- Event-based logging (tool_start, tool_end, llm_response) **Concurrency Safety:** - SessionMutex prevents race conditions during parallel agent execution -- Safe parallel execution of vulnerability and exploitation phases +- 5x faster execution with parallel vulnerability and exploitation phases **Metrics & Reporting:** - Export metrics to CSV with `./scripts/export-metrics.js` @@ -238,16 +174,20 @@ For detailed design, see `docs/unified-audit-system-design.md`. - **SDK-First Approach**: Heavy reliance on Claude Agent SDK for autonomous AI operations - **Progressive Analysis**: Each phase builds on previous phase results - **Local Repository Setup**: Target applications are accessed directly from user-provided local directories +- **Fire-and-Forget Execution**: Single entry point, runs all phases to completion ### Error Handling Strategy The application uses a comprehensive error handling system with: - Categorized error types (PentestError, ConfigError, NetworkError, etc.) -- Automatic retry logic for transient failures +- Automatic retry logic for transient failures (3 attempts per agent) - Graceful degradation when external tools are unavailable - Detailed error logging and user-friendly error messages ### Testing Mode -The agent includes a testing mode that skips external tool execution for faster development cycles. +The agent includes a testing mode that skips external tool execution for faster development cycles: +```bash +shannon --pipeline-testing +``` ### Security Focus This is explicitly designed as a **defensive security tool** for: @@ -263,32 +203,48 @@ The tool should only be used on systems you own or have explicit permission to t ``` src/ # TypeScript source files ├── shannon.ts # Main orchestration script (entry point) +├── constants.ts # Shared constants +├── config-parser.ts # Configuration handling +├── error-handling.ts # Error management +├── tool-checker.ts # Tool validation +├── session-manager.ts # Agent definitions, order, and parallel groups +├── queue-validation.ts # Deliverable validation +├── splash-screen.ts # ASCII art splash screen +├── progress-indicator.ts # Progress display utilities ├── types/ # TypeScript type definitions │ ├── index.ts # Barrel exports │ ├── agents.ts # Agent type definitions │ ├── config.ts # Configuration interfaces │ ├── errors.ts # Error type definitions │ └── session.ts # Session type definitions -├── audit/ # Unified audit system (v3.0) +├── audit/ # Audit system │ ├── index.ts # Public API │ ├── audit-session.ts # Main facade (logger + metrics + mutex) │ ├── logger.ts # Append-only crash-safe logging │ ├── metrics-tracker.ts # Timing, cost, attempt tracking │ └── utils.ts # Path generation, atomic writes -├── config-parser.ts # Configuration handling -├── error-handling.ts # Error management -├── tool-checker.ts # Tool validation -├── session-manager.ts # Session state + reconciliation -├── checkpoint-manager.ts # Git-based checkpointing + rollback -├── queue-validation.ts # Deliverable validation ├── ai/ │ └── claude-executor.ts # Claude Agent SDK integration +├── phases/ +│ ├── pre-recon.ts # Pre-reconnaissance phase +│ └── reporting.ts # Final report assembly +├── prompts/ +│ └── prompt-manager.ts # Prompt loading and variable substitution +├── setup/ +│ └── environment.ts # Local repository setup +├── cli/ +│ ├── ui.ts # Help text display +│ └── input-validator.ts # URL and path validation └── utils/ + ├── git-manager.ts # Git operations + ├── metrics.ts # Timing utilities + ├── output-formatter.ts # Output formatting utilities + └── concurrency.ts # SessionMutex for parallel execution dist/ # Compiled JavaScript output ├── shannon.js # Compiled entry point └── ... # Other compiled files package.json # Node.js dependencies -.shannon-store.json # Orchestration state (minimal) +.shannon-store.json # Session lock file audit-logs/ # Centralized audit data (default, or use --output) └── {hostname}_{sessionId}/ ├── session.json # Comprehensive metrics @@ -329,11 +285,9 @@ docs/ # Documentation ## Troubleshooting ### Common Issues -- **"Agent already completed"**: Use `--rerun ` for explicit re-execution -- **"Missing prerequisites"**: Check `--status` and run prerequisite agents first -- **"No sessions found"**: Create a session with `--setup-only` first +- **"A session is already running"**: Wait for the current session to complete, or delete `.shannon-store.json` - **"Repository not found"**: Ensure target local directory exists and is accessible -- **"Too many test sessions"**: Use `--cleanup` to remove old sessions and free disk space +- **Concurrent runs blocked**: Only one session can run at a time per target ### External Tool Dependencies Missing tools can be skipped using `--pipeline-testing` mode during development: diff --git a/README.md b/README.md index dfcaf93..4f728cb 100644 --- a/README.md +++ b/README.md @@ -286,14 +286,6 @@ rules: If your application uses two-factor authentication, simply add the TOTP secret to your config file. The AI will automatically generate the required codes during testing. -### Check Status - -View progress of previous runs: - -```bash -docker run --rm shannon:latest --status -``` - ### Output and Results All results are saved to `./audit-logs/` by default. Use `--output ` to specify a custom directory. If using `--output`, ensure that path is mounted to an accessible host directory (e.g., `-v "$(pwd)/custom-directory:/app/reports"`). diff --git a/src/audit/audit-session.ts b/src/audit/audit-session.ts index 0eb907e..b3540a7 100644 --- a/src/audit/audit-session.ts +++ b/src/audit/audit-session.ts @@ -155,21 +155,6 @@ export class AuditSession { } } - /** - * Mark multiple agents as rolled back - */ - async markMultipleRolledBack(agentNames: string[]): Promise { - await this.ensureInitialized(); - - const unlock = await sessionMutex.lock(this.sessionId); - try { - await this.metricsTracker.reload(); - await this.metricsTracker.markMultipleRolledBack(agentNames); - } finally { - unlock(); - } - } - /** * Update session status */ diff --git a/src/audit/metrics-tracker.ts b/src/audit/metrics-tracker.ts index 5afa6c8..54ec973 100644 --- a/src/audit/metrics-tracker.ts +++ b/src/audit/metrics-tracker.ts @@ -20,7 +20,6 @@ import { calculatePercentage, type SessionMetadata, } from './utils.js'; -import type { AgentName, PhaseName } from '../types/index.js'; interface AttemptData { attempt_number: number; @@ -32,12 +31,11 @@ interface AttemptData { } interface AgentMetrics { - status: 'in-progress' | 'success' | 'failed' | 'rolled-back'; + status: 'in-progress' | 'success' | 'failed'; attempts: AttemptData[]; final_duration_ms: number; total_cost_usd: number; checkpoint?: string; - rolled_back_at?: string; } interface PhaseMetrics { @@ -208,42 +206,6 @@ export class MetricsTracker { await this.save(); } - /** - * Mark agent as rolled back - */ - async markRolledBack(agentName: string): Promise { - if (!this.data || !this.data.metrics.agents[agentName]) { - return; // Agent not tracked - } - - const agent = this.data.metrics.agents[agentName]!; - agent.status = 'rolled-back'; - agent.rolled_back_at = formatTimestamp(); - - // Recalculate aggregations (exclude rolled-back agents) - this.recalculateAggregations(); - - await this.save(); - } - - /** - * Mark multiple agents as rolled back - */ - async markMultipleRolledBack(agentNames: string[]): Promise { - if (!this.data) return; - - for (const agentName of agentNames) { - if (this.data.metrics.agents[agentName]) { - const agent = this.data.metrics.agents[agentName]!; - agent.status = 'rolled-back'; - agent.rolled_back_at = formatTimestamp(); - } - } - - this.recalculateAggregations(); - await this.save(); - } - /** * Update session status */ @@ -267,7 +229,7 @@ export class MetricsTracker { const agents = this.data.metrics.agents; - // Only count successful agents (not rolled-back or failed) + // Only count successful agents const successfulAgents = Object.entries(agents).filter( ([, data]) => data.status === 'success' ); diff --git a/src/checkpoint-manager.ts b/src/checkpoint-manager.ts deleted file mode 100644 index 5a8e03f..0000000 --- a/src/checkpoint-manager.ts +++ /dev/null @@ -1,936 +0,0 @@ -// 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; - -type LoadPrompt = ( - promptName: string, - variables: { webUrl: string; repoPath: string; sourceDir?: string }, - config: DistributedConfig | null, - pipelineTestingMode: boolean -) => Promise; - -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 => { - 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 => { - 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 => { - 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 = { - '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> = { - '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 => { - // 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, ...(currentSession.outputPath && { outputPath: currentSession.outputPath }) } - ); - - 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 => { - 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 => { - 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 => { - 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 => { - 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, - ...(session.outputPath && { outputPath: session.outputPath }) - }; - 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 => { - 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 => { - 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 => { - 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}`)); - }); - }); -}; diff --git a/src/cli/command-handler.ts b/src/cli/command-handler.ts deleted file mode 100644 index f161b31..0000000 --- a/src/cli/command-handler.ts +++ /dev/null @@ -1,180 +0,0 @@ -// 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 chalk from 'chalk'; -import { - selectSession, deleteSession, deleteAllSessions, - validateAgent, validatePhase, reconcileSession, getSession -} from '../session-manager.js'; -import type { Session } from '../session-manager.js'; -import { - runPhase, runAll, rollbackTo, rerunAgent, displayStatus, listAgents -} from '../checkpoint-manager.js'; -import type { AgentResult } from '../checkpoint-manager.js'; -import { logError, PentestError } from '../error-handling.js'; -import { promptConfirmation } from './prompts.js'; -import type { ChalkInstance } from 'chalk'; -import type { SessionMetadata } from '../audit/utils.js'; -import type { DistributedConfig } from '../types/config.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; - -type LoadPrompt = ( - promptName: string, - variables: { webUrl: string; repoPath: string }, - config: DistributedConfig | null, - pipelineTestingMode: boolean -) => Promise; - -// Developer command handlers -export async function handleDeveloperCommand( - command: string, - args: string[], - pipelineTestingMode: boolean, - runClaudePromptWithRetry: RunClaudePromptWithRetry, - loadPrompt: LoadPrompt -): Promise { - try { - let session: Session | null; - - // Commands that don't require session selection - if (command === '--list-agents') { - listAgents(); - return; - } - - if (command === '--cleanup') { - // Handle cleanup without needing session selection first - if (args[0]) { - // Cleanup specific session by ID - const sessionId = args[0]; - const deletedSession = await deleteSession(sessionId); - console.log(chalk.green(`✅ Deleted session ${sessionId} (${new URL(deletedSession.webUrl).hostname})`)); - } else { - // Cleanup all sessions - require confirmation - const confirmed = await promptConfirmation(chalk.yellow('⚠️ This will delete all pentest sessions. Are you sure? (y/N):')); - if (confirmed) { - const deleted = await deleteAllSessions(); - if (deleted) { - console.log(chalk.green('✅ All sessions deleted')); - } else { - console.log(chalk.yellow('⚠️ No sessions found to delete')); - } - } else { - console.log(chalk.gray('Cleanup cancelled')); - } - } - return; - } - - // Early validation for commands with agent names (before session selection) - - if (command === '--run-phase') { - if (!args[0]) { - console.log(chalk.red('❌ --run-phase requires a phase name')); - console.log(chalk.gray('Usage: shannon --run-phase ')); - process.exit(1); - } - validatePhase(args[0]); // This will throw PentestError if invalid - } - - if (command === '--rollback-to' || command === '--rerun') { - if (!args[0]) { - console.log(chalk.red(`❌ ${command} requires an agent name`)); - console.log(chalk.gray(`Usage: shannon ${command} `)); - process.exit(1); - } - validateAgent(args[0]); // This will throw PentestError if invalid - } - - // Get session for other commands - try { - session = await selectSession(); - } catch (error) { - const errMsg = error instanceof Error ? error.message : String(error); - console.log(chalk.red(`❌ ${errMsg}`)); - process.exit(1); - } - - // Self-healing: Reconcile session with audit logs before executing command - // This ensures Shannon store is consistent with audit data, even after crash recovery - try { - const reconcileReport = await reconcileSession(session.id); - - if (reconcileReport.promotions.length > 0) { - console.log(chalk.blue(`🔄 Reconciled: Added ${reconcileReport.promotions.length} completed agents from audit logs`)); - } - if (reconcileReport.demotions.length > 0) { - console.log(chalk.yellow(`🔄 Reconciled: Removed ${reconcileReport.demotions.length} rolled-back agents`)); - } - if (reconcileReport.failures.length > 0) { - console.log(chalk.yellow(`🔄 Reconciled: Marked ${reconcileReport.failures.length} failed agents`)); - } - - // Reload session after reconciliation to get fresh state - session = await getSession(session.id); - } catch (error) { - // Reconciliation failure is non-critical, but log warning - const errMsg = error instanceof Error ? error.message : String(error); - console.log(chalk.yellow(`⚠️ Failed to reconcile session with audit logs: ${errMsg}`)); - } - - if (!session) { - console.log(chalk.red('❌ Session not found after reconciliation')); - process.exit(1); - } - - switch (command) { - - case '--run-phase': - await runPhase(args[0]!, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); - break; - - case '--run-all': - await runAll(session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); - break; - - case '--rollback-to': - await rollbackTo(args[0]!, session); - break; - - case '--rerun': - await rerunAgent(args[0]!, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); - break; - - case '--status': - await displayStatus(session); - break; - - default: - console.log(chalk.red(`❌ Unknown developer command: ${command}`)); - console.log(chalk.gray('Use --help to see available commands')); - process.exit(1); - } - } catch (error) { - if (error instanceof PentestError) { - await logError(error, `Developer command ${command}`); - console.log(chalk.red.bold(`\n🚨 Command failed: ${error.message}`)); - } else { - const errMsg = error instanceof Error ? error.message : String(error); - console.log(chalk.red.bold(`\n🚨 Unexpected error: ${errMsg}`)); - if (process.env.DEBUG && error instanceof Error) { - console.log(chalk.gray(error.stack)); - } - } - process.exit(1); - } -} diff --git a/src/cli/prompts.ts b/src/cli/prompts.ts deleted file mode 100644 index 4d02be9..0000000 --- a/src/cli/prompts.ts +++ /dev/null @@ -1,60 +0,0 @@ -// 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 { createInterface } from 'readline'; -import { PentestError } from '../error-handling.js'; - -/** - * Prompt user for yes/no confirmation - */ -export async function promptConfirmation(message: string): Promise { - const readline = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - readline.question(message + ' ', (answer) => { - readline.close(); - const confirmed = answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'; - resolve(confirmed); - }); - }); -} - -/** - * Prompt user to select from numbered list - */ -export async function promptSelection(message: string, items: T[]): Promise { - if (!items || items.length === 0) { - throw new PentestError('No items available for selection', 'validation', false); - } - - const readline = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve, reject) => { - readline.question(message + ' ', (answer) => { - readline.close(); - - const choice = parseInt(answer); - if (isNaN(choice) || choice < 1 || choice > items.length) { - reject( - new PentestError( - `Invalid selection. Please enter a number between 1 and ${items.length}`, - 'validation', - false, - { choice: answer } - ) - ); - } else { - resolve(items[choice - 1]!); - } - }); - }); -} diff --git a/src/cli/ui.ts b/src/cli/ui.ts index 07f49ba..b059457 100644 --- a/src/cli/ui.ts +++ b/src/cli/ui.ts @@ -12,22 +12,8 @@ export function showHelp(): void { console.log(chalk.cyan.bold('AI Penetration Testing Agent')); console.log(chalk.gray('Automated security assessment tool\n')); - console.log(chalk.yellow.bold('NORMAL MODE (Creates Sessions):')); - console.log( - ' shannon [--config config.yaml] [--pipeline-testing]' - ); - console.log( - ' shannon --setup-only # Setup local repo and create session only\n' - ); - - console.log(chalk.yellow.bold('DEVELOPER MODE (Operates on Existing Sessions):')); - console.log(' shannon --run-phase [--pipeline-testing]'); - console.log(' shannon --run-all [--pipeline-testing]'); - console.log(' shannon --rollback-to '); - console.log(' shannon --rerun [--pipeline-testing]'); - console.log(' shannon --status'); - console.log(' shannon --list-agents'); - console.log(' shannon --cleanup [session-id] # Delete sessions\n'); + console.log(chalk.yellow.bold('USAGE:')); + console.log(' shannon [--config config.yaml] [--output /path/to/reports]\n'); console.log(chalk.yellow.bold('OPTIONS:')); console.log( @@ -40,42 +26,20 @@ export function showHelp(): void { ' --pipeline-testing Use minimal prompts for fast pipeline testing (creates minimal deliverables)' ); console.log( - ' --disable-loader Disable the animated progress loader (useful when logs interfere with spinner)\n' + ' --disable-loader Disable the animated progress loader (useful when logs interfere with spinner)' ); - - console.log(chalk.yellow.bold('DEVELOPER COMMANDS:')); - console.log( - ' --run-phase Run all agents in a phase (parallel execution for 5x speedup)' - ); - console.log(' --run-all Run all remaining agents to completion (parallel execution)'); - console.log(' --rollback-to Rollback git workspace to agent checkpoint'); - console.log(' --rerun Rollback and rerun specific agent'); - console.log(' --status Show current session status and progress'); - console.log(' --list-agents List all available agents and phases'); - console.log(' --cleanup Delete all sessions or specific session by ID\n'); + console.log(' --help Show this help message\n'); console.log(chalk.yellow.bold('EXAMPLES:')); - console.log(' # Normal mode - create new session'); console.log(' shannon "https://example.com" "/path/to/local/repo"'); console.log(' shannon "https://example.com" "/path/to/local/repo" --config auth.yaml'); console.log(' shannon "https://example.com" "/path/to/local/repo" --output /path/to/reports'); - console.log( - ' shannon "https://example.com" "/path/to/local/repo" --setup-only # Setup only\n' - ); - - console.log(' # Developer mode - operate on existing session'); - console.log(' shannon --status # Show session status'); - console.log(' shannon --run-phase exploitation # Run entire phase'); - console.log(' shannon --run-all # Run all remaining agents'); - console.log(' shannon --rerun xss-vuln # Fix and rerun failed agent'); - console.log(' shannon --cleanup # Delete all sessions'); - console.log(' shannon --cleanup # Delete specific session\n'); + console.log(' shannon "https://example.com" "/path/to/local/repo" --pipeline-testing\n'); console.log(chalk.yellow.bold('REQUIREMENTS:')); console.log(' • WEB_URL must start with http:// or https://'); console.log(' • REPO_PATH must be an accessible local directory'); - console.log(' • Only test systems you own or have permission to test'); - console.log(' • Developer mode requires existing pentest session\n'); + console.log(' • Only test systems you own or have permission to test\n'); console.log(chalk.yellow.bold('ENVIRONMENT VARIABLES:')); console.log(' PENTEST_MAX_RETRIES Number of retries for AI agents (default: 3)'); diff --git a/src/phases/pre-recon.ts b/src/phases/pre-recon.ts index f3ba1e2..34f1580 100644 --- a/src/phases/pre-recon.ts +++ b/src/phases/pre-recon.ts @@ -6,7 +6,7 @@ import { $, fs, path } from 'zx'; import chalk from 'chalk'; -import { Timer, timingResults } from '../utils/metrics.js'; +import { Timer } from '../utils/metrics.js'; import { formatDuration } from '../audit/utils.js'; import { handleToolError, PentestError } from '../error-handling.js'; import { AGENTS } from '../session-manager.js'; @@ -14,7 +14,14 @@ import { runClaudePromptWithRetry } from '../ai/claude-executor.js'; import { loadPrompt } from '../prompts/prompt-manager.js'; import type { ToolAvailability } from '../tool-checker.js'; import type { DistributedConfig } from '../types/config.js'; -import type { AgentResult } from '../checkpoint-manager.js'; + +interface AgentResult { + success: boolean; + duration: number; + cost?: number; + error?: string; + retryable?: boolean; +} type ToolName = 'nmap' | 'subfinder' | 'whatweb' | 'schemathesis'; type ToolStatus = 'success' | 'skipped' | 'error'; @@ -61,7 +68,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string const nmapHostname = new URL(target).hostname; result = await $({ silent: true, stdio: ['ignore', 'pipe', 'ignore'] })`nmap -sV -sC ${nmapHostname}`; const duration = timer.stop(); - timingResults.commands[tool] = duration; console.log(chalk.green(` ✅ ${tool} completed in ${formatDuration(duration)}`)); return { tool: 'nmap', output: result.stdout, status: 'success', duration }; } @@ -70,7 +76,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string const hostname = new URL(target).hostname; result = await $({ silent: true, stdio: ['ignore', 'pipe', 'ignore'] })`subfinder -d ${hostname}`; const subfinderDuration = timer.stop(); - timingResults.commands[tool] = subfinderDuration; console.log(chalk.green(` ✅ ${tool} completed in ${formatDuration(subfinderDuration)}`)); return { tool: 'subfinder', output: result.stdout, status: 'success', duration: subfinderDuration }; } @@ -80,7 +85,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string console.log(chalk.gray(` Command: ${command}`)); result = await $({ silent: true, stdio: ['ignore', 'pipe', 'ignore'] })`whatweb --open-timeout 30 --read-timeout 60 ${target}`; const whatwebDuration = timer.stop(); - timingResults.commands[tool] = whatwebDuration; console.log(chalk.green(` ✅ ${tool} completed in ${formatDuration(whatwebDuration)}`)); return { tool: 'whatweb', output: result.stdout, status: 'success', duration: whatwebDuration }; } @@ -107,7 +111,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string } const schemaDuration = timer.stop(); - timingResults.commands[tool] = schemaDuration; console.log(chalk.green(` ✅ ${tool} completed in ${formatDuration(schemaDuration)}`)); return { tool: 'schemathesis', output: allResults.join('\n\n'), status: 'success', duration: schemaDuration }; } else { @@ -124,7 +127,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string } } catch (error) { const duration = timer.stop(); - timingResults.commands[tool] = duration; console.log(chalk.red(` ❌ ${tool} failed in ${formatDuration(duration)}`)); return handleToolError(tool, error as Error & { code?: string }) as TerminalScanResult; } diff --git a/src/session-manager.ts b/src/session-manager.ts index 00b3257..fbf6d4d 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -4,809 +4,112 @@ // 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 from 'chalk'; -import crypto from 'crypto'; -import { PentestError } from './error-handling.js'; -import { SessionMutex } from './utils/concurrency.js'; -import { promptSelection } from './cli/prompts.js'; -import type { AgentName, PhaseName } from './types/index.js'; -import type { SessionMetadata } from './audit/utils.js'; - -// Audit data types for reconciliation -interface AuditAgentData { - status: 'in-progress' | 'success' | 'failed' | 'rolled-back'; - checkpoint?: string; -} - -interface AuditMetricsData { - metrics: { - agents: Record; - }; -} +import { path } from 'zx'; +import type { AgentName } from './types/index.js'; // Agent definition interface export interface AgentDefinition { name: AgentName; displayName: string; - phase: PhaseName; - order: number; prerequisites: AgentName[]; } -// Session interface -export interface Session { - id: string; - webUrl: string; - repoPath: string; - configFile: string | null; - targetRepo: string; - outputPath: string | null; - status: 'in-progress' | 'completed' | 'failed'; - completedAgents: AgentName[]; - failedAgents: AgentName[]; - checkpoints: Record; - createdAt: string; - lastActivity: string; -} +// Agent definitions according to PRD +export const AGENTS: Readonly> = Object.freeze({ + 'pre-recon': { + name: 'pre-recon', + displayName: 'Pre-recon agent', + prerequisites: [] + }, + 'recon': { + name: 'recon', + displayName: 'Recon agent', + prerequisites: ['pre-recon'] + }, + 'injection-vuln': { + name: 'injection-vuln', + displayName: 'Injection vuln agent', + prerequisites: ['recon'] + }, + 'xss-vuln': { + name: 'xss-vuln', + displayName: 'XSS vuln agent', + prerequisites: ['recon'] + }, + 'auth-vuln': { + name: 'auth-vuln', + displayName: 'Auth vuln agent', + prerequisites: ['recon'] + }, + 'ssrf-vuln': { + name: 'ssrf-vuln', + displayName: 'SSRF vuln agent', + prerequisites: ['recon'] + }, + 'authz-vuln': { + name: 'authz-vuln', + displayName: 'Authz vuln agent', + prerequisites: ['recon'] + }, + 'injection-exploit': { + name: 'injection-exploit', + displayName: 'Injection exploit agent', + prerequisites: ['injection-vuln'] + }, + 'xss-exploit': { + name: 'xss-exploit', + displayName: 'XSS exploit agent', + prerequisites: ['xss-vuln'] + }, + 'auth-exploit': { + name: 'auth-exploit', + displayName: 'Auth exploit agent', + prerequisites: ['auth-vuln'] + }, + 'ssrf-exploit': { + name: 'ssrf-exploit', + displayName: 'SSRF exploit agent', + prerequisites: ['ssrf-vuln'] + }, + 'authz-exploit': { + name: 'authz-exploit', + displayName: 'Authz exploit agent', + prerequisites: ['authz-vuln'] + }, + 'report': { + name: 'report', + displayName: 'Report agent', + prerequisites: ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'] + } +}); -// Session store interface -interface SessionStore { - sessions: Record; -} +// Agent execution order +export const AGENT_ORDER: readonly AgentName[] = Object.freeze([ + 'pre-recon', + 'recon', + 'injection-vuln', + 'xss-vuln', + 'auth-vuln', + 'ssrf-vuln', + 'authz-vuln', + 'injection-exploit', + 'xss-exploit', + 'auth-exploit', + 'ssrf-exploit', + 'authz-exploit', + 'report' +] as const); -// Session status result -export interface SessionStatusResult { - status: 'in-progress' | 'completed' | 'failed'; - completedCount: number; - totalAgents: number; - failedCount: number; - completionPercentage: number; -} +// Parallel execution groups +export const getParallelGroups = (): Readonly<{ vuln: AgentName[]; exploit: AgentName[] }> => Object.freeze({ + vuln: ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln'], + exploit: ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'] +}); -// Reconciliation report -interface ReconciliationReport { - promotions: string[]; - demotions: string[]; - failures: string[]; -} - -// Generate a session-based log folder path -// NEW FORMAT: {hostname}_{sessionId} (no hash, full UUID for consistency with audit system) +// Generate a session-based log folder path (used by claude-executor.ts) export const generateSessionLogPath = (webUrl: string, sessionId: string): string => { const hostname = new URL(webUrl).hostname.replace(/[^a-zA-Z0-9-]/g, '-'); const sessionFolderName = `${hostname}_${sessionId}`; return path.join(process.cwd(), 'agent-logs', sessionFolderName); }; -const sessionMutex = new SessionMutex(); - -// Agent definitions according to PRD -export const AGENTS: Readonly> = Object.freeze({ - // Phase 1 - Pre-reconnaissance - 'pre-recon': { - name: 'pre-recon', - displayName: 'Pre-recon agent', - phase: 'pre-reconnaissance', - order: 1, - prerequisites: [] - }, - - // Phase 2 - Reconnaissance - 'recon': { - name: 'recon', - displayName: 'Recon agent', - phase: 'reconnaissance', - order: 2, - prerequisites: ['pre-recon'] - }, - - // Phase 3 - Vulnerability Analysis - 'injection-vuln': { - name: 'injection-vuln', - displayName: 'Injection vuln agent', - phase: 'vulnerability-analysis', - order: 3, - prerequisites: ['recon'] - }, - 'xss-vuln': { - name: 'xss-vuln', - displayName: 'XSS vuln agent', - phase: 'vulnerability-analysis', - order: 4, - prerequisites: ['recon'] - }, - 'auth-vuln': { - name: 'auth-vuln', - displayName: 'Auth vuln agent', - phase: 'vulnerability-analysis', - order: 5, - prerequisites: ['recon'] - }, - 'ssrf-vuln': { - name: 'ssrf-vuln', - displayName: 'SSRF vuln agent', - phase: 'vulnerability-analysis', - order: 6, - prerequisites: ['recon'] - }, - 'authz-vuln': { - name: 'authz-vuln', - displayName: 'Authz vuln agent', - phase: 'vulnerability-analysis', - order: 7, - prerequisites: ['recon'] - }, - - // Phase 4 - Exploitation - 'injection-exploit': { - name: 'injection-exploit', - displayName: 'Injection exploit agent', - phase: 'exploitation', - order: 8, - prerequisites: ['injection-vuln'] - }, - 'xss-exploit': { - name: 'xss-exploit', - displayName: 'XSS exploit agent', - phase: 'exploitation', - order: 9, - prerequisites: ['xss-vuln'] - }, - 'auth-exploit': { - name: 'auth-exploit', - displayName: 'Auth exploit agent', - phase: 'exploitation', - order: 10, - prerequisites: ['auth-vuln'] - }, - 'ssrf-exploit': { - name: 'ssrf-exploit', - displayName: 'SSRF exploit agent', - phase: 'exploitation', - order: 11, - prerequisites: ['ssrf-vuln'] - }, - 'authz-exploit': { - name: 'authz-exploit', - displayName: 'Authz exploit agent', - phase: 'exploitation', - order: 12, - prerequisites: ['authz-vuln'] - }, - - // Phase 5 - Reporting - 'report': { - name: 'report', - displayName: 'Report agent', - phase: 'reporting', - order: 13, - prerequisites: ['authz-exploit'] - } -}); - -// Phase definitions -export const PHASES: Readonly> = Object.freeze({ - 'pre-reconnaissance': ['pre-recon'], - 'reconnaissance': ['recon'], - 'vulnerability-analysis': ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln'], - 'exploitation': ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'], - 'reporting': ['report'] -}); - -// Session store file path -const STORE_FILE = path.join(process.cwd(), '.shannon-store.json'); - -// Load sessions from store file -const loadSessions = async (): Promise => { - try { - if (!await fs.pathExists(STORE_FILE)) { - return { sessions: {} }; - } - - const content = await fs.readFile(STORE_FILE, 'utf8'); - const store = JSON.parse(content) as unknown; - - // Validate store structure - if (!store || typeof store !== 'object' || !('sessions' in store)) { - console.log(chalk.yellow('⚠️ Invalid session store format, creating new store')); - return { sessions: {} }; - } - - return store as SessionStore; - } catch (error) { - const errMsg = error instanceof Error ? error.message : String(error); - console.log(chalk.yellow(`⚠️ Failed to load session store: ${errMsg}, creating new store`)); - return { sessions: {} }; - } -}; - -// Save sessions to store file atomically -const saveSessions = async (store: SessionStore): Promise => { - try { - const tempFile = `${STORE_FILE}.tmp`; - await fs.writeJSON(tempFile, store, { spaces: 2 }); - await fs.move(tempFile, STORE_FILE, { overwrite: true }); - } catch (error) { - const errMsg = error instanceof Error ? error.message : String(error); - throw new PentestError( - `Failed to save session store: ${errMsg}`, - 'filesystem', - false, - { storeFile: STORE_FILE, originalError: errMsg } - ); - } -}; - -// Find existing session for the same web URL and repository path -const findExistingSession = async (webUrl: string, targetRepo: string): Promise => { - const store = await loadSessions(); - const sessions = Object.values(store.sessions); - - // Normalize paths for comparison - const normalizedTargetRepo = path.resolve(targetRepo); - - // Look for existing session with same webUrl and targetRepo - const existingSession = sessions.find(session => { - const normalizedSessionRepo = path.resolve(session.targetRepo || session.repoPath); - return session.webUrl === webUrl && normalizedSessionRepo === normalizedTargetRepo; - }); - - return existingSession; -}; - -// Generate session ID as unique UUID -const generateSessionId = (): string => { - // Always generate a unique UUID for each session - return crypto.randomUUID(); -}; - -// Create new session or return existing one -export const createSession = async ( - webUrl: string, - repoPath: string, - configFile: string | null = null, - targetRepo: string | null = null, - outputPath: string | null = null -): Promise => { - // Use targetRepo if provided, otherwise use repoPath - const resolvedTargetRepo = targetRepo || repoPath; - - // Check for existing session first - const existingSession = await findExistingSession(webUrl, resolvedTargetRepo); - - if (existingSession) { - // If session is not completed, reuse it - if (existingSession.status !== 'completed') { - console.log(chalk.blue(`📝 Reusing existing session: ${existingSession.id.substring(0, 8)}...`)); - console.log(chalk.gray(` Progress: ${existingSession.completedAgents.length}/${Object.keys(AGENTS).length} agents completed`)); - - // Update last activity timestamp and outputPath if provided - await updateSession(existingSession.id, { - lastActivity: new Date().toISOString(), - ...(outputPath && { outputPath }) - }); - return { ...existingSession, ...(outputPath && { outputPath }) }; - } - - // If completed, create a new session (allows re-running after completion) - console.log(chalk.gray(`Previous session was completed, creating new session...`)); - } - - const sessionId = generateSessionId(); - - // STANDARD: All sessions use 'id' field (NOT 'sessionId') - // This is the canonical session structure used throughout the codebase - const session: Session = { - id: sessionId, - webUrl, - repoPath, - configFile, - targetRepo: resolvedTargetRepo, - outputPath, - status: 'in-progress', - completedAgents: [], - failedAgents: [], - checkpoints: {} as Record, - createdAt: new Date().toISOString(), - lastActivity: new Date().toISOString() - }; - - const store = await loadSessions(); - store.sessions[sessionId] = session; - await saveSessions(store); - - return session; -}; - -// Get session by ID -export const getSession = async (sessionId: string): Promise => { - const store = await loadSessions(); - return store.sessions[sessionId] || null; -}; - -// Update session -export const updateSession = async ( - sessionId: string, - updates: Partial -): Promise => { - const store = await loadSessions(); - - if (!store.sessions[sessionId]) { - throw new PentestError( - `Session ${sessionId} not found`, - 'validation', - false, - { sessionId } - ); - } - - store.sessions[sessionId] = { - ...store.sessions[sessionId]!, - ...updates, - lastActivity: new Date().toISOString() - }; - - await saveSessions(store); - return store.sessions[sessionId]!; -}; - -// List all sessions -const listSessions = async (): Promise => { - const store = await loadSessions(); - return Object.values(store.sessions); -}; - -// Interactive session selection -export const selectSession = async (): Promise => { - const sessions = await listSessions(); - - if (sessions.length === 0) { - throw new PentestError( - 'No pentest sessions found. Run a normal pentest first to create a session.', - 'validation', - false - ); - } - - if (sessions.length === 1) { - return sessions[0]!; - } - - // Display session options - console.log(chalk.cyan('\nMultiple pentest sessions found:\n')); - - sessions.forEach((session, index) => { - const completedCount = session.completedAgents.length; - const totalAgents = Object.keys(AGENTS).length; - const timeAgo = getTimeAgo(session.lastActivity); - - // Use dynamic status calculation instead of stored status - const { status } = getSessionStatus(session); - const statusColor = status === 'completed' ? chalk.green : chalk.blue; - - console.log(statusColor(`${index + 1}) ${new URL(session.webUrl).hostname} + ${path.basename(session.repoPath)} [${status}]`)); - console.log(chalk.gray(` Last activity: ${timeAgo}, Completed: ${completedCount}/${totalAgents} agents`)); - console.log(chalk.gray(` Session ID: ${session.id}`)); - - if (session.configFile) { - console.log(chalk.gray(` Config: ${session.configFile}`)); - } - - console.log(); // Empty line between sessions - }); - - // Get user selection - return await promptSelection( - chalk.cyan(`Select session (1-${sessions.length}):`), - sessions - ); -}; - -// Validate agent name -export const validateAgent = (agentName: string): AgentDefinition => { - const agent = AGENTS[agentName as AgentName]; - if (!agent) { - throw new PentestError( - `Agent '${agentName}' not recognized. Use --list-agents to see valid names.`, - 'validation', - false, - { agentName, validAgents: Object.keys(AGENTS) } - ); - } - return agent; -}; - -// Validate agent range -export const validateAgentRange = (startAgent: string, endAgent: string): AgentDefinition[] => { - const start = validateAgent(startAgent); - const end = validateAgent(endAgent); - - if (start.order >= end.order) { - throw new PentestError( - `End agent '${endAgent}' must come after start agent '${startAgent}' in sequence.`, - 'validation', - false, - { startAgent, endAgent, startOrder: start.order, endOrder: end.order } - ); - } - - // Get all agents in range - const agentList = Object.values(AGENTS) - .filter(agent => agent.order >= start.order && agent.order <= end.order) - .sort((a, b) => a.order - b.order); - - return agentList; -}; - -// Validate phase name -export const validatePhase = (phaseName: string): AgentDefinition[] => { - const phase = PHASES[phaseName as PhaseName]; - if (!phase) { - throw new PentestError( - `Phase '${phaseName}' not recognized. Valid phases: ${Object.keys(PHASES).join(', ')}`, - 'validation', - false, - { phaseName, validPhases: Object.keys(PHASES) } - ); - } - return phase.map(agentName => AGENTS[agentName]!); -}; - -// Check prerequisites for an agent -export const checkPrerequisites = (session: Session, agentName: string): boolean => { - const agent = validateAgent(agentName); - - const missingPrereqs = agent.prerequisites.filter(prereq => - !session.completedAgents.includes(prereq) - ); - - if (missingPrereqs.length > 0) { - throw new PentestError( - `Cannot run '${agentName}': prerequisite agent(s) not completed: ${missingPrereqs.join(', ')}`, - 'validation', - false, - { agentName, missingPrerequisites: missingPrereqs, completedAgents: session.completedAgents } - ); - } - - return true; -}; - -// Get next suggested agent -export const getNextAgent = (session: Session): AgentDefinition | undefined => { - const completed = new Set(session.completedAgents); - - // Find the next agent that hasn't been completed and has all prerequisites - const nextAgent = Object.values(AGENTS) - .sort((a, b) => a.order - b.order) - .find(agent => { - if (completed.has(agent.name)) return false; // Already completed - - // Check if all prerequisites are completed - const prereqsMet = agent.prerequisites.every(prereq => completed.has(prereq)); - return prereqsMet; - }); - - return nextAgent; -}; - -// Mark agent as completed with checkpoint -// NOTE: Timing, cost, and validation data now managed by AuditSession (audit-logs/session.json) -// Shannon store contains ONLY orchestration state (completedAgents, checkpoints) -export const markAgentCompleted = async ( - sessionId: string, - agentName: string, - checkpointCommit: string -): Promise => { - // Use mutex to prevent race conditions during parallel agent execution - const unlock = await sessionMutex.lock(sessionId); - - try { - // Get fresh session data under lock - const session = await getSession(sessionId); - if (!session) { - throw new PentestError(`Session ${sessionId} not found`, 'validation', false); - } - - validateAgent(agentName); - - const updates: Partial = { - completedAgents: [...new Set([...session.completedAgents, agentName as AgentName])], - failedAgents: session.failedAgents.filter(agent => agent !== agentName), - checkpoints: { - ...session.checkpoints, - [agentName]: checkpointCommit - } as Record - }; - - // Check if all agents are now completed and update session status - const totalAgents = Object.keys(AGENTS).length; - if (updates.completedAgents!.length === totalAgents) { - updates.status = 'completed'; - } - - return await updateSession(sessionId, updates); - } finally { - // Always release the lock, even if an error occurs - unlock(); - } -}; - -// Mark agent as failed -export const markAgentFailed = async (sessionId: string, agentName: string): Promise => { - const session = await getSession(sessionId); - if (!session) { - throw new PentestError(`Session ${sessionId} not found`, 'validation', false); - } - - validateAgent(agentName); - - const updates: Partial = { - failedAgents: [...new Set([...session.failedAgents, agentName as AgentName])], - completedAgents: session.completedAgents.filter(agent => agent !== agentName) - }; - - return await updateSession(sessionId, updates); -}; - -// Get time ago helper -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`; - } -}; - -// Get session status summary -export const getSessionStatus = (session: Session): SessionStatusResult => { - const totalAgents = Object.keys(AGENTS).length; - const completedCount = session.completedAgents.length; - const failedCount = session.failedAgents.length; - - let status: 'in-progress' | 'completed' | 'failed'; - if (completedCount === totalAgents) { - status = 'completed'; - } else if (failedCount > 0) { - status = 'failed'; - } else { - status = 'in-progress'; - } - - return { - status, - completedCount, - totalAgents, - failedCount, - completionPercentage: Math.round((completedCount / totalAgents) * 100) - }; -}; - -// Calculate comprehensive summary statistics for vulnerability analysis -export const calculateVulnerabilityAnalysisSummary = (session: Session): Readonly<{ - totalAnalyses: number; - totalVulnerabilities: number; - exploitationCandidates: number; - completedAgents: AgentName[]; -}> => { - const vulnAgents = PHASES['vulnerability-analysis']; - const completedVulnAgents = session.completedAgents.filter(agent => - vulnAgents.includes(agent) - ); - - // NOTE: Actual vulnerability counts require reading queue files - // This summary only shows completion counts - return Object.freeze({ - totalAnalyses: completedVulnAgents.length, - totalVulnerabilities: 0, // Requires reading queue files - exploitationCandidates: 0, // Requires reading queue files - completedAgents: completedVulnAgents - }); -}; - -// Calculate exploitation summary statistics -export const calculateExploitationSummary = (session: Session): Readonly<{ - totalAttempts: number; - eligibleExploits: number; - skippedExploits: number; - completedAgents: AgentName[]; -}> => { - const exploitAgents = PHASES['exploitation']; - const completedExploitAgents = session.completedAgents.filter(agent => - exploitAgents.includes(agent) - ); - - // NOTE: Eligibility requires reading queue files - // This summary only shows completion counts - return Object.freeze({ - totalAttempts: completedExploitAgents.length, - eligibleExploits: 0, // Requires reading queue files - skippedExploits: 0, // Requires reading queue files - completedAgents: completedExploitAgents - }); -}; - -// Rollback session to specific agent checkpoint -export const rollbackToAgent = async ( - sessionId: string, - targetAgent: string -): Promise => { - const session = await getSession(sessionId); - if (!session) { - throw new PentestError(`Session ${sessionId} not found`, 'validation', false); - } - - validateAgent(targetAgent); - - if (!session.checkpoints[targetAgent as AgentName]) { - throw new PentestError( - `No checkpoint found for agent '${targetAgent}' in session history`, - 'validation', - false, - { targetAgent, availableCheckpoints: Object.keys(session.checkpoints) } - ); - } - - // Find agents that need to be removed (those after the target agent) - const targetOrder = AGENTS[targetAgent as AgentName]!.order; - const agentsToRemove = Object.values(AGENTS) - .filter(agent => agent.order > targetOrder) - .map(agent => agent.name); - - const updates: Partial = { - completedAgents: session.completedAgents.filter(agent => !agentsToRemove.includes(agent)), - failedAgents: session.failedAgents.filter(agent => !agentsToRemove.includes(agent)), - checkpoints: Object.fromEntries( - Object.entries(session.checkpoints).filter(([agent]) => !agentsToRemove.includes(agent as AgentName)) - ) as Record - }; - - // NOTE: Timing and cost data now managed in audit-logs/session.json - // Rollback will be reflected via reconcileSession() which marks agents as "rolled-back" - - return await updateSession(sessionId, updates); -}; - -/** - * Reconcile Shannon store with audit logs (self-healing) - * - * This function ensures the Shannon store (.shannon-store.json) is consistent with - * the audit logs (audit-logs/session.json) by syncing agent completion status. - * - * Three-part reconciliation: - * 1. PROMOTIONS: Agents completed/failed in audit → added to Shannon store - * 2. DEMOTIONS: Agents rolled-back in audit → removed from Shannon store - * 3. VERIFICATION: Ensure audit state fully reflected in orchestration - * - * Critical for crash recovery, especially crash during rollback operations. - */ -export const reconcileSession = async (sessionId: string): Promise => { - const { AuditSession } = await import('./audit/index.js'); - - // Get Shannon store session - const shannonSession = await getSession(sessionId); - if (!shannonSession) { - throw new PentestError(`Session ${sessionId} not found in Shannon store`, 'validation', false); - } - - // Get audit session data - cast session to SessionMetadata for compatibility - const sessionMetadata: SessionMetadata = { - id: shannonSession.id, - webUrl: shannonSession.webUrl, - repoPath: shannonSession.repoPath, - }; - const auditSession = new AuditSession(sessionMetadata); - await auditSession.initialize(); - const auditData = await auditSession.getMetrics() as AuditMetricsData; - - const report: ReconciliationReport = { - promotions: [], - demotions: [], - failures: [] - }; - - // PART 1: PROMOTIONS (Additive) - // Find agents completed in audit but not in Shannon store - const auditCompleted = Object.entries(auditData.metrics.agents) - .filter(([, agentData]) => agentData.status === 'success') - .map(([agentName]) => agentName); - - const missing = auditCompleted.filter(agent => !shannonSession.completedAgents.includes(agent as AgentName)); - - for (const agentName of missing) { - const agentData = auditData.metrics.agents[agentName]; - const checkpoint = agentData?.checkpoint || ''; - await markAgentCompleted(sessionId, agentName, checkpoint); - report.promotions.push(agentName); - } - - // PART 2: DEMOTIONS (Subtractive) - CRITICAL FOR ROLLBACK RECOVERY - // Find agents rolled-back in audit but still in Shannon store - const auditRolledBack = Object.entries(auditData.metrics.agents) - .filter(([, agentData]) => agentData.status === 'rolled-back') - .map(([agentName]) => agentName); - - const toRemove = shannonSession.completedAgents.filter(agent => auditRolledBack.includes(agent)); - - if (toRemove.length > 0) { - // Reload session to get fresh state - const freshSession = await getSession(sessionId); - - if (freshSession) { - const updates: Partial = { - completedAgents: freshSession.completedAgents.filter(agent => !toRemove.includes(agent)), - checkpoints: Object.fromEntries( - Object.entries(freshSession.checkpoints).filter(([agent]) => !toRemove.includes(agent as AgentName)) - ) as Record - }; - - await updateSession(sessionId, updates); - report.demotions.push(...toRemove); - } - } - - // PART 3: FAILURES - // Find agents failed in audit but not marked failed in Shannon store - const auditFailed = Object.entries(auditData.metrics.agents) - .filter(([, agentData]) => agentData.status === 'failed') - .map(([agentName]) => agentName); - - const failedToAdd = auditFailed.filter(agent => !shannonSession.failedAgents.includes(agent as AgentName)); - - for (const agentName of failedToAdd) { - await markAgentFailed(sessionId, agentName); - report.failures.push(agentName); - } - - return report; -}; - -// Delete a specific session by ID -export const deleteSession = async (sessionId: string): Promise => { - const store = await loadSessions(); - - if (!store.sessions[sessionId]) { - throw new PentestError( - `Session ${sessionId} not found`, - 'validation', - false, - { sessionId } - ); - } - - const deletedSession = store.sessions[sessionId]!; - delete store.sessions[sessionId]; - await saveSessions(store); - - return deletedSession; -}; - -// Delete all sessions (remove entire storage) -export const deleteAllSessions = async (): Promise => { - try { - if (await fs.pathExists(STORE_FILE)) { - await fs.remove(STORE_FILE); - return true; - } - return false; // File didn't exist - } catch (error) { - const errMsg = error instanceof Error ? error.message : String(error); - throw new PentestError( - `Failed to delete session storage: ${errMsg}`, - 'filesystem', - false, - { storeFile: STORE_FILE, originalError: errMsg } - ); - } -}; diff --git a/src/shannon.ts b/src/shannon.ts index 54974c6..e493047 100644 --- a/src/shannon.ts +++ b/src/shannon.ts @@ -6,7 +6,7 @@ // as published by the Free Software Foundation. import { path, fs, $ } from 'zx'; -import chalk from 'chalk'; +import chalk, { type ChalkInstance } from 'chalk'; import dotenv from 'dotenv'; dotenv.config(); @@ -15,11 +15,9 @@ dotenv.config(); import { parseConfig, distributeConfig } from './config-parser.js'; import { checkToolAvailability, handleMissingTools } from './tool-checker.js'; -// Session and Checkpoints -import { createSession, updateSession, getSession, AGENTS } from './session-manager.js'; -import type { Session } from './session-manager.js'; -import type { AgentName } from './types/index.js'; -import { runPhase, getGitCommitHash } from './checkpoint-manager.js'; +// Session +import { AGENTS, getParallelGroups } from './session-manager.js'; +import type { AgentName, PromptName } from './types/index.js'; // Setup and Deliverables import { setupLocalRepo } from './setup/environment.js'; @@ -33,56 +31,173 @@ import { executePreReconPhase } from './phases/pre-recon.js'; import { assembleFinalReport } from './phases/reporting.js'; // Utils -import { timingResults, costResults, displayTimingSummary, Timer } from './utils/metrics.js'; +import { timingResults, displayTimingSummary, Timer } from './utils/metrics.js'; import { formatDuration, generateAuditPath } from './audit/utils.js'; +import type { SessionMetadata } from './audit/utils.js'; +import { AuditSession } from './audit/audit-session.js'; // CLI -import { handleDeveloperCommand } from './cli/command-handler.js'; import { showHelp, displaySplashScreen } from './cli/ui.js'; import { validateWebUrl, validateRepoPath } from './cli/input-validator.js'; // Error Handling import { PentestError, logError } from './error-handling.js'; -// Session Manager Functions -import { - calculateVulnerabilityAnalysisSummary, - calculateExploitationSummary, - getNextAgent -} from './session-manager.js'; - import type { DistributedConfig } from './types/config.js'; import type { ToolAvailability } from './tool-checker.js'; +import { safeValidateQueueAndDeliverable } from './queue-validation.js'; // Extend global namespace for SHANNON_DISABLE_LOADER declare global { var SHANNON_DISABLE_LOADER: boolean | undefined; } +// Session Lock File Management +const STORE_PATH = path.join(process.cwd(), '.shannon-store.json'); + +interface Session { + id: string; + webUrl: string; + repoPath: string; + status: 'in-progress' | 'completed' | 'failed'; + startedAt: string; +} + +interface SessionStore { + sessions: Session[]; +} + +function generateSessionId(): string { + return crypto.randomUUID(); +} + +async function loadSessions(): Promise { + try { + if (await fs.pathExists(STORE_PATH)) { + return await fs.readJson(STORE_PATH) as SessionStore; + } + } catch { + // Corrupted file, start fresh + } + return { sessions: [] }; +} + +async function saveSessions(store: SessionStore): Promise { + await fs.writeJson(STORE_PATH, store, { spaces: 2 }); +} + +async function createSession(webUrl: string, repoPath: string): Promise { + const store = await loadSessions(); + + // Check for existing in-progress session + const existing = store.sessions.find( + s => s.repoPath === repoPath && s.status === 'in-progress' + ); + if (existing) { + throw new PentestError( + `Session already in progress for ${repoPath}`, + 'validation', + false, + { sessionId: existing.id } + ); + } + + const session: Session = { + id: generateSessionId(), + webUrl, + repoPath, + status: 'in-progress', + startedAt: new Date().toISOString() + }; + + store.sessions.push(session); + await saveSessions(store); + return session; +} + +async function updateSessionStatus( + sessionId: string, + status: 'in-progress' | 'completed' | 'failed' +): Promise { + const store = await loadSessions(); + const session = store.sessions.find(s => s.id === sessionId); + if (session) { + session.status = status; + await saveSessions(store); + } +} + interface PromptVariables { webUrl: string; repoPath: string; sourceDir: string; } -interface SessionUpdates { - completedAgents?: AgentName[]; - failedAgents?: AgentName[]; - status?: 'in-progress' | 'completed' | 'failed'; - checkpoints?: Record; -} - interface MainResult { reportPath: string; auditLogsPath: string; } +interface AgentResult { + success: boolean; + duration: number; + cost?: number; + error?: string; + retryable?: boolean; +} + +interface ParallelAgentResult { + agentName: AgentName; + success: boolean; + timing?: number | undefined; + cost?: number | undefined; + attempts: number; + error?: string | undefined; +} + // Configure zx to disable timeouts (let tools run as long as needed) $.timeout = 0; +// Helper function to get prompt name from agent name +const getPromptName = (agentName: AgentName): PromptName => { + const mappings: Record = { + '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> = { + '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; +}; + /** * Consolidate deliverables from target repo into the session folder - * Copies deliverables directory from source repo to session audit path */ async function consolidateOutputs(sourceDir: string, sessionPath: string): Promise { const srcDeliverables = path.join(sourceDir, 'deliverables'); @@ -101,6 +216,316 @@ async function consolidateOutputs(sourceDir: string, sessionPath: string): Promi } } +/** + * Run a single agent + */ +async function runAgent( + agentName: AgentName, + sourceDir: string, + variables: PromptVariables, + distributedConfig: DistributedConfig | null, + pipelineTestingMode: boolean, + sessionMetadata: SessionMetadata +): Promise { + const agent = AGENTS[agentName]; + const promptName = getPromptName(agentName); + const prompt = await loadPrompt(promptName, variables, distributedConfig, pipelineTestingMode); + + return await runClaudePromptWithRetry( + prompt, + sourceDir, + '*', + '', + agent.displayName, + agentName, + getAgentColor(agentName), + sessionMetadata + ); +} + +/** + * Run vulnerability agents in parallel + */ +async function runParallelVuln( + sourceDir: string, + variables: PromptVariables, + distributedConfig: DistributedConfig | null, + pipelineTestingMode: boolean, + sessionMetadata: SessionMetadata +): Promise { + const { vuln: vulnAgents } = getParallelGroups(); + + console.log(chalk.cyan(`\nStarting ${vulnAgents.length} vulnerability analysis specialists in parallel...`)); + console.log(chalk.gray(' Specialists: ' + vulnAgents.join(', '))); + console.log(); + + const startTime = Date.now(); + + const results = await Promise.allSettled( + vulnAgents.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 runAgent( + agentName, + sourceDir, + variables, + distributedConfig, + pipelineTestingMode, + sessionMetadata + ); + + // Validate vulnerability analysis results + const vulnType = agentName.replace('-vuln', ''); + try { + const validation = await safeValidateQueueAndDeliverable(vulnType as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz', sourceDir); + + if (validation.success && validation.data) { + console.log(chalk.blue(`${agentName}: ${validation.data.shouldExploit ? `Ready for exploitation (${validation.data.vulnerabilityCount} vulnerabilities)` : 'No vulnerabilities found'}`)); + } + } catch { + // Validation failure is non-critical + } + + return { + agentName, + success: result.success, + timing: result.duration, + cost: result.cost, + attempts + }; + } catch (error) { + lastError = error as Error; + if (attempts < maxAttempts) { + console.log(chalk.yellow(`Warning: ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`)); + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + } + + return { + agentName, + success: false, + attempts, + error: lastError?.message || 'Unknown error' + }; + }) + ); + + const totalDuration = Date.now() - startTime; + + // Process and display results + console.log(chalk.cyan('\nVulnerability Analysis Results')); + console.log(chalk.gray('-'.repeat(80))); + console.log(chalk.bold('Agent Status Attempt Duration Cost')); + console.log(chalk.gray('-'.repeat(80))); + + const processedResults: ParallelAgentResult[] = []; + + results.forEach((result, index) => { + const agentName = vulnAgents[index]!; + const agentDisplay = agentName.padEnd(22); + + if (result.status === 'fulfilled') { + const data = result.value; + processedResults.push(data); + + if (data.success) { + const duration = formatDuration(data.timing || 0); + const cost = `$${(data.cost || 0).toFixed(4)}`; + + console.log( + `${chalk.green(agentDisplay)} ${chalk.green('Success')} ` + + `${data.attempts}/3 ${duration.padEnd(11)} ${cost}` + ); + } else { + console.log( + `${chalk.red(agentDisplay)} ${chalk.red('Failed ')} ` + + `${data.attempts}/3 - -` + ); + if (data.error) { + console.log(chalk.gray(` Error: ${data.error.substring(0, 60)}...`)); + } + } + } else { + processedResults.push({ + agentName, + success: false, + attempts: 3, + error: String(result.reason) + }); + + console.log( + `${chalk.red(agentDisplay)} ${chalk.red('Failed ')} ` + + `3/3 - -` + ); + } + }); + + console.log(chalk.gray('-'.repeat(80))); + const successCount = processedResults.filter(r => r.success).length; + console.log(chalk.cyan(`Summary: ${successCount}/${vulnAgents.length} succeeded in ${formatDuration(totalDuration)}`)); + + return processedResults; +} + +/** + * Run exploitation agents in parallel + */ +async function runParallelExploit( + sourceDir: string, + variables: PromptVariables, + distributedConfig: DistributedConfig | null, + pipelineTestingMode: boolean, + sessionMetadata: SessionMetadata +): Promise { + const { exploit: exploitAgents, vuln: vulnAgents } = getParallelGroups(); + + // 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; + const vulnType = vulnAgentName.replace('-vuln', '') as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz'; + + const validation = await safeValidateQueueAndDeliverable(vulnType, sourceDir); + + 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); + + if (eligibleAgents.length === 0) { + console.log(chalk.gray('No exploitation agents eligible (no vulnerabilities found)')); + return []; + } + + console.log(chalk.cyan(`\nStarting ${eligibleAgents.length} exploitation specialists in parallel...`)); + console.log(chalk.gray(' Specialists: ' + eligibleAgents.join(', '))); + console.log(); + + const startTime = Date.now(); + + const results = await Promise.allSettled( + eligibleAgents.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 runAgent( + agentName, + sourceDir, + variables, + distributedConfig, + pipelineTestingMode, + sessionMetadata + ); + + return { + agentName, + success: result.success, + timing: result.duration, + cost: result.cost, + attempts + }; + } catch (error) { + lastError = error as Error; + if (attempts < maxAttempts) { + console.log(chalk.yellow(`Warning: ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`)); + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + } + + return { + agentName, + success: false, + attempts, + error: lastError?.message || 'Unknown error' + }; + }) + ); + + const totalDuration = Date.now() - startTime; + + // Process and display results + console.log(chalk.cyan('\nExploitation Results')); + console.log(chalk.gray('-'.repeat(80))); + console.log(chalk.bold('Agent Status Attempt Duration Cost')); + console.log(chalk.gray('-'.repeat(80))); + + const processedResults: ParallelAgentResult[] = []; + + results.forEach((result, index) => { + const agentName = eligibleAgents[index]!; + const agentDisplay = agentName.padEnd(22); + + if (result.status === 'fulfilled') { + const data = result.value; + processedResults.push(data); + + if (data.success) { + const duration = formatDuration(data.timing || 0); + const cost = `$${(data.cost || 0).toFixed(4)}`; + + console.log( + `${chalk.green(agentDisplay)} ${chalk.green('Success')} ` + + `${data.attempts}/3 ${duration.padEnd(11)} ${cost}` + ); + } else { + console.log( + `${chalk.red(agentDisplay)} ${chalk.red('Failed ')} ` + + `${data.attempts}/3 - -` + ); + if (data.error) { + console.log(chalk.gray(` Error: ${data.error.substring(0, 60)}...`)); + } + } + } else { + processedResults.push({ + agentName, + success: false, + attempts: 3, + error: String(result.reason) + }); + + console.log( + `${chalk.red(agentDisplay)} ${chalk.red('Failed ')} ` + + `3/3 - -` + ); + } + }); + + console.log(chalk.gray('-'.repeat(80))); + const successCount = processedResults.filter(r => r.success).length; + console.log(chalk.cyan(`Summary: ${successCount}/${eligibleAgents.length} succeeded in ${formatDuration(totalDuration)}`)); + + return processedResults; +} + // Setup graceful cleanup on process signals process.on('SIGINT', async () => { console.log(chalk.yellow('\n⚠️ Received SIGINT, cleaning up...')); @@ -190,40 +615,16 @@ async function main( const variables: PromptVariables = { webUrl, repoPath, sourceDir }; - // Create session for tracking (in normal mode) - const session: Session = await createSession(webUrl, repoPath, configPath, sourceDir, outputPath); - console.log(chalk.blue(`📝 Session created: ${session.id.substring(0, 8)}...`)); + // Create session (acts as lock file) + const session: Session = await createSession(webUrl, repoPath); + console.log(chalk.blue(`Session created: ${session.id.substring(0, 8)}...`)); - // If setup-only mode, exit after session creation - if (process.argv.includes('--setup-only')) { - console.log(chalk.green('✅ Setup complete! Local repository setup and session created.')); - console.log(chalk.gray('Use developer commands to run individual agents:')); - console.log(chalk.gray(' shannon --run-agent pre-recon')); - console.log(chalk.gray(' shannon --status')); - process.exit(0); - } - - // Helper function to update session progress - const updateSessionProgress = async (agentName: AgentName, commitHash: string | null = null): Promise => { - try { - const updates: SessionUpdates = { - completedAgents: [...new Set([...session.completedAgents, agentName])] as AgentName[], - failedAgents: session.failedAgents.filter(name => name !== agentName), - status: 'in-progress' - }; - - if (commitHash) { - updates.checkpoints = { ...session.checkpoints, [agentName]: commitHash }; - } - - await updateSession(session.id, updates); - // Update local session object for subsequent updates - Object.assign(session, updates); - console.log(chalk.gray(` 📝 Session updated: ${agentName} completed`)); - } catch (error) { - const err = error as Error; - console.log(chalk.yellow(` ⚠️ Failed to update session: ${err.message}`)); - } + // Session metadata for audit logging + const sessionMetadata: SessionMetadata = { + id: session.id, + webUrl, + repoPath: sourceDir, + ...(outputPath && { outputPath }) }; // Create outputs directory in source directory @@ -242,25 +643,8 @@ async function main( ); } - // Check if we should continue from where session left off - const nextAgent = getNextAgent(session); - if (!nextAgent) { - console.log(chalk.green(`✅ All agents completed! Session is finished.`)); - displayTimingSummary(); - process.exit(0); - } - - console.log(chalk.blue(`🔄 Continuing from ${nextAgent.displayName} (${session.completedAgents.length}/${Object.keys(AGENTS).length} agents completed)`)); - - // Determine which phase to start from based on next agent - const startPhase = nextAgent.name === 'pre-recon' ? 1 - : nextAgent.name === 'recon' ? 2 - : ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln'].includes(nextAgent.name) ? 3 - : ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'].includes(nextAgent.name) ? 4 - : nextAgent.name === 'report' ? 5 : 1; - + try { // PHASE 1: PRE-RECONNAISSANCE - if (startPhase <= 1) { const { duration: preReconDuration } = await executePreReconPhase( webUrl, sourceDir, @@ -268,92 +652,64 @@ async function main( distributedConfig, toolAvailability, pipelineTestingMode, - session.id, // Pass session ID for logging - outputPath // Pass output path for audit logging + session.id, + outputPath ); - timingResults.phases['pre-recon'] = preReconDuration; - await updateSessionProgress('pre-recon'); - } + console.log(chalk.green(`Pre-reconnaissance complete in ${formatDuration(preReconDuration)}`)); // PHASE 2: RECONNAISSANCE - if (startPhase <= 2) { console.log(chalk.magenta.bold('\n🔎 PHASE 2: RECONNAISSANCE')); console.log(chalk.magenta('Analyzing initial findings...')); const reconTimer = new Timer('phase-2-recon'); - await runClaudePromptWithRetry( - await loadPrompt('recon', variables, distributedConfig, pipelineTestingMode), + + await runAgent( + 'recon', sourceDir, - '*', - '', - AGENTS['recon'].displayName, - 'recon', // Agent name for snapshot creation - chalk.cyan, - { id: session.id, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field) + variables, + distributedConfig, + pipelineTestingMode, + sessionMetadata ); const reconDuration = reconTimer.stop(); - timingResults.phases['recon'] = reconDuration; - console.log(chalk.green(`✅ Reconnaissance complete in ${formatDuration(reconDuration)}`)); - await updateSessionProgress('recon'); - } // PHASE 3: VULNERABILITY ANALYSIS - if (startPhase <= 3) { const vulnTimer = new Timer('phase-3-vulnerability-analysis'); console.log(chalk.red.bold('\n🚨 PHASE 3: VULNERABILITY ANALYSIS')); - await runPhase('vulnerability-analysis', session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); - - // Display vulnerability analysis summary - const currentSession = await getSession(session.id); - if (currentSession) { - const vulnSummary = calculateVulnerabilityAnalysisSummary(currentSession); - console.log(chalk.blue(`\n📊 Vulnerability Analysis Summary: ${vulnSummary.totalAnalyses} analyses, ${vulnSummary.totalVulnerabilities} vulnerabilities found, ${vulnSummary.exploitationCandidates} ready for exploitation`)); - } + const vulnResults = await runParallelVuln( + sourceDir, + variables, + distributedConfig, + pipelineTestingMode, + sessionMetadata + ); const vulnDuration = vulnTimer.stop(); - timingResults.phases['vulnerability-analysis'] = vulnDuration; - console.log(chalk.green(`✅ Vulnerability analysis phase complete in ${formatDuration(vulnDuration)}`)); - } // PHASE 4: EXPLOITATION - if (startPhase <= 4) { const exploitTimer = new Timer('phase-4-exploitation'); console.log(chalk.red.bold('\n💥 PHASE 4: EXPLOITATION')); - // Get fresh session data to ensure we have latest vulnerability analysis results - const freshSession = await getSession(session.id); - if (freshSession) { - await runPhase('exploitation', freshSession, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); - } - - // Display exploitation summary - const finalSession = await getSession(session.id); - if (finalSession) { - const exploitSummary = calculateExploitationSummary(finalSession); - if (exploitSummary.eligibleExploits > 0) { - console.log(chalk.blue(`\n🎯 Exploitation Summary: ${exploitSummary.totalAttempts}/${exploitSummary.eligibleExploits} attempted, ${exploitSummary.skippedExploits} skipped (no vulnerabilities)`)); - } else { - console.log(chalk.gray(`\n🎯 Exploitation Summary: No exploitation attempts (no vulnerabilities found)`)); - } - } + const exploitResults = await runParallelExploit( + sourceDir, + variables, + distributedConfig, + pipelineTestingMode, + sessionMetadata + ); const exploitDuration = exploitTimer.stop(); - timingResults.phases['exploitation'] = exploitDuration; - console.log(chalk.green(`✅ Exploitation phase complete in ${formatDuration(exploitDuration)}`)); - } // PHASE 5: REPORTING - if (startPhase <= 5) { console.log(chalk.greenBright.bold('\n📊 PHASE 5: REPORTING')); console.log(chalk.greenBright('Generating executive summary and assembling final report...')); const reportTimer = new Timer('phase-5-reporting'); - // First, assemble all deliverables into a single concatenated report + // Assemble all deliverables into a single concatenated report console.log(chalk.blue('📝 Assembling deliverables from specialist agents...')); - try { await assembleFinalReport(sourceDir); } catch (error) { @@ -361,62 +717,58 @@ async function main( console.log(chalk.red(`❌ Error assembling final report: ${err.message}`)); } - // Then run reporter agent to create executive summary and clean up hallucinations - console.log(chalk.blue('📋 Generating executive summary and cleaning up report...')); - await runClaudePromptWithRetry( - await loadPrompt('report-executive', variables, distributedConfig, pipelineTestingMode), + // Run reporter agent to create executive summary + console.log(chalk.blue('Generating executive summary and cleaning up report...')); + await runAgent( + 'report', sourceDir, - '*', - '', - 'Executive Summary and Report Cleanup', - 'report', // Agent name for snapshot creation - chalk.cyan, - { id: session.id, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field) + variables, + distributedConfig, + pipelineTestingMode, + sessionMetadata ); const reportDuration = reportTimer.stop(); - timingResults.phases['reporting'] = reportDuration; - console.log(chalk.green(`✅ Final report generated in ${formatDuration(reportDuration)}`)); - // Get the commit hash after successful report generation for checkpoint - try { - const reportCommitHash = await getGitCommitHash(sourceDir); - await updateSessionProgress('report', reportCommitHash); - console.log(chalk.gray(` 📍 Report checkpoint saved: ${reportCommitHash.substring(0, 8)}`)); - } catch (error) { - const err = error as Error; - console.log(chalk.yellow(` ⚠️ Failed to save report checkpoint: ${err.message}`)); - await updateSessionProgress('report'); // Fallback without checkpoint - } - } + // Calculate final timing + timingResults.total.stop(); - // Calculate final timing and cost data - timingResults.total.stop(); + // Mark session as completed in both stores + await updateSessionStatus(session.id, 'completed'); - // Mark session as completed - await updateSession(session.id, { - status: 'completed' - }); + // Update audit system's session.json status + const auditSession = new AuditSession(sessionMetadata); + await auditSession.updateSessionStatus('completed'); - // Display comprehensive timing summary - displayTimingSummary(); + // Display comprehensive timing summary + displayTimingSummary(); console.log(chalk.cyan.bold('\n🎉 PENETRATION TESTING COMPLETE!')); console.log(chalk.gray('─'.repeat(60))); // Calculate audit logs path - const auditLogsPath = generateAuditPath({ id: session.id, webUrl: session.webUrl, repoPath: session.repoPath, ...(outputPath && { outputPath }) }); + const auditLogsPath = generateAuditPath(sessionMetadata); // Consolidate deliverables into the session folder await consolidateOutputs(sourceDir, auditLogsPath); console.log(chalk.green(`\n📂 All outputs consolidated: ${auditLogsPath}`)); - // Return final report path and audit logs path for clickable output - return { - reportPath: path.join(sourceDir, 'deliverables', 'comprehensive_security_assessment_report.md'), - auditLogsPath - }; + return { + reportPath: path.join(sourceDir, 'deliverables', 'comprehensive_security_assessment_report.md'), + auditLogsPath + }; + + } catch (error) { + // Mark session as failed in both stores + await updateSessionStatus(session.id, 'failed'); + + // Update audit system's session.json status + const auditSession = new AuditSession(sessionMetadata); + await auditSession.updateSessionStatus('failed'); + + throw error; + } } // Entry point - handle both direct node execution and shebang execution @@ -432,8 +784,6 @@ let outputPath: string | null = null; let pipelineTestingMode = false; let disableLoader = false; const nonFlagArgs: string[] = []; -let developerCommand: string | null = null; -const developerCommands = ['--run-phase', '--run-all', '--rollback-to', '--rerun', '--status', '--list-agents', '--cleanup']; for (let i = 0; i < args.length; i++) { if (args[i] === '--config') { @@ -456,24 +806,6 @@ for (let i = 0; i < args.length; i++) { pipelineTestingMode = true; } else if (args[i] === '--disable-loader') { disableLoader = true; - } else if (developerCommands.includes(args[i]!)) { - developerCommand = args[i]!; - // Collect remaining args for the developer command - const remainingArgs = args.slice(i + 1).filter(arg => !arg.startsWith('--') || arg === '--pipeline-testing' || arg === '--disable-loader'); - - // Check for --pipeline-testing in remaining args - if (remainingArgs.includes('--pipeline-testing')) { - pipelineTestingMode = true; - } - - // Check for --disable-loader in remaining args - if (remainingArgs.includes('--disable-loader')) { - disableLoader = true; - } - - // Add non-flag args (excluding --pipeline-testing and --disable-loader) - nonFlagArgs.push(...remainingArgs.filter(arg => arg !== '--pipeline-testing' && arg !== '--disable-loader')); - break; // Stop parsing after developer command } else if (!args[i]!.startsWith('-')) { nonFlagArgs.push(args[i]!); } @@ -485,16 +817,6 @@ if (args.includes('--help') || args.includes('-h') || args.includes('help')) { process.exit(0); } -// Handle developer commands -if (developerCommand) { - // Set global flag for loader control in developer mode too - global.SHANNON_DISABLE_LOADER = disableLoader; - - await handleDeveloperCommand(developerCommand, nonFlagArgs, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); - - process.exit(0); -} - // Handle no arguments - show help if (nonFlagArgs.length === 0) { console.log(chalk.red.bold('❌ Error: No arguments provided\n')); diff --git a/src/types/agents.ts b/src/types/agents.ts index 76e2a85..d358095 100644 --- a/src/types/agents.ts +++ b/src/types/agents.ts @@ -51,23 +51,15 @@ export type AgentValidatorMap = Record; export type McpAgentMapping = Record; -export type AgentPhase = - | 'pre-recon' - | 'recon' - | 'vuln' - | 'exploit' - | 'report'; - -export interface AgentDefinition { - name: AgentName; - promptName: PromptName; - phase: AgentPhase; - dependencies?: AgentName[]; -} - export type AgentStatus = | 'pending' | 'in_progress' | 'completed' | 'failed' | 'rolled-back'; + +export interface AgentDefinition { + name: AgentName; + displayName: string; + prerequisites: AgentName[]; +} diff --git a/src/types/index.ts b/src/types/index.ts index 6940ce7..9d7088d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,5 +10,4 @@ export * from './errors.js'; export * from './config.js'; -export * from './session.js'; export * from './agents.js'; diff --git a/src/types/session.ts b/src/types/session.ts deleted file mode 100644 index bbd25f1..0000000 --- a/src/types/session.ts +++ /dev/null @@ -1,63 +0,0 @@ -// 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. - -/** - * Session type definitions - */ - -import type { AgentName, AgentStatus } from './agents.js'; - -export type PhaseName = - | 'pre-reconnaissance' - | 'reconnaissance' - | 'vulnerability-analysis' - | 'exploitation' - | 'reporting'; - -export interface AgentInfo { - name: AgentName; - displayName: string; - phase: PhaseName; - order: number; - prerequisites: AgentName[]; -} - -export type AgentDefinitions = Record; - -export type PhaseDefinitions = Record; - -export interface AgentState { - status: AgentStatus; - startedAt?: string; - completedAt?: string; - error?: string; - attempts?: number; -} - -export interface Session { - id: string; - targetUrl: string; - repoPath: string; - configPath?: string; - createdAt: string; - updatedAt: string; - completedAgents: AgentName[]; - agentStates: Record; - checkpoints: Record; -} - -export interface SessionStore { - sessions: Record; -} - -export interface SessionSummary { - id: string; - targetUrl: string; - repoPath: string; - createdAt: string; - completedAgents: number; - totalAgents: number; -} diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index 3b82774..93ec456 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -30,22 +30,12 @@ export class Timer { } } -interface TimingResultsPhases { - [key: string]: number; -} - -interface TimingResultsCommands { - [key: string]: number; -} - interface TimingResultsAgents { [key: string]: number; } interface TimingResults { total: Timer | null; - phases: TimingResultsPhases; - commands: TimingResultsCommands; agents: TimingResultsAgents; } @@ -61,8 +51,6 @@ interface CostResults { // Global timing and cost tracker export const timingResults: TimingResults = { total: null, - phases: {}, - commands: {}, agents: {}, }; @@ -87,44 +75,6 @@ export const displayTimingSummary = (): void => { console.log(chalk.cyan(`📊 Total Execution Time: ${formatDuration(totalDuration)}`)); console.log(); - // Phase breakdown - if (Object.keys(timingResults.phases).length > 0) { - console.log(chalk.yellow.bold('🔍 Phase Breakdown:')); - let phaseTotal = 0; - for (const [phase, duration] of Object.entries(timingResults.phases)) { - const percentage = ((duration / totalDuration) * 100).toFixed(1); - console.log( - chalk.yellow(` ${phase.padEnd(20)} ${formatDuration(duration).padStart(8)} (${percentage}%)`) - ); - phaseTotal += duration; - } - console.log( - chalk.gray( - ` ${'Phases Total'.padEnd(20)} ${formatDuration(phaseTotal).padStart(8)} (${((phaseTotal / totalDuration) * 100).toFixed(1)}%)` - ) - ); - console.log(); - } - - // Command breakdown - if (Object.keys(timingResults.commands).length > 0) { - console.log(chalk.blue.bold('🖥️ Command Breakdown:')); - let commandTotal = 0; - for (const [command, duration] of Object.entries(timingResults.commands)) { - const percentage = ((duration / totalDuration) * 100).toFixed(1); - console.log( - chalk.blue(` ${command.padEnd(20)} ${formatDuration(duration).padStart(8)} (${percentage}%)`) - ); - commandTotal += duration; - } - console.log( - chalk.gray( - ` ${'Commands Total'.padEnd(20)} ${formatDuration(commandTotal).padStart(8)} (${((commandTotal / totalDuration) * 100).toFixed(1)}%)` - ) - ); - console.log(); - } - // Agent breakdown if (Object.keys(timingResults.agents).length > 0) { console.log(chalk.magenta.bold('🤖 Agent Breakdown:'));