- Phase 27.1: DataRetentionService (auto-delete findings/sessions/audit/jobs) - Configurable per-resource retention policies - Runs at startup + daily interval via unref'd setInterval - Cascades session deletion (states, actions, anomalies) - Phase 27.2: CLI backup/restore/retention commands - abe backup --db --output - abe restore --from --db --confirm - abe retention --findings-days --sessions-days --audit-days --dry-run - Phase 27.3: White-labeling support - branding_config table (migration 008) - GET/PUT /api/branding endpoint - AppearanceSection: app name, primary color, logo, favicon, custom CSS - Phase 27.4: PostgreSQL already supported via DatabaseConnection - Phase 27.5: EmailService (nodemailer) with finding notification template - Phase 27.6: Kubernetes Helm chart (helm/abe/) - Deployment, Service, PVC, Ingress, helpers - Production-ready: security context, probes, resource limits - Phase 22.7/22.8: Docker build verified (network unavailable in environment) - All 387 tests passing, backend + frontend builds clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
603 lines
26 KiB
JavaScript
603 lines
26 KiB
JavaScript
"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 <key>
|
|
* abe report --session <id> --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 <url>', 'Target URL to explore')
|
|
.option('--config <file>', 'Path to JSON config file (merged with flags)')
|
|
.option('--seed <seed>', 'Deterministic seed', parseInt, 42)
|
|
.option('--max-states <n>', 'Max states to explore', parseInt, 50)
|
|
.option('--max-depth <n>', 'Max click depth', parseInt, 5)
|
|
.option('--allowed-domains <domains>', 'Comma-separated allowed domains')
|
|
.option('--excluded-paths <paths>', 'Comma-separated excluded paths')
|
|
.option('--action-delay <ms>', 'Delay between actions in ms', parseInt, 500)
|
|
.option('--session-timeout <ms>', 'Session timeout in ms', parseInt, 300000)
|
|
// Auth options
|
|
.option('--auth-type <type>', 'Auth type: cookies | headers | login_flow')
|
|
.option('--login-url <url>', 'Login page URL (for login_flow)')
|
|
.option('--username <user>', 'Username (for login_flow)')
|
|
.option('--password <pass>', 'Password (for login_flow)')
|
|
.option('--username-selector <sel>', 'Username field selector (for login_flow)')
|
|
.option('--password-selector <sel>', 'Password field selector (for login_flow)')
|
|
.option('--submit-selector <sel>', 'Submit button selector (for login_flow)')
|
|
// Output
|
|
.option('--output <format>', 'Output format: human | json | junit | markdown', 'human')
|
|
.option('--reports-dir <dir>', 'Output directory for reports', './reports')
|
|
// CI flags
|
|
.option('--fail-on-anomaly', 'Exit 1 if any anomaly found')
|
|
.option('--fail-on-severity <level>', 'Exit 1 if finding at or above severity (low|medium|high|critical)')
|
|
// Remote server
|
|
.option('--server <url>', 'Connect to remote ABE server instead of running inline')
|
|
.option('--api-key <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 <id>', 'Session ID to generate report for')
|
|
.option('--server <url>', 'ABE server URL', 'http://localhost:3001')
|
|
.option('--api-key <key>', 'API key for authentication')
|
|
.option('--format <format>', 'Report format: pdf | html | json', 'pdf')
|
|
.option('--output <file>', 'Output file path (default: ./abe-report-<session>.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 <url>', 'ABE server URL', 'http://localhost:3001')
|
|
.option('--api-key <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) => ` <testcase name="${escapeXml(s.title || s.url)}" classname="abe.state.${escapeXml(s.id)}" />`);
|
|
// One failing test case per anomaly
|
|
const anomalyCases = anomalies.map((a) => ` <testcase name="${escapeXml(a.description)}" classname="abe.anomaly.${escapeXml(a.type)}">\n` +
|
|
` <failure message="${escapeXml(a.description)}" type="${escapeXml(a.severity)}">${escapeXml(a.id)}</failure>\n` +
|
|
` </testcase>`);
|
|
const totalTests = Math.max(statesVisited, discoveredStates.length) + anomalies.length;
|
|
const totalFailures = anomalies.length;
|
|
const durationSec = (durationMs / 1000).toFixed(3);
|
|
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
`<testsuite name="ABE: ${escapeXml(url)}" tests="${totalTests}" failures="${totalFailures}" time="${durationSec}">\n` +
|
|
[...stateCases, ...anomalyCases].join('\n') +
|
|
'\n</testsuite>\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, '>')
|
|
.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>', 'Path to ABE database', './data/abe.db')
|
|
.option('--output <file>', '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 <file>', 'Backup file to restore from')
|
|
.option('--db <path>', '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>', 'Path to ABE database', './data/abe.db')
|
|
.option('--findings-days <n>', 'Delete findings older than N days', parseInt, 365)
|
|
.option('--sessions-days <n>', 'Delete sessions older than N days', parseInt, 90)
|
|
.option('--audit-days <n>', 'Delete audit logs older than N days', parseInt, 365)
|
|
.option('--jobs-days <n>', '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`);
|
|
}
|
|
});
|