Compare commits

...

7 Commits

Author SHA1 Message Date
debian d62bd615bf fase(5): findings module complete
ABE Exploratory Testing / explore (push) Has been cancelled
2026-03-05 04:06:45 -05:00
debian 96bf6e5097 fase(4): crawling infrastructure with migrated code 2026-03-05 03:08:48 -05:00
debian 39c5313ba5 fase(3): crawling module domain and application 2026-03-04 16:32:09 -05:00
debian 4a58749048 fase(2): shared infrastructure layer 2026-03-04 16:26:32 -05:00
debian 0e6c0c3655 fase(1): shared domain building blocks 2026-03-04 16:22:42 -05:00
debian 2a93f1f5b7 fase(0): fix build errors — build passes with 0 errors 2026-03-04 16:18:52 -05:00
debian f8191133c8 docs: enterprise refactor plan with ralph specs 2026-03-04 16:17:03 -05:00
346 changed files with 38882 additions and 459 deletions
+36
View File
@@ -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": []
}
}
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
logs
reports
data
.ralph
tests
frontend
.git
*.log
+12
View File
@@ -0,0 +1,12 @@
ABE_API_KEY=change-me-in-production
ABE_CORS_ORIGIN=http://localhost:5173
ABE_PORT=3001
ABE_DB_PATH=./data/abe.db
ABE_REPORTS_DIR=./reports
ABE_LOGS_DIR=./logs
ABE_MAX_CONCURRENT_SESSIONS=3
ABE_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
ABE_WEBHOOK_URL=https://myapp.com/webhooks/abe
ABE_NOTIFY_MIN_SEVERITY=high
ABE_LOG_LEVEL=info
NODE_ENV=production
+48
View File
@@ -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
+3
View File
@@ -16,6 +16,9 @@
.ralph/docs/generated/*
!.ralph/docs/generated/.gitkeep
# Environment
.env
# General logs
*.log
+1
View File
@@ -0,0 +1 @@
96bf6e50979e4cc152e9715b965f27eeb2decbc1
+48 -137
View File
@@ -1,158 +1,69 @@
# Agent Build Instructions
# ABE — Build, Test & Development Commands
## Project Setup
## Install dependencies
```bash
# Install dependencies (example for Node.js project)
npm install
# Or for Python project
pip install -r requirements.txt
# Or for Rust project
cargo build
cd frontend && npm install && cd ..
```
## Running Tests
## Build (backend)
```bash
# Node.js
npm test
# Python
pytest
# Rust
cargo test
```
## Build Commands
```bash
# Production build
npm run build
# or
cargo build --release
```
## Development Server
## Build (frontend)
```bash
# Start development server
npm run dev
# or
cargo run
cd frontend && npm run build
```
## Key Learnings
- Update this section when you learn new build optimizations
- Document any gotchas or special setup requirements
- Keep track of the fastest test/build cycle
## Test
```bash
npm run test
```
## 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
- **Test Pass Rate**: 100% - all tests must pass, no exceptions
- **Test Types Required**:
- 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
## Database
```bash
npm run db:migrate # ejecutar migraciones Kysely
```
### 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**:
```bash
git add .
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
## Commit después de tarea completada
```bash
git add -A && git commit -m "fase(X.Y): descripción"
```
2. **Pushed to Remote Repository**:
```bash
git push origin <branch-name>
```
- Never leave completed features uncommitted
- Push regularly to maintain backup and enable collaboration
- Ensure CI/CD pipelines pass before considering feature complete
## Notas
- Source code: src/
- Frontend: frontend/
- Tests: junto al código (*.test.ts) o en tests/
- Reports output: reports/
- Logs: logs/
- Database: data/abe.db
3. **Branch Hygiene**:
- Work on feature branches, never directly on `main`
- Branch naming convention: `feature/<feature-name>`, `fix/<issue-name>`, `docs/<doc-update>`
- Create pull requests for all significant changes
4. **Ralph Integration**:
- Update .ralph/fix_plan.md with new tasks before starting work
- Mark items complete in .ralph/fix_plan.md upon completion
- Update .ralph/PROMPT.md if development patterns change
- Test features work within Ralph's autonomous loop
### Documentation Requirements
**ALL implementation documentation MUST remain synchronized with the codebase**:
1. **Code Documentation**:
- Language-appropriate documentation (JSDoc, docstrings, etc.)
- Update inline comments when implementation changes
- Remove outdated comments immediately
2. **Implementation Documentation**:
- Update relevant sections in this AGENT.md file
- Keep build and test commands current
- Update configuration examples when defaults change
- Document breaking changes prominently
3. **README Updates**:
- Keep feature lists current
- Update setup instructions when dependencies change
- Maintain accurate command examples
- Update version compatibility information
4. **AGENT.md Maintenance**:
- Add new build patterns to relevant sections
- Update "Key Learnings" with new insights
- Keep command examples accurate and tested
- Document new testing patterns or quality gates
### Feature Completion Checklist
Before marking ANY feature as complete, verify:
- [ ] All tests pass with appropriate framework command
- [ ] Code coverage meets 85% minimum threshold
- [ ] Coverage report reviewed for meaningful test quality
- [ ] Code formatted according to project standards
- [ ] Type checking passes (if applicable)
- [ ] All changes committed with conventional commit messages
- [ ] All commits pushed to remote repository
- [ ] .ralph/fix_plan.md task marked as complete
- [ ] Implementation documentation updated
- [ ] Inline code comments updated or added
- [ ] .ralph/AGENT.md updated (if new patterns introduced)
- [ ] Breaking changes documented
- [ ] Features tested within Ralph loop (if applicable)
- [ ] CI/CD pipeline passes
### Rationale
These standards ensure:
- **Quality**: High test coverage and pass rates prevent regressions
- **Traceability**: Git commits and .ralph/fix_plan.md provide clear history of changes
- **Maintainability**: Current documentation reduces onboarding time and prevents knowledge loss
- **Collaboration**: Pushed changes enable team visibility and code review
- **Reliability**: Consistent quality gates maintain production stability
- **Automation**: Ralph integration ensures continuous development practices
**Enforcement**: AI agents should automatically apply these standards to all feature development tasks without requiring explicit instruction for each task.
+148 -262
View File
@@ -1,296 +1,182 @@
# Ralph Development Instructions
## 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
# ABE — Autonomous Bug Explorer
## Instrucciones Maestras para Claude Code (via Ralph)
---
### Scenario 2: Test-Only Loop Detected
**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
## Visión del proyecto
**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:
```
---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---
```
**Posicionamiento**: "Playwright discovers what you test. ABE discovers what you miss."
**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
**Given**:
- Same error appears in last 5 consecutive loops
- No progress on fixing the error
- Error message is identical or very similar
## Estado actual del código
**When**: You encounter the same error again
**Then**: You must output:
Las fases 1-11 del proyecto original están implementadas con esta estructura:
```
---RALPH_STATUS---
STATUS: BLOCKED
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 2
TESTS_STATUS: FAILING
WORK_TYPE: DEBUGGING
EXIT_SIGNAL: false
RECOMMENDATION: Stuck on [error description] - human intervention needed
---END_RALPH_STATUS---
src/
├── core/ ← interfaces.ts, ExplorationEngine, StateGraph, AnomalyDetector
├── plugins/ ← PlaywrightAgent, collectors, exporters, fuzzers, reproducers
├── server/ ← Express API server + socket.io
├── db/ ← SQLite repositories (better-sqlite3)
├── cli.ts ← CLI entry point
frontend/ ← React + Vite + Tailwind (básico)
```
**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
**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
## Arquitectura objetivo: Modular Monolith Hexagonal
**When**: You search for work to do and find none
**Then**: You must output:
### Principio fundamental
```
---RALPH_STATUS---
STATUS: COMPLETE
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---
Infrastructure → Application → Domain
(el código SIEMPRE apunta hacia adentro, nunca al revés)
```
**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
**Given**:
- Tasks remain in .ralph/fix_plan.md
- Implementation is underway
- Files are being modified
- Tests are passing or being fixed
## Reglas de arquitectura INQUEBRANTABLES
**When**: You complete a task successfully
**Then**: You must output:
```
---RALPH_STATUS---
STATUS: IN_PROGRESS
TASKS_COMPLETED_THIS_LOOP: 3
FILES_MODIFIED: 7
TESTS_STATUS: PASSING
WORK_TYPE: IMPLEMENTATION
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)
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.
3. **Cada módulo exporta SOLO su facade** via `index.ts`.
4. **Controllers son THIN** — parsean request, llaman use case, formatean response.
5. **Use Cases retornan Result<T, E>** — NUNCA throw para errores de negocio.
6. **Un archivo = una clase = una responsabilidad**.
7. **Determinista** — no usar Math.random() sin seed. Loguear siempre el seed.
8. **Serializable** — entities y value objects JSON.stringify-able.
9. **No AI en el core loop** — AIEnrichment es post-proceso opcional.
10. **Plugins nunca se importan desde core** — core solo define interfaces/ports.
---
### Scenario 6: Blocked on External Dependency
**Given**:
- Task requires external API, library, or human decision
- Cannot proceed without missing information
- Have tried reasonable workarounds
## Stack tecnológico
**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:
```
---RALPH_STATUS---
STATUS: BLOCKED
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 0
TESTS_STATUS: NOT_RUN
WORK_TYPE: IMPLEMENTATION
EXIT_SIGNAL: false
RECOMMENDATION: Blocked on [specific dependency] - need [what's needed]
---END_RALPH_STATUS---
```
**Ralph's Action**: Logs blocker, may exit after multiple blocked loops
### Frontend
- React 18 + Vite + TypeScript
- shadcn/ui (Radix UI + Tailwind CSS)
- Tremor + Recharts (charts/dashboards)
- TanStack Table + TanStack Query
- Zustand (client state)
- React Hook Form + Zod resolver
- socket.io-client
- Framer Motion
---
## File Structure
- .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
## REGLAS OBLIGATORIAS PARA CADA TAREA
## Current Task
Follow .ralph/fix_plan.md and choose the most important item to implement next.
Use your judgment to prioritize what will have the biggest impact on project progress.
### Antes de empezar
1. Leer la tarea actual del fix_plan.md
2. Leer la spec correspondiente en .ralph/specs/ SI existe
3. Verificar que las dependencias (tareas previas) están completas
Remember: Quality over speed. Build it right the first time. Know when you're done.
### Después de CADA tarea individual
1. `npm run build` — DEBE compilar sin errores
2. `cd frontend && npm run build` — DEBE compilar sin errores
3. `npm run test` — DEBE pasar (o no romper tests existentes)
4. Si ALGUNO falla → NO marcar tarea → arreglar PRIMERO
### Después de marcar tarea como completa
1. `git add -A`
2. `git commit -m "fase(X.Y): descripción breve de la tarea"`
Ejemplo: `git commit -m "fase(1.3): create ValueObject base class with equals"`
3. Verificar que el commit se hizo correctamente
### Reglas de código
- Todo nuevo código DEBE tener tipos explícitos (CERO `any`)
- Imports ordenados: node_modules → shared → modules → relative
- Nombres: PascalCase para clases, camelCase para funciones/variables
- Cada módulo nuevo DEBE tener al menos un test unitario
- Código existente que se MUEVE debe seguir funcionando igual
---
## Señal de completado
Cuando TODAS las tareas en fix_plan.md estén marcadas [x]:
RALPH_STATUS:
completion_indicators: done
EXIT_SIGNAL: true
summary: "ABE enterprise refactor complete."
+439 -22
View File
@@ -1,27 +1,444 @@
# Ralph Fix Plan
# ABE Enterprise Refactor — Fix Plan
## High Priority
- [ ] Set up basic project structure and build system
- [ ] Define core data structures and types
- [ ] Implement basic input/output handling
- [ ] Create test framework and initial tests
## REGLAS CRÍTICAS
1. NO pasar a la siguiente tarea si el build falla
2. Hacer `git commit` después de CADA tarea completada
3. Leer la spec en `.ralph/specs/` ANTES de cada phase
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
- [ ] Performance optimization
- [ ] Extended feature set
- [ ] Integration with external services
- [ ] Advanced error recovery
## Phase 0: Hotfix — Build actual funcional [COMPLETO]
## Completed
- [x] Project initialization
- [x] 0.1: Fix errores TypeScript en src/ que impidan compilación (IAnomaly import, NodeListOf iterator, cualquier otro)
- [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
- Update this file after each major milestone
---
## Phase 1: Shared Domain — Building Blocks [COMPLETO]
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`
+7
View File
@@ -0,0 +1,7 @@
{
"status": "executing",
"indicator": "⠹",
"elapsed_seconds": 30,
"last_output": "",
"timestamp": "2026-03-05 03:53:30"
}
+130
View File
@@ -0,0 +1,130 @@
# ABE — AI Bug Report Enrichment Specification
## Concepto
Este es el diferenciador más importante de ABE frente a cualquier competidor.
Después de detectar una anomalía, ABE puede usar una LLM para enriquecer
el bug report con un análisis inteligente: causa probable, impacto,
sugerencia de fix, y prompt listo para usar con Claude/GPT.
## IMPORTANTE: esto es una capa OPCIONAL sobre el core determinista.
El core engine nunca llama a LLMs. El enriquecimiento es post-procesado,
ejecutado solo si el usuario lo configura.
## Qué genera la IA
### 1. Root Cause Analysis
A partir del action trace, HTTP log, console errors y DOM snapshot,
la IA propone la causa más probable del bug.
Ejemplo: "The 500 error is likely caused by missing server-side validation
of the email field. The server crashes when receiving an empty string
where a valid email is expected."
### 2. User Impact Assessment
La IA evalúa el impacto del bug en términos de negocio:
"This bug blocks users from completing registration. Any user who
submits an empty email will encounter an unhandled server error,
preventing account creation."
### 3. Suggested Fix
La IA propone un fix concreto:
"Add server-side validation: check if email is present and valid
before processing. Return a 422 with a descriptive error message
instead of propagating the exception."
### 4. AI-Ready Debug Prompt
Un prompt completo listo para copiar y pegar en Claude/ChatGPT:
```
Bug Report Context:
- Type: HTTP 500 on form submission
- Steps to reproduce: [exact action trace]
- Error: [exact error message]
- Request: POST /api/register with body {"email": ""}
- Response: 500 Internal Server Error
Please analyze this bug and provide:
1. Root cause
2. Code fix
3. Test case to prevent regression
```
## Implementación
### Provider abstraction
```typescript
interface IAIProvider {
name: string;
enrich(anomaly: IAnomaly, context: IEnrichmentContext): Promise<IAIEnrichment>;
}
interface IEnrichmentContext {
domSnapshot: string;
httpLog: IHttpResponse[];
consoleErrors: string[];
actionTrace: IAction[];
pageTitle: string;
url: string;
}
interface IAIEnrichment {
rootCause: string;
userImpact: string;
suggestedFix: string;
debugPrompt: string;
confidence: 'low' | 'medium' | 'high';
generatedAt: number;
provider: string;
model: string;
}
```
### Providers implementados
- `ClaudeProvider` — usa Anthropic API (claude-3-5-haiku — rápido y barato)
- `OpenAIProvider` — usa OpenAI API (gpt-4o-mini)
- `OllamaProvider` — usa Ollama local (llama3.2 — sin API key, offline)
### Cuándo se ejecuta
- Automático: si `aiEnrichment.autoEnrich: true`, se ejecuta tras cada anomalía high/critical
- Manual: botón "Enrich with AI" en AnomalyDetail page
- No bloquea: el bug report se guarda sin enriquecimiento, la IA lo añade async
## Configuración en .env
```
ABE_AI_PROVIDER=claude # claude | openai | ollama | none
ABE_AI_API_KEY=sk-ant-xxx # Anthropic key (si provider=claude)
ABE_OPENAI_API_KEY=sk-xxx # OpenAI key (si provider=openai)
ABE_OLLAMA_URL=http://localhost:11434 # (si provider=ollama)
ABE_AI_MODEL=claude-haiku-4-5 # modelo específico (opcional)
ABE_AI_AUTO_ENRICH=false # default false para no incurrir en costes
ABE_AI_MIN_SEVERITY=high # solo enriquecer high/critical automáticamente
```
## Modelo de datos — añadir a SQLite
### Añadir columna a anomalies
```sql
ALTER TABLE anomalies ADD COLUMN ai_enrichment_json TEXT;
ALTER TABLE anomalies ADD COLUMN ai_enriched_at INTEGER;
```
## Frontend — AI panel en AnomalyDetail
Si la anomalía tiene ai_enrichment_json, mostrar panel "AI Analysis" con:
- 🔍 Root Cause (texto con ícono)
- 👥 User Impact (texto con ícono)
- 🔧 Suggested Fix (bloque de código si contiene código)
- 📋 "Copy debug prompt" button (copia el debugPrompt al clipboard)
- Badge: "Analyzed by Claude" / "Analyzed by GPT-4o-mini" / "Analyzed by Llama 3.2"
- Timestamp de cuándo se generó
Si no tiene enriquecimiento, mostrar botón "✨ Analyze with AI" que llama a:
POST /api/anomalies/:id/enrich
## Endpoint nuevo
### POST /api/anomalies/:anomalyId/enrich
Dispara el enriquecimiento de una anomalía concreta (async).
Response inmediata: { status: 'enriching' }
Cuando termina, emite WebSocket event: anomaly:enriched { anomalyId, enrichment }
### GET /api/anomalies/:anomalyId — actualizado
Incluye ai_enrichment si está disponible.
+59
View File
@@ -0,0 +1,59 @@
# ABE — API Security Specification
## Authentication: API Key
All API endpoints require an API key passed in the header:
`X-ABE-API-Key: <key>`
If missing or invalid → 401 Unauthorized.
## Configuration
API key is set via environment variable: `ABE_API_KEY`
If not set, server logs a warning and runs without auth (dev mode only).
## Implementation
Create `src/server/middleware/auth.ts`:
```typescript
export function apiKeyAuth(req, res, next) {
const apiKey = process.env.ABE_API_KEY;
if (!apiKey) return next(); // dev mode: no auth
const provided = req.headers['x-abe-api-key'];
if (!provided || provided !== apiKey) {
return res.status(401).json({ error: 'Invalid or missing API key' });
}
next();
}
```
Apply this middleware to ALL routes EXCEPT:
- GET /health
- GET /ready
## CORS
Only allow requests from the frontend origin.
Configure via environment variable: `ABE_CORS_ORIGIN` (default: `http://localhost:5173`)
## Rate Limiting
Add `express-rate-limit`:
- Max 20 POST /api/sessions per hour per IP
- Max 200 requests per minute per IP for other endpoints
## Environment Variables (full list for .env)
```
ABE_API_KEY=change-me-in-production
ABE_CORS_ORIGIN=http://localhost:5173
ABE_PORT=3001
ABE_DB_PATH=./data/abe.db
ABE_REPORTS_DIR=./reports
ABE_LOGS_DIR=./logs
NODE_ENV=production
```
## docker-compose update
Add .env file support and environment variables to docker-compose.yml.
Add a volumes entry for `data/` directory for SQLite persistence.
+187
View File
@@ -0,0 +1,187 @@
# ABE — API Server Specification
## Arquitectura general
```
React (puerto 5173)
↕ HTTP REST + WebSocket
API Server Express (puerto 3001)
↕ imports directos
ExplorationEngine (core)
```
El servidor vive en `src/server/` y es el único punto de entrada al motor desde el exterior. El frontend NUNCA importa código del core directamente.
---
## Tecnología del servidor
- Framework: Express.js
- WebSocket: socket.io (para streaming en tiempo real)
- Archivos: `src/server/index.ts` y `src/server/routes/`
---
## REST Endpoints
### POST /api/sessions
Lanza una nueva exploración.
Request body:
```json
{
"url": "http://localhost:3000",
"seed": 42,
"maxStates": 50
}
```
Response:
```json
{
"sessionId": "sess_abc123",
"status": "running",
"startedAt": "2025-01-15T10:00:00.000Z"
}
```
---
### GET /api/sessions
Lista todas las sesiones (activas e históricas).
Response:
```json
[
{
"sessionId": "sess_abc123",
"url": "http://localhost:3000",
"status": "running",
"startedAt": "2025-01-15T10:00:00.000Z",
"anomaliesFound": 3,
"statesVisited": 12
}
]
```
---
### GET /api/sessions/:sessionId
Detalle de una sesión específica.
Response:
```json
{
"sessionId": "sess_abc123",
"url": "http://localhost:3000",
"status": "completed",
"startedAt": "2025-01-15T10:00:00.000Z",
"finishedAt": "2025-01-15T10:05:00.000Z",
"statesVisited": 12,
"anomaliesFound": 3,
"seed": 42
}
```
---
### DELETE /api/sessions/:sessionId
Detiene una sesión activa.
Response:
```json
{ "stopped": true }
```
---
### GET /api/anomalies
Lista todas las anomalías encontradas (todas las sesiones).
Query params opcionales: `?sessionId=sess_abc123&severity=high`
Response:
```json
[
{
"id": "anom_a1b2c3",
"sessionId": "sess_abc123",
"type": "http_error",
"severity": "high",
"description": "Form returns HTTP 500 on empty email",
"timestamp": 1705312200000,
"screenshotUrl": "/api/anomalies/anom_a1b2c3/screenshot"
}
]
```
---
### GET /api/anomalies/:anomalyId
Detalle completo de una anomalía incluyendo pasos de reproducción.
Response: el objeto IAnomaly completo serializado (definido en interfaces.md)
---
### GET /api/anomalies/:anomalyId/screenshot
Devuelve la imagen PNG del screenshot de la anomalía.
Response: imagen binaria con Content-Type: image/png
---
### POST /api/anomalies/:anomalyId/replay
Lanza el replay de una anomalía específica.
Response:
```json
{
"replayId": "replay_xyz",
"status": "running"
}
```
---
## WebSocket Events (socket.io)
El cliente se conecta a `ws://localhost:3001` y escucha estos eventos:
### Eventos que emite el SERVIDOR → cliente
`session:started`
```json
{ "sessionId": "sess_abc123", "url": "http://localhost:3000" }
```
`state:discovered`
```json
{ "sessionId": "sess_abc123", "stateId": "s_xyz", "url": "/register", "title": "Register" }
```
`action:executed`
```json
{ "sessionId": "sess_abc123", "actionType": "click", "selector": "button#submit", "timestamp": 1705312197000 }
```
`anomaly:detected`
```json
{ "sessionId": "sess_abc123", "anomalyId": "anom_a1b2c3", "type": "http_error", "severity": "high", "description": "..." }
```
`session:completed`
```json
{ "sessionId": "sess_abc123", "statesVisited": 12, "anomaliesFound": 3 }
```
`session:error`
```json
{ "sessionId": "sess_abc123", "error": "Target URL unreachable" }
```
### Eventos que emite el CLIENTE → servidor
`session:stop`
```json
{ "sessionId": "sess_abc123" }
```
+118
View File
@@ -0,0 +1,118 @@
# ABE — CLI & CI/CD Integration Specification
## CLI Entry Point
File: `src/cli.ts`
Script in package.json: `"abe": "ts-node src/cli.ts"`
Global after install: `npx abe` or `abe` if installed globally.
## CLI Usage
```bash
# Basic run
abe run --url http://localhost:3000
# With auth
abe run --url http://app.com \
--auth-type login_flow \
--login-url http://app.com/login \
--username test@app.com \
--password secret
# With scope limits
abe run --url http://app.com \
--max-states 30 \
--max-depth 4 \
--allowed-domains app.com
# CI mode: exit 1 if any anomaly found
abe run --url http://localhost:3000 --fail-on-anomaly
# CI mode: exit 1 only on high/critical anomalies
abe run --url http://localhost:3000 --fail-on-severity high
# Output formats
abe run --url http://localhost:3000 --output json # prints JSON summary to stdout
abe run --url http://localhost:3000 --output junit # generates junit.xml for CI
# Connect to a running ABE server instead of running inline
abe run --url http://localhost:3000 --server http://abe-server:3001 --api-key mykey
```
## Exit Codes
- 0 → exploration complete, no anomalies (or no anomalies above threshold)
- 1 → anomalies found above threshold
- 2 → exploration failed (target unreachable, auth failed, etc.)
## stdout JSON output (--output json)
```json
{
"sessionId": "sess_abc123",
"url": "http://localhost:3000",
"duration_ms": 45000,
"states_visited": 12,
"anomalies": [
{
"id": "anom_xyz",
"type": "http_error",
"severity": "high",
"description": "Form returns 500 on empty email",
"report_path": "reports/anom_xyz/report.json"
}
],
"exit_code": 1
}
```
## JUnit XML output (--output junit)
Generates `abe-results.xml` compatible with Jenkins, GitHub Actions, GitLab CI:
- Each anomaly = one failing test case
- Each explored state = one passing test case
## GitHub Actions Example Workflow
Create file: `.github/workflows/abe-example.yml` in the repo:
```yaml
name: ABE Exploratory Testing
on:
push:
branches: [main]
pull_request:
jobs:
explore:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start application
run: docker-compose up -d app
# assumes the project has a docker-compose with the target app
- name: Wait for app
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Run ABE
run: |
npm install -g abe-explorer # or: npx abe
abe run \
--url http://localhost:3000 \
--max-states 30 \
--fail-on-severity high \
--output junit
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: abe-reports
path: reports/
- name: Publish test results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: abe-results.xml
```
+99
View File
@@ -0,0 +1,99 @@
# ABE — Database Specification (SQLite)
## Rationale
File-based storage loses all data on container restart.
SQLite requires zero extra services and is perfect for self-hosted deployment.
## Library
Use `better-sqlite3` (synchronous, faster than async alternatives for this use case).
## Location
Database file: `data/abe.db` (persisted via Docker volume)
## Schema
### Table: sessions
```sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
seed INTEGER NOT NULL,
max_states INTEGER NOT NULL DEFAULT 50,
states_visited INTEGER NOT NULL DEFAULT 0,
anomalies_found INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL,
finished_at INTEGER,
config_json TEXT NOT NULL DEFAULT '{}'
);
```
### Table: states
```sql
CREATE TABLE IF NOT EXISTS states (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
url TEXT NOT NULL,
title TEXT NOT NULL,
dom_snapshot_path TEXT,
visit_count INTEGER NOT NULL DEFAULT 0,
discovered_at INTEGER NOT NULL
);
```
### Table: actions
```sql
CREATE TABLE IF NOT EXISTS actions (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
state_id TEXT NOT NULL REFERENCES states(id),
type TEXT NOT NULL,
selector TEXT,
value TEXT,
url TEXT,
seed INTEGER NOT NULL,
executed_at INTEGER NOT NULL,
sequence_order INTEGER NOT NULL
);
```
### Table: anomalies
```sql
CREATE TABLE IF NOT EXISTS anomalies (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
type TEXT NOT NULL,
severity TEXT NOT NULL,
description TEXT NOT NULL,
action_trace_json TEXT NOT NULL,
evidence_json TEXT NOT NULL,
screenshot_path TEXT,
dom_snapshot_path TEXT,
detected_at INTEGER NOT NULL
);
```
### Table: notifications
```sql
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
anomaly_id TEXT NOT NULL REFERENCES anomalies(id),
channel TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
sent_at INTEGER,
error TEXT
);
```
## Repository Pattern
Create `src/db/` with:
- `src/db/connection.ts` — singleton SQLite connection, runs migrations on startup
- `src/db/SessionRepository.ts` — CRUD for sessions
- `src/db/AnomalyRepository.ts` — CRUD for anomalies, includes filter by session/severity
- `src/db/migrations.ts` — runs all CREATE TABLE IF NOT EXISTS on startup
## Rules
- All DB operations are synchronous (better-sqlite3 is sync)
- Repositories are injected into the API server, never imported directly by core engine
- The engine emits events → the API server listens and persists to DB
+102
View File
@@ -0,0 +1,102 @@
# ABE — Docker Specification
## Objetivo
Permitir arrancar todo el proyecto (backend + frontend) con un solo comando:
docker-compose up --build
## Backend Dockerfile (raíz del proyecto)
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3001
CMD ["node", "dist/server/index.js"]
```
## Frontend Dockerfile (frontend/Dockerfile)
Usa build multistage: primero compila con Node, luego sirve con nginx.
```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
## nginx.conf (frontend/nginx.conf)
Necesario para que React Router funcione correctamente (todas las rutas apuntan a index.html):
```nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3001;
}
location /socket.io {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
## docker-compose.yml (raíz)
```yaml
version: '3.8'
services:
backend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- PORT=3001
volumes:
- ./reports:/app/reports
- ./logs:/app/logs
networks:
- abe-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:80"
depends_on:
- backend
networks:
- abe-network
networks:
abe-network:
driver: bridge
```
## Notas importantes
- El frontend en producción (nginx) hace proxy de /api y /socket.io al backend
- Los volúmenes reports/ y logs/ persisten datos entre reinicios del contenedor
- El frontend se accede en http://localhost:5173
- El backend se accede en http://localhost:3001
+84
View File
@@ -0,0 +1,84 @@
# ABE — Exploration Scope & Target Authentication Specification
## Exploration Config Object
This config is passed via POST /api/sessions and stored in sessions.config_json.
```typescript
interface ExplorationConfig {
// Scope
allowedDomains: string[]; // e.g. ["localhost", "myapp.com"] — never follow external links
maxStates: number; // default: 50 — stop after this many unique states
maxDepth: number; // default: 5 — max click depth from start URL
actionDelayMs: number; // default: 500 — wait between actions (politeness)
sessionTimeoutMs: number; // default: 300000 (5 min) — hard stop
// Exclusions
excludedPaths: string[]; // e.g. ["/logout", "/admin"] — never navigate here
excludedSelectors: string[]; // e.g. ["button.delete", "a[href*='delete']"]
// Target authentication
auth: AuthConfig | null;
// Fuzzing
fuzzingEnabled: boolean; // default: true
fuzzingIntensity: 'low' | 'medium' | 'high'; // default: 'medium'
}
type AuthConfig =
| { type: 'cookies'; cookies: Array<{ name: string; value: string; domain: string }> }
| { type: 'headers'; headers: Record<string, string> }
| { type: 'login_flow'; loginUrl: string; usernameSelector: string; passwordSelector: string; submitSelector: string; username: string; password: string }
```
## Scope Rules (enforced in PlaywrightAgent)
1. Before navigating to any URL, check if hostname is in allowedDomains. If not, skip.
2. Before executing any action, check if current path matches excludedPaths. If yes, skip.
3. Before clicking any element, check if it matches excludedSelectors. If yes, skip.
4. Stop exploration when statesVisited >= maxStates OR depth >= maxDepth OR elapsed > sessionTimeoutMs.
## Authentication Flow
### type: 'cookies'
Inject cookies before the first navigation using playwright context.addCookies().
### type: 'headers'
Set extra HTTP headers on the browser context using context.setExtraHTTPHeaders().
### type: 'login_flow'
Before starting exploration:
1. Navigate to loginUrl
2. Fill usernameSelector with username
3. Fill passwordSelector with password
4. Click submitSelector
5. Wait for navigation to complete
6. Verify we are no longer on loginUrl (if still there, login failed → abort session with error)
7. Proceed with exploration from startUrl
## Updated POST /api/sessions request body
```json
{
"url": "http://localhost:3000",
"seed": 42,
"config": {
"allowedDomains": ["localhost"],
"maxStates": 50,
"maxDepth": 5,
"actionDelayMs": 500,
"sessionTimeoutMs": 300000,
"excludedPaths": ["/logout"],
"excludedSelectors": [],
"auth": {
"type": "login_flow",
"loginUrl": "http://localhost:3000/login",
"usernameSelector": "input[name='email']",
"passwordSelector": "input[name='password']",
"submitSelector": "button[type='submit']",
"username": "test@example.com",
"password": "password123"
},
"fuzzingEnabled": true,
"fuzzingIntensity": "medium"
}
}
```
+72
View File
@@ -0,0 +1,72 @@
# ABE — Frontend v2 Specification
## New pages and components to add
### New Page: Settings (ruta: /settings)
Sections:
1. API Key — show current key, button to copy
2. Notifications — form to set Slack webhook URL and min severity (calls PATCH /api/config)
3. Default Exploration Config — form with default values for maxStates, maxDepth, delay, excluded paths
4. About — version, links to docs
### Updated: NewSessionForm
Add fields:
- Allowed Domains (chips input, default: hostname of URL)
- Max States (number, default 50)
- Max Depth (number, default 5)
- Action Delay ms (number, default 500)
- Excluded Paths (chips input)
- Auth Type (select: none / cookies / headers / login_flow)
- If login_flow: show loginUrl, usernameSelector, passwordSelector, submitSelector, username, password
- If cookies: textarea for JSON cookie array
- If headers: key-value pairs input
- Fuzzing enabled (toggle)
- Fuzzing intensity (select: low / medium / high)
### Updated: Dashboard
Add stats bar at the top with 4 numbers:
- Total sessions
- Total anomalies found
- Critical/High anomalies (highlighted in red)
- Sessions running now
### Updated: AnomalyList
Add filter bar:
- Filter by severity (multi-select: low, medium, high, critical)
- Filter by type (multi-select: http_error, js_exception, etc.)
- Filter by session (dropdown)
- Search by description (text input)
- Sort by: newest first / severity desc
### Updated: AnomalyDetail
Add:
- Download button → downloads report.json
- Download MD button → downloads report.md
- Copy replay command button → copies `abe replay --anomaly-id anom_xxx` to clipboard
### New Component: SeverityBadge
Reusable badge component used everywhere:
- critical → red bg, white text
- high → orange bg, white text
- medium → yellow bg, dark text
- low → blue bg, white text
### New API endpoints needed (add to api-server spec)
PATCH /api/config
- Updates server config (slack webhook, min severity, defaults)
- Body: Partial<ServerConfig>
- Returns: updated ServerConfig
GET /api/config
- Returns current server config (without API key value)
GET /api/stats
- Returns: { totalSessions, totalAnomalies, criticalHighCount, runningSessions }
- Used by dashboard stats bar
+99
View File
@@ -0,0 +1,99 @@
# ABE — Frontend Specification
## Tecnología
- React 18 + TypeScript
- Vite (bundler, más simple que webpack)
- TailwindCSS (estilos sin escribir CSS manual)
- socket.io-client (WebSocket)
- React Router v6 (navegación entre páginas)
## Ubicación
El frontend vive en `frontend/` en la raíz del proyecto, completamente separado de `src/`.
```
frontend/
├── src/
│ ├── pages/
│ │ ├── Dashboard.tsx ← página principal
│ │ ├── SessionDetail.tsx ← detalle de una sesión en vivo
│ │ └── AnomalyDetail.tsx ← detalle de un bug report
│ ├── components/
│ │ ├── NewSessionForm.tsx ← formulario para lanzar exploración
│ │ ├── SessionList.tsx ← lista de sesiones
│ │ ├── AnomalyList.tsx ← lista de anomalías
│ │ ├── LiveFeed.tsx ← stream en tiempo real de eventos
│ │ └── AnomalyCard.tsx ← tarjeta de una anomalía
│ ├── hooks/
│ │ ├── useSocket.ts ← conexión WebSocket reutilizable
│ │ └── useApi.ts ← fetch helper para la API REST
│ ├── types.ts ← tipos TypeScript del frontend (espejo de interfaces.ts)
│ ├── App.tsx ← router principal
│ └── main.tsx ← entry point
├── index.html
├── vite.config.ts
├── tailwind.config.ts
└── package.json
```
---
## Página 1 — Dashboard (ruta: `/`)
Contiene:
- Botón "New Exploration" que abre el formulario
- `NewSessionForm`: campos URL y Seed, botón Start
- `SessionList`: tabla con todas las sesiones (estado, URL, anomalías encontradas, fecha)
- `AnomalyList`: lista de las últimas anomalías de todas las sesiones
---
## Página 2 — Session Detail (ruta: `/sessions/:sessionId`)
Contiene:
- Header con URL explorada, estado (running/completed), seed
- Botón "Stop" si la sesión está activa
- `LiveFeed`: lista en tiempo real de eventos WebSocket
- Cada evento muestra icono + texto + timestamp
- Scroll automático al último evento
- Colores: verde para state:discovered, amarillo para action:executed, rojo para anomaly:detected
- `AnomalyList`: anomalías encontradas en esta sesión (se actualiza en tiempo real)
---
## Página 3 — Anomaly Detail (ruta: `/anomalies/:anomalyId`)
Contiene:
- Header con tipo, severidad (badge de color), descripción
- Sección "Reproduction Steps": lista numerada de acciones
- Sección "Evidence":
- Screenshot a tamaño completo (imagen)
- Botón para ver DOM snapshot (abre en nueva pestaña)
- Sección "HTTP Log": tabla con requests (URL, método, status, duración)
- Sección "Raw Errors": bloque de código con los errores textuales
- Botón "Run Replay": llama a POST /api/anomalies/:id/replay y muestra estado
---
## Colores de severidad (badges)
- critical → rojo (#ef4444)
- high → naranja (#f97316)
- medium → amarillo (#eab308)
- low → azul (#3b82f6)
---
## Conexión con la API
Todas las llamadas van a `http://localhost:3001`.
En `vite.config.ts` configurar proxy para `/api` y `/socket.io` apuntando a `localhost:3001`.
```typescript
// vite.config.ts
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3001',
'/socket.io': { target: 'http://localhost:3001', ws: true }
}
}
})
```
+94
View File
@@ -0,0 +1,94 @@
# ABE — Fuzzing / Disruption Module Specification
## Purpose
This is ABE's core differentiator. Instead of only clicking valid elements,
ABE injects abnormal inputs into forms to provoke unexpected server behavior.
## Architecture
```
src/plugins/fuzzers/
├── FuzzingEngine.ts ← orchestrator, decides when and how to fuzz
├── strategies/
│ ├── EmptyValueStrategy.ts
│ ├── OversizedStringStrategy.ts
│ ├── SpecialCharsStrategy.ts
│ ├── TypeMismatchStrategy.ts
│ └── BoundaryValueStrategy.ts
└── InputTypeDetector.ts ← detects field type from DOM attributes
```
## InputTypeDetector
Detects field type from: input[type], input[name], input[placeholder], label text, aria-label.
```typescript
type DetectedInputType =
| 'email' | 'password' | 'number' | 'date' | 'phone'
| 'url' | 'search' | 'text' | 'textarea' | 'select' | 'file'
```
## Fuzzing Strategies
### EmptyValueStrategy
Submits forms with all fields empty. Catches missing server-side validation.
Applies to: all input types.
Values: `""`, `" "` (space only), `"\t"` (tab).
### OversizedStringStrategy
Submits strings far beyond expected length. Catches buffer issues and UI overflow.
Applies to: text, email, password, textarea.
Values by intensity:
- low: 256 chars
- medium: 1024 chars
- high: 10000 chars + unicode chars
### SpecialCharsStrategy
Injects characters that break SQL, HTML, and shell contexts.
Applies to: text, email, search, textarea.
Values:
```
' OR 1=1 --
<script>alert(1)</script>
../../etc/passwd
${7*7}
\x00\x01\x02
```
### TypeMismatchStrategy
Submits wrong data types for the field.
- email field → "not-an-email", "12345", "@@@"
- number field → "abc", "-999999", "9.9.9", "NaN"
- date field → "yesterday", "32/13/2025", "0000-00-00"
- url field → "javascript:alert(1)", "not a url"
- phone field → "000", "++++", "abcdefghij"
### BoundaryValueStrategy
Tests values at the edges of expected ranges.
- number field → 0, -1, 2147483647, 2147483648, -2147483648
- date field → "1900-01-01", "2099-12-31", "1970-01-01"
## Fuzzing Execution Flow
```
For each form discovered in state:
1. InputTypeDetector analyzes each field
2. FuzzingEngine selects strategies based on fuzzingIntensity:
- low: EmptyValue + TypeMismatch only
- medium: + OversizedString + BoundaryValue
- high: + SpecialChars
3. For each strategy, fill all fields with fuzz values
4. Submit the form
5. Observe response via AnomalyDetector
6. Record results
```
## AnomalyDetector additions for fuzzing
Add these new anomaly types:
- `validation_bypass` — server accepted clearly invalid input (e.g. submitted empty required email, got 200)
- `server_error_on_fuzz` — server returned 500 on a fuzzed input
- `xss_reflection` — fuzzed script tag appears in response body
## Integration point
FuzzingEngine is called from ExplorationEngine AFTER normal action discovery,
only when `config.fuzzingEnabled === true`.
It is passed as an optional plugin, so the core engine doesn't depend on it directly.
+164
View File
@@ -0,0 +1,164 @@
# ABE — Core Interfaces Specification
## Regla fundamental
`src/core/` solo puede importar desde este documento.
`src/plugins/` implementa estas interfaces, nunca al revés.
---
## IState
Representa un estado único de la aplicación explorada.
```typescript
interface IState {
id: string; // hash SHA1 del snapshot DOM + URL
url: string; // URL completa en este estado
title: string; // document.title
timestamp: number; // Date.now() cuando se capturó
domSnapshot: string; // outerHTML del body serializado
visitCount: number; // cuántas veces se ha visitado este estado
}
```
---
## IAction
Representa una acción que el agente puede ejecutar.
```typescript
interface IAction {
id: string; // uuid v4 generado al crear la acción
type: 'click' | 'fill' | 'navigate' | 'select' | 'submit';
selector?: string; // CSS selector del elemento (si aplica)
value?: string; // valor a introducir (para fill/select)
url?: string; // destino (solo para navigate)
timestamp: number; // cuando se ejecutó
seed: number; // semilla usada para selección aleatoria
stateId: string; // ID del estado desde el que se ejecutó
}
```
---
## IObservation
Lo que el agente observa DESPUÉS de ejecutar una acción.
```typescript
interface IObservation {
id: string; // uuid v4
actionId: string; // acción que provocó esta observación
newStateId: string; // ID del nuevo estado resultante
httpResponses: IHttpResponse[]; // todas las requests durante la acción
consoleErrors: string[]; // mensajes de console.error capturados
jsExceptions: string[]; // excepciones JS no capturadas
timestamp: number;
}
interface IHttpResponse {
url: string;
status: number;
method: string;
durationMs: number;
}
```
---
## IAnomaly
Una desviación detectada del comportamiento esperado.
```typescript
interface IAnomaly {
id: string; // uuid v4
type: AnomalyType;
severity: 'low' | 'medium' | 'high' | 'critical';
observationId: string; // observación que la provocó
actionTrace: IAction[]; // secuencia exacta de acciones que llevaron aquí
description: string; // texto legible explicando qué pasó
evidence: IAnomalyEvidence;
timestamp: number;
}
type AnomalyType =
| 'http_error' // respuesta HTTP 4xx o 5xx
| 'js_exception' // excepción JavaScript no capturada
| 'console_error' // console.error detectado
| 'navigation_fail' // navegación no completada
| 'element_missing' // elemento esperado desaparece
| 'timeout'; // acción excede tiempo límite
interface IAnomalyEvidence {
screenshotPath?: string; // ruta relativa al screenshot
domSnapshotPath?: string; // ruta relativa al DOM serializado
httpLog?: IHttpResponse[]; // requests relevantes
rawErrors?: string[]; // errores textuales originales
}
```
---
## IInteractionAgent (plugin interface)
Lo que cualquier agente de interacción debe implementar.
```typescript
interface IInteractionAgent {
launch(url: string): Promise<void>;
close(): Promise<void>;
discoverActions(state: IState): Promise<IAction[]>;
executeAction(action: IAction): Promise<IObservation>;
captureState(): Promise<IState>;
}
```
---
## ICollector (plugin interface)
Lo que cualquier colector de contexto debe implementar.
```typescript
interface ICollector {
name: string;
collect(anomaly: IAnomaly, agent: IInteractionAgent): Promise<IAnomalyEvidence>;
}
```
---
## IReproducer
Genera un script de replay a partir de una traza de acciones.
```typescript
interface IReproducer {
serialize(trace: IAction[]): string; // JSON serializado
deserialize(raw: string): IAction[]; // reconstruye la traza
generateScript(trace: IAction[]): string; // script Playwright ejecutable
}
```
---
## IExporter (plugin interface)
Transforma una anomalía en un reporte consumible.
```typescript
interface IExporter {
format: 'markdown' | 'json';
export(anomaly: IAnomaly, outputDir: string): Promise<string>; // retorna la ruta del archivo generado
}
```
---
## StateGraph
No es una interfaz pero su contrato debe ser explícito.
```typescript
class StateGraph {
addState(state: IState): void;
hasState(stateId: string): boolean;
recordTransition(fromId: string, action: IAction, toId: string): void;
getUnvisited(): IState[]; // estados con visitCount === 0
getNextToExplore(): IState | null; // heurística BFS por defecto
toJSON(): object; // serializable para logs
}
```
@@ -0,0 +1,119 @@
# ABE — Multi-Browser, Mobile Emulation & Accessibility Specification
## Multi-browser testing
### Browsers soportados (via Playwright)
- chromium (Chrome/Edge) — siempre disponible
- firefox — opcional
- webkit (Safari) — opcional
### Configuración en ExplorationConfig
```typescript
browsers: Array<'chromium' | 'firefox' | 'webkit'>; // default: ['chromium']
```
### Comportamiento
Cuando se especifican múltiples browsers:
- ABE ejecuta la misma exploración en paralelo en cada browser
- Cada browser crea su propia sub-sesión con el mismo seed
- Los resultados se agrupan bajo la misma sesión padre
- Las anomalías incluyen qué browser las detectó
- Anomalías que aparecen en TODOS los browsers → severity += 1 level
- Anomalías que aparecen solo en un browser → añadir tag "browser-specific: webkit"
### Añadir a IAnomaly
```typescript
browser: 'chromium' | 'firefox' | 'webkit';
browserVersion: string;
```
---
## Mobile Viewport Emulation
### Devices predefinidos (usar Playwright devices)
```typescript
type MobileDevice =
| 'iPhone 14'
| 'iPhone 14 Pro Max'
| 'Pixel 7'
| 'Galaxy S23'
| 'iPad Pro'
| 'none' // desktop (default)
```
### En ExplorationConfig
```typescript
mobileDevice: MobileDevice; // default: 'none'
viewport: { width: number; height: number } | null; // override manual
```
### Implementación en PlaywrightAgent
```typescript
// Si mobileDevice !== 'none':
const device = playwright.devices[config.mobileDevice];
const context = await browser.newContext({ ...device });
```
### Anomalías específicas de mobile
Añadir tipo: `mobile_layout_issue` — detectado cuando:
- Un elemento clickable tiene menos de 44x44px (WCAG touch target)
- Hay scroll horizontal inesperado (viewport overflow)
- Un elemento está fuera del viewport en mobile
---
## Accessibility Testing (axe-core)
### Librería
Usar `@axe-core/playwright` (integración oficial axe + Playwright).
### Cuándo ejecutar
Después de cada acción que cambia el estado (navigation + click que resulta en nuevo estado).
NO ejecutar en cada acción fill (demasiado frecuente).
### Implementación
```typescript
import { checkA11y } from 'axe-playwright';
// En PlaywrightAgent, después de captureState():
async function runAccessibilityCheck(page: Page): Promise<IAccessibilityResult[]> {
const results = await checkA11y(page, undefined, {
detailedReport: true,
detailedReportOptions: { html: true },
});
return results.violations.map(v => ({
id: v.id,
impact: v.impact, // 'minor' | 'moderate' | 'serious' | 'critical'
description: v.description,
helpUrl: v.helpUrl,
nodes: v.nodes.length,
selector: v.nodes[0]?.target?.join(', '),
}));
}
```
### Nuevo tipo de anomalía
- type: `accessibility_violation`
- severity mapping desde axe impact:
- minor → low
- moderate → medium
- serious → high
- critical → critical
- description: "[axe] {violation.description}"
- evidence: { helpUrl, affectedNodes, wcagCriteria }
### En ExplorationConfig
```typescript
accessibility: {
enabled: boolean; // default: true
minImpact: 'minor' | 'moderate' | 'serious' | 'critical'; // default: 'serious'
wcagLevel: 'A' | 'AA' | 'AAA'; // default: 'AA'
}
```
### En el bug report
Añadir sección "Accessibility Violations" en report.md con:
- Lista de violaciones con impact badge
- Link a la documentación de cada regla (helpUrl de axe)
- Selector CSS del elemento afectado
+88
View File
@@ -0,0 +1,88 @@
# ABE — Network Chaos Specification
## Concepto
Inspirado en Gremlin y LitmusChaos, pero aplicado a nivel de browser.
ABE puede simular condiciones de red adversas durante la exploración
para descubrir cómo se comporta el app en redes lentas, intermitentes,
o con servicios externos fallando.
## Esto es diferente al fuzzing de inputs:
- Fuzzing: inputs inválidos en formularios
- Network chaos: condiciones de red adversas (latencia, pérdida de paquetes, timeout)
## Implementación via Playwright CDP
Playwright expone Chrome DevTools Protocol (CDP) que permite controlar la red:
```typescript
// En PlaywrightAgent
async function applyNetworkCondition(condition: NetworkCondition): Promise<void> {
const client = await this.page.context().newCDPSession(this.page);
await client.send('Network.emulateNetworkConditions', {
offline: condition.offline,
downloadThroughput: condition.downloadKbps * 1024 / 8,
uploadThroughput: condition.uploadKbps * 1024 / 8,
latency: condition.latencyMs,
});
}
```
## Perfiles de red predefinidos
```typescript
const NETWORK_PROFILES = {
'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false },
'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false },
'2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false },
'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true },
'none': null // sin limitación (default)
}
```
## API request interception (simular servicios caídos)
```typescript
// Simular que un endpoint específico falla con 503
await page.route('**/api/payment**', route => {
route.fulfill({ status: 503, body: 'Service Unavailable' });
});
// Simular latencia en un endpoint específico
await page.route('**/api/search**', async route => {
await new Promise(r => setTimeout(r, 3000)); // 3s delay
route.continue();
});
```
## Configuración en ExplorationConfig
```typescript
networkChaos: {
enabled: boolean; // default: false
profile: keyof typeof NETWORK_PROFILES; // default: 'none'
blockedEndpoints: string[]; // glob patterns — responden 503
slowEndpoints: Array<{
pattern: string; // glob
delayMs: number;
}>;
}
```
## Anomalías específicas de network chaos
Añadir tipos al AnomalyDetector:
- `offline_handling_missing` — app muestra pantalla en blanco o error no controlado cuando está offline
- `slow_network_no_feedback` — con slow-3g, la app no muestra loading indicator (detectado si CLS=0 pero LCP>5000ms y no hay elemento con rol 'progressbar' o 'status')
- `external_service_crash` — cuando un endpoint bloqueado causa error 500 en el frontend
## Integración con el flujo de exploración
NetworkChaos se aplica de forma secuencial, no simultánea:
1. Primera pasada: exploración normal (baseline)
2. Segunda pasada (si networkChaos.enabled): misma seed, con perfil de red aplicado
3. Comparar resultados: nuevas anomalías que aparecen solo en la segunda pasada son network-related
## Frontend — Network Chaos Config
En NewSessionForm, añadir sección collapsible "Network Chaos":
- Toggle "Enable network chaos"
- Select perfil: Fast 3G / Slow 3G / 2G / Offline
- Textarea "Blocked endpoints" (uno por línea, glob patterns)
- Lista "Slow endpoints" con campo pattern + delay ms
+64
View File
@@ -0,0 +1,64 @@
# ABE — Notifications Specification
## Purpose
When ABE finds an anomaly autonomously, notify the team immediately.
## Supported Channels
### 1. Slack Webhook
Environment variable: `ABE_SLACK_WEBHOOK_URL`
Payload sent to Slack on anomaly:detected:
```json
{
"text": "🐛 ABE found a bug!",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*ABE Bug Report*\n*Severity:* 🔴 HIGH\n*Type:* http_error\n*Description:* Form returns HTTP 500 on empty email\n*Session:* sess_abc123\n*Target:* http://localhost:3000"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View Report" },
"url": "http://localhost:5173/anomalies/anom_abc123"
}
]
}
]
}
```
Only send for severity: high or critical (configurable via `ABE_NOTIFY_MIN_SEVERITY`).
### 2. Generic Webhook
Environment variable: `ABE_WEBHOOK_URL`
POST request with the full IAnomaly object as JSON body.
Includes header: `X-ABE-Event: anomaly.detected`
## Implementation
Create `src/server/notifications/`:
- `NotificationService.ts` — main service, called after anomaly is persisted to DB
- `SlackNotifier.ts` — implements Slack webhook
- `WebhookNotifier.ts` — implements generic webhook
NotificationService.notify(anomaly) is called from the API server
after every anomaly:detected event from the engine.
## Configuration (environment variables)
```
ABE_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
ABE_WEBHOOK_URL=https://myapp.com/webhooks/abe
ABE_NOTIFY_MIN_SEVERITY=high # low | medium | high | critical
```
## Notification record
Every notification attempt (success or failure) is saved to the notifications table in SQLite.
Failed notifications are retried once after 60 seconds.
+130
View File
@@ -0,0 +1,130 @@
# ABE — Output Format Specification
Cada anomalía genera DOS archivos en `reports/{anomaly-id}/`:
---
## 1. report.json — Para consumo por AI y tooling
```json
{
"version": "1.0",
"generated_at": "2025-01-15T10:30:00.000Z",
"environment": {
"target_url": "http://localhost:3000",
"abe_version": "0.1.0",
"os": "linux",
"node_version": "20.x"
},
"anomaly": {
"id": "anom_a1b2c3d4",
"type": "http_error",
"severity": "high",
"description": "Form submission returns HTTP 500 on empty email field",
"timestamp": 1705312200000
},
"reproduction": {
"seed": 42,
"steps": [
{
"step": 1,
"action_type": "navigate",
"url": "http://localhost:3000/register",
"timestamp": 1705312195000
},
{
"step": 2,
"action_type": "fill",
"selector": "input[name='email']",
"value": "",
"timestamp": 1705312196000
},
{
"step": 3,
"action_type": "click",
"selector": "button[type='submit']",
"timestamp": 1705312197000
}
]
},
"evidence": {
"screenshot": "screenshot.png",
"dom_snapshot": "dom.html",
"http_log": [
{
"url": "http://localhost:3000/api/register",
"method": "POST",
"status": 500,
"duration_ms": 234
}
],
"console_errors": [],
"js_exceptions": []
}
}
```
---
## 2. report.md — Para lectura humana
El archivo Markdown debe tener exactamente esta estructura:
```markdown
# Bug Report — [tipo de anomalía] — [fecha]
## Summary
[Una frase describiendo qué pasó y dónde]
## Severity
[low | medium | high | critical] — [justificación en una frase]
## Reproduction Steps
1. Navigate to `[url]`
2. [acción 2]
3. [acción 3]
...
**Seed used**: `42`
**Replay command**: `npm run replay -- --report reports/anom_a1b2c3d4/report.json`
## Observed Behavior
[Qué ocurrió exactamente — errores, respuestas HTTP, mensajes]
## Evidence
- Screenshot: `reports/anom_a1b2c3d4/screenshot.png`
- DOM Snapshot: `reports/anom_a1b2c3d4/dom.html`
- HTTP Log: [tabla con las requests relevantes]
## Raw Errors
\`\`\`
[errores textuales tal cual aparecieron]
\`\`\`
```
---
## Estructura de carpetas de salida
```
reports/
└── anom_a1b2c3d4/
├── report.json ← estructurado para AI
├── report.md ← legible para humanos
├── screenshot.png ← captura en el momento de la anomalía
└── dom.html ← snapshot completo del DOM
logs/
└── session_20250115_103000.jsonl ← una línea JSON por evento
```
---
## Formato del log de sesión (.jsonl)
Cada línea es un objeto JSON independiente:
```jsonl
{"event":"session_start","timestamp":1705312190000,"seed":42,"target":"http://localhost:3000"}
{"event":"state_discovered","timestamp":1705312191000,"state_id":"s_abc123","url":"/","title":"Home"}
{"event":"action_executed","timestamp":1705312196000,"action_id":"act_xyz","type":"fill","selector":"input[name='email']","value":""}
{"event":"anomaly_detected","timestamp":1705312197000,"anomaly_id":"anom_a1b2c3d4","type":"http_error","severity":"high"}
{"event":"session_end","timestamp":1705312210000,"states_visited":3,"anomalies_found":1}
```
+124
View File
@@ -0,0 +1,124 @@
# ABE — Performance Metrics Specification
## Concepto
Durante la exploración, ABE captura métricas de rendimiento de cada
estado visitado. Inspirado en Checkly y Datadog RUM.
Esto permite detectar anomalías de rendimiento además de errores funcionales.
## Métricas capturadas por estado
```typescript
interface IPerformanceMetrics {
stateId: string;
url: string;
timestamp: number;
// Navigation Timing (disponibles via Playwright)
ttfb: number; // Time to First Byte (ms)
domContentLoaded: number; // DOMContentLoaded event (ms)
loadComplete: number; // Load event (ms)
// Core Web Vitals (via web-vitals library injected)
lcp: number | null; // Largest Contentful Paint (ms)
cls: number | null; // Cumulative Layout Shift (score)
fid: number | null; // First Input Delay (ms) - solo tras interacción
inp: number | null; // Interaction to Next Paint (ms)
// Resource counts
totalRequests: number;
failedRequests: number;
totalTransferSize: number; // bytes
}
```
## Implementación
### TTFB, DOMContentLoaded, Load
Via `page.evaluate()` usando `performance.timing` después de navigation:
```typescript
const timing = await page.evaluate(() => ({
ttfb: performance.timing.responseStart - performance.timing.requestStart,
domContentLoaded: performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart,
loadComplete: performance.timing.loadEventEnd - performance.timing.navigationStart,
}));
```
### Core Web Vitals
Inyectar el script de `web-vitals` (npm) en la página:
```typescript
await page.addScriptTag({ url: 'https://unpkg.com/web-vitals/dist/web-vitals.iife.js' });
const vitals = await page.evaluate(() => new Promise(resolve => {
const result = {};
webVitals.getLCP(m => result.lcp = m.value);
webVitals.getCLS(m => result.cls = m.value);
webVitals.getINP(m => result.inp = m.value);
setTimeout(() => resolve(result), 3000); // wait 3s for vitals
}));
```
## Anomalías de rendimiento (nuevos tipos)
Añadir al AnomalyDetector con umbrales basados en Core Web Vitals de Google:
| Métrica | Good | Needs Improvement | Poor (anomalía) |
|---------|---------|-------------------|-----------------|
| LCP | <2500ms | 2500-4000ms | >4000ms → high |
| CLS | <0.1 | 0.1-0.25 | >0.25 → medium |
| INP | <200ms | 200-500ms | >500ms → high |
| TTFB | <800ms | 800-1800ms | >1800ms → medium|
Tipo de anomalía: `performance_degradation`
## Modelo de datos — añadir a SQLite
### Table: performance_metrics
```sql
CREATE TABLE IF NOT EXISTS performance_metrics (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
ttfb INTEGER,
dom_content_loaded INTEGER,
load_complete INTEGER,
lcp INTEGER,
cls REAL,
fid INTEGER,
inp INTEGER,
total_requests INTEGER,
failed_requests INTEGER,
total_transfer_size INTEGER,
captured_at INTEGER NOT NULL
);
```
## Frontend — Performance tab
Añadir tab "Performance" en SessionDetail:
- Tabla con todos los estados visitados y sus métricas
- Columnas con color coded: verde/amarillo/rojo según umbrales de Google
- Gráfico de barras: LCP por estado (para identificar páginas lentas)
- Summary cards: peor LCP, peor CLS, peor TTFB de la sesión
## En el bug report
Si hay anomalía performance_degradation, añadir sección en report.md:
```
## Performance Issue
- LCP: 5200ms (threshold: 4000ms) ❌
- CLS: 0.08 ✅
- TTFB: 2100ms (threshold: 1800ms) ❌
- Total page size: 4.2MB
```
## Configuración
Añadir a ExplorationConfig:
```typescript
performance: {
enabled: boolean; // default: true
lcpThresholdMs: number; // default: 4000
clsThreshold: number; // default: 0.25
inpThresholdMs: number; // default: 500
ttfbThresholdMs: number; // default: 1800
}
```
@@ -0,0 +1,77 @@
# ABE — Production Hardening Specification
## Health Endpoints (no auth required)
### GET /health
Returns 200 if server is up.
```json
{ "status": "ok", "version": "0.1.0", "uptime_seconds": 3600 }
```
### GET /ready
Returns 200 if server is ready to accept requests (DB connected, no critical errors).
Returns 503 if not ready.
```json
{ "status": "ready", "db": "connected", "active_sessions": 2 }
```
Used by Docker HEALTHCHECK and Kubernetes readiness probes.
## Docker improvements
### Backend Dockerfile
Add HEALTHCHECK:
```dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
```
### docker-compose.yml updates
- Add healthcheck to backend service
- Add `restart: unless-stopped` to both services
- Add `data/` volume for SQLite persistence
- Load `.env` file: `env_file: .env`
- Add `depends_on: backend: condition: service_healthy` to frontend
### .env.example file
Create `.env.example` in repo root with all variables and example values.
`.env` added to `.gitignore`.
## Error handling improvements
Global Express error handler in `src/server/index.ts`:
- Catch all unhandled errors
- Log with timestamp and stack trace
- Return consistent JSON error format:
```json
{ "error": "Internal server error", "code": "INTERNAL_ERROR", "timestamp": 1705312200000 }
```
Never expose stack traces in production (NODE_ENV=production).
## Graceful shutdown
On SIGTERM/SIGINT:
1. Stop accepting new sessions
2. Wait for active sessions to finish (max 30s)
3. Close DB connection
4. Exit 0
## Concurrency limits
- Max concurrent exploration sessions: configurable via `ABE_MAX_CONCURRENT_SESSIONS` (default: 3)
- If limit reached, POST /api/sessions returns 429 with:
```json
{ "error": "Max concurrent sessions reached", "active": 3, "limit": 3 }
```
## Logging improvements
Replace console.log with structured logger (use `pino`):
```typescript
log.info({ sessionId, url, event: 'session_started' }, 'Session started')
log.error({ anomalyId, error }, 'Failed to capture screenshot')
```
All logs go to stdout (Docker captures them).
Log level configurable via `ABE_LOG_LEVEL` env var (default: 'info').
+138
View File
@@ -0,0 +1,138 @@
# ABE — Project Structure Specification
## Árbol completo de archivos a crear
```
abe/
├── src/
│ ├── core/
│ │ ├── interfaces.ts ← TODAS las interfaces (IState, IAction, etc.)
│ │ ├── StateGraph.ts ← implementación del grafo de estados
│ │ ├── ExplorationEngine.ts ← loop principal de exploración
│ │ └── AnomalyDetector.ts ← reglas heurísticas de detección
│ ├── plugins/
│ │ ├── agents/
│ │ │ └── PlaywrightAgent.ts ← implementa IInteractionAgent
│ │ ├── collectors/
│ │ │ ├── ScreenshotCollector.ts
│ │ │ ├── NetworkCollector.ts
│ │ │ └── DOMSnapshotCollector.ts
│ │ ├── exporters/
│ │ │ ├── MarkdownExporter.ts
│ │ │ └── JSONExporter.ts
│ │ └── reproducers/
│ │ └── PlaywrightReproducer.ts
│ └── index.ts ← punto de entrada, conecta todo
├── tests/
│ ├── core/
│ │ ├── StateGraph.test.ts
│ │ ├── ExplorationEngine.test.ts
│ │ └── AnomalyDetector.test.ts
│ └── plugins/
│ ├── agents/
│ │ └── PlaywrightAgent.test.ts
│ └── exporters/
│ ├── MarkdownExporter.test.ts
│ └── JSONExporter.test.ts
├── reports/ ← generado en runtime, ignorado por git
├── logs/ ← generado en runtime, ignorado por git
├── package.json
├── tsconfig.json
├── jest.config.ts
└── CLAUDE.md
```
---
## Reglas de importación — MUY IMPORTANTE
```
✅ PERMITIDO:
src/core/ExplorationEngine.ts → importa de src/core/interfaces.ts
src/plugins/agents/PlaywrightAgent.ts → importa de src/core/interfaces.ts
src/index.ts → importa de src/core/ Y src/plugins/
❌ PROHIBIDO:
src/core/ExplorationEngine.ts → importa de src/plugins/ (rompe el desacoplamiento)
src/plugins/agents/A.ts → importa de src/plugins/exporters/B.ts (plugins no se conocen entre sí)
```
---
## Cómo se conecta todo en src/index.ts
El archivo de entrada debe seguir este patrón:
```typescript
// src/index.ts
import { ExplorationEngine } from './core/ExplorationEngine';
import { StateGraph } from './core/StateGraph';
import { PlaywrightAgent } from './plugins/agents/PlaywrightAgent';
import { ScreenshotCollector } from './plugins/collectors/ScreenshotCollector';
import { NetworkCollector } from './plugins/collectors/NetworkCollector';
import { DOMSnapshotCollector } from './plugins/collectors/DOMSnapshotCollector';
import { JSONExporter } from './plugins/exporters/JSONExporter';
import { MarkdownExporter } from './plugins/exporters/MarkdownExporter';
import { PlaywrightReproducer } from './plugins/reproducers/PlaywrightReproducer';
const graph = new StateGraph();
const agent = new PlaywrightAgent();
const collectors = [new ScreenshotCollector(), new NetworkCollector(), new DOMSnapshotCollector()];
const exporters = [new JSONExporter(), new MarkdownExporter()];
const reproducer = new PlaywrightReproducer();
const engine = new ExplorationEngine({ graph, agent, collectors, exporters, reproducer });
engine.run({ url: process.argv[2] || 'http://localhost:3000', seed: 42 });
```
---
## package.json — scripts obligatorios
```json
{
"name": "abe",
"version": "0.1.0",
"scripts": {
"build": "tsc",
"test": "jest",
"typecheck": "tsc --noEmit",
"lint": "eslint src/ tests/",
"explore": "ts-node src/index.ts",
"replay": "ts-node src/replay.ts"
}
}
```
---
## tsconfig.json — configuración base
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
```
---
## jest.config.ts — configuración base
```typescript
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
};
```
@@ -0,0 +1,79 @@
# ABE — Scheduled Monitoring Specification
## Concepto
ABE puede ejecutar exploraciones de forma automática en intervalos definidos,
sin intervención humana. Esto convierte ABE de una herramienta manual
en un sistema de monitorización continua, al estilo Checkly.
## Modelo de datos — añadir a SQLite
### Table: schedules
```sql
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
config_json TEXT NOT NULL,
cron_expression TEXT NOT NULL, -- e.g. "0 */6 * * *" (every 6h)
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at INTEGER,
next_run_at INTEGER,
created_at INTEGER NOT NULL
);
```
## Expresiones cron soportadas (presets en la UI)
| Label | Cron |
|------------------|----------------|
| Every 15 minutes | */15 * * * * |
| Every hour | 0 * * * * |
| Every 6 hours | 0 */6 * * * |
| Every day at 2am | 0 2 * * * |
| Every Monday 9am | 0 9 * * 1 |
## Implementación
Usar `node-cron` para el scheduler.
Crear `src/server/scheduler/SchedulerService.ts`:
- En startup, carga todos los schedules con enabled=1 de la DB
- Registra un cron job por cada schedule
- Cuando dispara, llama internamente a POST /api/sessions con la config guardada
- Actualiza last_run_at y next_run_at en la DB después de cada disparo
- Si la sesión anterior sigue running, skip este tick y log warning
## API endpoints nuevos
### GET /api/schedules
Lista todos los schedules.
### POST /api/schedules
Crea un nuevo schedule.
Body:
```json
{
"name": "Production daily check",
"url": "https://myapp.com",
"config": { ... mismo ExplorationConfig ... },
"cronExpression": "0 2 * * *",
"enabled": true
}
```
### PATCH /api/schedules/:id
Actualiza o activa/desactiva un schedule.
### DELETE /api/schedules/:id
Elimina un schedule.
## Frontend — nueva sección en Settings
Añadir tab "Schedules" en /settings:
- Lista de schedules activos con: nombre, URL, cron, última ejecución, próxima ejecución, toggle activo/inactivo
- Botón "New Schedule" abre modal con: nombre, URL, config de exploración, selector de frecuencia (presets + custom cron)
- Badge "Running" si hay una sesión activa del schedule en este momento
## Notificaciones específicas de schedules
Cuando un schedule dispara una exploración y encuentra anomalías high/critical,
enviar notificación con el subject: "[SCHEDULED] ABE found bugs in {url}"
+124
View File
@@ -0,0 +1,124 @@
# ABE — Visual Regression Testing Specification
## Concepto
ABE toma screenshots durante la exploración. En vez de solo guardarlos,
los compara contra una baseline aprobada para detectar cambios visuales
inesperados entre ejecuciones. Inspirado en Percy y Chromatic,
pero integrado directamente en el flujo de exploración autónoma.
## Cómo funciona
### Primera ejecución (sin baseline)
1. ABE explora el app, toma screenshots de cada estado descubierto
2. Todos los screenshots se marcan como "pending review" en la UI
3. El usuario aprueba o rechaza cada uno desde la GUI
4. Los aprobados se convierten en la BASELINE
### Ejecuciones posteriores
1. ABE explora el app, toma screenshots de cada estado
2. Para cada screenshot, busca la baseline correspondiente por state_id (hash DOM+URL)
3. Si no hay baseline: marcar como "new state", notificar
4. Si hay baseline: comparar usando pixelmatch (npm library)
5. Si diff > threshold (default 0.1%): crear anomalía tipo visual_regression
6. Si diff <= threshold: marcar como "passed"
## Librería de comparación
Usar `pixelmatch` (npm) para comparación pixel a pixel.
Usar `sharp` para resize y normalización de imágenes antes de comparar.
```typescript
import pixelmatch from 'pixelmatch';
import sharp from 'sharp';
async function compareScreenshots(
baselinePath: string,
currentPath: string,
diffOutputPath: string,
threshold: number = 0.1
): Promise<{ diffPixels: number; diffPercent: number; hasDiff: boolean }> {
// resize both to same dimensions, compare, generate diff image
}
```
## Modelo de datos — añadir a SQLite
### Table: visual_baselines
```sql
CREATE TABLE IF NOT EXISTS visual_baselines (
id TEXT PRIMARY KEY,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
screenshot_path TEXT NOT NULL,
approved_at INTEGER NOT NULL,
approved_by TEXT DEFAULT 'user',
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
```
### Table: visual_comparisons
```sql
CREATE TABLE IF NOT EXISTS visual_comparisons (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
baseline_id TEXT,
current_screenshot_path TEXT NOT NULL,
diff_screenshot_path TEXT,
diff_pixels INTEGER,
diff_percent REAL,
status TEXT NOT NULL, -- 'passed' | 'failed' | 'new_state' | 'pending'
created_at INTEGER NOT NULL
);
```
## Nuevo tipo de anomalía
Añadir a AnomalyDetector:
- type: `visual_regression`
- severity: calculado por diff_percent:
- < 1% → low
- 1-5% → medium
- 5-15% → high
- > 15% → critical
- description: "Visual regression detected: X% of pixels changed"
- evidence: baseline screenshot + current screenshot + diff image (highlighted in red)
## Nuevo endpoint de API
### GET /api/visual/comparisons
Lista todas las comparaciones de la sesión más reciente.
Query: ?status=failed&sessionId=xxx
### POST /api/visual/baselines/:comparisonId/approve
Aprueba un screenshot como nueva baseline.
### POST /api/visual/baselines/:comparisonId/reject
Rechaza (anomalía confirmada, no actualizar baseline).
### POST /api/visual/baselines/approve-all
Aprueba todos los "new_state" pendientes de una sesión.
## Frontend — nueva sección Visual Review
Nueva página /visual-review:
- Grid de cards, cada una muestra: URL del estado, thumbnail del screenshot actual
- Filtros: passed | failed | new_state | pending
- Click en una card abre modal con:
- Vista lado a lado: baseline izquierda, actual derecha
- Vista diff: imagen con píxeles cambiados en rojo
- Porcentaje de cambio
- Botones: Approve as new baseline | Mark as bug | Ignore
- Bulk actions: "Approve all new states", "Mark all failed as bugs"
## Configuración
Añadir a ExplorationConfig:
```typescript
visualRegression: {
enabled: boolean; // default: true
threshold: number; // default: 0.001 (0.1%)
screenshotFullPage: boolean; // default: false (solo viewport)
ignoreSelectors: string[]; // e.g. [".timestamp", ".ad-banner"] — excluir zonas dinámicas
}
```
+134
View File
@@ -0,0 +1,134 @@
# Phase 1: Shared Domain — Building Blocks
## Objetivo
Crear las clases base que TODOS los módulos usarán. Esto es el cimiento.
## Result.ts
```typescript
// Discriminated union, no classes
type ResultOk<T> = { readonly ok: true; readonly value: T };
type ResultErr<E> = { readonly ok: false; readonly error: E };
export type Result<T, E = Error> = ResultOk<T> | ResultErr<E>;
export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
export function isOk<T, E>(r: Result<T, E>): r is ResultOk<T> { return r.ok; }
export function isErr<T, E>(r: Result<T, E>): r is ResultErr<E> { return !r.ok; }
```
## UniqueId.ts
```typescript
import { v4 as uuidv4 } from 'uuid';
export class UniqueId {
private constructor(private readonly value: string) {}
static create(): UniqueId { return new UniqueId(uuidv4()); }
static from(value: string): UniqueId { return new UniqueId(value); }
toString(): string { return this.value; }
equals(other?: UniqueId): boolean {
if (!other) return false;
return this.value === other.value;
}
}
```
## Entity.ts
```typescript
export abstract class Entity<T> {
protected readonly _id: UniqueId;
protected props: T;
constructor(props: T, id?: UniqueId) {
this._id = id ?? UniqueId.create();
this.props = props;
}
get id(): UniqueId { return this._id; }
equals(other?: Entity<T>): boolean {
if (!other) return false;
return this._id.equals(other._id);
}
}
```
## AggregateRoot.ts
```typescript
export abstract class AggregateRoot<T> extends Entity<T> {
private _domainEvents: DomainEvent[] = [];
get domainEvents(): ReadonlyArray<DomainEvent> {
return this._domainEvents;
}
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
clearEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
```
## ValueObject.ts
```typescript
export abstract class ValueObject<T> {
protected readonly props: T;
constructor(props: T) {
this.props = Object.freeze(props);
}
equals(other?: ValueObject<T>): boolean {
if (!other) return false;
return JSON.stringify(this.props) === JSON.stringify(other.props);
}
}
```
## DomainEvent.ts
```typescript
export interface DomainEvent {
readonly eventId: string;
readonly eventName: string;
readonly aggregateId: string;
readonly occurredOn: Date;
readonly payload: Record<string, unknown>;
}
```
## UseCase.ts
```typescript
export interface UseCase<TRequest, TResponse, TError = Error> {
execute(request: TRequest): Promise<Result<TResponse, TError>>;
}
```
## EventBus.ts + EventHandler.ts
```typescript
// EventBus.ts
export interface EventBus {
publish(event: DomainEvent): Promise<void>;
subscribe(eventName: string, handler: EventHandler): void;
}
// EventHandler.ts
export interface EventHandler {
handle(event: DomainEvent): Promise<void>;
}
```
## Tests requeridos (mínimo)
1. Result: Ok crea value accesible, Err crea error accesible, isOk/isErr discriminan
2. UniqueId: create genera string válido, equals funciona, from preserva valor
3. Entity: equals compara por id (no por props)
4. ValueObject: equals compara por props, props son inmutables
## IMPORTANTE
- Estos archivos NO importan NADA externo excepto 'uuid'
- NO usar decorators
- NO usar classes abstractas complicadas — mantener simple
- Cada archivo exporta UNA cosa principal
@@ -0,0 +1,136 @@
# Phase 2: Shared Infrastructure
## Config.ts
Usa Zod para validar TODAS las env vars al arranque. Si falla → crash inmediato con mensaje claro.
```typescript
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const configSchema = z.object({
port: z.coerce.number().default(3001),
host: z.string().default('0.0.0.0'),
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
db: z.object({
driver: z.enum(['sqlite', 'postgres']).default('sqlite'),
path: z.string().default('./data/abe.db'),
url: z.string().optional(),
}),
auth: z.object({
secret: z.string().min(16).default('abe-dev-secret-change-in-prod'),
sessionMaxAge: z.coerce.number().default(86400),
}),
storage: z.object({
driver: z.enum(['local', 's3']).default('local'),
path: z.string().default('./data/storage'),
}),
cors: z.object({ origin: z.string().default('http://localhost:5173') }),
log: z.object({ level: z.enum(['debug','info','warn','error']).default('info') }),
api: z.object({
key: z.string().default('abe-dev-key-123'),
rateLimitWindowMs: z.coerce.number().default(900000),
rateLimitMax: z.coerce.number().default(100),
}),
ai: z.object({
provider: z.enum(['claude','openai','ollama','none']).default('none'),
apiKey: z.string().default(''),
autoEnrich: z.coerce.boolean().default(false),
minSeverity: z.enum(['low','medium','high','critical']).default('high'),
}),
jobs: z.object({
maxConcurrentSessions: z.coerce.number().default(3),
pollIntervalMs: z.coerce.number().default(1000),
}),
license: z.object({ key: z.string().default('') }),
});
export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(): AppConfig {
// Map env vars to schema shape, parse
}
```
## Logger.ts
```typescript
import pino from 'pino';
export function createLogger(config: { level: string; nodeEnv: string }): pino.Logger {
return pino({
level: config.level,
transport: config.nodeEnv === 'development'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
: undefined,
});
}
export type Logger = pino.Logger;
```
## DatabaseConnection.ts
```typescript
import { Kysely, SqliteDialect } from 'kysely';
import SQLite from 'better-sqlite3';
// Define Database interface con todas las tablas
export interface Database {
sessions: SessionTable;
states: StateTable;
actions: ActionTable;
anomalies: AnomalyTable;
// ... más tablas se añaden en fases posteriores
}
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {
if (config.driver === 'postgres') {
// Import dinámico de pg para no requerir en SQLite
const { Pool } = require('pg');
const { PostgresDialect } = require('kysely');
return new Kysely<Database>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: config.url }) }),
});
}
// Crear directorio data/ si no existe
const path = require('path');
const fs = require('fs');
fs.mkdirSync(path.dirname(config.path), { recursive: true });
return new Kysely<Database>({
dialect: new SqliteDialect({ database: new SQLite(config.path) }),
});
}
```
## InProcessEventBus.ts
```typescript
import { EventEmitter } from 'events';
// Implements EventBus interface from shared/application
// Logging de cada evento publicado
// Catch errors en handlers (log pero no crash)
// setMaxListeners(50)
```
## StorageProvider.ts
```typescript
export interface IStorageProvider {
save(relativePath: string, data: Buffer): Promise<string>;
get(relativePath: string): Promise<Buffer | null>;
delete(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>;
}
// LocalStorageProvider: usa fs.promises, base path = config.storage.path
// Crea directorios automáticamente con mkdir recursive
```
## Migración 001
Crea las tablas que ya existen en el schema actual (sessions, states, actions, anomalies, notifications).
Usar `CREATE TABLE IF NOT EXISTS` para idempotencia.
Los tipos de columna deben coincidir con lo que ya tiene better-sqlite3.
## IMPORTANTE
- Config DEBE fallar rápido si hay env vars inválidas
- Logger NUNCA debe usar console.log
- Database factory NUNCA importa pg a menos que driver sea postgres
- EventBus handlers que fallan se loguean pero NO crashean el bus
+216
View File
@@ -0,0 +1,216 @@
# Phase 7: API Server Refactor + Composition Root
## Middleware stack (ORDEN IMPORTA)
```typescript
// server.ts
export function createServer(deps: ServerDependencies): Express {
const app = express();
// 1. Request ID (PRIMERO — todo log necesita esto)
app.use(requestIdMiddleware);
// 2. Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", "ws:", "wss:"],
scriptSrc: ["'self'", "'unsafe-inline'"], // para Scalar docs
},
},
}));
// 3. CORS
app.use(cors({
origin: deps.config.cors.origin,
credentials: true,
}));
// 4. Rate limiting global
app.use(rateLimit({
windowMs: deps.config.api.rateLimitWindowMs,
max: deps.config.api.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
}));
// 5. Body parsing
app.use(express.json({ limit: '10mb' }));
// 6. Health endpoints (SIN auth)
app.get('/health/live', (_, res) => res.json({ status: 'ok' }));
app.get('/health/ready', async (_, res) => { /* check DB */ });
// 7. Auth routes (SIN auth middleware general)
app.use('/api/auth', deps.authController.router);
// 8. Auth middleware (TODOS los /api/ a partir de aquí)
app.use('/api', deps.authMiddleware);
// 9. Module routes
app.use('/api', deps.crawlingController.router);
app.use('/api', deps.findingsController.router);
app.use('/api', deps.fuzzingController.router);
// ... más módulos
// 10. 404 handler
app.use(notFoundMiddleware);
// 11. Error handler (SIEMPRE último)
app.use(globalErrorHandler);
return app;
}
```
## Error hierarchy
```typescript
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
public readonly isOperational = true,
) { super(message); }
}
export class ValidationError extends AppError {
constructor(message: string, public readonly details?: unknown) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class AuthenticationError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT');
}
}
export class RateLimitError extends AppError {
constructor() {
super('Too many requests', 429, 'RATE_LIMIT');
}
}
```
## Global error handler
```typescript
export function globalErrorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
const logger = req.log || console; // pino child logger
if (err instanceof AppError && err.isOperational) {
logger.warn({ err, statusCode: err.statusCode }, err.message);
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
...(err instanceof ValidationError && err.details ? { details: err.details } : {}),
});
}
// Programmer error — log full stack, return generic message
logger.error({ err }, 'Unhandled error');
return res.status(500).json({
error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
code: 'INTERNAL_ERROR',
});
}
```
## Composition root (main.ts)
```typescript
async function bootstrap() {
// 1. Config
const config = loadConfig();
// 2. Logger
const logger = createLogger(config);
logger.info({ port: config.port }, 'Starting ABE...');
// 3. Database + migrations
const db = createDatabase(config.db);
await runMigrations(db, logger);
// 4. Event bus
const eventBus = new InProcessEventBus(logger);
// 5. Storage
const storage = new LocalStorageProvider(config.storage.path);
// 6. Repositories
const sessionRepo = new KyselyCrawlSessionRepository(db);
const findingRepo = new KyselyFindingRepository(db);
// ... etc
// 7. Use cases
const startCrawl = new StartCrawlCommand(sessionRepo, eventBus);
const listFindings = new ListFindingsQuery(findingRepo);
// ... etc
// 8. Event handlers — subscribe to event bus
const onAnomalyDetected = new OnAnomalyDetected(findingRepo, eventBus);
eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected);
// ... etc
// 9. Controllers
const crawlingController = new CrawlingController(startCrawl, ...);
const findingsController = new FindingsController(listFindings, ...);
// ... etc
// 10. HTTP server
const app = createServer({ config, authMiddleware, crawlingController, findingsController, ... });
const httpServer = createServer(app);
// 11. Socket.io
const io = new Server(httpServer, { cors: { origin: config.cors.origin } });
const gateway = new SocketGateway(io, eventBus);
// 12. Job queue
const jobQueue = new SQLiteJobQueue(db, logger);
jobQueue.start();
// 13. Listen
httpServer.listen(config.port, config.host, () => {
logger.info({ port: config.port }, 'ABE server ready');
});
// 14. Graceful shutdown
async function shutdown(signal: string) {
logger.info({ signal }, 'Shutting down...');
httpServer.close();
io.close();
jobQueue.pause();
await jobQueue.waitForActive(30000);
await db.destroy();
logger.info('Shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
bootstrap().catch((err) => {
console.error('Fatal: failed to start ABE', err);
process.exit(1);
});
```
## IMPORTANTE
- El código existente en src/server/ debe DEJAR DE USARSE gradualmente
- Mantener los endpoints viejos funcionando durante la migración
- Cada controller es una clase con un `.router` getter que retorna Express.Router
- NUNCA meter lógica de negocio en controllers — solo parse request → call use case → format response
+66
View File
@@ -0,0 +1,66 @@
# Phase 8: Job Queue System
## Tabla jobs (SQLite)
```sql
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
payload TEXT NOT NULL,
result TEXT,
error TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
priority INTEGER NOT NULL DEFAULT 0,
run_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_poll ON jobs(status, run_at, priority DESC);
```
## Interface
```typescript
export interface IJobQueue {
enqueue<T>(type: string, payload: T, opts?: { runAt?: Date; priority?: number; maxAttempts?: number }): Promise<string>;
start(): void;
pause(): void;
waitForActive(timeoutMs: number): Promise<void>;
}
```
## Polling logic
```
loop (cada pollIntervalMs):
SELECT id, type, payload FROM jobs
WHERE status = 'pending' AND run_at <= datetime('now')
ORDER BY priority DESC, created_at ASC
LIMIT 1
if found:
UPDATE jobs SET status = 'running', started_at = now, attempts = attempts + 1
WHERE id = ? AND status = 'pending' // optimistic lock
if updated 0 rows → skip (otro worker lo tomó)
try:
result = await executeJob(type, payload)
UPDATE jobs SET status = 'completed', result = ?, completed_at = now
catch:
if attempts >= max_attempts:
UPDATE jobs SET status = 'failed', error = ?
else:
backoff = min(1000 * 2^attempts, 60000)
UPDATE jobs SET status = 'pending', run_at = now + backoff, error = ?
```
## Job types
- `exploration:run` — payload: { sessionId, config }
- `report:generate` — payload: { reportId, format, filters }
- `cleanup:old-data` — payload: { retentionDays }
## NO usar Redis
El job queue es SQLite-based para zero-dependency self-hosted.
Es simple, funciona para el volumen esperado (decenas de jobs, no miles).
+148
View File
@@ -0,0 +1,148 @@
# Phase 9: Auth Module
## Objetivo
Sistema completo de autenticación y autorización para ABE como plataforma.
## Roles y permisos
| Role | Sessions | Findings | Reports | Integrations | Org/Users | Settings | License |
|---------|----------|----------|---------|--------------|-----------|----------|---------|
| owner | CRUD | CRUD | CRUD | CRUD | CRUD | CRUD | CRUD |
| admin | CRUD | CRUD | CRUD | CRUD | CRU | CRUD | R |
| member | CR | CRU | CR | R | R | R | R |
| viewer | R | R | R | R | R | R | R |
## Better Auth config
```typescript
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
database: {
// Usar Kysely adapter o direct SQLite
type: 'sqlite',
url: config.db.path,
},
emailAndPassword: { enabled: true },
session: {
maxAge: config.auth.sessionMaxAge,
updateAge: 60 * 60, // refresh cada hora
},
// Organization plugin si disponible, sino implementar manual
});
```
Si Better Auth no soporta organizaciones directamente, implementar manualmente:
- Tabla organizations (id, name, slug, created_at)
- Tabla org_members (id, org_id, user_id, role, invited_at, joined_at)
## CASL AbilityFactory
```typescript
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
export function defineAbilityFor(role: string) {
const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
switch (role) {
case 'owner':
can('manage', 'all');
break;
case 'admin':
can('manage', 'all');
cannot('delete', 'Organization');
cannot('manage', 'License');
can('read', 'License');
break;
case 'member':
can('create', ['Session', 'Finding', 'Report']);
can('read', 'all');
can('update', 'Finding');
break;
case 'viewer':
can('read', 'all');
break;
}
return build();
}
```
## AuthMiddleware — orden de verificación
1. Check cookie de session (web UI) via Better Auth
2. Check header `Authorization: Bearer <jwt>`
3. Check header `X-ABE-API-Key: <key>` (API keys para CI/CD)
4. Si ninguno → 401
## API Key system
- POST /api/auth/api-keys — crear key (retorna key UNA vez, después solo hash)
- GET /api/auth/api-keys — listar (sin mostrar key, solo nombre + último uso)
- DELETE /api/auth/api-keys/:id — revocar
- Keys hasheadas con SHA-256 en DB
- Cada key tiene: name, permissions (array de roles), expiresAt, lastUsedAt
## First-run flow
1. Backend: si tabla users tiene 0 rows → flag `setupRequired = true`
2. GET /api/auth/setup-required → `{ required: boolean }`
3. Si required, POST /api/auth/setup con { email, password, name, orgName }
4. Crea user con role owner + organization default
5. Después de setup, requiere login normal
## Migraciones
```sql
-- users (Better Auth maneja su propia tabla, pero añadir campos custom)
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS org_members (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organizations(id),
user_id TEXT NOT NULL REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'member',
joined_at INTEGER NOT NULL,
UNIQUE(org_id, user_id)
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL, -- primeros 8 chars para identificar
permissions TEXT NOT NULL DEFAULT '["member"]',
expires_at INTEGER,
last_used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
token TEXT UNIQUE NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
```
## NOTA sobre Better Auth
Si Better Auth resulta demasiado complejo de integrar con Express puro
o tiene incompatibilidades, implementar auth manualmente:
- argon2 para hash passwords
- crypto.randomUUID() para session tokens
- Cookie httpOnly + secure + sameSite para sessions
- Middleware custom que lee cookie → busca en auth_sessions → adjunta user a req
Esto es PERFECTAMENTE VÁLIDO. No over-engineer la auth.
La prioridad es que funcione, sea seguro, y tenga RBAC.
+129
View File
@@ -0,0 +1,129 @@
# Phase 10: Frontend Shell — shadcn/ui
## Setup shadcn/ui
```bash
cd frontend
npx shadcn@latest init
# Responder: Vite, Zinc, CSS variables, YES to tailwind
```
## Layout principal
```
┌────────────────────────────────────────────────┐
│ TopBar: [☰] [ABE logo] ···· [⌘K Search] [🌙] [👤]│
├────────┬───────────────────────────────────────┤
│ │ │
│ Side- │ Content Area │
│ bar │ (React Router Outlet) │
│ │ │
│ 📊 Dashboard │
│ 🔍 Explorations │
│ 🐛 Findings │
│ 📄 Reports │
│ ───────── │
│ ⚙️ Settings │
│ │ │
└────────┴───────────────────────────────────────┘
```
## Dark mode (DEFAULT)
- Usar estrategia class-based: `<html class="dark">`
- CSS variables de shadcn ya soportan dark mode
- Toggle en TopBar: sol/luna
- Persistir en localStorage key 'abe-theme'
## Auth flow en frontend
```
App monta →
GET /api/auth/setup-required
→ si required: mostrar /setup
→ si no:
GET /api/auth/me
→ si 401: redirect /login
→ si ok: render AppLayout con user data
```
## API client (lib/api.ts)
```typescript
const API_URL = import.meta.env.VITE_API_URL || '';
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
...init,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
if (res.status === 401) {
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || `HTTP ${res.status}`);
}
return res.json();
}
```
## Routing
```typescript
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/setup" element={<Setup />} />
<Route element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route path="/" element={<Dashboard />} />
<Route path="/sessions" element={<SessionList />} />
<Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/findings" element={<FindingsList />} />
<Route path="/findings/:id" element={<FindingDetail />} />
<Route path="/reports" element={<Reports />} />
<Route path="/visual-review" element={<VisualReview />} />
<Route path="/settings/*" element={<Settings />} />
</Route>
</Routes>
```
## Command Palette (⌘K)
Powered by shadcn Command (cmdk):
- Buscar por: sessions, findings, settings sections
- Acciones: "New Exploration", "Generate Report"
- Keyboard: ⌘K abre, Esc cierra
## File structure
```
frontend/src/
├── components/
│ ├── ui/ # shadcn generados (NO tocar)
│ ├── layout/
│ │ ├── AppLayout.tsx
│ │ ├── AppSidebar.tsx
│ │ ├── TopBar.tsx
│ │ ├── CommandPalette.tsx
│ │ ├── ProtectedRoute.tsx
│ │ └── ThemeProvider.tsx
│ └── common/
│ └── SeverityBadge.tsx
├── hooks/
│ ├── useAuth.ts
│ └── useSocket.ts
├── lib/
│ ├── api.ts
│ ├── queryClient.ts
│ └── utils.ts # cn() de shadcn
├── stores/
│ └── uiStore.ts
├── pages/
│ ├── Dashboard.tsx (placeholder "Coming in Phase 11")
│ ├── Login.tsx
│ └── Setup.tsx
├── App.tsx
└── main.tsx
```
## IMPORTANTE
- El Dashboard en esta fase puede ser un placeholder que diga "Dashboard — Coming soon"
- Lo importante es que el shell funcione: login → sidebar → routing → theme
- NO intentar hacer todo el dashboard aquí — eso es Phase 11
+6 -37
View File
@@ -1,38 +1,7 @@
# .ralphrc - Ralph project configuration
# Generated by: ralph-setup
# Documentation: https://github.com/frankbria/ralph-claude-code
ALLOWED_TOOLS="bash,write,edit,read,glob,grep,todoread,todowrite"
AUTO_APPROVE=true
MAX_LOOPS=200
MODEL="claude-sonnet-4-20250514"
# Project identification
PROJECT_NAME="abe"
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
# Allow all bash commands including docker
ALLOW_ALL_BASH=true
+59
View File
@@ -0,0 +1,59 @@
# CLAUDE.md — Contexto para Claude Code
## Qué es ABE
ABE (Autonomous Bug Explorer) es una plataforma enterprise self-hosted de
descubrimiento autónomo de bugs en aplicaciones web.
## Estado actual
Fases 1-11 originales implementadas. Ahora refactorizando hacia arquitectura
modular hexagonal enterprise. Ver `.ralph/PROMPT.md` para detalles completos.
## Arquitectura
Modular monolith hexagonal con bounded contexts.
**Regla #1**: Domain NUNCA importa infrastructure.
**Regla #2**: Cross-module communication SOLO via EventBus.
**Regla #3**: Controllers son thin — delegan a use cases.
**Regla #4**: Use cases retornan Result<T, E>, nunca throw.
## Comandos principales
```bash
npm run build # build backend
cd frontend && npm run build # build frontend
npm run test # vitest tests
npm run lint # eslint
npm run db:migrate # kysely migrations
docker compose up -d --build # todo con Docker
```
## Verificación obligatoria después de cambios
```bash
npm run build && cd frontend && npm run build && cd .. && npm run test
```
## Commit después de cada tarea
```bash
git add -A && git commit -m "fase(X.Y): descripción"
```
## Stack
- Backend: Node 20, TypeScript strict, Express, socket.io, Kysely, better-sqlite3, Pino, Zod, Better Auth, CASL, Playwright
- Frontend: React 18, Vite, shadcn/ui, Tailwind, Tremor, Recharts, TanStack Query/Table, Zustand
## Estructura
```
src/shared/ → building blocks compartidos
src/modules/ → bounded contexts
src/api/ → Express server + middleware global
src/realtime/ → socket.io gateway
src/jobs/ → job queue SQLite-backed
src/cli/ → CLI
src/main.ts → composition root
frontend/ → React app
```
## Para desarrollo con Ralph
```bash
cat .ralph/fix_plan.md | grep -E "^\- \[" | head -30
ralph --monitor
```
+46
View File
@@ -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"]
+126 -1
View File
@@ -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
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Vendored
+252
View File
@@ -0,0 +1,252 @@
"use strict";
/**
* ABE CLI — command-line interface for running explorations.
* Usage: abe run --url http://localhost:3000
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const commander_1 = require("commander");
const ExplorationEngine_1 = require("./core/ExplorationEngine");
const StateGraph_1 = require("./core/StateGraph");
const PlaywrightAgent_1 = require("./plugins/agents/PlaywrightAgent");
const ScreenshotCollector_1 = require("./plugins/collectors/ScreenshotCollector");
const NetworkCollector_1 = require("./plugins/collectors/NetworkCollector");
const DOMSnapshotCollector_1 = require("./plugins/collectors/DOMSnapshotCollector");
const MarkdownExporter_1 = require("./plugins/exporters/MarkdownExporter");
const JSONExporter_1 = require("./plugins/exporters/JSONExporter");
const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer");
const ExplorationConfig_1 = require("./core/ExplorationConfig");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const program = new commander_1.Command();
program
.name('abe')
.description('Autonomous Bug Explorer — explore web apps and find bugs')
.version('0.1.0');
program
.command('run')
.description('Run an exploration session against a target URL')
.requiredOption('--url <url>', 'Target URL to explore')
.option('--seed <seed>', 'Deterministic seed', parseInt, 42)
.option('--max-states <n>', 'Max states to explore', parseInt, 50)
.option('--max-depth <n>', 'Max click depth', parseInt, 5)
.option('--allowed-domains <domains>', 'Comma-separated allowed domains')
.option('--excluded-paths <paths>', 'Comma-separated excluded paths')
.option('--action-delay <ms>', 'Delay between actions in ms', parseInt, 500)
.option('--session-timeout <ms>', 'Session timeout in ms', parseInt, 300000)
// Auth options
.option('--auth-type <type>', 'Auth type: cookies | headers | login_flow')
.option('--login-url <url>', 'Login page URL (for login_flow)')
.option('--username <user>', 'Username (for login_flow)')
.option('--password <pass>', 'Password (for login_flow)')
.option('--username-selector <sel>', 'Username field selector (for login_flow)')
.option('--password-selector <sel>', 'Password field selector (for login_flow)')
.option('--submit-selector <sel>', 'Submit button selector (for login_flow)')
// Output
.option('--output <format>', 'Output format: human | json | junit', 'human')
.option('--reports-dir <dir>', 'Output directory for reports', './reports')
// CI flags
.option('--fail-on-anomaly', 'Exit 1 if any anomaly found')
.option('--fail-on-severity <level>', 'Exit 1 if anomaly at or above severity found')
// Remote server
.option('--server <url>', 'Connect to remote ABE server instead of running inline')
.option('--api-key <key>', 'API key for remote server')
.action(async (opts) => {
const startMs = Date.now();
// Build auth config
let auth = null;
if (opts.authType === 'login_flow') {
auth = {
type: 'login_flow',
loginUrl: opts.loginUrl ?? '',
usernameSelector: opts.usernameSelector ?? 'input[type="email"]',
passwordSelector: opts.passwordSelector ?? 'input[type="password"]',
submitSelector: opts.submitSelector ?? 'button[type="submit"]',
username: opts.username ?? '',
password: opts.password ?? '',
};
}
else if (opts.authType === 'headers') {
auth = { type: 'headers', headers: {} };
}
else if (opts.authType === 'cookies') {
auth = { type: 'cookies', cookies: [] };
}
const config = {
...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG,
maxStates: opts.maxStates,
maxDepth: opts.maxDepth,
actionDelayMs: opts.actionDelay,
sessionTimeoutMs: opts.sessionTimeout,
allowedDomains: opts.allowedDomains
? opts.allowedDomains.split(',').map((d) => d.trim())
: [new URL(opts.url).hostname],
excludedPaths: opts.excludedPaths
? opts.excludedPaths.split(',').map((p) => p.trim())
: [],
auth,
};
// If remote server mode
if (opts.server) {
await runRemote(opts, config);
return;
}
const anomalies = [];
let exitCode = 0;
let explorationError;
try {
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed: opts.seed, explorationConfig: config });
const engine = new ExplorationEngine_1.ExplorationEngine({
graph,
agent,
seed: opts.seed,
url: opts.url,
maxSteps: opts.maxStates,
outputDir: opts.reportsDir,
explorationConfig: config,
collectors: [
new ScreenshotCollector_1.ScreenshotCollector(opts.reportsDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(opts.reportsDir),
],
exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()],
reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(),
events: {
onAnomalyDetected: (_, anomaly) => {
anomalies.push(anomaly);
},
onSessionError: (_, error) => {
explorationError = error;
},
},
});
await engine.run();
}
catch (err) {
explorationError = err instanceof Error ? err.message : String(err);
exitCode = 2;
}
if (explorationError && exitCode === 0)
exitCode = 2;
// Determine exit code from flags
if (exitCode === 0 && opts.failOnAnomaly && anomalies.length > 0) {
exitCode = 1;
}
if (exitCode === 0 && opts.failOnSeverity) {
const severityRank = { low: 0, medium: 1, high: 2, critical: 3 };
const threshold = severityRank[opts.failOnSeverity] ?? 0;
const failing = anomalies.filter((a) => (severityRank[a.severity] ?? 0) >= threshold);
if (failing.length > 0)
exitCode = 1;
}
const durationMs = Date.now() - startMs;
const summary = {
url: opts.url,
duration_ms: durationMs,
anomalies: anomalies.map((a) => ({
id: a.id,
type: a.type,
severity: a.severity,
description: a.description,
report_path: path.join(opts.reportsDir, a.id, 'report.json'),
})),
exit_code: exitCode,
};
if (opts.output === 'json') {
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
}
else if (opts.output === 'junit') {
const xml = buildJunit(summary, opts.url);
const outPath = path.join(process.cwd(), 'abe-results.xml');
fs.writeFileSync(outPath, xml, 'utf8');
if (opts.output !== 'json') {
console.log(`JUnit results written to ${outPath}`);
}
}
else {
if (anomalies.length === 0) {
console.log(`✓ ABE finished. No anomalies found. (${durationMs}ms)`);
}
else {
console.log(`⚠ ABE finished. ${anomalies.length} anomaly(ies) found:`);
for (const a of anomalies) {
console.log(` [${a.severity.toUpperCase()}] ${a.type}: ${a.description}`);
}
}
}
process.exit(exitCode);
});
async function runRemote(opts, _config) {
const serverUrl = opts['server'];
const apiKey = opts['apiKey'];
const url = opts['url'];
const headers = { 'Content-Type': 'application/json' };
if (apiKey)
headers['x-abe-api-key'] = apiKey;
const res = await fetch(`${serverUrl}/api/sessions`, {
method: 'POST',
headers,
body: JSON.stringify({ url }),
});
if (!res.ok) {
console.error(`Server error: ${res.status} ${await res.text()}`);
process.exit(2);
return;
}
const session = await res.json();
console.log(`Session started: ${session.sessionId}`);
process.exit(0);
}
function buildJunit(summary, url) {
const anomalyCount = summary.anomalies.length;
const cases = summary.anomalies
.map((a) => ` <testcase name="${escapeXml(a.description)}" classname="abe.anomaly.${escapeXml(a.type)}">\n` +
` <failure message="${escapeXml(a.description)}" type="${escapeXml(a.severity)}">${escapeXml(a.id)}</failure>\n` +
` </testcase>`)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="ABE Exploration: ${escapeXml(url)}" tests="${anomalyCount}" failures="${anomalyCount}">\n` +
cases + '\n' +
`</testsuite>\n`;
}
function escapeXml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
program.parse(process.argv);
+137
View File
@@ -0,0 +1,137 @@
"use strict";
/**
* AnomalyDetector — heuristic rules to detect anomalies from observations.
* Each rule is independent and testable in isolation.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AnomalyDetector = void 0;
let anomalyCounter = 0;
function makeId() {
anomalyCounter += 1;
return `anom_${Date.now()}_${anomalyCounter.toString().padStart(4, '0')}`;
}
class AnomalyDetector {
detect(observation, actionTrace) {
const anomalies = [];
const httpAnomaly = this.checkHttpErrors(observation, actionTrace);
if (httpAnomaly)
anomalies.push(httpAnomaly);
const jsAnomaly = this.checkJsExceptions(observation, actionTrace);
if (jsAnomaly)
anomalies.push(jsAnomaly);
const consoleAnomaly = this.checkConsoleErrors(observation, actionTrace);
if (consoleAnomaly)
anomalies.push(consoleAnomaly);
return anomalies;
}
/** Rule: HTTP 4xx or 5xx responses */
checkHttpErrors(observation, actionTrace) {
const errorResponses = observation.httpResponses.filter((r) => r.status >= 400);
if (errorResponses.length === 0)
return null;
const hasServerError = errorResponses.some((r) => r.status >= 500);
const severity = hasServerError ? 'high' : 'medium';
const statusCodes = errorResponses.map((r) => r.status).join(', ');
return this.buildAnomaly({
type: 'http_error',
severity,
observationId: observation.id,
actionTrace,
description: `HTTP error responses detected: ${statusCodes}`,
evidence: {
httpLog: errorResponses,
rawErrors: errorResponses.map((r) => `${r.method} ${r.url}${r.status} (${r.durationMs}ms)`),
},
});
}
/** Rule: uncaught JS exceptions */
checkJsExceptions(observation, actionTrace) {
if (observation.jsExceptions.length === 0)
return null;
return this.buildAnomaly({
type: 'js_exception',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Uncaught JS exception: ${observation.jsExceptions[0]}`,
evidence: {
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: console.error messages */
checkConsoleErrors(observation, actionTrace) {
if (observation.consoleErrors.length === 0)
return null;
return this.buildAnomaly({
type: 'console_error',
severity: 'low',
observationId: observation.id,
actionTrace,
description: `Console error detected: ${observation.consoleErrors[0]}`,
evidence: {
rawErrors: observation.consoleErrors,
},
});
}
/**
* Rule: server accepted clearly invalid/empty fuzz input (got 2xx).
* fuzzedValue is the value that was submitted; responseStatus is the HTTP response.
*/
checkValidationBypass(observation, actionTrace, fuzzedValue) {
const has2xx = observation.httpResponses.some((r) => r.status >= 200 && r.status < 300);
if (!has2xx)
return null;
return this.buildAnomaly({
type: 'validation_bypass',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Server accepted invalid input without error (value: ${JSON.stringify(fuzzedValue).substring(0, 50)})`,
evidence: { httpLog: observation.httpResponses, rawErrors: [`Fuzzed with: ${fuzzedValue}`] },
});
}
/** Rule: server returned 500 on a fuzzed input */
checkServerErrorOnFuzz(observation, actionTrace) {
const has5xx = observation.httpResponses.some((r) => r.status >= 500);
if (!has5xx)
return null;
return this.buildAnomaly({
type: 'server_error_on_fuzz',
severity: 'high',
observationId: observation.id,
actionTrace,
description: 'Server returned 5xx on fuzzed input',
evidence: {
httpLog: observation.httpResponses.filter((r) => r.status >= 500),
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: fuzzed script tag appears in response body (XSS reflection) */
checkXssReflection(observation, actionTrace, domSnapshot) {
if (!domSnapshot.includes('<script>alert(1)</script>'))
return null;
return this.buildAnomaly({
type: 'xss_reflection',
severity: 'critical',
observationId: observation.id,
actionTrace,
description: 'XSS reflection detected: fuzzed script tag appeared in DOM',
evidence: { rawErrors: ['XSS payload reflected in DOM'] },
});
}
buildAnomaly(params) {
return {
id: makeId(),
type: params.type,
severity: params.severity,
observationId: params.observationId,
actionTrace: params.actionTrace,
description: params.description,
evidence: params.evidence,
timestamp: Date.now(),
};
}
}
exports.AnomalyDetector = AnomalyDetector;
+53
View File
@@ -0,0 +1,53 @@
"use strict";
/**
* ExplorationConfig — defines scope, auth, fuzzing, multi-browser, a11y,
* performance, visual regression, and network chaos settings for a session.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_EXPLORATION_CONFIG = exports.NETWORK_PROFILES = void 0;
exports.NETWORK_PROFILES = {
'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false },
'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false },
'2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false },
'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true },
'none': null,
};
exports.DEFAULT_EXPLORATION_CONFIG = {
allowedDomains: [],
maxStates: 50,
maxDepth: 5,
actionDelayMs: 500,
sessionTimeoutMs: 300000,
excludedPaths: [],
excludedSelectors: [],
auth: null,
fuzzingEnabled: true,
fuzzingIntensity: 'medium',
browsers: ['chromium'],
mobileDevice: 'none',
viewport: null,
accessibility: {
enabled: true,
minImpact: 'serious',
wcagLevel: 'AA',
},
performance: {
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
},
visualRegression: {
enabled: false,
threshold: 0.001,
screenshotFullPage: false,
ignoreSelectors: [],
},
networkChaos: {
enabled: false,
profile: 'none',
blockedEndpoints: [],
slowEndpoints: [],
},
};
+197
View File
@@ -0,0 +1,197 @@
"use strict";
/**
* ExplorationEngine — the core loop of ABE.
* Selects states, executes actions, records observations, and detects anomalies.
* Depends only on core interfaces — never imports concrete plugins.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ExplorationEngine = void 0;
const AnomalyDetector_1 = require("./AnomalyDetector");
const Logger_1 = require("./Logger");
class ExplorationEngine {
constructor(config) {
/** Accumulated action trace for the current session */
this.actionTrace = [];
/** Set to true to abort the running loop */
this.aborted = false;
this.graph = config.graph;
this.agent = config.agent;
this.detector = config.detector ?? new AnomalyDetector_1.AnomalyDetector();
this.collectors = config.collectors ?? [];
this.exporters = config.exporters ?? [];
this.reproducer = config.reproducer;
this.logger = config.logger ?? new Logger_1.NullLogger();
this.seed = config.seed;
this.url = config.url;
this.maxSteps = config.maxSteps ?? 100;
this.outputDir = config.outputDir ?? './reports';
this.events = config.events ?? {};
this.sessionId = config.sessionId ?? `${Date.now()}_${config.seed}`;
this.explorationConfig = config.explorationConfig ?? {};
this.fuzzingPlugin = config.fuzzingPlugin;
this.stateHooks = config.stateHooks ?? [];
}
/** Signals the engine to stop after the current step completes. */
stop() {
this.aborted = true;
}
async run() {
const anomalies = [];
let stepsExecuted = 0;
let depth = 0;
const sessionTimeoutMs = this.explorationConfig.sessionTimeoutMs ?? 0;
const maxDepth = this.explorationConfig.maxDepth ?? Infinity;
const sessionStart = Date.now();
this.logger.log({
event: 'session_start',
timestamp: sessionStart,
seed: this.seed,
target: this.url,
});
this.events.onSessionStarted?.(this.sessionId, this.url);
const isTimedOut = () => sessionTimeoutMs > 0 && Date.now() - sessionStart >= sessionTimeoutMs;
try {
await this.agent.launch(this.url);
// Capture initial state
const initialState = await this.agent.captureState();
this.graph.addState(initialState);
this.logger.log({
event: 'state_discovered',
timestamp: Date.now(),
stateId: initialState.id,
url: initialState.url,
title: initialState.title,
});
this.events.onStateDiscovered?.(this.sessionId, initialState.id, initialState.url, initialState.title);
while (stepsExecuted < this.maxSteps && !this.aborted && !isTimedOut() && depth <= maxDepth) {
const currentState = this.graph.getNextToExplore();
if (!currentState)
break;
// Mark state as being explored
this.graph.incrementVisit(currentState.id);
// Discover available actions in this state
const actions = await this.agent.discoverActions(currentState);
if (actions.length === 0)
continue;
// Select action deterministically using seed + step count
const actionIndex = (this.seed + stepsExecuted) % actions.length;
const action = actions[actionIndex];
this.logger.log({
event: 'action_executed',
timestamp: Date.now(),
actionId: action.id,
type: action.type,
selector: action.selector,
value: action.value,
url: action.url,
});
this.events.onActionExecuted?.(this.sessionId, action.type, action.selector, Date.now());
// Execute action and capture observation
const observation = await this.agent.executeAction(action);
this.actionTrace.push(action);
// Record new state if discovered
if (!this.graph.hasState(observation.newStateId)) {
const newState = await this.agent.captureState();
this.graph.addState(newState);
depth += 1;
this.logger.log({
event: 'state_discovered',
timestamp: Date.now(),
stateId: newState.id,
url: newState.url,
title: newState.title,
});
this.events.onStateDiscovered?.(this.sessionId, newState.id, newState.url, newState.title);
// Run per-state hooks (visual regression, accessibility, performance)
for (const hook of this.stateHooks) {
const hookAnomalies = await hook(newState, this.agent, this.sessionId, [...this.actionTrace]).catch(() => []);
for (const anomaly of hookAnomalies) {
anomalies.push(anomaly);
this.logger.log({
event: 'anomaly_detected',
timestamp: Date.now(),
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
});
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`);
}
}
}
}
this.graph.recordTransition(currentState.id, action, observation.newStateId);
this.logger.log({
event: 'exploration_step',
timestamp: Date.now(),
stateId: currentState.id,
actionId: action.id,
});
// Detect anomalies
const detected = this.detector.detect(observation, [...this.actionTrace]);
for (const anomaly of detected) {
for (const collector of this.collectors) {
const evidence = await collector.collect(anomaly, this.agent);
Object.assign(anomaly.evidence, evidence);
}
anomalies.push(anomaly);
this.logger.log({
event: 'anomaly_detected',
timestamp: Date.now(),
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
});
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
const reportDir = `${this.outputDir}/${anomaly.id}`;
await exporter.export(anomaly, reportDir);
}
}
stepsExecuted += 1;
// Run fuzzing if enabled and plugin provided
if (this.fuzzingPlugin &&
this.explorationConfig.fuzzingEnabled !== false &&
currentState.domSnapshot) {
const fuzzActions = this.fuzzingPlugin.generateFuzzActions(currentState.domSnapshot, currentState);
for (const fuzzAction of fuzzActions) {
if (this.aborted || isTimedOut())
break;
const fuzzObs = await this.agent.executeAction(fuzzAction);
this.actionTrace.push(fuzzAction);
const fuzzAnomalies = this.detector.detect(fuzzObs, [...this.actionTrace]);
for (const anomaly of fuzzAnomalies) {
for (const collector of this.collectors) {
const evidence = await collector.collect(anomaly, this.agent);
Object.assign(anomaly.evidence, evidence);
}
anomalies.push(anomaly);
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`);
}
}
}
}
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.events.onSessionError?.(this.sessionId, msg);
await this.agent.close().catch(() => undefined);
throw err;
}
await this.agent.close();
const statesVisited = this.graph.getAllStates().filter((s) => s.visitCount > 0).length;
this.logger.log({
event: 'session_end',
timestamp: Date.now(),
statesVisited,
anomaliesFound: anomalies.length,
});
this.events.onSessionCompleted?.(this.sessionId, statesVisited, anomalies.length);
return { statesVisited, anomaliesFound: anomalies.length, anomalies };
}
}
exports.ExplorationEngine = ExplorationEngine;
+66
View File
@@ -0,0 +1,66 @@
"use strict";
/**
* Logger — writes structured JSON log events to a .jsonl file.
* One JSON object per line.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.NullLogger = exports.FileLogger = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class FileLogger {
constructor(logDir, sessionId) {
const logPath = path.join(logDir, `session_${sessionId}.jsonl`);
fs.mkdirSync(logDir, { recursive: true });
this.stream = fs.createWriteStream(logPath, { flags: 'a' });
}
log(event) {
this.stream.write(JSON.stringify(event) + '\n');
}
close() {
this.stream.end();
}
}
exports.FileLogger = FileLogger;
/** No-op logger for testing */
class NullLogger {
constructor() {
this.events = [];
}
log(event) {
this.events.push(event);
}
}
exports.NullLogger = NullLogger;
+83
View File
@@ -0,0 +1,83 @@
"use strict";
/**
* StateGraph — manages known states and transitions between them.
* Uses BFS ordering by default for exploration scheduling.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.StateGraph = void 0;
class StateGraph {
constructor() {
this.states = new Map();
this.transitions = [];
/** Insertion order for BFS */
this.insertionOrder = [];
}
addState(state) {
if (!this.states.has(state.id)) {
this.states.set(state.id, state);
this.insertionOrder.push(state.id);
}
else {
// Update visit count on revisit
const existing = this.states.get(state.id);
this.states.set(state.id, { ...existing, visitCount: existing.visitCount + 1 });
}
}
hasState(stateId) {
return this.states.has(stateId);
}
getState(stateId) {
return this.states.get(stateId);
}
incrementVisit(stateId) {
const state = this.states.get(stateId);
if (state) {
this.states.set(stateId, { ...state, visitCount: state.visitCount + 1 });
}
}
recordTransition(fromId, action, toId) {
this.transitions.push({
fromId,
action,
toId,
timestamp: Date.now(),
});
}
/** Returns all states that have never been visited (visitCount === 0) */
getUnvisited() {
return this.insertionOrder
.map((id) => this.states.get(id))
.filter((s) => s.visitCount === 0);
}
/** BFS heuristic: returns the oldest unvisited state, or null if none */
getNextToExplore() {
const unvisited = this.getUnvisited();
return unvisited.length > 0 ? unvisited[0] : null;
}
getAllStates() {
return this.insertionOrder.map((id) => this.states.get(id));
}
getTransitions() {
return [...this.transitions];
}
toJSON() {
return {
stateCount: this.states.size,
transitionCount: this.transitions.length,
states: this.getAllStates().map((s) => ({
id: s.id,
url: s.url,
title: s.title,
visitCount: s.visitCount,
})),
transitions: this.transitions.map((t) => ({
fromId: t.fromId,
toId: t.toId,
actionId: t.action.id,
actionType: t.action.type,
timestamp: t.timestamp,
})),
};
}
}
exports.StateGraph = StateGraph;
+6
View File
@@ -0,0 +1,6 @@
"use strict";
/**
* ABE Core Interfaces
* Core data types only. Must NOT import from src/plugins/.
*/
Object.defineProperty(exports, "__esModule", { value: true });
+76
View File
@@ -0,0 +1,76 @@
"use strict";
/**
* AnomalyRepository — CRUD for anomalies table with filters.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AnomalyRepository = void 0;
function rowToAnomaly(row) {
return {
id: row.id,
sessionId: row.session_id,
type: row.type,
severity: row.severity,
description: row.description,
actionTrace: JSON.parse(row.action_trace_json),
evidence: {
...JSON.parse(row.evidence_json),
screenshotPath: row.screenshot_path ?? undefined,
domSnapshotPath: row.dom_snapshot_path ?? undefined,
},
observationId: '',
timestamp: row.detected_at,
};
}
class AnomalyRepository {
constructor(db) {
this.db = db;
}
create(anomaly, sessionId) {
this.db
.prepare(`INSERT INTO anomalies
(id, session_id, type, severity, description, action_trace_json, evidence_json, screenshot_path, dom_snapshot_path, detected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(anomaly.id, sessionId, anomaly.type, anomaly.severity, anomaly.description, JSON.stringify(anomaly.actionTrace), JSON.stringify({ httpLog: anomaly.evidence.httpLog, rawErrors: anomaly.evidence.rawErrors }), anomaly.evidence.screenshotPath ?? null, anomaly.evidence.domSnapshotPath ?? null, anomaly.timestamp);
}
findById(id) {
const row = this.db
.prepare('SELECT * FROM anomalies WHERE id = ?')
.get(id);
return row ? rowToAnomaly(row) : undefined;
}
findAll(filters) {
const conditions = [];
const values = [];
if (filters?.sessionId) {
conditions.push('session_id = ?');
values.push(filters.sessionId);
}
if (filters?.severity) {
conditions.push('severity = ?');
values.push(filters.severity);
}
if (filters?.type) {
conditions.push('type = ?');
values.push(filters.type);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const rows = this.db
.prepare(`SELECT * FROM anomalies ${where} ORDER BY detected_at DESC`)
.all(...values);
return rows.map(rowToAnomaly);
}
countBySeverity(severities) {
if (severities.length === 0)
return 0;
const placeholders = severities.map(() => '?').join(', ');
const result = this.db
.prepare(`SELECT COUNT(*) as cnt FROM anomalies WHERE severity IN (${placeholders})`)
.get(...severities);
return result.cnt;
}
count() {
const result = this.db.prepare('SELECT COUNT(*) as cnt FROM anomalies').get();
return result.cnt;
}
}
exports.AnomalyRepository = AnomalyRepository;
+82
View File
@@ -0,0 +1,82 @@
"use strict";
/**
* ScheduleRepository — CRUD for schedules table.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ScheduleRepository = void 0;
function rowToRecord(row) {
return {
id: row.id,
name: row.name,
url: row.url,
configJson: row.config_json,
cronExpression: row.cron_expression,
enabled: row.enabled === 1,
lastRunAt: row.last_run_at,
nextRunAt: row.next_run_at,
createdAt: row.created_at,
};
}
class ScheduleRepository {
constructor(db) {
this.db = db;
}
create(params) {
this.db
.prepare(`INSERT INTO schedules (id, name, url, config_json, cron_expression, enabled, next_run_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(params.id, params.name, params.url, params.configJson, params.cronExpression, params.enabled !== false ? 1 : 0, params.nextRunAt ?? null, Date.now());
}
findById(id) {
const row = this.db
.prepare('SELECT * FROM schedules WHERE id = ?')
.get(id);
return row ? rowToRecord(row) : undefined;
}
findAll(enabledOnly = false) {
const rows = enabledOnly
? this.db.prepare('SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC').all()
: this.db.prepare('SELECT * FROM schedules ORDER BY created_at DESC').all();
return rows.map(rowToRecord);
}
update(id, fields) {
const sets = [];
const values = [];
if (fields.name !== undefined) {
sets.push('name = ?');
values.push(fields.name);
}
if (fields.url !== undefined) {
sets.push('url = ?');
values.push(fields.url);
}
if (fields.configJson !== undefined) {
sets.push('config_json = ?');
values.push(fields.configJson);
}
if (fields.cronExpression !== undefined) {
sets.push('cron_expression = ?');
values.push(fields.cronExpression);
}
if (fields.enabled !== undefined) {
sets.push('enabled = ?');
values.push(fields.enabled ? 1 : 0);
}
if (fields.lastRunAt !== undefined) {
sets.push('last_run_at = ?');
values.push(fields.lastRunAt);
}
if (fields.nextRunAt !== undefined) {
sets.push('next_run_at = ?');
values.push(fields.nextRunAt);
}
if (sets.length === 0)
return;
values.push(id);
this.db.prepare(`UPDATE schedules SET ${sets.join(', ')} WHERE id = ?`).run(...values);
}
delete(id) {
this.db.prepare('DELETE FROM schedules WHERE id = ?').run(id);
}
}
exports.ScheduleRepository = ScheduleRepository;
+53
View File
@@ -0,0 +1,53 @@
"use strict";
/**
* SessionRepository — CRUD for sessions table.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SessionRepository = void 0;
class SessionRepository {
constructor(db) {
this.db = db;
}
create(params) {
this.db
.prepare(`INSERT INTO sessions (id, url, status, seed, max_states, started_at, config_json)
VALUES (?, ?, 'running', ?, ?, ?, ?)`)
.run(params.id, params.url, params.seed, params.maxStates, params.startedAt, params.configJson ?? '{}');
}
findById(id) {
return this.db
.prepare('SELECT * FROM sessions WHERE id = ?')
.get(id);
}
findAll() {
return this.db.prepare('SELECT * FROM sessions ORDER BY started_at DESC').all();
}
update(id, fields) {
const sets = [];
const values = [];
if (fields.status !== undefined) {
sets.push('status = ?');
values.push(fields.status);
}
if (fields.statesVisited !== undefined) {
sets.push('states_visited = ?');
values.push(fields.statesVisited);
}
if (fields.anomaliesFound !== undefined) {
sets.push('anomalies_found = ?');
values.push(fields.anomaliesFound);
}
if (fields.finishedAt !== undefined) {
sets.push('finished_at = ?');
values.push(fields.finishedAt);
}
if (sets.length === 0)
return;
values.push(id);
this.db.prepare(`UPDATE sessions SET ${sets.join(', ')} WHERE id = ?`).run(...values);
}
delete(id) {
this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
}
}
exports.SessionRepository = SessionRepository;
+77
View File
@@ -0,0 +1,77 @@
"use strict";
/**
* VisualBaselineRepository — CRUD for visual_baselines and visual_comparisons tables.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.VisualBaselineRepository = void 0;
class VisualBaselineRepository {
constructor(db) {
this.db = db;
}
// ─── Baselines ────────────────────────────────────────────────────────────
createBaseline(params) {
this.db.prepare(`
INSERT OR REPLACE INTO visual_baselines (id, state_id, url, screenshot_path, approved_at, approved_by, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(params.id, params.stateId, params.url, params.screenshotPath, Date.now(), params.approvedBy ?? 'user', params.width, params.height);
}
findBaselineByStateId(stateId) {
return this.db
.prepare('SELECT * FROM visual_baselines WHERE state_id = ? ORDER BY approved_at DESC LIMIT 1')
.get(stateId);
}
findBaselineById(id) {
return this.db
.prepare('SELECT * FROM visual_baselines WHERE id = ?')
.get(id);
}
// ─── Comparisons ──────────────────────────────────────────────────────────
createComparison(params) {
this.db.prepare(`
INSERT INTO visual_comparisons
(id, session_id, state_id, baseline_id, current_screenshot_path, diff_screenshot_path, diff_pixels, diff_percent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(params.id, params.sessionId, params.stateId, params.baselineId ?? null, params.currentScreenshotPath, params.diffScreenshotPath ?? null, params.diffPixels ?? null, params.diffPercent ?? null, params.status, Date.now());
}
findComparisonById(id) {
return this.db
.prepare('SELECT * FROM visual_comparisons WHERE id = ?')
.get(id);
}
findComparisons(filters) {
const conditions = [];
const values = [];
if (filters?.sessionId) {
conditions.push('session_id = ?');
values.push(filters.sessionId);
}
if (filters?.status) {
conditions.push('status = ?');
values.push(filters.status);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return this.db
.prepare(`SELECT * FROM visual_comparisons ${where} ORDER BY created_at DESC`)
.all(...values);
}
updateComparisonStatus(id, status) {
this.db.prepare('UPDATE visual_comparisons SET status = ? WHERE id = ?').run(status, id);
}
promoteToBaseline(comparisonId) {
const comparison = this.findComparisonById(comparisonId);
if (!comparison)
return null;
const baselineId = `baseline_${Date.now()}`;
this.createBaseline({
id: baselineId,
stateId: comparison.state_id,
url: comparison.session_id,
screenshotPath: comparison.current_screenshot_path,
width: 1280,
height: 720,
});
this.updateComparisonStatus(comparisonId, 'passed');
return baselineId;
}
}
exports.VisualBaselineRepository = VisualBaselineRepository;
+43
View File
@@ -0,0 +1,43 @@
"use strict";
/**
* ABE Database Connection
* Singleton SQLite connection using better-sqlite3.
* Runs migrations on first access.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getDb = getDb;
exports.setDb = setDb;
exports.closeDb = closeDb;
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const migrations_1 = require("./migrations");
let _db = null;
function getDb() {
if (_db)
return _db;
const dbPath = process.env['ABE_DB_PATH'] ?? path_1.default.join(process.cwd(), 'data', 'abe.db');
const dir = path_1.default.dirname(dbPath);
if (!fs_1.default.existsSync(dir)) {
fs_1.default.mkdirSync(dir, { recursive: true });
}
_db = new better_sqlite3_1.default(dbPath);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
(0, migrations_1.runMigrations)(_db);
return _db;
}
/** For testing — inject a custom (in-memory) database instance. */
function setDb(db) {
_db = db;
}
/** Close and reset. Used in tests. */
function closeDb() {
if (_db) {
_db.close();
_db = null;
}
}
+126
View File
@@ -0,0 +1,126 @@
"use strict";
/**
* ABE Database Migrations
* Creates all tables if they do not exist.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.runMigrations = runMigrations;
function runMigrations(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
seed INTEGER NOT NULL,
max_states INTEGER NOT NULL DEFAULT 50,
states_visited INTEGER NOT NULL DEFAULT 0,
anomalies_found INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL,
finished_at INTEGER,
config_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS states (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
url TEXT NOT NULL,
title TEXT NOT NULL,
dom_snapshot_path TEXT,
visit_count INTEGER NOT NULL DEFAULT 0,
discovered_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS actions (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
state_id TEXT NOT NULL REFERENCES states(id),
type TEXT NOT NULL,
selector TEXT,
value TEXT,
url TEXT,
seed INTEGER NOT NULL,
executed_at INTEGER NOT NULL,
sequence_order INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS anomalies (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
type TEXT NOT NULL,
severity TEXT NOT NULL,
description TEXT NOT NULL,
action_trace_json TEXT NOT NULL,
evidence_json TEXT NOT NULL,
screenshot_path TEXT,
dom_snapshot_path TEXT,
detected_at INTEGER NOT NULL,
ai_enrichment_json TEXT,
ai_enriched_at INTEGER,
browser TEXT,
browser_version TEXT
);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
anomaly_id TEXT NOT NULL REFERENCES anomalies(id),
channel TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
sent_at INTEGER,
error TEXT
);
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
config_json TEXT NOT NULL,
cron_expression TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at INTEGER,
next_run_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS visual_baselines (
id TEXT PRIMARY KEY,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
screenshot_path TEXT NOT NULL,
approved_at INTEGER NOT NULL,
approved_by TEXT DEFAULT 'user',
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS visual_comparisons (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
baseline_id TEXT,
current_screenshot_path TEXT NOT NULL,
diff_screenshot_path TEXT,
diff_pixels INTEGER,
diff_percent REAL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS performance_metrics (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
ttfb INTEGER,
dom_content_loaded INTEGER,
load_complete INTEGER,
lcp INTEGER,
cls REAL,
fid INTEGER,
inp INTEGER,
total_requests INTEGER,
failed_requests INTEGER,
total_transfer_size INTEGER,
captured_at INTEGER NOT NULL
);
`);
}
+135
View File
@@ -0,0 +1,135 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema.createTable('sessions')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('url', 'text', col => col.notNull())
.addColumn('status', 'text', col => col.notNull().defaultTo('running'))
.addColumn('seed', 'integer', col => col.notNull())
.addColumn('max_states', 'integer', col => col.notNull().defaultTo(50))
.addColumn('states_visited', 'integer', col => col.notNull().defaultTo(0))
.addColumn('anomalies_found', 'integer', col => col.notNull().defaultTo(0))
.addColumn('started_at', 'integer', col => col.notNull())
.addColumn('finished_at', 'integer')
.addColumn('config_json', 'text', col => col.notNull().defaultTo('{}'))
.execute();
await db.schema.createTable('states')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull().references('sessions.id'))
.addColumn('url', 'text', col => col.notNull())
.addColumn('title', 'text', col => col.notNull())
.addColumn('dom_snapshot_path', 'text')
.addColumn('visit_count', 'integer', col => col.notNull().defaultTo(0))
.addColumn('discovered_at', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('actions')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull().references('sessions.id'))
.addColumn('state_id', 'text', col => col.notNull().references('states.id'))
.addColumn('type', 'text', col => col.notNull())
.addColumn('selector', 'text')
.addColumn('value', 'text')
.addColumn('url', 'text')
.addColumn('seed', 'integer', col => col.notNull())
.addColumn('executed_at', 'integer', col => col.notNull())
.addColumn('sequence_order', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('anomalies')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull().references('sessions.id'))
.addColumn('type', 'text', col => col.notNull())
.addColumn('severity', 'text', col => col.notNull())
.addColumn('description', 'text', col => col.notNull())
.addColumn('action_trace_json', 'text', col => col.notNull())
.addColumn('evidence_json', 'text', col => col.notNull())
.addColumn('screenshot_path', 'text')
.addColumn('dom_snapshot_path', 'text')
.addColumn('detected_at', 'integer', col => col.notNull())
.addColumn('ai_enrichment_json', 'text')
.addColumn('ai_enriched_at', 'integer')
.addColumn('browser', 'text')
.addColumn('browser_version', 'text')
.execute();
await db.schema.createTable('notifications')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('anomaly_id', 'text', col => col.notNull().references('anomalies.id'))
.addColumn('channel', 'text', col => col.notNull())
.addColumn('status', 'text', col => col.notNull().defaultTo('pending'))
.addColumn('sent_at', 'integer')
.addColumn('error', 'text')
.execute();
await db.schema.createTable('schedules')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('name', 'text', col => col.notNull())
.addColumn('url', 'text', col => col.notNull())
.addColumn('config_json', 'text', col => col.notNull())
.addColumn('cron_expression', 'text', col => col.notNull())
.addColumn('enabled', 'integer', col => col.notNull().defaultTo(1))
.addColumn('last_run_at', 'integer')
.addColumn('next_run_at', 'integer')
.addColumn('created_at', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('visual_baselines')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('state_id', 'text', col => col.notNull())
.addColumn('url', 'text', col => col.notNull())
.addColumn('screenshot_path', 'text', col => col.notNull())
.addColumn('approved_at', 'integer', col => col.notNull())
.addColumn('approved_by', 'text')
.addColumn('width', 'integer', col => col.notNull())
.addColumn('height', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('visual_comparisons')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull())
.addColumn('state_id', 'text', col => col.notNull())
.addColumn('baseline_id', 'text')
.addColumn('current_screenshot_path', 'text', col => col.notNull())
.addColumn('diff_screenshot_path', 'text')
.addColumn('diff_pixels', 'integer')
.addColumn('diff_percent', 'real')
.addColumn('status', 'text', col => col.notNull())
.addColumn('created_at', 'integer', col => col.notNull())
.execute();
await db.schema.createTable('performance_metrics')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull())
.addColumn('state_id', 'text', col => col.notNull())
.addColumn('url', 'text', col => col.notNull())
.addColumn('ttfb', 'integer')
.addColumn('dom_content_loaded', 'integer')
.addColumn('load_complete', 'integer')
.addColumn('lcp', 'integer')
.addColumn('cls', 'real')
.addColumn('fid', 'integer')
.addColumn('inp', 'integer')
.addColumn('total_requests', 'integer')
.addColumn('failed_requests', 'integer')
.addColumn('total_transfer_size', 'integer')
.addColumn('captured_at', 'integer', col => col.notNull())
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('performance_metrics').ifExists().execute();
await db.schema.dropTable('visual_comparisons').ifExists().execute();
await db.schema.dropTable('visual_baselines').ifExists().execute();
await db.schema.dropTable('schedules').ifExists().execute();
await db.schema.dropTable('notifications').ifExists().execute();
await db.schema.dropTable('anomalies').ifExists().execute();
await db.schema.dropTable('actions').ifExists().execute();
await db.schema.dropTable('states').ifExists().execute();
await db.schema.dropTable('sessions').ifExists().execute();
}
+29
View File
@@ -0,0 +1,29 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema.createTable('findings')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('session_id', 'text', col => col.notNull().references('sessions.id'))
.addColumn('type', 'text', col => col.notNull())
.addColumn('severity', 'text', col => col.notNull())
.addColumn('description', 'text', col => col.notNull())
.addColumn('status', 'text', col => col.notNull().defaultTo('open'))
.addColumn('action_trace_json', 'text', col => col.notNull())
.addColumn('evidence_json', 'text', col => col.notNull())
.addColumn('screenshot_path', 'text')
.addColumn('dom_snapshot_path', 'text')
.addColumn('browser', 'text')
.addColumn('browser_version', 'text')
.addColumn('ai_enrichment_json', 'text')
.addColumn('created_at', 'integer', col => col.notNull())
.addColumn('resolved_at', 'integer')
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('findings').ifExists().execute();
}
+31
View File
@@ -0,0 +1,31 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.runMigrations = runMigrations;
const kysely_1 = require("kysely");
const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises"));
async function runMigrations(db) {
const migrator = new kysely_1.Migrator({
db,
provider: new kysely_1.FileMigrationProvider({
fs: promises_1.default,
path: path_1.default,
migrationFolder: path_1.default.join(__dirname, 'migrations'),
}),
});
const { error, results } = await migrator.migrateToLatest();
results?.forEach(result => {
if (result.status === 'Success') {
console.log(`Migration "${result.migrationName}" executed successfully`);
}
else if (result.status === 'Error') {
console.error(`Migration "${result.migrationName}" failed`);
}
});
if (error) {
throw error;
}
}
+86
View File
@@ -0,0 +1,86 @@
"use strict";
/**
* ABE — Autonomous Bug Explorer
* Entry point: wires all components together and starts exploration.
*
* Usage:
* npm run explore -- --url http://localhost:3000 --output ./reports
* ts-node src/index.ts http://localhost:3000
*/
Object.defineProperty(exports, "__esModule", { value: true });
const ExplorationEngine_1 = require("./core/ExplorationEngine");
const StateGraph_1 = require("./core/StateGraph");
const Logger_1 = require("./core/Logger");
const PlaywrightAgent_1 = require("./plugins/agents/PlaywrightAgent");
const ScreenshotCollector_1 = require("./plugins/collectors/ScreenshotCollector");
const NetworkCollector_1 = require("./plugins/collectors/NetworkCollector");
const DOMSnapshotCollector_1 = require("./plugins/collectors/DOMSnapshotCollector");
const JSONExporter_1 = require("./plugins/exporters/JSONExporter");
const MarkdownExporter_1 = require("./plugins/exporters/MarkdownExporter");
const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer");
// ─── Parse CLI arguments ─────────────────────────────────────────────────────
function parseArgs() {
const args = process.argv.slice(2);
let url = 'http://localhost:3000';
let outputDir = './reports';
let seed = 42;
let maxSteps = 100;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1])
url = args[++i];
else if (args[i] === '--output' && args[i + 1])
outputDir = args[++i];
else if (args[i] === '--seed' && args[i + 1])
seed = parseInt(args[++i], 10);
else if (args[i] === '--max-steps' && args[i + 1])
maxSteps = parseInt(args[++i], 10);
else if (!args[i].startsWith('--'))
url = args[i];
}
return { url, outputDir, seed, maxSteps };
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const { url, outputDir, seed, maxSteps } = parseArgs();
const sessionId = `${new Date().toISOString().replace(/[:.]/g, '-')}_seed${seed}`;
const logger = new Logger_1.FileLogger('./logs', sessionId);
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed, headless: true, logger });
const collectors = [
new ScreenshotCollector_1.ScreenshotCollector(outputDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(outputDir),
];
const exporters = [
new JSONExporter_1.JSONExporter(url),
new MarkdownExporter_1.MarkdownExporter(),
];
const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer();
const engine = new ExplorationEngine_1.ExplorationEngine({
graph,
agent,
collectors,
exporters,
reproducer,
logger,
seed,
url,
maxSteps,
outputDir,
});
console.log(`[ABE] Starting exploration of ${url} (seed=${seed}, maxSteps=${maxSteps})`);
try {
const result = await engine.run();
console.log(`[ABE] Exploration complete.`);
console.log(` States visited : ${result.statesVisited}`);
console.log(` Anomalies found: ${result.anomaliesFound}`);
if (result.anomaliesFound > 0) {
console.log(` Reports saved to: ${outputDir}/`);
}
}
catch (err) {
console.error('[ABE] Fatal error:', err);
process.exit(1);
}
}
main();
@@ -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;
+34
View File
@@ -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;
+84
View File
@@ -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;
+31
View File
@@ -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;
+14
View File
@@ -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;
+14
View File
@@ -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;
+14
View File
@@ -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;
+14
View File
@@ -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;
+14
View File
@@ -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 });
+2
View File
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+20
View File
@@ -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;
+24
View File
@@ -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;
+26
View File
@@ -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;
}
@@ -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;
+64
View File
@@ -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;
+14
View File
@@ -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;
+14
View File
@@ -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;
+14
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+25
View File
@@ -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