"use strict"; /** * ABE CLI — command-line interface for autonomous bug exploration. * * Commands: * explore Run an exploration session * report Generate a report for a session * status Ping the ABE server and show active sessions * * Usage: * abe explore --url http://localhost:3000 * abe explore --url http://localhost:3000 --server http://localhost:3001 --api-key * abe report --session --server http://localhost:3001 * abe status --server http://localhost:3001 */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); const commander_1 = require("commander"); const ExplorationEngine_1 = require("../core/ExplorationEngine"); const StateGraph_1 = require("../core/StateGraph"); const PlaywrightAgent_1 = require("../plugins/agents/PlaywrightAgent"); const ScreenshotCollector_1 = require("../plugins/collectors/ScreenshotCollector"); const NetworkCollector_1 = require("../plugins/collectors/NetworkCollector"); const DOMSnapshotCollector_1 = require("../plugins/collectors/DOMSnapshotCollector"); const MarkdownExporter_1 = require("../plugins/exporters/MarkdownExporter"); const JSONExporter_1 = require("../plugins/exporters/JSONExporter"); const PlaywrightReproducer_1 = require("../plugins/reproducers/PlaywrightReproducer"); const ExplorationConfig_1 = require("../core/ExplorationConfig"); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const program = new commander_1.Command(); program .name('abe') .description('Autonomous Bug Explorer — explore web apps and find bugs') .version('0.1.0'); // ─── explore ──────────────────────────────────────────────────────────────── program .command('explore') .description('Run an autonomous exploration session against a target URL') .requiredOption('--url ', 'Target URL to explore') .option('--config ', 'Path to JSON config file (merged with flags)') .option('--seed ', 'Deterministic seed', parseInt, 42) .option('--max-states ', 'Max states to explore', parseInt, 50) .option('--max-depth ', 'Max click depth', parseInt, 5) .option('--allowed-domains ', 'Comma-separated allowed domains') .option('--excluded-paths ', 'Comma-separated excluded paths') .option('--action-delay ', 'Delay between actions in ms', parseInt, 500) .option('--session-timeout ', 'Session timeout in ms', parseInt, 300000) // Auth options .option('--auth-type ', 'Auth type: cookies | headers | login_flow') .option('--login-url ', 'Login page URL (for login_flow)') .option('--username ', 'Username (for login_flow)') .option('--password ', 'Password (for login_flow)') .option('--username-selector ', 'Username field selector (for login_flow)') .option('--password-selector ', 'Password field selector (for login_flow)') .option('--submit-selector ', 'Submit button selector (for login_flow)') // Output .option('--output ', 'Output format: human | json | junit | markdown', 'human') .option('--reports-dir ', 'Output directory for reports', './reports') // CI flags .option('--fail-on-anomaly', 'Exit 1 if any anomaly found') .option('--fail-on-severity ', 'Exit 1 if finding at or above severity (low|medium|high|critical)') // Remote server .option('--server ', 'Connect to remote ABE server instead of running inline') .option('--api-key ', 'API key for remote server') .action(async (opts) => { // Load config file if provided let fileConfig = {}; if (opts['config']) { try { const raw = fs.readFileSync(opts['config'], 'utf8'); fileConfig = JSON.parse(raw); } catch (err) { console.error(`Failed to read config file: ${err.message}`); process.exit(2); } } // Merge file config with CLI flags (CLI flags take precedence) const seed = opts['seed'] ?? fileConfig['seed'] ?? 42; const maxStates = opts['maxStates'] ?? fileConfig['maxStates'] ?? 50; const maxDepth = opts['maxDepth'] ?? fileConfig['maxDepth'] ?? 5; const reportsDir = opts['reportsDir'] ?? './reports'; // Remote server mode if (opts['server']) { await exploreRemote(opts); return; } // Inline mode — build auth config let auth = null; if (opts['authType'] === 'login_flow') { auth = { type: 'login_flow', loginUrl: opts['loginUrl'] ?? '', usernameSelector: opts['usernameSelector'] ?? 'input[type="email"]', passwordSelector: opts['passwordSelector'] ?? 'input[type="password"]', submitSelector: opts['submitSelector'] ?? 'button[type="submit"]', username: opts['username'] ?? '', password: opts['password'] ?? '', }; } else if (opts['authType'] === 'headers') { auth = { type: 'headers', headers: {} }; } else if (opts['authType'] === 'cookies') { auth = { type: 'cookies', cookies: [] }; } const config = { ...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG, ...fileConfig, maxStates, maxDepth, actionDelayMs: opts['actionDelay'] ?? ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG.actionDelayMs, sessionTimeoutMs: opts['sessionTimeout'] ?? ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG.sessionTimeoutMs, allowedDomains: opts['allowedDomains'] ? opts['allowedDomains'].split(',').map((d) => d.trim()) : [new URL(opts['url']).hostname], excludedPaths: opts['excludedPaths'] ? opts['excludedPaths'].split(',').map((p) => p.trim()) : [], auth, }; const anomalies = []; const discoveredStates = []; let statesVisited = 0; let exitCode = 0; let explorationError; const startMs = Date.now(); try { const graph = new StateGraph_1.StateGraph(); const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed, explorationConfig: config }); const engine = new ExplorationEngine_1.ExplorationEngine({ graph, agent, seed, url: opts['url'], maxSteps: maxStates, outputDir: reportsDir, explorationConfig: config, collectors: [ new ScreenshotCollector_1.ScreenshotCollector(reportsDir), new NetworkCollector_1.NetworkCollector(), new DOMSnapshotCollector_1.DOMSnapshotCollector(reportsDir), ], exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()], reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(), events: { onStateDiscovered: (_sessionId, stateId, stateUrl, title) => { discoveredStates.push({ id: stateId, url: stateUrl, title }); }, onAnomalyDetected: (_sessionId, anomaly) => { anomalies.push(anomaly); }, onSessionCompleted: (_sessionId, visited) => { statesVisited = visited; }, onSessionError: (_sessionId, error) => { explorationError = error; }, }, }); const result = await engine.run(); statesVisited = result.statesVisited; } catch (err) { explorationError = err instanceof Error ? err.message : String(err); exitCode = 2; } if (explorationError && exitCode === 0) exitCode = 2; // Determine exit code from CI flags if (exitCode === 0 && opts['failOnAnomaly'] && anomalies.length > 0) { exitCode = 1; } if (exitCode === 0 && opts['failOnSeverity']) { const severityRank = { low: 0, medium: 1, high: 2, critical: 3 }; const threshold = severityRank[opts['failOnSeverity']] ?? 0; const failing = anomalies.filter((a) => (severityRank[a.severity] ?? 0) >= threshold); if (failing.length > 0) exitCode = 1; } const durationMs = Date.now() - startMs; const output = opts['output']; if (output === 'json') { const summary = { url: opts['url'], seed, duration_ms: durationMs, states_visited: statesVisited, findings: anomalies.map((a) => ({ id: a.id, type: a.type, severity: a.severity, description: a.description, report_path: path.join(reportsDir, a.id, 'report.json'), })), exit_code: exitCode, error: explorationError, }; process.stdout.write(JSON.stringify(summary, null, 2) + '\n'); } else if (output === 'junit') { const xml = buildJunit({ url: opts['url'], statesVisited, discoveredStates, anomalies, durationMs, }); const outPath = path.join(process.cwd(), 'abe-results.xml'); fs.writeFileSync(outPath, xml, 'utf8'); console.log(`JUnit results written to ${outPath}`); } else if (output === 'markdown') { printMarkdownSummary({ url: opts['url'], statesVisited, anomalies, durationMs, explorationError }); } else { // human-readable if (anomalies.length === 0 && !explorationError) { console.log(`✓ ABE finished. No findings. ${statesVisited} states explored. (${durationMs}ms)`); } else { if (explorationError) { console.error(`✗ ABE error: ${explorationError}`); } if (anomalies.length > 0) { console.log(`⚠ ABE finished. ${anomalies.length} finding(s) in ${statesVisited} states (${durationMs}ms):`); for (const a of anomalies) { console.log(` [${a.severity.toUpperCase()}] ${a.type}: ${a.description}`); } } } } process.exit(exitCode); }); // ─── report ───────────────────────────────────────────────────────────────── program .command('report') .description('Generate a report for a completed exploration session') .requiredOption('--session ', 'Session ID to generate report for') .option('--server ', 'ABE server URL', 'http://localhost:3001') .option('--api-key ', 'API key for authentication') .option('--format ', 'Report format: pdf | html | json', 'pdf') .option('--output ', 'Output file path (default: ./abe-report-.pdf)') .action(async (opts) => { const server = opts['server']; const sessionId = opts['session']; const apiKey = opts['apiKey']; const format = opts['format']; const outputFile = opts['output'] ?? `./abe-report-${sessionId}.${format}`; const headers = { 'Content-Type': 'application/json' }; if (apiKey) headers['x-abe-api-key'] = apiKey; console.log(`Generating ${format} report for session ${sessionId}...`); try { // Request report generation const genRes = await fetch(`${server}/api/reports`, { method: 'POST', headers, body: JSON.stringify({ sessionId, format }), }); if (!genRes.ok) { console.error(`Error generating report: ${genRes.status} ${await genRes.text()}`); process.exit(2); return; } const report = await genRes.json(); console.log(`Report queued: ${report.id}`); // Poll until ready let ready = false; let attempts = 0; const maxAttempts = 30; while (!ready && attempts < maxAttempts) { await sleep(2000); attempts++; const statusRes = await fetch(`${server}/api/reports/${report.id}`, { headers }); if (!statusRes.ok) break; const status = await statusRes.json(); if (status.status === 'completed') { ready = true; } else if (status.status === 'failed') { console.error('Report generation failed'); process.exit(2); return; } process.stdout.write('.'); } if (!ready) { console.error('\nTimeout waiting for report'); process.exit(2); return; } console.log('\nDownloading...'); // Download the report const dlRes = await fetch(`${server}/api/reports/${report.id}/download`, { headers }); if (!dlRes.ok) { console.error(`Download failed: ${dlRes.status}`); process.exit(2); return; } const buffer = Buffer.from(await dlRes.arrayBuffer()); fs.writeFileSync(outputFile, buffer); console.log(`Report saved to ${outputFile}`); } catch (err) { console.error(`Error: ${err.message}`); process.exit(2); } }); // ─── status ───────────────────────────────────────────────────────────────── program .command('status') .description('Ping the ABE server and show active sessions') .option('--server ', 'ABE server URL', 'http://localhost:3001') .option('--api-key ', 'API key for authentication') .option('--json', 'Output as JSON') .action(async (opts) => { const server = opts['server']; const apiKey = opts['apiKey']; const asJson = opts['json']; const headers = {}; if (apiKey) headers['x-abe-api-key'] = apiKey; try { // Health check const healthRes = await fetch(`${server}/health/ready`, { headers }); const healthy = healthRes.ok; if (!healthy) { if (asJson) { console.log(JSON.stringify({ status: 'down', server })); } else { console.error(`✗ Server at ${server} is not ready (${healthRes.status})`); } process.exit(1); return; } // Fetch active sessions const sessionsRes = await fetch(`${server}/api/sessions`, { headers }); const sessions = sessionsRes.ok ? await sessionsRes.json() : []; const active = sessions.filter((s) => s.status === 'running'); if (asJson) { console.log(JSON.stringify({ status: 'up', server, activeSessions: active.length, sessions: active })); } else { console.log(`✓ ABE server is ready at ${server}`); if (active.length === 0) { console.log(' No active sessions'); } else { console.log(` ${active.length} active session(s):`); for (const s of active) { console.log(` [${s.id}] ${s.url} — ${s.statesVisited} states explored`); } } } } catch (err) { if (asJson) { console.log(JSON.stringify({ status: 'down', server, error: err.message })); } else { console.error(`✗ Cannot reach ABE server at ${server}: ${err.message}`); } process.exit(1); } }); // ─── Helpers ───────────────────────────────────────────────────────────────── async function exploreRemote(opts) { const serverUrl = opts['server']; const apiKey = opts['apiKey']; const url = opts['url']; const failOnSeverity = opts['failOnSeverity']; const headers = { 'Content-Type': 'application/json' }; if (apiKey) headers['x-abe-api-key'] = apiKey; console.log(`Starting remote exploration of ${url} via ${serverUrl}...`); try { const res = await fetch(`${serverUrl}/api/sessions`, { method: 'POST', headers, body: JSON.stringify({ url, seed: opts['seed'], maxStates: opts['maxStates'], maxDepth: opts['maxDepth'], }), }); if (!res.ok) { console.error(`Server error: ${res.status} ${await res.text()}`); process.exit(2); return; } const session = await res.json(); const sessionId = session.sessionId ?? session.id ?? ''; console.log(`Session started: ${sessionId}`); // Poll for completion let done = false; let anomalyCount = 0; while (!done) { await sleep(3000); const statusRes = await fetch(`${serverUrl}/api/sessions/${sessionId}`, { headers }); if (!statusRes.ok) break; const status = await statusRes.json(); if (status.status === 'completed' || status.status === 'failed' || status.status === 'stopped') { done = true; anomalyCount = status.findingsCount ?? 0; console.log(`Session ${status.status}. States: ${status.statesVisited ?? 0}, Findings: ${anomalyCount}`); } else { process.stdout.write('.'); } } let exitCode = 0; if (opts['failOnAnomaly'] && anomalyCount > 0) exitCode = 1; if (failOnSeverity) { // We can't filter by severity without fetching findings — conservative: exit 1 if any if (anomalyCount > 0) exitCode = 1; } process.exit(exitCode); } catch (err) { console.error(`Error: ${err.message}`); process.exit(2); } } function buildJunit(input) { const { url, statesVisited, discoveredStates, anomalies, durationMs } = input; // One passing test case per discovered state (states without findings pass) const stateCases = discoveredStates.map((s) => ` `); // One failing test case per anomaly const anomalyCases = anomalies.map((a) => ` \n` + ` ${escapeXml(a.id)}\n` + ` `); const totalTests = Math.max(statesVisited, discoveredStates.length) + anomalies.length; const totalFailures = anomalies.length; const durationSec = (durationMs / 1000).toFixed(3); return (`\n` + `\n` + [...stateCases, ...anomalyCases].join('\n') + '\n\n'); } function printMarkdownSummary(input) { const { url, statesVisited, anomalies, durationMs, explorationError } = input; const lines = [ `# ABE Exploration Report`, ``, `**Target:** ${url}`, `**States explored:** ${statesVisited}`, `**Duration:** ${(durationMs / 1000).toFixed(1)}s`, `**Findings:** ${anomalies.length}`, ``, ]; if (explorationError) { lines.push(`> ⚠ **Error:** ${explorationError}`, ``); } if (anomalies.length === 0) { lines.push(`✅ No findings detected.`); } else { lines.push(`## Findings`, ``); for (const a of anomalies) { lines.push(`### [${a.severity.toUpperCase()}] ${a.type}`, ``, `**ID:** ${a.id}`, `**Description:** ${a.description}`, ``); } } console.log(lines.join('\n')); } function escapeXml(s) { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } program.parse(process.argv); // ─── backup ───────────────────────────────────────────────────────────────── program .command('backup') .description('Backup ABE database to a file') .option('--db ', 'Path to ABE database', './data/abe.db') .option('--output ', 'Backup file path', `./abe-backup-${new Date().toISOString().slice(0, 10)}.db`) .action((opts) => { const src = opts.db; const dest = opts.output; if (!fs.existsSync(src)) { console.error(`Database not found: ${src}`); process.exit(2); } fs.copyFileSync(src, dest); const size = fs.statSync(dest).size; console.log(`✅ Backup created: ${dest} (${Math.round(size / 1024)} KB)`); }); // ─── restore ──────────────────────────────────────────────────────────────── program .command('restore') .description('Restore ABE database from a backup file') .requiredOption('--from ', 'Backup file to restore from') .option('--db ', 'Path to ABE database', './data/abe.db') .option('--confirm', 'Skip confirmation prompt') .action((opts) => { if (!fs.existsSync(opts.from)) { console.error(`Backup file not found: ${opts.from}`); process.exit(2); } if (!opts.confirm) { console.warn(`⚠️ This will overwrite the database at: ${opts.db}`); console.warn(`Run with --confirm to proceed.`); process.exit(1); } const dir = path.dirname(opts.db); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.copyFileSync(opts.from, opts.db); const size = fs.statSync(opts.db).size; console.log(`✅ Database restored from: ${opts.from} (${Math.round(size / 1024)} KB)`); }); // ─── retention ────────────────────────────────────────────────────────────── program .command('retention') .description('Run data retention cleanup (enterprise feature)') .option('--db ', 'Path to ABE database', './data/abe.db') .option('--findings-days ', 'Delete findings older than N days', parseInt, 365) .option('--sessions-days ', 'Delete sessions older than N days', parseInt, 90) .option('--audit-days ', 'Delete audit logs older than N days', parseInt, 365) .option('--jobs-days ', 'Delete completed jobs older than N days', parseInt, 30) .option('--dry-run', 'Show what would be deleted without deleting') .action(async (opts) => { if (!fs.existsSync(opts.db)) { console.error(`Database not found: ${opts.db}`); process.exit(2); } if (opts.dryRun) { console.log('🔍 Dry run mode — nothing will be deleted'); console.log(` Findings older than ${opts.findingsDays} days`); console.log(` Sessions older than ${opts.sessionsDays} days`); console.log(` Audit logs older than ${opts.auditDays} days`); console.log(` Jobs older than ${opts.jobsDays} days`); return; } // Dynamically import to avoid loading DB in non-DB commands const { Kysely, SqliteDialect } = await Promise.resolve().then(() => __importStar(require('kysely'))); const SQLite = (await Promise.resolve().then(() => __importStar(require('better-sqlite3')))).default; const { DataRetentionService } = await Promise.resolve().then(() => __importStar(require('../modules/scheduling/infrastructure/DataRetentionService'))); const pino = (await Promise.resolve().then(() => __importStar(require('pino')))).default; const logger = pino({ level: 'info' }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const db = new Kysely({ dialect: new SqliteDialect({ database: new SQLite(opts.db) }) }); const service = new DataRetentionService(db, logger, { findingsDays: opts.findingsDays, sessionsDays: opts.sessionsDays, auditLogsDays: opts.auditDays, jobsDays: opts.jobsDays, }); const results = await service.runRetention(); await db.destroy(); console.log('✅ Data retention completed:'); for (const [key, count] of Object.entries(results)) { console.log(` ${key}: ${count} rows deleted`); } });