Compare commits
28 Commits
4c92712d20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a1aadd844 | |||
| af66d926e7 | |||
| 08011d22d5 | |||
| c3911bafe8 | |||
| 87b7698ece | |||
| 629eafecd8 | |||
| ddb4f66036 | |||
| 30f293fbf8 | |||
| 94defee1f8 | |||
| 49e76c92b1 | |||
| 1cf597fee1 | |||
| 5a28270dc9 | |||
| 1f1678af17 | |||
| cffa1aeea9 | |||
| 3ff36f0b6a | |||
| 458302ca86 | |||
| 5ef4ce5de0 | |||
| 7526a5bc15 | |||
| 39a5e41f75 | |||
| f01acfe985 | |||
| e746dc0497 | |||
| d62bd615bf | |||
| 96bf6e5097 | |||
| 39c5313ba5 | |||
| 4a58749048 | |||
| 0e6c0c3655 | |||
| 2a93f1f5b7 | |||
| f8191133c8 |
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
c3911bafe885d664a6870305dff172e1410a95ac
|
||||||
+48
-137
@@ -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
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"status": "failed", "timestamp": "2026-03-08 07:22:04"}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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" }
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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}
|
||||||
|
```
|
||||||
@@ -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').
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -1 +1,153 @@
|
|||||||
# abe
|
# ABE — Autonomous Bug Explorer
|
||||||
|
|
||||||
|
> "Playwright discovers what you test. ABE discovers what you miss."
|
||||||
|
|
||||||
|
[](https://github.com/your-org/abe/actions)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](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
Binary file not shown.
Binary file not shown.
Vendored
+87
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+78
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+9
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+11
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+622
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+47
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+69
@@ -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
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
program.parse(process.argv);
|
||||||
Vendored
+602
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
program.parse(process.argv);
|
||||||
|
// ─── backup ─────────────────────────────────────────────────────────────────
|
||||||
|
program
|
||||||
|
.command('backup')
|
||||||
|
.description('Backup ABE database to a file')
|
||||||
|
.option('--db <path>', 'Path to ABE database', './data/abe.db')
|
||||||
|
.option('--output <file>', 'Backup file path', `./abe-backup-${new Date().toISOString().slice(0, 10)}.db`)
|
||||||
|
.action((opts) => {
|
||||||
|
const src = opts.db;
|
||||||
|
const dest = opts.output;
|
||||||
|
if (!fs.existsSync(src)) {
|
||||||
|
console.error(`Database not found: ${src}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
const size = fs.statSync(dest).size;
|
||||||
|
console.log(`✅ Backup created: ${dest} (${Math.round(size / 1024)} KB)`);
|
||||||
|
});
|
||||||
|
// ─── restore ────────────────────────────────────────────────────────────────
|
||||||
|
program
|
||||||
|
.command('restore')
|
||||||
|
.description('Restore ABE database from a backup file')
|
||||||
|
.requiredOption('--from <file>', 'Backup file to restore from')
|
||||||
|
.option('--db <path>', 'Path to ABE database', './data/abe.db')
|
||||||
|
.option('--confirm', 'Skip confirmation prompt')
|
||||||
|
.action((opts) => {
|
||||||
|
if (!fs.existsSync(opts.from)) {
|
||||||
|
console.error(`Backup file not found: ${opts.from}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (!opts.confirm) {
|
||||||
|
console.warn(`⚠️ This will overwrite the database at: ${opts.db}`);
|
||||||
|
console.warn(`Run with --confirm to proceed.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const dir = path.dirname(opts.db);
|
||||||
|
if (!fs.existsSync(dir))
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.copyFileSync(opts.from, opts.db);
|
||||||
|
const size = fs.statSync(opts.db).size;
|
||||||
|
console.log(`✅ Database restored from: ${opts.from} (${Math.round(size / 1024)} KB)`);
|
||||||
|
});
|
||||||
|
// ─── retention ──────────────────────────────────────────────────────────────
|
||||||
|
program
|
||||||
|
.command('retention')
|
||||||
|
.description('Run data retention cleanup (enterprise feature)')
|
||||||
|
.option('--db <path>', 'Path to ABE database', './data/abe.db')
|
||||||
|
.option('--findings-days <n>', 'Delete findings older than N days', parseInt, 365)
|
||||||
|
.option('--sessions-days <n>', 'Delete sessions older than N days', parseInt, 90)
|
||||||
|
.option('--audit-days <n>', 'Delete audit logs older than N days', parseInt, 365)
|
||||||
|
.option('--jobs-days <n>', 'Delete completed jobs older than N days', parseInt, 30)
|
||||||
|
.option('--dry-run', 'Show what would be deleted without deleting')
|
||||||
|
.action(async (opts) => {
|
||||||
|
if (!fs.existsSync(opts.db)) {
|
||||||
|
console.error(`Database not found: ${opts.db}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (opts.dryRun) {
|
||||||
|
console.log('🔍 Dry run mode — nothing will be deleted');
|
||||||
|
console.log(` Findings older than ${opts.findingsDays} days`);
|
||||||
|
console.log(` Sessions older than ${opts.sessionsDays} days`);
|
||||||
|
console.log(` Audit logs older than ${opts.auditDays} days`);
|
||||||
|
console.log(` Jobs older than ${opts.jobsDays} days`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Dynamically import to avoid loading DB in non-DB commands
|
||||||
|
const { Kysely, SqliteDialect } = await Promise.resolve().then(() => __importStar(require('kysely')));
|
||||||
|
const SQLite = (await Promise.resolve().then(() => __importStar(require('better-sqlite3')))).default;
|
||||||
|
const { DataRetentionService } = await Promise.resolve().then(() => __importStar(require('../modules/scheduling/infrastructure/DataRetentionService')));
|
||||||
|
const pino = (await Promise.resolve().then(() => __importStar(require('pino')))).default;
|
||||||
|
const logger = pino({ level: 'info' });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const db = new Kysely({ dialect: new SqliteDialect({ database: new SQLite(opts.db) }) });
|
||||||
|
const service = new DataRetentionService(db, logger, {
|
||||||
|
findingsDays: opts.findingsDays,
|
||||||
|
sessionsDays: opts.sessionsDays,
|
||||||
|
auditLogsDays: opts.auditDays,
|
||||||
|
jobsDays: opts.jobsDays,
|
||||||
|
});
|
||||||
|
const results = await service.runRetention();
|
||||||
|
await db.destroy();
|
||||||
|
console.log('✅ Data retention completed:');
|
||||||
|
for (const [key, count] of Object.entries(results)) {
|
||||||
|
console.log(` ${key}: ${count} rows deleted`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Vendored
+137
@@ -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;
|
||||||
Vendored
+53
@@ -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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
Vendored
+197
@@ -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;
|
||||||
Vendored
+66
@@ -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;
|
||||||
Vendored
+83
@@ -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;
|
||||||
Vendored
+6
@@ -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 });
|
||||||
Vendored
+76
@@ -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;
|
||||||
Vendored
+82
@@ -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;
|
||||||
Vendored
+53
@@ -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;
|
||||||
Vendored
+77
@@ -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;
|
||||||
Vendored
+43
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+126
@@ -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
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
Vendored
+36
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
Vendored
+31
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+86
@@ -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();
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
Vendored
+172
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+50
@@ -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 };
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+278
@@ -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
@@ -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;
|
||||||
Vendored
+9
@@ -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;
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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
@@ -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
Reference in New Issue
Block a user