From 264b16991a895b3d2378c5ee6415900a4228f7b1 Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Thu, 8 Jan 2026 23:50:42 +0530 Subject: [PATCH] feat: add configurable output directory with --output flag (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add configurable output directory with --output flag Add --output CLI flag to specify custom output directory for session folders containing audit logs, prompts, agent logs, and deliverables. Changes: - Add --output CLI flag parsing - Update generateAuditPath() to use custom path when provided - Add consolidateOutputs() to copy deliverables to session folder - Update Docker examples with volume mounts for output directories - Default remains ./audit-logs/ when --output is not specified šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * feat: add configurable output directory with --output flag Add --output CLI flag to specify custom output directory for session folders containing audit logs, prompts, agent logs, and deliverables. Changes: - Add --output CLI flag parsing - Store outputPath in Session interface for persistence - Update generateAuditPath() to use custom path when provided - Pass outputPath through pre-recon and checkpoint-manager - Add consolidateOutputs() to copy deliverables to session folder - Update Docker examples with volume mount instructions - Default remains ./audit-logs/ when --output is not specified šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * chore: add gitkeep and fix formatting * fix: correct docker run command formatting in README Remove invalid inline comments after backslash continuations in docker run commands. Comments cannot appear after backslash line continuations in shell scripts, as the backslash escapes the newline character. Reorganized comments to appear on separate lines before or after the command block for better clarity and proper shell syntax. Co-Authored-By: Claude Haiku 4.5 --------- Co-authored-by: Claude --- CLAUDE.md | 14 ++++++---- README.md | 17 +++++++++++- audit-logs/.gitkeep | 0 src/audit/utils.ts | 5 +++- src/checkpoint-manager.ts | 5 ++-- src/cli/ui.ts | 4 +++ src/phases/pre-recon.ts | 12 +++++---- src/session-manager.ts | 14 +++++++--- src/shannon.ts | 56 ++++++++++++++++++++++++++++++++++----- 9 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 audit-logs/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index f5a7321..75ad253 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,13 +15,14 @@ npm install ### Running the Penetration Testing Agent ```bash -shannon --config +shannon [--config ] [--output ] ``` Example: ```bash shannon "https://example.com" "/path/to/local/repo" shannon "https://juice-shop.herokuapp.com" "/home/user/juice-shop" --config juice-shop-config.yaml +shannon "https://example.com" "/path/to/repo" --output /path/to/reports ``` ### Alternative Execution @@ -194,10 +195,11 @@ The agent implements a sophisticated checkpoint system using git: The agent implements a crash-safe, self-healing audit system (v3.0) with the following guarantees: **Architecture:** -- **audit-logs/**: Centralized metrics and forensic logs (source of truth) +- **audit-logs/** (or custom `--output` path): Centralized metrics and forensic logs (source of truth) - `{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) **Crash Safety:** @@ -287,13 +289,15 @@ dist/ # Compiled JavaScript output └── ... # Other compiled files package.json # Node.js dependencies .shannon-store.json # Orchestration state (minimal) -audit-logs/ # Centralized audit data (v3.0) +audit-logs/ # Centralized audit data (default, or use --output) └── {hostname}_{sessionId}/ ā”œā”€ā”€ session.json # Comprehensive metrics ā”œā”€ā”€ prompts/ # Prompt snapshots │ └── {agent}.md - └── agents/ # Agent execution logs - └── {timestamp}_{agent}_attempt-{N}.log + ā”œā”€ā”€ agents/ # Agent execution logs + │ └── {timestamp}_{agent}_attempt-{N}.log + └── deliverables/ # Security reports and findings + └── ... configs/ # Configuration files ā”œā”€ā”€ config-schema.json # JSON Schema validation ā”œā”€ā”€ example-config.yaml # Template configuration diff --git a/README.md b/README.md index 8fbf5e0..dfcaf93 100644 --- a/README.md +++ b/README.md @@ -169,10 +169,15 @@ docker run --rm -it \ -e CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 \ -v "$(pwd)/repos:/app/repos" \ -v "$(pwd)/configs:/app/configs" \ + # Comment below line if using custom output directory + -v "$(pwd)/audit-logs:/app/audit-logs" \ shannon:latest \ "https://your-app.com/" \ "/app/repos/your-app" \ --config /app/configs/example-config.yaml + # Optional: uncomment below for custom output directory + # -v "$(pwd)/reports:/app/reports" \ + # --output /app/reports ``` **With Anthropic API Key:** @@ -186,10 +191,15 @@ docker run --rm -it \ -e CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 \ -v "$(pwd)/repos:/app/repos" \ -v "$(pwd)/configs:/app/configs" \ + # Comment below line if using custom output directory + -v "$(pwd)/audit-logs:/app/audit-logs" \ shannon:latest \ "https://your-app.com/" \ "/app/repos/your-app" \ --config /app/configs/example-config.yaml + # Optional: uncomment below for custom output directory + # -v "$(pwd)/reports:/app/reports" \ + # --output /app/reports ``` #### Platform-Specific Instructions @@ -217,10 +227,15 @@ docker run --rm -it \ -e CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 \ -v "$(pwd)/repos:/app/repos" \ -v "$(pwd)/configs:/app/configs" \ + # Comment below line if using custom output directory + -v "$(pwd)/audit-logs:/app/audit-logs" \ shannon:latest \ "http://host.docker.internal:3000" \ "/app/repos/your-app" \ --config /app/configs/example-config.yaml + # Optional: uncomment below for custom output directory + # -v "$(pwd)/reports:/app/reports" \ + # --output /app/reports ``` ### Configuration (Optional) @@ -281,7 +296,7 @@ docker run --rm shannon:latest --status ### Output and Results -All analysis results are saved to the `deliverables/` directory: +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"`). - **Pre-reconnaissance reports** - External scan results - **Vulnerability assessments** - Potential vulnerabilities from thorough code analysis and network mapping diff --git a/audit-logs/.gitkeep b/audit-logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/audit/utils.ts b/src/audit/utils.ts index ffc2c78..b05d0d6 100644 --- a/src/audit/utils.ts +++ b/src/audit/utils.ts @@ -26,6 +26,7 @@ export interface SessionMetadata { id: string; webUrl: string; repoPath?: string; + outputPath?: string; [key: string]: unknown; } @@ -40,10 +41,12 @@ export function generateSessionIdentifier(sessionMetadata: SessionMetadata): str /** * Generate path to audit log directory for a session + * Uses custom outputPath if provided, otherwise defaults to AUDIT_LOGS_DIR */ export function generateAuditPath(sessionMetadata: SessionMetadata): string { const sessionIdentifier = generateSessionIdentifier(sessionMetadata); - return path.join(AUDIT_LOGS_DIR, sessionIdentifier); + const baseDir = sessionMetadata.outputPath || AUDIT_LOGS_DIR; + return path.join(baseDir, sessionIdentifier); } /** diff --git a/src/checkpoint-manager.ts b/src/checkpoint-manager.ts index d0f13f6..5a8e03f 100644 --- a/src/checkpoint-manager.ts +++ b/src/checkpoint-manager.ts @@ -293,7 +293,7 @@ const runSingleAgent = async ( AGENTS[agentName]!.displayName, agentName, getAgentColor(agentName), - { id: currentSession.id, webUrl: currentSession.webUrl, repoPath: currentSession.repoPath } + { id: currentSession.id, webUrl: currentSession.webUrl, repoPath: currentSession.repoPath, ...(currentSession.outputPath && { outputPath: currentSession.outputPath }) } ); if (!result.success) { @@ -727,7 +727,8 @@ export const rollbackTo = async (targetAgent: string, session: Session): Promise const sessionMetadata: SessionMetadata = { id: session.id, webUrl: session.webUrl, - repoPath: session.repoPath + repoPath: session.repoPath, + ...(session.outputPath && { outputPath: session.outputPath }) }; const auditSession = new AuditSession(sessionMetadata); await auditSession.initialize(); diff --git a/src/cli/ui.ts b/src/cli/ui.ts index 413ab54..07f49ba 100644 --- a/src/cli/ui.ts +++ b/src/cli/ui.ts @@ -33,6 +33,9 @@ export function showHelp(): void { console.log( ' --config YAML configuration file for authentication and testing parameters' ); + console.log( + ' --output Custom output directory for session folder (default: ./audit-logs/)' + ); console.log( ' --pipeline-testing Use minimal prompts for fast pipeline testing (creates minimal deliverables)' ); @@ -55,6 +58,7 @@ export function showHelp(): void { 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' ); diff --git a/src/phases/pre-recon.ts b/src/phases/pre-recon.ts index edbd395..f3ba1e2 100644 --- a/src/phases/pre-recon.ts +++ b/src/phases/pre-recon.ts @@ -137,7 +137,8 @@ async function runPreReconWave1( variables: PromptVariables, config: DistributedConfig | null, pipelineTestingMode: boolean = false, - sessionId: string | null = null + sessionId: string | null = null, + outputPath: string | null = null ): Promise { console.log(chalk.blue(' → Launching Wave 1 operations in parallel...')); @@ -155,7 +156,7 @@ async function runPreReconWave1( AGENTS['pre-recon'].displayName, 'pre-recon', // Agent name for snapshot creation chalk.cyan, - { id: sessionId!, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field) + { id: sessionId!, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field) ) ); const [codeAnalysis] = await Promise.all(operations); @@ -178,7 +179,7 @@ async function runPreReconWave1( AGENTS['pre-recon'].displayName, 'pre-recon', // Agent name for snapshot creation chalk.cyan, - { id: sessionId!, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field) + { id: sessionId!, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field) ) ); } @@ -357,13 +358,14 @@ export async function executePreReconPhase( config: DistributedConfig | null, toolAvailability: ToolAvailability, pipelineTestingMode: boolean, - sessionId: string | null = null + sessionId: string | null = null, + outputPath: string | null = null ): Promise { console.log(chalk.yellow.bold('\nšŸ” PHASE 1: PRE-RECONNAISSANCE')); const timer = new Timer('phase-1-pre-recon'); console.log(chalk.yellow('Wave 1: Initial footprinting...')); - const wave1Results = await runPreReconWave1(webUrl, sourceDir, variables, config, pipelineTestingMode, sessionId); + const wave1Results = await runPreReconWave1(webUrl, sourceDir, variables, config, pipelineTestingMode, sessionId, outputPath); console.log(chalk.green(' āœ… Wave 1 operations completed')); console.log(chalk.yellow('Wave 2: Additional scanning...')); diff --git a/src/session-manager.ts b/src/session-manager.ts index 8433778..00b3257 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -41,6 +41,7 @@ export interface Session { repoPath: string; configFile: string | null; targetRepo: string; + outputPath: string | null; status: 'in-progress' | 'completed' | 'failed'; completedAgents: AgentName[]; failedAgents: AgentName[]; @@ -265,7 +266,8 @@ export const createSession = async ( webUrl: string, repoPath: string, configFile: string | null = null, - targetRepo: string | null = null + targetRepo: string | null = null, + outputPath: string | null = null ): Promise => { // Use targetRepo if provided, otherwise use repoPath const resolvedTargetRepo = targetRepo || repoPath; @@ -279,9 +281,12 @@ export const createSession = async ( 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 - await updateSession(existingSession.id, { lastActivity: new Date().toISOString() }); - return existingSession; + // 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) @@ -298,6 +303,7 @@ export const createSession = async ( repoPath, configFile, targetRepo: resolvedTargetRepo, + outputPath, status: 'in-progress', completedAgents: [], failedAgents: [], diff --git a/src/shannon.ts b/src/shannon.ts index b597012..54974c6 100644 --- a/src/shannon.ts +++ b/src/shannon.ts @@ -80,6 +80,27 @@ interface MainResult { // Configure zx to disable timeouts (let tools run as long as needed) $.timeout = 0; +/** + * 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'); + const destDeliverables = path.join(sessionPath, 'deliverables'); + + try { + if (await fs.pathExists(srcDeliverables)) { + await fs.copy(srcDeliverables, destDeliverables, { overwrite: true }); + console.log(chalk.gray(`šŸ“„ Deliverables copied to session folder`)); + } else { + console.log(chalk.yellow(`āš ļø No deliverables directory found at ${srcDeliverables}`)); + } + } catch (error) { + const err = error as Error; + console.log(chalk.yellow(`āš ļø Failed to consolidate deliverables: ${err.message}`)); + } +} + // Setup graceful cleanup on process signals process.on('SIGINT', async () => { console.log(chalk.yellow('\nāš ļø Received SIGINT, cleaning up...')); @@ -99,7 +120,8 @@ async function main( repoPath: string, configPath: string | null = null, pipelineTestingMode: boolean = false, - disableLoader: boolean = false + disableLoader: boolean = false, + outputPath: string | null = null ): Promise { // Set global flag for loader control global.SHANNON_DISABLE_LOADER = disableLoader; @@ -116,6 +138,9 @@ async function main( if (configPath) { console.log(chalk.cyan(`āš™ļø Config: ${configPath}`)); } + if (outputPath) { + console.log(chalk.cyan(`šŸ“‚ Output: ${outputPath}`)); + } console.log(chalk.gray('─'.repeat(60))); // Parse configuration if provided @@ -166,7 +191,7 @@ 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); + const session: Session = await createSession(webUrl, repoPath, configPath, sourceDir, outputPath); console.log(chalk.blue(`šŸ“ Session created: ${session.id.substring(0, 8)}...`)); // If setup-only mode, exit after session creation @@ -243,7 +268,8 @@ async function main( distributedConfig, toolAvailability, pipelineTestingMode, - session.id // Pass session ID for logging + session.id, // Pass session ID for logging + outputPath // Pass output path for audit logging ); timingResults.phases['pre-recon'] = preReconDuration; await updateSessionProgress('pre-recon'); @@ -262,7 +288,7 @@ async function main( AGENTS['recon'].displayName, 'recon', // Agent name for snapshot creation chalk.cyan, - { id: session.id, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field) + { id: session.id, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field) ); const reconDuration = reconTimer.stop(); timingResults.phases['recon'] = reconDuration; @@ -345,7 +371,7 @@ async function main( 'Executive Summary and Report Cleanup', 'report', // Agent name for snapshot creation chalk.cyan, - { id: session.id, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field) + { id: session.id, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field) ); const reportDuration = reportTimer.stop(); @@ -380,7 +406,11 @@ async function main( console.log(chalk.gray('─'.repeat(60))); // Calculate audit logs path - const auditLogsPath = generateAuditPath({ id: session.id, webUrl: session.webUrl, repoPath: session.repoPath }); + const auditLogsPath = generateAuditPath({ id: session.id, webUrl: session.webUrl, repoPath: session.repoPath, ...(outputPath && { outputPath }) }); + + // 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 { @@ -398,6 +428,7 @@ if (args[0] && args[0].includes('shannon')) { // Parse flags and arguments let configPath: string | null = null; +let outputPath: string | null = null; let pipelineTestingMode = false; let disableLoader = false; const nonFlagArgs: string[] = []; @@ -413,6 +444,14 @@ for (let i = 0; i < args.length; i++) { console.log(chalk.red('āŒ --config flag requires a file path')); process.exit(1); } + } else if (args[i] === '--output') { + if (i + 1 < args.length) { + outputPath = path.resolve(args[i + 1]!); + i++; // Skip the next argument + } else { + console.log(chalk.red('āŒ --output flag requires a directory path')); + process.exit(1); + } } else if (args[i] === '--pipeline-testing') { pipelineTestingMode = true; } else if (args[i] === '--disable-loader') { @@ -494,6 +533,9 @@ console.log(chalk.green('āœ… Input validation passed:')); console.log(chalk.gray(` Target Web URL: ${webUrl}`)); console.log(chalk.gray(` Target Repository: ${repoPathValidation.path}\n`)); console.log(chalk.gray(` Config Path: ${configPath}\n`)); +if (outputPath) { + console.log(chalk.gray(` Output Path: ${outputPath}\n`)); +} if (pipelineTestingMode) { console.log(chalk.yellow('⚔ PIPELINE TESTING MODE ENABLED - Using minimal test prompts for fast pipeline validation\n')); } @@ -502,7 +544,7 @@ if (disableLoader) { } try { - const result = await main(webUrl!, repoPathValidation.path!, configPath, pipelineTestingMode, disableLoader); + const result = await main(webUrl!, repoPathValidation.path!, configPath, pipelineTestingMode, disableLoader, outputPath); console.log(chalk.green.bold('\nšŸ“„ FINAL REPORT AVAILABLE:')); console.log(chalk.cyan(result.reportPath)); console.log(chalk.green.bold('\nšŸ“‚ AUDIT LOGS AVAILABLE:'));