Compare commits
7 Commits
4c92712d20
...
d62bd615bf
| Author | SHA1 | Date | |
|---|---|---|---|
| d62bd615bf | |||
| 96bf6e5097 | |||
| 39c5313ba5 | |||
| 4a58749048 | |||
| 0e6c0c3655 | |||
| 2a93f1f5b7 | |||
| f8191133c8 |
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm *)",
|
||||||
|
"Bash(npx *)",
|
||||||
|
"Bash(node *)",
|
||||||
|
"Bash(git *)",
|
||||||
|
"Bash(cd *)",
|
||||||
|
"Bash(cat *)",
|
||||||
|
"Bash(ls *)",
|
||||||
|
"Bash(mkdir *)",
|
||||||
|
"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(docker *)",
|
||||||
|
"Bash(docker compose *)",
|
||||||
|
"Bash(tsc *)",
|
||||||
|
"Bash(vitest *)",
|
||||||
|
"Bash(eslint *)",
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Edit",
|
||||||
|
"MultiEdit"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
logs
|
||||||
|
reports
|
||||||
|
data
|
||||||
|
.ralph
|
||||||
|
tests
|
||||||
|
frontend
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
@@ -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,48 @@
|
|||||||
|
name: ABE Exploratory Testing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
explore:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- 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 run 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
|
||||||
@@ -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 @@
|
|||||||
|
96bf6e50979e4cc152e9715b965f27eeb2decbc1
|
||||||
+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 [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-05-findings-module.md`
|
||||||
|
|
||||||
|
- [ ] 5.1: Crear `domain/entities/Finding.ts` — AggregateRoot con severity, type, evidence, status, actionTrace
|
||||||
|
- [ ] 5.2: Crear value objects: `Severity.ts` (low/medium/high/critical), `FindingType.ts`, `Evidence.ts`, `FindingStatus.ts` (open/investigating/resolved/closed)
|
||||||
|
- [ ] 5.3: Crear events: `FindingCreated.ts`, `FindingResolved.ts`, `FindingEnriched.ts`
|
||||||
|
- [ ] 5.4: Crear ports: `IFindingRepository.ts`, `IAIEnricher.ts`
|
||||||
|
- [ ] 5.5: Crear commands: `CreateFindingCommand.ts`, `EnrichFindingCommand.ts`, `ResolveFindingCommand.ts`
|
||||||
|
- [ ] 5.6: Crear queries: `GetFindingQuery.ts`, `ListFindingsQuery.ts` (filtros: severity, type, session, status, search), `FindingStatsQuery.ts`
|
||||||
|
- [ ] 5.7: Crear `event-handlers/OnAnomalyDetected.ts` — escucha eventos crawling → crea Finding
|
||||||
|
- [ ] 5.8: Crear `infrastructure/repositories/KyselyFindingRepository.ts`
|
||||||
|
- [ ] 5.9: Migrar exporters existentes → `infrastructure/exporters/` (MarkdownExporter, JSONExporter)
|
||||||
|
- [ ] 5.10: Crear `infrastructure/exporters/PlaywrightScriptExporter.ts` — genera test Playwright reproducible desde actionTrace
|
||||||
|
- [ ] 5.11: Crear `infrastructure/http/FindingsController.ts` — routes para anomalies existentes + nuevas
|
||||||
|
- [ ] 5.12: Migración Kysely: tabla findings con columnas status, browser, ai_enrichment_json
|
||||||
|
- [ ] 5.13: Tests: Finding aggregate, CreateFinding, ListFindings con filtros
|
||||||
|
- [ ] 5.14: Verificar build + commit: `fase(5): findings module complete`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Fuzzing Module [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-06-fuzzing-module.md`
|
||||||
|
|
||||||
|
- [ ] 6.1: Crear domain: `FuzzSession.ts` (AggregateRoot), `FuzzResult.ts` (Entity)
|
||||||
|
- [ ] 6.2: Crear value objects: `FuzzStrategy.ts`, `FuzzPayload.ts`, `Seed.ts`, `FuzzIntensity.ts`
|
||||||
|
- [ ] 6.3: Crear events: `FuzzStarted.ts`, `VulnerabilityDetected.ts`, `FuzzCompleted.ts`
|
||||||
|
- [ ] 6.4: Crear port: `IFuzzerEngine.ts`
|
||||||
|
- [ ] 6.5: Crear `commands/RunFuzzCommand.ts`
|
||||||
|
- [ ] 6.6: Crear `event-handlers/OnActionExecuted.ts` — escucha crawling → trigger fuzzing
|
||||||
|
- [ ] 6.7: Migrar las 5 estrategias existentes → `infrastructure/strategies/` (Empty, Oversized, SpecialChars, TypeMismatch, Boundary)
|
||||||
|
- [ ] 6.8: Migrar `FuzzingEngine.ts` y `InputTypeDetector.ts` → `infrastructure/adapters/`
|
||||||
|
- [ ] 6.9: Crear `infrastructure/http/FuzzingController.ts`
|
||||||
|
- [ ] 6.10: Tests: cada estrategia de fuzzing genera payloads válidos
|
||||||
|
- [ ] 6.11: Verificar build + commit: `fase(6): fuzzing module complete`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: API Server Refactor + Composition Root [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-07-api-server.md`
|
||||||
|
|
||||||
|
- [ ] 7.1: Crear `src/api/middleware/errorHandler.ts` — AppError hierarchy (ValidationError, AuthenticationError, ForbiddenError, NotFoundError) + global error handler
|
||||||
|
- [ ] 7.2: Crear `src/api/middleware/requestId.ts` — genera UUID por request, adjunta a req + pino child logger
|
||||||
|
- [ ] 7.3: Crear `src/api/middleware/notFound.ts` — 404 handler para rutas no encontradas
|
||||||
|
- [ ] 7.4: Crear `src/api/server.ts` — Express app con middleware stack: requestId → helmet → cors → rateLimit → bodyParser → routes → notFound → errorHandler
|
||||||
|
- [ ] 7.5: Crear `src/api/router.ts` — registra routes de TODOS los módulos (crawling, findings, fuzzing)
|
||||||
|
- [ ] 7.6: Crear `src/realtime/SocketGateway.ts` — socket.io server que subscribe a EventBus y emite a clientes
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] 7.8: Implementar graceful shutdown en main.ts: SIGTERM/SIGINT → stop accepting → close sockets → close db → flush logs → exit
|
||||||
|
- [ ] 7.9: Health endpoints: GET /health/live (process alive), GET /health/ready (DB check)
|
||||||
|
- [ ] 7.10: Verificar que TODOS los endpoints existentes siguen funcionando tras refactor
|
||||||
|
- [ ] 7.11: Verificar build + commit: `fase(7): api server refactor with composition root`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Job Queue System [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-08-job-queue.md`
|
||||||
|
|
||||||
|
- [ ] 8.1: Crear `src/jobs/JobQueue.ts` — interface: enqueue, start, pause, waitForActive
|
||||||
|
- [ ] 8.2: Crear `src/jobs/SQLiteJobQueue.ts` — tabla jobs con status/type/payload/attempts/run_at, polling worker
|
||||||
|
- [ ] 8.3: Migración Kysely: tabla jobs
|
||||||
|
- [ ] 8.4: Crear `src/jobs/workers/ExplorationWorker.ts` — ejecuta crawl como job
|
||||||
|
- [ ] 8.5: Crear `src/jobs/workers/ReportWorker.ts` — genera reports en background
|
||||||
|
- [ ] 8.6: Integrar job queue en main.ts, mover exploraciones de sync a job-based
|
||||||
|
- [ ] 8.7: Tests: enqueue → dequeue → complete cycle, failed job retry
|
||||||
|
- [ ] 8.8: Verificar build + commit: `fase(8): sqlite job queue system`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Auth Module [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-09-auth-module.md`
|
||||||
|
|
||||||
|
- [ ] 9.1: Instalar: `npm i better-auth @casl/ability argon2`
|
||||||
|
- [ ] 9.2: Crear domain: `User.ts` (AggregateRoot), `Organization.ts` (AggregateRoot), `Team.ts` (Entity), `ApiKey.ts` (Entity)
|
||||||
|
- [ ] 9.3: Crear value objects: `Email.ts`, `Role.ts` (owner/admin/member/viewer), `Permission.ts`
|
||||||
|
- [ ] 9.4: Crear events: `UserCreated.ts`, `UserLoggedIn.ts`, `OrgCreated.ts`, `MemberInvited.ts`
|
||||||
|
- [ ] 9.5: Crear ports: `IUserRepository.ts`, `IOrganizationRepository.ts`
|
||||||
|
- [ ] 9.6: Crear commands: `RegisterCommand.ts`, `LoginCommand.ts`, `CreateOrganizationCommand.ts`, `InviteMemberCommand.ts`, `CreateApiKeyCommand.ts`
|
||||||
|
- [ ] 9.7: Crear queries: `GetUserQuery.ts`, `ListOrgMembersQuery.ts`
|
||||||
|
- [ ] 9.8: Crear `infrastructure/better-auth/authConfig.ts` — setup Better Auth con SQLite adapter, email+password, organization plugin con roles
|
||||||
|
- [ ] 9.9: Crear `infrastructure/casl/AbilityFactory.ts` — define permisos por role (owner: manage all, admin: manage all except delete org, member: create/read sessions+findings, viewer: read all)
|
||||||
|
- [ ] 9.10: Crear `application/middleware/AuthMiddleware.ts` — intenta session cookie → JWT → API key → 401
|
||||||
|
- [ ] 9.11: Crear `application/middleware/RBACMiddleware.ts` — verifica permisos CASL por ruta
|
||||||
|
- [ ] 9.12: Crear `infrastructure/repositories/KyselyUserRepository.ts`
|
||||||
|
- [ ] 9.13: Crear `infrastructure/http/AuthController.ts` — POST /api/auth/register, POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me, GET /api/auth/setup-required
|
||||||
|
- [ ] 9.14: Migración Kysely: tablas users, organizations, teams, org_members, api_keys, auth_sessions
|
||||||
|
- [ ] 9.15: First-run detection: si 0 users → GET /api/auth/setup-required retorna { required: true }
|
||||||
|
- [ ] 9.16: POST /api/auth/setup — crea primer user como owner + organización default
|
||||||
|
- [ ] 9.17: Integrar AuthMiddleware en todas las rutas /api/ excepto /health/* y /api/auth/*
|
||||||
|
- [ ] 9.18: Tests: register, login, RBAC permissions (admin can create session, viewer cannot)
|
||||||
|
- [ ] 9.19: Verificar build + commit: `fase(9): auth module with better-auth and casl`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: Frontend — shadcn/ui Shell [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-10-frontend-shell.md`
|
||||||
|
|
||||||
|
- [ ] 10.1: En frontend/: instalar shadcn/ui con `npx shadcn@latest init` (Vite, Zinc, CSS variables, Tailwind)
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] 10.3: Instalar deps: `npm i @tanstack/react-query @tanstack/react-table zustand react-hook-form @hookform/resolvers framer-motion react-hotkeys-hook`
|
||||||
|
- [ ] 10.4: Crear layout: `components/layout/AppSidebar.tsx` — sidebar collapsible con nav items (Dashboard, Explorations, Findings, Reports, Settings)
|
||||||
|
- [ ] 10.5: Crear `components/layout/TopBar.tsx` — logo, search trigger (⌘K), theme toggle, user avatar menu
|
||||||
|
- [ ] 10.6: Crear `components/layout/AppLayout.tsx` — wrapper: Sidebar + TopBar + Content outlet
|
||||||
|
- [ ] 10.7: Crear `components/layout/CommandPalette.tsx` — ⌘K con shadcn Command component
|
||||||
|
- [ ] 10.8: Crear ThemeProvider: dark mode como default, toggle dark/light, persistir en localStorage
|
||||||
|
- [ ] 10.9: Crear `lib/api.ts` — API client con fetch, credentials: include, auto-redirect a /login en 401
|
||||||
|
- [ ] 10.10: Crear `lib/queryClient.ts` — TanStack Query provider
|
||||||
|
- [ ] 10.11: Crear `stores/uiStore.ts` — Zustand: sidebarCollapsed, theme
|
||||||
|
- [ ] 10.12: Crear pages/Login.tsx — form email + password con shadcn
|
||||||
|
- [ ] 10.13: Crear pages/Setup.tsx — wizard first-run (crear admin + nombre org)
|
||||||
|
- [ ] 10.14: Crear `components/layout/ProtectedRoute.tsx` — check auth, redirect a /login o /setup
|
||||||
|
- [ ] 10.15: Actualizar App.tsx con React Router: / (dashboard), /login, /setup, /sessions/:id, /findings/:id, /settings — todo wrapped en ProtectedRoute excepto login/setup
|
||||||
|
- [ ] 10.16: Verificar frontend build + commit: `fase(10): frontend shadcn-ui shell with auth`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11: Dashboard Page [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-11-dashboard.md`
|
||||||
|
|
||||||
|
- [ ] 11.1: Instalar en frontend: `npm i tremor recharts`
|
||||||
|
- [ ] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats
|
||||||
|
- [ ] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession
|
||||||
|
- [ ] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect
|
||||||
|
- [ ] 11.5: Crear `components/dashboard/KPICards.tsx` — 4 cards Tremor: Total Findings, Critical/High, Active Sessions, Coverage
|
||||||
|
- [ ] 11.6: Crear `components/dashboard/TrendChart.tsx` — Recharts AreaChart stacked por severity, últimos 30 días
|
||||||
|
- [ ] 11.7: Crear `components/dashboard/SeverityDistribution.tsx` — Recharts PieChart con colores por severity
|
||||||
|
- [ ] 11.8: Crear `components/dashboard/RecentFindings.tsx` — TanStack Table, 10 rows, click → /findings/:id
|
||||||
|
- [ ] 11.9: Crear `components/dashboard/ActiveSessions.tsx` — lista con progress bars, click → /sessions/:id
|
||||||
|
- [ ] 11.10: Crear `components/dashboard/QuickActions.tsx` — botón "New Exploration" prominente
|
||||||
|
- [ ] 11.11: Crear `pages/Dashboard.tsx` — ensambla todo, responsive 2col desktop 1col mobile
|
||||||
|
- [ ] 11.12: Conectar real-time: socket events actualizan KPIs y recent findings
|
||||||
|
- [ ] 11.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 12: Sessions Pages [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-12-sessions-pages.md`
|
||||||
|
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
|
||||||
|
- [ ] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs
|
||||||
|
- [ ] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly)
|
||||||
|
- [ ] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges
|
||||||
|
- [ ] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
|
||||||
|
- [ ] 12.7: Progress bar estados explorados / maxStates
|
||||||
|
- [ ] 12.8: Stop button funcional (DELETE /api/sessions/:id)
|
||||||
|
- [ ] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 13: Findings Pages [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-13-findings-pages.md`
|
||||||
|
|
||||||
|
- [ ] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search
|
||||||
|
- [ ] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout
|
||||||
|
- [ ] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb
|
||||||
|
- [ ] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer)
|
||||||
|
- [ ] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI"
|
||||||
|
- [ ] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
|
||||||
|
- [ ] 13.7: Status workflow buttons: open → investigating → resolved → closed
|
||||||
|
- [ ] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul
|
||||||
|
- [ ] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 14: Settings Pages [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-14-settings-pages.md`
|
||||||
|
|
||||||
|
- [ ] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
|
||||||
|
- [ ] 14.2: Section "Profile" — cambiar nombre, email, password
|
||||||
|
- [ ] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
|
||||||
|
- [ ] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
|
||||||
|
- [ ] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
|
||||||
|
- [ ] 14.6: Section "Notifications" — Slack webhook URL, min severity
|
||||||
|
- [ ] 14.7: Section "Appearance" — tema dark/light, accent color
|
||||||
|
- [ ] 14.8: Section "License" — ver status licencia, input para activar key
|
||||||
|
- [ ] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 15: Reporting Module [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-15-reporting.md`
|
||||||
|
|
||||||
|
- [ ] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
|
||||||
|
- [ ] 15.2: Crear port: `IReportGenerator.ts`
|
||||||
|
- [ ] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
|
||||||
|
- [ ] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
|
||||||
|
- [ ] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
|
||||||
|
- [ ] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download
|
||||||
|
- [ ] 15.7: Integrar con job queue: generación async
|
||||||
|
- [ ] 15.8: Migración Kysely: tabla reports
|
||||||
|
- [ ] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
|
||||||
|
- [ ] 15.10: Tests: GenerateReportCommand con mock generator
|
||||||
|
- [ ] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 16: Integrations Module [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-16-integrations.md`
|
||||||
|
|
||||||
|
- [ ] 16.1: Instalar: `npm i @slack/web-api @octokit/rest`
|
||||||
|
- [ ] 16.2: Crear domain: `Integration.ts` (Entity), `WebhookEndpoint.ts` (Entity)
|
||||||
|
- [ ] 16.3: Crear value objects: `IntegrationType.ts` (jira/slack/github/webhook), `WebhookSecret.ts`
|
||||||
|
- [ ] 16.4: Crear port: `IIntegrationProvider.ts` (sendFinding)
|
||||||
|
- [ ] 16.5: Crear `infrastructure/webhooks/WebhookDispatcher.ts` — HMAC-SHA256 signature, retry con exponential backoff (3 intentos)
|
||||||
|
- [ ] 16.6: Crear `infrastructure/providers/SlackProvider.ts` — Block Kit message con severity, description, link
|
||||||
|
- [ ] 16.7: Crear `infrastructure/providers/GitHubIssuesProvider.ts` — crea issue con reproduction steps
|
||||||
|
- [ ] 16.8: Crear `infrastructure/providers/JiraProvider.ts` — REST API v3, crea issue con screenshots
|
||||||
|
- [ ] 16.9: Crear `event-handlers/OnFindingCreated.ts` — dispatch a todas las integrations activas
|
||||||
|
- [ ] 16.10: Crear `infrastructure/http/IntegrationsController.ts` — CRUD integrations + webhooks
|
||||||
|
- [ ] 16.11: Migración Kysely: tables integrations, webhook_endpoints, webhook_deliveries
|
||||||
|
- [ ] 16.12: Frontend: Settings/Integrations con forms por provider (Slack webhook URL, Jira config, GitHub token, custom webhook)
|
||||||
|
- [ ] 16.13: Tests: webhook dispatch + HMAC verification
|
||||||
|
- [ ] 16.14: Verificar build completo + commit: `fase(16): integrations module`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 17: Licensing Module [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-17-licensing.md`
|
||||||
|
|
||||||
|
- [ ] 17.1: Crear domain: `License.ts` (Entity), value objects `LicensePlan.ts` (free/pro/enterprise), `FeatureEntitlement.ts`
|
||||||
|
- [ ] 17.2: Crear port: `ILicenseValidator.ts` (validate, getEntitlements)
|
||||||
|
- [ ] 17.3: Crear `infrastructure/RSALicenseValidator.ts` — verifica firma RSA-2048 con public key bundled
|
||||||
|
- [ ] 17.4: Crear feature flags: `FREE_FEATURES`, `PRO_FEATURES`, `ENTERPRISE_FEATURES` arrays
|
||||||
|
- [ ] 17.5: Crear `infrastructure/middleware/FeatureGateMiddleware.ts` — checkea feature en license antes de permitir request
|
||||||
|
- [ ] 17.6: Crear `infrastructure/http/LicensingController.ts` — POST /api/license/activate, GET /api/license/status
|
||||||
|
- [ ] 17.7: Crear `scripts/generate-license.ts` — CLI tool para generar license keys firmadas (uso interno)
|
||||||
|
- [ ] 17.8: Integrar gate checks en rutas Pro/Enterprise (reporting, integrations, etc.)
|
||||||
|
- [ ] 17.9: Frontend: License section en Settings
|
||||||
|
- [ ] 17.10: Tests: valid license passes, expired fails, wrong signature fails, feature gate blocks
|
||||||
|
- [ ] 17.11: Verificar build completo + commit: `fase(17): licensing module with RSA validation`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 18: CLI + CI/CD [PENDIENTE]
|
||||||
|
Spec: `.ralph/specs/phase-18-cli-cicd.md`
|
||||||
|
|
||||||
|
- [ ] 18.1: Instalar: `npm i commander`
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] 18.3: Comando `abe report` — genera report de una sesión por id
|
||||||
|
- [ ] 18.4: Comando `abe status` — ping al servidor, muestra sessions activas
|
||||||
|
- [ ] 18.5: Output JUnit XML: cada finding = failing test, cada state sin findings = passing test
|
||||||
|
- [ ] 18.6: Exit codes: 0=clean, 1=findings over threshold, 2=error
|
||||||
|
- [ ] 18.7: Crear `.github/actions/abe-explore/action.yml` — GitHub Action composite
|
||||||
|
- [ ] 18.8: Crear `Dockerfile.ci` — imagen con Chromium para CI (basada en mcr.microsoft.com/playwright)
|
||||||
|
- [ ] 18.9: Crear `.github/workflows/abe-example.yml` — ejemplo completo
|
||||||
|
- [ ] 18.10: Actualizar README.md con sección CLI
|
||||||
|
- [ ] 18.11: Verificar build completo + commit: `fase(18): cli and cicd integration`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 19: Scheduling Module Refactor [PENDIENTE]
|
||||||
|
|
||||||
|
- [ ] 19.1: Migrar scheduling existente → nueva estructura modular (domain/application/infrastructure)
|
||||||
|
- [ ] 19.2: Crear Schedule aggregate con cron validation (Zod)
|
||||||
|
- [ ] 19.3: Integrar con job queue
|
||||||
|
- [ ] 19.4: Crear SchedulingController con CRUD + toggle
|
||||||
|
- [ ] 19.5: Frontend: Schedules section en Settings
|
||||||
|
- [ ] 19.6: Verificar build + commit: `fase(19): scheduling module refactor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 20: Visual Regression Refactor [PENDIENTE]
|
||||||
|
|
||||||
|
- [ ] 20.1: Migrar visual regression existente → nueva estructura modular
|
||||||
|
- [ ] 20.2: Integrar con StorageProvider para screenshots
|
||||||
|
- [ ] 20.3: Refactorizar frontend /visual-review con shadcn/ui components
|
||||||
|
- [ ] 20.4: Verificar build + commit: `fase(20): visual regression refactor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 21: API Documentation [PENDIENTE]
|
||||||
|
|
||||||
|
- [ ] 21.1: Instalar: `npm i @asteasolutions/zod-to-openapi @scalar/express-api-reference`
|
||||||
|
- [ ] 21.2: Crear Zod schemas compartidos para TODOS los endpoints (request + response)
|
||||||
|
- [ ] 21.3: Generar OpenAPI 3.1 spec desde Zod schemas
|
||||||
|
- [ ] 21.4: Montar Scalar UI en GET /api-docs
|
||||||
|
- [ ] 21.5: Servir spec JSON en GET /api-docs/openapi.json
|
||||||
|
- [ ] 21.6: Verificar que todos los endpoints están documentados
|
||||||
|
- [ ] 21.7: Verificar build + commit: `fase(21): openapi documentation with scalar`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 22: Docker Production [PENDIENTE]
|
||||||
|
|
||||||
|
- [ ] 22.1: Refactorizar Dockerfile backend: multi-stage, node:20-alpine, tini como init, non-root user, HEALTHCHECK
|
||||||
|
- [ ] 22.2: Refactorizar frontend Dockerfile: multi-stage build + nginx
|
||||||
|
- [ ] 22.3: Actualizar docker-compose.yml: healthcheck, restart policies, volumes, env_file
|
||||||
|
- [ ] 22.4: Crear docker-compose.prod.yml
|
||||||
|
- [ ] 22.5: Crear .dockerignore optimizado
|
||||||
|
- [ ] 22.6: CMD DEBE ser `["tini", "--", "node", "dist/main.js"]` — NUNCA npm
|
||||||
|
- [ ] 22.7: Verificar imagen final < 200MB
|
||||||
|
- [ ] 22.8: Verificar docker compose up funciona end-to-end
|
||||||
|
- [ ] 22.9: Commit: `fase(22): docker production setup`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 23: Observability [PENDIENTE]
|
||||||
|
|
||||||
|
- [ ] 23.1: Request correlation: requestId en CADA log entry via pino child logger
|
||||||
|
- [ ] 23.2: Structured error logging con contexto (userId, sessionId, etc.)
|
||||||
|
- [ ] 23.3: Liveness probe: GET /health/live
|
||||||
|
- [ ] 23.4: Readiness probe: GET /health/ready (DB + job queue check)
|
||||||
|
- [ ] 23.5: Startup probe: medir tiempo de arranque, loguear
|
||||||
|
- [ ] 23.6: Commit: `fase(23): observability and health probes`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 24: Onboarding + First-Run [PENDIENTE]
|
||||||
|
|
||||||
|
- [ ] 24.1: Detectar first-run en frontend (GET /api/auth/setup-required)
|
||||||
|
- [ ] 24.2: Wizard multi-step: paso 1 crear admin, paso 2 nombre org, paso 3 "Start your first exploration" con URL input
|
||||||
|
- [ ] 24.3: Empty states: ilustraciones/mensajes en tablas vacías ("No findings yet. Start an exploration!")
|
||||||
|
- [ ] 24.4: Commit: `fase(24): onboarding and first-run experience`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 25: Polish + Quality [PENDIENTE]
|
||||||
|
|
||||||
|
- [ ] 25.1: Audit TypeScript strict — eliminar TODOS los `any` restantes
|
||||||
|
- [ ] 25.2: Loading skeletons en todas las pages (shadcn Skeleton)
|
||||||
|
- [ ] 25.3: Error boundaries en cada page
|
||||||
|
- [ ] 25.4: Keyboard shortcuts: ⌘K (command palette), Esc (close dialogs), N (new exploration from dashboard)
|
||||||
|
- [ ] 25.5: Responsive mobile: sidebar collapse, tables scroll, forms stack
|
||||||
|
- [ ] 25.6: README.md profesional: badges (build, license, version), screenshots, features list, quick start, CLI docs, architecture diagram, contributing
|
||||||
|
- [ ] 25.7: CONTRIBUTING.md
|
||||||
|
- [ ] 25.8: LICENSE files: MIT para core, archivo LICENSE-ENTERPRISE separado
|
||||||
|
- [ ] 25.9: Commit: `fase(25): polish and quality improvements`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 26: SSO Enterprise [PENDIENTE — ENTERPRISE ONLY]
|
||||||
|
|
||||||
|
- [ ] 26.1: SAML 2.0 via @node-saml/passport-saml con MultiSamlStrategy
|
||||||
|
- [ ] 26.2: OIDC via openid-client (Okta, Azure AD, Google Workspace)
|
||||||
|
- [ ] 26.3: Per-organization IdP configuration
|
||||||
|
- [ ] 26.4: LDAP/AD integration via passport-ldapauth
|
||||||
|
- [ ] 26.5: MFA (TOTP) support
|
||||||
|
- [ ] 26.6: Audit log completo (who did what, when)
|
||||||
|
- [ ] 26.7: Session management dashboard (ver/revocar sessions activas)
|
||||||
|
- [ ] 26.8: Feature-gated tras LICENSE enterprise
|
||||||
|
- [ ] 26.9: Commit: `fase(26): enterprise sso saml oidc ldap`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 27: Advanced Enterprise [PENDIENTE — ENTERPRISE ONLY]
|
||||||
|
|
||||||
|
- [ ] 27.1: Data retention policies (auto-delete findings > X days)
|
||||||
|
- [ ] 27.2: Backup/restore CLI tool
|
||||||
|
- [ ] 27.3: White-labeling (CSS custom properties + logo upload)
|
||||||
|
- [ ] 27.4: PostgreSQL support validado end-to-end
|
||||||
|
- [ ] 27.5: Email notifications (nodemailer + templates)
|
||||||
|
- [ ] 27.6: Kubernetes Helm chart
|
||||||
|
- [ ] 27.7: Commit: `fase(27): advanced enterprise features`
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"status": "executing",
|
||||||
|
"indicator": "⠹",
|
||||||
|
"elapsed_seconds": 30,
|
||||||
|
"last_output": "",
|
||||||
|
"timestamp": "2026-03-05 03:53:30"
|
||||||
|
}
|
||||||
@@ -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,7 @@
|
|||||||
# .ralphrc - Ralph project configuration
|
ALLOWED_TOOLS="bash,write,edit,read,glob,grep,todoread,todowrite"
|
||||||
# Generated by: ralph-setup
|
AUTO_APPROVE=true
|
||||||
# Documentation: https://github.com/frankbria/ralph-claude-code
|
MAX_LOOPS=200
|
||||||
|
MODEL="claude-sonnet-4-20250514"
|
||||||
|
|
||||||
# Project identification
|
# Allow all bash commands including docker
|
||||||
PROJECT_NAME="abe"
|
ALLOW_ALL_BASH=true
|
||||||
PROJECT_TYPE="generic"
|
|
||||||
|
|
||||||
# Claude Code CLI command
|
|
||||||
# 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"
|
|
||||||
|
|
||||||
# Loop settings
|
|
||||||
MAX_CALLS_PER_HOUR=100
|
|
||||||
CLAUDE_TIMEOUT_MINUTES=15
|
|
||||||
CLAUDE_OUTPUT_FORMAT="json"
|
|
||||||
|
|
||||||
# Tool permissions
|
|
||||||
# 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_EXPIRY_HOURS=24
|
|
||||||
|
|
||||||
# Task sources (for ralph enable --sync)
|
|
||||||
# Options: local, beads, github (comma-separated for multiple)
|
|
||||||
TASK_SOURCES="local"
|
|
||||||
GITHUB_TASK_LABEL="ralph-task"
|
|
||||||
BEADS_FILTER="status:open"
|
|
||||||
|
|
||||||
# Circuit breaker thresholds
|
|
||||||
CB_NO_PROGRESS_THRESHOLD=3
|
|
||||||
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
|
||||||
|
```
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# ---- 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
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System dependencies required by Playwright / Chromium and healthcheck
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
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
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Runtime directories for reports and logs
|
||||||
|
RUN mkdir -p reports logs
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3001/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "dist/server/index.js"]
|
||||||
@@ -1 +1,126 @@
|
|||||||
# abe
|
# ABE — Autonomous Bug Explorer
|
||||||
|
|
||||||
|
An open-source framework that autonomously explores web applications, provokes failures, and generates reproducible bug reports for developers and AI coding assistants.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Install Playwright browser
|
||||||
|
npx playwright install chromium
|
||||||
|
|
||||||
|
# Run ABE against your app
|
||||||
|
npm run explore -- --url http://localhost:3000 --output ./reports
|
||||||
|
|
||||||
|
# Replay a discovered bug
|
||||||
|
npm run replay -- --report reports/<anomaly-id>/report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## What ABE Does
|
||||||
|
|
||||||
|
1. Launches a headless browser and navigates to the target URL
|
||||||
|
2. Discovers interactive elements (links, buttons, inputs)
|
||||||
|
3. Executes actions deterministically using a seed
|
||||||
|
4. Observes HTTP responses, JS exceptions, and console errors
|
||||||
|
5. Detects anomalies using heuristic rules
|
||||||
|
6. Captures screenshots and DOM snapshots at anomaly moments
|
||||||
|
7. Generates a JSON + Markdown bug report with exact reproduction steps
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── core/ # Interfaces, StateGraph, ExplorationEngine, AnomalyDetector
|
||||||
|
└── plugins/
|
||||||
|
├── agents/ # PlaywrightAgent
|
||||||
|
├── collectors/ # Screenshot, Network, DOMSnapshot
|
||||||
|
├── exporters/ # JSON, Markdown
|
||||||
|
└── reproducers/ # PlaywrightReproducer
|
||||||
|
|
||||||
|
tests/ # Unit and integration tests (mirrors src/)
|
||||||
|
reports/ # Generated bug reports (runtime)
|
||||||
|
logs/ # Session logs in .jsonl format (runtime)
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Options
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `--url` | `http://localhost:3000` | Target URL |
|
||||||
|
| `--output` | `./reports` | Output directory |
|
||||||
|
| `--seed` | `42` | Random seed for determinism |
|
||||||
|
| `--max-steps` | `100` | Maximum exploration steps |
|
||||||
|
|
||||||
|
## Web UI (API Server + Dashboard)
|
||||||
|
|
||||||
|
ABE also ships a web dashboard for launching explorations and watching results in real time.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start both the API server (port 3001) and the React frontend (port 5173)
|
||||||
|
npm run dev:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:5173` in your browser.
|
||||||
|
|
||||||
|
### API Server only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run server
|
||||||
|
```
|
||||||
|
|
||||||
|
REST endpoints available at `http://localhost:3001/api/`:
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `POST` | `/sessions` | Start a new exploration |
|
||||||
|
| `GET` | `/sessions` | List all sessions |
|
||||||
|
| `GET` | `/sessions/:id` | Session detail |
|
||||||
|
| `DELETE` | `/sessions/:id` | Stop a running session |
|
||||||
|
| `GET` | `/anomalies` | List all anomalies |
|
||||||
|
| `GET` | `/anomalies/:id` | Anomaly detail |
|
||||||
|
| `GET` | `/anomalies/:id/screenshot` | Bug screenshot (PNG) |
|
||||||
|
| `POST` | `/anomalies/:id/replay` | Trigger anomaly replay |
|
||||||
|
|
||||||
|
WebSocket events are emitted via socket.io (connect to `http://localhost:3001`).
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Run the full stack (backend + frontend) with a single command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
| Service | Host port | Description |
|
||||||
|
|---------|-----------|-------------|
|
||||||
|
| Backend | `3001` | Express API + socket.io |
|
||||||
|
| Frontend | `5173` | React dashboard (nginx) |
|
||||||
|
|
||||||
|
Then open `http://localhost:5173` in your browser.
|
||||||
|
|
||||||
|
Reports and logs are persisted via Docker volumes (`./reports`, `./logs`).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Compile TypeScript
|
||||||
|
npm test # Run all tests
|
||||||
|
npm run typecheck # Type-check without compiling
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/ (React + Vite, port 5173)
|
||||||
|
↕ HTTP REST + WebSocket
|
||||||
|
src/server/ (Express + socket.io, port 3001)
|
||||||
|
↕ imports
|
||||||
|
src/core/ + src/plugins/ (ABE engine)
|
||||||
|
```
|
||||||
|
|
||||||
|
Core principles:
|
||||||
|
- **Deterministic**: all random choices are seeded and logged
|
||||||
|
- **Plugin-oriented**: core engine never imports concrete plugin classes
|
||||||
|
- **Reproducible**: every anomaly includes an exact action trace and replay script
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
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
+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
+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();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.StartCrawlCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const Url_1 = require("../../domain/value-objects/Url");
|
||||||
|
const CrawlSession_1 = require("../../domain/entities/CrawlSession");
|
||||||
|
class StartCrawlCommand {
|
||||||
|
constructor(repository, eventBus) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const urlResult = Url_1.Url.create(request.url);
|
||||||
|
if (!urlResult.ok) {
|
||||||
|
return (0, Result_1.Err)(urlResult.error);
|
||||||
|
}
|
||||||
|
const sessionResult = CrawlSession_1.CrawlSession.create({
|
||||||
|
url: request.url,
|
||||||
|
seed: request.seed,
|
||||||
|
maxStates: request.maxStates,
|
||||||
|
config: request.config,
|
||||||
|
});
|
||||||
|
if (!sessionResult.ok) {
|
||||||
|
return (0, Result_1.Err)(sessionResult.error);
|
||||||
|
}
|
||||||
|
const session = sessionResult.value;
|
||||||
|
await this.repository.save(session);
|
||||||
|
const events = session.domainEvents;
|
||||||
|
for (const event of events) {
|
||||||
|
await this.eventBus.publish(event);
|
||||||
|
}
|
||||||
|
session.clearEvents();
|
||||||
|
return (0, Result_1.Ok)({ sessionId: session.id.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.StartCrawlCommand = StartCrawlCommand;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.StopCrawlCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
class StopCrawlCommand {
|
||||||
|
constructor(repository, eventBus) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const id = UniqueId_1.UniqueId.from(request.sessionId);
|
||||||
|
const session = await this.repository.findById(id);
|
||||||
|
if (!session) {
|
||||||
|
return (0, Result_1.Err)('Session not found');
|
||||||
|
}
|
||||||
|
session.stop();
|
||||||
|
await this.repository.update(session);
|
||||||
|
const events = session.domainEvents;
|
||||||
|
for (const event of events) {
|
||||||
|
await this.eventBus.publish(event);
|
||||||
|
}
|
||||||
|
session.clearEvents();
|
||||||
|
return (0, Result_1.Ok)(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.StopCrawlCommand = StopCrawlCommand;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.GetSessionQuery = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
class GetSessionQuery {
|
||||||
|
constructor(repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const id = UniqueId_1.UniqueId.from(request.sessionId);
|
||||||
|
const session = await this.repository.findById(id);
|
||||||
|
if (!session) {
|
||||||
|
return (0, Result_1.Err)('Session not found');
|
||||||
|
}
|
||||||
|
const dto = {
|
||||||
|
id: session.id.toString(),
|
||||||
|
url: session.url,
|
||||||
|
status: session.status,
|
||||||
|
seed: session.seed,
|
||||||
|
maxStates: session.maxStates,
|
||||||
|
statesVisited: session.statesVisited,
|
||||||
|
config: session.config,
|
||||||
|
};
|
||||||
|
return (0, Result_1.Ok)(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.GetSessionQuery = GetSessionQuery;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ListSessionsQuery = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class ListSessionsQuery {
|
||||||
|
constructor(repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
async execute(_request) {
|
||||||
|
const sessions = await this.repository.findAll();
|
||||||
|
const dtos = sessions.map((session) => ({
|
||||||
|
id: session.id.toString(),
|
||||||
|
url: session.url,
|
||||||
|
status: session.status,
|
||||||
|
seed: session.seed,
|
||||||
|
maxStates: session.maxStates,
|
||||||
|
statesVisited: session.statesVisited,
|
||||||
|
config: session.config,
|
||||||
|
}));
|
||||||
|
return (0, Result_1.Ok)(dtos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ListSessionsQuery = ListSessionsQuery;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CrawlAction = void 0;
|
||||||
|
const Entity_1 = require("../../../../shared/domain/Entity");
|
||||||
|
class CrawlAction extends Entity_1.Entity {
|
||||||
|
constructor(props, id) {
|
||||||
|
super(props, id);
|
||||||
|
}
|
||||||
|
static create(props, id) {
|
||||||
|
return new CrawlAction(props, id);
|
||||||
|
}
|
||||||
|
get type() {
|
||||||
|
return this.props.type;
|
||||||
|
}
|
||||||
|
get selector() {
|
||||||
|
return this.props.selector;
|
||||||
|
}
|
||||||
|
get value() {
|
||||||
|
return this.props.value;
|
||||||
|
}
|
||||||
|
get seed() {
|
||||||
|
return this.props.seed;
|
||||||
|
}
|
||||||
|
get stateId() {
|
||||||
|
return this.props.stateId;
|
||||||
|
}
|
||||||
|
get sessionId() {
|
||||||
|
return this.props.sessionId;
|
||||||
|
}
|
||||||
|
get sequenceOrder() {
|
||||||
|
return this.props.sequenceOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CrawlAction = CrawlAction;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CrawlSession = void 0;
|
||||||
|
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const Url_1 = require("../value-objects/Url");
|
||||||
|
const CrawlStarted_1 = require("../events/CrawlStarted");
|
||||||
|
const CrawlCompleted_1 = require("../events/CrawlCompleted");
|
||||||
|
const CrawlFailed_1 = require("../events/CrawlFailed");
|
||||||
|
class CrawlSession extends AggregateRoot_1.AggregateRoot {
|
||||||
|
constructor(props, id) {
|
||||||
|
super(props, id);
|
||||||
|
}
|
||||||
|
/** Reconstruct from persistence without emitting domain events */
|
||||||
|
static reconstitute(props, id) {
|
||||||
|
return new CrawlSession(props, id);
|
||||||
|
}
|
||||||
|
static create(request) {
|
||||||
|
const urlResult = Url_1.Url.create(request.url);
|
||||||
|
if (!urlResult.ok) {
|
||||||
|
return (0, Result_1.Err)(urlResult.error);
|
||||||
|
}
|
||||||
|
const props = {
|
||||||
|
url: request.url,
|
||||||
|
status: 'running',
|
||||||
|
seed: request.seed,
|
||||||
|
maxStates: request.maxStates,
|
||||||
|
statesVisited: 0,
|
||||||
|
config: request.config ?? {},
|
||||||
|
};
|
||||||
|
const session = new CrawlSession(props);
|
||||||
|
session.addDomainEvent(new CrawlStarted_1.CrawlStarted(session.id.toString(), {
|
||||||
|
url: request.url,
|
||||||
|
seed: request.seed,
|
||||||
|
maxStates: request.maxStates,
|
||||||
|
}));
|
||||||
|
return (0, Result_1.Ok)(session);
|
||||||
|
}
|
||||||
|
get url() {
|
||||||
|
return this.props.url;
|
||||||
|
}
|
||||||
|
get status() {
|
||||||
|
return this.props.status;
|
||||||
|
}
|
||||||
|
get seed() {
|
||||||
|
return this.props.seed;
|
||||||
|
}
|
||||||
|
get maxStates() {
|
||||||
|
return this.props.maxStates;
|
||||||
|
}
|
||||||
|
get statesVisited() {
|
||||||
|
return this.props.statesVisited;
|
||||||
|
}
|
||||||
|
get config() {
|
||||||
|
return this.props.config;
|
||||||
|
}
|
||||||
|
incrementStatesVisited() {
|
||||||
|
this.props = { ...this.props, statesVisited: this.props.statesVisited + 1 };
|
||||||
|
}
|
||||||
|
complete() {
|
||||||
|
this.props = { ...this.props, status: 'completed' };
|
||||||
|
this.addDomainEvent(new CrawlCompleted_1.CrawlCompleted(this.id.toString(), {
|
||||||
|
url: this.props.url,
|
||||||
|
statesVisited: this.props.statesVisited,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
fail(reason) {
|
||||||
|
this.props = { ...this.props, status: 'failed' };
|
||||||
|
this.addDomainEvent(new CrawlFailed_1.CrawlFailed(this.id.toString(), {
|
||||||
|
url: this.props.url,
|
||||||
|
reason,
|
||||||
|
statesVisited: this.props.statesVisited,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this.props = { ...this.props, status: 'stopped' };
|
||||||
|
this.addDomainEvent(new CrawlCompleted_1.CrawlCompleted(this.id.toString(), {
|
||||||
|
url: this.props.url,
|
||||||
|
statesVisited: this.props.statesVisited,
|
||||||
|
stopped: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CrawlSession = CrawlSession;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CrawlState = void 0;
|
||||||
|
const Entity_1 = require("../../../../shared/domain/Entity");
|
||||||
|
class CrawlState extends Entity_1.Entity {
|
||||||
|
constructor(props, id) {
|
||||||
|
super(props, id);
|
||||||
|
}
|
||||||
|
static create(props, id) {
|
||||||
|
return new CrawlState(props, id);
|
||||||
|
}
|
||||||
|
get url() {
|
||||||
|
return this.props.url;
|
||||||
|
}
|
||||||
|
get title() {
|
||||||
|
return this.props.title;
|
||||||
|
}
|
||||||
|
get domSnapshot() {
|
||||||
|
return this.props.domSnapshot;
|
||||||
|
}
|
||||||
|
get visitCount() {
|
||||||
|
return this.props.visitCount;
|
||||||
|
}
|
||||||
|
get stateId() {
|
||||||
|
return this.props.stateId;
|
||||||
|
}
|
||||||
|
get sessionId() {
|
||||||
|
return this.props.sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CrawlState = CrawlState;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ActionExecuted = void 0;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class ActionExecuted {
|
||||||
|
constructor(aggregateId, payload) {
|
||||||
|
this.aggregateId = aggregateId;
|
||||||
|
this.payload = payload;
|
||||||
|
this.eventId = (0, crypto_1.randomUUID)();
|
||||||
|
this.eventName = 'crawl.action_executed';
|
||||||
|
this.occurredOn = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ActionExecuted = ActionExecuted;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CrawlCompleted = void 0;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class CrawlCompleted {
|
||||||
|
constructor(aggregateId, payload) {
|
||||||
|
this.aggregateId = aggregateId;
|
||||||
|
this.payload = payload;
|
||||||
|
this.eventId = (0, crypto_1.randomUUID)();
|
||||||
|
this.eventName = 'crawl.completed';
|
||||||
|
this.occurredOn = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CrawlCompleted = CrawlCompleted;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CrawlFailed = void 0;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class CrawlFailed {
|
||||||
|
constructor(aggregateId, payload) {
|
||||||
|
this.aggregateId = aggregateId;
|
||||||
|
this.payload = payload;
|
||||||
|
this.eventId = (0, crypto_1.randomUUID)();
|
||||||
|
this.eventName = 'crawl.failed';
|
||||||
|
this.occurredOn = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CrawlFailed = CrawlFailed;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CrawlStarted = void 0;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class CrawlStarted {
|
||||||
|
constructor(aggregateId, payload) {
|
||||||
|
this.aggregateId = aggregateId;
|
||||||
|
this.payload = payload;
|
||||||
|
this.eventId = (0, crypto_1.randomUUID)();
|
||||||
|
this.eventName = 'crawl.started';
|
||||||
|
this.occurredOn = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CrawlStarted = CrawlStarted;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.StateDiscovered = void 0;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class StateDiscovered {
|
||||||
|
constructor(aggregateId, payload) {
|
||||||
|
this.aggregateId = aggregateId;
|
||||||
|
this.payload = payload;
|
||||||
|
this.eventId = (0, crypto_1.randomUUID)();
|
||||||
|
this.eventName = 'crawl.state_discovered';
|
||||||
|
this.occurredOn = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.StateDiscovered = StateDiscovered;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.Selector = void 0;
|
||||||
|
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class Selector extends ValueObject_1.ValueObject {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
static create(raw) {
|
||||||
|
if (!raw || raw.trim().length === 0) {
|
||||||
|
return (0, Result_1.Err)('Selector must not be empty');
|
||||||
|
}
|
||||||
|
return (0, Result_1.Ok)(new Selector({ value: raw.trim() }));
|
||||||
|
}
|
||||||
|
toString() {
|
||||||
|
return this.props.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.Selector = Selector;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.SessionStatus = void 0;
|
||||||
|
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const VALID_STATUSES = ['running', 'completed', 'failed', 'stopped'];
|
||||||
|
class SessionStatus extends ValueObject_1.ValueObject {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
static create(val) {
|
||||||
|
if (!VALID_STATUSES.includes(val)) {
|
||||||
|
return (0, Result_1.Err)(`Invalid session status: "${val}". Must be one of: ${VALID_STATUSES.join(', ')}`);
|
||||||
|
}
|
||||||
|
return (0, Result_1.Ok)(new SessionStatus({ value: val }));
|
||||||
|
}
|
||||||
|
getValue() {
|
||||||
|
return this.props.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.SessionStatus = SessionStatus;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.Url = void 0;
|
||||||
|
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class Url extends ValueObject_1.ValueObject {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
static create(raw) {
|
||||||
|
if (!raw || raw.trim().length === 0) {
|
||||||
|
return (0, Result_1.Err)('URL must not be empty');
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
||||||
|
return (0, Result_1.Err)('URL must start with http:// or https://');
|
||||||
|
}
|
||||||
|
return (0, Result_1.Ok)(new Url({ value: trimmed }));
|
||||||
|
}
|
||||||
|
toString() {
|
||||||
|
return this.props.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.Url = Url;
|
||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"use strict";
|
||||||
|
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 __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||||
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
__exportStar(require("./domain/entities/CrawlSession"), exports);
|
||||||
|
__exportStar(require("./domain/entities/CrawlState"), exports);
|
||||||
|
__exportStar(require("./domain/entities/CrawlAction"), exports);
|
||||||
|
__exportStar(require("./domain/ports/ICrawlerEngine"), exports);
|
||||||
|
__exportStar(require("./domain/ports/ICrawlSessionRepository"), exports);
|
||||||
|
__exportStar(require("./domain/ports/IStateRepository"), exports);
|
||||||
|
__exportStar(require("./application/commands/StartCrawlCommand"), exports);
|
||||||
|
__exportStar(require("./application/commands/StopCrawlCommand"), exports);
|
||||||
|
__exportStar(require("./application/queries/GetSessionQuery"), exports);
|
||||||
|
__exportStar(require("./application/queries/ListSessionsQuery"), exports);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CrawlingStateGraph = void 0;
|
||||||
|
class CrawlingStateGraph {
|
||||||
|
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 {
|
||||||
|
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() });
|
||||||
|
}
|
||||||
|
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.CrawlingStateGraph = CrawlingStateGraph;
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ExplorationOrchestrator = void 0;
|
||||||
|
const AnomalyDetector_1 = require("../../../../core/AnomalyDetector");
|
||||||
|
const Logger_1 = require("../../../../core/Logger");
|
||||||
|
class ExplorationOrchestrator {
|
||||||
|
constructor(config) {
|
||||||
|
this.actionTrace = [];
|
||||||
|
this.aborted = false;
|
||||||
|
this.graph = config.graph;
|
||||||
|
this.engine = config.engine;
|
||||||
|
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 ?? [];
|
||||||
|
}
|
||||||
|
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.engine.launch(this.url);
|
||||||
|
const initialState = await this.engine.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;
|
||||||
|
this.graph.incrementVisit(currentState.id);
|
||||||
|
const actions = await this.engine.discoverActions(currentState);
|
||||||
|
if (actions.length === 0)
|
||||||
|
continue;
|
||||||
|
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());
|
||||||
|
const observation = await this.engine.executeAction(action);
|
||||||
|
this.actionTrace.push(action);
|
||||||
|
if (!this.graph.hasState(observation.newStateId)) {
|
||||||
|
const newState = await this.engine.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);
|
||||||
|
for (const hook of this.stateHooks) {
|
||||||
|
const hookAnomalies = await hook(newState, this.engine, 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,
|
||||||
|
});
|
||||||
|
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.engine);
|
||||||
|
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;
|
||||||
|
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.engine.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.engine);
|
||||||
|
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.engine.close().catch(() => undefined);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await this.engine.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.ExplorationOrchestrator = ExplorationOrchestrator;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.PlaywrightCrawlerEngine = void 0;
|
||||||
|
/**
|
||||||
|
* PlaywrightCrawlerEngine — adapts PlaywrightAgent to implement the ICrawlerEngine port.
|
||||||
|
*/
|
||||||
|
const PlaywrightAgent_1 = require("../../../../plugins/agents/PlaywrightAgent");
|
||||||
|
class PlaywrightCrawlerEngine extends PlaywrightAgent_1.PlaywrightAgent {
|
||||||
|
constructor(config = {}) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.PlaywrightCrawlerEngine = PlaywrightCrawlerEngine;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createCrawlingRouter = createCrawlingRouter;
|
||||||
|
/**
|
||||||
|
* CrawlingController — thin Express controller for crawling routes.
|
||||||
|
* Delegates to use cases; returns Result-based responses.
|
||||||
|
*/
|
||||||
|
const express_1 = require("express");
|
||||||
|
function createCrawlingRouter(deps) {
|
||||||
|
const router = (0, express_1.Router)();
|
||||||
|
// POST /api/sessions — start a new crawl session
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const body = req.body;
|
||||||
|
const { url, seed = 42, maxStates = 50, config } = body;
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
res.status(400).json({ error: 'url is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await deps.startCrawl.execute({ url, seed, maxStates, config });
|
||||||
|
if (!result.ok) {
|
||||||
|
res.status(422).json({ error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(201).json(result.value);
|
||||||
|
});
|
||||||
|
// GET /api/sessions — list all sessions
|
||||||
|
router.get('/', async (_req, res) => {
|
||||||
|
const result = await deps.listSessions.execute({});
|
||||||
|
if (!result.ok) {
|
||||||
|
res.status(500).json({ error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result.value);
|
||||||
|
});
|
||||||
|
// GET /api/sessions/:id — session detail
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const sessionId = req.params['id'];
|
||||||
|
const result = await deps.getSession.execute({ sessionId });
|
||||||
|
if (!result.ok) {
|
||||||
|
res.status(404).json({ error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result.value);
|
||||||
|
});
|
||||||
|
// DELETE /api/sessions/:id — stop a session
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const sessionId = req.params['id'];
|
||||||
|
const result = await deps.stopCrawl.execute({ sessionId });
|
||||||
|
if (!result.ok) {
|
||||||
|
res.status(404).json({ error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ stopped: true });
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.KyselyCrawlSessionRepository = void 0;
|
||||||
|
const CrawlSession_1 = require("../../domain/entities/CrawlSession");
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
class KyselyCrawlSessionRepository {
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
async save(session) {
|
||||||
|
const row = {
|
||||||
|
id: session.id.toString(),
|
||||||
|
url: session.url,
|
||||||
|
status: session.status,
|
||||||
|
seed: session.seed,
|
||||||
|
max_states: session.maxStates,
|
||||||
|
states_visited: session.statesVisited,
|
||||||
|
anomalies_found: 0,
|
||||||
|
started_at: Date.now(),
|
||||||
|
finished_at: null,
|
||||||
|
config_json: JSON.stringify(session.config),
|
||||||
|
};
|
||||||
|
await this.db
|
||||||
|
.insertInto('sessions')
|
||||||
|
.values(row)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
async findById(id) {
|
||||||
|
const row = await this.db
|
||||||
|
.selectFrom('sessions')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', '=', id.toString())
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!row)
|
||||||
|
return null;
|
||||||
|
return this.toDomain(row);
|
||||||
|
}
|
||||||
|
async findAll() {
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom('sessions')
|
||||||
|
.selectAll()
|
||||||
|
.orderBy('started_at', 'desc')
|
||||||
|
.execute();
|
||||||
|
return rows.map((row) => this.toDomain(row));
|
||||||
|
}
|
||||||
|
async update(session) {
|
||||||
|
const isTerminal = session.status === 'completed' || session.status === 'failed' || session.status === 'stopped';
|
||||||
|
await this.db
|
||||||
|
.updateTable('sessions')
|
||||||
|
.set({
|
||||||
|
status: session.status,
|
||||||
|
states_visited: session.statesVisited,
|
||||||
|
finished_at: isTerminal ? Date.now() : null,
|
||||||
|
config_json: JSON.stringify(session.config),
|
||||||
|
})
|
||||||
|
.where('id', '=', session.id.toString())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
toDomain(row) {
|
||||||
|
const props = {
|
||||||
|
url: row.url,
|
||||||
|
status: row.status,
|
||||||
|
seed: row.seed,
|
||||||
|
maxStates: row.max_states,
|
||||||
|
statesVisited: row.states_visited,
|
||||||
|
config: this.parseJson(row.config_json),
|
||||||
|
};
|
||||||
|
return CrawlSession_1.CrawlSession.reconstitute(props, UniqueId_1.UniqueId.from(row.id));
|
||||||
|
}
|
||||||
|
parseJson(json) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(json);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.KyselyCrawlSessionRepository = KyselyCrawlSessionRepository;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.KyselyStateRepository = void 0;
|
||||||
|
const CrawlState_1 = require("../../domain/entities/CrawlState");
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
class KyselyStateRepository {
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
async save(state) {
|
||||||
|
const row = {
|
||||||
|
id: state.id.toString(),
|
||||||
|
session_id: state.sessionId,
|
||||||
|
url: state.url,
|
||||||
|
title: state.title,
|
||||||
|
dom_snapshot_path: null,
|
||||||
|
visit_count: state.visitCount,
|
||||||
|
discovered_at: Date.now(),
|
||||||
|
};
|
||||||
|
await this.db
|
||||||
|
.insertInto('states')
|
||||||
|
.values(row)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
async findById(id) {
|
||||||
|
const row = await this.db
|
||||||
|
.selectFrom('states')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', '=', id.toString())
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!row)
|
||||||
|
return null;
|
||||||
|
return this.toDomain(row);
|
||||||
|
}
|
||||||
|
async findAll() {
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom('states')
|
||||||
|
.selectAll()
|
||||||
|
.execute();
|
||||||
|
return rows.map((row) => this.toDomain(row));
|
||||||
|
}
|
||||||
|
async findBySessionId(sessionId) {
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom('states')
|
||||||
|
.selectAll()
|
||||||
|
.where('session_id', '=', sessionId)
|
||||||
|
.execute();
|
||||||
|
return rows.map((row) => this.toDomain(row));
|
||||||
|
}
|
||||||
|
async update(state) {
|
||||||
|
await this.db
|
||||||
|
.updateTable('states')
|
||||||
|
.set({
|
||||||
|
visit_count: state.visitCount,
|
||||||
|
url: state.url,
|
||||||
|
title: state.title,
|
||||||
|
})
|
||||||
|
.where('id', '=', state.id.toString())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
toDomain(row) {
|
||||||
|
return CrawlState_1.CrawlState.create({
|
||||||
|
url: row.url,
|
||||||
|
title: row.title,
|
||||||
|
domSnapshot: '',
|
||||||
|
visitCount: row.visit_count,
|
||||||
|
stateId: row.id,
|
||||||
|
sessionId: row.session_id,
|
||||||
|
}, UniqueId_1.UniqueId.from(row.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.KyselyStateRepository = KyselyStateRepository;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CreateFindingCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const Finding_1 = require("../../domain/entities/Finding");
|
||||||
|
const Severity_1 = require("../../domain/value-objects/Severity");
|
||||||
|
const FindingType_1 = require("../../domain/value-objects/FindingType");
|
||||||
|
const Evidence_1 = require("../../domain/value-objects/Evidence");
|
||||||
|
class CreateFindingCommand {
|
||||||
|
constructor(repository, eventBus) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
let severity;
|
||||||
|
let type;
|
||||||
|
try {
|
||||||
|
severity = Severity_1.Severity.fromString(request.anomaly.severity);
|
||||||
|
type = FindingType_1.FindingType.fromString(request.anomaly.type);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return (0, Result_1.Err)(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
const evidence = Evidence_1.Evidence.create({
|
||||||
|
screenshotPath: request.anomaly.evidence.screenshotPath,
|
||||||
|
domSnapshotPath: request.anomaly.evidence.domSnapshotPath,
|
||||||
|
httpLog: request.anomaly.evidence.httpLog,
|
||||||
|
rawErrors: request.anomaly.evidence.rawErrors,
|
||||||
|
});
|
||||||
|
const finding = Finding_1.Finding.create({
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
severity,
|
||||||
|
type,
|
||||||
|
description: request.anomaly.description,
|
||||||
|
evidence,
|
||||||
|
actionTrace: request.anomaly.actionTrace,
|
||||||
|
browser: request.anomaly.browser,
|
||||||
|
browserVersion: request.anomaly.browserVersion,
|
||||||
|
aiEnrichment: request.anomaly.aiEnrichment,
|
||||||
|
});
|
||||||
|
await this.repository.save(finding);
|
||||||
|
const events = finding.domainEvents;
|
||||||
|
for (const event of events) {
|
||||||
|
await this.eventBus.publish(event);
|
||||||
|
}
|
||||||
|
finding.clearEvents();
|
||||||
|
return (0, Result_1.Ok)({ findingId: finding.id.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CreateFindingCommand = CreateFindingCommand;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.EnrichFindingCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class EnrichFindingCommand {
|
||||||
|
constructor(repository, enricher, eventBus) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.enricher = enricher;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const finding = await this.repository.findById(request.findingId);
|
||||||
|
if (!finding) {
|
||||||
|
return (0, Result_1.Err)(`Finding not found: ${request.findingId}`);
|
||||||
|
}
|
||||||
|
let enrichment;
|
||||||
|
try {
|
||||||
|
enrichment = await this.enricher.enrich(finding);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return (0, Result_1.Err)(`Enrichment failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
finding.enrich(enrichment);
|
||||||
|
await this.repository.update(finding);
|
||||||
|
const events = finding.domainEvents;
|
||||||
|
for (const event of events) {
|
||||||
|
await this.eventBus.publish(event);
|
||||||
|
}
|
||||||
|
finding.clearEvents();
|
||||||
|
return (0, Result_1.Ok)({ findingId: finding.id.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.EnrichFindingCommand = EnrichFindingCommand;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ResolveFindingCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class ResolveFindingCommand {
|
||||||
|
constructor(repository, eventBus) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const finding = await this.repository.findById(request.findingId);
|
||||||
|
if (!finding) {
|
||||||
|
return (0, Result_1.Err)(`Finding not found: ${request.findingId}`);
|
||||||
|
}
|
||||||
|
switch (request.action) {
|
||||||
|
case 'resolve':
|
||||||
|
finding.resolve();
|
||||||
|
break;
|
||||||
|
case 'close':
|
||||||
|
finding.close();
|
||||||
|
break;
|
||||||
|
case 'investigate':
|
||||||
|
finding.investigate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await this.repository.update(finding);
|
||||||
|
const events = finding.domainEvents;
|
||||||
|
for (const event of events) {
|
||||||
|
await this.eventBus.publish(event);
|
||||||
|
}
|
||||||
|
finding.clearEvents();
|
||||||
|
return (0, Result_1.Ok)({ findingId: finding.id.toString(), status: finding.status.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ResolveFindingCommand = ResolveFindingCommand;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.OnAnomalyDetected = void 0;
|
||||||
|
/**
|
||||||
|
* Listens for anomaly_detected events from crawling module
|
||||||
|
* and creates a Finding in the findings module.
|
||||||
|
*/
|
||||||
|
class OnAnomalyDetected {
|
||||||
|
constructor(createFinding) {
|
||||||
|
this.createFinding = createFinding;
|
||||||
|
}
|
||||||
|
async handle(event) {
|
||||||
|
const payload = event.payload;
|
||||||
|
if (!payload.anomaly || !payload.sessionId)
|
||||||
|
return;
|
||||||
|
await this.createFinding.execute({
|
||||||
|
anomaly: payload.anomaly,
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.OnAnomalyDetected = OnAnomalyDetected;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.FindingStatsQuery = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class FindingStatsQuery {
|
||||||
|
constructor(repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const [total, bySeverity, openCount, resolvedCount] = await Promise.all([
|
||||||
|
this.repository.count(request.sessionId ? { sessionId: request.sessionId } : undefined),
|
||||||
|
this.repository.countBySeverity(),
|
||||||
|
this.repository.count({ status: 'open', sessionId: request.sessionId }),
|
||||||
|
this.repository.count({ status: 'resolved', sessionId: request.sessionId }),
|
||||||
|
]);
|
||||||
|
return (0, Result_1.Ok)({ total, bySeverity, openCount, resolvedCount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.FindingStatsQuery = FindingStatsQuery;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.GetFindingQuery = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class GetFindingQuery {
|
||||||
|
constructor(repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const finding = await this.repository.findById(request.findingId);
|
||||||
|
if (!finding) {
|
||||||
|
return (0, Result_1.Err)(`Finding not found: ${request.findingId}`);
|
||||||
|
}
|
||||||
|
return (0, Result_1.Ok)(finding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.GetFindingQuery = GetFindingQuery;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ListFindingsQuery = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class ListFindingsQuery {
|
||||||
|
constructor(repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const filters = {
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
severity: request.severity,
|
||||||
|
type: request.type,
|
||||||
|
status: request.status,
|
||||||
|
search: request.search,
|
||||||
|
};
|
||||||
|
const findings = await this.repository.findAll(filters);
|
||||||
|
const total = await this.repository.count(filters);
|
||||||
|
return (0, Result_1.Ok)({ findings, total });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ListFindingsQuery = ListFindingsQuery;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.Finding = void 0;
|
||||||
|
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
const FindingStatus_1 = require("../value-objects/FindingStatus");
|
||||||
|
const FindingCreated_1 = require("../events/FindingCreated");
|
||||||
|
const FindingResolved_1 = require("../events/FindingResolved");
|
||||||
|
const FindingEnriched_1 = require("../events/FindingEnriched");
|
||||||
|
class Finding extends AggregateRoot_1.AggregateRoot {
|
||||||
|
static create(props, id) {
|
||||||
|
const findingId = id ?? UniqueId_1.UniqueId.create();
|
||||||
|
const finding = new Finding({
|
||||||
|
...props,
|
||||||
|
status: FindingStatus_1.FindingStatus.open(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
}, findingId);
|
||||||
|
finding.addDomainEvent(new FindingCreated_1.FindingCreated(findingId.toString(), {
|
||||||
|
sessionId: props.sessionId,
|
||||||
|
severity: props.severity.value,
|
||||||
|
type: props.type.value,
|
||||||
|
description: props.description,
|
||||||
|
}));
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
static reconstitute(props, id) {
|
||||||
|
return new Finding(props, id);
|
||||||
|
}
|
||||||
|
get sessionId() { return this.props.sessionId; }
|
||||||
|
get severity() { return this.props.severity; }
|
||||||
|
get type() { return this.props.type; }
|
||||||
|
get description() { return this.props.description; }
|
||||||
|
get evidence() { return this.props.evidence; }
|
||||||
|
get status() { return this.props.status; }
|
||||||
|
get actionTrace() { return this.props.actionTrace; }
|
||||||
|
get browser() { return this.props.browser; }
|
||||||
|
get browserVersion() { return this.props.browserVersion; }
|
||||||
|
get aiEnrichment() { return this.props.aiEnrichment; }
|
||||||
|
get createdAt() { return this.props.createdAt; }
|
||||||
|
get resolvedAt() { return this.props.resolvedAt; }
|
||||||
|
resolve() {
|
||||||
|
this.props.status = FindingStatus_1.FindingStatus.resolved();
|
||||||
|
this.props.resolvedAt = new Date();
|
||||||
|
this.addDomainEvent(new FindingResolved_1.FindingResolved(this.id.toString(), {
|
||||||
|
sessionId: this.props.sessionId,
|
||||||
|
resolvedAt: this.props.resolvedAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.props.status = FindingStatus_1.FindingStatus.closed();
|
||||||
|
}
|
||||||
|
investigate() {
|
||||||
|
this.props.status = FindingStatus_1.FindingStatus.investigating();
|
||||||
|
}
|
||||||
|
enrich(enrichment) {
|
||||||
|
this.props.aiEnrichment = enrichment;
|
||||||
|
this.addDomainEvent(new FindingEnriched_1.FindingEnriched(this.id.toString(), {
|
||||||
|
provider: enrichment.provider,
|
||||||
|
model: enrichment.model,
|
||||||
|
confidence: enrichment.confidence,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.Finding = Finding;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.FindingCreated = void 0;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class FindingCreated {
|
||||||
|
constructor(aggregateId, payload) {
|
||||||
|
this.aggregateId = aggregateId;
|
||||||
|
this.payload = payload;
|
||||||
|
this.eventId = (0, crypto_1.randomUUID)();
|
||||||
|
this.eventName = 'finding.created';
|
||||||
|
this.occurredOn = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.FindingCreated = FindingCreated;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.FindingEnriched = void 0;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class FindingEnriched {
|
||||||
|
constructor(aggregateId, payload) {
|
||||||
|
this.aggregateId = aggregateId;
|
||||||
|
this.payload = payload;
|
||||||
|
this.eventId = (0, crypto_1.randomUUID)();
|
||||||
|
this.eventName = 'finding.enriched';
|
||||||
|
this.occurredOn = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.FindingEnriched = FindingEnriched;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.FindingResolved = void 0;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class FindingResolved {
|
||||||
|
constructor(aggregateId, payload) {
|
||||||
|
this.aggregateId = aggregateId;
|
||||||
|
this.payload = payload;
|
||||||
|
this.eventId = (0, crypto_1.randomUUID)();
|
||||||
|
this.eventName = 'finding.resolved';
|
||||||
|
this.occurredOn = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.FindingResolved = FindingResolved;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.Evidence = void 0;
|
||||||
|
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
|
||||||
|
class Evidence extends ValueObject_1.ValueObject {
|
||||||
|
static create(props) {
|
||||||
|
return new Evidence(props);
|
||||||
|
}
|
||||||
|
static empty() {
|
||||||
|
return new Evidence({});
|
||||||
|
}
|
||||||
|
get screenshotPath() { return this.props.screenshotPath; }
|
||||||
|
get domSnapshotPath() { return this.props.domSnapshotPath; }
|
||||||
|
get httpLog() { return this.props.httpLog ?? []; }
|
||||||
|
get rawErrors() { return this.props.rawErrors ?? []; }
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
screenshotPath: this.props.screenshotPath,
|
||||||
|
domSnapshotPath: this.props.domSnapshotPath,
|
||||||
|
httpLog: this.props.httpLog,
|
||||||
|
rawErrors: this.props.rawErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.Evidence = Evidence;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.FindingStatus = void 0;
|
||||||
|
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
|
||||||
|
class FindingStatus extends ValueObject_1.ValueObject {
|
||||||
|
static open() { return new FindingStatus({ value: 'open' }); }
|
||||||
|
static investigating() { return new FindingStatus({ value: 'investigating' }); }
|
||||||
|
static resolved() { return new FindingStatus({ value: 'resolved' }); }
|
||||||
|
static closed() { return new FindingStatus({ value: 'closed' }); }
|
||||||
|
static fromString(s) {
|
||||||
|
if (!FindingStatus.VALUES.includes(s)) {
|
||||||
|
throw new Error(`Invalid finding status: ${s}`);
|
||||||
|
}
|
||||||
|
return new FindingStatus({ value: s });
|
||||||
|
}
|
||||||
|
get value() { return this.props.value; }
|
||||||
|
isOpen() { return this.props.value === 'open'; }
|
||||||
|
isResolved() { return this.props.value === 'resolved'; }
|
||||||
|
}
|
||||||
|
exports.FindingStatus = FindingStatus;
|
||||||
|
FindingStatus.VALUES = ['open', 'investigating', 'resolved', 'closed'];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user