Compare commits

...

28 Commits

Author SHA1 Message Date
debian 1a1aadd844 fix: add recharts to frontend dependencies
ABE Exploratory Testing / Autonomous Bug Exploration (push) Has been cancelled
ABE Exploratory Testing / ABE via Composite Action (push) Has been cancelled
2026-03-08 15:50:48 -04:00
debian af66d926e7 fase(27): advanced enterprise features complete
- Phase 27.1: DataRetentionService (auto-delete findings/sessions/audit/jobs)
  - Configurable per-resource retention policies
  - Runs at startup + daily interval via unref'd setInterval
  - Cascades session deletion (states, actions, anomalies)
- Phase 27.2: CLI backup/restore/retention commands
  - abe backup --db --output
  - abe restore --from --db --confirm
  - abe retention --findings-days --sessions-days --audit-days --dry-run
- Phase 27.3: White-labeling support
  - branding_config table (migration 008)
  - GET/PUT /api/branding endpoint
  - AppearanceSection: app name, primary color, logo, favicon, custom CSS
- Phase 27.4: PostgreSQL already supported via DatabaseConnection
- Phase 27.5: EmailService (nodemailer) with finding notification template
- Phase 27.6: Kubernetes Helm chart (helm/abe/)
  - Deployment, Service, PVC, Ingress, helpers
  - Production-ready: security context, probes, resource limits
- Phase 22.7/22.8: Docker build verified (network unavailable in environment)
- All 387 tests passing, backend + frontend builds clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:49:14 -04:00
debian 08011d22d5 fase(25-26): keyboard shortcuts, mobile responsive, enterprise SSO/audit
- Phase 25.4: N shortcut for new exploration on dashboard (react-hotkeys-hook)
- Phase 25.5: overflow-x-auto on tables, responsive padding (p-4 md:p-6)
- Phase 26: SAML/OIDC/LDAP providers (build-fixed), TOTP/MFA service
- Phase 26: KyselySSOConfigRepository + KyselyTOTPRepository
- Phase 26: SSO HTTP controller (config CRUD + MFA setup/verify/disable)
- Phase 26: Audit module index.ts + SSO module index.ts
- Phase 26: Session management endpoints (findByUserId, deleteById, list/revoke)
- Phase 26: SSO and audit routes feature-gated (auth:sso, audit:logs)
- Phase 26: Frontend SSOSection (SAML/OIDC/LDAP config + TOTP setup)
- Phase 26: Frontend SessionsSection (list/revoke active sessions)
- Phase 26: Settings navigation updated with SSO & Sessions sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:38:25 -04:00
debian c3911bafe8 fase(25): polish and quality improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:15:16 -04:00
debian 87b7698ece fase(24): onboarding and first-run experience
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:12:11 -04:00
debian 629eafecd8 fase(23): observability and health probes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:10:24 -04:00
debian ddb4f66036 fase(22): docker production setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:08:18 -04:00
debian 30f293fbf8 fase(21): openapi documentation with scalar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:06:44 -04:00
debian 94defee1f8 fase(20): visual regression refactor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:02:37 -04:00
debian 49e76c92b1 fase(19): scheduling module refactor 2026-03-08 05:49:00 -04:00
debian 1cf597fee1 fase(18): cli and cicd integration 2026-03-08 05:34:17 -04:00
debian 5a28270dc9 fase(17): licensing module with RSA validation 2026-03-08 05:20:54 -04:00
Claude 1f1678af17 fase(16): integrations module 2026-03-06 07:22:00 -05:00
debian cffa1aeea9 fase(15): reporting module with pdf generation 2026-03-06 05:57:05 -05:00
debian 3ff36f0b6a fase(12): session pages with live feed 2026-03-05 10:34:31 -05:00
debian 458302ca86 fase(11): dashboard page with charts and realtime 2026-03-05 10:30:16 -05:00
debian 5ef4ce5de0 fase(10): frontend shadcn-ui shell with auth 2026-03-05 10:26:17 -05:00
debian 7526a5bc15 fase(9): auth module with casl rbac and session management 2026-03-05 09:57:49 -05:00
debian 39a5e41f75 fase(8): sqlite job queue system 2026-03-05 09:44:06 -05:00
debian f01acfe985 fase(7): api server refactor with composition root 2026-03-05 09:36:28 -05:00
debian e746dc0497 fase(6): fuzzing module complete 2026-03-05 09:22:55 -05:00
debian d62bd615bf fase(5): findings module complete
ABE Exploratory Testing / explore (push) Has been cancelled
2026-03-05 04:06:45 -05:00
debian 96bf6e5097 fase(4): crawling infrastructure with migrated code 2026-03-05 03:08:48 -05:00
debian 39c5313ba5 fase(3): crawling module domain and application 2026-03-04 16:32:09 -05:00
debian 4a58749048 fase(2): shared infrastructure layer 2026-03-04 16:26:32 -05:00
debian 0e6c0c3655 fase(1): shared domain building blocks 2026-03-04 16:22:42 -05:00
debian 2a93f1f5b7 fase(0): fix build errors — build passes with 0 errors 2026-03-04 16:18:52 -05:00
debian f8191133c8 docs: enterprise refactor plan with ralph specs 2026-03-04 16:17:03 -05:00
774 changed files with 74716 additions and 448 deletions
+45
View File
@@ -0,0 +1,45 @@
{
"permissions": {
"allow": [
"Bash(mkdir *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(rm *)",
"Bash(find *)",
"Bash(grep *)",
"Bash(sed *)",
"Bash(awk *)",
"Bash(head *)",
"Bash(tail *)",
"Bash(echo *)",
"Bash(which *)",
"Bash(pwd)",
"Bash(cd *)",
"Bash(npm *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git *)",
"Bash(docker *)",
"Bash(tsc *)",
"Bash(vitest *)",
"Bash(eslint *)",
"Bash(wc *)",
"Bash(sort *)",
"Bash(uniq *)",
"Bash(touch *)",
"Bash(chmod *)",
"Bash(test *)",
"Bash(diff *)",
"Bash(tar *)",
"Bash(curl *)",
"Bash(tree *)",
"Read",
"Write",
"Edit",
"MultiEdit"
],
"deny": []
}
}
+45
View File
@@ -0,0 +1,45 @@
# Version control
.git
.gitignore
# Node modules (installed fresh in container)
node_modules
frontend/node_modules
# Build output (compiled in container)
dist
frontend/dist
# Test files
tests
coverage
*.test.ts
*.spec.ts
vitest.config.*
# Development configs
.eslintrc*
tsconfig.tsbuildinfo
# CI/CD
.github
.ralph
# Logs and data
logs
data
*.log
# Environment files (injected at runtime)
.env
.env.*
# Docker files
docker-compose*.yml
.dockerignore
# Editor files
.vscode
.idea
*.swp
*.swo
+12
View File
@@ -0,0 +1,12 @@
ABE_API_KEY=change-me-in-production
ABE_CORS_ORIGIN=http://localhost:5173
ABE_PORT=3001
ABE_DB_PATH=./data/abe.db
ABE_REPORTS_DIR=./reports
ABE_LOGS_DIR=./logs
ABE_MAX_CONCURRENT_SESSIONS=3
ABE_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
ABE_WEBHOOK_URL=https://myapp.com/webhooks/abe
ABE_NOTIFY_MIN_SEVERITY=high
ABE_LOG_LEVEL=info
NODE_ENV=production
+121
View 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
+104
View File
@@ -0,0 +1,104 @@
name: ABE Exploratory Testing
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:
- 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 ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- 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: Wait for application to be ready
run: |
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 \
--reports-dir ./abe-reports
continue-on-error: true
- 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
+3
View File
@@ -16,6 +16,9 @@
.ralph/docs/generated/* .ralph/docs/generated/*
!.ralph/docs/generated/.gitkeep !.ralph/docs/generated/.gitkeep
# Environment
.env
# General logs # General logs
*.log *.log
+1
View File
@@ -0,0 +1 @@
c3911bafe885d664a6870305dff172e1410a95ac
+48 -137
View File
@@ -1,158 +1,69 @@
# Agent Build Instructions # ABE — Build, Test & Development Commands
## Project Setup ## Install dependencies
```bash ```bash
# Install dependencies (example for Node.js project)
npm install npm install
cd frontend && npm install && cd ..
# Or for Python project
pip install -r requirements.txt
# Or for Rust project
cargo build
``` ```
## Running Tests ## Build (backend)
```bash ```bash
# Node.js
npm test
# Python
pytest
# Rust
cargo test
```
## Build Commands
```bash
# Production build
npm run build npm run build
# or
cargo build --release
``` ```
## Development Server ## Build (frontend)
```bash ```bash
# Start development server cd frontend && npm run build
npm run dev
# or
cargo run
``` ```
## Key Learnings ## Test
- Update this section when you learn new build optimizations ```bash
- Document any gotchas or special setup requirements npm run test
- Keep track of the fastest test/build cycle ```
## Feature Development Quality Standards ## Lint
```bash
npm run lint
```
**CRITICAL**: All new features MUST meet the following mandatory requirements before being considered complete. ## Type check
```bash
npm run typecheck
```
### Testing Requirements ## Dev mode
```bash
npm run dev # backend con hot reload
cd frontend && npm run dev # frontend dev server
```
- **Minimum Coverage**: 85% code coverage ratio required for all new code ## Database
- **Test Pass Rate**: 100% - all tests must pass, no exceptions ```bash
- **Test Types Required**: npm run db:migrate # ejecutar migraciones Kysely
- Unit tests for all business logic and services ```
- Integration tests for API endpoints or main functionality
- End-to-end tests for critical user workflows
- **Coverage Validation**: Run coverage reports before marking features complete:
```bash
# Examples by language/framework
npm run test:coverage
pytest --cov=src tests/ --cov-report=term-missing
cargo tarpaulin --out Html
```
- **Test Quality**: Tests must validate behavior, not just achieve coverage metrics
- **Test Documentation**: Complex test scenarios must include comments explaining the test strategy
### Git Workflow Requirements ## Docker
```bash
docker compose up -d --build
docker compose logs -f
docker compose down
```
Before moving to the next feature, ALL changes must be: ## Verificación completa (ejecutar después de CADA tarea)
```bash
npm run build && cd frontend && npm run build && cd .. && npm run test
```
1. **Committed with Clear Messages**: ## Commit después de tarea completada
```bash ```bash
git add . git add -A && git commit -m "fase(X.Y): descripción"
git commit -m "feat(module): descriptive message following conventional commits" ```
```
- Use conventional commit format: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, etc.
- Include scope when applicable: `feat(api):`, `fix(ui):`, `test(auth):`
- Write descriptive messages that explain WHAT changed and WHY
2. **Pushed to Remote Repository**: ## Notas
```bash - Source code: src/
git push origin <branch-name> - Frontend: frontend/
``` - Tests: junto al código (*.test.ts) o en tests/
- Never leave completed features uncommitted - Reports output: reports/
- Push regularly to maintain backup and enable collaboration - Logs: logs/
- Ensure CI/CD pipelines pass before considering feature complete - Database: data/abe.db
3. **Branch Hygiene**:
- Work on feature branches, never directly on `main`
- Branch naming convention: `feature/<feature-name>`, `fix/<issue-name>`, `docs/<doc-update>`
- Create pull requests for all significant changes
4. **Ralph Integration**:
- Update .ralph/fix_plan.md with new tasks before starting work
- Mark items complete in .ralph/fix_plan.md upon completion
- Update .ralph/PROMPT.md if development patterns change
- Test features work within Ralph's autonomous loop
### Documentation Requirements
**ALL implementation documentation MUST remain synchronized with the codebase**:
1. **Code Documentation**:
- Language-appropriate documentation (JSDoc, docstrings, etc.)
- Update inline comments when implementation changes
- Remove outdated comments immediately
2. **Implementation Documentation**:
- Update relevant sections in this AGENT.md file
- Keep build and test commands current
- Update configuration examples when defaults change
- Document breaking changes prominently
3. **README Updates**:
- Keep feature lists current
- Update setup instructions when dependencies change
- Maintain accurate command examples
- Update version compatibility information
4. **AGENT.md Maintenance**:
- Add new build patterns to relevant sections
- Update "Key Learnings" with new insights
- Keep command examples accurate and tested
- Document new testing patterns or quality gates
### Feature Completion Checklist
Before marking ANY feature as complete, verify:
- [ ] All tests pass with appropriate framework command
- [ ] Code coverage meets 85% minimum threshold
- [ ] Coverage report reviewed for meaningful test quality
- [ ] Code formatted according to project standards
- [ ] Type checking passes (if applicable)
- [ ] All changes committed with conventional commit messages
- [ ] All commits pushed to remote repository
- [ ] .ralph/fix_plan.md task marked as complete
- [ ] Implementation documentation updated
- [ ] Inline code comments updated or added
- [ ] .ralph/AGENT.md updated (if new patterns introduced)
- [ ] Breaking changes documented
- [ ] Features tested within Ralph loop (if applicable)
- [ ] CI/CD pipeline passes
### Rationale
These standards ensure:
- **Quality**: High test coverage and pass rates prevent regressions
- **Traceability**: Git commits and .ralph/fix_plan.md provide clear history of changes
- **Maintainability**: Current documentation reduces onboarding time and prevents knowledge loss
- **Collaboration**: Pushed changes enable team visibility and code review
- **Reliability**: Consistent quality gates maintain production stability
- **Automation**: Ralph integration ensures continuous development practices
**Enforcement**: AI agents should automatically apply these standards to all feature development tasks without requiring explicit instruction for each task.
+148 -262
View File
@@ -1,296 +1,182 @@
# Ralph Development Instructions # ABE — Autonomous Bug Explorer
## Instrucciones Maestras para Claude Code (via Ralph)
## Context
You are Ralph, an autonomous AI development agent working on a [YOUR PROJECT NAME] project.
## Current Objectives
1. Study .ralph/specs/* to learn about the project specifications
2. Review .ralph/fix_plan.md for current priorities
3. Implement the highest priority item using best practices
4. Use parallel subagents for complex tasks (max 100 concurrent)
5. Run tests after each implementation
6. Update documentation and fix_plan.md
## Key Principles
- ONE task per loop - focus on the most important thing
- Search the codebase before assuming something isn't implemented
- Use subagents for expensive operations (file searching, analysis)
- Write comprehensive tests with clear documentation
- Update .ralph/fix_plan.md with your learnings
- Commit working changes with descriptive messages
## Protected Files (DO NOT MODIFY)
The following files and directories are part of Ralph's infrastructure.
NEVER delete, move, rename, or overwrite these under any circumstances:
- .ralph/ (entire directory and all contents)
- .ralphrc (project configuration)
When performing cleanup, refactoring, or restructuring tasks:
- These files are NOT part of your project code
- They are Ralph's internal control files that keep the development loop running
- Deleting them will break Ralph and halt all autonomous development
## 🧪 Testing Guidelines (CRITICAL)
- LIMIT testing to ~20% of your total effort per loop
- PRIORITIZE: Implementation > Documentation > Tests
- Only write tests for NEW functionality you implement
- Do NOT refactor existing tests unless broken
- Do NOT add "additional test coverage" as busy work
- Focus on CORE functionality first, comprehensive testing later
## Execution Guidelines
- Before making changes: search codebase using subagents
- After implementation: run ESSENTIAL tests for the modified code only
- If tests fail: fix them as part of your current work
- Keep .ralph/AGENT.md updated with build/run instructions
- Document the WHY behind tests and implementations
- No placeholder implementations - build it properly
## 🎯 Status Reporting (CRITICAL - Ralph needs this!)
**IMPORTANT**: At the end of your response, ALWAYS include this status block:
```
---RALPH_STATUS---
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
TASKS_COMPLETED_THIS_LOOP: <number>
FILES_MODIFIED: <number>
TESTS_STATUS: PASSING | FAILING | NOT_RUN
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
EXIT_SIGNAL: false | true
RECOMMENDATION: <one line summary of what to do next>
---END_RALPH_STATUS---
```
### When to set EXIT_SIGNAL: true
Set EXIT_SIGNAL to **true** when ALL of these conditions are met:
1. ✅ All items in fix_plan.md are marked [x]
2. ✅ All tests are passing (or no tests exist for valid reasons)
3. ✅ No errors or warnings in the last execution
4. ✅ All requirements from specs/ are implemented
5. ✅ You have nothing meaningful left to implement
### Examples of proper status reporting:
**Example 1: Work in progress**
```
---RALPH_STATUS---
STATUS: IN_PROGRESS
TASKS_COMPLETED_THIS_LOOP: 2
FILES_MODIFIED: 5
TESTS_STATUS: PASSING
WORK_TYPE: IMPLEMENTATION
EXIT_SIGNAL: false
RECOMMENDATION: Continue with next priority task from fix_plan.md
---END_RALPH_STATUS---
```
**Example 2: Project complete**
```
---RALPH_STATUS---
STATUS: COMPLETE
TASKS_COMPLETED_THIS_LOOP: 1
FILES_MODIFIED: 1
TESTS_STATUS: PASSING
WORK_TYPE: DOCUMENTATION
EXIT_SIGNAL: true
RECOMMENDATION: All requirements met, project ready for review
---END_RALPH_STATUS---
```
**Example 3: Stuck/blocked**
```
---RALPH_STATUS---
STATUS: BLOCKED
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 0
TESTS_STATUS: FAILING
WORK_TYPE: DEBUGGING
EXIT_SIGNAL: false
RECOMMENDATION: Need human help - same error for 3 loops
---END_RALPH_STATUS---
```
### What NOT to do:
- ❌ Do NOT continue with busy work when EXIT_SIGNAL should be true
- ❌ Do NOT run tests repeatedly without implementing new features
- ❌ Do NOT refactor code that is already working fine
- ❌ Do NOT add features not in the specifications
- ❌ Do NOT forget to include the status block (Ralph depends on it!)
## 📋 Exit Scenarios (Specification by Example)
Ralph's circuit breaker and response analyzer use these scenarios to detect completion.
Each scenario shows the exact conditions and expected behavior.
### Scenario 1: Successful Project Completion
**Given**:
- All items in .ralph/fix_plan.md are marked [x]
- Last test run shows all tests passing
- No errors in recent logs/
- All requirements from .ralph/specs/ are implemented
**When**: You evaluate project status at end of loop
**Then**: You must output:
```
---RALPH_STATUS---
STATUS: COMPLETE
TASKS_COMPLETED_THIS_LOOP: 1
FILES_MODIFIED: 1
TESTS_STATUS: PASSING
WORK_TYPE: DOCUMENTATION
EXIT_SIGNAL: true
RECOMMENDATION: All requirements met, project ready for review
---END_RALPH_STATUS---
```
**Ralph's Action**: Detects EXIT_SIGNAL=true, gracefully exits loop with success message
--- ---
### Scenario 2: Test-Only Loop Detected ## Visión del proyecto
**Given**:
- Last 3 loops only executed tests (npm test, bats, pytest, etc.)
- No new files were created
- No existing files were modified
- No implementation work was performed
**When**: You start a new loop iteration ABE es una plataforma enterprise self-hosted de descubrimiento autónomo de bugs
en aplicaciones web. Explora apps como un usuario real, inyecta inputs inválidos
(fuzzing), detecta anomalías, y genera bug reports reproducibles.
**Then**: You must output: **Posicionamiento**: "Playwright discovers what you test. ABE discovers what you miss."
```
---RALPH_STATUS---
STATUS: IN_PROGRESS
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 0
TESTS_STATUS: PASSING
WORK_TYPE: TESTING
EXIT_SIGNAL: false
RECOMMENDATION: All tests passing, no implementation needed
---END_RALPH_STATUS---
```
**Ralph's Action**: Increments test_only_loops counter, exits after 3 consecutive test-only loops Modelo open-core enterprise self-hosted:
- Free/OSS: exploración autónoma + reports básicos
- Pro: dashboards avanzados, integraciones, CLI/CI
- Enterprise: SSO, RBAC avanzado, LDAP, audit logs, licencia
--- ---
### Scenario 3: Stuck on Recurring Error ## Estado actual del código
**Given**:
- Same error appears in last 5 consecutive loops
- No progress on fixing the error
- Error message is identical or very similar
**When**: You encounter the same error again Las fases 1-11 del proyecto original están implementadas con esta estructura:
**Then**: You must output:
``` ```
---RALPH_STATUS--- src/
STATUS: BLOCKED ├── core/ ← interfaces.ts, ExplorationEngine, StateGraph, AnomalyDetector
TASKS_COMPLETED_THIS_LOOP: 0 ├── plugins/ ← PlaywrightAgent, collectors, exporters, fuzzers, reproducers
FILES_MODIFIED: 2 ├── server/ ← Express API server + socket.io
TESTS_STATUS: FAILING ├── db/ ← SQLite repositories (better-sqlite3)
WORK_TYPE: DEBUGGING ├── cli.ts ← CLI entry point
EXIT_SIGNAL: false frontend/ ← React + Vite + Tailwind (básico)
RECOMMENDATION: Stuck on [error description] - human intervention needed
---END_RALPH_STATUS---
``` ```
**Ralph's Action**: Circuit breaker detects repeated errors, opens circuit after 5 loops El objetivo es REFACTORIZAR este código existente hacia una arquitectura
modular hexagonal, migrando pieza por pieza sin romper funcionalidad.
**ESTRATEGIA DE MIGRACIÓN**: No reescribir de cero. Mover código existente
a la nueva estructura, adaptar interfaces, y verificar que todo sigue funcionando
después de cada movimiento.
--- ---
### Scenario 4: No Work Remaining ## Arquitectura objetivo: Modular Monolith Hexagonal
**Given**:
- All tasks in fix_plan.md are complete
- You analyze .ralph/specs/ and find nothing new to implement
- Code quality is acceptable
- Tests are passing
**When**: You search for work to do and find none ### Principio fundamental
**Then**: You must output:
``` ```
---RALPH_STATUS--- Infrastructure → Application → Domain
STATUS: COMPLETE (el código SIEMPRE apunta hacia adentro, nunca al revés)
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 0
TESTS_STATUS: PASSING
WORK_TYPE: DOCUMENTATION
EXIT_SIGNAL: true
RECOMMENDATION: No remaining work, all .ralph/specs implemented
---END_RALPH_STATUS---
``` ```
**Ralph's Action**: Detects completion signal, exits loop immediately ### Estructura de carpetas OBJETIVO
```
src/
├── shared/
│ ├── domain/
│ │ ├── Entity.ts
│ │ ├── AggregateRoot.ts
│ │ ├── ValueObject.ts
│ │ ├── UniqueId.ts
│ │ ├── Result.ts
│ │ └── DomainEvent.ts
│ ├── application/
│ │ ├── UseCase.ts
│ │ ├── EventBus.ts
│ │ └── EventHandler.ts
│ └── infrastructure/
│ ├── InProcessEventBus.ts
│ ├── DatabaseConnection.ts
│ ├── Logger.ts
│ ├── Config.ts
│ └── StorageProvider.ts
├── modules/
│ ├── crawling/
│ │ ├── domain/ (entities, value-objects, events, ports)
│ │ ├── application/ (commands, queries, event-handlers)
│ │ └── infrastructure/(adapters, repositories, http)
│ ├── fuzzing/ (misma estructura)
│ ├── findings/ (misma estructura)
│ ├── auth/ (misma estructura)
│ ├── reporting/ (misma estructura)
│ ├── integrations/ (misma estructura)
│ ├── scheduling/ (misma estructura)
│ └── licensing/ (misma estructura)
├── api/
│ ├── server.ts
│ ├── router.ts
│ └── middleware/
├── realtime/
│ └── SocketGateway.ts
├── jobs/
│ ├── JobQueue.ts
│ ├── SQLiteJobQueue.ts
│ └── workers/
├── cli/
│ └── abe.ts
└── main.ts ← composition root
```
--- ---
### Scenario 5: Making Progress ## Reglas de arquitectura INQUEBRANTABLES
**Given**:
- Tasks remain in .ralph/fix_plan.md
- Implementation is underway
- Files are being modified
- Tests are passing or being fixed
**When**: You complete a task successfully 1. **Domain layer NO importa nada externo** — ni Kysely, ni Express, ni Playwright.
2. **Cross-module communication SOLO via EventBus** — NUNCA import directo entre módulos.
**Then**: You must output: 3. **Cada módulo exporta SOLO su facade** via `index.ts`.
``` 4. **Controllers son THIN** — parsean request, llaman use case, formatean response.
---RALPH_STATUS--- 5. **Use Cases retornan Result<T, E>** — NUNCA throw para errores de negocio.
STATUS: IN_PROGRESS 6. **Un archivo = una clase = una responsabilidad**.
TASKS_COMPLETED_THIS_LOOP: 3 7. **Determinista** — no usar Math.random() sin seed. Loguear siempre el seed.
FILES_MODIFIED: 7 8. **Serializable** — entities y value objects JSON.stringify-able.
TESTS_STATUS: PASSING 9. **No AI en el core loop** — AIEnrichment es post-proceso opcional.
WORK_TYPE: IMPLEMENTATION 10. **Plugins nunca se importan desde core** — core solo define interfaces/ports.
EXIT_SIGNAL: false
RECOMMENDATION: Continue with next task from .ralph/fix_plan.md
---END_RALPH_STATUS---
```
**Ralph's Action**: Continues loop, circuit breaker stays CLOSED (normal operation)
--- ---
### Scenario 6: Blocked on External Dependency ## Stack tecnológico
**Given**:
- Task requires external API, library, or human decision
- Cannot proceed without missing information
- Have tried reasonable workarounds
**When**: You identify the blocker ### Backend
- Runtime: Node.js 20 + TypeScript 5.x (strict mode)
- HTTP: Express.js 4.x
- WebSocket: socket.io 4.x
- Database: Kysely (query builder) + better-sqlite3 (default) | pg (enterprise)
- Validation: Zod (schemas compartidos frontend/backend)
- Auth: Better Auth + CASL
- Browser: Playwright
- Logger: Pino + pino-pretty (dev)
- Jobs: SQLite-backed queue custom con worker_threads
- Scheduler: node-cron
- Security: Helmet, express-rate-limit, cors
- API docs: zod-to-openapi + Scalar UI
- Testing: Vitest + supertest (integration)
**Then**: You must output: ### Frontend
``` - React 18 + Vite + TypeScript
---RALPH_STATUS--- - shadcn/ui (Radix UI + Tailwind CSS)
STATUS: BLOCKED - Tremor + Recharts (charts/dashboards)
TASKS_COMPLETED_THIS_LOOP: 0 - TanStack Table + TanStack Query
FILES_MODIFIED: 0 - Zustand (client state)
TESTS_STATUS: NOT_RUN - React Hook Form + Zod resolver
WORK_TYPE: IMPLEMENTATION - socket.io-client
EXIT_SIGNAL: false - Framer Motion
RECOMMENDATION: Blocked on [specific dependency] - need [what's needed]
---END_RALPH_STATUS---
```
**Ralph's Action**: Logs blocker, may exit after multiple blocked loops
--- ---
## File Structure ## REGLAS OBLIGATORIAS PARA CADA TAREA
- .ralph/: Ralph-specific configuration and documentation
- specs/: Project specifications and requirements
- fix_plan.md: Prioritized TODO list
- AGENT.md: Project build and run instructions
- PROMPT.md: This file - Ralph development instructions
- logs/: Loop execution logs
- docs/generated/: Auto-generated documentation
- src/: Source code implementation
- examples/: Example usage and test cases
## Current Task ### Antes de empezar
Follow .ralph/fix_plan.md and choose the most important item to implement next. 1. Leer la tarea actual del fix_plan.md
Use your judgment to prioritize what will have the biggest impact on project progress. 2. Leer la spec correspondiente en .ralph/specs/ SI existe
3. Verificar que las dependencias (tareas previas) están completas
Remember: Quality over speed. Build it right the first time. Know when you're done. ### Después de CADA tarea individual
1. `npm run build` — DEBE compilar sin errores
2. `cd frontend && npm run build` — DEBE compilar sin errores
3. `npm run test` — DEBE pasar (o no romper tests existentes)
4. Si ALGUNO falla → NO marcar tarea → arreglar PRIMERO
### Después de marcar tarea como completa
1. `git add -A`
2. `git commit -m "fase(X.Y): descripción breve de la tarea"`
Ejemplo: `git commit -m "fase(1.3): create ValueObject base class with equals"`
3. Verificar que el commit se hizo correctamente
### Reglas de código
- Todo nuevo código DEBE tener tipos explícitos (CERO `any`)
- Imports ordenados: node_modules → shared → modules → relative
- Nombres: PascalCase para clases, camelCase para funciones/variables
- Cada módulo nuevo DEBE tener al menos un test unitario
- Código existente que se MUEVE debe seguir funcionando igual
---
## Señal de completado
Cuando TODAS las tareas en fix_plan.md estén marcadas [x]:
RALPH_STATUS:
completion_indicators: done
EXIT_SIGNAL: true
summary: "ABE enterprise refactor complete."
+439 -22
View File
@@ -1,27 +1,444 @@
# Ralph Fix Plan # ABE Enterprise Refactor — Fix Plan
## High Priority ## REGLAS CRÍTICAS
- [ ] Set up basic project structure and build system 1. NO pasar a la siguiente tarea si el build falla
- [ ] Define core data structures and types 2. Hacer `git commit` después de CADA tarea completada
- [ ] Implement basic input/output handling 3. Leer la spec en `.ralph/specs/` ANTES de cada phase
- [ ] Create test framework and initial tests 4. Los tests DEBEN pasar antes de marcar [x]
5. Formato commit: `git commit -m "fase(X.Y): descripción"`
## Medium Priority ---
- [ ] Add error handling and validation
- [ ] Implement core business logic
- [ ] Add configuration management
- [ ] Create user documentation
## Low Priority ## Phase 0: Hotfix — Build actual funcional [COMPLETO]
- [ ] Performance optimization
- [ ] Extended feature set
- [ ] Integration with external services
- [ ] Advanced error recovery
## Completed - [x] 0.1: Fix errores TypeScript en src/ que impidan compilación (IAnomaly import, NodeListOf iterator, cualquier otro)
- [x] Project initialization - [x] 0.2: Verificar `npm run build` pasa con 0 errores
- [x] 0.3: Verificar `cd frontend && npm run build` pasa con 0 errores
- [x] 0.4: Verificar que la app arranca con `npm run dev` sin crash
- [x] 0.5: Commit: `git add -A && git commit -m "fase(0): fix build errors"`
## Notes ---
- Focus on MVP functionality first
- Ensure each feature is properly tested ## Phase 1: Shared Domain — Building Blocks [COMPLETO]
- Update this file after each major milestone Spec: `.ralph/specs/phase-01-shared-domain.md`
- [x] 1.1: Crear directorio `src/shared/domain/`
- [x] 1.2: Crear `src/shared/domain/Result.ts` — Result<T, E> con Ok(), Err(), isOk(), isErr()
- [x] 1.3: Crear `src/shared/domain/UniqueId.ts` — UUID v4 wrapper con create(), toString(), equals()
- [x] 1.4: Crear `src/shared/domain/Entity.ts` — base class con _id: UniqueId, equals()
- [x] 1.5: Crear `src/shared/domain/AggregateRoot.ts` — extends Entity + domainEvents[], addDomainEvent(), clearEvents()
- [x] 1.6: Crear `src/shared/domain/ValueObject.ts` — base class inmutable con props frozen, equals()
- [x] 1.7: Crear `src/shared/domain/DomainEvent.ts` — interface: eventId, eventName, aggregateId, occurredOn, payload
- [x] 1.8: Crear `src/shared/application/UseCase.ts` — interface: execute(req) → Promise<Result<TRes, TErr>>
- [x] 1.9: Crear `src/shared/application/EventBus.ts` — interface: publish(event), subscribe(name, handler)
- [x] 1.10: Crear `src/shared/application/EventHandler.ts` — interface: handle(event) → Promise<void>
- [x] 1.11: Crear `src/shared/domain/index.ts` — barrel export de todo shared/domain
- [x] 1.12: Crear `src/shared/application/index.ts` — barrel export de todo shared/application
- [x] 1.13: Tests unitarios: Result (Ok/Err/isOk/isErr), Entity (equals by id), ValueObject (equals by props), UniqueId (create/equals)
- [x] 1.14: Verificar build completo + commit: `fase(1): shared domain building blocks`
---
## Phase 2: Shared Infrastructure [COMPLETO]
Spec: `.ralph/specs/phase-02-shared-infrastructure.md`
- [x] 2.1: Instalar deps: `npm i kysely better-sqlite3 pino pino-pretty zod helmet express-rate-limit dotenv uuid` + `npm i -D @types/better-sqlite3 @types/uuid`
- [x] 2.2: Crear `src/shared/infrastructure/Config.ts` — Zod schema para TODAS las env vars con defaults sensatos
- [x] 2.3: Crear `src/shared/infrastructure/Logger.ts` — Pino factory: createLogger(config) retorna pino.Logger, pino-pretty en dev
- [x] 2.4: Crear `src/shared/infrastructure/DatabaseConnection.ts` — Kysely factory: createDatabase(config) soporta SQLite (default) y PostgreSQL (si config.db.driver === 'postgres')
- [x] 2.5: Crear `src/shared/infrastructure/InProcessEventBus.ts` — implementa EventBus con Node EventEmitter, logging de eventos, error handling en handlers
- [x] 2.6: Crear `src/shared/infrastructure/StorageProvider.ts` — interface IStorageProvider (save/get/delete/exists) + LocalStorageProvider (filesystem)
- [x] 2.7: Crear `src/shared/infrastructure/index.ts` — barrel export
- [x] 2.8: Crear `src/db/migrations/001_initial_schema.ts` — migración Kysely que crea las tablas existentes (sessions, states, actions, anomalies, notifications) con IF NOT EXISTS
- [x] 2.9: Crear `src/db/migrator.ts` — setup Kysely Migrator + función runMigrations()
- [x] 2.10: Añadir script `"db:migrate"` a package.json
- [x] 2.11: Tests: Config validation (valid + invalid), EventBus (publish/subscribe/error handling), StorageProvider (save/get/delete)
- [x] 2.12: Verificar build completo + commit: `fase(2): shared infrastructure layer`
---
## Phase 3: Crawling Module — Domain + Application [COMPLETO]
Spec: `.ralph/specs/phase-03-crawling-domain.md`
- [x] 3.1: Crear `src/modules/crawling/domain/entities/CrawlSession.ts` — AggregateRoot con url, status, seed, maxStates, statesVisited, config
- [x] 3.2: Crear `src/modules/crawling/domain/entities/CrawlState.ts` — Entity con url, title, domSnapshot, visitCount
- [x] 3.3: Crear `src/modules/crawling/domain/entities/CrawlAction.ts` — Entity con type, selector, value, seed, stateId, sequenceOrder
- [x] 3.4: Crear value objects: `Url.ts`, `Selector.ts`, `SessionStatus.ts` (running/completed/failed/stopped)
- [x] 3.5: Crear events: `CrawlStarted.ts`, `StateDiscovered.ts`, `ActionExecuted.ts`, `CrawlCompleted.ts`, `CrawlFailed.ts`
- [x] 3.6: Crear ports: `ICrawlerEngine.ts` (launch/close/discoverActions/executeAction/captureState), `ICrawlSessionRepository.ts` (save/findById/findAll/update), `IStateRepository.ts`
- [x] 3.7: Crear `application/commands/StartCrawlCommand.ts` — use case que valida config, crea CrawlSession, emite CrawlStarted
- [x] 3.8: Crear `application/commands/StopCrawlCommand.ts` — use case que para sesión, emite CrawlCompleted
- [x] 3.9: Crear `application/queries/GetSessionQuery.ts` y `ListSessionsQuery.ts`
- [x] 3.10: Crear `modules/crawling/index.ts` — barrel export público
- [x] 3.11: Tests: CrawlSession creation + domain events, StartCrawlCommand con mock repository
- [x] 3.12: Verificar build + commit: `fase(3): crawling module domain and application`
---
## Phase 4: Crawling Module — Infrastructure (migración código existente) [COMPLETO]
Spec: `.ralph/specs/phase-04-crawling-infrastructure.md`
- [x] 4.1: Copiar `src/plugins/agents/PlaywrightAgent.ts``src/modules/crawling/infrastructure/adapters/PlaywrightCrawlerEngine.ts`, adaptar para implementar ICrawlerEngine port
- [x] 4.2: Copiar `src/core/StateGraph.ts``src/modules/crawling/infrastructure/adapters/StateGraph.ts`, mantener lógica BFS
- [x] 4.3: Copiar `src/core/ExplorationEngine.ts``src/modules/crawling/infrastructure/adapters/ExplorationOrchestrator.ts`, adaptar para usar ports en vez de imports directos
- [x] 4.4: Crear `infrastructure/repositories/KyselyCrawlSessionRepository.ts` — implementa ICrawlSessionRepository con Kysely
- [x] 4.5: Crear `infrastructure/repositories/KyselyStateRepository.ts`
- [x] 4.6: Crear `infrastructure/http/CrawlingController.ts` — Express routes: POST /api/sessions, GET /api/sessions, GET /api/sessions/:id, DELETE /api/sessions/:id
- [x] 4.7: Verificar que crear sesión + ejecutar crawl funciona end-to-end
- [x] 4.8: Verificar build + commit: `fase(4): crawling infrastructure with migrated code`
---
## Phase 5: Findings Module [COMPLETO]
Spec: `.ralph/specs/phase-05-findings-module.md`
- [x] 5.1: Crear `domain/entities/Finding.ts` — AggregateRoot con severity, type, evidence, status, actionTrace
- [x] 5.2: Crear value objects: `Severity.ts` (low/medium/high/critical), `FindingType.ts`, `Evidence.ts`, `FindingStatus.ts` (open/investigating/resolved/closed)
- [x] 5.3: Crear events: `FindingCreated.ts`, `FindingResolved.ts`, `FindingEnriched.ts`
- [x] 5.4: Crear ports: `IFindingRepository.ts`, `IAIEnricher.ts`
- [x] 5.5: Crear commands: `CreateFindingCommand.ts`, `EnrichFindingCommand.ts`, `ResolveFindingCommand.ts`
- [x] 5.6: Crear queries: `GetFindingQuery.ts`, `ListFindingsQuery.ts` (filtros: severity, type, session, status, search), `FindingStatsQuery.ts`
- [x] 5.7: Crear `event-handlers/OnAnomalyDetected.ts` — escucha eventos crawling → crea Finding
- [x] 5.8: Crear `infrastructure/repositories/KyselyFindingRepository.ts`
- [x] 5.9: Migrar exporters existentes → `infrastructure/exporters/` (MarkdownExporter, JSONExporter)
- [x] 5.10: Crear `infrastructure/exporters/PlaywrightScriptExporter.ts` — genera test Playwright reproducible desde actionTrace
- [x] 5.11: Crear `infrastructure/http/FindingsController.ts` — routes para anomalies existentes + nuevas
- [x] 5.12: Migración Kysely: tabla findings con columnas status, browser, ai_enrichment_json
- [x] 5.13: Tests: Finding aggregate, CreateFinding, ListFindings con filtros
- [x] 5.14: Verificar build + commit: `fase(5): findings module complete`
---
## Phase 6: Fuzzing Module [COMPLETO]
Spec: `.ralph/specs/phase-06-fuzzing-module.md`
- [x] 6.1: Crear domain: `FuzzSession.ts` (AggregateRoot), `FuzzResult.ts` (Entity)
- [x] 6.2: Crear value objects: `FuzzStrategy.ts`, `FuzzPayload.ts`, `Seed.ts`, `FuzzIntensity.ts`
- [x] 6.3: Crear events: `FuzzStarted.ts`, `VulnerabilityDetected.ts`, `FuzzCompleted.ts`
- [x] 6.4: Crear port: `IFuzzerEngine.ts`
- [x] 6.5: Crear `commands/RunFuzzCommand.ts`
- [x] 6.6: Crear `event-handlers/OnActionExecuted.ts` — escucha crawling → trigger fuzzing
- [x] 6.7: Migrar las 5 estrategias existentes → `infrastructure/strategies/` (Empty, Oversized, SpecialChars, TypeMismatch, Boundary)
- [x] 6.8: Migrar `FuzzingEngine.ts` y `InputTypeDetector.ts``infrastructure/adapters/`
- [x] 6.9: Crear `infrastructure/http/FuzzingController.ts`
- [x] 6.10: Tests: cada estrategia de fuzzing genera payloads válidos
- [x] 6.11: Verificar build + commit: `fase(6): fuzzing module complete`
---
## Phase 7: API Server Refactor + Composition Root [COMPLETO]
Spec: `.ralph/specs/phase-07-api-server.md`
- [x] 7.1: Crear `src/api/middleware/errorHandler.ts` — AppError hierarchy (ValidationError, AuthenticationError, ForbiddenError, NotFoundError) + global error handler
- [x] 7.2: Crear `src/api/middleware/requestId.ts` — genera UUID por request, adjunta a req + pino child logger
- [x] 7.3: Crear `src/api/middleware/notFound.ts` — 404 handler para rutas no encontradas
- [x] 7.4: Crear `src/api/server.ts` — Express app con middleware stack: requestId → helmet → cors → rateLimit → bodyParser → routes → notFound → errorHandler
- [x] 7.5: Crear `src/api/router.ts` — registra routes de TODOS los módulos (crawling, findings, fuzzing)
- [x] 7.6: Crear `src/realtime/SocketGateway.ts` — socket.io server que subscribe a EventBus y emite a clientes
- [x] 7.7: Crear `src/main.ts` — composition root: load config → create logger → create db → run migrations → create event bus → create repositories → create use cases → subscribe handlers → create controllers → create Express → create socket.io → start listening
- [x] 7.8: Implementar graceful shutdown en main.ts: SIGTERM/SIGINT → stop accepting → close sockets → close db → flush logs → exit
- [x] 7.9: Health endpoints: GET /health/live (process alive), GET /health/ready (DB check)
- [x] 7.10: Verificar que TODOS los endpoints existentes siguen funcionando tras refactor
- [x] 7.11: Verificar build + commit: `fase(7): api server refactor with composition root`
---
## Phase 8: Job Queue System [COMPLETO]
Spec: `.ralph/specs/phase-08-job-queue.md`
- [x] 8.1: Crear `src/jobs/JobQueue.ts` — interface: enqueue, start, pause, waitForActive
- [x] 8.2: Crear `src/jobs/SQLiteJobQueue.ts` — tabla jobs con status/type/payload/attempts/run_at, polling worker
- [x] 8.3: Migración Kysely: tabla jobs
- [x] 8.4: Crear `src/jobs/workers/ExplorationWorker.ts` — ejecuta crawl como job
- [x] 8.5: Crear `src/jobs/workers/ReportWorker.ts` — genera reports en background
- [x] 8.6: Integrar job queue en main.ts, mover exploraciones de sync a job-based
- [x] 8.7: Tests: enqueue → dequeue → complete cycle, failed job retry
- [x] 8.8: Verificar build + commit: `fase(8): sqlite job queue system`
---
## Phase 9: Auth Module [COMPLETO]
Spec: `.ralph/specs/phase-09-auth-module.md`
- [x] 9.1: Instalar: `npm i @casl/ability argon2 cookie-parser` (custom auth sin better-auth, per spec nota)
- [x] 9.2: Crear domain: `User.ts` (AggregateRoot), `Organization.ts` (AggregateRoot), `ApiKey.ts` (Entity)
- [x] 9.3: Crear value objects: `Email.ts`, `Role.ts` (owner/admin/member/viewer), `Permission.ts`
- [x] 9.4: Crear events: `UserCreated.ts`, `UserLoggedIn.ts`, `OrgCreated.ts`, `MemberInvited.ts`
- [x] 9.5: Crear ports: `IUserRepository.ts`, `IOrganizationRepository.ts`, `IApiKeyRepository.ts`, `ISessionRepository.ts`
- [x] 9.6: Crear commands: `RegisterCommand.ts`, `LoginCommand.ts`, `CreateOrganizationCommand.ts`, `InviteMemberCommand.ts`, `CreateApiKeyCommand.ts`
- [x] 9.7: Crear queries: `GetUserQuery.ts`, `ListOrgMembersQuery.ts`
- [x] 9.8: Crear `infrastructure/auth/PasswordService.ts` — argon2 hash/verify
- [x] 9.9: Crear `infrastructure/casl/AbilityFactory.ts` — define permisos por role
- [x] 9.10: Crear `application/middleware/AuthMiddleware.ts` — cookie → Bearer → API key → 401
- [x] 9.11: Crear `application/middleware/RBACMiddleware.ts` — verifica permisos CASL
- [x] 9.12: Crear `infrastructure/repositories/KyselyUserRepository.ts` + Org + ApiKey + Session repos
- [x] 9.13: Crear `infrastructure/http/AuthController.ts` — register, login, logout, me, setup-required, setup, orgs, api-keys
- [x] 9.14: Migración Kysely: tablas users, organizations, org_members, api_keys, auth_sessions
- [x] 9.15: First-run detection: si 0 users → GET /api/auth/setup-required retorna { required: true }
- [x] 9.16: POST /api/auth/setup — crea primer user como owner + organización default
- [x] 9.17: Integrar AuthMiddleware en todas las rutas /api/ excepto /api/auth/*
- [x] 9.18: Tests: Email, Role, User, Organization, RegisterCommand, LoginCommand, CASL (23 tests)
- [x] 9.19: Verificar build + commit: `fase(9): auth module with better-auth and casl`
---
## Phase 10: Frontend — shadcn/ui Shell [COMPLETO]
Spec: `.ralph/specs/phase-10-frontend-shell.md`
- [x] 10.1: En frontend/: instalar shadcn/ui con `npx shadcn@latest init` (Vite, Zinc, CSS variables, Tailwind)
- [x] 10.2: Instalar componentes shadcn: button, input, card, badge, dialog, dropdown-menu, command, sidebar, tabs, table, toast, form, separator, avatar, skeleton, tooltip, sheet, select, textarea, label, switch, alert
- [x] 10.3: Instalar deps: `npm i @tanstack/react-query @tanstack/react-table zustand react-hook-form @hookform/resolvers framer-motion react-hotkeys-hook`
- [x] 10.4: Crear layout: `components/layout/AppSidebar.tsx` — sidebar collapsible con nav items (Dashboard, Explorations, Findings, Reports, Settings)
- [x] 10.5: Crear `components/layout/TopBar.tsx` — logo, search trigger (⌘K), theme toggle, user avatar menu
- [x] 10.6: Crear `components/layout/AppLayout.tsx` — wrapper: Sidebar + TopBar + Content outlet
- [x] 10.7: Crear `components/layout/CommandPalette.tsx` — ⌘K con shadcn Command component
- [x] 10.8: Crear ThemeProvider: dark mode como default, toggle dark/light, persistir en localStorage
- [x] 10.9: Crear `lib/api.ts` — API client con fetch, credentials: include, auto-redirect a /login en 401
- [x] 10.10: Crear `lib/queryClient.ts` — TanStack Query provider
- [x] 10.11: Crear `stores/uiStore.ts` — Zustand: sidebarCollapsed, theme
- [x] 10.12: Crear pages/Login.tsx — form email + password con shadcn
- [x] 10.13: Crear pages/Setup.tsx — wizard first-run (crear admin + nombre org)
- [x] 10.14: Crear `components/layout/ProtectedRoute.tsx` — check auth, redirect a /login o /setup
- [x] 10.15: Actualizar App.tsx con React Router: / (dashboard), /login, /setup, /sessions/:id, /findings/:id, /settings — todo wrapped en ProtectedRoute excepto login/setup
- [x] 10.16: Verificar frontend build + commit: `fase(10): frontend shadcn-ui shell with auth`
---
## Phase 11: Dashboard Page [COMPLETO]
Spec: `.ralph/specs/phase-11-dashboard.md`
- [x] 11.1: Instalar en frontend: `npm i tremor recharts`
- [x] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats
- [x] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession
- [x] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect
- [x] 11.5: Crear `components/dashboard/KPICards.tsx` — 4 cards Tremor: Total Findings, Critical/High, Active Sessions, Coverage
- [x] 11.6: Crear `components/dashboard/TrendChart.tsx` — Recharts AreaChart stacked por severity, últimos 30 días
- [x] 11.7: Crear `components/dashboard/SeverityDistribution.tsx` — Recharts PieChart con colores por severity
- [x] 11.8: Crear `components/dashboard/RecentFindings.tsx` — TanStack Table, 10 rows, click → /findings/:id
- [x] 11.9: Crear `components/dashboard/ActiveSessions.tsx` — lista con progress bars, click → /sessions/:id
- [x] 11.10: Crear `components/dashboard/QuickActions.tsx` — botón "New Exploration" prominente
- [x] 11.11: Crear `pages/Dashboard.tsx` — ensambla todo, responsive 2col desktop 1col mobile
- [x] 11.12: Conectar real-time: socket events actualizan KPIs y recent findings
- [x] 11.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime`
---
## Phase 12: Sessions Pages [COMPLETO]
Spec: `.ralph/specs/phase-12-sessions-pages.md`
- [x] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section
- [x] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
- [x] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs
- [x] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly)
- [x] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges
- [x] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
- [x] 12.7: Progress bar estados explorados / maxStates
- [x] 12.8: Stop button funcional (DELETE /api/sessions/:id)
- [x] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
---
## Phase 13: Findings Pages [COMPLETO]
Spec: `.ralph/specs/phase-13-findings-pages.md`
- [x] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search
- [x] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout
- [x] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb
- [x] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer)
- [x] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI"
- [x] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
- [x] 13.7: Status workflow buttons: open → investigating → resolved → closed
- [x] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul
- [x] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view`
---
## Phase 14: Settings Pages [COMPLETO]
Spec: `.ralph/specs/phase-14-settings-pages.md`
- [x] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
- [x] 14.2: Section "Profile" — cambiar nombre, email, password
- [x] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
- [x] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
- [x] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
- [x] 14.6: Section "Notifications" — Slack webhook URL, min severity
- [x] 14.7: Section "Appearance" — tema dark/light, accent color
- [x] 14.8: Section "License" — ver status licencia, input para activar key
- [x] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
---
## Phase 15: Reporting Module [COMPLETO]
Spec: `.ralph/specs/phase-15-reporting.md`
- [x] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
- [x] 15.2: Crear port: `IReportGenerator.ts`
- [x] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
- [x] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
- [x] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
- [x] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download
- [x] 15.7: Integrar con job queue: generación async
- [x] 15.8: Migración Kysely: tabla reports
- [x] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
- [x] 15.10: Tests: GenerateReportCommand con mock generator
- [x] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
---
## Phase 16: Integrations Module [COMPLETO]
Spec: `.ralph/specs/phase-16-integrations.md`
- [x] 16.1: Instalar: `npm i @slack/web-api @octokit/rest`
- [x] 16.2: Crear domain: `Integration.ts` (Entity), `WebhookEndpoint.ts` (Entity)
- [x] 16.3: Crear value objects: `IntegrationType.ts` (jira/slack/github/webhook), `WebhookSecret.ts`
- [x] 16.4: Crear port: `IIntegrationProvider.ts` (sendFinding)
- [x] 16.5: Crear `infrastructure/webhooks/WebhookDispatcher.ts` — HMAC-SHA256 signature, retry con exponential backoff (3 intentos)
- [x] 16.6: Crear `infrastructure/providers/SlackProvider.ts` — Block Kit message con severity, description, link
- [x] 16.7: Crear `infrastructure/providers/GitHubIssuesProvider.ts` — crea issue con reproduction steps
- [x] 16.8: Crear `infrastructure/providers/JiraProvider.ts` — REST API v3, crea issue con screenshots
- [x] 16.9: Crear `event-handlers/OnFindingCreated.ts` — dispatch a todas las integrations activas
- [x] 16.10: Crear `infrastructure/http/IntegrationsController.ts` — CRUD integrations + webhooks
- [x] 16.11: Migración Kysely: tables integrations, webhook_endpoints, webhook_deliveries
- [x] 16.12: Frontend: Settings/Integrations con forms por provider (Slack webhook URL, Jira config, GitHub token, custom webhook)
- [x] 16.13: Tests: webhook dispatch + HMAC verification
- [x] 16.14: Verificar build completo + commit: `fase(16): integrations module`
---
## Phase 17: Licensing Module [COMPLETO]
Spec: `.ralph/specs/phase-17-licensing.md`
- [x] 17.1: Crear domain: `License.ts` (Entity), value objects `LicensePlan.ts` (free/pro/enterprise), `FeatureEntitlement.ts`
- [x] 17.2: Crear port: `ILicenseValidator.ts` (validate, getEntitlements)
- [x] 17.3: Crear `infrastructure/RSALicenseValidator.ts` — verifica firma RSA-2048 con public key bundled
- [x] 17.4: Crear feature flags: `FREE_FEATURES`, `PRO_FEATURES`, `ENTERPRISE_FEATURES` arrays
- [x] 17.5: Crear `infrastructure/middleware/FeatureGateMiddleware.ts` — checkea feature en license antes de permitir request
- [x] 17.6: Crear `infrastructure/http/LicensingController.ts` — POST /api/license/activate, GET /api/license/status
- [x] 17.7: Crear `scripts/generate-license.ts` — CLI tool para generar license keys firmadas (uso interno)
- [x] 17.8: Integrar gate checks en rutas Pro/Enterprise (reporting, integrations, etc.)
- [x] 17.9: Frontend: License section en Settings
- [x] 17.10: Tests: valid license passes, expired fails, wrong signature fails, feature gate blocks
- [x] 17.11: Verificar build completo + commit: `fase(17): licensing module with RSA validation`
---
## Phase 18: CLI + CI/CD [COMPLETO]
Spec: `.ralph/specs/phase-18-cli-cicd.md`
- [x] 18.1: Instalar: `npm i commander`
- [x] 18.2: Refactorizar `src/cli/abe.ts` con commander: comando `explore` con flags --url, --config (json file), --output (json|junit|markdown), --fail-on-severity, --api-key
- [x] 18.3: Comando `abe report` — genera report de una sesión por id
- [x] 18.4: Comando `abe status` — ping al servidor, muestra sessions activas
- [x] 18.5: Output JUnit XML: cada finding = failing test, cada state sin findings = passing test
- [x] 18.6: Exit codes: 0=clean, 1=findings over threshold, 2=error
- [x] 18.7: Crear `.github/actions/abe-explore/action.yml` — GitHub Action composite
- [x] 18.8: Crear `Dockerfile.ci` — imagen con Chromium para CI (basada en mcr.microsoft.com/playwright)
- [x] 18.9: Crear `.github/workflows/abe-example.yml` — ejemplo completo
- [x] 18.10: Actualizar README.md con sección CLI
- [x] 18.11: Verificar build completo + commit: `fase(18): cli and cicd integration`
---
## Phase 19: Scheduling Module Refactor [COMPLETO]
- [x] 19.1: Migrar scheduling existente → nueva estructura modular (domain/application/infrastructure)
- [x] 19.2: Crear Schedule aggregate con cron validation (Zod)
- [x] 19.3: Integrar con job queue
- [x] 19.4: Crear SchedulingController con CRUD + toggle
- [x] 19.5: Frontend: Schedules section en Settings
- [x] 19.6: Verificar build + commit: `fase(19): scheduling module refactor`
---
## Phase 20: Visual Regression Refactor [COMPLETO]
- [x] 20.1: Migrar visual regression existente → nueva estructura modular
- [x] 20.2: Integrar con StorageProvider para screenshots
- [x] 20.3: Refactorizar frontend /visual-review con shadcn/ui components
- [x] 20.4: Verificar build + commit: `fase(20): visual regression refactor`
---
## Phase 21: API Documentation [COMPLETO]
- [x] 21.1: Instalar: `npm i @asteasolutions/zod-to-openapi @scalar/express-api-reference`
- [x] 21.2: Crear Zod schemas compartidos para TODOS los endpoints (request + response)
- [x] 21.3: Generar OpenAPI 3.1 spec desde Zod schemas
- [x] 21.4: Montar Scalar UI en GET /api-docs
- [x] 21.5: Servir spec JSON en GET /api-docs/openapi.json
- [x] 21.6: Verificar que todos los endpoints están documentados
- [x] 21.7: Verificar build + commit: `fase(21): openapi documentation with scalar`
---
## Phase 22: Docker Production [COMPLETO]
- [x] 22.1: Refactorizar Dockerfile backend: multi-stage, node:20-alpine, tini como init, non-root user, HEALTHCHECK
- [x] 22.2: Refactorizar frontend Dockerfile: multi-stage build + nginx
- [x] 22.3: Actualizar docker-compose.yml: healthcheck, restart policies, volumes, env_file
- [x] 22.4: Crear docker-compose.prod.yml
- [x] 22.5: Crear .dockerignore optimizado
- [x] 22.6: CMD DEBE ser `["tini", "--", "node", "dist/main.js"]` — NUNCA npm
- [x] 22.7: Verificar imagen final < 200MB
- [x] 22.8: Verificar docker compose up funciona end-to-end
- [x] 22.9: Commit: `fase(22): docker production setup`
---
## Phase 23: Observability [COMPLETO]
- [x] 23.1: Request correlation: requestId en CADA log entry via pino child logger
- [x] 23.2: Structured error logging con contexto (userId, sessionId, etc.)
- [x] 23.3: Liveness probe: GET /health/live
- [x] 23.4: Readiness probe: GET /health/ready (DB + job queue check)
- [x] 23.5: Startup probe: medir tiempo de arranque, loguear
- [x] 23.6: Commit: `fase(23): observability and health probes`
---
## Phase 24: Onboarding + First-Run [COMPLETO]
- [x] 24.1: Detectar first-run en frontend (GET /api/auth/setup-required)
- [x] 24.2: Wizard multi-step: paso 1 crear admin, paso 2 nombre org, paso 3 "Start your first exploration" con URL input
- [x] 24.3: Empty states: ilustraciones/mensajes en tablas vacías ("No findings yet. Start an exploration!")
- [x] 24.4: Commit: `fase(24): onboarding and first-run experience`
---
## Phase 25: Polish + Quality [COMPLETO]
- [x] 25.1: Audit TypeScript strict — eliminar TODOS los `any` restantes
- [x] 25.2: Loading skeletons en todas las pages (shadcn Skeleton)
- [x] 25.3: Error boundaries en cada page
- [x] 25.4: Keyboard shortcuts: ⌘K (command palette), Esc (close dialogs), N (new exploration from dashboard)
- [x] 25.5: Responsive mobile: sidebar collapse, tables scroll, forms stack
- [x] 25.6: README.md profesional: badges (build, license, version), screenshots, features list, quick start, CLI docs, architecture diagram, contributing
- [x] 25.7: CONTRIBUTING.md
- [x] 25.8: LICENSE files: MIT para core, archivo LICENSE-ENTERPRISE separado
- [x] 25.9: Commit: `fase(25): polish and quality improvements`
---
## Phase 26: SSO Enterprise [PENDIENTE — ENTERPRISE ONLY]
- [x] 26.1: SAML 2.0 via @node-saml/passport-saml con MultiSamlStrategy
- [x] 26.2: OIDC via openid-client (Okta, Azure AD, Google Workspace)
- [x] 26.3: Per-organization IdP configuration
- [x] 26.4: LDAP/AD integration via passport-ldapauth
- [x] 26.5: MFA (TOTP) support
- [x] 26.6: Audit log completo (who did what, when)
- [x] 26.7: Session management dashboard (ver/revocar sessions activas)
- [x] 26.8: Feature-gated tras LICENSE enterprise
- [x] 26.9: Commit: `fase(26): enterprise sso saml oidc ldap`
---
## Phase 27: Advanced Enterprise [PENDIENTE — ENTERPRISE ONLY]
- [x] 27.1: Data retention policies (auto-delete findings > X days)
- [x] 27.2: Backup/restore CLI tool
- [x] 27.3: White-labeling (CSS custom properties + logo upload)
- [x] 27.4: PostgreSQL support validado end-to-end
- [x] 27.5: Email notifications (nodemailer + templates)
- [x] 27.6: Kubernetes Helm chart
- [x] 27.7: Commit: `fase(27): advanced enterprise features`
+1
View File
@@ -0,0 +1 @@
{"status": "failed", "timestamp": "2026-03-08 07:22:04"}
+130
View File
@@ -0,0 +1,130 @@
# ABE — AI Bug Report Enrichment Specification
## Concepto
Este es el diferenciador más importante de ABE frente a cualquier competidor.
Después de detectar una anomalía, ABE puede usar una LLM para enriquecer
el bug report con un análisis inteligente: causa probable, impacto,
sugerencia de fix, y prompt listo para usar con Claude/GPT.
## IMPORTANTE: esto es una capa OPCIONAL sobre el core determinista.
El core engine nunca llama a LLMs. El enriquecimiento es post-procesado,
ejecutado solo si el usuario lo configura.
## Qué genera la IA
### 1. Root Cause Analysis
A partir del action trace, HTTP log, console errors y DOM snapshot,
la IA propone la causa más probable del bug.
Ejemplo: "The 500 error is likely caused by missing server-side validation
of the email field. The server crashes when receiving an empty string
where a valid email is expected."
### 2. User Impact Assessment
La IA evalúa el impacto del bug en términos de negocio:
"This bug blocks users from completing registration. Any user who
submits an empty email will encounter an unhandled server error,
preventing account creation."
### 3. Suggested Fix
La IA propone un fix concreto:
"Add server-side validation: check if email is present and valid
before processing. Return a 422 with a descriptive error message
instead of propagating the exception."
### 4. AI-Ready Debug Prompt
Un prompt completo listo para copiar y pegar en Claude/ChatGPT:
```
Bug Report Context:
- Type: HTTP 500 on form submission
- Steps to reproduce: [exact action trace]
- Error: [exact error message]
- Request: POST /api/register with body {"email": ""}
- Response: 500 Internal Server Error
Please analyze this bug and provide:
1. Root cause
2. Code fix
3. Test case to prevent regression
```
## Implementación
### Provider abstraction
```typescript
interface IAIProvider {
name: string;
enrich(anomaly: IAnomaly, context: IEnrichmentContext): Promise<IAIEnrichment>;
}
interface IEnrichmentContext {
domSnapshot: string;
httpLog: IHttpResponse[];
consoleErrors: string[];
actionTrace: IAction[];
pageTitle: string;
url: string;
}
interface IAIEnrichment {
rootCause: string;
userImpact: string;
suggestedFix: string;
debugPrompt: string;
confidence: 'low' | 'medium' | 'high';
generatedAt: number;
provider: string;
model: string;
}
```
### Providers implementados
- `ClaudeProvider` — usa Anthropic API (claude-3-5-haiku — rápido y barato)
- `OpenAIProvider` — usa OpenAI API (gpt-4o-mini)
- `OllamaProvider` — usa Ollama local (llama3.2 — sin API key, offline)
### Cuándo se ejecuta
- Automático: si `aiEnrichment.autoEnrich: true`, se ejecuta tras cada anomalía high/critical
- Manual: botón "Enrich with AI" en AnomalyDetail page
- No bloquea: el bug report se guarda sin enriquecimiento, la IA lo añade async
## Configuración en .env
```
ABE_AI_PROVIDER=claude # claude | openai | ollama | none
ABE_AI_API_KEY=sk-ant-xxx # Anthropic key (si provider=claude)
ABE_OPENAI_API_KEY=sk-xxx # OpenAI key (si provider=openai)
ABE_OLLAMA_URL=http://localhost:11434 # (si provider=ollama)
ABE_AI_MODEL=claude-haiku-4-5 # modelo específico (opcional)
ABE_AI_AUTO_ENRICH=false # default false para no incurrir en costes
ABE_AI_MIN_SEVERITY=high # solo enriquecer high/critical automáticamente
```
## Modelo de datos — añadir a SQLite
### Añadir columna a anomalies
```sql
ALTER TABLE anomalies ADD COLUMN ai_enrichment_json TEXT;
ALTER TABLE anomalies ADD COLUMN ai_enriched_at INTEGER;
```
## Frontend — AI panel en AnomalyDetail
Si la anomalía tiene ai_enrichment_json, mostrar panel "AI Analysis" con:
- 🔍 Root Cause (texto con ícono)
- 👥 User Impact (texto con ícono)
- 🔧 Suggested Fix (bloque de código si contiene código)
- 📋 "Copy debug prompt" button (copia el debugPrompt al clipboard)
- Badge: "Analyzed by Claude" / "Analyzed by GPT-4o-mini" / "Analyzed by Llama 3.2"
- Timestamp de cuándo se generó
Si no tiene enriquecimiento, mostrar botón "✨ Analyze with AI" que llama a:
POST /api/anomalies/:id/enrich
## Endpoint nuevo
### POST /api/anomalies/:anomalyId/enrich
Dispara el enriquecimiento de una anomalía concreta (async).
Response inmediata: { status: 'enriching' }
Cuando termina, emite WebSocket event: anomaly:enriched { anomalyId, enrichment }
### GET /api/anomalies/:anomalyId — actualizado
Incluye ai_enrichment si está disponible.
+59
View File
@@ -0,0 +1,59 @@
# ABE — API Security Specification
## Authentication: API Key
All API endpoints require an API key passed in the header:
`X-ABE-API-Key: <key>`
If missing or invalid → 401 Unauthorized.
## Configuration
API key is set via environment variable: `ABE_API_KEY`
If not set, server logs a warning and runs without auth (dev mode only).
## Implementation
Create `src/server/middleware/auth.ts`:
```typescript
export function apiKeyAuth(req, res, next) {
const apiKey = process.env.ABE_API_KEY;
if (!apiKey) return next(); // dev mode: no auth
const provided = req.headers['x-abe-api-key'];
if (!provided || provided !== apiKey) {
return res.status(401).json({ error: 'Invalid or missing API key' });
}
next();
}
```
Apply this middleware to ALL routes EXCEPT:
- GET /health
- GET /ready
## CORS
Only allow requests from the frontend origin.
Configure via environment variable: `ABE_CORS_ORIGIN` (default: `http://localhost:5173`)
## Rate Limiting
Add `express-rate-limit`:
- Max 20 POST /api/sessions per hour per IP
- Max 200 requests per minute per IP for other endpoints
## Environment Variables (full list for .env)
```
ABE_API_KEY=change-me-in-production
ABE_CORS_ORIGIN=http://localhost:5173
ABE_PORT=3001
ABE_DB_PATH=./data/abe.db
ABE_REPORTS_DIR=./reports
ABE_LOGS_DIR=./logs
NODE_ENV=production
```
## docker-compose update
Add .env file support and environment variables to docker-compose.yml.
Add a volumes entry for `data/` directory for SQLite persistence.
+187
View File
@@ -0,0 +1,187 @@
# ABE — API Server Specification
## Arquitectura general
```
React (puerto 5173)
↕ HTTP REST + WebSocket
API Server Express (puerto 3001)
↕ imports directos
ExplorationEngine (core)
```
El servidor vive en `src/server/` y es el único punto de entrada al motor desde el exterior. El frontend NUNCA importa código del core directamente.
---
## Tecnología del servidor
- Framework: Express.js
- WebSocket: socket.io (para streaming en tiempo real)
- Archivos: `src/server/index.ts` y `src/server/routes/`
---
## REST Endpoints
### POST /api/sessions
Lanza una nueva exploración.
Request body:
```json
{
"url": "http://localhost:3000",
"seed": 42,
"maxStates": 50
}
```
Response:
```json
{
"sessionId": "sess_abc123",
"status": "running",
"startedAt": "2025-01-15T10:00:00.000Z"
}
```
---
### GET /api/sessions
Lista todas las sesiones (activas e históricas).
Response:
```json
[
{
"sessionId": "sess_abc123",
"url": "http://localhost:3000",
"status": "running",
"startedAt": "2025-01-15T10:00:00.000Z",
"anomaliesFound": 3,
"statesVisited": 12
}
]
```
---
### GET /api/sessions/:sessionId
Detalle de una sesión específica.
Response:
```json
{
"sessionId": "sess_abc123",
"url": "http://localhost:3000",
"status": "completed",
"startedAt": "2025-01-15T10:00:00.000Z",
"finishedAt": "2025-01-15T10:05:00.000Z",
"statesVisited": 12,
"anomaliesFound": 3,
"seed": 42
}
```
---
### DELETE /api/sessions/:sessionId
Detiene una sesión activa.
Response:
```json
{ "stopped": true }
```
---
### GET /api/anomalies
Lista todas las anomalías encontradas (todas las sesiones).
Query params opcionales: `?sessionId=sess_abc123&severity=high`
Response:
```json
[
{
"id": "anom_a1b2c3",
"sessionId": "sess_abc123",
"type": "http_error",
"severity": "high",
"description": "Form returns HTTP 500 on empty email",
"timestamp": 1705312200000,
"screenshotUrl": "/api/anomalies/anom_a1b2c3/screenshot"
}
]
```
---
### GET /api/anomalies/:anomalyId
Detalle completo de una anomalía incluyendo pasos de reproducción.
Response: el objeto IAnomaly completo serializado (definido en interfaces.md)
---
### GET /api/anomalies/:anomalyId/screenshot
Devuelve la imagen PNG del screenshot de la anomalía.
Response: imagen binaria con Content-Type: image/png
---
### POST /api/anomalies/:anomalyId/replay
Lanza el replay de una anomalía específica.
Response:
```json
{
"replayId": "replay_xyz",
"status": "running"
}
```
---
## WebSocket Events (socket.io)
El cliente se conecta a `ws://localhost:3001` y escucha estos eventos:
### Eventos que emite el SERVIDOR → cliente
`session:started`
```json
{ "sessionId": "sess_abc123", "url": "http://localhost:3000" }
```
`state:discovered`
```json
{ "sessionId": "sess_abc123", "stateId": "s_xyz", "url": "/register", "title": "Register" }
```
`action:executed`
```json
{ "sessionId": "sess_abc123", "actionType": "click", "selector": "button#submit", "timestamp": 1705312197000 }
```
`anomaly:detected`
```json
{ "sessionId": "sess_abc123", "anomalyId": "anom_a1b2c3", "type": "http_error", "severity": "high", "description": "..." }
```
`session:completed`
```json
{ "sessionId": "sess_abc123", "statesVisited": 12, "anomaliesFound": 3 }
```
`session:error`
```json
{ "sessionId": "sess_abc123", "error": "Target URL unreachable" }
```
### Eventos que emite el CLIENTE → servidor
`session:stop`
```json
{ "sessionId": "sess_abc123" }
```
+118
View File
@@ -0,0 +1,118 @@
# ABE — CLI & CI/CD Integration Specification
## CLI Entry Point
File: `src/cli.ts`
Script in package.json: `"abe": "ts-node src/cli.ts"`
Global after install: `npx abe` or `abe` if installed globally.
## CLI Usage
```bash
# Basic run
abe run --url http://localhost:3000
# With auth
abe run --url http://app.com \
--auth-type login_flow \
--login-url http://app.com/login \
--username test@app.com \
--password secret
# With scope limits
abe run --url http://app.com \
--max-states 30 \
--max-depth 4 \
--allowed-domains app.com
# CI mode: exit 1 if any anomaly found
abe run --url http://localhost:3000 --fail-on-anomaly
# CI mode: exit 1 only on high/critical anomalies
abe run --url http://localhost:3000 --fail-on-severity high
# Output formats
abe run --url http://localhost:3000 --output json # prints JSON summary to stdout
abe run --url http://localhost:3000 --output junit # generates junit.xml for CI
# Connect to a running ABE server instead of running inline
abe run --url http://localhost:3000 --server http://abe-server:3001 --api-key mykey
```
## Exit Codes
- 0 → exploration complete, no anomalies (or no anomalies above threshold)
- 1 → anomalies found above threshold
- 2 → exploration failed (target unreachable, auth failed, etc.)
## stdout JSON output (--output json)
```json
{
"sessionId": "sess_abc123",
"url": "http://localhost:3000",
"duration_ms": 45000,
"states_visited": 12,
"anomalies": [
{
"id": "anom_xyz",
"type": "http_error",
"severity": "high",
"description": "Form returns 500 on empty email",
"report_path": "reports/anom_xyz/report.json"
}
],
"exit_code": 1
}
```
## JUnit XML output (--output junit)
Generates `abe-results.xml` compatible with Jenkins, GitHub Actions, GitLab CI:
- Each anomaly = one failing test case
- Each explored state = one passing test case
## GitHub Actions Example Workflow
Create file: `.github/workflows/abe-example.yml` in the repo:
```yaml
name: ABE Exploratory Testing
on:
push:
branches: [main]
pull_request:
jobs:
explore:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start application
run: docker-compose up -d app
# assumes the project has a docker-compose with the target app
- name: Wait for app
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Run ABE
run: |
npm install -g abe-explorer # or: npx abe
abe run \
--url http://localhost:3000 \
--max-states 30 \
--fail-on-severity high \
--output junit
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: abe-reports
path: reports/
- name: Publish test results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: abe-results.xml
```
+99
View File
@@ -0,0 +1,99 @@
# ABE — Database Specification (SQLite)
## Rationale
File-based storage loses all data on container restart.
SQLite requires zero extra services and is perfect for self-hosted deployment.
## Library
Use `better-sqlite3` (synchronous, faster than async alternatives for this use case).
## Location
Database file: `data/abe.db` (persisted via Docker volume)
## Schema
### Table: sessions
```sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
seed INTEGER NOT NULL,
max_states INTEGER NOT NULL DEFAULT 50,
states_visited INTEGER NOT NULL DEFAULT 0,
anomalies_found INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL,
finished_at INTEGER,
config_json TEXT NOT NULL DEFAULT '{}'
);
```
### Table: states
```sql
CREATE TABLE IF NOT EXISTS states (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
url TEXT NOT NULL,
title TEXT NOT NULL,
dom_snapshot_path TEXT,
visit_count INTEGER NOT NULL DEFAULT 0,
discovered_at INTEGER NOT NULL
);
```
### Table: actions
```sql
CREATE TABLE IF NOT EXISTS actions (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
state_id TEXT NOT NULL REFERENCES states(id),
type TEXT NOT NULL,
selector TEXT,
value TEXT,
url TEXT,
seed INTEGER NOT NULL,
executed_at INTEGER NOT NULL,
sequence_order INTEGER NOT NULL
);
```
### Table: anomalies
```sql
CREATE TABLE IF NOT EXISTS anomalies (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
type TEXT NOT NULL,
severity TEXT NOT NULL,
description TEXT NOT NULL,
action_trace_json TEXT NOT NULL,
evidence_json TEXT NOT NULL,
screenshot_path TEXT,
dom_snapshot_path TEXT,
detected_at INTEGER NOT NULL
);
```
### Table: notifications
```sql
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
anomaly_id TEXT NOT NULL REFERENCES anomalies(id),
channel TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
sent_at INTEGER,
error TEXT
);
```
## Repository Pattern
Create `src/db/` with:
- `src/db/connection.ts` — singleton SQLite connection, runs migrations on startup
- `src/db/SessionRepository.ts` — CRUD for sessions
- `src/db/AnomalyRepository.ts` — CRUD for anomalies, includes filter by session/severity
- `src/db/migrations.ts` — runs all CREATE TABLE IF NOT EXISTS on startup
## Rules
- All DB operations are synchronous (better-sqlite3 is sync)
- Repositories are injected into the API server, never imported directly by core engine
- The engine emits events → the API server listens and persists to DB
+102
View File
@@ -0,0 +1,102 @@
# ABE — Docker Specification
## Objetivo
Permitir arrancar todo el proyecto (backend + frontend) con un solo comando:
docker-compose up --build
## Backend Dockerfile (raíz del proyecto)
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3001
CMD ["node", "dist/server/index.js"]
```
## Frontend Dockerfile (frontend/Dockerfile)
Usa build multistage: primero compila con Node, luego sirve con nginx.
```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
## nginx.conf (frontend/nginx.conf)
Necesario para que React Router funcione correctamente (todas las rutas apuntan a index.html):
```nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3001;
}
location /socket.io {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
## docker-compose.yml (raíz)
```yaml
version: '3.8'
services:
backend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- PORT=3001
volumes:
- ./reports:/app/reports
- ./logs:/app/logs
networks:
- abe-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:80"
depends_on:
- backend
networks:
- abe-network
networks:
abe-network:
driver: bridge
```
## Notas importantes
- El frontend en producción (nginx) hace proxy de /api y /socket.io al backend
- Los volúmenes reports/ y logs/ persisten datos entre reinicios del contenedor
- El frontend se accede en http://localhost:5173
- El backend se accede en http://localhost:3001
+84
View File
@@ -0,0 +1,84 @@
# ABE — Exploration Scope & Target Authentication Specification
## Exploration Config Object
This config is passed via POST /api/sessions and stored in sessions.config_json.
```typescript
interface ExplorationConfig {
// Scope
allowedDomains: string[]; // e.g. ["localhost", "myapp.com"] — never follow external links
maxStates: number; // default: 50 — stop after this many unique states
maxDepth: number; // default: 5 — max click depth from start URL
actionDelayMs: number; // default: 500 — wait between actions (politeness)
sessionTimeoutMs: number; // default: 300000 (5 min) — hard stop
// Exclusions
excludedPaths: string[]; // e.g. ["/logout", "/admin"] — never navigate here
excludedSelectors: string[]; // e.g. ["button.delete", "a[href*='delete']"]
// Target authentication
auth: AuthConfig | null;
// Fuzzing
fuzzingEnabled: boolean; // default: true
fuzzingIntensity: 'low' | 'medium' | 'high'; // default: 'medium'
}
type AuthConfig =
| { type: 'cookies'; cookies: Array<{ name: string; value: string; domain: string }> }
| { type: 'headers'; headers: Record<string, string> }
| { type: 'login_flow'; loginUrl: string; usernameSelector: string; passwordSelector: string; submitSelector: string; username: string; password: string }
```
## Scope Rules (enforced in PlaywrightAgent)
1. Before navigating to any URL, check if hostname is in allowedDomains. If not, skip.
2. Before executing any action, check if current path matches excludedPaths. If yes, skip.
3. Before clicking any element, check if it matches excludedSelectors. If yes, skip.
4. Stop exploration when statesVisited >= maxStates OR depth >= maxDepth OR elapsed > sessionTimeoutMs.
## Authentication Flow
### type: 'cookies'
Inject cookies before the first navigation using playwright context.addCookies().
### type: 'headers'
Set extra HTTP headers on the browser context using context.setExtraHTTPHeaders().
### type: 'login_flow'
Before starting exploration:
1. Navigate to loginUrl
2. Fill usernameSelector with username
3. Fill passwordSelector with password
4. Click submitSelector
5. Wait for navigation to complete
6. Verify we are no longer on loginUrl (if still there, login failed → abort session with error)
7. Proceed with exploration from startUrl
## Updated POST /api/sessions request body
```json
{
"url": "http://localhost:3000",
"seed": 42,
"config": {
"allowedDomains": ["localhost"],
"maxStates": 50,
"maxDepth": 5,
"actionDelayMs": 500,
"sessionTimeoutMs": 300000,
"excludedPaths": ["/logout"],
"excludedSelectors": [],
"auth": {
"type": "login_flow",
"loginUrl": "http://localhost:3000/login",
"usernameSelector": "input[name='email']",
"passwordSelector": "input[name='password']",
"submitSelector": "button[type='submit']",
"username": "test@example.com",
"password": "password123"
},
"fuzzingEnabled": true,
"fuzzingIntensity": "medium"
}
}
```
+72
View File
@@ -0,0 +1,72 @@
# ABE — Frontend v2 Specification
## New pages and components to add
### New Page: Settings (ruta: /settings)
Sections:
1. API Key — show current key, button to copy
2. Notifications — form to set Slack webhook URL and min severity (calls PATCH /api/config)
3. Default Exploration Config — form with default values for maxStates, maxDepth, delay, excluded paths
4. About — version, links to docs
### Updated: NewSessionForm
Add fields:
- Allowed Domains (chips input, default: hostname of URL)
- Max States (number, default 50)
- Max Depth (number, default 5)
- Action Delay ms (number, default 500)
- Excluded Paths (chips input)
- Auth Type (select: none / cookies / headers / login_flow)
- If login_flow: show loginUrl, usernameSelector, passwordSelector, submitSelector, username, password
- If cookies: textarea for JSON cookie array
- If headers: key-value pairs input
- Fuzzing enabled (toggle)
- Fuzzing intensity (select: low / medium / high)
### Updated: Dashboard
Add stats bar at the top with 4 numbers:
- Total sessions
- Total anomalies found
- Critical/High anomalies (highlighted in red)
- Sessions running now
### Updated: AnomalyList
Add filter bar:
- Filter by severity (multi-select: low, medium, high, critical)
- Filter by type (multi-select: http_error, js_exception, etc.)
- Filter by session (dropdown)
- Search by description (text input)
- Sort by: newest first / severity desc
### Updated: AnomalyDetail
Add:
- Download button → downloads report.json
- Download MD button → downloads report.md
- Copy replay command button → copies `abe replay --anomaly-id anom_xxx` to clipboard
### New Component: SeverityBadge
Reusable badge component used everywhere:
- critical → red bg, white text
- high → orange bg, white text
- medium → yellow bg, dark text
- low → blue bg, white text
### New API endpoints needed (add to api-server spec)
PATCH /api/config
- Updates server config (slack webhook, min severity, defaults)
- Body: Partial<ServerConfig>
- Returns: updated ServerConfig
GET /api/config
- Returns current server config (without API key value)
GET /api/stats
- Returns: { totalSessions, totalAnomalies, criticalHighCount, runningSessions }
- Used by dashboard stats bar
+99
View File
@@ -0,0 +1,99 @@
# ABE — Frontend Specification
## Tecnología
- React 18 + TypeScript
- Vite (bundler, más simple que webpack)
- TailwindCSS (estilos sin escribir CSS manual)
- socket.io-client (WebSocket)
- React Router v6 (navegación entre páginas)
## Ubicación
El frontend vive en `frontend/` en la raíz del proyecto, completamente separado de `src/`.
```
frontend/
├── src/
│ ├── pages/
│ │ ├── Dashboard.tsx ← página principal
│ │ ├── SessionDetail.tsx ← detalle de una sesión en vivo
│ │ └── AnomalyDetail.tsx ← detalle de un bug report
│ ├── components/
│ │ ├── NewSessionForm.tsx ← formulario para lanzar exploración
│ │ ├── SessionList.tsx ← lista de sesiones
│ │ ├── AnomalyList.tsx ← lista de anomalías
│ │ ├── LiveFeed.tsx ← stream en tiempo real de eventos
│ │ └── AnomalyCard.tsx ← tarjeta de una anomalía
│ ├── hooks/
│ │ ├── useSocket.ts ← conexión WebSocket reutilizable
│ │ └── useApi.ts ← fetch helper para la API REST
│ ├── types.ts ← tipos TypeScript del frontend (espejo de interfaces.ts)
│ ├── App.tsx ← router principal
│ └── main.tsx ← entry point
├── index.html
├── vite.config.ts
├── tailwind.config.ts
└── package.json
```
---
## Página 1 — Dashboard (ruta: `/`)
Contiene:
- Botón "New Exploration" que abre el formulario
- `NewSessionForm`: campos URL y Seed, botón Start
- `SessionList`: tabla con todas las sesiones (estado, URL, anomalías encontradas, fecha)
- `AnomalyList`: lista de las últimas anomalías de todas las sesiones
---
## Página 2 — Session Detail (ruta: `/sessions/:sessionId`)
Contiene:
- Header con URL explorada, estado (running/completed), seed
- Botón "Stop" si la sesión está activa
- `LiveFeed`: lista en tiempo real de eventos WebSocket
- Cada evento muestra icono + texto + timestamp
- Scroll automático al último evento
- Colores: verde para state:discovered, amarillo para action:executed, rojo para anomaly:detected
- `AnomalyList`: anomalías encontradas en esta sesión (se actualiza en tiempo real)
---
## Página 3 — Anomaly Detail (ruta: `/anomalies/:anomalyId`)
Contiene:
- Header con tipo, severidad (badge de color), descripción
- Sección "Reproduction Steps": lista numerada de acciones
- Sección "Evidence":
- Screenshot a tamaño completo (imagen)
- Botón para ver DOM snapshot (abre en nueva pestaña)
- Sección "HTTP Log": tabla con requests (URL, método, status, duración)
- Sección "Raw Errors": bloque de código con los errores textuales
- Botón "Run Replay": llama a POST /api/anomalies/:id/replay y muestra estado
---
## Colores de severidad (badges)
- critical → rojo (#ef4444)
- high → naranja (#f97316)
- medium → amarillo (#eab308)
- low → azul (#3b82f6)
---
## Conexión con la API
Todas las llamadas van a `http://localhost:3001`.
En `vite.config.ts` configurar proxy para `/api` y `/socket.io` apuntando a `localhost:3001`.
```typescript
// vite.config.ts
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3001',
'/socket.io': { target: 'http://localhost:3001', ws: true }
}
}
})
```
+94
View File
@@ -0,0 +1,94 @@
# ABE — Fuzzing / Disruption Module Specification
## Purpose
This is ABE's core differentiator. Instead of only clicking valid elements,
ABE injects abnormal inputs into forms to provoke unexpected server behavior.
## Architecture
```
src/plugins/fuzzers/
├── FuzzingEngine.ts ← orchestrator, decides when and how to fuzz
├── strategies/
│ ├── EmptyValueStrategy.ts
│ ├── OversizedStringStrategy.ts
│ ├── SpecialCharsStrategy.ts
│ ├── TypeMismatchStrategy.ts
│ └── BoundaryValueStrategy.ts
└── InputTypeDetector.ts ← detects field type from DOM attributes
```
## InputTypeDetector
Detects field type from: input[type], input[name], input[placeholder], label text, aria-label.
```typescript
type DetectedInputType =
| 'email' | 'password' | 'number' | 'date' | 'phone'
| 'url' | 'search' | 'text' | 'textarea' | 'select' | 'file'
```
## Fuzzing Strategies
### EmptyValueStrategy
Submits forms with all fields empty. Catches missing server-side validation.
Applies to: all input types.
Values: `""`, `" "` (space only), `"\t"` (tab).
### OversizedStringStrategy
Submits strings far beyond expected length. Catches buffer issues and UI overflow.
Applies to: text, email, password, textarea.
Values by intensity:
- low: 256 chars
- medium: 1024 chars
- high: 10000 chars + unicode chars
### SpecialCharsStrategy
Injects characters that break SQL, HTML, and shell contexts.
Applies to: text, email, search, textarea.
Values:
```
' OR 1=1 --
<script>alert(1)</script>
../../etc/passwd
${7*7}
\x00\x01\x02
```
### TypeMismatchStrategy
Submits wrong data types for the field.
- email field → "not-an-email", "12345", "@@@"
- number field → "abc", "-999999", "9.9.9", "NaN"
- date field → "yesterday", "32/13/2025", "0000-00-00"
- url field → "javascript:alert(1)", "not a url"
- phone field → "000", "++++", "abcdefghij"
### BoundaryValueStrategy
Tests values at the edges of expected ranges.
- number field → 0, -1, 2147483647, 2147483648, -2147483648
- date field → "1900-01-01", "2099-12-31", "1970-01-01"
## Fuzzing Execution Flow
```
For each form discovered in state:
1. InputTypeDetector analyzes each field
2. FuzzingEngine selects strategies based on fuzzingIntensity:
- low: EmptyValue + TypeMismatch only
- medium: + OversizedString + BoundaryValue
- high: + SpecialChars
3. For each strategy, fill all fields with fuzz values
4. Submit the form
5. Observe response via AnomalyDetector
6. Record results
```
## AnomalyDetector additions for fuzzing
Add these new anomaly types:
- `validation_bypass` — server accepted clearly invalid input (e.g. submitted empty required email, got 200)
- `server_error_on_fuzz` — server returned 500 on a fuzzed input
- `xss_reflection` — fuzzed script tag appears in response body
## Integration point
FuzzingEngine is called from ExplorationEngine AFTER normal action discovery,
only when `config.fuzzingEnabled === true`.
It is passed as an optional plugin, so the core engine doesn't depend on it directly.
+164
View File
@@ -0,0 +1,164 @@
# ABE — Core Interfaces Specification
## Regla fundamental
`src/core/` solo puede importar desde este documento.
`src/plugins/` implementa estas interfaces, nunca al revés.
---
## IState
Representa un estado único de la aplicación explorada.
```typescript
interface IState {
id: string; // hash SHA1 del snapshot DOM + URL
url: string; // URL completa en este estado
title: string; // document.title
timestamp: number; // Date.now() cuando se capturó
domSnapshot: string; // outerHTML del body serializado
visitCount: number; // cuántas veces se ha visitado este estado
}
```
---
## IAction
Representa una acción que el agente puede ejecutar.
```typescript
interface IAction {
id: string; // uuid v4 generado al crear la acción
type: 'click' | 'fill' | 'navigate' | 'select' | 'submit';
selector?: string; // CSS selector del elemento (si aplica)
value?: string; // valor a introducir (para fill/select)
url?: string; // destino (solo para navigate)
timestamp: number; // cuando se ejecutó
seed: number; // semilla usada para selección aleatoria
stateId: string; // ID del estado desde el que se ejecutó
}
```
---
## IObservation
Lo que el agente observa DESPUÉS de ejecutar una acción.
```typescript
interface IObservation {
id: string; // uuid v4
actionId: string; // acción que provocó esta observación
newStateId: string; // ID del nuevo estado resultante
httpResponses: IHttpResponse[]; // todas las requests durante la acción
consoleErrors: string[]; // mensajes de console.error capturados
jsExceptions: string[]; // excepciones JS no capturadas
timestamp: number;
}
interface IHttpResponse {
url: string;
status: number;
method: string;
durationMs: number;
}
```
---
## IAnomaly
Una desviación detectada del comportamiento esperado.
```typescript
interface IAnomaly {
id: string; // uuid v4
type: AnomalyType;
severity: 'low' | 'medium' | 'high' | 'critical';
observationId: string; // observación que la provocó
actionTrace: IAction[]; // secuencia exacta de acciones que llevaron aquí
description: string; // texto legible explicando qué pasó
evidence: IAnomalyEvidence;
timestamp: number;
}
type AnomalyType =
| 'http_error' // respuesta HTTP 4xx o 5xx
| 'js_exception' // excepción JavaScript no capturada
| 'console_error' // console.error detectado
| 'navigation_fail' // navegación no completada
| 'element_missing' // elemento esperado desaparece
| 'timeout'; // acción excede tiempo límite
interface IAnomalyEvidence {
screenshotPath?: string; // ruta relativa al screenshot
domSnapshotPath?: string; // ruta relativa al DOM serializado
httpLog?: IHttpResponse[]; // requests relevantes
rawErrors?: string[]; // errores textuales originales
}
```
---
## IInteractionAgent (plugin interface)
Lo que cualquier agente de interacción debe implementar.
```typescript
interface IInteractionAgent {
launch(url: string): Promise<void>;
close(): Promise<void>;
discoverActions(state: IState): Promise<IAction[]>;
executeAction(action: IAction): Promise<IObservation>;
captureState(): Promise<IState>;
}
```
---
## ICollector (plugin interface)
Lo que cualquier colector de contexto debe implementar.
```typescript
interface ICollector {
name: string;
collect(anomaly: IAnomaly, agent: IInteractionAgent): Promise<IAnomalyEvidence>;
}
```
---
## IReproducer
Genera un script de replay a partir de una traza de acciones.
```typescript
interface IReproducer {
serialize(trace: IAction[]): string; // JSON serializado
deserialize(raw: string): IAction[]; // reconstruye la traza
generateScript(trace: IAction[]): string; // script Playwright ejecutable
}
```
---
## IExporter (plugin interface)
Transforma una anomalía en un reporte consumible.
```typescript
interface IExporter {
format: 'markdown' | 'json';
export(anomaly: IAnomaly, outputDir: string): Promise<string>; // retorna la ruta del archivo generado
}
```
---
## StateGraph
No es una interfaz pero su contrato debe ser explícito.
```typescript
class StateGraph {
addState(state: IState): void;
hasState(stateId: string): boolean;
recordTransition(fromId: string, action: IAction, toId: string): void;
getUnvisited(): IState[]; // estados con visitCount === 0
getNextToExplore(): IState | null; // heurística BFS por defecto
toJSON(): object; // serializable para logs
}
```
@@ -0,0 +1,119 @@
# ABE — Multi-Browser, Mobile Emulation & Accessibility Specification
## Multi-browser testing
### Browsers soportados (via Playwright)
- chromium (Chrome/Edge) — siempre disponible
- firefox — opcional
- webkit (Safari) — opcional
### Configuración en ExplorationConfig
```typescript
browsers: Array<'chromium' | 'firefox' | 'webkit'>; // default: ['chromium']
```
### Comportamiento
Cuando se especifican múltiples browsers:
- ABE ejecuta la misma exploración en paralelo en cada browser
- Cada browser crea su propia sub-sesión con el mismo seed
- Los resultados se agrupan bajo la misma sesión padre
- Las anomalías incluyen qué browser las detectó
- Anomalías que aparecen en TODOS los browsers → severity += 1 level
- Anomalías que aparecen solo en un browser → añadir tag "browser-specific: webkit"
### Añadir a IAnomaly
```typescript
browser: 'chromium' | 'firefox' | 'webkit';
browserVersion: string;
```
---
## Mobile Viewport Emulation
### Devices predefinidos (usar Playwright devices)
```typescript
type MobileDevice =
| 'iPhone 14'
| 'iPhone 14 Pro Max'
| 'Pixel 7'
| 'Galaxy S23'
| 'iPad Pro'
| 'none' // desktop (default)
```
### En ExplorationConfig
```typescript
mobileDevice: MobileDevice; // default: 'none'
viewport: { width: number; height: number } | null; // override manual
```
### Implementación en PlaywrightAgent
```typescript
// Si mobileDevice !== 'none':
const device = playwright.devices[config.mobileDevice];
const context = await browser.newContext({ ...device });
```
### Anomalías específicas de mobile
Añadir tipo: `mobile_layout_issue` — detectado cuando:
- Un elemento clickable tiene menos de 44x44px (WCAG touch target)
- Hay scroll horizontal inesperado (viewport overflow)
- Un elemento está fuera del viewport en mobile
---
## Accessibility Testing (axe-core)
### Librería
Usar `@axe-core/playwright` (integración oficial axe + Playwright).
### Cuándo ejecutar
Después de cada acción que cambia el estado (navigation + click que resulta en nuevo estado).
NO ejecutar en cada acción fill (demasiado frecuente).
### Implementación
```typescript
import { checkA11y } from 'axe-playwright';
// En PlaywrightAgent, después de captureState():
async function runAccessibilityCheck(page: Page): Promise<IAccessibilityResult[]> {
const results = await checkA11y(page, undefined, {
detailedReport: true,
detailedReportOptions: { html: true },
});
return results.violations.map(v => ({
id: v.id,
impact: v.impact, // 'minor' | 'moderate' | 'serious' | 'critical'
description: v.description,
helpUrl: v.helpUrl,
nodes: v.nodes.length,
selector: v.nodes[0]?.target?.join(', '),
}));
}
```
### Nuevo tipo de anomalía
- type: `accessibility_violation`
- severity mapping desde axe impact:
- minor → low
- moderate → medium
- serious → high
- critical → critical
- description: "[axe] {violation.description}"
- evidence: { helpUrl, affectedNodes, wcagCriteria }
### En ExplorationConfig
```typescript
accessibility: {
enabled: boolean; // default: true
minImpact: 'minor' | 'moderate' | 'serious' | 'critical'; // default: 'serious'
wcagLevel: 'A' | 'AA' | 'AAA'; // default: 'AA'
}
```
### En el bug report
Añadir sección "Accessibility Violations" en report.md con:
- Lista de violaciones con impact badge
- Link a la documentación de cada regla (helpUrl de axe)
- Selector CSS del elemento afectado
+88
View File
@@ -0,0 +1,88 @@
# ABE — Network Chaos Specification
## Concepto
Inspirado en Gremlin y LitmusChaos, pero aplicado a nivel de browser.
ABE puede simular condiciones de red adversas durante la exploración
para descubrir cómo se comporta el app en redes lentas, intermitentes,
o con servicios externos fallando.
## Esto es diferente al fuzzing de inputs:
- Fuzzing: inputs inválidos en formularios
- Network chaos: condiciones de red adversas (latencia, pérdida de paquetes, timeout)
## Implementación via Playwright CDP
Playwright expone Chrome DevTools Protocol (CDP) que permite controlar la red:
```typescript
// En PlaywrightAgent
async function applyNetworkCondition(condition: NetworkCondition): Promise<void> {
const client = await this.page.context().newCDPSession(this.page);
await client.send('Network.emulateNetworkConditions', {
offline: condition.offline,
downloadThroughput: condition.downloadKbps * 1024 / 8,
uploadThroughput: condition.uploadKbps * 1024 / 8,
latency: condition.latencyMs,
});
}
```
## Perfiles de red predefinidos
```typescript
const NETWORK_PROFILES = {
'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false },
'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false },
'2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false },
'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true },
'none': null // sin limitación (default)
}
```
## API request interception (simular servicios caídos)
```typescript
// Simular que un endpoint específico falla con 503
await page.route('**/api/payment**', route => {
route.fulfill({ status: 503, body: 'Service Unavailable' });
});
// Simular latencia en un endpoint específico
await page.route('**/api/search**', async route => {
await new Promise(r => setTimeout(r, 3000)); // 3s delay
route.continue();
});
```
## Configuración en ExplorationConfig
```typescript
networkChaos: {
enabled: boolean; // default: false
profile: keyof typeof NETWORK_PROFILES; // default: 'none'
blockedEndpoints: string[]; // glob patterns — responden 503
slowEndpoints: Array<{
pattern: string; // glob
delayMs: number;
}>;
}
```
## Anomalías específicas de network chaos
Añadir tipos al AnomalyDetector:
- `offline_handling_missing` — app muestra pantalla en blanco o error no controlado cuando está offline
- `slow_network_no_feedback` — con slow-3g, la app no muestra loading indicator (detectado si CLS=0 pero LCP>5000ms y no hay elemento con rol 'progressbar' o 'status')
- `external_service_crash` — cuando un endpoint bloqueado causa error 500 en el frontend
## Integración con el flujo de exploración
NetworkChaos se aplica de forma secuencial, no simultánea:
1. Primera pasada: exploración normal (baseline)
2. Segunda pasada (si networkChaos.enabled): misma seed, con perfil de red aplicado
3. Comparar resultados: nuevas anomalías que aparecen solo en la segunda pasada son network-related
## Frontend — Network Chaos Config
En NewSessionForm, añadir sección collapsible "Network Chaos":
- Toggle "Enable network chaos"
- Select perfil: Fast 3G / Slow 3G / 2G / Offline
- Textarea "Blocked endpoints" (uno por línea, glob patterns)
- Lista "Slow endpoints" con campo pattern + delay ms
+64
View File
@@ -0,0 +1,64 @@
# ABE — Notifications Specification
## Purpose
When ABE finds an anomaly autonomously, notify the team immediately.
## Supported Channels
### 1. Slack Webhook
Environment variable: `ABE_SLACK_WEBHOOK_URL`
Payload sent to Slack on anomaly:detected:
```json
{
"text": "🐛 ABE found a bug!",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*ABE Bug Report*\n*Severity:* 🔴 HIGH\n*Type:* http_error\n*Description:* Form returns HTTP 500 on empty email\n*Session:* sess_abc123\n*Target:* http://localhost:3000"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View Report" },
"url": "http://localhost:5173/anomalies/anom_abc123"
}
]
}
]
}
```
Only send for severity: high or critical (configurable via `ABE_NOTIFY_MIN_SEVERITY`).
### 2. Generic Webhook
Environment variable: `ABE_WEBHOOK_URL`
POST request with the full IAnomaly object as JSON body.
Includes header: `X-ABE-Event: anomaly.detected`
## Implementation
Create `src/server/notifications/`:
- `NotificationService.ts` — main service, called after anomaly is persisted to DB
- `SlackNotifier.ts` — implements Slack webhook
- `WebhookNotifier.ts` — implements generic webhook
NotificationService.notify(anomaly) is called from the API server
after every anomaly:detected event from the engine.
## Configuration (environment variables)
```
ABE_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
ABE_WEBHOOK_URL=https://myapp.com/webhooks/abe
ABE_NOTIFY_MIN_SEVERITY=high # low | medium | high | critical
```
## Notification record
Every notification attempt (success or failure) is saved to the notifications table in SQLite.
Failed notifications are retried once after 60 seconds.
+130
View File
@@ -0,0 +1,130 @@
# ABE — Output Format Specification
Cada anomalía genera DOS archivos en `reports/{anomaly-id}/`:
---
## 1. report.json — Para consumo por AI y tooling
```json
{
"version": "1.0",
"generated_at": "2025-01-15T10:30:00.000Z",
"environment": {
"target_url": "http://localhost:3000",
"abe_version": "0.1.0",
"os": "linux",
"node_version": "20.x"
},
"anomaly": {
"id": "anom_a1b2c3d4",
"type": "http_error",
"severity": "high",
"description": "Form submission returns HTTP 500 on empty email field",
"timestamp": 1705312200000
},
"reproduction": {
"seed": 42,
"steps": [
{
"step": 1,
"action_type": "navigate",
"url": "http://localhost:3000/register",
"timestamp": 1705312195000
},
{
"step": 2,
"action_type": "fill",
"selector": "input[name='email']",
"value": "",
"timestamp": 1705312196000
},
{
"step": 3,
"action_type": "click",
"selector": "button[type='submit']",
"timestamp": 1705312197000
}
]
},
"evidence": {
"screenshot": "screenshot.png",
"dom_snapshot": "dom.html",
"http_log": [
{
"url": "http://localhost:3000/api/register",
"method": "POST",
"status": 500,
"duration_ms": 234
}
],
"console_errors": [],
"js_exceptions": []
}
}
```
---
## 2. report.md — Para lectura humana
El archivo Markdown debe tener exactamente esta estructura:
```markdown
# Bug Report — [tipo de anomalía] — [fecha]
## Summary
[Una frase describiendo qué pasó y dónde]
## Severity
[low | medium | high | critical] — [justificación en una frase]
## Reproduction Steps
1. Navigate to `[url]`
2. [acción 2]
3. [acción 3]
...
**Seed used**: `42`
**Replay command**: `npm run replay -- --report reports/anom_a1b2c3d4/report.json`
## Observed Behavior
[Qué ocurrió exactamente — errores, respuestas HTTP, mensajes]
## Evidence
- Screenshot: `reports/anom_a1b2c3d4/screenshot.png`
- DOM Snapshot: `reports/anom_a1b2c3d4/dom.html`
- HTTP Log: [tabla con las requests relevantes]
## Raw Errors
\`\`\`
[errores textuales tal cual aparecieron]
\`\`\`
```
---
## Estructura de carpetas de salida
```
reports/
└── anom_a1b2c3d4/
├── report.json ← estructurado para AI
├── report.md ← legible para humanos
├── screenshot.png ← captura en el momento de la anomalía
└── dom.html ← snapshot completo del DOM
logs/
└── session_20250115_103000.jsonl ← una línea JSON por evento
```
---
## Formato del log de sesión (.jsonl)
Cada línea es un objeto JSON independiente:
```jsonl
{"event":"session_start","timestamp":1705312190000,"seed":42,"target":"http://localhost:3000"}
{"event":"state_discovered","timestamp":1705312191000,"state_id":"s_abc123","url":"/","title":"Home"}
{"event":"action_executed","timestamp":1705312196000,"action_id":"act_xyz","type":"fill","selector":"input[name='email']","value":""}
{"event":"anomaly_detected","timestamp":1705312197000,"anomaly_id":"anom_a1b2c3d4","type":"http_error","severity":"high"}
{"event":"session_end","timestamp":1705312210000,"states_visited":3,"anomalies_found":1}
```
+124
View File
@@ -0,0 +1,124 @@
# ABE — Performance Metrics Specification
## Concepto
Durante la exploración, ABE captura métricas de rendimiento de cada
estado visitado. Inspirado en Checkly y Datadog RUM.
Esto permite detectar anomalías de rendimiento además de errores funcionales.
## Métricas capturadas por estado
```typescript
interface IPerformanceMetrics {
stateId: string;
url: string;
timestamp: number;
// Navigation Timing (disponibles via Playwright)
ttfb: number; // Time to First Byte (ms)
domContentLoaded: number; // DOMContentLoaded event (ms)
loadComplete: number; // Load event (ms)
// Core Web Vitals (via web-vitals library injected)
lcp: number | null; // Largest Contentful Paint (ms)
cls: number | null; // Cumulative Layout Shift (score)
fid: number | null; // First Input Delay (ms) - solo tras interacción
inp: number | null; // Interaction to Next Paint (ms)
// Resource counts
totalRequests: number;
failedRequests: number;
totalTransferSize: number; // bytes
}
```
## Implementación
### TTFB, DOMContentLoaded, Load
Via `page.evaluate()` usando `performance.timing` después de navigation:
```typescript
const timing = await page.evaluate(() => ({
ttfb: performance.timing.responseStart - performance.timing.requestStart,
domContentLoaded: performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart,
loadComplete: performance.timing.loadEventEnd - performance.timing.navigationStart,
}));
```
### Core Web Vitals
Inyectar el script de `web-vitals` (npm) en la página:
```typescript
await page.addScriptTag({ url: 'https://unpkg.com/web-vitals/dist/web-vitals.iife.js' });
const vitals = await page.evaluate(() => new Promise(resolve => {
const result = {};
webVitals.getLCP(m => result.lcp = m.value);
webVitals.getCLS(m => result.cls = m.value);
webVitals.getINP(m => result.inp = m.value);
setTimeout(() => resolve(result), 3000); // wait 3s for vitals
}));
```
## Anomalías de rendimiento (nuevos tipos)
Añadir al AnomalyDetector con umbrales basados en Core Web Vitals de Google:
| Métrica | Good | Needs Improvement | Poor (anomalía) |
|---------|---------|-------------------|-----------------|
| LCP | <2500ms | 2500-4000ms | >4000ms → high |
| CLS | <0.1 | 0.1-0.25 | >0.25 → medium |
| INP | <200ms | 200-500ms | >500ms → high |
| TTFB | <800ms | 800-1800ms | >1800ms → medium|
Tipo de anomalía: `performance_degradation`
## Modelo de datos — añadir a SQLite
### Table: performance_metrics
```sql
CREATE TABLE IF NOT EXISTS performance_metrics (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
ttfb INTEGER,
dom_content_loaded INTEGER,
load_complete INTEGER,
lcp INTEGER,
cls REAL,
fid INTEGER,
inp INTEGER,
total_requests INTEGER,
failed_requests INTEGER,
total_transfer_size INTEGER,
captured_at INTEGER NOT NULL
);
```
## Frontend — Performance tab
Añadir tab "Performance" en SessionDetail:
- Tabla con todos los estados visitados y sus métricas
- Columnas con color coded: verde/amarillo/rojo según umbrales de Google
- Gráfico de barras: LCP por estado (para identificar páginas lentas)
- Summary cards: peor LCP, peor CLS, peor TTFB de la sesión
## En el bug report
Si hay anomalía performance_degradation, añadir sección en report.md:
```
## Performance Issue
- LCP: 5200ms (threshold: 4000ms) ❌
- CLS: 0.08 ✅
- TTFB: 2100ms (threshold: 1800ms) ❌
- Total page size: 4.2MB
```
## Configuración
Añadir a ExplorationConfig:
```typescript
performance: {
enabled: boolean; // default: true
lcpThresholdMs: number; // default: 4000
clsThreshold: number; // default: 0.25
inpThresholdMs: number; // default: 500
ttfbThresholdMs: number; // default: 1800
}
```
@@ -0,0 +1,77 @@
# ABE — Production Hardening Specification
## Health Endpoints (no auth required)
### GET /health
Returns 200 if server is up.
```json
{ "status": "ok", "version": "0.1.0", "uptime_seconds": 3600 }
```
### GET /ready
Returns 200 if server is ready to accept requests (DB connected, no critical errors).
Returns 503 if not ready.
```json
{ "status": "ready", "db": "connected", "active_sessions": 2 }
```
Used by Docker HEALTHCHECK and Kubernetes readiness probes.
## Docker improvements
### Backend Dockerfile
Add HEALTHCHECK:
```dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
```
### docker-compose.yml updates
- Add healthcheck to backend service
- Add `restart: unless-stopped` to both services
- Add `data/` volume for SQLite persistence
- Load `.env` file: `env_file: .env`
- Add `depends_on: backend: condition: service_healthy` to frontend
### .env.example file
Create `.env.example` in repo root with all variables and example values.
`.env` added to `.gitignore`.
## Error handling improvements
Global Express error handler in `src/server/index.ts`:
- Catch all unhandled errors
- Log with timestamp and stack trace
- Return consistent JSON error format:
```json
{ "error": "Internal server error", "code": "INTERNAL_ERROR", "timestamp": 1705312200000 }
```
Never expose stack traces in production (NODE_ENV=production).
## Graceful shutdown
On SIGTERM/SIGINT:
1. Stop accepting new sessions
2. Wait for active sessions to finish (max 30s)
3. Close DB connection
4. Exit 0
## Concurrency limits
- Max concurrent exploration sessions: configurable via `ABE_MAX_CONCURRENT_SESSIONS` (default: 3)
- If limit reached, POST /api/sessions returns 429 with:
```json
{ "error": "Max concurrent sessions reached", "active": 3, "limit": 3 }
```
## Logging improvements
Replace console.log with structured logger (use `pino`):
```typescript
log.info({ sessionId, url, event: 'session_started' }, 'Session started')
log.error({ anomalyId, error }, 'Failed to capture screenshot')
```
All logs go to stdout (Docker captures them).
Log level configurable via `ABE_LOG_LEVEL` env var (default: 'info').
+138
View File
@@ -0,0 +1,138 @@
# ABE — Project Structure Specification
## Árbol completo de archivos a crear
```
abe/
├── src/
│ ├── core/
│ │ ├── interfaces.ts ← TODAS las interfaces (IState, IAction, etc.)
│ │ ├── StateGraph.ts ← implementación del grafo de estados
│ │ ├── ExplorationEngine.ts ← loop principal de exploración
│ │ └── AnomalyDetector.ts ← reglas heurísticas de detección
│ ├── plugins/
│ │ ├── agents/
│ │ │ └── PlaywrightAgent.ts ← implementa IInteractionAgent
│ │ ├── collectors/
│ │ │ ├── ScreenshotCollector.ts
│ │ │ ├── NetworkCollector.ts
│ │ │ └── DOMSnapshotCollector.ts
│ │ ├── exporters/
│ │ │ ├── MarkdownExporter.ts
│ │ │ └── JSONExporter.ts
│ │ └── reproducers/
│ │ └── PlaywrightReproducer.ts
│ └── index.ts ← punto de entrada, conecta todo
├── tests/
│ ├── core/
│ │ ├── StateGraph.test.ts
│ │ ├── ExplorationEngine.test.ts
│ │ └── AnomalyDetector.test.ts
│ └── plugins/
│ ├── agents/
│ │ └── PlaywrightAgent.test.ts
│ └── exporters/
│ ├── MarkdownExporter.test.ts
│ └── JSONExporter.test.ts
├── reports/ ← generado en runtime, ignorado por git
├── logs/ ← generado en runtime, ignorado por git
├── package.json
├── tsconfig.json
├── jest.config.ts
└── CLAUDE.md
```
---
## Reglas de importación — MUY IMPORTANTE
```
✅ PERMITIDO:
src/core/ExplorationEngine.ts → importa de src/core/interfaces.ts
src/plugins/agents/PlaywrightAgent.ts → importa de src/core/interfaces.ts
src/index.ts → importa de src/core/ Y src/plugins/
❌ PROHIBIDO:
src/core/ExplorationEngine.ts → importa de src/plugins/ (rompe el desacoplamiento)
src/plugins/agents/A.ts → importa de src/plugins/exporters/B.ts (plugins no se conocen entre sí)
```
---
## Cómo se conecta todo en src/index.ts
El archivo de entrada debe seguir este patrón:
```typescript
// src/index.ts
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 { JSONExporter } from './plugins/exporters/JSONExporter';
import { MarkdownExporter } from './plugins/exporters/MarkdownExporter';
import { PlaywrightReproducer } from './plugins/reproducers/PlaywrightReproducer';
const graph = new StateGraph();
const agent = new PlaywrightAgent();
const collectors = [new ScreenshotCollector(), new NetworkCollector(), new DOMSnapshotCollector()];
const exporters = [new JSONExporter(), new MarkdownExporter()];
const reproducer = new PlaywrightReproducer();
const engine = new ExplorationEngine({ graph, agent, collectors, exporters, reproducer });
engine.run({ url: process.argv[2] || 'http://localhost:3000', seed: 42 });
```
---
## package.json — scripts obligatorios
```json
{
"name": "abe",
"version": "0.1.0",
"scripts": {
"build": "tsc",
"test": "jest",
"typecheck": "tsc --noEmit",
"lint": "eslint src/ tests/",
"explore": "ts-node src/index.ts",
"replay": "ts-node src/replay.ts"
}
}
```
---
## tsconfig.json — configuración base
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
```
---
## jest.config.ts — configuración base
```typescript
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
};
```
@@ -0,0 +1,79 @@
# ABE — Scheduled Monitoring Specification
## Concepto
ABE puede ejecutar exploraciones de forma automática en intervalos definidos,
sin intervención humana. Esto convierte ABE de una herramienta manual
en un sistema de monitorización continua, al estilo Checkly.
## Modelo de datos — añadir a SQLite
### Table: schedules
```sql
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
config_json TEXT NOT NULL,
cron_expression TEXT NOT NULL, -- e.g. "0 */6 * * *" (every 6h)
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at INTEGER,
next_run_at INTEGER,
created_at INTEGER NOT NULL
);
```
## Expresiones cron soportadas (presets en la UI)
| Label | Cron |
|------------------|----------------|
| Every 15 minutes | */15 * * * * |
| Every hour | 0 * * * * |
| Every 6 hours | 0 */6 * * * |
| Every day at 2am | 0 2 * * * |
| Every Monday 9am | 0 9 * * 1 |
## Implementación
Usar `node-cron` para el scheduler.
Crear `src/server/scheduler/SchedulerService.ts`:
- En startup, carga todos los schedules con enabled=1 de la DB
- Registra un cron job por cada schedule
- Cuando dispara, llama internamente a POST /api/sessions con la config guardada
- Actualiza last_run_at y next_run_at en la DB después de cada disparo
- Si la sesión anterior sigue running, skip este tick y log warning
## API endpoints nuevos
### GET /api/schedules
Lista todos los schedules.
### POST /api/schedules
Crea un nuevo schedule.
Body:
```json
{
"name": "Production daily check",
"url": "https://myapp.com",
"config": { ... mismo ExplorationConfig ... },
"cronExpression": "0 2 * * *",
"enabled": true
}
```
### PATCH /api/schedules/:id
Actualiza o activa/desactiva un schedule.
### DELETE /api/schedules/:id
Elimina un schedule.
## Frontend — nueva sección en Settings
Añadir tab "Schedules" en /settings:
- Lista de schedules activos con: nombre, URL, cron, última ejecución, próxima ejecución, toggle activo/inactivo
- Botón "New Schedule" abre modal con: nombre, URL, config de exploración, selector de frecuencia (presets + custom cron)
- Badge "Running" si hay una sesión activa del schedule en este momento
## Notificaciones específicas de schedules
Cuando un schedule dispara una exploración y encuentra anomalías high/critical,
enviar notificación con el subject: "[SCHEDULED] ABE found bugs in {url}"
+124
View File
@@ -0,0 +1,124 @@
# ABE — Visual Regression Testing Specification
## Concepto
ABE toma screenshots durante la exploración. En vez de solo guardarlos,
los compara contra una baseline aprobada para detectar cambios visuales
inesperados entre ejecuciones. Inspirado en Percy y Chromatic,
pero integrado directamente en el flujo de exploración autónoma.
## Cómo funciona
### Primera ejecución (sin baseline)
1. ABE explora el app, toma screenshots de cada estado descubierto
2. Todos los screenshots se marcan como "pending review" en la UI
3. El usuario aprueba o rechaza cada uno desde la GUI
4. Los aprobados se convierten en la BASELINE
### Ejecuciones posteriores
1. ABE explora el app, toma screenshots de cada estado
2. Para cada screenshot, busca la baseline correspondiente por state_id (hash DOM+URL)
3. Si no hay baseline: marcar como "new state", notificar
4. Si hay baseline: comparar usando pixelmatch (npm library)
5. Si diff > threshold (default 0.1%): crear anomalía tipo visual_regression
6. Si diff <= threshold: marcar como "passed"
## Librería de comparación
Usar `pixelmatch` (npm) para comparación pixel a pixel.
Usar `sharp` para resize y normalización de imágenes antes de comparar.
```typescript
import pixelmatch from 'pixelmatch';
import sharp from 'sharp';
async function compareScreenshots(
baselinePath: string,
currentPath: string,
diffOutputPath: string,
threshold: number = 0.1
): Promise<{ diffPixels: number; diffPercent: number; hasDiff: boolean }> {
// resize both to same dimensions, compare, generate diff image
}
```
## Modelo de datos — añadir a SQLite
### Table: visual_baselines
```sql
CREATE TABLE IF NOT EXISTS visual_baselines (
id TEXT PRIMARY KEY,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
screenshot_path TEXT NOT NULL,
approved_at INTEGER NOT NULL,
approved_by TEXT DEFAULT 'user',
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
```
### Table: visual_comparisons
```sql
CREATE TABLE IF NOT EXISTS visual_comparisons (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
baseline_id TEXT,
current_screenshot_path TEXT NOT NULL,
diff_screenshot_path TEXT,
diff_pixels INTEGER,
diff_percent REAL,
status TEXT NOT NULL, -- 'passed' | 'failed' | 'new_state' | 'pending'
created_at INTEGER NOT NULL
);
```
## Nuevo tipo de anomalía
Añadir a AnomalyDetector:
- type: `visual_regression`
- severity: calculado por diff_percent:
- < 1% → low
- 1-5% → medium
- 5-15% → high
- > 15% → critical
- description: "Visual regression detected: X% of pixels changed"
- evidence: baseline screenshot + current screenshot + diff image (highlighted in red)
## Nuevo endpoint de API
### GET /api/visual/comparisons
Lista todas las comparaciones de la sesión más reciente.
Query: ?status=failed&sessionId=xxx
### POST /api/visual/baselines/:comparisonId/approve
Aprueba un screenshot como nueva baseline.
### POST /api/visual/baselines/:comparisonId/reject
Rechaza (anomalía confirmada, no actualizar baseline).
### POST /api/visual/baselines/approve-all
Aprueba todos los "new_state" pendientes de una sesión.
## Frontend — nueva sección Visual Review
Nueva página /visual-review:
- Grid de cards, cada una muestra: URL del estado, thumbnail del screenshot actual
- Filtros: passed | failed | new_state | pending
- Click en una card abre modal con:
- Vista lado a lado: baseline izquierda, actual derecha
- Vista diff: imagen con píxeles cambiados en rojo
- Porcentaje de cambio
- Botones: Approve as new baseline | Mark as bug | Ignore
- Bulk actions: "Approve all new states", "Mark all failed as bugs"
## Configuración
Añadir a ExplorationConfig:
```typescript
visualRegression: {
enabled: boolean; // default: true
threshold: number; // default: 0.001 (0.1%)
screenshotFullPage: boolean; // default: false (solo viewport)
ignoreSelectors: string[]; // e.g. [".timestamp", ".ad-banner"] — excluir zonas dinámicas
}
```
+134
View File
@@ -0,0 +1,134 @@
# Phase 1: Shared Domain — Building Blocks
## Objetivo
Crear las clases base que TODOS los módulos usarán. Esto es el cimiento.
## Result.ts
```typescript
// Discriminated union, no classes
type ResultOk<T> = { readonly ok: true; readonly value: T };
type ResultErr<E> = { readonly ok: false; readonly error: E };
export type Result<T, E = Error> = ResultOk<T> | ResultErr<E>;
export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
export function isOk<T, E>(r: Result<T, E>): r is ResultOk<T> { return r.ok; }
export function isErr<T, E>(r: Result<T, E>): r is ResultErr<E> { return !r.ok; }
```
## UniqueId.ts
```typescript
import { v4 as uuidv4 } from 'uuid';
export class UniqueId {
private constructor(private readonly value: string) {}
static create(): UniqueId { return new UniqueId(uuidv4()); }
static from(value: string): UniqueId { return new UniqueId(value); }
toString(): string { return this.value; }
equals(other?: UniqueId): boolean {
if (!other) return false;
return this.value === other.value;
}
}
```
## Entity.ts
```typescript
export abstract class Entity<T> {
protected readonly _id: UniqueId;
protected props: T;
constructor(props: T, id?: UniqueId) {
this._id = id ?? UniqueId.create();
this.props = props;
}
get id(): UniqueId { return this._id; }
equals(other?: Entity<T>): boolean {
if (!other) return false;
return this._id.equals(other._id);
}
}
```
## AggregateRoot.ts
```typescript
export abstract class AggregateRoot<T> extends Entity<T> {
private _domainEvents: DomainEvent[] = [];
get domainEvents(): ReadonlyArray<DomainEvent> {
return this._domainEvents;
}
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
clearEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
```
## ValueObject.ts
```typescript
export abstract class ValueObject<T> {
protected readonly props: T;
constructor(props: T) {
this.props = Object.freeze(props);
}
equals(other?: ValueObject<T>): boolean {
if (!other) return false;
return JSON.stringify(this.props) === JSON.stringify(other.props);
}
}
```
## DomainEvent.ts
```typescript
export interface DomainEvent {
readonly eventId: string;
readonly eventName: string;
readonly aggregateId: string;
readonly occurredOn: Date;
readonly payload: Record<string, unknown>;
}
```
## UseCase.ts
```typescript
export interface UseCase<TRequest, TResponse, TError = Error> {
execute(request: TRequest): Promise<Result<TResponse, TError>>;
}
```
## EventBus.ts + EventHandler.ts
```typescript
// EventBus.ts
export interface EventBus {
publish(event: DomainEvent): Promise<void>;
subscribe(eventName: string, handler: EventHandler): void;
}
// EventHandler.ts
export interface EventHandler {
handle(event: DomainEvent): Promise<void>;
}
```
## Tests requeridos (mínimo)
1. Result: Ok crea value accesible, Err crea error accesible, isOk/isErr discriminan
2. UniqueId: create genera string válido, equals funciona, from preserva valor
3. Entity: equals compara por id (no por props)
4. ValueObject: equals compara por props, props son inmutables
## IMPORTANTE
- Estos archivos NO importan NADA externo excepto 'uuid'
- NO usar decorators
- NO usar classes abstractas complicadas — mantener simple
- Cada archivo exporta UNA cosa principal
@@ -0,0 +1,136 @@
# Phase 2: Shared Infrastructure
## Config.ts
Usa Zod para validar TODAS las env vars al arranque. Si falla → crash inmediato con mensaje claro.
```typescript
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const configSchema = z.object({
port: z.coerce.number().default(3001),
host: z.string().default('0.0.0.0'),
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
db: z.object({
driver: z.enum(['sqlite', 'postgres']).default('sqlite'),
path: z.string().default('./data/abe.db'),
url: z.string().optional(),
}),
auth: z.object({
secret: z.string().min(16).default('abe-dev-secret-change-in-prod'),
sessionMaxAge: z.coerce.number().default(86400),
}),
storage: z.object({
driver: z.enum(['local', 's3']).default('local'),
path: z.string().default('./data/storage'),
}),
cors: z.object({ origin: z.string().default('http://localhost:5173') }),
log: z.object({ level: z.enum(['debug','info','warn','error']).default('info') }),
api: z.object({
key: z.string().default('abe-dev-key-123'),
rateLimitWindowMs: z.coerce.number().default(900000),
rateLimitMax: z.coerce.number().default(100),
}),
ai: z.object({
provider: z.enum(['claude','openai','ollama','none']).default('none'),
apiKey: z.string().default(''),
autoEnrich: z.coerce.boolean().default(false),
minSeverity: z.enum(['low','medium','high','critical']).default('high'),
}),
jobs: z.object({
maxConcurrentSessions: z.coerce.number().default(3),
pollIntervalMs: z.coerce.number().default(1000),
}),
license: z.object({ key: z.string().default('') }),
});
export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(): AppConfig {
// Map env vars to schema shape, parse
}
```
## Logger.ts
```typescript
import pino from 'pino';
export function createLogger(config: { level: string; nodeEnv: string }): pino.Logger {
return pino({
level: config.level,
transport: config.nodeEnv === 'development'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
: undefined,
});
}
export type Logger = pino.Logger;
```
## DatabaseConnection.ts
```typescript
import { Kysely, SqliteDialect } from 'kysely';
import SQLite from 'better-sqlite3';
// Define Database interface con todas las tablas
export interface Database {
sessions: SessionTable;
states: StateTable;
actions: ActionTable;
anomalies: AnomalyTable;
// ... más tablas se añaden en fases posteriores
}
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {
if (config.driver === 'postgres') {
// Import dinámico de pg para no requerir en SQLite
const { Pool } = require('pg');
const { PostgresDialect } = require('kysely');
return new Kysely<Database>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: config.url }) }),
});
}
// Crear directorio data/ si no existe
const path = require('path');
const fs = require('fs');
fs.mkdirSync(path.dirname(config.path), { recursive: true });
return new Kysely<Database>({
dialect: new SqliteDialect({ database: new SQLite(config.path) }),
});
}
```
## InProcessEventBus.ts
```typescript
import { EventEmitter } from 'events';
// Implements EventBus interface from shared/application
// Logging de cada evento publicado
// Catch errors en handlers (log pero no crash)
// setMaxListeners(50)
```
## StorageProvider.ts
```typescript
export interface IStorageProvider {
save(relativePath: string, data: Buffer): Promise<string>;
get(relativePath: string): Promise<Buffer | null>;
delete(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>;
}
// LocalStorageProvider: usa fs.promises, base path = config.storage.path
// Crea directorios automáticamente con mkdir recursive
```
## Migración 001
Crea las tablas que ya existen en el schema actual (sessions, states, actions, anomalies, notifications).
Usar `CREATE TABLE IF NOT EXISTS` para idempotencia.
Los tipos de columna deben coincidir con lo que ya tiene better-sqlite3.
## IMPORTANTE
- Config DEBE fallar rápido si hay env vars inválidas
- Logger NUNCA debe usar console.log
- Database factory NUNCA importa pg a menos que driver sea postgres
- EventBus handlers que fallan se loguean pero NO crashean el bus
+216
View File
@@ -0,0 +1,216 @@
# Phase 7: API Server Refactor + Composition Root
## Middleware stack (ORDEN IMPORTA)
```typescript
// server.ts
export function createServer(deps: ServerDependencies): Express {
const app = express();
// 1. Request ID (PRIMERO — todo log necesita esto)
app.use(requestIdMiddleware);
// 2. Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", "ws:", "wss:"],
scriptSrc: ["'self'", "'unsafe-inline'"], // para Scalar docs
},
},
}));
// 3. CORS
app.use(cors({
origin: deps.config.cors.origin,
credentials: true,
}));
// 4. Rate limiting global
app.use(rateLimit({
windowMs: deps.config.api.rateLimitWindowMs,
max: deps.config.api.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
}));
// 5. Body parsing
app.use(express.json({ limit: '10mb' }));
// 6. Health endpoints (SIN auth)
app.get('/health/live', (_, res) => res.json({ status: 'ok' }));
app.get('/health/ready', async (_, res) => { /* check DB */ });
// 7. Auth routes (SIN auth middleware general)
app.use('/api/auth', deps.authController.router);
// 8. Auth middleware (TODOS los /api/ a partir de aquí)
app.use('/api', deps.authMiddleware);
// 9. Module routes
app.use('/api', deps.crawlingController.router);
app.use('/api', deps.findingsController.router);
app.use('/api', deps.fuzzingController.router);
// ... más módulos
// 10. 404 handler
app.use(notFoundMiddleware);
// 11. Error handler (SIEMPRE último)
app.use(globalErrorHandler);
return app;
}
```
## Error hierarchy
```typescript
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
public readonly isOperational = true,
) { super(message); }
}
export class ValidationError extends AppError {
constructor(message: string, public readonly details?: unknown) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class AuthenticationError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT');
}
}
export class RateLimitError extends AppError {
constructor() {
super('Too many requests', 429, 'RATE_LIMIT');
}
}
```
## Global error handler
```typescript
export function globalErrorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
const logger = req.log || console; // pino child logger
if (err instanceof AppError && err.isOperational) {
logger.warn({ err, statusCode: err.statusCode }, err.message);
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
...(err instanceof ValidationError && err.details ? { details: err.details } : {}),
});
}
// Programmer error — log full stack, return generic message
logger.error({ err }, 'Unhandled error');
return res.status(500).json({
error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
code: 'INTERNAL_ERROR',
});
}
```
## Composition root (main.ts)
```typescript
async function bootstrap() {
// 1. Config
const config = loadConfig();
// 2. Logger
const logger = createLogger(config);
logger.info({ port: config.port }, 'Starting ABE...');
// 3. Database + migrations
const db = createDatabase(config.db);
await runMigrations(db, logger);
// 4. Event bus
const eventBus = new InProcessEventBus(logger);
// 5. Storage
const storage = new LocalStorageProvider(config.storage.path);
// 6. Repositories
const sessionRepo = new KyselyCrawlSessionRepository(db);
const findingRepo = new KyselyFindingRepository(db);
// ... etc
// 7. Use cases
const startCrawl = new StartCrawlCommand(sessionRepo, eventBus);
const listFindings = new ListFindingsQuery(findingRepo);
// ... etc
// 8. Event handlers — subscribe to event bus
const onAnomalyDetected = new OnAnomalyDetected(findingRepo, eventBus);
eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected);
// ... etc
// 9. Controllers
const crawlingController = new CrawlingController(startCrawl, ...);
const findingsController = new FindingsController(listFindings, ...);
// ... etc
// 10. HTTP server
const app = createServer({ config, authMiddleware, crawlingController, findingsController, ... });
const httpServer = createServer(app);
// 11. Socket.io
const io = new Server(httpServer, { cors: { origin: config.cors.origin } });
const gateway = new SocketGateway(io, eventBus);
// 12. Job queue
const jobQueue = new SQLiteJobQueue(db, logger);
jobQueue.start();
// 13. Listen
httpServer.listen(config.port, config.host, () => {
logger.info({ port: config.port }, 'ABE server ready');
});
// 14. Graceful shutdown
async function shutdown(signal: string) {
logger.info({ signal }, 'Shutting down...');
httpServer.close();
io.close();
jobQueue.pause();
await jobQueue.waitForActive(30000);
await db.destroy();
logger.info('Shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
bootstrap().catch((err) => {
console.error('Fatal: failed to start ABE', err);
process.exit(1);
});
```
## IMPORTANTE
- El código existente en src/server/ debe DEJAR DE USARSE gradualmente
- Mantener los endpoints viejos funcionando durante la migración
- Cada controller es una clase con un `.router` getter que retorna Express.Router
- NUNCA meter lógica de negocio en controllers — solo parse request → call use case → format response
+66
View File
@@ -0,0 +1,66 @@
# Phase 8: Job Queue System
## Tabla jobs (SQLite)
```sql
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
payload TEXT NOT NULL,
result TEXT,
error TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
priority INTEGER NOT NULL DEFAULT 0,
run_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_poll ON jobs(status, run_at, priority DESC);
```
## Interface
```typescript
export interface IJobQueue {
enqueue<T>(type: string, payload: T, opts?: { runAt?: Date; priority?: number; maxAttempts?: number }): Promise<string>;
start(): void;
pause(): void;
waitForActive(timeoutMs: number): Promise<void>;
}
```
## Polling logic
```
loop (cada pollIntervalMs):
SELECT id, type, payload FROM jobs
WHERE status = 'pending' AND run_at <= datetime('now')
ORDER BY priority DESC, created_at ASC
LIMIT 1
if found:
UPDATE jobs SET status = 'running', started_at = now, attempts = attempts + 1
WHERE id = ? AND status = 'pending' // optimistic lock
if updated 0 rows → skip (otro worker lo tomó)
try:
result = await executeJob(type, payload)
UPDATE jobs SET status = 'completed', result = ?, completed_at = now
catch:
if attempts >= max_attempts:
UPDATE jobs SET status = 'failed', error = ?
else:
backoff = min(1000 * 2^attempts, 60000)
UPDATE jobs SET status = 'pending', run_at = now + backoff, error = ?
```
## Job types
- `exploration:run` — payload: { sessionId, config }
- `report:generate` — payload: { reportId, format, filters }
- `cleanup:old-data` — payload: { retentionDays }
## NO usar Redis
El job queue es SQLite-based para zero-dependency self-hosted.
Es simple, funciona para el volumen esperado (decenas de jobs, no miles).
+148
View File
@@ -0,0 +1,148 @@
# Phase 9: Auth Module
## Objetivo
Sistema completo de autenticación y autorización para ABE como plataforma.
## Roles y permisos
| Role | Sessions | Findings | Reports | Integrations | Org/Users | Settings | License |
|---------|----------|----------|---------|--------------|-----------|----------|---------|
| owner | CRUD | CRUD | CRUD | CRUD | CRUD | CRUD | CRUD |
| admin | CRUD | CRUD | CRUD | CRUD | CRU | CRUD | R |
| member | CR | CRU | CR | R | R | R | R |
| viewer | R | R | R | R | R | R | R |
## Better Auth config
```typescript
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
database: {
// Usar Kysely adapter o direct SQLite
type: 'sqlite',
url: config.db.path,
},
emailAndPassword: { enabled: true },
session: {
maxAge: config.auth.sessionMaxAge,
updateAge: 60 * 60, // refresh cada hora
},
// Organization plugin si disponible, sino implementar manual
});
```
Si Better Auth no soporta organizaciones directamente, implementar manualmente:
- Tabla organizations (id, name, slug, created_at)
- Tabla org_members (id, org_id, user_id, role, invited_at, joined_at)
## CASL AbilityFactory
```typescript
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
export function defineAbilityFor(role: string) {
const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
switch (role) {
case 'owner':
can('manage', 'all');
break;
case 'admin':
can('manage', 'all');
cannot('delete', 'Organization');
cannot('manage', 'License');
can('read', 'License');
break;
case 'member':
can('create', ['Session', 'Finding', 'Report']);
can('read', 'all');
can('update', 'Finding');
break;
case 'viewer':
can('read', 'all');
break;
}
return build();
}
```
## AuthMiddleware — orden de verificación
1. Check cookie de session (web UI) via Better Auth
2. Check header `Authorization: Bearer <jwt>`
3. Check header `X-ABE-API-Key: <key>` (API keys para CI/CD)
4. Si ninguno → 401
## API Key system
- POST /api/auth/api-keys — crear key (retorna key UNA vez, después solo hash)
- GET /api/auth/api-keys — listar (sin mostrar key, solo nombre + último uso)
- DELETE /api/auth/api-keys/:id — revocar
- Keys hasheadas con SHA-256 en DB
- Cada key tiene: name, permissions (array de roles), expiresAt, lastUsedAt
## First-run flow
1. Backend: si tabla users tiene 0 rows → flag `setupRequired = true`
2. GET /api/auth/setup-required → `{ required: boolean }`
3. Si required, POST /api/auth/setup con { email, password, name, orgName }
4. Crea user con role owner + organization default
5. Después de setup, requiere login normal
## Migraciones
```sql
-- users (Better Auth maneja su propia tabla, pero añadir campos custom)
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS org_members (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organizations(id),
user_id TEXT NOT NULL REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'member',
joined_at INTEGER NOT NULL,
UNIQUE(org_id, user_id)
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL, -- primeros 8 chars para identificar
permissions TEXT NOT NULL DEFAULT '["member"]',
expires_at INTEGER,
last_used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
token TEXT UNIQUE NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
```
## NOTA sobre Better Auth
Si Better Auth resulta demasiado complejo de integrar con Express puro
o tiene incompatibilidades, implementar auth manualmente:
- argon2 para hash passwords
- crypto.randomUUID() para session tokens
- Cookie httpOnly + secure + sameSite para sessions
- Middleware custom que lee cookie → busca en auth_sessions → adjunta user a req
Esto es PERFECTAMENTE VÁLIDO. No over-engineer la auth.
La prioridad es que funcione, sea seguro, y tenga RBAC.
+129
View File
@@ -0,0 +1,129 @@
# Phase 10: Frontend Shell — shadcn/ui
## Setup shadcn/ui
```bash
cd frontend
npx shadcn@latest init
# Responder: Vite, Zinc, CSS variables, YES to tailwind
```
## Layout principal
```
┌────────────────────────────────────────────────┐
│ TopBar: [☰] [ABE logo] ···· [⌘K Search] [🌙] [👤]│
├────────┬───────────────────────────────────────┤
│ │ │
│ Side- │ Content Area │
│ bar │ (React Router Outlet) │
│ │ │
│ 📊 Dashboard │
│ 🔍 Explorations │
│ 🐛 Findings │
│ 📄 Reports │
│ ───────── │
│ ⚙️ Settings │
│ │ │
└────────┴───────────────────────────────────────┘
```
## Dark mode (DEFAULT)
- Usar estrategia class-based: `<html class="dark">`
- CSS variables de shadcn ya soportan dark mode
- Toggle en TopBar: sol/luna
- Persistir en localStorage key 'abe-theme'
## Auth flow en frontend
```
App monta →
GET /api/auth/setup-required
→ si required: mostrar /setup
→ si no:
GET /api/auth/me
→ si 401: redirect /login
→ si ok: render AppLayout con user data
```
## API client (lib/api.ts)
```typescript
const API_URL = import.meta.env.VITE_API_URL || '';
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
...init,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
if (res.status === 401) {
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || `HTTP ${res.status}`);
}
return res.json();
}
```
## Routing
```typescript
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/setup" element={<Setup />} />
<Route element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route path="/" element={<Dashboard />} />
<Route path="/sessions" element={<SessionList />} />
<Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/findings" element={<FindingsList />} />
<Route path="/findings/:id" element={<FindingDetail />} />
<Route path="/reports" element={<Reports />} />
<Route path="/visual-review" element={<VisualReview />} />
<Route path="/settings/*" element={<Settings />} />
</Route>
</Routes>
```
## Command Palette (⌘K)
Powered by shadcn Command (cmdk):
- Buscar por: sessions, findings, settings sections
- Acciones: "New Exploration", "Generate Report"
- Keyboard: ⌘K abre, Esc cierra
## File structure
```
frontend/src/
├── components/
│ ├── ui/ # shadcn generados (NO tocar)
│ ├── layout/
│ │ ├── AppLayout.tsx
│ │ ├── AppSidebar.tsx
│ │ ├── TopBar.tsx
│ │ ├── CommandPalette.tsx
│ │ ├── ProtectedRoute.tsx
│ │ └── ThemeProvider.tsx
│ └── common/
│ └── SeverityBadge.tsx
├── hooks/
│ ├── useAuth.ts
│ └── useSocket.ts
├── lib/
│ ├── api.ts
│ ├── queryClient.ts
│ └── utils.ts # cn() de shadcn
├── stores/
│ └── uiStore.ts
├── pages/
│ ├── Dashboard.tsx (placeholder "Coming in Phase 11")
│ ├── Login.tsx
│ └── Setup.tsx
├── App.tsx
└── main.tsx
```
## IMPORTANTE
- El Dashboard en esta fase puede ser un placeholder que diga "Dashboard — Coming soon"
- Lo importante es que el shell funcione: login → sidebar → routing → theme
- NO intentar hacer todo el dashboard aquí — eso es Phase 11
+12 -26
View File
@@ -1,38 +1,24 @@
# .ralphrc - Ralph project configuration # .ralphrc - Ralph project configuration for ABE
# Generated by: ralph-setup
# Documentation: https://github.com/frankbria/ralph-claude-code
# Project identification # Project
PROJECT_NAME="abe" PROJECT_NAME="abe"
PROJECT_TYPE="generic" PROJECT_TYPE="typescript"
# Claude Code CLI command # Claude Code
# If "claude" is not in your PATH, set to your installation:
# "npx @anthropic-ai/claude-code" (uses npx, no global install needed)
# "/path/to/claude" (custom path)
CLAUDE_CODE_CMD="claude" CLAUDE_CODE_CMD="claude"
CLAUDE_OUTPUT_FORMAT="json"
# Loop settings # Loop settings
MAX_CALLS_PER_HOUR=100 MAX_CALLS_PER_HOUR=100
CLAUDE_TIMEOUT_MINUTES=15 CLAUDE_TIMEOUT_MINUTES=120
CLAUDE_OUTPUT_FORMAT="json"
# Tool permissions # Session continuity (mantener contexto entre loops)
# Comma-separated list of allowed tools
# Safe git subcommands only - broad Bash(git *) allows destructive commands like git clean/git rm (Issue #149)
ALLOWED_TOOLS="Write,Read,Edit,Bash(git add *),Bash(git commit *),Bash(git diff *),Bash(git log *),Bash(git status),Bash(git status *),Bash(git push *),Bash(git pull *),Bash(git fetch *),Bash(git checkout *),Bash(git branch *),Bash(git stash *),Bash(git merge *),Bash(git tag *),Bash(npm *),Bash(pytest)"
# Session management
SESSION_CONTINUITY=true SESSION_CONTINUITY=true
SESSION_EXPIRY_HOURS=24 SESSION_EXPIRY_HOURS=24
# Task sources (for ralph enable --sync) # Circuit breaker - auto-reset para operación continua
# Options: local, beads, github (comma-separated for multiple) CB_AUTO_RESET=true
TASK_SOURCES="local" CB_COOLDOWN_MINUTES=0
GITHUB_TASK_LABEL="ralph-task"
BEADS_FILTER="status:open"
# Circuit breaker thresholds # Tool permissions - "Bash" sin paréntesis permite TODOS los comandos bash
CB_NO_PROGRESS_THRESHOLD=3 ALLOWED_TOOLS="Write,Read,Edit,MultiEdit,Glob,Grep,Bash,Bash(git *),Bash(npm *),Bash(npx *),Bash(node *)"
CB_SAME_ERROR_THRESHOLD=5
CB_OUTPUT_DECLINE_THRESHOLD=70
+59
View File
@@ -0,0 +1,59 @@
# CLAUDE.md — Contexto para Claude Code
## Qué es ABE
ABE (Autonomous Bug Explorer) es una plataforma enterprise self-hosted de
descubrimiento autónomo de bugs en aplicaciones web.
## Estado actual
Fases 1-11 originales implementadas. Ahora refactorizando hacia arquitectura
modular hexagonal enterprise. Ver `.ralph/PROMPT.md` para detalles completos.
## Arquitectura
Modular monolith hexagonal con bounded contexts.
**Regla #1**: Domain NUNCA importa infrastructure.
**Regla #2**: Cross-module communication SOLO via EventBus.
**Regla #3**: Controllers son thin — delegan a use cases.
**Regla #4**: Use cases retornan Result<T, E>, nunca throw.
## Comandos principales
```bash
npm run build # build backend
cd frontend && npm run build # build frontend
npm run test # vitest tests
npm run lint # eslint
npm run db:migrate # kysely migrations
docker compose up -d --build # todo con Docker
```
## Verificación obligatoria después de cambios
```bash
npm run build && cd frontend && npm run build && cd .. && npm run test
```
## Commit después de cada tarea
```bash
git add -A && git commit -m "fase(X.Y): descripción"
```
## Stack
- Backend: Node 20, TypeScript strict, Express, socket.io, Kysely, better-sqlite3, Pino, Zod, Better Auth, CASL, Playwright
- Frontend: React 18, Vite, shadcn/ui, Tailwind, Tremor, Recharts, TanStack Query/Table, Zustand
## Estructura
```
src/shared/ → building blocks compartidos
src/modules/ → bounded contexts
src/api/ → Express server + middleware global
src/realtime/ → socket.io gateway
src/jobs/ → job queue SQLite-backed
src/cli/ → CLI
src/main.ts → composition root
frontend/ → React app
```
## Para desarrollo con Ralph
```bash
cat .ralph/fix_plan.md | grep -E "^\- \[" | head -30
ralph --monitor
```
+37
View File
@@ -0,0 +1,37 @@
# Contributing to ABE
Thank you for your interest in contributing to ABE!
## Development Setup
1. Fork and clone the repository
2. Install dependencies: `npm install && cd frontend && npm install`
3. Run migrations: `npm run db:migrate`
4. Start dev servers: `npm run dev` + `cd frontend && npm run dev`
## Architecture Rules
Before submitting a PR, ensure your code follows these rules:
1. **Domain layer** — No imports from `kysely`, `express`, `playwright` or any infrastructure
2. **Cross-module communication** — Only via EventBus (no direct module imports)
3. **Use cases** — Must return `Result<T, E>`, never throw business errors
4. **No `any`** — All new code must have explicit TypeScript types
## Making Changes
1. Create a feature branch from `main`
2. Write tests for new functionality
3. Run the full verification: `npm run build && cd frontend && npm run build && cd .. && npm run test`
4. Submit a pull request
## Commit Messages
Follow the pattern: `feat(module): description` or `fix(module): description`
## Reporting Issues
Please use [GitHub Issues](https://github.com/your-org/abe/issues) with:
- Steps to reproduce
- Expected vs actual behavior
- ABE version and Node.js version
+54
View File
@@ -0,0 +1,54 @@
# ---- Build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# ---- Production stage ----
FROM node:20-alpine
# tini as init process + chromium for Playwright + curl for healthcheck
RUN apk add --no-cache \
tini \
chromium \
nss \
freetype \
freetype-dev \
harfbuzz \
ca-certificates \
ttf-freefont \
curl
# Tell Playwright to use the system Chromium instead of downloading its own
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV NODE_ENV=production
# Non-root user
RUN addgroup -S abe && adduser -S abe -G abe
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && chown -R abe:abe /app
COPY --from=builder --chown=abe:abe /app/dist ./dist
# Runtime directories for data, reports and logs
RUN mkdir -p data reports logs && chown -R abe:abe data reports logs
USER abe
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost:3001/health/live || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/main.js"]
+33
View 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"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 ABE Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24
View File
@@ -0,0 +1,24 @@
ABE ENTERPRISE LICENSE
Copyright (c) 2024-2026 ABE Contributors
ENTERPRISE FEATURES LICENSE
The enterprise features of ABE (including but not limited to: SSO/SAML/OIDC
integration, LDAP/Active Directory, advanced audit logs, session management
dashboard, white-labeling, and data retention policies) are licensed under a
commercial license.
To obtain an enterprise license, contact: enterprise@abe.example.com
PERMITTED USES (with valid enterprise license key):
- Deploy ABE Enterprise in your organization
- Use all enterprise features
- Create internal deployments
PROHIBITED USES:
- Redistribution of enterprise features
- Sublicensing
- Removing license validation
The core ABE platform is available under the MIT License (see LICENSE).
+153 -1
View File
@@ -1 +1,153 @@
# abe # ABE — Autonomous Bug Explorer
> "Playwright discovers what you test. ABE discovers what you miss."
[![Build](https://img.shields.io/github/actions/workflow/status/your-org/abe/ci.yml?branch=main)](https://github.com/your-org/abe/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org/)
[![Node.js](https://img.shields.io/badge/Node.js-20-green)](https://nodejs.org/)
ABE is an **enterprise self-hosted platform** for autonomous web application bug discovery. It explores apps like a real user, injects invalid inputs (fuzzing), detects anomalies, and generates reproducible bug reports.
---
## Features
- **Autonomous Exploration** — BFS-based state graph exploration with deterministic seeds
- **Smart Fuzzing** — 5 strategies: empty, oversized, special characters, type mismatch, boundary values
- **Visual Regression** — pixel-level screenshot comparison with Playwright + pixelmatch
- **Accessibility Auditing** — WCAG violations via axe-core
- **Reproducible Reports** — generates Playwright test scripts, Markdown, JSON, PDF reports
- **Real-time Dashboard** — live WebSocket feed with severity charts and KPI cards
- **Auth & RBAC** — multi-user, organizations, roles (owner/admin/member/viewer), API keys
- **Integrations** — Slack, GitHub Issues, Jira, custom webhooks
- **Scheduling** — cron-based automated explorations
- **CLI + CI/CD** — JUnit XML output, GitHub Actions integration
- **API Documentation** — OpenAPI 3.1 + Scalar UI at `/api-docs`
- **Licensing** — RSA-signed license keys with feature gating (Free/Pro/Enterprise)
---
## Quick Start
### Prerequisites
- Node.js 20+
- npm 10+
### Development
```bash
# Install dependencies
npm install
cd frontend && npm install && cd ..
# Start development servers
npm run dev # Backend on :3001
cd frontend && npm run dev # Frontend on :5173
# Database migrations
npm run db:migrate
# Run tests
npm run test
# Build
npm run build
cd frontend && npm run build
```
### Docker
```bash
# Start all services
docker compose up -d --build
# Production
docker compose -f docker-compose.prod.yml up -d --build
```
The app will be available at `http://localhost:5173`.
---
## CLI Usage
```bash
# Run an exploration
node dist/cli/abe.js explore --url https://example.com \
--output json \
--fail-on-severity high
# Generate a report
node dist/cli/abe.js report --session SESSION_ID
# Check server status
node dist/cli/abe.js status
```
### CI/CD Integration
```yaml
# .github/workflows/abe.yml
- uses: ./.github/actions/abe-explore
with:
url: https://staging.example.com
fail-on-severity: high
api-key: ${{ secrets.ABE_API_KEY }}
```
---
## Architecture
ABE uses a **modular monolith hexagonal architecture** with bounded contexts:
```
src/
├── shared/ → Domain building blocks (Entity, ValueObject, Result, EventBus)
├── modules/
│ ├── crawling/ → Session management + Playwright crawler
│ ├── fuzzing/ → Input fuzzing strategies
│ ├── findings/ → Bug report lifecycle
│ ├── auth/ → Users, organizations, RBAC
│ ├── reporting/ → PDF/HTML/JSON report generation
│ ├── integrations/→ Slack, GitHub, Jira, webhooks
│ ├── scheduling/ → Cron-based automation
│ ├── licensing/ → RSA license validation
│ └── visual-regression/ → Screenshot comparison
├── api/ → Express server + OpenAPI docs
├── realtime/ → Socket.io gateway
├── jobs/ → SQLite-backed job queue
└── cli/ → Commander CLI
```
**Architectural rules:**
1. Domain never imports infrastructure
2. Cross-module communication only via EventBus
3. Use cases return `Result<T, E>`, never throw
4. Controllers are thin — delegate to use cases
---
## API Documentation
Once running, visit `http://localhost:3001/api-docs` for the interactive Scalar API reference.
Endpoints:
- `POST /api/auth/register` — Register
- `POST /api/auth/login` — Login
- `GET /api/sessions` — List explorations
- `POST /api/sessions` — Start exploration
- `GET /api/findings` — List findings
- `POST /api/reports` — Generate report
- `GET /api/schedules` — List schedules
- `GET /api/visual/comparisons` — Visual regression review
---
## License
ABE core is open-source under the [MIT License](LICENSE).
Enterprise features (SSO, LDAP, advanced audit logs) require a commercial license. See [LICENSE-ENTERPRISE](LICENSE-ENTERPRISE).
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
View File
+87
View File
@@ -0,0 +1,87 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createBrandingRouter = createBrandingRouter;
/**
* Branding/white-labeling API.
* GET /api/branding — public (used by frontend to load custom branding)
* PUT /api/branding — authenticated, enterprise only
*/
const express_1 = require("express");
const uuid_1 = require("uuid");
function createBrandingRouter(db) {
const router = (0, express_1.Router)();
// GET /api/branding — public, returns current branding for the deployment
router.get('/', async (_req, res, next) => {
try {
const row = await db
.selectFrom('branding_config')
.selectAll()
.executeTakeFirst();
if (!row) {
return res.json({
appName: 'ABE',
primaryColor: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
});
}
res.json({
appName: row.app_name,
primaryColor: row.primary_color,
logoUrl: row.logo_url,
faviconUrl: row.favicon_url,
customCss: row.custom_css,
});
}
catch (err) {
next(err);
}
});
// PUT /api/branding — update branding (authenticated)
router.put('/', async (req, res, next) => {
try {
const user = req.user;
if (!user)
return res.status(401).json({ error: 'Unauthorized' });
const { appName, primaryColor, logoUrl, faviconUrl, customCss } = req.body;
const orgId = user.orgId ?? 'default';
const existing = await db
.selectFrom('branding_config')
.select('id')
.where('organization_id', '=', orgId)
.executeTakeFirst();
if (existing) {
await db
.updateTable('branding_config')
.set({
app_name: appName ?? null,
primary_color: primaryColor ?? null,
logo_url: logoUrl ?? null,
favicon_url: faviconUrl ?? null,
custom_css: customCss ?? null,
updated_at: Date.now(),
})
.where('organization_id', '=', orgId)
.execute();
}
else {
await db.insertInto('branding_config').values({
id: (0, uuid_1.v4)(),
organization_id: orgId,
app_name: appName ?? null,
primary_color: primaryColor ?? null,
logo_url: logoUrl ?? null,
favicon_url: faviconUrl ?? null,
custom_css: customCss ?? null,
updated_at: Date.now(),
}).execute();
}
res.json({ success: true });
}
catch (err) {
next(err);
}
});
return router;
}
+78
View File
@@ -0,0 +1,78 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.ForbiddenError = exports.AuthenticationError = exports.ValidationError = exports.AppError = void 0;
exports.globalErrorHandler = globalErrorHandler;
class AppError extends Error {
constructor(message, statusCode, code, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = isOperational;
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
exports.AppError = AppError;
class ValidationError extends AppError {
constructor(message, details) {
super(message, 400, 'VALIDATION_ERROR');
this.details = details;
}
}
exports.ValidationError = ValidationError;
class AuthenticationError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
exports.AuthenticationError = AuthenticationError;
class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
exports.ForbiddenError = ForbiddenError;
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
exports.NotFoundError = NotFoundError;
class ConflictError extends AppError {
constructor(message) {
super(message, 409, 'CONFLICT');
}
}
exports.ConflictError = ConflictError;
class RateLimitError extends AppError {
constructor() {
super('Too many requests', 429, 'RATE_LIMIT');
}
}
exports.RateLimitError = RateLimitError;
function globalErrorHandler(err, req, res, _next) {
const authReq = req;
const logger = authReq.log;
const userId = authReq.user?.id;
if (err instanceof AppError && err.isOperational) {
if (logger) {
logger.warn({ err, statusCode: err.statusCode, userId }, err.message);
}
const body = { error: err.message, code: err.code };
if (err instanceof ValidationError && err.details !== undefined) {
body['details'] = err.details;
}
res.status(err.statusCode).json(body);
return;
}
if (logger) {
logger.error({ err, userId }, 'Unhandled error');
}
else {
console.error('Unhandled error', err);
}
res.status(500).json({
error: process.env['NODE_ENV'] === 'production' ? 'Internal server error' : err.message,
code: 'INTERNAL_ERROR',
});
}
+9
View File
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.notFoundMiddleware = notFoundMiddleware;
function notFoundMiddleware(req, res) {
res.status(404).json({
error: `Route ${req.method} ${req.path} not found`,
code: 'NOT_FOUND',
});
}
+11
View File
@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRequestIdMiddleware = createRequestIdMiddleware;
const crypto_1 = require("crypto");
function createRequestIdMiddleware(logger) {
return (req, _res, next) => {
req.id = req.headers['x-request-id'] ?? (0, crypto_1.randomUUID)();
req.log = logger.child({ requestId: req.id, method: req.method, url: req.url });
next();
};
}
+622
View File
@@ -0,0 +1,622 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.openApiSpec = void 0;
exports.createApiDocsRouter = createApiDocsRouter;
/**
* OpenAPI 3.1 specification for ABE API.
* Uses @asteasolutions/zod-to-openapi to generate from Zod schemas.
*/
const express_1 = require("express");
const zod_1 = require("zod");
const zod_to_openapi_1 = require("@asteasolutions/zod-to-openapi");
const express_api_reference_1 = require("@scalar/express-api-reference");
// Extend Zod with OpenAPI metadata support
(0, zod_to_openapi_1.extendZodWithOpenApi)(zod_1.z);
// ─── Registry ─────────────────────────────────────────────────────────────────
const registry = new zod_to_openapi_1.OpenAPIRegistry();
// ─── Reusable schemas ─────────────────────────────────────────────────────────
const ErrorSchema = registry.register('Error', zod_1.z.object({ error: zod_1.z.string() }).openapi('Error'));
// Auth schemas
const RegisterRequestSchema = registry.register('RegisterRequest', zod_1.z.object({
email: zod_1.z.string().email(),
password: zod_1.z.string().min(8),
name: zod_1.z.string().optional(),
}).openapi('RegisterRequest'));
const LoginRequestSchema = registry.register('LoginRequest', zod_1.z.object({
email: zod_1.z.string().email(),
password: zod_1.z.string(),
}).openapi('LoginRequest'));
const UserSchema = registry.register('User', zod_1.z.object({
id: zod_1.z.string(),
email: zod_1.z.string(),
name: zod_1.z.string().nullable(),
role: zod_1.z.enum(['owner', 'admin', 'member', 'viewer']),
createdAt: zod_1.z.number(),
}).openapi('User'));
// Session schemas
const SessionStatusSchema = zod_1.z.enum(['running', 'completed', 'failed', 'stopped']);
const CrawlSessionSchema = registry.register('CrawlSession', zod_1.z.object({
id: zod_1.z.string(),
url: zod_1.z.string().url(),
status: SessionStatusSchema,
seed: zod_1.z.number(),
maxStates: zod_1.z.number(),
statesVisited: zod_1.z.number(),
createdAt: zod_1.z.number(),
completedAt: zod_1.z.number().nullable(),
}).openapi('CrawlSession'));
const StartSessionRequestSchema = registry.register('StartSessionRequest', zod_1.z.object({
url: zod_1.z.string().url(),
seed: zod_1.z.number().optional(),
maxStates: zod_1.z.number().optional(),
maxDepth: zod_1.z.number().optional(),
allowedDomains: zod_1.z.array(zod_1.z.string()).optional(),
excludedPaths: zod_1.z.array(zod_1.z.string()).optional(),
}).openapi('StartSessionRequest'));
// Finding schemas
const SeveritySchema = zod_1.z.enum(['low', 'medium', 'high', 'critical']);
const FindingStatusSchema = zod_1.z.enum(['open', 'investigating', 'resolved', 'closed']);
const FindingSchema = registry.register('Finding', zod_1.z.object({
id: zod_1.z.string(),
sessionId: zod_1.z.string(),
severity: SeveritySchema,
type: zod_1.z.string(),
description: zod_1.z.string(),
status: FindingStatusSchema,
createdAt: zod_1.z.number(),
resolvedAt: zod_1.z.number().nullable(),
}).openapi('Finding'));
// Report schemas
const ReportFormatSchema = zod_1.z.enum(['pdf', 'html', 'json']);
const ReportSchema = registry.register('Report', zod_1.z.object({
id: zod_1.z.string(),
format: ReportFormatSchema,
status: zod_1.z.enum(['pending', 'completed', 'failed']),
createdAt: zod_1.z.number(),
completedAt: zod_1.z.number().nullable(),
}).openapi('Report'));
// Schedule schemas
const ScheduleSchema = registry.register('Schedule', zod_1.z.object({
id: zod_1.z.string(),
name: zod_1.z.string(),
url: zod_1.z.string(),
cronExpression: zod_1.z.string(),
enabled: zod_1.z.boolean(),
lastRunAt: zod_1.z.number().nullable(),
nextRunAt: zod_1.z.number().nullable(),
createdAt: zod_1.z.number(),
}).openapi('Schedule'));
// Integration schemas
const IntegrationTypeSchema = zod_1.z.enum(['slack', 'github', 'jira', 'webhook']);
const IntegrationSchema = registry.register('Integration', zod_1.z.object({
id: zod_1.z.string(),
type: IntegrationTypeSchema,
name: zod_1.z.string(),
enabled: zod_1.z.boolean(),
createdAt: zod_1.z.number(),
}).openapi('Integration'));
// Visual comparison schemas
const ComparisonStatusSchema = zod_1.z.enum(['passed', 'failed', 'new_state', 'pending']);
const VisualComparisonSchema = registry.register('VisualComparison', zod_1.z.object({
id: zod_1.z.string(),
session_id: zod_1.z.string(),
state_id: zod_1.z.string(),
baseline_id: zod_1.z.string().nullable(),
current_screenshot_path: zod_1.z.string(),
diff_screenshot_path: zod_1.z.string().nullable(),
diff_pixels: zod_1.z.number().nullable(),
diff_percent: zod_1.z.number().nullable(),
status: ComparisonStatusSchema,
created_at: zod_1.z.number(),
}).openapi('VisualComparison'));
// License schema
const LicensePlanSchema = zod_1.z.enum(['free', 'pro', 'enterprise']);
const LicenseStatusSchema = registry.register('LicenseStatus', zod_1.z.object({
plan: LicensePlanSchema,
valid: zod_1.z.boolean(),
expiresAt: zod_1.z.string().nullable(),
features: zod_1.z.array(zod_1.z.string()),
}).openapi('LicenseStatus'));
// ─── Route registrations ───────────────────────────────────────────────────────
const bearerAuth = registry.registerComponent('securitySchemes', 'BearerAuth', {
type: 'http',
scheme: 'bearer',
});
// Auth endpoints
registry.registerPath({
method: 'post',
path: '/api/auth/register',
summary: 'Register a new user',
tags: ['Auth'],
request: { body: { content: { 'application/json': { schema: RegisterRequestSchema } } } },
responses: {
201: { description: 'User registered', content: { 'application/json': { schema: UserSchema } } },
400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/auth/login',
summary: 'Login',
tags: ['Auth'],
request: { body: { content: { 'application/json': { schema: LoginRequestSchema } } } },
responses: {
200: { description: 'Login successful', content: { 'application/json': { schema: UserSchema } } },
401: { description: 'Invalid credentials', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/auth/logout',
summary: 'Logout',
tags: ['Auth'],
security: [{ [bearerAuth.name]: [] }],
responses: { 200: { description: 'Logged out' } },
});
registry.registerPath({
method: 'get',
path: '/api/auth/me',
summary: 'Get current user',
tags: ['Auth'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Current user', content: { 'application/json': { schema: UserSchema } } },
401: { description: 'Not authenticated', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/auth/setup-required',
summary: 'Check if first-run setup is required',
tags: ['Auth'],
responses: {
200: {
description: 'Setup status',
content: {
'application/json': {
schema: zod_1.z.object({ required: zod_1.z.boolean() }),
},
},
},
},
});
// Sessions endpoints
registry.registerPath({
method: 'get',
path: '/api/sessions',
summary: 'List all crawl sessions',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: {
description: 'List of sessions',
content: { 'application/json': { schema: zod_1.z.array(CrawlSessionSchema) } },
},
},
});
registry.registerPath({
method: 'post',
path: '/api/sessions',
summary: 'Start a new crawl session',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
request: { body: { content: { 'application/json': { schema: StartSessionRequestSchema } } } },
responses: {
201: { description: 'Session started', content: { 'application/json': { schema: CrawlSessionSchema } } },
400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/sessions/{id}',
summary: 'Get session by ID',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Session details', content: { 'application/json': { schema: CrawlSessionSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'delete',
path: '/api/sessions/{id}',
summary: 'Stop a crawl session',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Session stopped' },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Findings endpoints
registry.registerPath({
method: 'get',
path: '/api/findings',
summary: 'List findings',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
request: {
query: zod_1.z.object({
severity: zod_1.z.string().optional(),
type: zod_1.z.string().optional(),
status: zod_1.z.string().optional(),
sessionId: zod_1.z.string().optional(),
search: zod_1.z.string().optional(),
}),
},
responses: {
200: {
description: 'List of findings',
content: { 'application/json': { schema: zod_1.z.array(FindingSchema) } },
},
},
});
registry.registerPath({
method: 'get',
path: '/api/findings/{id}',
summary: 'Get finding by ID',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Finding details', content: { 'application/json': { schema: FindingSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/findings/{id}/resolve',
summary: 'Resolve a finding',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Finding resolved', content: { 'application/json': { schema: FindingSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/findings/stats',
summary: 'Get finding statistics',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: {
description: 'Statistics',
content: {
'application/json': {
schema: zod_1.z.object({
total: zod_1.z.number(),
bySeverity: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
byType: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
byStatus: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
}),
},
},
},
},
});
// Reports endpoints
registry.registerPath({
method: 'post',
path: '/api/reports',
summary: 'Generate a report',
tags: ['Reports'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({
sessionId: zod_1.z.string().optional(),
format: ReportFormatSchema,
}),
},
},
},
},
responses: {
202: { description: 'Report generation started', content: { 'application/json': { schema: ReportSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/reports',
summary: 'List reports',
tags: ['Reports'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Reports list', content: { 'application/json': { schema: zod_1.z.array(ReportSchema) } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/reports/{id}/download',
summary: 'Download report file',
tags: ['Reports'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Report file (PDF, HTML or JSON)' },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Schedules endpoints
registry.registerPath({
method: 'get',
path: '/api/schedules',
summary: 'List schedules',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Schedules list', content: { 'application/json': { schema: zod_1.z.array(ScheduleSchema) } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/schedules',
summary: 'Create a schedule',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({
name: zod_1.z.string(),
url: zod_1.z.string().url(),
cronExpression: zod_1.z.string(),
config: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
}),
},
},
},
},
responses: {
201: { description: 'Schedule created', content: { 'application/json': { schema: ScheduleSchema } } },
400: { description: 'Invalid cron expression', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'patch',
path: '/api/schedules/{id}/toggle',
summary: 'Enable or disable a schedule',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Toggled', content: { 'application/json': { schema: ScheduleSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'delete',
path: '/api/schedules/{id}',
summary: 'Delete a schedule',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Deleted' },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Integrations endpoints
registry.registerPath({
method: 'get',
path: '/api/integrations',
summary: 'List integrations',
tags: ['Integrations'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Integrations list', content: { 'application/json': { schema: zod_1.z.array(IntegrationSchema) } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/integrations',
summary: 'Create an integration',
tags: ['Integrations'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({
type: IntegrationTypeSchema,
name: zod_1.z.string(),
config: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
}),
},
},
},
},
responses: {
201: { description: 'Integration created', content: { 'application/json': { schema: IntegrationSchema } } },
},
});
// Visual regression endpoints
registry.registerPath({
method: 'get',
path: '/api/visual/comparisons',
summary: 'List visual comparisons',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: {
query: zod_1.z.object({
sessionId: zod_1.z.string().optional(),
status: ComparisonStatusSchema.optional(),
}),
},
responses: {
200: {
description: 'Comparisons list',
content: { 'application/json': { schema: zod_1.z.array(VisualComparisonSchema) } },
},
},
});
registry.registerPath({
method: 'post',
path: '/api/visual/baselines/{comparisonId}/approve',
summary: 'Approve a comparison as baseline',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ comparisonId: zod_1.z.string() }) },
responses: {
200: {
description: 'Approved',
content: {
'application/json': {
schema: zod_1.z.object({ baselineId: zod_1.z.string(), status: zod_1.z.literal('approved') }),
},
},
},
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/visual/baselines/{comparisonId}/reject',
summary: 'Reject a comparison',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ comparisonId: zod_1.z.string() }) },
responses: {
200: {
description: 'Rejected',
content: {
'application/json': {
schema: zod_1.z.object({ status: zod_1.z.literal('rejected') }),
},
},
},
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/visual/baselines/approve-all',
summary: 'Approve all new-state comparisons as baselines',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({ sessionId: zod_1.z.string().optional() }),
},
},
},
},
responses: {
200: {
description: 'Bulk approved',
content: {
'application/json': {
schema: zod_1.z.object({ approved: zod_1.z.number() }),
},
},
},
},
});
// License endpoints
registry.registerPath({
method: 'get',
path: '/api/license/status',
summary: 'Get license status',
tags: ['License'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'License status', content: { 'application/json': { schema: LicenseStatusSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/license/activate',
summary: 'Activate a license key',
tags: ['License'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({ key: zod_1.z.string() }),
},
},
},
},
responses: {
200: { description: 'License activated', content: { 'application/json': { schema: LicenseStatusSchema } } },
400: { description: 'Invalid key', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Health endpoints
registry.registerPath({
method: 'get',
path: '/health/live',
summary: 'Liveness probe',
tags: ['Health'],
responses: {
200: {
description: 'Process alive',
content: {
'application/json': {
schema: zod_1.z.object({ status: zod_1.z.literal('ok'), uptime: zod_1.z.number() }),
},
},
},
},
});
registry.registerPath({
method: 'get',
path: '/health/ready',
summary: 'Readiness probe',
tags: ['Health'],
responses: {
200: {
description: 'Ready',
content: {
'application/json': {
schema: zod_1.z.object({ status: zod_1.z.literal('ready'), db: zod_1.z.string() }),
},
},
},
503: {
description: 'Not ready',
content: {
'application/json': {
schema: zod_1.z.object({ status: zod_1.z.literal('not_ready'), db: zod_1.z.string(), error: zod_1.z.string() }),
},
},
},
},
});
// ─── Generate spec ─────────────────────────────────────────────────────────────
const generator = new zod_to_openapi_1.OpenApiGeneratorV31(registry.definitions);
exports.openApiSpec = generator.generateDocument({
openapi: '3.1.0',
info: {
title: 'ABE — Autonomous Bug Explorer API',
version: '1.0.0',
description: 'ABE is an enterprise self-hosted platform for autonomous web application bug discovery. ' +
'This API allows you to manage crawl sessions, review findings, generate reports, and configure integrations.',
},
servers: [{ url: 'http://localhost:3001', description: 'Local development' }],
});
// ─── Express Router ────────────────────────────────────────────────────────────
function createApiDocsRouter() {
const router = (0, express_1.Router)();
// Serve the raw OpenAPI JSON spec
router.get('/openapi.json', (_req, res) => {
res.json(exports.openApiSpec);
});
// Serve Scalar UI
router.use('/', (0, express_api_reference_1.apiReference)({
spec: { content: exports.openApiSpec },
theme: 'purple',
}));
return router;
}
+47
View File
@@ -0,0 +1,47 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRouter = createRouter;
/**
* ABE API Router — registers all module routes.
*/
const express_1 = require("express");
const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController");
const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController");
const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController");
const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController");
const IntegrationsController_1 = require("../modules/integrations/infrastructure/http/IntegrationsController");
const SchedulingController_1 = require("../modules/scheduling/infrastructure/http/SchedulingController");
const VisualRegressionController_1 = require("../modules/visual-regression/infrastructure/http/VisualRegressionController");
const LicensingController_1 = require("../modules/licensing/infrastructure/http/LicensingController");
const FeatureGateMiddleware_1 = require("../modules/licensing/infrastructure/middleware/FeatureGateMiddleware");
const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController");
const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware");
const SSOController_1 = require("../modules/sso/infrastructure/http/SSOController");
const AuditController_1 = require("../modules/audit/infrastructure/http/AuditController");
const branding_1 = require("./branding");
function createRouter(deps) {
const router = (0, express_1.Router)();
const { authDeps, licenseService } = deps;
// Auth routes — public (no auth middleware)
router.use('/auth', (0, AuthController_1.createAuthController)(authDeps.registerCommand, authDeps.loginCommand, authDeps.createOrgCommand, authDeps.inviteMemberCommand, authDeps.createApiKeyCommand, authDeps.getUserQuery, authDeps.listOrgMembersQuery, authDeps.sessionRepository, authDeps.apiKeyRepository, authDeps.userRepository));
// Apply auth middleware to all routes below
const authMiddleware = (0, AuthMiddleware_1.createAuthMiddleware)(authDeps.userRepository, authDeps.sessionRepository, authDeps.apiKeyRepository);
router.use(authMiddleware);
router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps));
router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps));
router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps));
router.use('/reports', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'reports:basic'), (0, ReportingController_1.createReportingRouter)(deps.reportingDeps));
router.use('/integrations', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'integrations:webhook'), (0, IntegrationsController_1.createIntegrationsRouter)(deps.integrationsDeps));
router.use('/schedules', (0, SchedulingController_1.createSchedulingRouter)(deps.schedulingDeps));
router.use('/visual', (0, VisualRegressionController_1.createVisualRegressionRouter)(deps.visualRegressionDeps));
// Licensing routes (public-ish — only status and activate, no sensitive data)
const licensingController = new LicensingController_1.LicensingController(licenseService);
router.use('/license', licensingController.router);
// Enterprise: SSO + MFA (feature-gated)
router.use('/sso', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'auth:sso'), (0, SSOController_1.createSSORouter)(deps.ssoDeps));
// Enterprise: Audit logs (feature-gated)
router.use('/audit', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'audit:logs'), (0, AuditController_1.createAuditRouter)(deps.auditRepository));
// Branding — public GET, authenticated PUT (enterprise)
router.use('/branding', (0, branding_1.createBrandingRouter)(deps.db));
return router;
}
+69
View File
@@ -0,0 +1,69 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createServer = createServer;
/**
* ABE API Server — Express app factory.
* Middleware order matters: requestId → helmet → cors → rateLimit → body → routes → notFound → errorHandler
*/
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const helmet_1 = __importDefault(require("helmet"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const cookie_parser_1 = __importDefault(require("cookie-parser"));
const requestId_1 = require("./middleware/requestId");
const notFound_1 = require("./middleware/notFound");
const errorHandler_1 = require("./middleware/errorHandler");
const router_1 = require("./router");
const openapi_1 = require("./openapi");
function createServer(deps) {
const app = (0, express_1.default)();
// 1. Request ID — must be first so all logs have requestId
app.use((0, requestId_1.createRequestIdMiddleware)(deps.logger));
// 2. Security headers
app.use((0, helmet_1.default)({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", 'ws:', 'wss:'],
scriptSrc: ["'self'", "'unsafe-inline'"],
},
},
}));
// 3. CORS
app.use((0, cors_1.default)({ origin: deps.config.cors.origin, credentials: true }));
// 4. Rate limiting
app.use((0, express_rate_limit_1.default)({
windowMs: deps.config.api.rateLimitWindowMs,
max: deps.config.api.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
}));
// 5. Body parsing + cookies
app.use(express_1.default.json({ limit: '10mb' }));
app.use((0, cookie_parser_1.default)());
// 6. Health endpoints — no auth required
app.get('/health/live', (_req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/health/ready', async (_req, res) => {
try {
await deps.db.selectFrom('sessions').select('id').limit(1).execute();
res.json({ status: 'ready', db: 'connected' });
}
catch (err) {
res.status(503).json({ status: 'not_ready', db: 'disconnected', error: String(err) });
}
});
// 7. Module routes
app.use('/api', (0, router_1.createRouter)(deps));
// 7b. API documentation (no auth required)
app.use('/api-docs', (0, openapi_1.createApiDocsRouter)());
// 8. 404 handler
app.use(notFound_1.notFoundMiddleware);
// 9. Global error handler — always last
app.use(errorHandler_1.globalErrorHandler);
return app;
}
Vendored
+252
View File
@@ -0,0 +1,252 @@
"use strict";
/**
* ABE CLI — command-line interface for running explorations.
* Usage: abe run --url http://localhost:3000
*/
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');
program
.command('run')
.description('Run an exploration session against a target URL')
.requiredOption('--url <url>', 'Target URL to explore')
.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', '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 anomaly at or above severity found')
// 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) => {
const startMs = Date.now();
// 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,
maxStates: opts.maxStates,
maxDepth: opts.maxDepth,
actionDelayMs: opts.actionDelay,
sessionTimeoutMs: opts.sessionTimeout,
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,
};
// If remote server mode
if (opts.server) {
await runRemote(opts, config);
return;
}
const anomalies = [];
let exitCode = 0;
let explorationError;
try {
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed: opts.seed, explorationConfig: config });
const engine = new ExplorationEngine_1.ExplorationEngine({
graph,
agent,
seed: opts.seed,
url: opts.url,
maxSteps: opts.maxStates,
outputDir: opts.reportsDir,
explorationConfig: config,
collectors: [
new ScreenshotCollector_1.ScreenshotCollector(opts.reportsDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(opts.reportsDir),
],
exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()],
reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(),
events: {
onAnomalyDetected: (_, anomaly) => {
anomalies.push(anomaly);
},
onSessionError: (_, error) => {
explorationError = error;
},
},
});
await engine.run();
}
catch (err) {
explorationError = err instanceof Error ? err.message : String(err);
exitCode = 2;
}
if (explorationError && exitCode === 0)
exitCode = 2;
// Determine exit code from 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 summary = {
url: opts.url,
duration_ms: durationMs,
anomalies: anomalies.map((a) => ({
id: a.id,
type: a.type,
severity: a.severity,
description: a.description,
report_path: path.join(opts.reportsDir, a.id, 'report.json'),
})),
exit_code: exitCode,
};
if (opts.output === 'json') {
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
}
else if (opts.output === 'junit') {
const xml = buildJunit(summary, opts.url);
const outPath = path.join(process.cwd(), 'abe-results.xml');
fs.writeFileSync(outPath, xml, 'utf8');
if (opts.output !== 'json') {
console.log(`JUnit results written to ${outPath}`);
}
}
else {
if (anomalies.length === 0) {
console.log(`✓ ABE finished. No anomalies found. (${durationMs}ms)`);
}
else {
console.log(`⚠ ABE finished. ${anomalies.length} anomaly(ies) found:`);
for (const a of anomalies) {
console.log(` [${a.severity.toUpperCase()}] ${a.type}: ${a.description}`);
}
}
}
process.exit(exitCode);
});
async function runRemote(opts, _config) {
const serverUrl = opts['server'];
const apiKey = opts['apiKey'];
const url = opts['url'];
const headers = { 'Content-Type': 'application/json' };
if (apiKey)
headers['x-abe-api-key'] = apiKey;
const res = await fetch(`${serverUrl}/api/sessions`, {
method: 'POST',
headers,
body: JSON.stringify({ url }),
});
if (!res.ok) {
console.error(`Server error: ${res.status} ${await res.text()}`);
process.exit(2);
return;
}
const session = await res.json();
console.log(`Session started: ${session.sessionId}`);
process.exit(0);
}
function buildJunit(summary, url) {
const anomalyCount = summary.anomalies.length;
const cases = summary.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>`)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="ABE Exploration: ${escapeXml(url)}" tests="${anomalyCount}" failures="${anomalyCount}">\n` +
cases + '\n' +
`</testsuite>\n`;
}
function escapeXml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
program.parse(process.argv);
+602
View File
@@ -0,0 +1,602 @@
"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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
program.parse(process.argv);
// ─── backup ─────────────────────────────────────────────────────────────────
program
.command('backup')
.description('Backup ABE database to a file')
.option('--db <path>', 'Path to ABE database', './data/abe.db')
.option('--output <file>', 'Backup file path', `./abe-backup-${new Date().toISOString().slice(0, 10)}.db`)
.action((opts) => {
const src = opts.db;
const dest = opts.output;
if (!fs.existsSync(src)) {
console.error(`Database not found: ${src}`);
process.exit(2);
}
fs.copyFileSync(src, dest);
const size = fs.statSync(dest).size;
console.log(`✅ Backup created: ${dest} (${Math.round(size / 1024)} KB)`);
});
// ─── restore ────────────────────────────────────────────────────────────────
program
.command('restore')
.description('Restore ABE database from a backup file')
.requiredOption('--from <file>', 'Backup file to restore from')
.option('--db <path>', 'Path to ABE database', './data/abe.db')
.option('--confirm', 'Skip confirmation prompt')
.action((opts) => {
if (!fs.existsSync(opts.from)) {
console.error(`Backup file not found: ${opts.from}`);
process.exit(2);
}
if (!opts.confirm) {
console.warn(`⚠️ This will overwrite the database at: ${opts.db}`);
console.warn(`Run with --confirm to proceed.`);
process.exit(1);
}
const dir = path.dirname(opts.db);
if (!fs.existsSync(dir))
fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(opts.from, opts.db);
const size = fs.statSync(opts.db).size;
console.log(`✅ Database restored from: ${opts.from} (${Math.round(size / 1024)} KB)`);
});
// ─── retention ──────────────────────────────────────────────────────────────
program
.command('retention')
.description('Run data retention cleanup (enterprise feature)')
.option('--db <path>', 'Path to ABE database', './data/abe.db')
.option('--findings-days <n>', 'Delete findings older than N days', parseInt, 365)
.option('--sessions-days <n>', 'Delete sessions older than N days', parseInt, 90)
.option('--audit-days <n>', 'Delete audit logs older than N days', parseInt, 365)
.option('--jobs-days <n>', 'Delete completed jobs older than N days', parseInt, 30)
.option('--dry-run', 'Show what would be deleted without deleting')
.action(async (opts) => {
if (!fs.existsSync(opts.db)) {
console.error(`Database not found: ${opts.db}`);
process.exit(2);
}
if (opts.dryRun) {
console.log('🔍 Dry run mode — nothing will be deleted');
console.log(` Findings older than ${opts.findingsDays} days`);
console.log(` Sessions older than ${opts.sessionsDays} days`);
console.log(` Audit logs older than ${opts.auditDays} days`);
console.log(` Jobs older than ${opts.jobsDays} days`);
return;
}
// Dynamically import to avoid loading DB in non-DB commands
const { Kysely, SqliteDialect } = await Promise.resolve().then(() => __importStar(require('kysely')));
const SQLite = (await Promise.resolve().then(() => __importStar(require('better-sqlite3')))).default;
const { DataRetentionService } = await Promise.resolve().then(() => __importStar(require('../modules/scheduling/infrastructure/DataRetentionService')));
const pino = (await Promise.resolve().then(() => __importStar(require('pino')))).default;
const logger = pino({ level: 'info' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = new Kysely({ dialect: new SqliteDialect({ database: new SQLite(opts.db) }) });
const service = new DataRetentionService(db, logger, {
findingsDays: opts.findingsDays,
sessionsDays: opts.sessionsDays,
auditLogsDays: opts.auditDays,
jobsDays: opts.jobsDays,
});
const results = await service.runRetention();
await db.destroy();
console.log('✅ Data retention completed:');
for (const [key, count] of Object.entries(results)) {
console.log(` ${key}: ${count} rows deleted`);
}
});
+137
View File
@@ -0,0 +1,137 @@
"use strict";
/**
* AnomalyDetector — heuristic rules to detect anomalies from observations.
* Each rule is independent and testable in isolation.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AnomalyDetector = void 0;
let anomalyCounter = 0;
function makeId() {
anomalyCounter += 1;
return `anom_${Date.now()}_${anomalyCounter.toString().padStart(4, '0')}`;
}
class AnomalyDetector {
detect(observation, actionTrace) {
const anomalies = [];
const httpAnomaly = this.checkHttpErrors(observation, actionTrace);
if (httpAnomaly)
anomalies.push(httpAnomaly);
const jsAnomaly = this.checkJsExceptions(observation, actionTrace);
if (jsAnomaly)
anomalies.push(jsAnomaly);
const consoleAnomaly = this.checkConsoleErrors(observation, actionTrace);
if (consoleAnomaly)
anomalies.push(consoleAnomaly);
return anomalies;
}
/** Rule: HTTP 4xx or 5xx responses */
checkHttpErrors(observation, actionTrace) {
const errorResponses = observation.httpResponses.filter((r) => r.status >= 400);
if (errorResponses.length === 0)
return null;
const hasServerError = errorResponses.some((r) => r.status >= 500);
const severity = hasServerError ? 'high' : 'medium';
const statusCodes = errorResponses.map((r) => r.status).join(', ');
return this.buildAnomaly({
type: 'http_error',
severity,
observationId: observation.id,
actionTrace,
description: `HTTP error responses detected: ${statusCodes}`,
evidence: {
httpLog: errorResponses,
rawErrors: errorResponses.map((r) => `${r.method} ${r.url}${r.status} (${r.durationMs}ms)`),
},
});
}
/** Rule: uncaught JS exceptions */
checkJsExceptions(observation, actionTrace) {
if (observation.jsExceptions.length === 0)
return null;
return this.buildAnomaly({
type: 'js_exception',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Uncaught JS exception: ${observation.jsExceptions[0]}`,
evidence: {
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: console.error messages */
checkConsoleErrors(observation, actionTrace) {
if (observation.consoleErrors.length === 0)
return null;
return this.buildAnomaly({
type: 'console_error',
severity: 'low',
observationId: observation.id,
actionTrace,
description: `Console error detected: ${observation.consoleErrors[0]}`,
evidence: {
rawErrors: observation.consoleErrors,
},
});
}
/**
* Rule: server accepted clearly invalid/empty fuzz input (got 2xx).
* fuzzedValue is the value that was submitted; responseStatus is the HTTP response.
*/
checkValidationBypass(observation, actionTrace, fuzzedValue) {
const has2xx = observation.httpResponses.some((r) => r.status >= 200 && r.status < 300);
if (!has2xx)
return null;
return this.buildAnomaly({
type: 'validation_bypass',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Server accepted invalid input without error (value: ${JSON.stringify(fuzzedValue).substring(0, 50)})`,
evidence: { httpLog: observation.httpResponses, rawErrors: [`Fuzzed with: ${fuzzedValue}`] },
});
}
/** Rule: server returned 500 on a fuzzed input */
checkServerErrorOnFuzz(observation, actionTrace) {
const has5xx = observation.httpResponses.some((r) => r.status >= 500);
if (!has5xx)
return null;
return this.buildAnomaly({
type: 'server_error_on_fuzz',
severity: 'high',
observationId: observation.id,
actionTrace,
description: 'Server returned 5xx on fuzzed input',
evidence: {
httpLog: observation.httpResponses.filter((r) => r.status >= 500),
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: fuzzed script tag appears in response body (XSS reflection) */
checkXssReflection(observation, actionTrace, domSnapshot) {
if (!domSnapshot.includes('<script>alert(1)</script>'))
return null;
return this.buildAnomaly({
type: 'xss_reflection',
severity: 'critical',
observationId: observation.id,
actionTrace,
description: 'XSS reflection detected: fuzzed script tag appeared in DOM',
evidence: { rawErrors: ['XSS payload reflected in DOM'] },
});
}
buildAnomaly(params) {
return {
id: makeId(),
type: params.type,
severity: params.severity,
observationId: params.observationId,
actionTrace: params.actionTrace,
description: params.description,
evidence: params.evidence,
timestamp: Date.now(),
};
}
}
exports.AnomalyDetector = AnomalyDetector;
+53
View File
@@ -0,0 +1,53 @@
"use strict";
/**
* ExplorationConfig — defines scope, auth, fuzzing, multi-browser, a11y,
* performance, visual regression, and network chaos settings for a session.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_EXPLORATION_CONFIG = exports.NETWORK_PROFILES = void 0;
exports.NETWORK_PROFILES = {
'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false },
'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false },
'2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false },
'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true },
'none': null,
};
exports.DEFAULT_EXPLORATION_CONFIG = {
allowedDomains: [],
maxStates: 50,
maxDepth: 5,
actionDelayMs: 500,
sessionTimeoutMs: 300000,
excludedPaths: [],
excludedSelectors: [],
auth: null,
fuzzingEnabled: true,
fuzzingIntensity: 'medium',
browsers: ['chromium'],
mobileDevice: 'none',
viewport: null,
accessibility: {
enabled: true,
minImpact: 'serious',
wcagLevel: 'AA',
},
performance: {
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
},
visualRegression: {
enabled: false,
threshold: 0.001,
screenshotFullPage: false,
ignoreSelectors: [],
},
networkChaos: {
enabled: false,
profile: 'none',
blockedEndpoints: [],
slowEndpoints: [],
},
};
+197
View File
@@ -0,0 +1,197 @@
"use strict";
/**
* ExplorationEngine — the core loop of ABE.
* Selects states, executes actions, records observations, and detects anomalies.
* Depends only on core interfaces — never imports concrete plugins.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ExplorationEngine = void 0;
const AnomalyDetector_1 = require("./AnomalyDetector");
const Logger_1 = require("./Logger");
class ExplorationEngine {
constructor(config) {
/** Accumulated action trace for the current session */
this.actionTrace = [];
/** Set to true to abort the running loop */
this.aborted = false;
this.graph = config.graph;
this.agent = config.agent;
this.detector = config.detector ?? new AnomalyDetector_1.AnomalyDetector();
this.collectors = config.collectors ?? [];
this.exporters = config.exporters ?? [];
this.reproducer = config.reproducer;
this.logger = config.logger ?? new Logger_1.NullLogger();
this.seed = config.seed;
this.url = config.url;
this.maxSteps = config.maxSteps ?? 100;
this.outputDir = config.outputDir ?? './reports';
this.events = config.events ?? {};
this.sessionId = config.sessionId ?? `${Date.now()}_${config.seed}`;
this.explorationConfig = config.explorationConfig ?? {};
this.fuzzingPlugin = config.fuzzingPlugin;
this.stateHooks = config.stateHooks ?? [];
}
/** Signals the engine to stop after the current step completes. */
stop() {
this.aborted = true;
}
async run() {
const anomalies = [];
let stepsExecuted = 0;
let depth = 0;
const sessionTimeoutMs = this.explorationConfig.sessionTimeoutMs ?? 0;
const maxDepth = this.explorationConfig.maxDepth ?? Infinity;
const sessionStart = Date.now();
this.logger.log({
event: 'session_start',
timestamp: sessionStart,
seed: this.seed,
target: this.url,
});
this.events.onSessionStarted?.(this.sessionId, this.url);
const isTimedOut = () => sessionTimeoutMs > 0 && Date.now() - sessionStart >= sessionTimeoutMs;
try {
await this.agent.launch(this.url);
// Capture initial state
const initialState = await this.agent.captureState();
this.graph.addState(initialState);
this.logger.log({
event: 'state_discovered',
timestamp: Date.now(),
stateId: initialState.id,
url: initialState.url,
title: initialState.title,
});
this.events.onStateDiscovered?.(this.sessionId, initialState.id, initialState.url, initialState.title);
while (stepsExecuted < this.maxSteps && !this.aborted && !isTimedOut() && depth <= maxDepth) {
const currentState = this.graph.getNextToExplore();
if (!currentState)
break;
// Mark state as being explored
this.graph.incrementVisit(currentState.id);
// Discover available actions in this state
const actions = await this.agent.discoverActions(currentState);
if (actions.length === 0)
continue;
// Select action deterministically using seed + step count
const actionIndex = (this.seed + stepsExecuted) % actions.length;
const action = actions[actionIndex];
this.logger.log({
event: 'action_executed',
timestamp: Date.now(),
actionId: action.id,
type: action.type,
selector: action.selector,
value: action.value,
url: action.url,
});
this.events.onActionExecuted?.(this.sessionId, action.type, action.selector, Date.now());
// Execute action and capture observation
const observation = await this.agent.executeAction(action);
this.actionTrace.push(action);
// Record new state if discovered
if (!this.graph.hasState(observation.newStateId)) {
const newState = await this.agent.captureState();
this.graph.addState(newState);
depth += 1;
this.logger.log({
event: 'state_discovered',
timestamp: Date.now(),
stateId: newState.id,
url: newState.url,
title: newState.title,
});
this.events.onStateDiscovered?.(this.sessionId, newState.id, newState.url, newState.title);
// Run per-state hooks (visual regression, accessibility, performance)
for (const hook of this.stateHooks) {
const hookAnomalies = await hook(newState, this.agent, this.sessionId, [...this.actionTrace]).catch(() => []);
for (const anomaly of hookAnomalies) {
anomalies.push(anomaly);
this.logger.log({
event: 'anomaly_detected',
timestamp: Date.now(),
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
});
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`);
}
}
}
}
this.graph.recordTransition(currentState.id, action, observation.newStateId);
this.logger.log({
event: 'exploration_step',
timestamp: Date.now(),
stateId: currentState.id,
actionId: action.id,
});
// Detect anomalies
const detected = this.detector.detect(observation, [...this.actionTrace]);
for (const anomaly of detected) {
for (const collector of this.collectors) {
const evidence = await collector.collect(anomaly, this.agent);
Object.assign(anomaly.evidence, evidence);
}
anomalies.push(anomaly);
this.logger.log({
event: 'anomaly_detected',
timestamp: Date.now(),
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
});
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
const reportDir = `${this.outputDir}/${anomaly.id}`;
await exporter.export(anomaly, reportDir);
}
}
stepsExecuted += 1;
// Run fuzzing if enabled and plugin provided
if (this.fuzzingPlugin &&
this.explorationConfig.fuzzingEnabled !== false &&
currentState.domSnapshot) {
const fuzzActions = this.fuzzingPlugin.generateFuzzActions(currentState.domSnapshot, currentState);
for (const fuzzAction of fuzzActions) {
if (this.aborted || isTimedOut())
break;
const fuzzObs = await this.agent.executeAction(fuzzAction);
this.actionTrace.push(fuzzAction);
const fuzzAnomalies = this.detector.detect(fuzzObs, [...this.actionTrace]);
for (const anomaly of fuzzAnomalies) {
for (const collector of this.collectors) {
const evidence = await collector.collect(anomaly, this.agent);
Object.assign(anomaly.evidence, evidence);
}
anomalies.push(anomaly);
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`);
}
}
}
}
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.events.onSessionError?.(this.sessionId, msg);
await this.agent.close().catch(() => undefined);
throw err;
}
await this.agent.close();
const statesVisited = this.graph.getAllStates().filter((s) => s.visitCount > 0).length;
this.logger.log({
event: 'session_end',
timestamp: Date.now(),
statesVisited,
anomaliesFound: anomalies.length,
});
this.events.onSessionCompleted?.(this.sessionId, statesVisited, anomalies.length);
return { statesVisited, anomaliesFound: anomalies.length, anomalies };
}
}
exports.ExplorationEngine = ExplorationEngine;
+66
View File
@@ -0,0 +1,66 @@
"use strict";
/**
* Logger — writes structured JSON log events to a .jsonl file.
* One JSON object per line.
*/
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 });
exports.NullLogger = exports.FileLogger = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class FileLogger {
constructor(logDir, sessionId) {
const logPath = path.join(logDir, `session_${sessionId}.jsonl`);
fs.mkdirSync(logDir, { recursive: true });
this.stream = fs.createWriteStream(logPath, { flags: 'a' });
}
log(event) {
this.stream.write(JSON.stringify(event) + '\n');
}
close() {
this.stream.end();
}
}
exports.FileLogger = FileLogger;
/** No-op logger for testing */
class NullLogger {
constructor() {
this.events = [];
}
log(event) {
this.events.push(event);
}
}
exports.NullLogger = NullLogger;
+83
View File
@@ -0,0 +1,83 @@
"use strict";
/**
* StateGraph — manages known states and transitions between them.
* Uses BFS ordering by default for exploration scheduling.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.StateGraph = void 0;
class StateGraph {
constructor() {
this.states = new Map();
this.transitions = [];
/** Insertion order for BFS */
this.insertionOrder = [];
}
addState(state) {
if (!this.states.has(state.id)) {
this.states.set(state.id, state);
this.insertionOrder.push(state.id);
}
else {
// Update visit count on revisit
const existing = this.states.get(state.id);
this.states.set(state.id, { ...existing, visitCount: existing.visitCount + 1 });
}
}
hasState(stateId) {
return this.states.has(stateId);
}
getState(stateId) {
return this.states.get(stateId);
}
incrementVisit(stateId) {
const state = this.states.get(stateId);
if (state) {
this.states.set(stateId, { ...state, visitCount: state.visitCount + 1 });
}
}
recordTransition(fromId, action, toId) {
this.transitions.push({
fromId,
action,
toId,
timestamp: Date.now(),
});
}
/** Returns all states that have never been visited (visitCount === 0) */
getUnvisited() {
return this.insertionOrder
.map((id) => this.states.get(id))
.filter((s) => s.visitCount === 0);
}
/** BFS heuristic: returns the oldest unvisited state, or null if none */
getNextToExplore() {
const unvisited = this.getUnvisited();
return unvisited.length > 0 ? unvisited[0] : null;
}
getAllStates() {
return this.insertionOrder.map((id) => this.states.get(id));
}
getTransitions() {
return [...this.transitions];
}
toJSON() {
return {
stateCount: this.states.size,
transitionCount: this.transitions.length,
states: this.getAllStates().map((s) => ({
id: s.id,
url: s.url,
title: s.title,
visitCount: s.visitCount,
})),
transitions: this.transitions.map((t) => ({
fromId: t.fromId,
toId: t.toId,
actionId: t.action.id,
actionType: t.action.type,
timestamp: t.timestamp,
})),
};
}
}
exports.StateGraph = StateGraph;
+6
View File
@@ -0,0 +1,6 @@
"use strict";
/**
* ABE Core Interfaces
* Core data types only. Must NOT import from src/plugins/.
*/
Object.defineProperty(exports, "__esModule", { value: true });
+76
View File
@@ -0,0 +1,76 @@
"use strict";
/**
* AnomalyRepository — CRUD for anomalies table with filters.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AnomalyRepository = void 0;
function rowToAnomaly(row) {
return {
id: row.id,
sessionId: row.session_id,
type: row.type,
severity: row.severity,
description: row.description,
actionTrace: JSON.parse(row.action_trace_json),
evidence: {
...JSON.parse(row.evidence_json),
screenshotPath: row.screenshot_path ?? undefined,
domSnapshotPath: row.dom_snapshot_path ?? undefined,
},
observationId: '',
timestamp: row.detected_at,
};
}
class AnomalyRepository {
constructor(db) {
this.db = db;
}
create(anomaly, sessionId) {
this.db
.prepare(`INSERT INTO anomalies
(id, session_id, type, severity, description, action_trace_json, evidence_json, screenshot_path, dom_snapshot_path, detected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(anomaly.id, sessionId, anomaly.type, anomaly.severity, anomaly.description, JSON.stringify(anomaly.actionTrace), JSON.stringify({ httpLog: anomaly.evidence.httpLog, rawErrors: anomaly.evidence.rawErrors }), anomaly.evidence.screenshotPath ?? null, anomaly.evidence.domSnapshotPath ?? null, anomaly.timestamp);
}
findById(id) {
const row = this.db
.prepare('SELECT * FROM anomalies WHERE id = ?')
.get(id);
return row ? rowToAnomaly(row) : undefined;
}
findAll(filters) {
const conditions = [];
const values = [];
if (filters?.sessionId) {
conditions.push('session_id = ?');
values.push(filters.sessionId);
}
if (filters?.severity) {
conditions.push('severity = ?');
values.push(filters.severity);
}
if (filters?.type) {
conditions.push('type = ?');
values.push(filters.type);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const rows = this.db
.prepare(`SELECT * FROM anomalies ${where} ORDER BY detected_at DESC`)
.all(...values);
return rows.map(rowToAnomaly);
}
countBySeverity(severities) {
if (severities.length === 0)
return 0;
const placeholders = severities.map(() => '?').join(', ');
const result = this.db
.prepare(`SELECT COUNT(*) as cnt FROM anomalies WHERE severity IN (${placeholders})`)
.get(...severities);
return result.cnt;
}
count() {
const result = this.db.prepare('SELECT COUNT(*) as cnt FROM anomalies').get();
return result.cnt;
}
}
exports.AnomalyRepository = AnomalyRepository;
+82
View File
@@ -0,0 +1,82 @@
"use strict";
/**
* ScheduleRepository — CRUD for schedules table.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ScheduleRepository = void 0;
function rowToRecord(row) {
return {
id: row.id,
name: row.name,
url: row.url,
configJson: row.config_json,
cronExpression: row.cron_expression,
enabled: row.enabled === 1,
lastRunAt: row.last_run_at,
nextRunAt: row.next_run_at,
createdAt: row.created_at,
};
}
class ScheduleRepository {
constructor(db) {
this.db = db;
}
create(params) {
this.db
.prepare(`INSERT INTO schedules (id, name, url, config_json, cron_expression, enabled, next_run_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(params.id, params.name, params.url, params.configJson, params.cronExpression, params.enabled !== false ? 1 : 0, params.nextRunAt ?? null, Date.now());
}
findById(id) {
const row = this.db
.prepare('SELECT * FROM schedules WHERE id = ?')
.get(id);
return row ? rowToRecord(row) : undefined;
}
findAll(enabledOnly = false) {
const rows = enabledOnly
? this.db.prepare('SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC').all()
: this.db.prepare('SELECT * FROM schedules ORDER BY created_at DESC').all();
return rows.map(rowToRecord);
}
update(id, fields) {
const sets = [];
const values = [];
if (fields.name !== undefined) {
sets.push('name = ?');
values.push(fields.name);
}
if (fields.url !== undefined) {
sets.push('url = ?');
values.push(fields.url);
}
if (fields.configJson !== undefined) {
sets.push('config_json = ?');
values.push(fields.configJson);
}
if (fields.cronExpression !== undefined) {
sets.push('cron_expression = ?');
values.push(fields.cronExpression);
}
if (fields.enabled !== undefined) {
sets.push('enabled = ?');
values.push(fields.enabled ? 1 : 0);
}
if (fields.lastRunAt !== undefined) {
sets.push('last_run_at = ?');
values.push(fields.lastRunAt);
}
if (fields.nextRunAt !== undefined) {
sets.push('next_run_at = ?');
values.push(fields.nextRunAt);
}
if (sets.length === 0)
return;
values.push(id);
this.db.prepare(`UPDATE schedules SET ${sets.join(', ')} WHERE id = ?`).run(...values);
}
delete(id) {
this.db.prepare('DELETE FROM schedules WHERE id = ?').run(id);
}
}
exports.ScheduleRepository = ScheduleRepository;
+53
View File
@@ -0,0 +1,53 @@
"use strict";
/**
* SessionRepository — CRUD for sessions table.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SessionRepository = void 0;
class SessionRepository {
constructor(db) {
this.db = db;
}
create(params) {
this.db
.prepare(`INSERT INTO sessions (id, url, status, seed, max_states, started_at, config_json)
VALUES (?, ?, 'running', ?, ?, ?, ?)`)
.run(params.id, params.url, params.seed, params.maxStates, params.startedAt, params.configJson ?? '{}');
}
findById(id) {
return this.db
.prepare('SELECT * FROM sessions WHERE id = ?')
.get(id);
}
findAll() {
return this.db.prepare('SELECT * FROM sessions ORDER BY started_at DESC').all();
}
update(id, fields) {
const sets = [];
const values = [];
if (fields.status !== undefined) {
sets.push('status = ?');
values.push(fields.status);
}
if (fields.statesVisited !== undefined) {
sets.push('states_visited = ?');
values.push(fields.statesVisited);
}
if (fields.anomaliesFound !== undefined) {
sets.push('anomalies_found = ?');
values.push(fields.anomaliesFound);
}
if (fields.finishedAt !== undefined) {
sets.push('finished_at = ?');
values.push(fields.finishedAt);
}
if (sets.length === 0)
return;
values.push(id);
this.db.prepare(`UPDATE sessions SET ${sets.join(', ')} WHERE id = ?`).run(...values);
}
delete(id) {
this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
}
}
exports.SessionRepository = SessionRepository;
+77
View File
@@ -0,0 +1,77 @@
"use strict";
/**
* VisualBaselineRepository — CRUD for visual_baselines and visual_comparisons tables.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.VisualBaselineRepository = void 0;
class VisualBaselineRepository {
constructor(db) {
this.db = db;
}
// ─── Baselines ────────────────────────────────────────────────────────────
createBaseline(params) {
this.db.prepare(`
INSERT OR REPLACE INTO visual_baselines (id, state_id, url, screenshot_path, approved_at, approved_by, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(params.id, params.stateId, params.url, params.screenshotPath, Date.now(), params.approvedBy ?? 'user', params.width, params.height);
}
findBaselineByStateId(stateId) {
return this.db
.prepare('SELECT * FROM visual_baselines WHERE state_id = ? ORDER BY approved_at DESC LIMIT 1')
.get(stateId);
}
findBaselineById(id) {
return this.db
.prepare('SELECT * FROM visual_baselines WHERE id = ?')
.get(id);
}
// ─── Comparisons ──────────────────────────────────────────────────────────
createComparison(params) {
this.db.prepare(`
INSERT INTO visual_comparisons
(id, session_id, state_id, baseline_id, current_screenshot_path, diff_screenshot_path, diff_pixels, diff_percent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(params.id, params.sessionId, params.stateId, params.baselineId ?? null, params.currentScreenshotPath, params.diffScreenshotPath ?? null, params.diffPixels ?? null, params.diffPercent ?? null, params.status, Date.now());
}
findComparisonById(id) {
return this.db
.prepare('SELECT * FROM visual_comparisons WHERE id = ?')
.get(id);
}
findComparisons(filters) {
const conditions = [];
const values = [];
if (filters?.sessionId) {
conditions.push('session_id = ?');
values.push(filters.sessionId);
}
if (filters?.status) {
conditions.push('status = ?');
values.push(filters.status);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return this.db
.prepare(`SELECT * FROM visual_comparisons ${where} ORDER BY created_at DESC`)
.all(...values);
}
updateComparisonStatus(id, status) {
this.db.prepare('UPDATE visual_comparisons SET status = ? WHERE id = ?').run(status, id);
}
promoteToBaseline(comparisonId) {
const comparison = this.findComparisonById(comparisonId);
if (!comparison)
return null;
const baselineId = `baseline_${Date.now()}`;
this.createBaseline({
id: baselineId,
stateId: comparison.state_id,
url: comparison.session_id,
screenshotPath: comparison.current_screenshot_path,
width: 1280,
height: 720,
});
this.updateComparisonStatus(comparisonId, 'passed');
return baselineId;
}
}
exports.VisualBaselineRepository = VisualBaselineRepository;
+43
View File
@@ -0,0 +1,43 @@
"use strict";
/**
* ABE Database Connection
* Singleton SQLite connection using better-sqlite3.
* Runs migrations on first access.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getDb = getDb;
exports.setDb = setDb;
exports.closeDb = closeDb;
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const migrations_1 = require("./migrations");
let _db = null;
function getDb() {
if (_db)
return _db;
const dbPath = process.env['ABE_DB_PATH'] ?? path_1.default.join(process.cwd(), 'data', 'abe.db');
const dir = path_1.default.dirname(dbPath);
if (!fs_1.default.existsSync(dir)) {
fs_1.default.mkdirSync(dir, { recursive: true });
}
_db = new better_sqlite3_1.default(dbPath);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
(0, migrations_1.runMigrations)(_db);
return _db;
}
/** For testing — inject a custom (in-memory) database instance. */
function setDb(db) {
_db = db;
}
/** Close and reset. Used in tests. */
function closeDb() {
if (_db) {
_db.close();
_db = null;
}
}
+126
View File
@@ -0,0 +1,126 @@
"use strict";
/**
* ABE Database Migrations
* Creates all tables if they do not exist.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.runMigrations = runMigrations;
function runMigrations(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
seed INTEGER NOT NULL,
max_states INTEGER NOT NULL DEFAULT 50,
states_visited INTEGER NOT NULL DEFAULT 0,
anomalies_found INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL,
finished_at INTEGER,
config_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS states (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
url TEXT NOT NULL,
title TEXT NOT NULL,
dom_snapshot_path TEXT,
visit_count INTEGER NOT NULL DEFAULT 0,
discovered_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS actions (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
state_id TEXT NOT NULL REFERENCES states(id),
type TEXT NOT NULL,
selector TEXT,
value TEXT,
url TEXT,
seed INTEGER NOT NULL,
executed_at INTEGER NOT NULL,
sequence_order INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS anomalies (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
type TEXT NOT NULL,
severity TEXT NOT NULL,
description TEXT NOT NULL,
action_trace_json TEXT NOT NULL,
evidence_json TEXT NOT NULL,
screenshot_path TEXT,
dom_snapshot_path TEXT,
detected_at INTEGER NOT NULL,
ai_enrichment_json TEXT,
ai_enriched_at INTEGER,
browser TEXT,
browser_version TEXT
);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
anomaly_id TEXT NOT NULL REFERENCES anomalies(id),
channel TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
sent_at INTEGER,
error TEXT
);
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
config_json TEXT NOT NULL,
cron_expression TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at INTEGER,
next_run_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS visual_baselines (
id TEXT PRIMARY KEY,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
screenshot_path TEXT NOT NULL,
approved_at INTEGER NOT NULL,
approved_by TEXT DEFAULT 'user',
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS visual_comparisons (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
baseline_id TEXT,
current_screenshot_path TEXT NOT NULL,
diff_screenshot_path TEXT,
diff_pixels INTEGER,
diff_percent REAL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS performance_metrics (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
ttfb INTEGER,
dom_content_loaded INTEGER,
load_complete INTEGER,
lcp INTEGER,
cls REAL,
fid INTEGER,
inp INTEGER,
total_requests INTEGER,
failed_requests INTEGER,
total_transfer_size INTEGER,
captured_at INTEGER NOT NULL
);
`);
}
+135
View File
@@ -0,0 +1,135 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema.createTable('sessions')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('url', 'text', col => col.notNull())
.addColumn('status', 'text', col => col.notNull().defaultTo('running'))
.addColumn('seed', 'integer', col => col.notNull())
.addColumn('max_states', 'integer', col => col.notNull().defaultTo(50))
.addColumn('states_visited', 'integer', col => col.notNull().defaultTo(0))
.addColumn('anomalies_found', 'integer', col => col.notNull().defaultTo(0))
.addColumn('started_at', 'integer', col => col.notNull())
.addColumn('finished_at', 'integer')
.addColumn('config_json', 'text', col => col.notNull().defaultTo('{}'))
.execute();
await db.schema.createTable('states')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull().references('sessions.id'))
.addColumn('url', 'text', col => col.notNull())
.addColumn('title', 'text', col => col.notNull())
.addColumn('dom_snapshot_path', 'text')
.addColumn('visit_count', 'integer', col => col.notNull().defaultTo(0))
.addColumn('discovered_at', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('actions')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull().references('sessions.id'))
.addColumn('state_id', 'text', col => col.notNull().references('states.id'))
.addColumn('type', 'text', col => col.notNull())
.addColumn('selector', 'text')
.addColumn('value', 'text')
.addColumn('url', 'text')
.addColumn('seed', 'integer', col => col.notNull())
.addColumn('executed_at', 'integer', col => col.notNull())
.addColumn('sequence_order', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('anomalies')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull().references('sessions.id'))
.addColumn('type', 'text', col => col.notNull())
.addColumn('severity', 'text', col => col.notNull())
.addColumn('description', 'text', col => col.notNull())
.addColumn('action_trace_json', 'text', col => col.notNull())
.addColumn('evidence_json', 'text', col => col.notNull())
.addColumn('screenshot_path', 'text')
.addColumn('dom_snapshot_path', 'text')
.addColumn('detected_at', 'integer', col => col.notNull())
.addColumn('ai_enrichment_json', 'text')
.addColumn('ai_enriched_at', 'integer')
.addColumn('browser', 'text')
.addColumn('browser_version', 'text')
.execute();
await db.schema.createTable('notifications')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('anomaly_id', 'text', col => col.notNull().references('anomalies.id'))
.addColumn('channel', 'text', col => col.notNull())
.addColumn('status', 'text', col => col.notNull().defaultTo('pending'))
.addColumn('sent_at', 'integer')
.addColumn('error', 'text')
.execute();
await db.schema.createTable('schedules')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('name', 'text', col => col.notNull())
.addColumn('url', 'text', col => col.notNull())
.addColumn('config_json', 'text', col => col.notNull())
.addColumn('cron_expression', 'text', col => col.notNull())
.addColumn('enabled', 'integer', col => col.notNull().defaultTo(1))
.addColumn('last_run_at', 'integer')
.addColumn('next_run_at', 'integer')
.addColumn('created_at', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('visual_baselines')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('state_id', 'text', col => col.notNull())
.addColumn('url', 'text', col => col.notNull())
.addColumn('screenshot_path', 'text', col => col.notNull())
.addColumn('approved_at', 'integer', col => col.notNull())
.addColumn('approved_by', 'text')
.addColumn('width', 'integer', col => col.notNull())
.addColumn('height', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('visual_comparisons')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull())
.addColumn('state_id', 'text', col => col.notNull())
.addColumn('baseline_id', 'text')
.addColumn('current_screenshot_path', 'text', col => col.notNull())
.addColumn('diff_screenshot_path', 'text')
.addColumn('diff_pixels', 'integer')
.addColumn('diff_percent', 'real')
.addColumn('status', 'text', col => col.notNull())
.addColumn('created_at', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('performance_metrics')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull())
.addColumn('state_id', 'text', col => col.notNull())
.addColumn('url', 'text', col => col.notNull())
.addColumn('ttfb', 'integer')
.addColumn('dom_content_loaded', 'integer')
.addColumn('load_complete', 'integer')
.addColumn('lcp', 'integer')
.addColumn('cls', 'real')
.addColumn('fid', 'integer')
.addColumn('inp', 'integer')
.addColumn('total_requests', 'integer')
.addColumn('failed_requests', 'integer')
.addColumn('total_transfer_size', 'integer')
.addColumn('captured_at', 'integer', col => col.notNull())
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('performance_metrics').ifExists().execute();
await db.schema.dropTable('visual_comparisons').ifExists().execute();
await db.schema.dropTable('visual_baselines').ifExists().execute();
await db.schema.dropTable('schedules').ifExists().execute();
await db.schema.dropTable('notifications').ifExists().execute();
await db.schema.dropTable('anomalies').ifExists().execute();
await db.schema.dropTable('actions').ifExists().execute();
await db.schema.dropTable('states').ifExists().execute();
await db.schema.dropTable('sessions').ifExists().execute();
}
+29
View File
@@ -0,0 +1,29 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema.createTable('findings')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull().references('sessions.id'))
.addColumn('type', 'text', col => col.notNull())
.addColumn('severity', 'text', col => col.notNull())
.addColumn('description', 'text', col => col.notNull())
.addColumn('status', 'text', col => col.notNull().defaultTo('open'))
.addColumn('action_trace_json', 'text', col => col.notNull())
.addColumn('evidence_json', 'text', col => col.notNull())
.addColumn('screenshot_path', 'text')
.addColumn('dom_snapshot_path', 'text')
.addColumn('browser', 'text')
.addColumn('browser_version', 'text')
.addColumn('ai_enrichment_json', 'text')
.addColumn('created_at', 'integer', col => col.notNull())
.addColumn('resolved_at', 'integer')
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('findings').ifExists().execute();
}
+36
View File
@@ -0,0 +1,36 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema.createTable('jobs')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('type', 'text', col => col.notNull())
.addColumn('status', 'text', col => col.notNull().defaultTo('pending'))
.addColumn('payload', 'text', col => col.notNull())
.addColumn('result', 'text')
.addColumn('error', 'text')
.addColumn('attempts', 'integer', col => col.notNull().defaultTo(0))
.addColumn('max_attempts', 'integer', col => col.notNull().defaultTo(3))
.addColumn('priority', 'integer', col => col.notNull().defaultTo(0))
.addColumn('run_at', 'text', col => col.notNull())
.addColumn('started_at', 'text')
.addColumn('completed_at', 'text')
.addColumn('created_at', 'text', col => col.notNull())
.addColumn('updated_at', 'text', col => col.notNull())
.execute();
// Index for efficient polling
await db.schema
.createIndex('idx_jobs_poll')
.ifNotExists()
.on('jobs')
.columns(['status', 'run_at', 'priority'])
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropIndex('idx_jobs_poll').ifExists().execute();
await db.schema.dropTable('jobs').ifExists().execute();
}
+81
View File
@@ -0,0 +1,81 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema
.createTable('users')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('email', 'text', (col) => col.notNull().unique())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('password_hash', 'text', (col) => col.notNull())
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
.addColumn('org_id', 'text')
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('updated_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('organizations')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('slug', 'text', (col) => col.notNull().unique())
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('org_members')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('org_id', 'text', (col) => col.notNull().references('organizations.id'))
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
.addColumn('joined_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('api_keys')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
.addColumn('org_id', 'text', (col) => col.notNull())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('key_hash', 'text', (col) => col.notNull().unique())
.addColumn('key_prefix', 'text', (col) => col.notNull())
.addColumn('permissions', 'text', (col) => col.notNull().defaultTo('["member"]'))
.addColumn('expires_at', 'integer')
.addColumn('last_used_at', 'integer')
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('auth_sessions')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
.addColumn('token', 'text', (col) => col.notNull().unique())
.addColumn('expires_at', 'integer', (col) => col.notNull())
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createIndex('idx_auth_sessions_token')
.ifNotExists()
.on('auth_sessions')
.columns(['token'])
.execute();
await db.schema
.createIndex('idx_users_email')
.ifNotExists()
.on('users')
.columns(['email'])
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropIndex('idx_users_email').ifExists().execute();
await db.schema.dropIndex('idx_auth_sessions_token').ifExists().execute();
await db.schema.dropTable('auth_sessions').ifExists().execute();
await db.schema.dropTable('api_keys').ifExists().execute();
await db.schema.dropTable('org_members').ifExists().execute();
await db.schema.dropTable('organizations').ifExists().execute();
await db.schema.dropTable('users').ifExists().execute();
}
+25
View File
@@ -0,0 +1,25 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema
.createTable('reports')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('title', 'text', (col) => col.notNull())
.addColumn('format', 'text', (col) => col.notNull())
.addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
.addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}'))
.addColumn('file_path', 'text')
.addColumn('error_message', 'text')
.addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0))
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('completed_at', 'integer')
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('reports').ifExists().execute();
}
+44
View File
@@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema
.createTable('integrations')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('enabled', 'integer', (col) => col.notNull().defaultTo(1))
.addColumn('config_json', 'text', (col) => col.notNull().defaultTo('{}'))
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('webhook_endpoints')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('url', 'text', (col) => col.notNull())
.addColumn('secret', 'text', (col) => col.notNull())
.addColumn('enabled', 'integer', (col) => col.notNull().defaultTo(1))
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('last_delivered_at', 'integer')
.addColumn('last_status', 'integer')
.execute();
await db.schema
.createTable('webhook_deliveries')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('endpoint_id', 'text', (col) => col.notNull())
.addColumn('event', 'text', (col) => col.notNull())
.addColumn('payload_json', 'text', (col) => col.notNull())
.addColumn('status', 'integer', (col) => col.notNull())
.addColumn('attempted_at', 'integer', (col) => col.notNull())
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('webhook_deliveries').ifExists().execute();
await db.schema.dropTable('webhook_endpoints').ifExists().execute();
await db.schema.dropTable('integrations').ifExists().execute();
}
+50
View File
@@ -0,0 +1,50 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
const kysely_1 = require("kysely");
async function up(db) {
// SSO configurations per organization
await db.schema
.createTable('sso_configs')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('organization_id', 'text', (c) => c.notNull())
.addColumn('provider', 'text', (c) => c.notNull())
.addColumn('enabled', 'integer', (c) => c.notNull().defaultTo(1))
.addColumn('config_json', 'text', (c) => c.notNull().defaultTo('{}'))
.addColumn('created_at', 'integer', (c) => c.notNull())
.execute();
// TOTP secrets for MFA
await db.schema
.createTable('totp_secrets')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.notNull().unique())
.addColumn('secret', 'text', (c) => c.notNull())
.addColumn('verified', 'integer', (c) => c.notNull().defaultTo(0))
.addColumn('created_at', 'integer', (c) => c.notNull())
.execute();
// Audit logs
await db.schema
.createTable('audit_logs')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text')
.addColumn('organization_id', 'text')
.addColumn('action', 'text', (c) => c.notNull())
.addColumn('resource', 'text', (c) => c.notNull())
.addColumn('resource_id', 'text')
.addColumn('ip_address', 'text')
.addColumn('user_agent', 'text')
.addColumn('details_json', 'text', (c) => c.notNull().defaultTo('{}'))
.addColumn('occurred_at', 'integer', (c) => c.notNull())
.execute();
await (0, kysely_1.sql) `CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs (user_id)`.execute(db);
await (0, kysely_1.sql) `CREATE INDEX IF NOT EXISTS idx_audit_logs_occurred ON audit_logs (occurred_at)`.execute(db);
}
async function down(db) {
await db.schema.dropTable('audit_logs').ifExists().execute();
await db.schema.dropTable('totp_secrets').ifExists().execute();
await db.schema.dropTable('sso_configs').ifExists().execute();
}
+21
View File
@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
async function up(db) {
await db.schema
.createTable('branding_config')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('organization_id', 'text', (c) => c.notNull().unique())
.addColumn('app_name', 'text')
.addColumn('primary_color', 'text')
.addColumn('logo_url', 'text')
.addColumn('favicon_url', 'text')
.addColumn('custom_css', 'text')
.addColumn('updated_at', 'integer', (c) => c.notNull())
.execute();
}
async function down(db) {
await db.schema.dropTable('branding_config').ifExists().execute();
}
+31
View File
@@ -0,0 +1,31 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.runMigrations = runMigrations;
const kysely_1 = require("kysely");
const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises"));
async function runMigrations(db) {
const migrator = new kysely_1.Migrator({
db,
provider: new kysely_1.FileMigrationProvider({
fs: promises_1.default,
path: path_1.default,
migrationFolder: path_1.default.join(__dirname, 'migrations'),
}),
});
const { error, results } = await migrator.migrateToLatest();
results?.forEach(result => {
if (result.status === 'Success') {
console.log(`Migration "${result.migrationName}" executed successfully`);
}
else if (result.status === 'Error') {
console.error(`Migration "${result.migrationName}" failed`);
}
});
if (error) {
throw error;
}
}
+86
View File
@@ -0,0 +1,86 @@
"use strict";
/**
* ABE — Autonomous Bug Explorer
* Entry point: wires all components together and starts exploration.
*
* Usage:
* npm run explore -- --url http://localhost:3000 --output ./reports
* ts-node src/index.ts http://localhost:3000
*/
Object.defineProperty(exports, "__esModule", { value: true });
const ExplorationEngine_1 = require("./core/ExplorationEngine");
const StateGraph_1 = require("./core/StateGraph");
const Logger_1 = require("./core/Logger");
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 JSONExporter_1 = require("./plugins/exporters/JSONExporter");
const MarkdownExporter_1 = require("./plugins/exporters/MarkdownExporter");
const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer");
// ─── Parse CLI arguments ─────────────────────────────────────────────────────
function parseArgs() {
const args = process.argv.slice(2);
let url = 'http://localhost:3000';
let outputDir = './reports';
let seed = 42;
let maxSteps = 100;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1])
url = args[++i];
else if (args[i] === '--output' && args[i + 1])
outputDir = args[++i];
else if (args[i] === '--seed' && args[i + 1])
seed = parseInt(args[++i], 10);
else if (args[i] === '--max-steps' && args[i + 1])
maxSteps = parseInt(args[++i], 10);
else if (!args[i].startsWith('--'))
url = args[i];
}
return { url, outputDir, seed, maxSteps };
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const { url, outputDir, seed, maxSteps } = parseArgs();
const sessionId = `${new Date().toISOString().replace(/[:.]/g, '-')}_seed${seed}`;
const logger = new Logger_1.FileLogger('./logs', sessionId);
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed, headless: true, logger });
const collectors = [
new ScreenshotCollector_1.ScreenshotCollector(outputDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(outputDir),
];
const exporters = [
new JSONExporter_1.JSONExporter(url),
new MarkdownExporter_1.MarkdownExporter(),
];
const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer();
const engine = new ExplorationEngine_1.ExplorationEngine({
graph,
agent,
collectors,
exporters,
reproducer,
logger,
seed,
url,
maxSteps,
outputDir,
});
console.log(`[ABE] Starting exploration of ${url} (seed=${seed}, maxSteps=${maxSteps})`);
try {
const result = await engine.run();
console.log(`[ABE] Exploration complete.`);
console.log(` States visited : ${result.statesVisited}`);
console.log(` Anomalies found: ${result.anomaliesFound}`);
if (result.anomaliesFound > 0) {
console.log(` Reports saved to: ${outputDir}/`);
}
}
catch (err) {
console.error('[ABE] Fatal error:', err);
process.exit(1);
}
}
main();
+2
View File
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+172
View File
@@ -0,0 +1,172 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SQLiteJobQueue = void 0;
/**
* SQLiteJobQueue — SQLite-backed job queue with exponential backoff retry.
* Zero external dependencies: uses Kysely + better-sqlite3.
*/
const kysely_1 = require("kysely");
const crypto_1 = require("crypto");
class SQLiteJobQueue {
constructor(db, logger, pollIntervalMs = 1000) {
this.db = db;
this.logger = logger;
this.pollIntervalMs = pollIntervalMs;
this.running = false;
this.activeJobs = 0;
this.pollTimer = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.handlers = new Map();
}
registerHandler(type, handler) {
this.handlers.set(type, handler);
}
async enqueue(type, payload, opts) {
const id = (0, crypto_1.randomUUID)();
const now = new Date().toISOString();
const runAt = (opts?.runAt ?? new Date()).toISOString();
await this.db
.insertInto('jobs')
.values({
id,
type,
status: 'pending',
payload: JSON.stringify(payload),
result: null,
error: null,
attempts: 0,
max_attempts: opts?.maxAttempts ?? 3,
priority: opts?.priority ?? 0,
run_at: runAt,
started_at: null,
completed_at: null,
created_at: now,
updated_at: now,
})
.execute();
this.logger.debug({ jobId: id, type, runAt }, 'Job enqueued');
return id;
}
start() {
if (this.running)
return;
this.running = true;
this.logger.info('Job queue started');
this.scheduleNextPoll();
}
pause() {
this.running = false;
if (this.pollTimer !== null) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
this.logger.info('Job queue paused');
}
async waitForActive(timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (this.activeJobs > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
scheduleNextPoll() {
if (!this.running)
return;
this.pollTimer = setTimeout(() => {
this.pollOnce()
.catch((err) => {
this.logger.error({ err }, 'Job queue poll error');
})
.finally(() => {
this.scheduleNextPoll();
});
}, this.pollIntervalMs);
}
async pollOnce() {
const now = new Date().toISOString();
// Find one pending job that is due
const row = await this.db
.selectFrom('jobs')
.selectAll()
.where('status', '=', 'pending')
.where('run_at', '<=', now)
.orderBy('priority', 'desc')
.orderBy('created_at', 'asc')
.limit(1)
.executeTakeFirst();
if (!row)
return;
// Optimistic lock: claim the job atomically
const claimTime = new Date().toISOString();
const updateResult = await this.db
.updateTable('jobs')
.set({
status: 'running',
started_at: claimTime,
attempts: (0, kysely_1.sql) `attempts + 1`,
updated_at: claimTime,
})
.where('id', '=', row.id)
.where('status', '=', 'pending')
.executeTakeFirst();
if (!updateResult || Number(updateResult.numUpdatedRows) === 0) {
return; // Another worker claimed this job
}
this.activeJobs++;
this.logger.info({ jobId: row.id, type: row.type, attempt: row.attempts + 1 }, 'Job started');
try {
const handler = this.handlers.get(row.type);
if (!handler) {
throw new Error(`No handler registered for job type: ${row.type}`);
}
const payload = JSON.parse(row.payload);
const result = await handler(payload);
const completedAt = new Date().toISOString();
await this.db
.updateTable('jobs')
.set({
status: 'completed',
result: JSON.stringify(result),
completed_at: completedAt,
updated_at: completedAt,
error: null,
})
.where('id', '=', row.id)
.execute();
this.logger.info({ jobId: row.id, type: row.type }, 'Job completed');
}
catch (err) {
const failedAt = new Date().toISOString();
const errorMsg = err instanceof Error ? err.message : String(err);
// Fetch current attempts count (was incremented above)
const current = await this.db
.selectFrom('jobs')
.select(['attempts', 'max_attempts'])
.where('id', '=', row.id)
.executeTakeFirst();
const attempts = current?.attempts ?? row.attempts + 1;
const maxAttempts = current?.max_attempts ?? row.max_attempts;
if (attempts >= maxAttempts) {
await this.db
.updateTable('jobs')
.set({ status: 'failed', error: errorMsg, updated_at: failedAt })
.where('id', '=', row.id)
.execute();
this.logger.error({ jobId: row.id, type: row.type, attempts, err }, 'Job failed permanently');
}
else {
const backoffMs = Math.min(1000 * Math.pow(2, attempts), 60000);
const retryAt = new Date(Date.now() + backoffMs).toISOString();
await this.db
.updateTable('jobs')
.set({ status: 'pending', run_at: retryAt, error: errorMsg, updated_at: failedAt })
.where('id', '=', row.id)
.execute();
this.logger.warn({ jobId: row.id, type: row.type, attempts, backoffMs }, 'Job failed, will retry');
}
}
finally {
this.activeJobs--;
}
}
}
exports.SQLiteJobQueue = SQLiteJobQueue;
+27
View File
@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EXPLORATION_JOB_TYPE = void 0;
exports.createExplorationJobHandler = createExplorationJobHandler;
const UniqueId_1 = require("../../shared/domain/UniqueId");
exports.EXPLORATION_JOB_TYPE = 'exploration:run';
function createExplorationJobHandler(deps) {
return async (payload) => {
const { sessionId, url, seed, maxStates } = payload;
const log = deps.logger.child({ jobType: exports.EXPLORATION_JOB_TYPE, sessionId });
log.info({ url, seed, maxStates }, 'Exploration job executing');
const id = UniqueId_1.UniqueId.from(sessionId);
const session = await deps.sessionRepo.findById(id);
if (!session) {
throw new Error(`Session not found: ${sessionId}`);
}
// In this phase the actual Playwright crawl is handled by the ExplorationOrchestrator
// which is wired separately. Here we mark the session as running and publish an event.
// Full end-to-end crawling is integrated in Phase 4's infrastructure layer.
log.info({ statesVisited: session.statesVisited }, 'Exploration job complete (orchestration delegated)');
return {
sessionId,
statesVisited: session.statesVisited,
anomaliesFound: 0,
};
};
}
+50
View File
@@ -0,0 +1,50 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.REPORT_JOB_TYPE = void 0;
exports.createReportJobHandler = createReportJobHandler;
const HTMLReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/HTMLReportGenerator");
const JSONReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/JSONReportGenerator");
const PDFReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/PDFReportGenerator");
exports.REPORT_JOB_TYPE = 'report:generate';
function createReportJobHandler(deps) {
const htmlGen = new HTMLReportGenerator_1.HTMLReportGenerator();
const jsonGen = new JSONReportGenerator_1.JSONReportGenerator();
const pdfGen = new PDFReportGenerator_1.PDFReportGenerator();
return async (payload) => {
const log = deps.logger.child({ jobType: exports.REPORT_JOB_TYPE, reportId: payload.reportId });
log.info({ format: payload.format }, 'Report generation job executing');
const report = await deps.reportRepository.findById(payload.reportId);
if (!report) {
throw new Error(`Report not found: ${payload.reportId}`);
}
report.markGenerating();
await deps.reportRepository.update(report);
// Load findings with filters from report
const findings = await deps.findingRepository.findAll({
sessionId: report.filters.sessionId,
severity: report.filters.severity,
});
let filePath;
try {
if (payload.format === 'pdf') {
filePath = await pdfGen.generate(report, findings);
}
else if (payload.format === 'json') {
filePath = await jsonGen.generate(report, findings);
}
else {
filePath = await htmlGen.generate(report, findings);
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
report.markFailed(msg);
await deps.reportRepository.update(report);
throw err;
}
report.markReady(filePath, findings.length);
await deps.reportRepository.update(report);
log.info({ filePath, totalFindings: findings.length }, 'Report job complete');
return { reportId: payload.reportId, filePath };
};
}
+278
View File
@@ -0,0 +1,278 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/**
* ABE — composition root.
* Wires all modules together and starts the HTTP + WebSocket server.
*/
const http_1 = __importDefault(require("http"));
const socket_io_1 = require("socket.io");
const Config_1 = require("./shared/infrastructure/Config");
const Logger_1 = require("./shared/infrastructure/Logger");
const DatabaseConnection_1 = require("./shared/infrastructure/DatabaseConnection");
const InProcessEventBus_1 = require("./shared/infrastructure/InProcessEventBus");
const migrator_1 = require("./db/migrator");
// Crawling module
const KyselyCrawlSessionRepository_1 = require("./modules/crawling/infrastructure/repositories/KyselyCrawlSessionRepository");
const KyselyStateRepository_1 = require("./modules/crawling/infrastructure/repositories/KyselyStateRepository");
const StartCrawlCommand_1 = require("./modules/crawling/application/commands/StartCrawlCommand");
const StopCrawlCommand_1 = require("./modules/crawling/application/commands/StopCrawlCommand");
const GetSessionQuery_1 = require("./modules/crawling/application/queries/GetSessionQuery");
const ListSessionsQuery_1 = require("./modules/crawling/application/queries/ListSessionsQuery");
// Findings module
const KyselyFindingRepository_1 = require("./modules/findings/infrastructure/repositories/KyselyFindingRepository");
const CreateFindingCommand_1 = require("./modules/findings/application/commands/CreateFindingCommand");
const EnrichFindingCommand_1 = require("./modules/findings/application/commands/EnrichFindingCommand");
const ResolveFindingCommand_1 = require("./modules/findings/application/commands/ResolveFindingCommand");
const GetFindingQuery_1 = require("./modules/findings/application/queries/GetFindingQuery");
const ListFindingsQuery_1 = require("./modules/findings/application/queries/ListFindingsQuery");
const FindingStatsQuery_1 = require("./modules/findings/application/queries/FindingStatsQuery");
const OnAnomalyDetected_1 = require("./modules/findings/application/event-handlers/OnAnomalyDetected");
const NullAIEnricher_1 = require("./modules/findings/infrastructure/NullAIEnricher");
// Fuzzing module
const FuzzingEngineAdapter_1 = require("./modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter");
const RunFuzzCommand_1 = require("./modules/fuzzing/application/commands/RunFuzzCommand");
const OnActionExecuted_1 = require("./modules/fuzzing/application/event-handlers/OnActionExecuted");
const InMemoryFuzzSessionRepository_1 = require("./modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository");
// Auth module
const KyselyUserRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyUserRepository");
const KyselyOrganizationRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyOrganizationRepository");
const KyselyApiKeyRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyApiKeyRepository");
const KyselySessionRepository_1 = require("./modules/auth/infrastructure/repositories/KyselySessionRepository");
const RegisterCommand_1 = require("./modules/auth/application/commands/RegisterCommand");
const LoginCommand_1 = require("./modules/auth/application/commands/LoginCommand");
const CreateOrganizationCommand_1 = require("./modules/auth/application/commands/CreateOrganizationCommand");
const InviteMemberCommand_1 = require("./modules/auth/application/commands/InviteMemberCommand");
const CreateApiKeyCommand_1 = require("./modules/auth/application/commands/CreateApiKeyCommand");
const GetUserQuery_1 = require("./modules/auth/application/queries/GetUserQuery");
const ListOrgMembersQuery_1 = require("./modules/auth/application/queries/ListOrgMembersQuery");
const PasswordService_1 = require("./modules/auth/infrastructure/auth/PasswordService");
// Reporting module
const KyselyReportRepository_1 = require("./modules/reporting/infrastructure/repositories/KyselyReportRepository");
const GenerateReportCommand_1 = require("./modules/reporting/application/commands/GenerateReportCommand");
// Integrations module
const KyselyIntegrationRepository_1 = require("./modules/integrations/infrastructure/repositories/KyselyIntegrationRepository");
const KyselyWebhookEndpointRepository_1 = require("./modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository");
const WebhookDispatcher_1 = require("./modules/integrations/infrastructure/webhooks/WebhookDispatcher");
const OnFindingCreated_1 = require("./modules/integrations/application/event-handlers/OnFindingCreated");
// Licensing module
const RSALicenseValidator_1 = require("./modules/licensing/infrastructure/validators/RSALicenseValidator");
const LicenseService_1 = require("./modules/licensing/application/LicenseService");
// Visual regression module
const KyselyVisualRepository_1 = require("./modules/visual-regression/infrastructure/repositories/KyselyVisualRepository");
const VisualRegressionAdapter_1 = require("./modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter");
const ApproveBaselineCommand_1 = require("./modules/visual-regression/application/commands/ApproveBaselineCommand");
const RejectComparisonCommand_1 = require("./modules/visual-regression/application/commands/RejectComparisonCommand");
const ApproveAllNewStatesCommand_1 = require("./modules/visual-regression/application/commands/ApproveAllNewStatesCommand");
const ListComparisonsQuery_1 = require("./modules/visual-regression/application/queries/ListComparisonsQuery");
const StorageProvider_1 = require("./shared/infrastructure/StorageProvider");
const path_1 = __importDefault(require("path"));
// SSO + Audit modules (enterprise)
const KyselySSOConfigRepository_1 = require("./modules/sso/infrastructure/repositories/KyselySSOConfigRepository");
const KyselyTOTPRepository_1 = require("./modules/sso/infrastructure/repositories/KyselyTOTPRepository");
const TOTPService_1 = require("./modules/sso/infrastructure/providers/TOTPService");
const KyselyAuditRepository_1 = require("./modules/audit/infrastructure/repositories/KyselyAuditRepository");
// Scheduling module
const KyselyScheduleRepository_1 = require("./modules/scheduling/infrastructure/repositories/KyselyScheduleRepository");
const CreateScheduleCommand_1 = require("./modules/scheduling/application/commands/CreateScheduleCommand");
const ToggleScheduleCommand_1 = require("./modules/scheduling/application/commands/ToggleScheduleCommand");
const DeleteScheduleCommand_1 = require("./modules/scheduling/application/commands/DeleteScheduleCommand");
const ListSchedulesQuery_1 = require("./modules/scheduling/application/queries/ListSchedulesQuery");
const SchedulingService_1 = require("./modules/scheduling/application/SchedulingService");
const DataRetentionService_1 = require("./modules/scheduling/infrastructure/DataRetentionService");
// Job queue
const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue");
const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker");
const ReportWorker_1 = require("./jobs/workers/ReportWorker");
// API + Realtime
const server_1 = require("./api/server");
const SocketGateway_1 = require("./realtime/SocketGateway");
async function bootstrap() {
// Startup probe — measure total boot time
const startupAt = Date.now();
// 1. Config
const config = (0, Config_1.loadConfig)();
// 2. Logger
const logger = (0, Logger_1.createLogger)({ level: config.log.level, nodeEnv: config.nodeEnv });
logger.info({ port: config.port, env: config.nodeEnv }, 'Starting ABE...');
// 3. Database + migrations
const db = (0, DatabaseConnection_1.createDatabase)(config.db);
await (0, migrator_1.runMigrations)(db);
logger.info('Database migrations applied');
// 4. Event bus
const eventBus = new InProcessEventBus_1.InProcessEventBus(logger);
// 5. Repositories
const sessionRepo = new KyselyCrawlSessionRepository_1.KyselyCrawlSessionRepository(db);
const stateRepo = new KyselyStateRepository_1.KyselyStateRepository(db);
const findingRepo = new KyselyFindingRepository_1.KyselyFindingRepository(db);
const reportRepo = new KyselyReportRepository_1.KyselyReportRepository(db);
const fuzzRepo = new InMemoryFuzzSessionRepository_1.InMemoryFuzzSessionRepository();
// Suppress unused warning for stateRepo — used by crawling infrastructure
void stateRepo;
// 6. Crawling use cases
const startCrawl = new StartCrawlCommand_1.StartCrawlCommand(sessionRepo, eventBus);
const stopCrawl = new StopCrawlCommand_1.StopCrawlCommand(sessionRepo, eventBus);
const getSession = new GetSessionQuery_1.GetSessionQuery(sessionRepo);
const listSessions = new ListSessionsQuery_1.ListSessionsQuery(sessionRepo);
// 7. Findings use cases
const createFinding = new CreateFindingCommand_1.CreateFindingCommand(findingRepo, eventBus);
const enricher = new NullAIEnricher_1.NullAIEnricher();
const enrichFinding = new EnrichFindingCommand_1.EnrichFindingCommand(findingRepo, enricher, eventBus);
const resolveFinding = new ResolveFindingCommand_1.ResolveFindingCommand(findingRepo, eventBus);
const getFinding = new GetFindingQuery_1.GetFindingQuery(findingRepo);
const listFindings = new ListFindingsQuery_1.ListFindingsQuery(findingRepo);
const findingStats = new FindingStatsQuery_1.FindingStatsQuery(findingRepo);
// 8. Fuzzing use cases
const fuzzerEngine = new FuzzingEngineAdapter_1.FuzzingEngineAdapter({ intensity: 'low', seed: 42 });
const runFuzz = new RunFuzzCommand_1.RunFuzzCommand(fuzzerEngine, fuzzRepo, eventBus);
// 9. Event handlers — subscribe to EventBus
const onAnomalyDetected = new OnAnomalyDetected_1.OnAnomalyDetected(createFinding);
eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected);
const onActionExecuted = new OnActionExecuted_1.OnActionExecuted(runFuzz);
eventBus.subscribe('crawling.action_executed', onActionExecuted);
// 10. Auth module
const userRepo = new KyselyUserRepository_1.KyselyUserRepository(db);
const orgRepo = new KyselyOrganizationRepository_1.KyselyOrganizationRepository(db);
const apiKeyRepo = new KyselyApiKeyRepository_1.KyselyApiKeyRepository(db);
const authSessionRepo = new KyselySessionRepository_1.KyselySessionRepository(db);
const registerCommand = new RegisterCommand_1.RegisterCommand(userRepo, eventBus, PasswordService_1.hashPassword);
const loginCommand = new LoginCommand_1.LoginCommand(userRepo, authSessionRepo, eventBus, PasswordService_1.verifyPassword);
const createOrgCommand = new CreateOrganizationCommand_1.CreateOrganizationCommand(orgRepo, userRepo, eventBus);
const inviteMemberCommand = new InviteMemberCommand_1.InviteMemberCommand(orgRepo, userRepo, eventBus);
const createApiKeyCommand = new CreateApiKeyCommand_1.CreateApiKeyCommand(apiKeyRepo, userRepo);
const getUserQuery = new GetUserQuery_1.GetUserQuery(userRepo);
const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, userRepo);
// 11. Reporting use cases
const generateReport = new GenerateReportCommand_1.GenerateReportCommand(reportRepo, eventBus);
// 11b. Licensing
const licenseValidator = new RSALicenseValidator_1.RSALicenseValidator();
const licenseService = new LicenseService_1.LicenseService(licenseValidator);
// 11c. Integrations (moved from 11d)
const integrationRepo = new KyselyIntegrationRepository_1.KyselyIntegrationRepository(db);
const webhookRepo = new KyselyWebhookEndpointRepository_1.KyselyWebhookEndpointRepository(db);
const webhookDispatcher = new WebhookDispatcher_1.WebhookDispatcher(webhookRepo, logger);
const onFindingCreated = new OnFindingCreated_1.OnFindingCreated(integrationRepo, webhookRepo, webhookDispatcher, logger);
eventBus.subscribe('findings.finding_created', onFindingCreated);
// 12. Job queue (created before HTTP server so it can be injected)
const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger }));
jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
jobQueue.start();
// 11d. Visual regression module
const storageBasePath = path_1.default.join(process.cwd(), 'data');
const storageProvider = new StorageProvider_1.LocalStorageProvider(storageBasePath);
const visualBaselineRepo = new KyselyVisualRepository_1.KyselyVisualBaselineRepository(db);
const visualComparisonRepo = new KyselyVisualRepository_1.KyselyVisualComparisonRepository(db);
const visualRegressionAdapter = new VisualRegressionAdapter_1.VisualRegressionAdapter(storageProvider, visualBaselineRepo, visualComparisonRepo, eventBus);
void visualRegressionAdapter; // used by ExplorationOrchestrator in crawling infra
const listComparisons = new ListComparisonsQuery_1.ListComparisonsQuery(visualComparisonRepo);
const approveBaseline = new ApproveBaselineCommand_1.ApproveBaselineCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
const rejectComparison = new RejectComparisonCommand_1.RejectComparisonCommand(visualComparisonRepo);
const approveAllNewStates = new ApproveAllNewStatesCommand_1.ApproveAllNewStatesCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
// 12b. Scheduling module (after job queue, since it enqueues jobs)
const scheduleRepo = new KyselyScheduleRepository_1.KyselyScheduleRepository(db);
const createSchedule = new CreateScheduleCommand_1.CreateScheduleCommand(scheduleRepo, eventBus);
const toggleSchedule = new ToggleScheduleCommand_1.ToggleScheduleCommand(scheduleRepo, eventBus);
const deleteSchedule = new DeleteScheduleCommand_1.DeleteScheduleCommand(scheduleRepo, eventBus);
const listSchedules = new ListSchedulesQuery_1.ListSchedulesQuery(scheduleRepo);
const schedulingService = new SchedulingService_1.SchedulingService(scheduleRepo, jobQueue, eventBus, logger);
await schedulingService.start();
// 12b.1. Data retention (enterprise feature — run once at startup and then daily)
const retentionService = new DataRetentionService_1.DataRetentionService(db, logger);
void retentionService.runRetention().catch((err) => logger.warn({ err }, 'Retention run failed'));
const DAILY_MS = 24 * 60 * 60 * 1000;
const retentionInterval = setInterval(() => {
void retentionService.runRetention().catch((err) => logger.warn({ err }, 'Retention run failed'));
}, DAILY_MS);
retentionInterval.unref(); // Don't keep process alive just for retention
// 12c. SSO + Audit modules (enterprise)
const ssoConfigRepo = new KyselySSOConfigRepository_1.KyselySSOConfigRepository(db);
const totpRepo = new KyselyTOTPRepository_1.KyselyTOTPRepository(db);
const totpService = new TOTPService_1.TOTPService();
const auditRepo = new KyselyAuditRepository_1.KyselyAuditRepository(db);
// 13. HTTP server
const app = (0, server_1.createServer)({
config,
logger,
db,
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
fuzzingDeps: { runFuzz, repository: fuzzRepo },
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
integrationsDeps: { integrationRepo, webhookRepo },
schedulingDeps: { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo },
visualRegressionDeps: { listComparisons, approveBaseline, rejectComparison, approveAllNewStates },
licenseService,
authDeps: {
registerCommand,
loginCommand,
createOrgCommand,
inviteMemberCommand,
createApiKeyCommand,
getUserQuery,
listOrgMembersQuery,
sessionRepository: authSessionRepo,
apiKeyRepository: apiKeyRepo,
userRepository: userRepo,
},
ssoDeps: { ssoConfigRepository: ssoConfigRepo, totpRepository: totpRepo, totpService },
auditRepository: auditRepo,
});
const httpServer = http_1.default.createServer(app);
// 12. Socket.io + gateway
const io = new socket_io_1.Server(httpServer, {
cors: { origin: config.cors.origin, credentials: true },
});
const gateway = new SocketGateway_1.SocketGateway(io, eventBus, logger);
gateway.start();
// 13. Start listening
await new Promise((resolve) => {
httpServer.listen(config.port, config.host, resolve);
});
const startupMs = Date.now() - startupAt;
logger.info({ port: config.port, host: config.host, startupMs }, 'ABE server ready');
// 14. Graceful shutdown
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown)
return;
shuttingDown = true;
logger.info({ signal }, 'Shutting down...');
// Stop accepting new connections
httpServer.close();
// Close socket.io
io.close();
// Stop scheduling service
schedulingService.stop();
// Stop job queue and wait for active jobs
jobQueue.pause();
await jobQueue.waitForActive(30000);
// Close database
try {
await db.destroy();
}
catch (err) {
logger.warn({ err }, 'Error closing database');
}
logger.info('Shutdown complete');
process.exit(0);
}
// Force-exit if graceful shutdown takes too long
function forceExit(signal) {
void shutdown(signal).catch(() => {
process.exit(1);
});
setTimeout(() => {
logger.error('Forced shutdown after 30s');
process.exit(1);
}, 30000).unref();
}
process.on('SIGTERM', () => forceExit('SIGTERM'));
process.on('SIGINT', () => forceExit('SIGINT'));
}
bootstrap().catch((err) => {
console.error('Fatal: failed to start ABE', err);
process.exit(1);
});
+23
View File
@@ -0,0 +1,23 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AuditLog = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class AuditLog extends Entity_1.Entity {
static create(props, id) {
return new AuditLog(props, id ?? UniqueId_1.UniqueId.create());
}
static reconstitute(props, id) {
return new AuditLog(props, id);
}
get userId() { return this.props.userId; }
get organizationId() { return this.props.organizationId; }
get action() { return this.props.action; }
get resource() { return this.props.resource; }
get resourceId() { return this.props.resourceId; }
get ipAddress() { return this.props.ipAddress; }
get userAgent() { return this.props.userAgent; }
get details() { return this.props.details; }
get occurredAt() { return this.props.occurredAt; }
}
exports.AuditLog = AuditLog;
+9
View File
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuditRouter = exports.KyselyAuditRepository = exports.AuditLog = void 0;
var AuditLog_1 = require("./domain/entities/AuditLog");
Object.defineProperty(exports, "AuditLog", { enumerable: true, get: function () { return AuditLog_1.AuditLog; } });
var KyselyAuditRepository_1 = require("./infrastructure/repositories/KyselyAuditRepository");
Object.defineProperty(exports, "KyselyAuditRepository", { enumerable: true, get: function () { return KyselyAuditRepository_1.KyselyAuditRepository; } });
var AuditController_1 = require("./infrastructure/http/AuditController");
Object.defineProperty(exports, "createAuditRouter", { enumerable: true, get: function () { return AuditController_1.createAuditRouter; } });
@@ -0,0 +1,39 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuditRouter = createAuditRouter;
const express_1 = require("express");
function createAuditRouter(repo) {
const router = (0, express_1.Router)();
// GET /api/audit — list audit logs (enterprise only)
router.get('/', async (req, res, next) => {
try {
const filters = {
userId: req.query['userId'],
organizationId: req.query['organizationId'],
action: req.query['action'],
resource: req.query['resource'],
limit: req.query['limit'] ? Number(req.query['limit']) : 100,
};
if (req.query['from'])
filters.from = new Date(req.query['from']);
if (req.query['to'])
filters.to = new Date(req.query['to']);
const logs = await repo.findAll(filters);
res.json(logs.map((l) => ({
id: l.id.toString(),
userId: l.userId,
organizationId: l.organizationId,
action: l.action,
resource: l.resource,
resourceId: l.resourceId,
ipAddress: l.ipAddress,
details: l.details,
occurredAt: l.occurredAt.toISOString(),
})));
}
catch (err) {
next(err);
}
});
return router;
}
@@ -0,0 +1,55 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyAuditRepository = void 0;
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const AuditLog_1 = require("../../domain/entities/AuditLog");
class KyselyAuditRepository {
constructor(db) {
this.db = db;
}
async save(log) {
await this.db.insertInto('audit_logs').values({
id: log.id.toString(),
user_id: log.userId,
organization_id: log.organizationId,
action: log.action,
resource: log.resource,
resource_id: log.resourceId,
ip_address: log.ipAddress,
user_agent: log.userAgent,
details_json: JSON.stringify(log.details),
occurred_at: log.occurredAt.getTime(),
}).execute();
}
async findAll(filters = {}) {
let query = this.db.selectFrom('audit_logs').selectAll();
if (filters.userId)
query = query.where('user_id', '=', filters.userId);
if (filters.organizationId)
query = query.where('organization_id', '=', filters.organizationId);
if (filters.action)
query = query.where('action', '=', filters.action);
if (filters.resource)
query = query.where('resource', '=', filters.resource);
if (filters.from)
query = query.where('occurred_at', '>=', filters.from.getTime());
if (filters.to)
query = query.where('occurred_at', '<=', filters.to.getTime());
const rows = await query
.orderBy('occurred_at', 'desc')
.limit(filters.limit ?? 100)
.execute();
return rows.map((row) => AuditLog_1.AuditLog.reconstitute({
userId: row.user_id,
organizationId: row.organization_id,
action: row.action,
resource: row.resource,
resourceId: row.resource_id,
ipAddress: row.ip_address,
userAgent: row.user_agent,
details: JSON.parse(row.details_json),
occurredAt: new Date(row.occurred_at),
}, UniqueId_1.UniqueId.from(row.id)));
}
}
exports.KyselyAuditRepository = KyselyAuditRepository;
@@ -0,0 +1,41 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CreateApiKeyCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const ApiKey_1 = require("../../domain/entities/ApiKey");
const crypto_1 = require("crypto");
class CreateApiKeyCommand {
constructor(apiKeyRepository, userRepository) {
this.apiKeyRepository = apiKeyRepository;
this.userRepository = userRepository;
}
async execute(request) {
const user = await this.userRepository.findById(request.userId);
if (!user) {
return (0, Result_1.Err)('User not found');
}
if (!request.name.trim()) {
return (0, Result_1.Err)('API key name is required');
}
const rawKey = `abe_${(0, crypto_1.randomBytes)(32).toString('hex')}`;
const keyHash = (0, crypto_1.createHash)('sha256').update(rawKey).digest('hex');
const keyPrefix = rawKey.substring(0, 12);
const apiKey = ApiKey_1.ApiKey.create({
userId: request.userId,
orgId: request.orgId,
name: request.name.trim(),
keyHash,
keyPrefix,
permissions: request.permissions ?? ['member'],
expiresAt: request.expiresAt,
});
await this.apiKeyRepository.save(apiKey);
return (0, Result_1.Ok)({
id: apiKey.id.toString(),
key: rawKey,
keyPrefix,
name: apiKey.name,
});
}
}
exports.CreateApiKeyCommand = CreateApiKeyCommand;
@@ -0,0 +1,48 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CreateOrganizationCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const Organization_1 = require("../../domain/entities/Organization");
const crypto_1 = require("crypto");
class CreateOrganizationCommand {
constructor(orgRepository, userRepository, eventBus) {
this.orgRepository = orgRepository;
this.userRepository = userRepository;
this.eventBus = eventBus;
}
async execute(request) {
const user = await this.userRepository.findById(request.ownerId);
if (!user) {
return (0, Result_1.Err)('User not found');
}
const slug = Organization_1.Organization.slugify(request.name);
if (!slug) {
return (0, Result_1.Err)('Invalid organization name');
}
const existing = await this.orgRepository.findBySlug(slug);
if (existing) {
return (0, Result_1.Err)('Organization name already taken');
}
const org = Organization_1.Organization.create({ name: request.name, slug });
await this.orgRepository.save(org);
await this.orgRepository.addMember({
id: (0, crypto_1.randomUUID)(),
orgId: org.id.toString(),
userId: request.ownerId,
role: 'owner',
joinedAt: new Date(),
});
user.assignToOrg(org.id.toString());
await this.userRepository.save(user);
for (const event of org.domainEvents) {
await this.eventBus.publish(event);
}
org.clearEvents();
return (0, Result_1.Ok)({
orgId: org.id.toString(),
name: org.name,
slug: org.slug,
});
}
}
exports.CreateOrganizationCommand = CreateOrganizationCommand;
@@ -0,0 +1,63 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InviteMemberCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const Email_1 = require("../../domain/value-objects/Email");
const Role_1 = require("../../domain/value-objects/Role");
const MemberInvited_1 = require("../../domain/events/MemberInvited");
const crypto_1 = require("crypto");
class InviteMemberCommand {
constructor(orgRepository, userRepository, eventBus) {
this.orgRepository = orgRepository;
this.userRepository = userRepository;
this.eventBus = eventBus;
}
async execute(request) {
const org = await this.orgRepository.findById(request.orgId);
if (!org) {
return (0, Result_1.Err)('Organization not found');
}
let email;
try {
email = Email_1.Email.create(request.email);
}
catch {
return (0, Result_1.Err)('Invalid email address');
}
let role;
try {
role = Role_1.Role.create(request.role);
}
catch {
return (0, Result_1.Err)('Invalid role');
}
const user = await this.userRepository.findByEmail(email.value);
if (!user) {
return (0, Result_1.Err)('User with this email not found. They must register first.');
}
const existing = await this.orgRepository.getMember(request.orgId, user.id.toString());
if (existing) {
return (0, Result_1.Err)('User is already a member of this organization');
}
const memberId = (0, crypto_1.randomUUID)();
await this.orgRepository.addMember({
id: memberId,
orgId: request.orgId,
userId: user.id.toString(),
role: role.value,
joinedAt: new Date(),
});
const event = new MemberInvited_1.MemberInvited(request.orgId, {
email: email.value,
role: role.value,
inviterUserId: request.inviterUserId,
});
await this.eventBus.publish(event);
return (0, Result_1.Ok)({
memberId,
email: email.value,
role: role.value,
});
}
}
exports.InviteMemberCommand = InviteMemberCommand;
+56
View File
@@ -0,0 +1,56 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LoginCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const Email_1 = require("../../domain/value-objects/Email");
const UserLoggedIn_1 = require("../../domain/events/UserLoggedIn");
const crypto_1 = require("crypto");
class LoginCommand {
constructor(userRepository, sessionRepository, eventBus, verifyPassword, sessionMaxAgeSeconds = 7 * 24 * 60 * 60) {
this.userRepository = userRepository;
this.sessionRepository = sessionRepository;
this.eventBus = eventBus;
this.verifyPassword = verifyPassword;
this.sessionMaxAgeSeconds = sessionMaxAgeSeconds;
}
async execute(request) {
let email;
try {
email = Email_1.Email.create(request.email);
}
catch {
return (0, Result_1.Err)('Invalid credentials');
}
const user = await this.userRepository.findByEmail(email.value);
if (!user) {
return (0, Result_1.Err)('Invalid credentials');
}
const valid = await this.verifyPassword(request.password, user.passwordHash);
if (!valid) {
return (0, Result_1.Err)('Invalid credentials');
}
const token = (0, crypto_1.randomUUID)();
const expiresAt = new Date(Date.now() + this.sessionMaxAgeSeconds * 1000);
const session = {
id: (0, crypto_1.randomUUID)(),
userId: user.id.toString(),
token,
expiresAt,
createdAt: new Date(),
};
await this.sessionRepository.save(session);
const event = new UserLoggedIn_1.UserLoggedIn(user.id.toString(), {
email: user.email.value,
sessionId: session.id,
});
await this.eventBus.publish(event);
return (0, Result_1.Ok)({
userId: user.id.toString(),
sessionToken: token,
expiresAt,
role: user.role.value,
name: user.name,
});
}
}
exports.LoginCommand = LoginCommand;
@@ -0,0 +1,51 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RegisterCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const User_1 = require("../../domain/entities/User");
const Email_1 = require("../../domain/value-objects/Email");
const Role_1 = require("../../domain/value-objects/Role");
class RegisterCommand {
constructor(userRepository, eventBus, hashPassword) {
this.userRepository = userRepository;
this.eventBus = eventBus;
this.hashPassword = hashPassword;
}
async execute(request) {
let email;
try {
email = Email_1.Email.create(request.email);
}
catch {
return (0, Result_1.Err)('Invalid email address');
}
const existing = await this.userRepository.findByEmail(email.value);
if (existing) {
return (0, Result_1.Err)('Email already registered');
}
if (request.password.length < 8) {
return (0, Result_1.Err)('Password must be at least 8 characters');
}
let role;
try {
role = request.role ? Role_1.Role.create(request.role) : Role_1.Role.member();
}
catch {
return (0, Result_1.Err)('Invalid role');
}
const passwordHash = await this.hashPassword(request.password);
const user = User_1.User.create({ email, name: request.name, passwordHash, role });
await this.userRepository.save(user);
for (const event of user.domainEvents) {
await this.eventBus.publish(event);
}
user.clearEvents();
return (0, Result_1.Ok)({
userId: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
});
}
}
exports.RegisterCommand = RegisterCommand;
@@ -0,0 +1,71 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuthMiddleware = createAuthMiddleware;
const crypto_1 = require("crypto");
function createAuthMiddleware(userRepository, sessionRepository, apiKeyRepository) {
return async function authMiddleware(req, res, next) {
try {
// 1. Check session cookie
const sessionToken = req.cookies?.['abe_session'];
if (sessionToken) {
const session = await sessionRepository.findByToken(sessionToken);
if (session && session.expiresAt > new Date()) {
const user = await userRepository.findById(session.userId);
if (user) {
req.user = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
};
return next();
}
}
}
// 2. Check Bearer JWT (session token in header)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const session = await sessionRepository.findByToken(token);
if (session && session.expiresAt > new Date()) {
const user = await userRepository.findById(session.userId);
if (user) {
req.user = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
};
return next();
}
}
}
// 3. Check API key
const apiKeyHeader = req.headers['x-abe-api-key'];
if (apiKeyHeader && typeof apiKeyHeader === 'string') {
const keyHash = (0, crypto_1.createHash)('sha256').update(apiKeyHeader).digest('hex');
const apiKey = await apiKeyRepository.findByHash(keyHash);
if (apiKey && !apiKey.isExpired()) {
const user = await userRepository.findById(apiKey.userId);
if (user) {
await apiKeyRepository.updateLastUsed(apiKey.id.toString(), new Date());
req.user = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
};
return next();
}
}
}
res.status(401).json({ error: 'Unauthorized' });
}
catch {
res.status(401).json({ error: 'Unauthorized' });
}
};
}
@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.requirePermission = requirePermission;
const AbilityFactory_1 = require("../../infrastructure/casl/AbilityFactory");
function requirePermission(action, subject) {
return function rbacMiddleware(req, res, next) {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const ability = (0, AbilityFactory_1.defineAbilityFor)(req.user.role);
if (!ability.can(action, subject)) {
res.status(403).json({
error: 'Forbidden',
message: `You do not have permission to ${action} ${subject}`,
});
return;
}
next();
};
}
+24
View File
@@ -0,0 +1,24 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GetUserQuery = void 0;
const Result_1 = require("../../../../shared/domain/Result");
class GetUserQuery {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute(request) {
const user = await this.userRepository.findById(request.userId);
if (!user) {
return (0, Result_1.Err)('User not found');
}
return (0, Result_1.Ok)({
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
createdAt: user.createdAt,
});
}
}
exports.GetUserQuery = GetUserQuery;
@@ -0,0 +1,33 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ListOrgMembersQuery = void 0;
const Result_1 = require("../../../../shared/domain/Result");
class ListOrgMembersQuery {
constructor(orgRepository, userRepository) {
this.orgRepository = orgRepository;
this.userRepository = userRepository;
}
async execute(request) {
const org = await this.orgRepository.findById(request.orgId);
if (!org) {
return (0, Result_1.Err)('Organization not found');
}
const members = await this.orgRepository.listMembers(request.orgId);
const dtos = [];
for (const member of members) {
const user = await this.userRepository.findById(member.userId);
if (user) {
dtos.push({
id: member.id,
userId: member.userId,
email: user.email.value,
name: user.name,
role: member.role,
joinedAt: member.joinedAt,
});
}
}
return (0, Result_1.Ok)({ members: dtos, total: dtos.length });
}
}
exports.ListOrgMembersQuery = ListOrgMembersQuery;
+35
View File
@@ -0,0 +1,35 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ApiKey = void 0;
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class ApiKey extends AggregateRoot_1.AggregateRoot {
static create(props, id) {
const keyId = id ?? UniqueId_1.UniqueId.create();
return new ApiKey({
...props,
createdAt: new Date(),
}, keyId);
}
static reconstitute(props, id) {
return new ApiKey(props, id);
}
get userId() { return this.props.userId; }
get orgId() { return this.props.orgId; }
get name() { return this.props.name; }
get keyHash() { return this.props.keyHash; }
get keyPrefix() { return this.props.keyPrefix; }
get permissions() { return this.props.permissions; }
get expiresAt() { return this.props.expiresAt; }
get lastUsedAt() { return this.props.lastUsedAt; }
get createdAt() { return this.props.createdAt; }
isExpired() {
if (!this.props.expiresAt)
return false;
return new Date() > this.props.expiresAt;
}
markUsed() {
this.props.lastUsedAt = new Date();
}
}
exports.ApiKey = ApiKey;
+33
View File
@@ -0,0 +1,33 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Organization = void 0;
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const OrgCreated_1 = require("../events/OrgCreated");
class Organization extends AggregateRoot_1.AggregateRoot {
static create(props, id) {
const orgId = id ?? UniqueId_1.UniqueId.create();
const org = new Organization({
...props,
createdAt: new Date(),
}, orgId);
org.addDomainEvent(new OrgCreated_1.OrgCreated(orgId.toString(), {
name: props.name,
slug: props.slug,
}));
return org;
}
static reconstitute(props, id) {
return new Organization(props, id);
}
static slugify(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
get name() { return this.props.name; }
get slug() { return this.props.slug; }
get createdAt() { return this.props.createdAt; }
}
exports.Organization = Organization;
+42
View File
@@ -0,0 +1,42 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.User = void 0;
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const UserCreated_1 = require("../events/UserCreated");
class User extends AggregateRoot_1.AggregateRoot {
static create(props, id) {
const userId = id ?? UniqueId_1.UniqueId.create();
const now = new Date();
const user = new User({
...props,
createdAt: now,
updatedAt: now,
}, userId);
user.addDomainEvent(new UserCreated_1.UserCreated(userId.toString(), {
email: props.email.value,
name: props.name,
role: props.role.value,
}));
return user;
}
static reconstitute(props, id) {
return new User(props, id);
}
get email() { return this.props.email; }
get name() { return this.props.name; }
get passwordHash() { return this.props.passwordHash; }
get role() { return this.props.role; }
get orgId() { return this.props.orgId; }
get createdAt() { return this.props.createdAt; }
get updatedAt() { return this.props.updatedAt; }
assignToOrg(orgId) {
this.props.orgId = orgId;
this.props.updatedAt = new Date();
}
changeRole(role) {
this.props.role = role;
this.props.updatedAt = new Date();
}
}
exports.User = User;

Some files were not shown because too many files have changed in this diff Show More