diff --git a/shannon.mjs b/shannon.mjs index 90cd839..4315256 100755 --- a/shannon.mjs +++ b/shannon.mjs @@ -25,6 +25,7 @@ import { assembleFinalReport } from './src/phases/reporting.js'; // Utils import { timingResults, costResults, displayTimingSummary, Timer, formatDuration } from './src/utils/metrics.js'; +import { setupLogging } from './src/utils/logger.js'; // CLI import { handleDeveloperCommand } from './src/cli/command-handler.js'; @@ -44,16 +45,21 @@ import { // Configure zx to disable timeouts (let tools run as long as needed) $.timeout = 0; +// Global cleanup function for logging +let cleanupLogging = null; + // Setup graceful cleanup on process signals process.on('SIGINT', async () => { console.log(chalk.yellow('\n⚠️ Received SIGINT, cleaning up...')); await cleanupMCP(); + if (cleanupLogging) await cleanupLogging(); process.exit(0); }); process.on('SIGTERM', async () => { console.log(chalk.yellow('\n⚠️ Received SIGTERM, cleaning up...')); await cleanupMCP(); + if (cleanupLogging) await cleanupLogging(); process.exit(0); }); @@ -377,6 +383,7 @@ if (args[0] && args[0].includes('shannon.mjs')) { // Parse flags and arguments let configPath = null; let pipelineTestingMode = false; +let logFilePath = null; const nonFlagArgs = []; let developerCommand = null; const developerCommands = ['--run-phase', '--run-all', '--rollback-to', '--rerun', '--status', '--list-agents', '--cleanup']; @@ -390,6 +397,16 @@ 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] === '--log') { + // --log can optionally take a file path, otherwise use default + if (i + 1 < args.length && !args[i + 1].startsWith('-')) { + logFilePath = args[i + 1]; + i++; // Skip the next argument + } else { + // Generate default log filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + logFilePath = `shannon-${timestamp}.log`; + } } else if (args[i] === '--pipeline-testing') { pipelineTestingMode = true; } else if (developerCommands.includes(args[i])) { @@ -416,10 +433,25 @@ if (args.includes('--help') || args.includes('-h') || args.includes('help')) { process.exit(0); } +// Setup logging if --log flag is present +if (logFilePath) { + try { + cleanupLogging = await setupLogging(logFilePath); + const absoluteLogPath = path.isAbsolute(logFilePath) + ? logFilePath + : path.join(process.cwd(), logFilePath); + console.log(chalk.green(`📝 Logging enabled: ${absoluteLogPath}`)); + } catch (error) { + console.log(chalk.yellow(`⚠️ Failed to setup logging: ${error.message}`)); + console.log(chalk.gray('Continuing without logging...')); + } +} + // Handle developer commands if (developerCommand) { await handleDeveloperCommand(developerCommand, nonFlagArgs, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); await cleanupMCP(); + if (cleanupLogging) await cleanupLogging(); process.exit(0); } @@ -470,6 +502,7 @@ try { console.log(chalk.green.bold('\n📄 FINAL REPORT AVAILABLE:')); console.log(chalk.cyan(finalReportPath)); await cleanupMCP(); + if (cleanupLogging) await cleanupLogging(); } catch (error) { // Enhanced error boundary with proper logging if (error instanceof PentestError) { @@ -490,5 +523,6 @@ try { } } await cleanupMCP(); + if (cleanupLogging) await cleanupLogging(); process.exit(1); } \ No newline at end of file diff --git a/src/cli/ui.js b/src/cli/ui.js index c12a4c0..cea967e 100644 --- a/src/cli/ui.js +++ b/src/cli/ui.js @@ -21,6 +21,7 @@ export function showHelp() { console.log(chalk.yellow.bold('OPTIONS:')); console.log(' --config YAML configuration file for authentication and testing parameters'); + console.log(' --log [file] Capture all output to log file (default: shannon-.log)'); console.log(' --pipeline-testing Use minimal prompts for fast pipeline testing (creates minimal deliverables)\n'); console.log(chalk.yellow.bold('DEVELOPER COMMANDS:')); @@ -36,6 +37,7 @@ export function showHelp() { console.log(' # Normal mode - create new session'); console.log(' ./shannon.mjs "https://example.com" "/path/to/local/repo"'); console.log(' ./shannon.mjs "https://example.com" "/path/to/local/repo" --config auth.yaml'); + console.log(' ./shannon.mjs "https://example.com" "/path/to/local/repo" --log pentest.log'); console.log(' ./shannon.mjs "https://example.com" "/path/to/local/repo" --setup-only # Setup only\n'); console.log(' # Developer mode - operate on existing session'); diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..be285d4 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,126 @@ +import { fs } from 'zx'; +import { path } from 'zx'; + +/** + * Strips ANSI escape codes from a string + * @param {string} str - String with ANSI codes + * @returns {string} Clean string without ANSI codes + */ +function stripAnsi(str) { + if (typeof str !== 'string') { + return str; + } + + // Remove ANSI escape sequences + // This regex matches all common ANSI codes including: + // - Colors (e.g., \x1b[32m) + // - Cursor movement (e.g., \x1b[1;1H) + // - Screen clearing (e.g., \x1b[0J) + // - 256-color codes (e.g., \x1b[38;2;244;197;66m) + return str.replace( + // eslint-disable-next-line no-control-regex + /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][0-9];.*?\x07|\x1b\[[\d;]*m/g, + '' + ); +} + +/** + * Sets up logging to capture all stdout and stderr to a file + * @param {string} logFilePath - Path to the log file + * @returns {Promise} Cleanup function to restore original streams + */ +export async function setupLogging(logFilePath) { + // Resolve to absolute path + const absoluteLogPath = path.isAbsolute(logFilePath) + ? logFilePath + : path.join(process.cwd(), logFilePath); + + // Ensure the directory exists + await fs.ensureDir(path.dirname(absoluteLogPath)); + + // Create write stream for the log file + const logStream = fs.createWriteStream(absoluteLogPath, { flags: 'a' }); + + // Buffer for lines that might be overwritten (carriage return without newline) + let stdoutBuffer = ''; + let stderrBuffer = ''; + + // Store original stdout/stderr write functions + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + // Override stdout + process.stdout.write = function(chunk, encoding, callback) { + // Write colorized output to terminal + originalStdoutWrite(chunk, encoding, callback); + + // Write plain text (without ANSI codes) to log file + const cleanChunk = stripAnsi(chunk.toString()); + + // Handle carriage returns - only log when we get a newline + if (cleanChunk.includes('\r') && !cleanChunk.includes('\n')) { + // Buffer this line - it will be overwritten in terminal + stdoutBuffer = cleanChunk.replace(/\r/g, ''); + } else if (cleanChunk.includes('\n')) { + // Flush buffer if exists, then write the new line + if (stdoutBuffer) { + stdoutBuffer = ''; // Clear buffer without writing (it was overwritten) + } + logStream.write(cleanChunk); + } else { + // Normal write + logStream.write(cleanChunk); + } + + return true; + }; + + // Override stderr + process.stderr.write = function(chunk, encoding, callback) { + // Write colorized output to terminal + originalStderrWrite(chunk, encoding, callback); + + // Write plain text (without ANSI codes) to log file + const cleanChunk = stripAnsi(chunk.toString()); + + // Handle carriage returns - only log when we get a newline + if (cleanChunk.includes('\r') && !cleanChunk.includes('\n')) { + // Buffer this line - it will be overwritten in terminal + stderrBuffer = cleanChunk.replace(/\r/g, ''); + } else if (cleanChunk.includes('\n')) { + // Flush buffer if exists, then write the new line + if (stderrBuffer) { + stderrBuffer = ''; // Clear buffer without writing (it was overwritten) + } + logStream.write(cleanChunk); + } else { + // Normal write + logStream.write(cleanChunk); + } + + return true; + }; + + // Return cleanup function + return async function cleanup() { + // Restore original streams + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + + // Flush any remaining buffers + if (stdoutBuffer) { + logStream.write(stdoutBuffer + '\n'); + } + if (stderrBuffer) { + logStream.write(stderrBuffer + '\n'); + } + + // Close the log stream + return new Promise((resolve, reject) => { + logStream.end((err) => { + if (err) reject(err); + else resolve(); + }); + }); + }; +}