fase(18): cli and cicd integration
This commit is contained in:
121
.github/actions/abe-explore/action.yml
vendored
Normal file
121
.github/actions/abe-explore/action.yml
vendored
Normal file
@@ -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
|
||||||
96
.github/workflows/abe-example.yml
vendored
96
.github/workflows/abe-example.yml
vendored
@@ -4,45 +4,101 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
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:
|
jobs:
|
||||||
explore:
|
explore:
|
||||||
|
name: Autonomous Bug Exploration
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Start application
|
- name: Install Playwright browsers
|
||||||
run: docker-compose up -d app
|
run: npx playwright install chromium --with-deps
|
||||||
# assumes the project has a docker-compose with the target app
|
|
||||||
|
|
||||||
- name: Wait for app
|
- name: Start target application
|
||||||
run: npx wait-on http://localhost:3000 --timeout 30000
|
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: |
|
run: |
|
||||||
npm run abe -- run \
|
npx wait-on \
|
||||||
--url http://localhost:3000 \
|
http://localhost:3000 \
|
||||||
--max-states 30 \
|
--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 \
|
--fail-on-severity high \
|
||||||
--output junit
|
--reports-dir ./abe-reports
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Upload results
|
- name: Publish JUnit test results
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: abe-reports
|
|
||||||
path: reports/
|
|
||||||
|
|
||||||
- name: Publish test results
|
|
||||||
if: always()
|
if: always()
|
||||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||||
with:
|
with:
|
||||||
files: abe-results.xml
|
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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1f1678af17637b190210f6a2f16acff4b0ee2427
|
5a28270dc9f8480d705811b8558f2662bab460f5
|
||||||
|
|||||||
@@ -1,7 +1 @@
|
|||||||
{
|
{"status": "completed", "timestamp": "2026-03-08 05:21:06"}
|
||||||
"status": "executing",
|
|
||||||
"indicator": "⠋",
|
|
||||||
"elapsed_seconds": 10,
|
|
||||||
"last_output": "",
|
|
||||||
"timestamp": "2026-03-06 11:29:02"
|
|
||||||
}
|
|
||||||
|
|||||||
33
Dockerfile.ci
Normal file
33
Dockerfile.ci
Normal file
@@ -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"]
|
||||||
302
README.md
302
README.md
@@ -1,6 +1,23 @@
|
|||||||
# ABE — Autonomous Bug Explorer
|
# 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.
|
[](https://github.com/your-org/abe/actions)
|
||||||
|
[](LICENSE)
|
||||||
|
[](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
|
## Quick Start
|
||||||
|
|
||||||
@@ -11,116 +28,229 @@ npm install
|
|||||||
# Install Playwright browser
|
# Install Playwright browser
|
||||||
npx playwright install chromium
|
npx playwright install chromium
|
||||||
|
|
||||||
# Run ABE against your app
|
# Explore your app (inline mode — no server needed)
|
||||||
npm run explore -- --url http://localhost:3000 --output ./reports
|
npm run abe -- explore --url http://localhost:3000
|
||||||
|
|
||||||
# Replay a discovered bug
|
# Start the full dashboard (API server + React frontend)
|
||||||
npm run replay -- --report reports/<anomaly-id>/report.json
|
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
|
### `abe explore` — Run an exploration
|
||||||
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
|
|
||||||
|
|
||||||
|
```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 |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `--url` | `http://localhost:3000` | Target URL |
|
| `--url <url>` | *(required)* | Target URL to explore |
|
||||||
| `--output` | `./reports` | Output directory |
|
| `--config <file>` | — | JSON config file (merged with flags) |
|
||||||
| `--seed` | `42` | Random seed for determinism |
|
| `--seed <n>` | `42` | Deterministic seed |
|
||||||
| `--max-steps` | `100` | Maximum exploration steps |
|
| `--max-states <n>` | `50` | Max states to visit |
|
||||||
|
| `--max-depth <n>` | `5` | Max click depth |
|
||||||
|
| `--allowed-domains <d>` | *(from URL)* | Comma-separated allowed domains |
|
||||||
|
| `--excluded-paths <p>` | — | Comma-separated paths to skip |
|
||||||
|
| `--auth-type <type>` | — | `cookies` \| `headers` \| `login_flow` |
|
||||||
|
| `--output <format>` | `human` | `human` \| `json` \| `junit` \| `markdown` |
|
||||||
|
| `--reports-dir <dir>` | `./reports` | Output directory |
|
||||||
|
| `--fail-on-severity <s>` | — | Exit 1 if finding at `low`/`medium`/`high`/`critical` or above |
|
||||||
|
| `--fail-on-anomaly` | — | Exit 1 if any finding found |
|
||||||
|
| `--server <url>` | — | Remote ABE server URL (skips inline engine) |
|
||||||
|
| `--api-key <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
|
```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 <id> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `--session <id>` | *(required)* | Session ID to report on |
|
||||||
|
| `--server <url>` | `http://localhost:3001` | ABE server URL |
|
||||||
|
| `--api-key <key>` | — | API key |
|
||||||
|
| `--format <fmt>` | `pdf` | `pdf` \| `html` \| `json` |
|
||||||
|
| `--output <file>` | `./abe-report-<id>.<fmt>` | 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 <url>` | `http://localhost:3001` | ABE server URL |
|
||||||
|
| `--api-key <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
|
npm run dev:all
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open `http://localhost:5173` in your browser.
|
Open `http://localhost:5173`. First run prompts you to create an admin account and organization.
|
||||||
|
|
||||||
### 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`).
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Run the full stack (backend + frontend) with a single command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
| Service | Host port | Description |
|
| Service | Port | Description |
|
||||||
|---------|-----------|-------------|
|
|---------|------|-------------|
|
||||||
| Backend | `3001` | Express API + socket.io |
|
| Backend | 3001 | Express API + socket.io |
|
||||||
| Frontend | `5173` | React dashboard (nginx) |
|
| 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/ (React + Vite, port 5173)
|
Domain (pure TypeScript — no infrastructure dependencies)
|
||||||
↕ HTTP REST + WebSocket
|
↑
|
||||||
src/server/ (Express + socket.io, port 3001)
|
Application (use cases, commands, queries, event handlers)
|
||||||
↕ imports
|
↑
|
||||||
src/core/ + src/plugins/ (ABE engine)
|
Infrastructure (Kysely/SQLite, Playwright, Express controllers)
|
||||||
```
|
```
|
||||||
|
|
||||||
Core principles:
|
**Modules:** `crawling` · `findings` · `fuzzing` · `auth` · `reporting` · `integrations` · `licensing`
|
||||||
- **Deterministic**: all random choices are seeded and logged
|
|
||||||
- **Plugin-oriented**: core engine never imports concrete plugin classes
|
Cross-module communication via `EventBus` only — bounded contexts never import each other directly.
|
||||||
- **Reproducible**: every anomaly includes an exact action trace and replay script
|
|
||||||
|
## 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.
|
||||||
|
|||||||
517
dist/cli/abe.js
vendored
Normal file
517
dist/cli/abe.js
vendored
Normal file
@@ -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 <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);
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"explore": "ts-node src/index.ts",
|
"explore": "ts-node src/index.ts",
|
||||||
"replay": "ts-node src/replay.ts",
|
"replay": "ts-node src/replay.ts",
|
||||||
"server": "ts-node src/server/index.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\"",
|
"dev:all": "concurrently \"npm run server\" \"npm --prefix frontend run dev\"",
|
||||||
"db:migrate": "ts-node src/db/migrator.ts"
|
"db:migrate": "ts-node src/db/migrator.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
555
src/cli/abe.ts
Normal file
555
src/cli/abe.ts
Normal file
@@ -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 <key>
|
||||||
|
* abe report --session <id> --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 <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: Record<string, unknown>) => {
|
||||||
|
// Load config file if provided
|
||||||
|
let fileConfig: Partial<ExplorationConfig & { seed?: number; maxStates?: number; maxDepth?: number }> = {};
|
||||||
|
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<string, number> = { 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 <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: Record<string, unknown>) => {
|
||||||
|
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<string, string> = { '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 <url>', 'ABE server URL', 'http://localhost:3001')
|
||||||
|
.option('--api-key <key>', 'API key for authentication')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (opts: Record<string, unknown>) => {
|
||||||
|
const server = opts['server'] as string;
|
||||||
|
const apiKey = opts['apiKey'] as string | undefined;
|
||||||
|
const asJson = opts['json'] as boolean | undefined;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
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<string, unknown>): Promise<void> {
|
||||||
|
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<string, string> = { '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) => ` <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: {
|
||||||
|
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, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
program.parse(process.argv);
|
||||||
Reference in New Issue
Block a user