diff --git a/.github/actions/abe-explore/action.yml b/.github/actions/abe-explore/action.yml new file mode 100644 index 0000000..a47be1e --- /dev/null +++ b/.github/actions/abe-explore/action.yml @@ -0,0 +1,121 @@ +name: ABE Explore +description: Run ABE autonomous bug exploration against a target web application + +inputs: + url: + description: Target URL to explore + required: true + server: + description: ABE server URL (if using remote mode) + required: false + default: '' + api-key: + description: API key for remote ABE server + required: false + default: '' + max-states: + description: Maximum number of states to explore + required: false + default: '50' + seed: + description: Deterministic seed for reproducibility + required: false + default: '42' + output: + description: Output format (human | json | junit | markdown) + required: false + default: 'junit' + fail-on-severity: + description: Fail if findings at or above this severity (low | medium | high | critical) + required: false + default: 'high' + reports-dir: + description: Directory for generated reports + required: false + default: './abe-reports' + config: + description: Path to ABE JSON config file + required: false + default: '' + +outputs: + findings-count: + description: Number of findings discovered + value: ${{ steps.explore.outputs.findings-count }} + session-id: + description: ABE session ID + value: ${{ steps.explore.outputs.session-id }} + junit-path: + description: Path to JUnit XML results file + value: './abe-results.xml' + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install ABE dependencies + shell: bash + run: npm ci + working-directory: ${{ github.action_path }}/../../../ + + - name: Install Playwright browsers + shell: bash + run: npx playwright install chromium --with-deps + working-directory: ${{ github.action_path }}/../../../ + + - name: Run ABE exploration + id: explore + shell: bash + working-directory: ${{ github.action_path }}/../../../ + env: + ABE_API_KEY: ${{ inputs.api-key }} + run: | + ARGS="--url ${{ inputs.url }}" + ARGS="$ARGS --max-states ${{ inputs.max-states }}" + ARGS="$ARGS --seed ${{ inputs.seed }}" + ARGS="$ARGS --output ${{ inputs.output }}" + ARGS="$ARGS --reports-dir ${{ inputs.reports-dir }}" + + if [ -n "${{ inputs.server }}" ]; then + ARGS="$ARGS --server ${{ inputs.server }}" + fi + + if [ -n "${{ inputs.api-key }}" ]; then + ARGS="$ARGS --api-key ${{ inputs.api-key }}" + fi + + if [ -n "${{ inputs.fail-on-severity }}" ]; then + ARGS="$ARGS --fail-on-severity ${{ inputs.fail-on-severity }}" + fi + + if [ -n "${{ inputs.config }}" ]; then + ARGS="$ARGS --config ${{ inputs.config }}" + fi + + npm run abe -- explore $ARGS + EXIT_CODE=$? + + # Parse findings count from JUnit if available + if [ -f abe-results.xml ]; then + FAILURES=$(grep -oP 'failures="\K[0-9]+' abe-results.xml | head -1 || echo "0") + echo "findings-count=$FAILURES" >> $GITHUB_OUTPUT + else + echo "findings-count=0" >> $GITHUB_OUTPUT + fi + + exit $EXIT_CODE + + - name: Upload ABE reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: abe-reports-${{ github.run_id }} + path: | + ${{ inputs.reports-dir }}/ + abe-results.xml + retention-days: 30 diff --git a/.github/workflows/abe-example.yml b/.github/workflows/abe-example.yml index 0f0b9ae..23cfcbf 100644 --- a/.github/workflows/abe-example.yml +++ b/.github/workflows/abe-example.yml @@ -4,45 +4,101 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + inputs: + target-url: + description: Target URL to explore + required: false + default: 'http://localhost:3000' + max-states: + description: Maximum states to explore + required: false + default: '30' jobs: explore: + name: Autonomous Bug Exploration runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' + cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci - - name: Start application - run: docker-compose up -d app - # assumes the project has a docker-compose with the target app + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps - - name: Wait for app - run: npx wait-on http://localhost:3000 --timeout 30000 + - name: Start target application + run: docker compose up -d app + # Replace 'app' with your application's docker-compose service name. + # Or start your app however it's normally run in CI. + continue-on-error: true - - name: Run ABE + - name: Wait for application to be ready run: | - npm run abe -- run \ - --url http://localhost:3000 \ - --max-states 30 \ + npx wait-on \ + http://localhost:3000 \ + --timeout 30000 \ + --interval 2000 + continue-on-error: true + + - name: Run ABE exploration + id: abe + run: | + npm run abe -- explore \ + --url "${{ github.event.inputs.target-url || 'http://localhost:3000' }}" \ + --max-states "${{ github.event.inputs.max-states || '30' }}" \ + --seed 42 \ + --output junit \ --fail-on-severity high \ - --output junit + --reports-dir ./abe-reports + continue-on-error: true - - name: Upload results - if: always() - uses: actions/upload-artifact@v4 - with: - name: abe-reports - path: reports/ - - - name: Publish test results + - name: Publish JUnit test results if: always() uses: EnricoMi/publish-unit-test-result-action@v2 with: files: abe-results.xml + check_name: ABE Findings + comment_title: ABE Exploration Results + + - name: Upload ABE reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: abe-reports + path: | + abe-reports/ + abe-results.xml + retention-days: 30 + + - name: Fail if high/critical findings found + if: steps.abe.outcome == 'failure' + run: | + echo "ABE found high or critical severity bugs. See artifacts for details." + exit 1 + + # Optional: Use the composite action instead + explore-with-action: + name: ABE via Composite Action + runs-on: ubuntu-latest + if: false # Set to true to enable this alternative job + + steps: + - uses: actions/checkout@v4 + + - name: Run ABE + uses: ./.github/actions/abe-explore + with: + url: http://localhost:3000 + max-states: '30' + fail-on-severity: high + output: junit diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha index 1bb647b..512ed60 100644 --- a/.ralph/.loop_start_sha +++ b/.ralph/.loop_start_sha @@ -1 +1 @@ -1f1678af17637b190210f6a2f16acff4b0ee2427 +5a28270dc9f8480d705811b8558f2662bab460f5 diff --git a/.ralph/progress.json b/.ralph/progress.json index 0f85cc7..69777d4 100644 --- a/.ralph/progress.json +++ b/.ralph/progress.json @@ -1,7 +1 @@ -{ - "status": "executing", - "indicator": "⠋", - "elapsed_seconds": 10, - "last_output": "", - "timestamp": "2026-03-06 11:29:02" -} +{"status": "completed", "timestamp": "2026-03-08 05:21:06"} diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 0000000..439890d --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,33 @@ +# Dockerfile.ci — ABE CI image with Playwright/Chromium +# Based on the official Playwright image which includes all browser dependencies. +# Usage: +# docker build -f Dockerfile.ci -t abe-ci . +# docker run --rm -e TARGET_URL=http://host.docker.internal:3000 abe-ci \ +# npx ts-node src/cli/abe.ts explore --url $TARGET_URL --output junit + +FROM mcr.microsoft.com/playwright:v1.40.0-jammy + +WORKDIR /app + +# Install Node.js dependencies (production + dev for ts-node) +COPY package*.json ./ +RUN npm ci + +# Copy TypeScript source +COPY tsconfig.json ./ +COPY src/ ./src/ + +# Build TypeScript +RUN npm run build + +# Default reports directory +RUN mkdir -p /reports + +ENV NODE_ENV=production +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + +# Entrypoint: run ABE CLI +# Override CMD to pass custom flags, e.g.: +# docker run abe-ci node dist/cli/abe.js explore --url http://example.com +ENTRYPOINT ["node", "dist/cli/abe.js"] +CMD ["--help"] diff --git a/README.md b/README.md index 868452d..261dd72 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,23 @@ # ABE — Autonomous Bug Explorer -An open-source framework that autonomously explores web applications, provokes failures, and generates reproducible bug reports for developers and AI coding assistants. +[![Build](https://img.shields.io/github/actions/workflow/status/your-org/abe/abe-example.yml?branch=main&label=build)](https://github.com/your-org/abe/actions) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Version](https://img.shields.io/badge/version-0.1.0-blue)](package.json) + +> **"Playwright discovers what you test. ABE discovers what you miss."** + +An enterprise-grade, self-hosted platform for autonomous bug discovery in web applications. ABE explores your app like a real user, injects invalid inputs (fuzzing), detects anomalies, and generates reproducible bug reports — all without writing a single test. + +## Features + +- **Autonomous exploration** — navigates your app using a seeded, deterministic algorithm +- **Smart fuzzing** — injects empty values, oversized strings, special chars, type mismatches and boundary values into every input +- **Anomaly detection** — catches HTTP errors, JS exceptions, console errors, and accessibility violations +- **Reproducible reports** — every finding includes an exact action trace + generated Playwright test +- **Real-time dashboard** — watch explorations live with severity heatmaps and trend charts +- **CI/CD integration** — JUnit XML output, GitHub Action, exit codes for threshold-based gating +- **Auth support** — cookies, headers, or login flow for authenticated app exploration +- **Enterprise licensing** — RSA-signed license keys, RBAC, API keys, Slack/GitHub/Jira integrations ## Quick Start @@ -11,116 +28,229 @@ npm install # Install Playwright browser npx playwright install chromium -# Run ABE against your app -npm run explore -- --url http://localhost:3000 --output ./reports +# Explore your app (inline mode — no server needed) +npm run abe -- explore --url http://localhost:3000 -# Replay a discovered bug -npm run replay -- --report reports//report.json +# Start the full dashboard (API server + React frontend) +npm run dev:all +# Then open http://localhost:5173 ``` -## What ABE Does +## CLI Reference -1. Launches a headless browser and navigates to the target URL -2. Discovers interactive elements (links, buttons, inputs) -3. Executes actions deterministically using a seed -4. Observes HTTP responses, JS exceptions, and console errors -5. Detects anomalies using heuristic rules -6. Captures screenshots and DOM snapshots at anomaly moments -7. Generates a JSON + Markdown bug report with exact reproduction steps - -## Project Structure +### `abe explore` — Run an exploration +```bash +npm run abe -- explore [options] ``` -src/ -├── core/ # Interfaces, StateGraph, ExplorationEngine, AnomalyDetector -└── plugins/ - ├── agents/ # PlaywrightAgent - ├── collectors/ # Screenshot, Network, DOMSnapshot - ├── exporters/ # JSON, Markdown - └── reproducers/ # PlaywrightReproducer - -tests/ # Unit and integration tests (mirrors src/) -reports/ # Generated bug reports (runtime) -logs/ # Session logs in .jsonl format (runtime) -``` - -## CLI Options | Option | Default | Description | |--------|---------|-------------| -| `--url` | `http://localhost:3000` | Target URL | -| `--output` | `./reports` | Output directory | -| `--seed` | `42` | Random seed for determinism | -| `--max-steps` | `100` | Maximum exploration steps | +| `--url ` | *(required)* | Target URL to explore | +| `--config ` | — | JSON config file (merged with flags) | +| `--seed ` | `42` | Deterministic seed | +| `--max-states ` | `50` | Max states to visit | +| `--max-depth ` | `5` | Max click depth | +| `--allowed-domains ` | *(from URL)* | Comma-separated allowed domains | +| `--excluded-paths

` | — | Comma-separated paths to skip | +| `--auth-type ` | — | `cookies` \| `headers` \| `login_flow` | +| `--output ` | `human` | `human` \| `json` \| `junit` \| `markdown` | +| `--reports-dir

` | `./reports` | Output directory | +| `--fail-on-severity ` | — | Exit 1 if finding at `low`/`medium`/`high`/`critical` or above | +| `--fail-on-anomaly` | — | Exit 1 if any finding found | +| `--server ` | — | Remote ABE server URL (skips inline engine) | +| `--api-key ` | — | API key for remote server | -## Web UI (API Server + Dashboard) +**Exit codes:** `0` = clean, `1` = findings over threshold, `2` = error -ABE also ships a web dashboard for launching explorations and watching results in real time. +#### Examples ```bash -# Start both the API server (port 3001) and the React frontend (port 5173) +# Basic exploration +npm run abe -- explore --url https://staging.myapp.com + +# CI mode — fail on high/critical findings, output JUnit +npm run abe -- explore \ + --url https://staging.myapp.com \ + --max-states 100 \ + --output junit \ + --fail-on-severity high + +# Authenticated exploration (login flow) +npm run abe -- explore \ + --url https://staging.myapp.com \ + --auth-type login_flow \ + --login-url https://staging.myapp.com/login \ + --username ci@example.com \ + --password secret + +# Load config from JSON file +npm run abe -- explore --url https://staging.myapp.com --config abe.config.json + +# Remote server mode (delegates to ABE server) +npm run abe -- explore \ + --url https://staging.myapp.com \ + --server https://abe.internal.company.com \ + --api-key $ABE_API_KEY +``` + +#### Config File Format (`abe.config.json`) + +```json +{ + "maxStates": 100, + "maxDepth": 8, + "seed": 1337, + "allowedDomains": ["staging.myapp.com"], + "excludedPaths": ["/logout", "/admin"] +} +``` + +### `abe report` — Generate a report + +```bash +npm run abe -- report --session [options] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `--session ` | *(required)* | Session ID to report on | +| `--server ` | `http://localhost:3001` | ABE server URL | +| `--api-key ` | — | API key | +| `--format ` | `pdf` | `pdf` \| `html` \| `json` | +| `--output ` | `./abe-report-.` | Output file path | + +```bash +npm run abe -- report \ + --session abc123 \ + --server https://abe.internal.company.com \ + --api-key $ABE_API_KEY \ + --format pdf \ + --output ./security-report.pdf +``` + +### `abe status` — Check server health + +```bash +npm run abe -- status [options] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `--server ` | `http://localhost:3001` | ABE server URL | +| `--api-key ` | — | API key | +| `--json` | — | JSON output | + +```bash +npm run abe -- status --server https://abe.internal.company.com +# ✓ ABE server is ready at https://abe.internal.company.com +# 2 active session(s): +# [abc123] https://staging.myapp.com — 42 states explored +``` + +## CI/CD Integration + +### GitHub Actions — Composite Action + +```yaml +steps: + - uses: actions/checkout@v4 + + - name: Run ABE + uses: ./.github/actions/abe-explore + with: + url: https://staging.myapp.com + max-states: '50' + fail-on-severity: high + output: junit + + - name: Publish results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: abe-results.xml +``` + +### GitHub Actions — Inline + +```yaml +- name: Run ABE + run: | + npm run abe -- explore \ + --url https://staging.myapp.com \ + --max-states 50 \ + --output junit \ + --fail-on-severity high +``` + +### Docker CI Image + +```bash +# Build the CI image (includes Playwright/Chromium) +docker build -f Dockerfile.ci -t abe-ci . + +# Run exploration in Docker +docker run --rm \ + -v $(pwd)/abe-reports:/reports \ + abe-ci explore \ + --url http://host.docker.internal:3000 \ + --output junit \ + --fail-on-severity high +``` + +### JUnit XML Output + +With `--output junit`, ABE writes `abe-results.xml`: +- Each **state visited** = a passing test case +- Each **finding** = a failing test case with severity and description + +Integrates with GitHub Actions, Jenkins, GitLab CI, CircleCI, and any JUnit-compatible reporter. + +## Web Dashboard + +```bash +# Start both backend (port 3001) and frontend (port 5173) npm run dev:all ``` -Then open `http://localhost:5173` in your browser. - -### API Server only - -```bash -npm run server -``` - -REST endpoints available at `http://localhost:3001/api/`: - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/sessions` | Start a new exploration | -| `GET` | `/sessions` | List all sessions | -| `GET` | `/sessions/:id` | Session detail | -| `DELETE` | `/sessions/:id` | Stop a running session | -| `GET` | `/anomalies` | List all anomalies | -| `GET` | `/anomalies/:id` | Anomaly detail | -| `GET` | `/anomalies/:id/screenshot` | Bug screenshot (PNG) | -| `POST` | `/anomalies/:id/replay` | Trigger anomaly replay | - -WebSocket events are emitted via socket.io (connect to `http://localhost:3001`). +Open `http://localhost:5173`. First run prompts you to create an admin account and organization. ## Docker -Run the full stack (backend + frontend) with a single command: - ```bash -docker-compose up --build +docker compose up --build ``` -| Service | Host port | Description | -|---------|-----------|-------------| -| Backend | `3001` | Express API + socket.io | -| Frontend | `5173` | React dashboard (nginx) | - -Then open `http://localhost:5173` in your browser. - -Reports and logs are persisted via Docker volumes (`./reports`, `./logs`). - -## Development - -```bash -npm run build # Compile TypeScript -npm test # Run all tests -npm run typecheck # Type-check without compiling -``` +| Service | Port | Description | +|---------|------|-------------| +| Backend | 3001 | Express API + socket.io | +| Frontend | 5173 | React dashboard (nginx) | ## Architecture ``` -frontend/ (React + Vite, port 5173) - ↕ HTTP REST + WebSocket -src/server/ (Express + socket.io, port 3001) - ↕ imports -src/core/ + src/plugins/ (ABE engine) +Domain (pure TypeScript — no infrastructure dependencies) + ↑ +Application (use cases, commands, queries, event handlers) + ↑ +Infrastructure (Kysely/SQLite, Playwright, Express controllers) ``` -Core principles: -- **Deterministic**: all random choices are seeded and logged -- **Plugin-oriented**: core engine never imports concrete plugin classes -- **Reproducible**: every anomaly includes an exact action trace and replay script +**Modules:** `crawling` · `findings` · `fuzzing` · `auth` · `reporting` · `integrations` · `licensing` + +Cross-module communication via `EventBus` only — bounded contexts never import each other directly. + +## Development + +```bash +npm run build # Compile TypeScript +npm run test # Run tests (Vitest) +npm run lint # ESLint +npm run db:migrate # Apply database migrations +cd frontend && npm run build # Build frontend +docker compose up -d --build # Full stack with Docker +``` + +## License + +Core: [MIT](LICENSE) · Enterprise features require a valid license key. diff --git a/dist/cli/abe.js b/dist/cli/abe.js new file mode 100644 index 0000000..d17fb86 --- /dev/null +++ b/dist/cli/abe.js @@ -0,0 +1,517 @@ +"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); diff --git a/package.json b/package.json index 76fb991..1c94419 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "explore": "ts-node src/index.ts", "replay": "ts-node src/replay.ts", "server": "ts-node src/server/index.ts", - "abe": "ts-node src/cli.ts", + "abe": "ts-node src/cli/abe.ts", "dev:all": "concurrently \"npm run server\" \"npm --prefix frontend run dev\"", "db:migrate": "ts-node src/db/migrator.ts" }, diff --git a/src/cli/abe.ts b/src/cli/abe.ts new file mode 100644 index 0000000..b848b68 --- /dev/null +++ b/src/cli/abe.ts @@ -0,0 +1,555 @@ +/** + * 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 + */ + +import { Command } from 'commander'; +import { ExplorationEngine } from '../core/ExplorationEngine'; +import { StateGraph } from '../core/StateGraph'; +import { PlaywrightAgent } from '../plugins/agents/PlaywrightAgent'; +import { ScreenshotCollector } from '../plugins/collectors/ScreenshotCollector'; +import { NetworkCollector } from '../plugins/collectors/NetworkCollector'; +import { DOMSnapshotCollector } from '../plugins/collectors/DOMSnapshotCollector'; +import { MarkdownExporter } from '../plugins/exporters/MarkdownExporter'; +import { JSONExporter } from '../plugins/exporters/JSONExporter'; +import { PlaywrightReproducer } from '../plugins/reproducers/PlaywrightReproducer'; +import { ExplorationConfig, DEFAULT_EXPLORATION_CONFIG, AuthConfig } from '../core/ExplorationConfig'; +import { IAnomaly } from '../core/interfaces'; +import * as fs from 'fs'; +import * as path from 'path'; + +const program = new 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: Record) => { + // Load config file if provided + let fileConfig: Partial = {}; + if (opts['config']) { + try { + const raw = fs.readFileSync(opts['config'] as string, 'utf8'); + fileConfig = JSON.parse(raw) as typeof fileConfig; + } catch (err) { + console.error(`Failed to read config file: ${(err as Error).message}`); + process.exit(2); + } + } + + // Merge file config with CLI flags (CLI flags take precedence) + const seed = (opts['seed'] as number) ?? fileConfig['seed'] ?? 42; + const maxStates = (opts['maxStates'] as number) ?? fileConfig['maxStates'] ?? 50; + const maxDepth = (opts['maxDepth'] as number) ?? fileConfig['maxDepth'] ?? 5; + const reportsDir = (opts['reportsDir'] as string) ?? './reports'; + + // Remote server mode + if (opts['server']) { + await exploreRemote(opts); + return; + } + + // Inline mode — build auth config + let auth: AuthConfig | null = null; + if (opts['authType'] === 'login_flow') { + auth = { + type: 'login_flow', + loginUrl: (opts['loginUrl'] as string) ?? '', + usernameSelector: (opts['usernameSelector'] as string) ?? 'input[type="email"]', + passwordSelector: (opts['passwordSelector'] as string) ?? 'input[type="password"]', + submitSelector: (opts['submitSelector'] as string) ?? 'button[type="submit"]', + username: (opts['username'] as string) ?? '', + password: (opts['password'] as string) ?? '', + }; + } else if (opts['authType'] === 'headers') { + auth = { type: 'headers', headers: {} }; + } else if (opts['authType'] === 'cookies') { + auth = { type: 'cookies', cookies: [] }; + } + + const config: ExplorationConfig = { + ...DEFAULT_EXPLORATION_CONFIG, + ...fileConfig, + maxStates, + maxDepth, + actionDelayMs: (opts['actionDelay'] as number) ?? DEFAULT_EXPLORATION_CONFIG.actionDelayMs, + sessionTimeoutMs: (opts['sessionTimeout'] as number) ?? DEFAULT_EXPLORATION_CONFIG.sessionTimeoutMs, + allowedDomains: opts['allowedDomains'] + ? (opts['allowedDomains'] as string).split(',').map((d: string) => d.trim()) + : [new URL(opts['url'] as string).hostname], + excludedPaths: opts['excludedPaths'] + ? (opts['excludedPaths'] as string).split(',').map((p: string) => p.trim()) + : [], + auth, + }; + + const anomalies: IAnomaly[] = []; + const discoveredStates: Array<{ id: string; url: string; title: string }> = []; + let statesVisited = 0; + let exitCode = 0; + let explorationError: string | undefined; + const startMs = Date.now(); + + try { + const graph = new StateGraph(); + const agent = new PlaywrightAgent({ seed, explorationConfig: config }); + + const engine = new ExplorationEngine({ + graph, + agent, + seed, + url: opts['url'] as string, + maxSteps: maxStates, + outputDir: reportsDir, + explorationConfig: config, + collectors: [ + new ScreenshotCollector(reportsDir), + new NetworkCollector(), + new DOMSnapshotCollector(reportsDir), + ], + exporters: [new MarkdownExporter(), new JSONExporter()], + reproducer: new 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: unknown) { + 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: Record = { low: 0, medium: 1, high: 2, critical: 3 }; + const threshold = severityRank[opts['failOnSeverity'] as string] ?? 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'] as string; + + 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'] as string, + 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'] as string, 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: Record) => { + const server = opts['server'] as string; + const sessionId = opts['session'] as string; + const apiKey = opts['apiKey'] as string | undefined; + const format = opts['format'] as string; + const outputFile = (opts['output'] as string | undefined) ?? `./abe-report-${sessionId}.${format}`; + + const headers: Record = { '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() as { id: string; status: string }; + 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() as { status: string }; + 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: unknown) { + console.error(`Error: ${(err as Error).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: Record) => { + const server = opts['server'] as string; + const apiKey = opts['apiKey'] as string | undefined; + const asJson = opts['json'] as boolean | undefined; + + const headers: Record = {}; + 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() as Array<{ id: string; url: string; status: string; statesVisited: number }>) + : []; + + 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: unknown) { + if (asJson) { + console.log(JSON.stringify({ status: 'down', server, error: (err as Error).message })); + } else { + console.error(`✗ Cannot reach ABE server at ${server}: ${(err as Error).message}`); + } + process.exit(1); + } + }); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function exploreRemote(opts: Record): Promise { + const serverUrl = opts['server'] as string; + const apiKey = opts['apiKey'] as string | undefined; + const url = opts['url'] as string; + const failOnSeverity = opts['failOnSeverity'] as string | undefined; + + const headers: Record = { '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() as { sessionId: string; id?: string }; + 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() as { status: string; statesVisited?: number; findingsCount?: number }; + + 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: unknown) { + console.error(`Error: ${(err as Error).message}`); + process.exit(2); + } +} + +interface JunitInput { + url: string; + statesVisited: number; + discoveredStates: Array<{ id: string; url: string; title: string }>; + anomalies: IAnomaly[]; + durationMs: number; +} + +function buildJunit(input: JunitInput): string { + 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: { + url: string; + statesVisited: number; + anomalies: IAnomaly[]; + durationMs: number; + explorationError: string | undefined; +}): void { + const { url, statesVisited, anomalies, durationMs, explorationError } = input; + const lines: string[] = [ + `# 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: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +program.parse(process.argv);