Compare commits

...

21 Commits

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:38:25 -04:00
debian c3911bafe8 fase(25): polish and quality improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:15:16 -04:00
debian 87b7698ece fase(24): onboarding and first-run experience
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:12:11 -04:00
debian 629eafecd8 fase(23): observability and health probes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:10:24 -04:00
debian ddb4f66036 fase(22): docker production setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:08:18 -04:00
debian 30f293fbf8 fase(21): openapi documentation with scalar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:06:44 -04:00
debian 94defee1f8 fase(20): visual regression refactor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:02:37 -04:00
debian 49e76c92b1 fase(19): scheduling module refactor 2026-03-08 05:49:00 -04:00
debian 1cf597fee1 fase(18): cli and cicd integration 2026-03-08 05:34:17 -04:00
debian 5a28270dc9 fase(17): licensing module with RSA validation 2026-03-08 05:20:54 -04:00
Claude 1f1678af17 fase(16): integrations module 2026-03-06 07:22:00 -05:00
debian cffa1aeea9 fase(15): reporting module with pdf generation 2026-03-06 05:57:05 -05:00
debian 3ff36f0b6a fase(12): session pages with live feed 2026-03-05 10:34:31 -05:00
debian 458302ca86 fase(11): dashboard page with charts and realtime 2026-03-05 10:30:16 -05:00
debian 5ef4ce5de0 fase(10): frontend shadcn-ui shell with auth 2026-03-05 10:26:17 -05:00
debian 7526a5bc15 fase(9): auth module with casl rbac and session management 2026-03-05 09:57:49 -05:00
debian 39a5e41f75 fase(8): sqlite job queue system 2026-03-05 09:44:06 -05:00
debian f01acfe985 fase(7): api server refactor with composition root 2026-03-05 09:36:28 -05:00
debian e746dc0497 fase(6): fuzzing module complete 2026-03-05 09:22:55 -05:00
462 changed files with 36612 additions and 767 deletions
+17 -8
View File
@@ -1,14 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npm *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git *)",
"Bash(cd *)",
"Bash(cat *)",
"Bash(ls *)",
"Bash(mkdir *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(rm *)",
@@ -21,11 +16,25 @@
"Bash(echo *)",
"Bash(which *)",
"Bash(pwd)",
"Bash(cd *)",
"Bash(npm *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git *)",
"Bash(docker *)",
"Bash(docker compose *)",
"Bash(tsc *)",
"Bash(vitest *)",
"Bash(eslint *)",
"Bash(wc *)",
"Bash(sort *)",
"Bash(uniq *)",
"Bash(touch *)",
"Bash(chmod *)",
"Bash(test *)",
"Bash(diff *)",
"Bash(tar *)",
"Bash(curl *)",
"Bash(tree *)",
"Read",
"Write",
"Edit",
+43 -8
View File
@@ -1,10 +1,45 @@
node_modules
dist
logs
reports
data
.ralph
tests
frontend
# Version control
.git
.gitignore
# Node modules (installed fresh in container)
node_modules
frontend/node_modules
# Build output (compiled in container)
dist
frontend/dist
# Test files
tests
coverage
*.test.ts
*.spec.ts
vitest.config.*
# Development configs
.eslintrc*
tsconfig.tsbuildinfo
# CI/CD
.github
.ralph
# Logs and data
logs
data
*.log
# Environment files (injected at runtime)
.env
.env.*
# Docker files
docker-compose*.yml
.dockerignore
# Editor files
.vscode
.idea
*.swp
*.swo
+121
View File
@@ -0,0 +1,121 @@
name: ABE Explore
description: Run ABE autonomous bug exploration against a target web application
inputs:
url:
description: Target URL to explore
required: true
server:
description: ABE server URL (if using remote mode)
required: false
default: ''
api-key:
description: API key for remote ABE server
required: false
default: ''
max-states:
description: Maximum number of states to explore
required: false
default: '50'
seed:
description: Deterministic seed for reproducibility
required: false
default: '42'
output:
description: Output format (human | json | junit | markdown)
required: false
default: 'junit'
fail-on-severity:
description: Fail if findings at or above this severity (low | medium | high | critical)
required: false
default: 'high'
reports-dir:
description: Directory for generated reports
required: false
default: './abe-reports'
config:
description: Path to ABE JSON config file
required: false
default: ''
outputs:
findings-count:
description: Number of findings discovered
value: ${{ steps.explore.outputs.findings-count }}
session-id:
description: ABE session ID
value: ${{ steps.explore.outputs.session-id }}
junit-path:
description: Path to JUnit XML results file
value: './abe-results.xml'
runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install ABE dependencies
shell: bash
run: npm ci
working-directory: ${{ github.action_path }}/../../../
- name: Install Playwright browsers
shell: bash
run: npx playwright install chromium --with-deps
working-directory: ${{ github.action_path }}/../../../
- name: Run ABE exploration
id: explore
shell: bash
working-directory: ${{ github.action_path }}/../../../
env:
ABE_API_KEY: ${{ inputs.api-key }}
run: |
ARGS="--url ${{ inputs.url }}"
ARGS="$ARGS --max-states ${{ inputs.max-states }}"
ARGS="$ARGS --seed ${{ inputs.seed }}"
ARGS="$ARGS --output ${{ inputs.output }}"
ARGS="$ARGS --reports-dir ${{ inputs.reports-dir }}"
if [ -n "${{ inputs.server }}" ]; then
ARGS="$ARGS --server ${{ inputs.server }}"
fi
if [ -n "${{ inputs.api-key }}" ]; then
ARGS="$ARGS --api-key ${{ inputs.api-key }}"
fi
if [ -n "${{ inputs.fail-on-severity }}" ]; then
ARGS="$ARGS --fail-on-severity ${{ inputs.fail-on-severity }}"
fi
if [ -n "${{ inputs.config }}" ]; then
ARGS="$ARGS --config ${{ inputs.config }}"
fi
npm run abe -- explore $ARGS
EXIT_CODE=$?
# Parse findings count from JUnit if available
if [ -f abe-results.xml ]; then
FAILURES=$(grep -oP 'failures="\K[0-9]+' abe-results.xml | head -1 || echo "0")
echo "findings-count=$FAILURES" >> $GITHUB_OUTPUT
else
echo "findings-count=0" >> $GITHUB_OUTPUT
fi
exit $EXIT_CODE
- name: Upload ABE reports
if: always()
uses: actions/upload-artifact@v4
with:
name: abe-reports-${{ github.run_id }}
path: |
${{ inputs.reports-dir }}/
abe-results.xml
retention-days: 30
+76 -20
View File
@@ -4,45 +4,101 @@ on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
target-url:
description: Target URL to explore
required: false
default: 'http://localhost:3000'
max-states:
description: Maximum states to explore
required: false
default: '30'
jobs:
explore:
name: Autonomous Bug Exploration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
run: npm ci
- name: Start application
run: docker-compose up -d app
# assumes the project has a docker-compose with the target app
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Wait for app
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Start target application
run: docker compose up -d app
# Replace 'app' with your application's docker-compose service name.
# Or start your app however it's normally run in CI.
continue-on-error: true
- name: Run ABE
- name: Wait for application to be ready
run: |
npm run abe -- run \
--url http://localhost:3000 \
--max-states 30 \
npx wait-on \
http://localhost:3000 \
--timeout 30000 \
--interval 2000
continue-on-error: true
- name: Run ABE exploration
id: abe
run: |
npm run abe -- explore \
--url "${{ github.event.inputs.target-url || 'http://localhost:3000' }}" \
--max-states "${{ github.event.inputs.max-states || '30' }}" \
--seed 42 \
--output junit \
--fail-on-severity high \
--output junit
--reports-dir ./abe-reports
continue-on-error: true
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: abe-reports
path: reports/
- name: Publish test results
- name: Publish JUnit test results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: abe-results.xml
check_name: ABE Findings
comment_title: ABE Exploration Results
- name: Upload ABE reports
if: always()
uses: actions/upload-artifact@v4
with:
name: abe-reports
path: |
abe-reports/
abe-results.xml
retention-days: 30
- name: Fail if high/critical findings found
if: steps.abe.outcome == 'failure'
run: |
echo "ABE found high or critical severity bugs. See artifacts for details."
exit 1
# Optional: Use the composite action instead
explore-with-action:
name: ABE via Composite Action
runs-on: ubuntu-latest
if: false # Set to true to enable this alternative job
steps:
- uses: actions/checkout@v4
- name: Run ABE
uses: ./.github/actions/abe-explore
with:
url: http://localhost:3000
max-states: '30'
fail-on-severity: high
output: junit
+1 -1
View File
@@ -1 +1 @@
96bf6e50979e4cc152e9715b965f27eeb2decbc1
c3911bafe885d664a6870305dff172e1410a95ac
+248 -248
View File
@@ -89,356 +89,356 @@ Spec: `.ralph/specs/phase-04-crawling-infrastructure.md`
---
## Phase 5: Findings Module [PENDIENTE]
## Phase 5: Findings Module [COMPLETO]
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`
- [x] 5.1: Crear `domain/entities/Finding.ts` — AggregateRoot con severity, type, evidence, status, actionTrace
- [x] 5.2: Crear value objects: `Severity.ts` (low/medium/high/critical), `FindingType.ts`, `Evidence.ts`, `FindingStatus.ts` (open/investigating/resolved/closed)
- [x] 5.3: Crear events: `FindingCreated.ts`, `FindingResolved.ts`, `FindingEnriched.ts`
- [x] 5.4: Crear ports: `IFindingRepository.ts`, `IAIEnricher.ts`
- [x] 5.5: Crear commands: `CreateFindingCommand.ts`, `EnrichFindingCommand.ts`, `ResolveFindingCommand.ts`
- [x] 5.6: Crear queries: `GetFindingQuery.ts`, `ListFindingsQuery.ts` (filtros: severity, type, session, status, search), `FindingStatsQuery.ts`
- [x] 5.7: Crear `event-handlers/OnAnomalyDetected.ts` — escucha eventos crawling → crea Finding
- [x] 5.8: Crear `infrastructure/repositories/KyselyFindingRepository.ts`
- [x] 5.9: Migrar exporters existentes → `infrastructure/exporters/` (MarkdownExporter, JSONExporter)
- [x] 5.10: Crear `infrastructure/exporters/PlaywrightScriptExporter.ts` — genera test Playwright reproducible desde actionTrace
- [x] 5.11: Crear `infrastructure/http/FindingsController.ts` — routes para anomalies existentes + nuevas
- [x] 5.12: Migración Kysely: tabla findings con columnas status, browser, ai_enrichment_json
- [x] 5.13: Tests: Finding aggregate, CreateFinding, ListFindings con filtros
- [x] 5.14: Verificar build + commit: `fase(5): findings module complete`
---
## Phase 6: Fuzzing Module [PENDIENTE]
## Phase 6: Fuzzing Module [COMPLETO]
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`
- [x] 6.1: Crear domain: `FuzzSession.ts` (AggregateRoot), `FuzzResult.ts` (Entity)
- [x] 6.2: Crear value objects: `FuzzStrategy.ts`, `FuzzPayload.ts`, `Seed.ts`, `FuzzIntensity.ts`
- [x] 6.3: Crear events: `FuzzStarted.ts`, `VulnerabilityDetected.ts`, `FuzzCompleted.ts`
- [x] 6.4: Crear port: `IFuzzerEngine.ts`
- [x] 6.5: Crear `commands/RunFuzzCommand.ts`
- [x] 6.6: Crear `event-handlers/OnActionExecuted.ts` — escucha crawling → trigger fuzzing
- [x] 6.7: Migrar las 5 estrategias existentes → `infrastructure/strategies/` (Empty, Oversized, SpecialChars, TypeMismatch, Boundary)
- [x] 6.8: Migrar `FuzzingEngine.ts` y `InputTypeDetector.ts``infrastructure/adapters/`
- [x] 6.9: Crear `infrastructure/http/FuzzingController.ts`
- [x] 6.10: Tests: cada estrategia de fuzzing genera payloads válidos
- [x] 6.11: Verificar build + commit: `fase(6): fuzzing module complete`
---
## Phase 7: API Server Refactor + Composition Root [PENDIENTE]
## Phase 7: API Server Refactor + Composition Root [COMPLETO]
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`
- [x] 7.1: Crear `src/api/middleware/errorHandler.ts` — AppError hierarchy (ValidationError, AuthenticationError, ForbiddenError, NotFoundError) + global error handler
- [x] 7.2: Crear `src/api/middleware/requestId.ts` — genera UUID por request, adjunta a req + pino child logger
- [x] 7.3: Crear `src/api/middleware/notFound.ts` — 404 handler para rutas no encontradas
- [x] 7.4: Crear `src/api/server.ts` — Express app con middleware stack: requestId → helmet → cors → rateLimit → bodyParser → routes → notFound → errorHandler
- [x] 7.5: Crear `src/api/router.ts` — registra routes de TODOS los módulos (crawling, findings, fuzzing)
- [x] 7.6: Crear `src/realtime/SocketGateway.ts` — socket.io server que subscribe a EventBus y emite a clientes
- [x] 7.7: Crear `src/main.ts` — composition root: load config → create logger → create db → run migrations → create event bus → create repositories → create use cases → subscribe handlers → create controllers → create Express → create socket.io → start listening
- [x] 7.8: Implementar graceful shutdown en main.ts: SIGTERM/SIGINT → stop accepting → close sockets → close db → flush logs → exit
- [x] 7.9: Health endpoints: GET /health/live (process alive), GET /health/ready (DB check)
- [x] 7.10: Verificar que TODOS los endpoints existentes siguen funcionando tras refactor
- [x] 7.11: Verificar build + commit: `fase(7): api server refactor with composition root`
---
## Phase 8: Job Queue System [PENDIENTE]
## Phase 8: Job Queue System [COMPLETO]
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`
- [x] 8.1: Crear `src/jobs/JobQueue.ts` — interface: enqueue, start, pause, waitForActive
- [x] 8.2: Crear `src/jobs/SQLiteJobQueue.ts` — tabla jobs con status/type/payload/attempts/run_at, polling worker
- [x] 8.3: Migración Kysely: tabla jobs
- [x] 8.4: Crear `src/jobs/workers/ExplorationWorker.ts` — ejecuta crawl como job
- [x] 8.5: Crear `src/jobs/workers/ReportWorker.ts` — genera reports en background
- [x] 8.6: Integrar job queue en main.ts, mover exploraciones de sync a job-based
- [x] 8.7: Tests: enqueue → dequeue → complete cycle, failed job retry
- [x] 8.8: Verificar build + commit: `fase(8): sqlite job queue system`
---
## Phase 9: Auth Module [PENDIENTE]
## Phase 9: Auth Module [COMPLETO]
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`
- [x] 9.1: Instalar: `npm i @casl/ability argon2 cookie-parser` (custom auth sin better-auth, per spec nota)
- [x] 9.2: Crear domain: `User.ts` (AggregateRoot), `Organization.ts` (AggregateRoot), `ApiKey.ts` (Entity)
- [x] 9.3: Crear value objects: `Email.ts`, `Role.ts` (owner/admin/member/viewer), `Permission.ts`
- [x] 9.4: Crear events: `UserCreated.ts`, `UserLoggedIn.ts`, `OrgCreated.ts`, `MemberInvited.ts`
- [x] 9.5: Crear ports: `IUserRepository.ts`, `IOrganizationRepository.ts`, `IApiKeyRepository.ts`, `ISessionRepository.ts`
- [x] 9.6: Crear commands: `RegisterCommand.ts`, `LoginCommand.ts`, `CreateOrganizationCommand.ts`, `InviteMemberCommand.ts`, `CreateApiKeyCommand.ts`
- [x] 9.7: Crear queries: `GetUserQuery.ts`, `ListOrgMembersQuery.ts`
- [x] 9.8: Crear `infrastructure/auth/PasswordService.ts` — argon2 hash/verify
- [x] 9.9: Crear `infrastructure/casl/AbilityFactory.ts` — define permisos por role
- [x] 9.10: Crear `application/middleware/AuthMiddleware.ts` — cookie → Bearer → API key → 401
- [x] 9.11: Crear `application/middleware/RBACMiddleware.ts` — verifica permisos CASL
- [x] 9.12: Crear `infrastructure/repositories/KyselyUserRepository.ts` + Org + ApiKey + Session repos
- [x] 9.13: Crear `infrastructure/http/AuthController.ts`register, login, logout, me, setup-required, setup, orgs, api-keys
- [x] 9.14: Migración Kysely: tablas users, organizations, org_members, api_keys, auth_sessions
- [x] 9.15: First-run detection: si 0 users → GET /api/auth/setup-required retorna { required: true }
- [x] 9.16: POST /api/auth/setup — crea primer user como owner + organización default
- [x] 9.17: Integrar AuthMiddleware en todas las rutas /api/ excepto /api/auth/*
- [x] 9.18: Tests: Email, Role, User, Organization, RegisterCommand, LoginCommand, CASL (23 tests)
- [x] 9.19: Verificar build + commit: `fase(9): auth module with better-auth and casl`
---
## Phase 10: Frontend — shadcn/ui Shell [PENDIENTE]
## Phase 10: Frontend — shadcn/ui Shell [COMPLETO]
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`
- [x] 10.1: En frontend/: instalar shadcn/ui con `npx shadcn@latest init` (Vite, Zinc, CSS variables, Tailwind)
- [x] 10.2: Instalar componentes shadcn: button, input, card, badge, dialog, dropdown-menu, command, sidebar, tabs, table, toast, form, separator, avatar, skeleton, tooltip, sheet, select, textarea, label, switch, alert
- [x] 10.3: Instalar deps: `npm i @tanstack/react-query @tanstack/react-table zustand react-hook-form @hookform/resolvers framer-motion react-hotkeys-hook`
- [x] 10.4: Crear layout: `components/layout/AppSidebar.tsx` — sidebar collapsible con nav items (Dashboard, Explorations, Findings, Reports, Settings)
- [x] 10.5: Crear `components/layout/TopBar.tsx` — logo, search trigger (⌘K), theme toggle, user avatar menu
- [x] 10.6: Crear `components/layout/AppLayout.tsx` — wrapper: Sidebar + TopBar + Content outlet
- [x] 10.7: Crear `components/layout/CommandPalette.tsx` — ⌘K con shadcn Command component
- [x] 10.8: Crear ThemeProvider: dark mode como default, toggle dark/light, persistir en localStorage
- [x] 10.9: Crear `lib/api.ts` — API client con fetch, credentials: include, auto-redirect a /login en 401
- [x] 10.10: Crear `lib/queryClient.ts` — TanStack Query provider
- [x] 10.11: Crear `stores/uiStore.ts` — Zustand: sidebarCollapsed, theme
- [x] 10.12: Crear pages/Login.tsx — form email + password con shadcn
- [x] 10.13: Crear pages/Setup.tsx — wizard first-run (crear admin + nombre org)
- [x] 10.14: Crear `components/layout/ProtectedRoute.tsx` — check auth, redirect a /login o /setup
- [x] 10.15: Actualizar App.tsx con React Router: / (dashboard), /login, /setup, /sessions/:id, /findings/:id, /settings — todo wrapped en ProtectedRoute excepto login/setup
- [x] 10.16: Verificar frontend build + commit: `fase(10): frontend shadcn-ui shell with auth`
---
## Phase 11: Dashboard Page [PENDIENTE]
## Phase 11: Dashboard Page [COMPLETO]
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`
- [x] 11.1: Instalar en frontend: `npm i tremor recharts`
- [x] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats
- [x] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession
- [x] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect
- [x] 11.5: Crear `components/dashboard/KPICards.tsx` — 4 cards Tremor: Total Findings, Critical/High, Active Sessions, Coverage
- [x] 11.6: Crear `components/dashboard/TrendChart.tsx` — Recharts AreaChart stacked por severity, últimos 30 días
- [x] 11.7: Crear `components/dashboard/SeverityDistribution.tsx` — Recharts PieChart con colores por severity
- [x] 11.8: Crear `components/dashboard/RecentFindings.tsx` — TanStack Table, 10 rows, click → /findings/:id
- [x] 11.9: Crear `components/dashboard/ActiveSessions.tsx` — lista con progress bars, click → /sessions/:id
- [x] 11.10: Crear `components/dashboard/QuickActions.tsx` — botón "New Exploration" prominente
- [x] 11.11: Crear `pages/Dashboard.tsx` — ensambla todo, responsive 2col desktop 1col mobile
- [x] 11.12: Conectar real-time: socket events actualizan KPIs y recent findings
- [x] 11.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime`
---
## Phase 12: Sessions Pages [PENDIENTE]
## Phase 12: Sessions Pages [COMPLETO]
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`
- [x] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section
- [x] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
- [x] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs
- [x] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly)
- [x] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges
- [x] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
- [x] 12.7: Progress bar estados explorados / maxStates
- [x] 12.8: Stop button funcional (DELETE /api/sessions/:id)
- [x] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
---
## Phase 13: Findings Pages [PENDIENTE]
## Phase 13: Findings Pages [COMPLETO]
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`
- [x] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search
- [x] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout
- [x] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb
- [x] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer)
- [x] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI"
- [x] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
- [x] 13.7: Status workflow buttons: open → investigating → resolved → closed
- [x] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul
- [x] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view`
---
## Phase 14: Settings Pages [PENDIENTE]
## Phase 14: Settings Pages [COMPLETO]
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`
- [x] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
- [x] 14.2: Section "Profile" — cambiar nombre, email, password
- [x] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
- [x] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
- [x] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
- [x] 14.6: Section "Notifications" — Slack webhook URL, min severity
- [x] 14.7: Section "Appearance" — tema dark/light, accent color
- [x] 14.8: Section "License" — ver status licencia, input para activar key
- [x] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
---
## Phase 15: Reporting Module [PENDIENTE]
## Phase 15: Reporting Module [COMPLETO]
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`
- [x] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
- [x] 15.2: Crear port: `IReportGenerator.ts`
- [x] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
- [x] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
- [x] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
- [x] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download
- [x] 15.7: Integrar con job queue: generación async
- [x] 15.8: Migración Kysely: tabla reports
- [x] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
- [x] 15.10: Tests: GenerateReportCommand con mock generator
- [x] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
---
## Phase 16: Integrations Module [PENDIENTE]
## Phase 16: Integrations Module [COMPLETO]
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`
- [x] 16.1: Instalar: `npm i @slack/web-api @octokit/rest`
- [x] 16.2: Crear domain: `Integration.ts` (Entity), `WebhookEndpoint.ts` (Entity)
- [x] 16.3: Crear value objects: `IntegrationType.ts` (jira/slack/github/webhook), `WebhookSecret.ts`
- [x] 16.4: Crear port: `IIntegrationProvider.ts` (sendFinding)
- [x] 16.5: Crear `infrastructure/webhooks/WebhookDispatcher.ts` — HMAC-SHA256 signature, retry con exponential backoff (3 intentos)
- [x] 16.6: Crear `infrastructure/providers/SlackProvider.ts` — Block Kit message con severity, description, link
- [x] 16.7: Crear `infrastructure/providers/GitHubIssuesProvider.ts` — crea issue con reproduction steps
- [x] 16.8: Crear `infrastructure/providers/JiraProvider.ts` — REST API v3, crea issue con screenshots
- [x] 16.9: Crear `event-handlers/OnFindingCreated.ts` — dispatch a todas las integrations activas
- [x] 16.10: Crear `infrastructure/http/IntegrationsController.ts` — CRUD integrations + webhooks
- [x] 16.11: Migración Kysely: tables integrations, webhook_endpoints, webhook_deliveries
- [x] 16.12: Frontend: Settings/Integrations con forms por provider (Slack webhook URL, Jira config, GitHub token, custom webhook)
- [x] 16.13: Tests: webhook dispatch + HMAC verification
- [x] 16.14: Verificar build completo + commit: `fase(16): integrations module`
---
## Phase 17: Licensing Module [PENDIENTE]
## Phase 17: Licensing Module [COMPLETO]
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`
- [x] 17.1: Crear domain: `License.ts` (Entity), value objects `LicensePlan.ts` (free/pro/enterprise), `FeatureEntitlement.ts`
- [x] 17.2: Crear port: `ILicenseValidator.ts` (validate, getEntitlements)
- [x] 17.3: Crear `infrastructure/RSALicenseValidator.ts` — verifica firma RSA-2048 con public key bundled
- [x] 17.4: Crear feature flags: `FREE_FEATURES`, `PRO_FEATURES`, `ENTERPRISE_FEATURES` arrays
- [x] 17.5: Crear `infrastructure/middleware/FeatureGateMiddleware.ts` — checkea feature en license antes de permitir request
- [x] 17.6: Crear `infrastructure/http/LicensingController.ts` — POST /api/license/activate, GET /api/license/status
- [x] 17.7: Crear `scripts/generate-license.ts` — CLI tool para generar license keys firmadas (uso interno)
- [x] 17.8: Integrar gate checks en rutas Pro/Enterprise (reporting, integrations, etc.)
- [x] 17.9: Frontend: License section en Settings
- [x] 17.10: Tests: valid license passes, expired fails, wrong signature fails, feature gate blocks
- [x] 17.11: Verificar build completo + commit: `fase(17): licensing module with RSA validation`
---
## Phase 18: CLI + CI/CD [PENDIENTE]
## Phase 18: CLI + CI/CD [COMPLETO]
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`
- [x] 18.1: Instalar: `npm i commander`
- [x] 18.2: Refactorizar `src/cli/abe.ts` con commander: comando `explore` con flags --url, --config (json file), --output (json|junit|markdown), --fail-on-severity, --api-key
- [x] 18.3: Comando `abe report` — genera report de una sesión por id
- [x] 18.4: Comando `abe status` — ping al servidor, muestra sessions activas
- [x] 18.5: Output JUnit XML: cada finding = failing test, cada state sin findings = passing test
- [x] 18.6: Exit codes: 0=clean, 1=findings over threshold, 2=error
- [x] 18.7: Crear `.github/actions/abe-explore/action.yml` — GitHub Action composite
- [x] 18.8: Crear `Dockerfile.ci` — imagen con Chromium para CI (basada en mcr.microsoft.com/playwright)
- [x] 18.9: Crear `.github/workflows/abe-example.yml` — ejemplo completo
- [x] 18.10: Actualizar README.md con sección CLI
- [x] 18.11: Verificar build completo + commit: `fase(18): cli and cicd integration`
---
## Phase 19: Scheduling Module Refactor [PENDIENTE]
## Phase 19: Scheduling Module Refactor [COMPLETO]
- [ ] 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`
- [x] 19.1: Migrar scheduling existente → nueva estructura modular (domain/application/infrastructure)
- [x] 19.2: Crear Schedule aggregate con cron validation (Zod)
- [x] 19.3: Integrar con job queue
- [x] 19.4: Crear SchedulingController con CRUD + toggle
- [x] 19.5: Frontend: Schedules section en Settings
- [x] 19.6: Verificar build + commit: `fase(19): scheduling module refactor`
---
## Phase 20: Visual Regression Refactor [PENDIENTE]
## Phase 20: Visual Regression Refactor [COMPLETO]
- [ ] 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`
- [x] 20.1: Migrar visual regression existente → nueva estructura modular
- [x] 20.2: Integrar con StorageProvider para screenshots
- [x] 20.3: Refactorizar frontend /visual-review con shadcn/ui components
- [x] 20.4: Verificar build + commit: `fase(20): visual regression refactor`
---
## Phase 21: API Documentation [PENDIENTE]
## Phase 21: API Documentation [COMPLETO]
- [ ] 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`
- [x] 21.1: Instalar: `npm i @asteasolutions/zod-to-openapi @scalar/express-api-reference`
- [x] 21.2: Crear Zod schemas compartidos para TODOS los endpoints (request + response)
- [x] 21.3: Generar OpenAPI 3.1 spec desde Zod schemas
- [x] 21.4: Montar Scalar UI en GET /api-docs
- [x] 21.5: Servir spec JSON en GET /api-docs/openapi.json
- [x] 21.6: Verificar que todos los endpoints están documentados
- [x] 21.7: Verificar build + commit: `fase(21): openapi documentation with scalar`
---
## Phase 22: Docker Production [PENDIENTE]
## Phase 22: Docker Production [COMPLETO]
- [ ] 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`
- [x] 22.1: Refactorizar Dockerfile backend: multi-stage, node:20-alpine, tini como init, non-root user, HEALTHCHECK
- [x] 22.2: Refactorizar frontend Dockerfile: multi-stage build + nginx
- [x] 22.3: Actualizar docker-compose.yml: healthcheck, restart policies, volumes, env_file
- [x] 22.4: Crear docker-compose.prod.yml
- [x] 22.5: Crear .dockerignore optimizado
- [x] 22.6: CMD DEBE ser `["tini", "--", "node", "dist/main.js"]` — NUNCA npm
- [x] 22.7: Verificar imagen final < 200MB
- [x] 22.8: Verificar docker compose up funciona end-to-end
- [x] 22.9: Commit: `fase(22): docker production setup`
---
## Phase 23: Observability [PENDIENTE]
## Phase 23: Observability [COMPLETO]
- [ ] 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`
- [x] 23.1: Request correlation: requestId en CADA log entry via pino child logger
- [x] 23.2: Structured error logging con contexto (userId, sessionId, etc.)
- [x] 23.3: Liveness probe: GET /health/live
- [x] 23.4: Readiness probe: GET /health/ready (DB + job queue check)
- [x] 23.5: Startup probe: medir tiempo de arranque, loguear
- [x] 23.6: Commit: `fase(23): observability and health probes`
---
## Phase 24: Onboarding + First-Run [PENDIENTE]
## Phase 24: Onboarding + First-Run [COMPLETO]
- [ ] 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`
- [x] 24.1: Detectar first-run en frontend (GET /api/auth/setup-required)
- [x] 24.2: Wizard multi-step: paso 1 crear admin, paso 2 nombre org, paso 3 "Start your first exploration" con URL input
- [x] 24.3: Empty states: ilustraciones/mensajes en tablas vacías ("No findings yet. Start an exploration!")
- [x] 24.4: Commit: `fase(24): onboarding and first-run experience`
---
## Phase 25: Polish + Quality [PENDIENTE]
## Phase 25: Polish + Quality [COMPLETO]
- [ ] 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`
- [x] 25.1: Audit TypeScript strict — eliminar TODOS los `any` restantes
- [x] 25.2: Loading skeletons en todas las pages (shadcn Skeleton)
- [x] 25.3: Error boundaries en cada page
- [x] 25.4: Keyboard shortcuts: ⌘K (command palette), Esc (close dialogs), N (new exploration from dashboard)
- [x] 25.5: Responsive mobile: sidebar collapse, tables scroll, forms stack
- [x] 25.6: README.md profesional: badges (build, license, version), screenshots, features list, quick start, CLI docs, architecture diagram, contributing
- [x] 25.7: CONTRIBUTING.md
- [x] 25.8: LICENSE files: MIT para core, archivo LICENSE-ENTERPRISE separado
- [x] 25.9: Commit: `fase(25): polish and quality improvements`
---
## Phase 26: SSO Enterprise [PENDIENTE — ENTERPRISE ONLY]
- [ ] 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`
- [x] 26.1: SAML 2.0 via @node-saml/passport-saml con MultiSamlStrategy
- [x] 26.2: OIDC via openid-client (Okta, Azure AD, Google Workspace)
- [x] 26.3: Per-organization IdP configuration
- [x] 26.4: LDAP/AD integration via passport-ldapauth
- [x] 26.5: MFA (TOTP) support
- [x] 26.6: Audit log completo (who did what, when)
- [x] 26.7: Session management dashboard (ver/revocar sessions activas)
- [x] 26.8: Feature-gated tras LICENSE enterprise
- [x] 26.9: Commit: `fase(26): enterprise sso saml oidc ldap`
---
## Phase 27: Advanced Enterprise [PENDIENTE — ENTERPRISE ONLY]
- [ ] 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`
- [x] 27.1: Data retention policies (auto-delete findings > X days)
- [x] 27.2: Backup/restore CLI tool
- [x] 27.3: White-labeling (CSS custom properties + logo upload)
- [x] 27.4: PostgreSQL support validado end-to-end
- [x] 27.5: Email notifications (nodemailer + templates)
- [x] 27.6: Kubernetes Helm chart
- [x] 27.7: Commit: `fase(27): advanced enterprise features`
+1 -7
View File
@@ -1,7 +1 @@
{
"status": "executing",
"indicator": "⠹",
"elapsed_seconds": 30,
"last_output": "",
"timestamp": "2026-03-05 03:53:30"
}
{"status": "failed", "timestamp": "2026-03-08 07:22:04"}
+23 -6
View File
@@ -1,7 +1,24 @@
ALLOWED_TOOLS="bash,write,edit,read,glob,grep,todoread,todowrite"
AUTO_APPROVE=true
MAX_LOOPS=200
MODEL="claude-sonnet-4-20250514"
# .ralphrc - Ralph project configuration for ABE
# Allow all bash commands including docker
ALLOW_ALL_BASH=true
# Project
PROJECT_NAME="abe"
PROJECT_TYPE="typescript"
# Claude Code
CLAUDE_CODE_CMD="claude"
CLAUDE_OUTPUT_FORMAT="json"
# Loop settings
MAX_CALLS_PER_HOUR=100
CLAUDE_TIMEOUT_MINUTES=120
# Session continuity (mantener contexto entre loops)
SESSION_CONTINUITY=true
SESSION_EXPIRY_HOURS=24
# Circuit breaker - auto-reset para operación continua
CB_AUTO_RESET=true
CB_COOLDOWN_MINUTES=0
# Tool permissions - "Bash" sin paréntesis permite TODOS los comandos bash
ALLOWED_TOOLS="Write,Read,Edit,MultiEdit,Glob,Grep,Bash,Bash(git *),Bash(npm *),Bash(npx *),Bash(node *)"
+37
View File
@@ -0,0 +1,37 @@
# Contributing to ABE
Thank you for your interest in contributing to ABE!
## Development Setup
1. Fork and clone the repository
2. Install dependencies: `npm install && cd frontend && npm install`
3. Run migrations: `npm run db:migrate`
4. Start dev servers: `npm run dev` + `cd frontend && npm run dev`
## Architecture Rules
Before submitting a PR, ensure your code follows these rules:
1. **Domain layer** — No imports from `kysely`, `express`, `playwright` or any infrastructure
2. **Cross-module communication** — Only via EventBus (no direct module imports)
3. **Use cases** — Must return `Result<T, E>`, never throw business errors
4. **No `any`** — All new code must have explicit TypeScript types
## Making Changes
1. Create a feature branch from `main`
2. Write tests for new functionality
3. Run the full verification: `npm run build && cd frontend && npm run build && cd .. && npm run test`
4. Submit a pull request
## Commit Messages
Follow the pattern: `feat(module): description` or `fix(module): description`
## Reporting Issues
Please use [GitHub Issues](https://github.com/your-org/abe/issues) with:
- Steps to reproduce
- Expected vs actual behavior
- ABE version and Node.js version
+18 -10
View File
@@ -13,10 +13,9 @@ RUN npm run build
# ---- Production stage ----
FROM node:20-alpine
WORKDIR /app
# System dependencies required by Playwright / Chromium and healthcheck
# tini as init process + chromium for Playwright + curl for healthcheck
RUN apk add --no-cache \
tini \
chromium \
nss \
freetype \
@@ -29,18 +28,27 @@ RUN apk add --no-cache \
# Tell Playwright to use the system Chromium instead of downloading its own
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV NODE_ENV=production
# Non-root user
RUN addgroup -S abe && adduser -S abe -G abe
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
RUN npm ci --omit=dev && chown -R abe:abe /app
COPY --from=builder /app/dist ./dist
COPY --from=builder --chown=abe:abe /app/dist ./dist
# Runtime directories for reports and logs
RUN mkdir -p reports logs
# Runtime directories for data, reports and logs
RUN mkdir -p data reports logs && chown -R abe:abe data reports logs
USER abe
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost:3001/health/live || exit 1
CMD ["node", "dist/server/index.js"]
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/main.js"]
+33
View File
@@ -0,0 +1,33 @@
# Dockerfile.ci — ABE CI image with Playwright/Chromium
# Based on the official Playwright image which includes all browser dependencies.
# Usage:
# docker build -f Dockerfile.ci -t abe-ci .
# docker run --rm -e TARGET_URL=http://host.docker.internal:3000 abe-ci \
# npx ts-node src/cli/abe.ts explore --url $TARGET_URL --output junit
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
WORKDIR /app
# Install Node.js dependencies (production + dev for ts-node)
COPY package*.json ./
RUN npm ci
# Copy TypeScript source
COPY tsconfig.json ./
COPY src/ ./src/
# Build TypeScript
RUN npm run build
# Default reports directory
RUN mkdir -p /reports
ENV NODE_ENV=production
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# Entrypoint: run ABE CLI
# Override CMD to pass custom flags, e.g.:
# docker run abe-ci node dist/cli/abe.js explore --url http://example.com
ENTRYPOINT ["node", "dist/cli/abe.js"]
CMD ["--help"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 ABE Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24
View File
@@ -0,0 +1,24 @@
ABE ENTERPRISE LICENSE
Copyright (c) 2024-2026 ABE Contributors
ENTERPRISE FEATURES LICENSE
The enterprise features of ABE (including but not limited to: SSO/SAML/OIDC
integration, LDAP/Active Directory, advanced audit logs, session management
dashboard, white-labeling, and data retention policies) are licensed under a
commercial license.
To obtain an enterprise license, contact: enterprise@abe.example.com
PERMITTED USES (with valid enterprise license key):
- Deploy ABE Enterprise in your organization
- Use all enterprise features
- Create internal deployments
PROHIBITED USES:
- Redistribution of enterprise features
- Sublicensing
- Removing license validation
The core ABE platform is available under the MIT License (see LICENSE).
+122 -95
View File
@@ -1,126 +1,153 @@
# 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.
> "Playwright discovers what you test. ABE discovers what you miss."
[![Build](https://img.shields.io/github/actions/workflow/status/your-org/abe/ci.yml?branch=main)](https://github.com/your-org/abe/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org/)
[![Node.js](https://img.shields.io/badge/Node.js-20-green)](https://nodejs.org/)
ABE is an **enterprise self-hosted platform** for autonomous web application bug discovery. It explores apps like a real user, injects invalid inputs (fuzzing), detects anomalies, and generates reproducible bug reports.
---
## Features
- **Autonomous Exploration** — BFS-based state graph exploration with deterministic seeds
- **Smart Fuzzing** — 5 strategies: empty, oversized, special characters, type mismatch, boundary values
- **Visual Regression** — pixel-level screenshot comparison with Playwright + pixelmatch
- **Accessibility Auditing** — WCAG violations via axe-core
- **Reproducible Reports** — generates Playwright test scripts, Markdown, JSON, PDF reports
- **Real-time Dashboard** — live WebSocket feed with severity charts and KPI cards
- **Auth & RBAC** — multi-user, organizations, roles (owner/admin/member/viewer), API keys
- **Integrations** — Slack, GitHub Issues, Jira, custom webhooks
- **Scheduling** — cron-based automated explorations
- **CLI + CI/CD** — JUnit XML output, GitHub Actions integration
- **API Documentation** — OpenAPI 3.1 + Scalar UI at `/api-docs`
- **Licensing** — RSA-signed license keys with feature gating (Free/Pro/Enterprise)
---
## Quick Start
### Prerequisites
- Node.js 20+
- npm 10+
### Development
```bash
# Install dependencies
npm install
cd frontend && npm install && cd ..
# Install Playwright browser
npx playwright install chromium
# Start development servers
npm run dev # Backend on :3001
cd frontend && npm run dev # Frontend on :5173
# Run ABE against your app
npm run explore -- --url http://localhost:3000 --output ./reports
# Database migrations
npm run db:migrate
# Replay a discovered bug
npm run replay -- --report reports/<anomaly-id>/report.json
# Run tests
npm run test
# Build
npm run build
cd frontend && npm run build
```
## 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.
### Docker
```bash
# Start both the API server (port 3001) and the React frontend (port 5173)
npm run dev:all
# Start all services
docker compose up -d --build
# Production
docker compose -f docker-compose.prod.yml up -d --build
```
Then open `http://localhost:5173` in your browser.
The app will be available at `http://localhost:5173`.
### API Server only
---
## CLI Usage
```bash
npm run server
# Run an exploration
node dist/cli/abe.js explore --url https://example.com \
--output json \
--fail-on-severity high
# Generate a report
node dist/cli/abe.js report --session SESSION_ID
# Check server status
node dist/cli/abe.js status
```
REST endpoints available at `http://localhost:3001/api/`:
### CI/CD Integration
| 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
```yaml
# .github/workflows/abe.yml
- uses: ./.github/actions/abe-explore
with:
url: https://staging.example.com
fail-on-severity: high
api-key: ${{ secrets.ABE_API_KEY }}
```
| 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
ABE uses a **modular monolith hexagonal architecture** with bounded contexts:
```
frontend/ (React + Vite, port 5173)
↕ HTTP REST + WebSocket
src/server/ (Express + socket.io, port 3001)
↕ imports
src/core/ + src/plugins/ (ABE engine)
src/
├── shared/ → Domain building blocks (Entity, ValueObject, Result, EventBus)
├── modules/
│ ├── crawling/ → Session management + Playwright crawler
│ ├── fuzzing/ → Input fuzzing strategies
│ ├── findings/ → Bug report lifecycle
│ ├── auth/ → Users, organizations, RBAC
│ ├── reporting/ → PDF/HTML/JSON report generation
│ ├── integrations/→ Slack, GitHub, Jira, webhooks
│ ├── scheduling/ → Cron-based automation
│ ├── licensing/ → RSA license validation
│ └── visual-regression/ → Screenshot comparison
├── api/ → Express server + OpenAPI docs
├── realtime/ → Socket.io gateway
├── jobs/ → SQLite-backed job queue
└── cli/ → Commander CLI
```
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
**Architectural rules:**
1. Domain never imports infrastructure
2. Cross-module communication only via EventBus
3. Use cases return `Result<T, E>`, never throw
4. Controllers are thin — delegate to use cases
---
## API Documentation
Once running, visit `http://localhost:3001/api-docs` for the interactive Scalar API reference.
Endpoints:
- `POST /api/auth/register` — Register
- `POST /api/auth/login` — Login
- `GET /api/sessions` — List explorations
- `POST /api/sessions` — Start exploration
- `GET /api/findings` — List findings
- `POST /api/reports` — Generate report
- `GET /api/schedules` — List schedules
- `GET /api/visual/comparisons` — Visual regression review
---
## License
ABE core is open-source under the [MIT License](LICENSE).
Enterprise features (SSO, LDAP, advanced audit logs) require a commercial license. See [LICENSE-ENTERPRISE](LICENSE-ENTERPRISE).
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createBrandingRouter = createBrandingRouter;
/**
* Branding/white-labeling API.
* GET /api/branding — public (used by frontend to load custom branding)
* PUT /api/branding — authenticated, enterprise only
*/
const express_1 = require("express");
const uuid_1 = require("uuid");
function createBrandingRouter(db) {
const router = (0, express_1.Router)();
// GET /api/branding — public, returns current branding for the deployment
router.get('/', async (_req, res, next) => {
try {
const row = await db
.selectFrom('branding_config')
.selectAll()
.executeTakeFirst();
if (!row) {
return res.json({
appName: 'ABE',
primaryColor: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
});
}
res.json({
appName: row.app_name,
primaryColor: row.primary_color,
logoUrl: row.logo_url,
faviconUrl: row.favicon_url,
customCss: row.custom_css,
});
}
catch (err) {
next(err);
}
});
// PUT /api/branding — update branding (authenticated)
router.put('/', async (req, res, next) => {
try {
const user = req.user;
if (!user)
return res.status(401).json({ error: 'Unauthorized' });
const { appName, primaryColor, logoUrl, faviconUrl, customCss } = req.body;
const orgId = user.orgId ?? 'default';
const existing = await db
.selectFrom('branding_config')
.select('id')
.where('organization_id', '=', orgId)
.executeTakeFirst();
if (existing) {
await db
.updateTable('branding_config')
.set({
app_name: appName ?? null,
primary_color: primaryColor ?? null,
logo_url: logoUrl ?? null,
favicon_url: faviconUrl ?? null,
custom_css: customCss ?? null,
updated_at: Date.now(),
})
.where('organization_id', '=', orgId)
.execute();
}
else {
await db.insertInto('branding_config').values({
id: (0, uuid_1.v4)(),
organization_id: orgId,
app_name: appName ?? null,
primary_color: primaryColor ?? null,
logo_url: logoUrl ?? null,
favicon_url: faviconUrl ?? null,
custom_css: customCss ?? null,
updated_at: Date.now(),
}).execute();
}
res.json({ success: true });
}
catch (err) {
next(err);
}
});
return router;
}
+78
View File
@@ -0,0 +1,78 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.ForbiddenError = exports.AuthenticationError = exports.ValidationError = exports.AppError = void 0;
exports.globalErrorHandler = globalErrorHandler;
class AppError extends Error {
constructor(message, statusCode, code, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = isOperational;
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
exports.AppError = AppError;
class ValidationError extends AppError {
constructor(message, details) {
super(message, 400, 'VALIDATION_ERROR');
this.details = details;
}
}
exports.ValidationError = ValidationError;
class AuthenticationError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
exports.AuthenticationError = AuthenticationError;
class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
exports.ForbiddenError = ForbiddenError;
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
exports.NotFoundError = NotFoundError;
class ConflictError extends AppError {
constructor(message) {
super(message, 409, 'CONFLICT');
}
}
exports.ConflictError = ConflictError;
class RateLimitError extends AppError {
constructor() {
super('Too many requests', 429, 'RATE_LIMIT');
}
}
exports.RateLimitError = RateLimitError;
function globalErrorHandler(err, req, res, _next) {
const authReq = req;
const logger = authReq.log;
const userId = authReq.user?.id;
if (err instanceof AppError && err.isOperational) {
if (logger) {
logger.warn({ err, statusCode: err.statusCode, userId }, err.message);
}
const body = { error: err.message, code: err.code };
if (err instanceof ValidationError && err.details !== undefined) {
body['details'] = err.details;
}
res.status(err.statusCode).json(body);
return;
}
if (logger) {
logger.error({ err, userId }, 'Unhandled error');
}
else {
console.error('Unhandled error', err);
}
res.status(500).json({
error: process.env['NODE_ENV'] === 'production' ? 'Internal server error' : err.message,
code: 'INTERNAL_ERROR',
});
}
+9
View File
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.notFoundMiddleware = notFoundMiddleware;
function notFoundMiddleware(req, res) {
res.status(404).json({
error: `Route ${req.method} ${req.path} not found`,
code: 'NOT_FOUND',
});
}
+11
View File
@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRequestIdMiddleware = createRequestIdMiddleware;
const crypto_1 = require("crypto");
function createRequestIdMiddleware(logger) {
return (req, _res, next) => {
req.id = req.headers['x-request-id'] ?? (0, crypto_1.randomUUID)();
req.log = logger.child({ requestId: req.id, method: req.method, url: req.url });
next();
};
}
+622
View File
@@ -0,0 +1,622 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.openApiSpec = void 0;
exports.createApiDocsRouter = createApiDocsRouter;
/**
* OpenAPI 3.1 specification for ABE API.
* Uses @asteasolutions/zod-to-openapi to generate from Zod schemas.
*/
const express_1 = require("express");
const zod_1 = require("zod");
const zod_to_openapi_1 = require("@asteasolutions/zod-to-openapi");
const express_api_reference_1 = require("@scalar/express-api-reference");
// Extend Zod with OpenAPI metadata support
(0, zod_to_openapi_1.extendZodWithOpenApi)(zod_1.z);
// ─── Registry ─────────────────────────────────────────────────────────────────
const registry = new zod_to_openapi_1.OpenAPIRegistry();
// ─── Reusable schemas ─────────────────────────────────────────────────────────
const ErrorSchema = registry.register('Error', zod_1.z.object({ error: zod_1.z.string() }).openapi('Error'));
// Auth schemas
const RegisterRequestSchema = registry.register('RegisterRequest', zod_1.z.object({
email: zod_1.z.string().email(),
password: zod_1.z.string().min(8),
name: zod_1.z.string().optional(),
}).openapi('RegisterRequest'));
const LoginRequestSchema = registry.register('LoginRequest', zod_1.z.object({
email: zod_1.z.string().email(),
password: zod_1.z.string(),
}).openapi('LoginRequest'));
const UserSchema = registry.register('User', zod_1.z.object({
id: zod_1.z.string(),
email: zod_1.z.string(),
name: zod_1.z.string().nullable(),
role: zod_1.z.enum(['owner', 'admin', 'member', 'viewer']),
createdAt: zod_1.z.number(),
}).openapi('User'));
// Session schemas
const SessionStatusSchema = zod_1.z.enum(['running', 'completed', 'failed', 'stopped']);
const CrawlSessionSchema = registry.register('CrawlSession', zod_1.z.object({
id: zod_1.z.string(),
url: zod_1.z.string().url(),
status: SessionStatusSchema,
seed: zod_1.z.number(),
maxStates: zod_1.z.number(),
statesVisited: zod_1.z.number(),
createdAt: zod_1.z.number(),
completedAt: zod_1.z.number().nullable(),
}).openapi('CrawlSession'));
const StartSessionRequestSchema = registry.register('StartSessionRequest', zod_1.z.object({
url: zod_1.z.string().url(),
seed: zod_1.z.number().optional(),
maxStates: zod_1.z.number().optional(),
maxDepth: zod_1.z.number().optional(),
allowedDomains: zod_1.z.array(zod_1.z.string()).optional(),
excludedPaths: zod_1.z.array(zod_1.z.string()).optional(),
}).openapi('StartSessionRequest'));
// Finding schemas
const SeveritySchema = zod_1.z.enum(['low', 'medium', 'high', 'critical']);
const FindingStatusSchema = zod_1.z.enum(['open', 'investigating', 'resolved', 'closed']);
const FindingSchema = registry.register('Finding', zod_1.z.object({
id: zod_1.z.string(),
sessionId: zod_1.z.string(),
severity: SeveritySchema,
type: zod_1.z.string(),
description: zod_1.z.string(),
status: FindingStatusSchema,
createdAt: zod_1.z.number(),
resolvedAt: zod_1.z.number().nullable(),
}).openapi('Finding'));
// Report schemas
const ReportFormatSchema = zod_1.z.enum(['pdf', 'html', 'json']);
const ReportSchema = registry.register('Report', zod_1.z.object({
id: zod_1.z.string(),
format: ReportFormatSchema,
status: zod_1.z.enum(['pending', 'completed', 'failed']),
createdAt: zod_1.z.number(),
completedAt: zod_1.z.number().nullable(),
}).openapi('Report'));
// Schedule schemas
const ScheduleSchema = registry.register('Schedule', zod_1.z.object({
id: zod_1.z.string(),
name: zod_1.z.string(),
url: zod_1.z.string(),
cronExpression: zod_1.z.string(),
enabled: zod_1.z.boolean(),
lastRunAt: zod_1.z.number().nullable(),
nextRunAt: zod_1.z.number().nullable(),
createdAt: zod_1.z.number(),
}).openapi('Schedule'));
// Integration schemas
const IntegrationTypeSchema = zod_1.z.enum(['slack', 'github', 'jira', 'webhook']);
const IntegrationSchema = registry.register('Integration', zod_1.z.object({
id: zod_1.z.string(),
type: IntegrationTypeSchema,
name: zod_1.z.string(),
enabled: zod_1.z.boolean(),
createdAt: zod_1.z.number(),
}).openapi('Integration'));
// Visual comparison schemas
const ComparisonStatusSchema = zod_1.z.enum(['passed', 'failed', 'new_state', 'pending']);
const VisualComparisonSchema = registry.register('VisualComparison', zod_1.z.object({
id: zod_1.z.string(),
session_id: zod_1.z.string(),
state_id: zod_1.z.string(),
baseline_id: zod_1.z.string().nullable(),
current_screenshot_path: zod_1.z.string(),
diff_screenshot_path: zod_1.z.string().nullable(),
diff_pixels: zod_1.z.number().nullable(),
diff_percent: zod_1.z.number().nullable(),
status: ComparisonStatusSchema,
created_at: zod_1.z.number(),
}).openapi('VisualComparison'));
// License schema
const LicensePlanSchema = zod_1.z.enum(['free', 'pro', 'enterprise']);
const LicenseStatusSchema = registry.register('LicenseStatus', zod_1.z.object({
plan: LicensePlanSchema,
valid: zod_1.z.boolean(),
expiresAt: zod_1.z.string().nullable(),
features: zod_1.z.array(zod_1.z.string()),
}).openapi('LicenseStatus'));
// ─── Route registrations ───────────────────────────────────────────────────────
const bearerAuth = registry.registerComponent('securitySchemes', 'BearerAuth', {
type: 'http',
scheme: 'bearer',
});
// Auth endpoints
registry.registerPath({
method: 'post',
path: '/api/auth/register',
summary: 'Register a new user',
tags: ['Auth'],
request: { body: { content: { 'application/json': { schema: RegisterRequestSchema } } } },
responses: {
201: { description: 'User registered', content: { 'application/json': { schema: UserSchema } } },
400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/auth/login',
summary: 'Login',
tags: ['Auth'],
request: { body: { content: { 'application/json': { schema: LoginRequestSchema } } } },
responses: {
200: { description: 'Login successful', content: { 'application/json': { schema: UserSchema } } },
401: { description: 'Invalid credentials', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/auth/logout',
summary: 'Logout',
tags: ['Auth'],
security: [{ [bearerAuth.name]: [] }],
responses: { 200: { description: 'Logged out' } },
});
registry.registerPath({
method: 'get',
path: '/api/auth/me',
summary: 'Get current user',
tags: ['Auth'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Current user', content: { 'application/json': { schema: UserSchema } } },
401: { description: 'Not authenticated', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/auth/setup-required',
summary: 'Check if first-run setup is required',
tags: ['Auth'],
responses: {
200: {
description: 'Setup status',
content: {
'application/json': {
schema: zod_1.z.object({ required: zod_1.z.boolean() }),
},
},
},
},
});
// Sessions endpoints
registry.registerPath({
method: 'get',
path: '/api/sessions',
summary: 'List all crawl sessions',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: {
description: 'List of sessions',
content: { 'application/json': { schema: zod_1.z.array(CrawlSessionSchema) } },
},
},
});
registry.registerPath({
method: 'post',
path: '/api/sessions',
summary: 'Start a new crawl session',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
request: { body: { content: { 'application/json': { schema: StartSessionRequestSchema } } } },
responses: {
201: { description: 'Session started', content: { 'application/json': { schema: CrawlSessionSchema } } },
400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/sessions/{id}',
summary: 'Get session by ID',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Session details', content: { 'application/json': { schema: CrawlSessionSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'delete',
path: '/api/sessions/{id}',
summary: 'Stop a crawl session',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Session stopped' },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Findings endpoints
registry.registerPath({
method: 'get',
path: '/api/findings',
summary: 'List findings',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
request: {
query: zod_1.z.object({
severity: zod_1.z.string().optional(),
type: zod_1.z.string().optional(),
status: zod_1.z.string().optional(),
sessionId: zod_1.z.string().optional(),
search: zod_1.z.string().optional(),
}),
},
responses: {
200: {
description: 'List of findings',
content: { 'application/json': { schema: zod_1.z.array(FindingSchema) } },
},
},
});
registry.registerPath({
method: 'get',
path: '/api/findings/{id}',
summary: 'Get finding by ID',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Finding details', content: { 'application/json': { schema: FindingSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/findings/{id}/resolve',
summary: 'Resolve a finding',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Finding resolved', content: { 'application/json': { schema: FindingSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/findings/stats',
summary: 'Get finding statistics',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: {
description: 'Statistics',
content: {
'application/json': {
schema: zod_1.z.object({
total: zod_1.z.number(),
bySeverity: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
byType: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
byStatus: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
}),
},
},
},
},
});
// Reports endpoints
registry.registerPath({
method: 'post',
path: '/api/reports',
summary: 'Generate a report',
tags: ['Reports'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({
sessionId: zod_1.z.string().optional(),
format: ReportFormatSchema,
}),
},
},
},
},
responses: {
202: { description: 'Report generation started', content: { 'application/json': { schema: ReportSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/reports',
summary: 'List reports',
tags: ['Reports'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Reports list', content: { 'application/json': { schema: zod_1.z.array(ReportSchema) } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/reports/{id}/download',
summary: 'Download report file',
tags: ['Reports'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Report file (PDF, HTML or JSON)' },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Schedules endpoints
registry.registerPath({
method: 'get',
path: '/api/schedules',
summary: 'List schedules',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Schedules list', content: { 'application/json': { schema: zod_1.z.array(ScheduleSchema) } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/schedules',
summary: 'Create a schedule',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({
name: zod_1.z.string(),
url: zod_1.z.string().url(),
cronExpression: zod_1.z.string(),
config: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
}),
},
},
},
},
responses: {
201: { description: 'Schedule created', content: { 'application/json': { schema: ScheduleSchema } } },
400: { description: 'Invalid cron expression', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'patch',
path: '/api/schedules/{id}/toggle',
summary: 'Enable or disable a schedule',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Toggled', content: { 'application/json': { schema: ScheduleSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'delete',
path: '/api/schedules/{id}',
summary: 'Delete a schedule',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
responses: {
200: { description: 'Deleted' },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Integrations endpoints
registry.registerPath({
method: 'get',
path: '/api/integrations',
summary: 'List integrations',
tags: ['Integrations'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Integrations list', content: { 'application/json': { schema: zod_1.z.array(IntegrationSchema) } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/integrations',
summary: 'Create an integration',
tags: ['Integrations'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({
type: IntegrationTypeSchema,
name: zod_1.z.string(),
config: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
}),
},
},
},
},
responses: {
201: { description: 'Integration created', content: { 'application/json': { schema: IntegrationSchema } } },
},
});
// Visual regression endpoints
registry.registerPath({
method: 'get',
path: '/api/visual/comparisons',
summary: 'List visual comparisons',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: {
query: zod_1.z.object({
sessionId: zod_1.z.string().optional(),
status: ComparisonStatusSchema.optional(),
}),
},
responses: {
200: {
description: 'Comparisons list',
content: { 'application/json': { schema: zod_1.z.array(VisualComparisonSchema) } },
},
},
});
registry.registerPath({
method: 'post',
path: '/api/visual/baselines/{comparisonId}/approve',
summary: 'Approve a comparison as baseline',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ comparisonId: zod_1.z.string() }) },
responses: {
200: {
description: 'Approved',
content: {
'application/json': {
schema: zod_1.z.object({ baselineId: zod_1.z.string(), status: zod_1.z.literal('approved') }),
},
},
},
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/visual/baselines/{comparisonId}/reject',
summary: 'Reject a comparison',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: { params: zod_1.z.object({ comparisonId: zod_1.z.string() }) },
responses: {
200: {
description: 'Rejected',
content: {
'application/json': {
schema: zod_1.z.object({ status: zod_1.z.literal('rejected') }),
},
},
},
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/visual/baselines/approve-all',
summary: 'Approve all new-state comparisons as baselines',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({ sessionId: zod_1.z.string().optional() }),
},
},
},
},
responses: {
200: {
description: 'Bulk approved',
content: {
'application/json': {
schema: zod_1.z.object({ approved: zod_1.z.number() }),
},
},
},
},
});
// License endpoints
registry.registerPath({
method: 'get',
path: '/api/license/status',
summary: 'Get license status',
tags: ['License'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'License status', content: { 'application/json': { schema: LicenseStatusSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/license/activate',
summary: 'Activate a license key',
tags: ['License'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: zod_1.z.object({ key: zod_1.z.string() }),
},
},
},
},
responses: {
200: { description: 'License activated', content: { 'application/json': { schema: LicenseStatusSchema } } },
400: { description: 'Invalid key', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Health endpoints
registry.registerPath({
method: 'get',
path: '/health/live',
summary: 'Liveness probe',
tags: ['Health'],
responses: {
200: {
description: 'Process alive',
content: {
'application/json': {
schema: zod_1.z.object({ status: zod_1.z.literal('ok'), uptime: zod_1.z.number() }),
},
},
},
},
});
registry.registerPath({
method: 'get',
path: '/health/ready',
summary: 'Readiness probe',
tags: ['Health'],
responses: {
200: {
description: 'Ready',
content: {
'application/json': {
schema: zod_1.z.object({ status: zod_1.z.literal('ready'), db: zod_1.z.string() }),
},
},
},
503: {
description: 'Not ready',
content: {
'application/json': {
schema: zod_1.z.object({ status: zod_1.z.literal('not_ready'), db: zod_1.z.string(), error: zod_1.z.string() }),
},
},
},
},
});
// ─── Generate spec ─────────────────────────────────────────────────────────────
const generator = new zod_to_openapi_1.OpenApiGeneratorV31(registry.definitions);
exports.openApiSpec = generator.generateDocument({
openapi: '3.1.0',
info: {
title: 'ABE — Autonomous Bug Explorer API',
version: '1.0.0',
description: 'ABE is an enterprise self-hosted platform for autonomous web application bug discovery. ' +
'This API allows you to manage crawl sessions, review findings, generate reports, and configure integrations.',
},
servers: [{ url: 'http://localhost:3001', description: 'Local development' }],
});
// ─── Express Router ────────────────────────────────────────────────────────────
function createApiDocsRouter() {
const router = (0, express_1.Router)();
// Serve the raw OpenAPI JSON spec
router.get('/openapi.json', (_req, res) => {
res.json(exports.openApiSpec);
});
// Serve Scalar UI
router.use('/', (0, express_api_reference_1.apiReference)({
spec: { content: exports.openApiSpec },
theme: 'purple',
}));
return router;
}
+47
View File
@@ -0,0 +1,47 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRouter = createRouter;
/**
* ABE API Router — registers all module routes.
*/
const express_1 = require("express");
const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController");
const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController");
const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController");
const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController");
const IntegrationsController_1 = require("../modules/integrations/infrastructure/http/IntegrationsController");
const SchedulingController_1 = require("../modules/scheduling/infrastructure/http/SchedulingController");
const VisualRegressionController_1 = require("../modules/visual-regression/infrastructure/http/VisualRegressionController");
const LicensingController_1 = require("../modules/licensing/infrastructure/http/LicensingController");
const FeatureGateMiddleware_1 = require("../modules/licensing/infrastructure/middleware/FeatureGateMiddleware");
const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController");
const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware");
const SSOController_1 = require("../modules/sso/infrastructure/http/SSOController");
const AuditController_1 = require("../modules/audit/infrastructure/http/AuditController");
const branding_1 = require("./branding");
function createRouter(deps) {
const router = (0, express_1.Router)();
const { authDeps, licenseService } = deps;
// Auth routes — public (no auth middleware)
router.use('/auth', (0, AuthController_1.createAuthController)(authDeps.registerCommand, authDeps.loginCommand, authDeps.createOrgCommand, authDeps.inviteMemberCommand, authDeps.createApiKeyCommand, authDeps.getUserQuery, authDeps.listOrgMembersQuery, authDeps.sessionRepository, authDeps.apiKeyRepository, authDeps.userRepository));
// Apply auth middleware to all routes below
const authMiddleware = (0, AuthMiddleware_1.createAuthMiddleware)(authDeps.userRepository, authDeps.sessionRepository, authDeps.apiKeyRepository);
router.use(authMiddleware);
router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps));
router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps));
router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps));
router.use('/reports', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'reports:basic'), (0, ReportingController_1.createReportingRouter)(deps.reportingDeps));
router.use('/integrations', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'integrations:webhook'), (0, IntegrationsController_1.createIntegrationsRouter)(deps.integrationsDeps));
router.use('/schedules', (0, SchedulingController_1.createSchedulingRouter)(deps.schedulingDeps));
router.use('/visual', (0, VisualRegressionController_1.createVisualRegressionRouter)(deps.visualRegressionDeps));
// Licensing routes (public-ish — only status and activate, no sensitive data)
const licensingController = new LicensingController_1.LicensingController(licenseService);
router.use('/license', licensingController.router);
// Enterprise: SSO + MFA (feature-gated)
router.use('/sso', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'auth:sso'), (0, SSOController_1.createSSORouter)(deps.ssoDeps));
// Enterprise: Audit logs (feature-gated)
router.use('/audit', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'audit:logs'), (0, AuditController_1.createAuditRouter)(deps.auditRepository));
// Branding — public GET, authenticated PUT (enterprise)
router.use('/branding', (0, branding_1.createBrandingRouter)(deps.db));
return router;
}
+69
View File
@@ -0,0 +1,69 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createServer = createServer;
/**
* ABE API Server — Express app factory.
* Middleware order matters: requestId → helmet → cors → rateLimit → body → routes → notFound → errorHandler
*/
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const helmet_1 = __importDefault(require("helmet"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const cookie_parser_1 = __importDefault(require("cookie-parser"));
const requestId_1 = require("./middleware/requestId");
const notFound_1 = require("./middleware/notFound");
const errorHandler_1 = require("./middleware/errorHandler");
const router_1 = require("./router");
const openapi_1 = require("./openapi");
function createServer(deps) {
const app = (0, express_1.default)();
// 1. Request ID — must be first so all logs have requestId
app.use((0, requestId_1.createRequestIdMiddleware)(deps.logger));
// 2. Security headers
app.use((0, helmet_1.default)({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", 'ws:', 'wss:'],
scriptSrc: ["'self'", "'unsafe-inline'"],
},
},
}));
// 3. CORS
app.use((0, cors_1.default)({ origin: deps.config.cors.origin, credentials: true }));
// 4. Rate limiting
app.use((0, express_rate_limit_1.default)({
windowMs: deps.config.api.rateLimitWindowMs,
max: deps.config.api.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
}));
// 5. Body parsing + cookies
app.use(express_1.default.json({ limit: '10mb' }));
app.use((0, cookie_parser_1.default)());
// 6. Health endpoints — no auth required
app.get('/health/live', (_req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/health/ready', async (_req, res) => {
try {
await deps.db.selectFrom('sessions').select('id').limit(1).execute();
res.json({ status: 'ready', db: 'connected' });
}
catch (err) {
res.status(503).json({ status: 'not_ready', db: 'disconnected', error: String(err) });
}
});
// 7. Module routes
app.use('/api', (0, router_1.createRouter)(deps));
// 7b. API documentation (no auth required)
app.use('/api-docs', (0, openapi_1.createApiDocsRouter)());
// 8. 404 handler
app.use(notFound_1.notFoundMiddleware);
// 9. Global error handler — always last
app.use(errorHandler_1.globalErrorHandler);
return app;
}
+602
View File
@@ -0,0 +1,602 @@
"use strict";
/**
* ABE CLI — command-line interface for autonomous bug exploration.
*
* Commands:
* explore Run an exploration session
* report Generate a report for a session
* status Ping the ABE server and show active sessions
*
* Usage:
* abe explore --url http://localhost:3000
* abe explore --url http://localhost:3000 --server http://localhost:3001 --api-key <key>
* abe report --session <id> --server http://localhost:3001
* abe status --server http://localhost:3001
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const commander_1 = require("commander");
const ExplorationEngine_1 = require("../core/ExplorationEngine");
const StateGraph_1 = require("../core/StateGraph");
const PlaywrightAgent_1 = require("../plugins/agents/PlaywrightAgent");
const ScreenshotCollector_1 = require("../plugins/collectors/ScreenshotCollector");
const NetworkCollector_1 = require("../plugins/collectors/NetworkCollector");
const DOMSnapshotCollector_1 = require("../plugins/collectors/DOMSnapshotCollector");
const MarkdownExporter_1 = require("../plugins/exporters/MarkdownExporter");
const JSONExporter_1 = require("../plugins/exporters/JSONExporter");
const PlaywrightReproducer_1 = require("../plugins/reproducers/PlaywrightReproducer");
const ExplorationConfig_1 = require("../core/ExplorationConfig");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const program = new commander_1.Command();
program
.name('abe')
.description('Autonomous Bug Explorer — explore web apps and find bugs')
.version('0.1.0');
// ─── explore ────────────────────────────────────────────────────────────────
program
.command('explore')
.description('Run an autonomous exploration session against a target URL')
.requiredOption('--url <url>', 'Target URL to explore')
.option('--config <file>', 'Path to JSON config file (merged with flags)')
.option('--seed <seed>', 'Deterministic seed', parseInt, 42)
.option('--max-states <n>', 'Max states to explore', parseInt, 50)
.option('--max-depth <n>', 'Max click depth', parseInt, 5)
.option('--allowed-domains <domains>', 'Comma-separated allowed domains')
.option('--excluded-paths <paths>', 'Comma-separated excluded paths')
.option('--action-delay <ms>', 'Delay between actions in ms', parseInt, 500)
.option('--session-timeout <ms>', 'Session timeout in ms', parseInt, 300000)
// Auth options
.option('--auth-type <type>', 'Auth type: cookies | headers | login_flow')
.option('--login-url <url>', 'Login page URL (for login_flow)')
.option('--username <user>', 'Username (for login_flow)')
.option('--password <pass>', 'Password (for login_flow)')
.option('--username-selector <sel>', 'Username field selector (for login_flow)')
.option('--password-selector <sel>', 'Password field selector (for login_flow)')
.option('--submit-selector <sel>', 'Submit button selector (for login_flow)')
// Output
.option('--output <format>', 'Output format: human | json | junit | markdown', 'human')
.option('--reports-dir <dir>', 'Output directory for reports', './reports')
// CI flags
.option('--fail-on-anomaly', 'Exit 1 if any anomaly found')
.option('--fail-on-severity <level>', 'Exit 1 if finding at or above severity (low|medium|high|critical)')
// Remote server
.option('--server <url>', 'Connect to remote ABE server instead of running inline')
.option('--api-key <key>', 'API key for remote server')
.action(async (opts) => {
// Load config file if provided
let fileConfig = {};
if (opts['config']) {
try {
const raw = fs.readFileSync(opts['config'], 'utf8');
fileConfig = JSON.parse(raw);
}
catch (err) {
console.error(`Failed to read config file: ${err.message}`);
process.exit(2);
}
}
// Merge file config with CLI flags (CLI flags take precedence)
const seed = opts['seed'] ?? fileConfig['seed'] ?? 42;
const maxStates = opts['maxStates'] ?? fileConfig['maxStates'] ?? 50;
const maxDepth = opts['maxDepth'] ?? fileConfig['maxDepth'] ?? 5;
const reportsDir = opts['reportsDir'] ?? './reports';
// Remote server mode
if (opts['server']) {
await exploreRemote(opts);
return;
}
// Inline mode — build auth config
let auth = null;
if (opts['authType'] === 'login_flow') {
auth = {
type: 'login_flow',
loginUrl: opts['loginUrl'] ?? '',
usernameSelector: opts['usernameSelector'] ?? 'input[type="email"]',
passwordSelector: opts['passwordSelector'] ?? 'input[type="password"]',
submitSelector: opts['submitSelector'] ?? 'button[type="submit"]',
username: opts['username'] ?? '',
password: opts['password'] ?? '',
};
}
else if (opts['authType'] === 'headers') {
auth = { type: 'headers', headers: {} };
}
else if (opts['authType'] === 'cookies') {
auth = { type: 'cookies', cookies: [] };
}
const config = {
...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG,
...fileConfig,
maxStates,
maxDepth,
actionDelayMs: opts['actionDelay'] ?? ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG.actionDelayMs,
sessionTimeoutMs: opts['sessionTimeout'] ?? ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG.sessionTimeoutMs,
allowedDomains: opts['allowedDomains']
? opts['allowedDomains'].split(',').map((d) => d.trim())
: [new URL(opts['url']).hostname],
excludedPaths: opts['excludedPaths']
? opts['excludedPaths'].split(',').map((p) => p.trim())
: [],
auth,
};
const anomalies = [];
const discoveredStates = [];
let statesVisited = 0;
let exitCode = 0;
let explorationError;
const startMs = Date.now();
try {
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed, explorationConfig: config });
const engine = new ExplorationEngine_1.ExplorationEngine({
graph,
agent,
seed,
url: opts['url'],
maxSteps: maxStates,
outputDir: reportsDir,
explorationConfig: config,
collectors: [
new ScreenshotCollector_1.ScreenshotCollector(reportsDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(reportsDir),
],
exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()],
reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(),
events: {
onStateDiscovered: (_sessionId, stateId, stateUrl, title) => {
discoveredStates.push({ id: stateId, url: stateUrl, title });
},
onAnomalyDetected: (_sessionId, anomaly) => {
anomalies.push(anomaly);
},
onSessionCompleted: (_sessionId, visited) => {
statesVisited = visited;
},
onSessionError: (_sessionId, error) => {
explorationError = error;
},
},
});
const result = await engine.run();
statesVisited = result.statesVisited;
}
catch (err) {
explorationError = err instanceof Error ? err.message : String(err);
exitCode = 2;
}
if (explorationError && exitCode === 0)
exitCode = 2;
// Determine exit code from CI flags
if (exitCode === 0 && opts['failOnAnomaly'] && anomalies.length > 0) {
exitCode = 1;
}
if (exitCode === 0 && opts['failOnSeverity']) {
const severityRank = { low: 0, medium: 1, high: 2, critical: 3 };
const threshold = severityRank[opts['failOnSeverity']] ?? 0;
const failing = anomalies.filter((a) => (severityRank[a.severity] ?? 0) >= threshold);
if (failing.length > 0)
exitCode = 1;
}
const durationMs = Date.now() - startMs;
const output = opts['output'];
if (output === 'json') {
const summary = {
url: opts['url'],
seed,
duration_ms: durationMs,
states_visited: statesVisited,
findings: anomalies.map((a) => ({
id: a.id,
type: a.type,
severity: a.severity,
description: a.description,
report_path: path.join(reportsDir, a.id, 'report.json'),
})),
exit_code: exitCode,
error: explorationError,
};
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
}
else if (output === 'junit') {
const xml = buildJunit({
url: opts['url'],
statesVisited,
discoveredStates,
anomalies,
durationMs,
});
const outPath = path.join(process.cwd(), 'abe-results.xml');
fs.writeFileSync(outPath, xml, 'utf8');
console.log(`JUnit results written to ${outPath}`);
}
else if (output === 'markdown') {
printMarkdownSummary({ url: opts['url'], statesVisited, anomalies, durationMs, explorationError });
}
else {
// human-readable
if (anomalies.length === 0 && !explorationError) {
console.log(`✓ ABE finished. No findings. ${statesVisited} states explored. (${durationMs}ms)`);
}
else {
if (explorationError) {
console.error(`✗ ABE error: ${explorationError}`);
}
if (anomalies.length > 0) {
console.log(`⚠ ABE finished. ${anomalies.length} finding(s) in ${statesVisited} states (${durationMs}ms):`);
for (const a of anomalies) {
console.log(` [${a.severity.toUpperCase()}] ${a.type}: ${a.description}`);
}
}
}
}
process.exit(exitCode);
});
// ─── report ─────────────────────────────────────────────────────────────────
program
.command('report')
.description('Generate a report for a completed exploration session')
.requiredOption('--session <id>', 'Session ID to generate report for')
.option('--server <url>', 'ABE server URL', 'http://localhost:3001')
.option('--api-key <key>', 'API key for authentication')
.option('--format <format>', 'Report format: pdf | html | json', 'pdf')
.option('--output <file>', 'Output file path (default: ./abe-report-<session>.pdf)')
.action(async (opts) => {
const server = opts['server'];
const sessionId = opts['session'];
const apiKey = opts['apiKey'];
const format = opts['format'];
const outputFile = opts['output'] ?? `./abe-report-${sessionId}.${format}`;
const headers = { 'Content-Type': 'application/json' };
if (apiKey)
headers['x-abe-api-key'] = apiKey;
console.log(`Generating ${format} report for session ${sessionId}...`);
try {
// Request report generation
const genRes = await fetch(`${server}/api/reports`, {
method: 'POST',
headers,
body: JSON.stringify({ sessionId, format }),
});
if (!genRes.ok) {
console.error(`Error generating report: ${genRes.status} ${await genRes.text()}`);
process.exit(2);
return;
}
const report = await genRes.json();
console.log(`Report queued: ${report.id}`);
// Poll until ready
let ready = false;
let attempts = 0;
const maxAttempts = 30;
while (!ready && attempts < maxAttempts) {
await sleep(2000);
attempts++;
const statusRes = await fetch(`${server}/api/reports/${report.id}`, { headers });
if (!statusRes.ok)
break;
const status = await statusRes.json();
if (status.status === 'completed') {
ready = true;
}
else if (status.status === 'failed') {
console.error('Report generation failed');
process.exit(2);
return;
}
process.stdout.write('.');
}
if (!ready) {
console.error('\nTimeout waiting for report');
process.exit(2);
return;
}
console.log('\nDownloading...');
// Download the report
const dlRes = await fetch(`${server}/api/reports/${report.id}/download`, { headers });
if (!dlRes.ok) {
console.error(`Download failed: ${dlRes.status}`);
process.exit(2);
return;
}
const buffer = Buffer.from(await dlRes.arrayBuffer());
fs.writeFileSync(outputFile, buffer);
console.log(`Report saved to ${outputFile}`);
}
catch (err) {
console.error(`Error: ${err.message}`);
process.exit(2);
}
});
// ─── status ─────────────────────────────────────────────────────────────────
program
.command('status')
.description('Ping the ABE server and show active sessions')
.option('--server <url>', 'ABE server URL', 'http://localhost:3001')
.option('--api-key <key>', 'API key for authentication')
.option('--json', 'Output as JSON')
.action(async (opts) => {
const server = opts['server'];
const apiKey = opts['apiKey'];
const asJson = opts['json'];
const headers = {};
if (apiKey)
headers['x-abe-api-key'] = apiKey;
try {
// Health check
const healthRes = await fetch(`${server}/health/ready`, { headers });
const healthy = healthRes.ok;
if (!healthy) {
if (asJson) {
console.log(JSON.stringify({ status: 'down', server }));
}
else {
console.error(`✗ Server at ${server} is not ready (${healthRes.status})`);
}
process.exit(1);
return;
}
// Fetch active sessions
const sessionsRes = await fetch(`${server}/api/sessions`, { headers });
const sessions = sessionsRes.ok
? await sessionsRes.json()
: [];
const active = sessions.filter((s) => s.status === 'running');
if (asJson) {
console.log(JSON.stringify({ status: 'up', server, activeSessions: active.length, sessions: active }));
}
else {
console.log(`✓ ABE server is ready at ${server}`);
if (active.length === 0) {
console.log(' No active sessions');
}
else {
console.log(` ${active.length} active session(s):`);
for (const s of active) {
console.log(` [${s.id}] ${s.url}${s.statesVisited} states explored`);
}
}
}
}
catch (err) {
if (asJson) {
console.log(JSON.stringify({ status: 'down', server, error: err.message }));
}
else {
console.error(`✗ Cannot reach ABE server at ${server}: ${err.message}`);
}
process.exit(1);
}
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function exploreRemote(opts) {
const serverUrl = opts['server'];
const apiKey = opts['apiKey'];
const url = opts['url'];
const failOnSeverity = opts['failOnSeverity'];
const headers = { 'Content-Type': 'application/json' };
if (apiKey)
headers['x-abe-api-key'] = apiKey;
console.log(`Starting remote exploration of ${url} via ${serverUrl}...`);
try {
const res = await fetch(`${serverUrl}/api/sessions`, {
method: 'POST',
headers,
body: JSON.stringify({
url,
seed: opts['seed'],
maxStates: opts['maxStates'],
maxDepth: opts['maxDepth'],
}),
});
if (!res.ok) {
console.error(`Server error: ${res.status} ${await res.text()}`);
process.exit(2);
return;
}
const session = await res.json();
const sessionId = session.sessionId ?? session.id ?? '';
console.log(`Session started: ${sessionId}`);
// Poll for completion
let done = false;
let anomalyCount = 0;
while (!done) {
await sleep(3000);
const statusRes = await fetch(`${serverUrl}/api/sessions/${sessionId}`, { headers });
if (!statusRes.ok)
break;
const status = await statusRes.json();
if (status.status === 'completed' || status.status === 'failed' || status.status === 'stopped') {
done = true;
anomalyCount = status.findingsCount ?? 0;
console.log(`Session ${status.status}. States: ${status.statesVisited ?? 0}, Findings: ${anomalyCount}`);
}
else {
process.stdout.write('.');
}
}
let exitCode = 0;
if (opts['failOnAnomaly'] && anomalyCount > 0)
exitCode = 1;
if (failOnSeverity) {
// We can't filter by severity without fetching findings — conservative: exit 1 if any
if (anomalyCount > 0)
exitCode = 1;
}
process.exit(exitCode);
}
catch (err) {
console.error(`Error: ${err.message}`);
process.exit(2);
}
}
function buildJunit(input) {
const { url, statesVisited, discoveredStates, anomalies, durationMs } = input;
// One passing test case per discovered state (states without findings pass)
const stateCases = discoveredStates.map((s) => ` <testcase name="${escapeXml(s.title || s.url)}" classname="abe.state.${escapeXml(s.id)}" />`);
// One failing test case per anomaly
const anomalyCases = anomalies.map((a) => ` <testcase name="${escapeXml(a.description)}" classname="abe.anomaly.${escapeXml(a.type)}">\n` +
` <failure message="${escapeXml(a.description)}" type="${escapeXml(a.severity)}">${escapeXml(a.id)}</failure>\n` +
` </testcase>`);
const totalTests = Math.max(statesVisited, discoveredStates.length) + anomalies.length;
const totalFailures = anomalies.length;
const durationSec = (durationMs / 1000).toFixed(3);
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="ABE: ${escapeXml(url)}" tests="${totalTests}" failures="${totalFailures}" time="${durationSec}">\n` +
[...stateCases, ...anomalyCases].join('\n') +
'\n</testsuite>\n');
}
function printMarkdownSummary(input) {
const { url, statesVisited, anomalies, durationMs, explorationError } = input;
const lines = [
`# ABE Exploration Report`,
``,
`**Target:** ${url}`,
`**States explored:** ${statesVisited}`,
`**Duration:** ${(durationMs / 1000).toFixed(1)}s`,
`**Findings:** ${anomalies.length}`,
``,
];
if (explorationError) {
lines.push(`> ⚠ **Error:** ${explorationError}`, ``);
}
if (anomalies.length === 0) {
lines.push(`✅ No findings detected.`);
}
else {
lines.push(`## Findings`, ``);
for (const a of anomalies) {
lines.push(`### [${a.severity.toUpperCase()}] ${a.type}`, ``, `**ID:** ${a.id}`, `**Description:** ${a.description}`, ``);
}
}
console.log(lines.join('\n'));
}
function escapeXml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
program.parse(process.argv);
// ─── backup ─────────────────────────────────────────────────────────────────
program
.command('backup')
.description('Backup ABE database to a file')
.option('--db <path>', 'Path to ABE database', './data/abe.db')
.option('--output <file>', 'Backup file path', `./abe-backup-${new Date().toISOString().slice(0, 10)}.db`)
.action((opts) => {
const src = opts.db;
const dest = opts.output;
if (!fs.existsSync(src)) {
console.error(`Database not found: ${src}`);
process.exit(2);
}
fs.copyFileSync(src, dest);
const size = fs.statSync(dest).size;
console.log(`✅ Backup created: ${dest} (${Math.round(size / 1024)} KB)`);
});
// ─── restore ────────────────────────────────────────────────────────────────
program
.command('restore')
.description('Restore ABE database from a backup file')
.requiredOption('--from <file>', 'Backup file to restore from')
.option('--db <path>', 'Path to ABE database', './data/abe.db')
.option('--confirm', 'Skip confirmation prompt')
.action((opts) => {
if (!fs.existsSync(opts.from)) {
console.error(`Backup file not found: ${opts.from}`);
process.exit(2);
}
if (!opts.confirm) {
console.warn(`⚠️ This will overwrite the database at: ${opts.db}`);
console.warn(`Run with --confirm to proceed.`);
process.exit(1);
}
const dir = path.dirname(opts.db);
if (!fs.existsSync(dir))
fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(opts.from, opts.db);
const size = fs.statSync(opts.db).size;
console.log(`✅ Database restored from: ${opts.from} (${Math.round(size / 1024)} KB)`);
});
// ─── retention ──────────────────────────────────────────────────────────────
program
.command('retention')
.description('Run data retention cleanup (enterprise feature)')
.option('--db <path>', 'Path to ABE database', './data/abe.db')
.option('--findings-days <n>', 'Delete findings older than N days', parseInt, 365)
.option('--sessions-days <n>', 'Delete sessions older than N days', parseInt, 90)
.option('--audit-days <n>', 'Delete audit logs older than N days', parseInt, 365)
.option('--jobs-days <n>', 'Delete completed jobs older than N days', parseInt, 30)
.option('--dry-run', 'Show what would be deleted without deleting')
.action(async (opts) => {
if (!fs.existsSync(opts.db)) {
console.error(`Database not found: ${opts.db}`);
process.exit(2);
}
if (opts.dryRun) {
console.log('🔍 Dry run mode — nothing will be deleted');
console.log(` Findings older than ${opts.findingsDays} days`);
console.log(` Sessions older than ${opts.sessionsDays} days`);
console.log(` Audit logs older than ${opts.auditDays} days`);
console.log(` Jobs older than ${opts.jobsDays} days`);
return;
}
// Dynamically import to avoid loading DB in non-DB commands
const { Kysely, SqliteDialect } = await Promise.resolve().then(() => __importStar(require('kysely')));
const SQLite = (await Promise.resolve().then(() => __importStar(require('better-sqlite3')))).default;
const { DataRetentionService } = await Promise.resolve().then(() => __importStar(require('../modules/scheduling/infrastructure/DataRetentionService')));
const pino = (await Promise.resolve().then(() => __importStar(require('pino')))).default;
const logger = pino({ level: 'info' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = new Kysely({ dialect: new SqliteDialect({ database: new SQLite(opts.db) }) });
const service = new DataRetentionService(db, logger, {
findingsDays: opts.findingsDays,
sessionsDays: opts.sessionsDays,
auditLogsDays: opts.auditDays,
jobsDays: opts.jobsDays,
});
const results = await service.runRetention();
await db.destroy();
console.log('✅ Data retention completed:');
for (const [key, count] of Object.entries(results)) {
console.log(` ${key}: ${count} rows deleted`);
}
});
+36
View File
@@ -0,0 +1,36 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema.createTable('jobs')
.ifNotExists()
.addColumn('id', 'text', col => col.primaryKey())
.addColumn('type', 'text', col => col.notNull())
.addColumn('status', 'text', col => col.notNull().defaultTo('pending'))
.addColumn('payload', 'text', col => col.notNull())
.addColumn('result', 'text')
.addColumn('error', 'text')
.addColumn('attempts', 'integer', col => col.notNull().defaultTo(0))
.addColumn('max_attempts', 'integer', col => col.notNull().defaultTo(3))
.addColumn('priority', 'integer', col => col.notNull().defaultTo(0))
.addColumn('run_at', 'text', col => col.notNull())
.addColumn('started_at', 'text')
.addColumn('completed_at', 'text')
.addColumn('created_at', 'text', col => col.notNull())
.addColumn('updated_at', 'text', col => col.notNull())
.execute();
// Index for efficient polling
await db.schema
.createIndex('idx_jobs_poll')
.ifNotExists()
.on('jobs')
.columns(['status', 'run_at', 'priority'])
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropIndex('idx_jobs_poll').ifExists().execute();
await db.schema.dropTable('jobs').ifExists().execute();
}
+81
View File
@@ -0,0 +1,81 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema
.createTable('users')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('email', 'text', (col) => col.notNull().unique())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('password_hash', 'text', (col) => col.notNull())
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
.addColumn('org_id', 'text')
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('updated_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('organizations')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('slug', 'text', (col) => col.notNull().unique())
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('org_members')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('org_id', 'text', (col) => col.notNull().references('organizations.id'))
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
.addColumn('joined_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('api_keys')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
.addColumn('org_id', 'text', (col) => col.notNull())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('key_hash', 'text', (col) => col.notNull().unique())
.addColumn('key_prefix', 'text', (col) => col.notNull())
.addColumn('permissions', 'text', (col) => col.notNull().defaultTo('["member"]'))
.addColumn('expires_at', 'integer')
.addColumn('last_used_at', 'integer')
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('auth_sessions')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
.addColumn('token', 'text', (col) => col.notNull().unique())
.addColumn('expires_at', 'integer', (col) => col.notNull())
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createIndex('idx_auth_sessions_token')
.ifNotExists()
.on('auth_sessions')
.columns(['token'])
.execute();
await db.schema
.createIndex('idx_users_email')
.ifNotExists()
.on('users')
.columns(['email'])
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropIndex('idx_users_email').ifExists().execute();
await db.schema.dropIndex('idx_auth_sessions_token').ifExists().execute();
await db.schema.dropTable('auth_sessions').ifExists().execute();
await db.schema.dropTable('api_keys').ifExists().execute();
await db.schema.dropTable('org_members').ifExists().execute();
await db.schema.dropTable('organizations').ifExists().execute();
await db.schema.dropTable('users').ifExists().execute();
}
+25
View File
@@ -0,0 +1,25 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema
.createTable('reports')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('title', 'text', (col) => col.notNull())
.addColumn('format', 'text', (col) => col.notNull())
.addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
.addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}'))
.addColumn('file_path', 'text')
.addColumn('error_message', 'text')
.addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0))
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('completed_at', 'integer')
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('reports').ifExists().execute();
}
+44
View File
@@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema
.createTable('integrations')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('enabled', 'integer', (col) => col.notNull().defaultTo(1))
.addColumn('config_json', 'text', (col) => col.notNull().defaultTo('{}'))
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('webhook_endpoints')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('url', 'text', (col) => col.notNull())
.addColumn('secret', 'text', (col) => col.notNull())
.addColumn('enabled', 'integer', (col) => col.notNull().defaultTo(1))
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('last_delivered_at', 'integer')
.addColumn('last_status', 'integer')
.execute();
await db.schema
.createTable('webhook_deliveries')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('endpoint_id', 'text', (col) => col.notNull())
.addColumn('event', 'text', (col) => col.notNull())
.addColumn('payload_json', 'text', (col) => col.notNull())
.addColumn('status', 'integer', (col) => col.notNull())
.addColumn('attempted_at', 'integer', (col) => col.notNull())
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('webhook_deliveries').ifExists().execute();
await db.schema.dropTable('webhook_endpoints').ifExists().execute();
await db.schema.dropTable('integrations').ifExists().execute();
}
+50
View File
@@ -0,0 +1,50 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
const kysely_1 = require("kysely");
async function up(db) {
// SSO configurations per organization
await db.schema
.createTable('sso_configs')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('organization_id', 'text', (c) => c.notNull())
.addColumn('provider', 'text', (c) => c.notNull())
.addColumn('enabled', 'integer', (c) => c.notNull().defaultTo(1))
.addColumn('config_json', 'text', (c) => c.notNull().defaultTo('{}'))
.addColumn('created_at', 'integer', (c) => c.notNull())
.execute();
// TOTP secrets for MFA
await db.schema
.createTable('totp_secrets')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.notNull().unique())
.addColumn('secret', 'text', (c) => c.notNull())
.addColumn('verified', 'integer', (c) => c.notNull().defaultTo(0))
.addColumn('created_at', 'integer', (c) => c.notNull())
.execute();
// Audit logs
await db.schema
.createTable('audit_logs')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text')
.addColumn('organization_id', 'text')
.addColumn('action', 'text', (c) => c.notNull())
.addColumn('resource', 'text', (c) => c.notNull())
.addColumn('resource_id', 'text')
.addColumn('ip_address', 'text')
.addColumn('user_agent', 'text')
.addColumn('details_json', 'text', (c) => c.notNull().defaultTo('{}'))
.addColumn('occurred_at', 'integer', (c) => c.notNull())
.execute();
await (0, kysely_1.sql) `CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs (user_id)`.execute(db);
await (0, kysely_1.sql) `CREATE INDEX IF NOT EXISTS idx_audit_logs_occurred ON audit_logs (occurred_at)`.execute(db);
}
async function down(db) {
await db.schema.dropTable('audit_logs').ifExists().execute();
await db.schema.dropTable('totp_secrets').ifExists().execute();
await db.schema.dropTable('sso_configs').ifExists().execute();
}
+21
View File
@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
async function up(db) {
await db.schema
.createTable('branding_config')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('organization_id', 'text', (c) => c.notNull().unique())
.addColumn('app_name', 'text')
.addColumn('primary_color', 'text')
.addColumn('logo_url', 'text')
.addColumn('favicon_url', 'text')
.addColumn('custom_css', 'text')
.addColumn('updated_at', 'integer', (c) => c.notNull())
.execute();
}
async function down(db) {
await db.schema.dropTable('branding_config').ifExists().execute();
}
+2
View File
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+172
View File
@@ -0,0 +1,172 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SQLiteJobQueue = void 0;
/**
* SQLiteJobQueue — SQLite-backed job queue with exponential backoff retry.
* Zero external dependencies: uses Kysely + better-sqlite3.
*/
const kysely_1 = require("kysely");
const crypto_1 = require("crypto");
class SQLiteJobQueue {
constructor(db, logger, pollIntervalMs = 1000) {
this.db = db;
this.logger = logger;
this.pollIntervalMs = pollIntervalMs;
this.running = false;
this.activeJobs = 0;
this.pollTimer = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.handlers = new Map();
}
registerHandler(type, handler) {
this.handlers.set(type, handler);
}
async enqueue(type, payload, opts) {
const id = (0, crypto_1.randomUUID)();
const now = new Date().toISOString();
const runAt = (opts?.runAt ?? new Date()).toISOString();
await this.db
.insertInto('jobs')
.values({
id,
type,
status: 'pending',
payload: JSON.stringify(payload),
result: null,
error: null,
attempts: 0,
max_attempts: opts?.maxAttempts ?? 3,
priority: opts?.priority ?? 0,
run_at: runAt,
started_at: null,
completed_at: null,
created_at: now,
updated_at: now,
})
.execute();
this.logger.debug({ jobId: id, type, runAt }, 'Job enqueued');
return id;
}
start() {
if (this.running)
return;
this.running = true;
this.logger.info('Job queue started');
this.scheduleNextPoll();
}
pause() {
this.running = false;
if (this.pollTimer !== null) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
this.logger.info('Job queue paused');
}
async waitForActive(timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (this.activeJobs > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
scheduleNextPoll() {
if (!this.running)
return;
this.pollTimer = setTimeout(() => {
this.pollOnce()
.catch((err) => {
this.logger.error({ err }, 'Job queue poll error');
})
.finally(() => {
this.scheduleNextPoll();
});
}, this.pollIntervalMs);
}
async pollOnce() {
const now = new Date().toISOString();
// Find one pending job that is due
const row = await this.db
.selectFrom('jobs')
.selectAll()
.where('status', '=', 'pending')
.where('run_at', '<=', now)
.orderBy('priority', 'desc')
.orderBy('created_at', 'asc')
.limit(1)
.executeTakeFirst();
if (!row)
return;
// Optimistic lock: claim the job atomically
const claimTime = new Date().toISOString();
const updateResult = await this.db
.updateTable('jobs')
.set({
status: 'running',
started_at: claimTime,
attempts: (0, kysely_1.sql) `attempts + 1`,
updated_at: claimTime,
})
.where('id', '=', row.id)
.where('status', '=', 'pending')
.executeTakeFirst();
if (!updateResult || Number(updateResult.numUpdatedRows) === 0) {
return; // Another worker claimed this job
}
this.activeJobs++;
this.logger.info({ jobId: row.id, type: row.type, attempt: row.attempts + 1 }, 'Job started');
try {
const handler = this.handlers.get(row.type);
if (!handler) {
throw new Error(`No handler registered for job type: ${row.type}`);
}
const payload = JSON.parse(row.payload);
const result = await handler(payload);
const completedAt = new Date().toISOString();
await this.db
.updateTable('jobs')
.set({
status: 'completed',
result: JSON.stringify(result),
completed_at: completedAt,
updated_at: completedAt,
error: null,
})
.where('id', '=', row.id)
.execute();
this.logger.info({ jobId: row.id, type: row.type }, 'Job completed');
}
catch (err) {
const failedAt = new Date().toISOString();
const errorMsg = err instanceof Error ? err.message : String(err);
// Fetch current attempts count (was incremented above)
const current = await this.db
.selectFrom('jobs')
.select(['attempts', 'max_attempts'])
.where('id', '=', row.id)
.executeTakeFirst();
const attempts = current?.attempts ?? row.attempts + 1;
const maxAttempts = current?.max_attempts ?? row.max_attempts;
if (attempts >= maxAttempts) {
await this.db
.updateTable('jobs')
.set({ status: 'failed', error: errorMsg, updated_at: failedAt })
.where('id', '=', row.id)
.execute();
this.logger.error({ jobId: row.id, type: row.type, attempts, err }, 'Job failed permanently');
}
else {
const backoffMs = Math.min(1000 * Math.pow(2, attempts), 60000);
const retryAt = new Date(Date.now() + backoffMs).toISOString();
await this.db
.updateTable('jobs')
.set({ status: 'pending', run_at: retryAt, error: errorMsg, updated_at: failedAt })
.where('id', '=', row.id)
.execute();
this.logger.warn({ jobId: row.id, type: row.type, attempts, backoffMs }, 'Job failed, will retry');
}
}
finally {
this.activeJobs--;
}
}
}
exports.SQLiteJobQueue = SQLiteJobQueue;
+27
View File
@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EXPLORATION_JOB_TYPE = void 0;
exports.createExplorationJobHandler = createExplorationJobHandler;
const UniqueId_1 = require("../../shared/domain/UniqueId");
exports.EXPLORATION_JOB_TYPE = 'exploration:run';
function createExplorationJobHandler(deps) {
return async (payload) => {
const { sessionId, url, seed, maxStates } = payload;
const log = deps.logger.child({ jobType: exports.EXPLORATION_JOB_TYPE, sessionId });
log.info({ url, seed, maxStates }, 'Exploration job executing');
const id = UniqueId_1.UniqueId.from(sessionId);
const session = await deps.sessionRepo.findById(id);
if (!session) {
throw new Error(`Session not found: ${sessionId}`);
}
// In this phase the actual Playwright crawl is handled by the ExplorationOrchestrator
// which is wired separately. Here we mark the session as running and publish an event.
// Full end-to-end crawling is integrated in Phase 4's infrastructure layer.
log.info({ statesVisited: session.statesVisited }, 'Exploration job complete (orchestration delegated)');
return {
sessionId,
statesVisited: session.statesVisited,
anomaliesFound: 0,
};
};
}
+50
View File
@@ -0,0 +1,50 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.REPORT_JOB_TYPE = void 0;
exports.createReportJobHandler = createReportJobHandler;
const HTMLReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/HTMLReportGenerator");
const JSONReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/JSONReportGenerator");
const PDFReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/PDFReportGenerator");
exports.REPORT_JOB_TYPE = 'report:generate';
function createReportJobHandler(deps) {
const htmlGen = new HTMLReportGenerator_1.HTMLReportGenerator();
const jsonGen = new JSONReportGenerator_1.JSONReportGenerator();
const pdfGen = new PDFReportGenerator_1.PDFReportGenerator();
return async (payload) => {
const log = deps.logger.child({ jobType: exports.REPORT_JOB_TYPE, reportId: payload.reportId });
log.info({ format: payload.format }, 'Report generation job executing');
const report = await deps.reportRepository.findById(payload.reportId);
if (!report) {
throw new Error(`Report not found: ${payload.reportId}`);
}
report.markGenerating();
await deps.reportRepository.update(report);
// Load findings with filters from report
const findings = await deps.findingRepository.findAll({
sessionId: report.filters.sessionId,
severity: report.filters.severity,
});
let filePath;
try {
if (payload.format === 'pdf') {
filePath = await pdfGen.generate(report, findings);
}
else if (payload.format === 'json') {
filePath = await jsonGen.generate(report, findings);
}
else {
filePath = await htmlGen.generate(report, findings);
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
report.markFailed(msg);
await deps.reportRepository.update(report);
throw err;
}
report.markReady(filePath, findings.length);
await deps.reportRepository.update(report);
log.info({ filePath, totalFindings: findings.length }, 'Report job complete');
return { reportId: payload.reportId, filePath };
};
}
+278
View File
@@ -0,0 +1,278 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/**
* ABE — composition root.
* Wires all modules together and starts the HTTP + WebSocket server.
*/
const http_1 = __importDefault(require("http"));
const socket_io_1 = require("socket.io");
const Config_1 = require("./shared/infrastructure/Config");
const Logger_1 = require("./shared/infrastructure/Logger");
const DatabaseConnection_1 = require("./shared/infrastructure/DatabaseConnection");
const InProcessEventBus_1 = require("./shared/infrastructure/InProcessEventBus");
const migrator_1 = require("./db/migrator");
// Crawling module
const KyselyCrawlSessionRepository_1 = require("./modules/crawling/infrastructure/repositories/KyselyCrawlSessionRepository");
const KyselyStateRepository_1 = require("./modules/crawling/infrastructure/repositories/KyselyStateRepository");
const StartCrawlCommand_1 = require("./modules/crawling/application/commands/StartCrawlCommand");
const StopCrawlCommand_1 = require("./modules/crawling/application/commands/StopCrawlCommand");
const GetSessionQuery_1 = require("./modules/crawling/application/queries/GetSessionQuery");
const ListSessionsQuery_1 = require("./modules/crawling/application/queries/ListSessionsQuery");
// Findings module
const KyselyFindingRepository_1 = require("./modules/findings/infrastructure/repositories/KyselyFindingRepository");
const CreateFindingCommand_1 = require("./modules/findings/application/commands/CreateFindingCommand");
const EnrichFindingCommand_1 = require("./modules/findings/application/commands/EnrichFindingCommand");
const ResolveFindingCommand_1 = require("./modules/findings/application/commands/ResolveFindingCommand");
const GetFindingQuery_1 = require("./modules/findings/application/queries/GetFindingQuery");
const ListFindingsQuery_1 = require("./modules/findings/application/queries/ListFindingsQuery");
const FindingStatsQuery_1 = require("./modules/findings/application/queries/FindingStatsQuery");
const OnAnomalyDetected_1 = require("./modules/findings/application/event-handlers/OnAnomalyDetected");
const NullAIEnricher_1 = require("./modules/findings/infrastructure/NullAIEnricher");
// Fuzzing module
const FuzzingEngineAdapter_1 = require("./modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter");
const RunFuzzCommand_1 = require("./modules/fuzzing/application/commands/RunFuzzCommand");
const OnActionExecuted_1 = require("./modules/fuzzing/application/event-handlers/OnActionExecuted");
const InMemoryFuzzSessionRepository_1 = require("./modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository");
// Auth module
const KyselyUserRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyUserRepository");
const KyselyOrganizationRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyOrganizationRepository");
const KyselyApiKeyRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyApiKeyRepository");
const KyselySessionRepository_1 = require("./modules/auth/infrastructure/repositories/KyselySessionRepository");
const RegisterCommand_1 = require("./modules/auth/application/commands/RegisterCommand");
const LoginCommand_1 = require("./modules/auth/application/commands/LoginCommand");
const CreateOrganizationCommand_1 = require("./modules/auth/application/commands/CreateOrganizationCommand");
const InviteMemberCommand_1 = require("./modules/auth/application/commands/InviteMemberCommand");
const CreateApiKeyCommand_1 = require("./modules/auth/application/commands/CreateApiKeyCommand");
const GetUserQuery_1 = require("./modules/auth/application/queries/GetUserQuery");
const ListOrgMembersQuery_1 = require("./modules/auth/application/queries/ListOrgMembersQuery");
const PasswordService_1 = require("./modules/auth/infrastructure/auth/PasswordService");
// Reporting module
const KyselyReportRepository_1 = require("./modules/reporting/infrastructure/repositories/KyselyReportRepository");
const GenerateReportCommand_1 = require("./modules/reporting/application/commands/GenerateReportCommand");
// Integrations module
const KyselyIntegrationRepository_1 = require("./modules/integrations/infrastructure/repositories/KyselyIntegrationRepository");
const KyselyWebhookEndpointRepository_1 = require("./modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository");
const WebhookDispatcher_1 = require("./modules/integrations/infrastructure/webhooks/WebhookDispatcher");
const OnFindingCreated_1 = require("./modules/integrations/application/event-handlers/OnFindingCreated");
// Licensing module
const RSALicenseValidator_1 = require("./modules/licensing/infrastructure/validators/RSALicenseValidator");
const LicenseService_1 = require("./modules/licensing/application/LicenseService");
// Visual regression module
const KyselyVisualRepository_1 = require("./modules/visual-regression/infrastructure/repositories/KyselyVisualRepository");
const VisualRegressionAdapter_1 = require("./modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter");
const ApproveBaselineCommand_1 = require("./modules/visual-regression/application/commands/ApproveBaselineCommand");
const RejectComparisonCommand_1 = require("./modules/visual-regression/application/commands/RejectComparisonCommand");
const ApproveAllNewStatesCommand_1 = require("./modules/visual-regression/application/commands/ApproveAllNewStatesCommand");
const ListComparisonsQuery_1 = require("./modules/visual-regression/application/queries/ListComparisonsQuery");
const StorageProvider_1 = require("./shared/infrastructure/StorageProvider");
const path_1 = __importDefault(require("path"));
// SSO + Audit modules (enterprise)
const KyselySSOConfigRepository_1 = require("./modules/sso/infrastructure/repositories/KyselySSOConfigRepository");
const KyselyTOTPRepository_1 = require("./modules/sso/infrastructure/repositories/KyselyTOTPRepository");
const TOTPService_1 = require("./modules/sso/infrastructure/providers/TOTPService");
const KyselyAuditRepository_1 = require("./modules/audit/infrastructure/repositories/KyselyAuditRepository");
// Scheduling module
const KyselyScheduleRepository_1 = require("./modules/scheduling/infrastructure/repositories/KyselyScheduleRepository");
const CreateScheduleCommand_1 = require("./modules/scheduling/application/commands/CreateScheduleCommand");
const ToggleScheduleCommand_1 = require("./modules/scheduling/application/commands/ToggleScheduleCommand");
const DeleteScheduleCommand_1 = require("./modules/scheduling/application/commands/DeleteScheduleCommand");
const ListSchedulesQuery_1 = require("./modules/scheduling/application/queries/ListSchedulesQuery");
const SchedulingService_1 = require("./modules/scheduling/application/SchedulingService");
const DataRetentionService_1 = require("./modules/scheduling/infrastructure/DataRetentionService");
// Job queue
const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue");
const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker");
const ReportWorker_1 = require("./jobs/workers/ReportWorker");
// API + Realtime
const server_1 = require("./api/server");
const SocketGateway_1 = require("./realtime/SocketGateway");
async function bootstrap() {
// Startup probe — measure total boot time
const startupAt = Date.now();
// 1. Config
const config = (0, Config_1.loadConfig)();
// 2. Logger
const logger = (0, Logger_1.createLogger)({ level: config.log.level, nodeEnv: config.nodeEnv });
logger.info({ port: config.port, env: config.nodeEnv }, 'Starting ABE...');
// 3. Database + migrations
const db = (0, DatabaseConnection_1.createDatabase)(config.db);
await (0, migrator_1.runMigrations)(db);
logger.info('Database migrations applied');
// 4. Event bus
const eventBus = new InProcessEventBus_1.InProcessEventBus(logger);
// 5. Repositories
const sessionRepo = new KyselyCrawlSessionRepository_1.KyselyCrawlSessionRepository(db);
const stateRepo = new KyselyStateRepository_1.KyselyStateRepository(db);
const findingRepo = new KyselyFindingRepository_1.KyselyFindingRepository(db);
const reportRepo = new KyselyReportRepository_1.KyselyReportRepository(db);
const fuzzRepo = new InMemoryFuzzSessionRepository_1.InMemoryFuzzSessionRepository();
// Suppress unused warning for stateRepo — used by crawling infrastructure
void stateRepo;
// 6. Crawling use cases
const startCrawl = new StartCrawlCommand_1.StartCrawlCommand(sessionRepo, eventBus);
const stopCrawl = new StopCrawlCommand_1.StopCrawlCommand(sessionRepo, eventBus);
const getSession = new GetSessionQuery_1.GetSessionQuery(sessionRepo);
const listSessions = new ListSessionsQuery_1.ListSessionsQuery(sessionRepo);
// 7. Findings use cases
const createFinding = new CreateFindingCommand_1.CreateFindingCommand(findingRepo, eventBus);
const enricher = new NullAIEnricher_1.NullAIEnricher();
const enrichFinding = new EnrichFindingCommand_1.EnrichFindingCommand(findingRepo, enricher, eventBus);
const resolveFinding = new ResolveFindingCommand_1.ResolveFindingCommand(findingRepo, eventBus);
const getFinding = new GetFindingQuery_1.GetFindingQuery(findingRepo);
const listFindings = new ListFindingsQuery_1.ListFindingsQuery(findingRepo);
const findingStats = new FindingStatsQuery_1.FindingStatsQuery(findingRepo);
// 8. Fuzzing use cases
const fuzzerEngine = new FuzzingEngineAdapter_1.FuzzingEngineAdapter({ intensity: 'low', seed: 42 });
const runFuzz = new RunFuzzCommand_1.RunFuzzCommand(fuzzerEngine, fuzzRepo, eventBus);
// 9. Event handlers — subscribe to EventBus
const onAnomalyDetected = new OnAnomalyDetected_1.OnAnomalyDetected(createFinding);
eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected);
const onActionExecuted = new OnActionExecuted_1.OnActionExecuted(runFuzz);
eventBus.subscribe('crawling.action_executed', onActionExecuted);
// 10. Auth module
const userRepo = new KyselyUserRepository_1.KyselyUserRepository(db);
const orgRepo = new KyselyOrganizationRepository_1.KyselyOrganizationRepository(db);
const apiKeyRepo = new KyselyApiKeyRepository_1.KyselyApiKeyRepository(db);
const authSessionRepo = new KyselySessionRepository_1.KyselySessionRepository(db);
const registerCommand = new RegisterCommand_1.RegisterCommand(userRepo, eventBus, PasswordService_1.hashPassword);
const loginCommand = new LoginCommand_1.LoginCommand(userRepo, authSessionRepo, eventBus, PasswordService_1.verifyPassword);
const createOrgCommand = new CreateOrganizationCommand_1.CreateOrganizationCommand(orgRepo, userRepo, eventBus);
const inviteMemberCommand = new InviteMemberCommand_1.InviteMemberCommand(orgRepo, userRepo, eventBus);
const createApiKeyCommand = new CreateApiKeyCommand_1.CreateApiKeyCommand(apiKeyRepo, userRepo);
const getUserQuery = new GetUserQuery_1.GetUserQuery(userRepo);
const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, userRepo);
// 11. Reporting use cases
const generateReport = new GenerateReportCommand_1.GenerateReportCommand(reportRepo, eventBus);
// 11b. Licensing
const licenseValidator = new RSALicenseValidator_1.RSALicenseValidator();
const licenseService = new LicenseService_1.LicenseService(licenseValidator);
// 11c. Integrations (moved from 11d)
const integrationRepo = new KyselyIntegrationRepository_1.KyselyIntegrationRepository(db);
const webhookRepo = new KyselyWebhookEndpointRepository_1.KyselyWebhookEndpointRepository(db);
const webhookDispatcher = new WebhookDispatcher_1.WebhookDispatcher(webhookRepo, logger);
const onFindingCreated = new OnFindingCreated_1.OnFindingCreated(integrationRepo, webhookRepo, webhookDispatcher, logger);
eventBus.subscribe('findings.finding_created', onFindingCreated);
// 12. Job queue (created before HTTP server so it can be injected)
const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger }));
jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
jobQueue.start();
// 11d. Visual regression module
const storageBasePath = path_1.default.join(process.cwd(), 'data');
const storageProvider = new StorageProvider_1.LocalStorageProvider(storageBasePath);
const visualBaselineRepo = new KyselyVisualRepository_1.KyselyVisualBaselineRepository(db);
const visualComparisonRepo = new KyselyVisualRepository_1.KyselyVisualComparisonRepository(db);
const visualRegressionAdapter = new VisualRegressionAdapter_1.VisualRegressionAdapter(storageProvider, visualBaselineRepo, visualComparisonRepo, eventBus);
void visualRegressionAdapter; // used by ExplorationOrchestrator in crawling infra
const listComparisons = new ListComparisonsQuery_1.ListComparisonsQuery(visualComparisonRepo);
const approveBaseline = new ApproveBaselineCommand_1.ApproveBaselineCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
const rejectComparison = new RejectComparisonCommand_1.RejectComparisonCommand(visualComparisonRepo);
const approveAllNewStates = new ApproveAllNewStatesCommand_1.ApproveAllNewStatesCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
// 12b. Scheduling module (after job queue, since it enqueues jobs)
const scheduleRepo = new KyselyScheduleRepository_1.KyselyScheduleRepository(db);
const createSchedule = new CreateScheduleCommand_1.CreateScheduleCommand(scheduleRepo, eventBus);
const toggleSchedule = new ToggleScheduleCommand_1.ToggleScheduleCommand(scheduleRepo, eventBus);
const deleteSchedule = new DeleteScheduleCommand_1.DeleteScheduleCommand(scheduleRepo, eventBus);
const listSchedules = new ListSchedulesQuery_1.ListSchedulesQuery(scheduleRepo);
const schedulingService = new SchedulingService_1.SchedulingService(scheduleRepo, jobQueue, eventBus, logger);
await schedulingService.start();
// 12b.1. Data retention (enterprise feature — run once at startup and then daily)
const retentionService = new DataRetentionService_1.DataRetentionService(db, logger);
void retentionService.runRetention().catch((err) => logger.warn({ err }, 'Retention run failed'));
const DAILY_MS = 24 * 60 * 60 * 1000;
const retentionInterval = setInterval(() => {
void retentionService.runRetention().catch((err) => logger.warn({ err }, 'Retention run failed'));
}, DAILY_MS);
retentionInterval.unref(); // Don't keep process alive just for retention
// 12c. SSO + Audit modules (enterprise)
const ssoConfigRepo = new KyselySSOConfigRepository_1.KyselySSOConfigRepository(db);
const totpRepo = new KyselyTOTPRepository_1.KyselyTOTPRepository(db);
const totpService = new TOTPService_1.TOTPService();
const auditRepo = new KyselyAuditRepository_1.KyselyAuditRepository(db);
// 13. HTTP server
const app = (0, server_1.createServer)({
config,
logger,
db,
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
fuzzingDeps: { runFuzz, repository: fuzzRepo },
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
integrationsDeps: { integrationRepo, webhookRepo },
schedulingDeps: { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo },
visualRegressionDeps: { listComparisons, approveBaseline, rejectComparison, approveAllNewStates },
licenseService,
authDeps: {
registerCommand,
loginCommand,
createOrgCommand,
inviteMemberCommand,
createApiKeyCommand,
getUserQuery,
listOrgMembersQuery,
sessionRepository: authSessionRepo,
apiKeyRepository: apiKeyRepo,
userRepository: userRepo,
},
ssoDeps: { ssoConfigRepository: ssoConfigRepo, totpRepository: totpRepo, totpService },
auditRepository: auditRepo,
});
const httpServer = http_1.default.createServer(app);
// 12. Socket.io + gateway
const io = new socket_io_1.Server(httpServer, {
cors: { origin: config.cors.origin, credentials: true },
});
const gateway = new SocketGateway_1.SocketGateway(io, eventBus, logger);
gateway.start();
// 13. Start listening
await new Promise((resolve) => {
httpServer.listen(config.port, config.host, resolve);
});
const startupMs = Date.now() - startupAt;
logger.info({ port: config.port, host: config.host, startupMs }, 'ABE server ready');
// 14. Graceful shutdown
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown)
return;
shuttingDown = true;
logger.info({ signal }, 'Shutting down...');
// Stop accepting new connections
httpServer.close();
// Close socket.io
io.close();
// Stop scheduling service
schedulingService.stop();
// Stop job queue and wait for active jobs
jobQueue.pause();
await jobQueue.waitForActive(30000);
// Close database
try {
await db.destroy();
}
catch (err) {
logger.warn({ err }, 'Error closing database');
}
logger.info('Shutdown complete');
process.exit(0);
}
// Force-exit if graceful shutdown takes too long
function forceExit(signal) {
void shutdown(signal).catch(() => {
process.exit(1);
});
setTimeout(() => {
logger.error('Forced shutdown after 30s');
process.exit(1);
}, 30000).unref();
}
process.on('SIGTERM', () => forceExit('SIGTERM'));
process.on('SIGINT', () => forceExit('SIGINT'));
}
bootstrap().catch((err) => {
console.error('Fatal: failed to start ABE', err);
process.exit(1);
});
+23
View File
@@ -0,0 +1,23 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AuditLog = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class AuditLog extends Entity_1.Entity {
static create(props, id) {
return new AuditLog(props, id ?? UniqueId_1.UniqueId.create());
}
static reconstitute(props, id) {
return new AuditLog(props, id);
}
get userId() { return this.props.userId; }
get organizationId() { return this.props.organizationId; }
get action() { return this.props.action; }
get resource() { return this.props.resource; }
get resourceId() { return this.props.resourceId; }
get ipAddress() { return this.props.ipAddress; }
get userAgent() { return this.props.userAgent; }
get details() { return this.props.details; }
get occurredAt() { return this.props.occurredAt; }
}
exports.AuditLog = AuditLog;
+9
View File
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuditRouter = exports.KyselyAuditRepository = exports.AuditLog = void 0;
var AuditLog_1 = require("./domain/entities/AuditLog");
Object.defineProperty(exports, "AuditLog", { enumerable: true, get: function () { return AuditLog_1.AuditLog; } });
var KyselyAuditRepository_1 = require("./infrastructure/repositories/KyselyAuditRepository");
Object.defineProperty(exports, "KyselyAuditRepository", { enumerable: true, get: function () { return KyselyAuditRepository_1.KyselyAuditRepository; } });
var AuditController_1 = require("./infrastructure/http/AuditController");
Object.defineProperty(exports, "createAuditRouter", { enumerable: true, get: function () { return AuditController_1.createAuditRouter; } });
@@ -0,0 +1,39 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuditRouter = createAuditRouter;
const express_1 = require("express");
function createAuditRouter(repo) {
const router = (0, express_1.Router)();
// GET /api/audit — list audit logs (enterprise only)
router.get('/', async (req, res, next) => {
try {
const filters = {
userId: req.query['userId'],
organizationId: req.query['organizationId'],
action: req.query['action'],
resource: req.query['resource'],
limit: req.query['limit'] ? Number(req.query['limit']) : 100,
};
if (req.query['from'])
filters.from = new Date(req.query['from']);
if (req.query['to'])
filters.to = new Date(req.query['to']);
const logs = await repo.findAll(filters);
res.json(logs.map((l) => ({
id: l.id.toString(),
userId: l.userId,
organizationId: l.organizationId,
action: l.action,
resource: l.resource,
resourceId: l.resourceId,
ipAddress: l.ipAddress,
details: l.details,
occurredAt: l.occurredAt.toISOString(),
})));
}
catch (err) {
next(err);
}
});
return router;
}
@@ -0,0 +1,55 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyAuditRepository = void 0;
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const AuditLog_1 = require("../../domain/entities/AuditLog");
class KyselyAuditRepository {
constructor(db) {
this.db = db;
}
async save(log) {
await this.db.insertInto('audit_logs').values({
id: log.id.toString(),
user_id: log.userId,
organization_id: log.organizationId,
action: log.action,
resource: log.resource,
resource_id: log.resourceId,
ip_address: log.ipAddress,
user_agent: log.userAgent,
details_json: JSON.stringify(log.details),
occurred_at: log.occurredAt.getTime(),
}).execute();
}
async findAll(filters = {}) {
let query = this.db.selectFrom('audit_logs').selectAll();
if (filters.userId)
query = query.where('user_id', '=', filters.userId);
if (filters.organizationId)
query = query.where('organization_id', '=', filters.organizationId);
if (filters.action)
query = query.where('action', '=', filters.action);
if (filters.resource)
query = query.where('resource', '=', filters.resource);
if (filters.from)
query = query.where('occurred_at', '>=', filters.from.getTime());
if (filters.to)
query = query.where('occurred_at', '<=', filters.to.getTime());
const rows = await query
.orderBy('occurred_at', 'desc')
.limit(filters.limit ?? 100)
.execute();
return rows.map((row) => AuditLog_1.AuditLog.reconstitute({
userId: row.user_id,
organizationId: row.organization_id,
action: row.action,
resource: row.resource,
resourceId: row.resource_id,
ipAddress: row.ip_address,
userAgent: row.user_agent,
details: JSON.parse(row.details_json),
occurredAt: new Date(row.occurred_at),
}, UniqueId_1.UniqueId.from(row.id)));
}
}
exports.KyselyAuditRepository = KyselyAuditRepository;
@@ -0,0 +1,41 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CreateApiKeyCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const ApiKey_1 = require("../../domain/entities/ApiKey");
const crypto_1 = require("crypto");
class CreateApiKeyCommand {
constructor(apiKeyRepository, userRepository) {
this.apiKeyRepository = apiKeyRepository;
this.userRepository = userRepository;
}
async execute(request) {
const user = await this.userRepository.findById(request.userId);
if (!user) {
return (0, Result_1.Err)('User not found');
}
if (!request.name.trim()) {
return (0, Result_1.Err)('API key name is required');
}
const rawKey = `abe_${(0, crypto_1.randomBytes)(32).toString('hex')}`;
const keyHash = (0, crypto_1.createHash)('sha256').update(rawKey).digest('hex');
const keyPrefix = rawKey.substring(0, 12);
const apiKey = ApiKey_1.ApiKey.create({
userId: request.userId,
orgId: request.orgId,
name: request.name.trim(),
keyHash,
keyPrefix,
permissions: request.permissions ?? ['member'],
expiresAt: request.expiresAt,
});
await this.apiKeyRepository.save(apiKey);
return (0, Result_1.Ok)({
id: apiKey.id.toString(),
key: rawKey,
keyPrefix,
name: apiKey.name,
});
}
}
exports.CreateApiKeyCommand = CreateApiKeyCommand;
@@ -0,0 +1,48 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CreateOrganizationCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const Organization_1 = require("../../domain/entities/Organization");
const crypto_1 = require("crypto");
class CreateOrganizationCommand {
constructor(orgRepository, userRepository, eventBus) {
this.orgRepository = orgRepository;
this.userRepository = userRepository;
this.eventBus = eventBus;
}
async execute(request) {
const user = await this.userRepository.findById(request.ownerId);
if (!user) {
return (0, Result_1.Err)('User not found');
}
const slug = Organization_1.Organization.slugify(request.name);
if (!slug) {
return (0, Result_1.Err)('Invalid organization name');
}
const existing = await this.orgRepository.findBySlug(slug);
if (existing) {
return (0, Result_1.Err)('Organization name already taken');
}
const org = Organization_1.Organization.create({ name: request.name, slug });
await this.orgRepository.save(org);
await this.orgRepository.addMember({
id: (0, crypto_1.randomUUID)(),
orgId: org.id.toString(),
userId: request.ownerId,
role: 'owner',
joinedAt: new Date(),
});
user.assignToOrg(org.id.toString());
await this.userRepository.save(user);
for (const event of org.domainEvents) {
await this.eventBus.publish(event);
}
org.clearEvents();
return (0, Result_1.Ok)({
orgId: org.id.toString(),
name: org.name,
slug: org.slug,
});
}
}
exports.CreateOrganizationCommand = CreateOrganizationCommand;
@@ -0,0 +1,63 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InviteMemberCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const Email_1 = require("../../domain/value-objects/Email");
const Role_1 = require("../../domain/value-objects/Role");
const MemberInvited_1 = require("../../domain/events/MemberInvited");
const crypto_1 = require("crypto");
class InviteMemberCommand {
constructor(orgRepository, userRepository, eventBus) {
this.orgRepository = orgRepository;
this.userRepository = userRepository;
this.eventBus = eventBus;
}
async execute(request) {
const org = await this.orgRepository.findById(request.orgId);
if (!org) {
return (0, Result_1.Err)('Organization not found');
}
let email;
try {
email = Email_1.Email.create(request.email);
}
catch {
return (0, Result_1.Err)('Invalid email address');
}
let role;
try {
role = Role_1.Role.create(request.role);
}
catch {
return (0, Result_1.Err)('Invalid role');
}
const user = await this.userRepository.findByEmail(email.value);
if (!user) {
return (0, Result_1.Err)('User with this email not found. They must register first.');
}
const existing = await this.orgRepository.getMember(request.orgId, user.id.toString());
if (existing) {
return (0, Result_1.Err)('User is already a member of this organization');
}
const memberId = (0, crypto_1.randomUUID)();
await this.orgRepository.addMember({
id: memberId,
orgId: request.orgId,
userId: user.id.toString(),
role: role.value,
joinedAt: new Date(),
});
const event = new MemberInvited_1.MemberInvited(request.orgId, {
email: email.value,
role: role.value,
inviterUserId: request.inviterUserId,
});
await this.eventBus.publish(event);
return (0, Result_1.Ok)({
memberId,
email: email.value,
role: role.value,
});
}
}
exports.InviteMemberCommand = InviteMemberCommand;
+56
View File
@@ -0,0 +1,56 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LoginCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const Email_1 = require("../../domain/value-objects/Email");
const UserLoggedIn_1 = require("../../domain/events/UserLoggedIn");
const crypto_1 = require("crypto");
class LoginCommand {
constructor(userRepository, sessionRepository, eventBus, verifyPassword, sessionMaxAgeSeconds = 7 * 24 * 60 * 60) {
this.userRepository = userRepository;
this.sessionRepository = sessionRepository;
this.eventBus = eventBus;
this.verifyPassword = verifyPassword;
this.sessionMaxAgeSeconds = sessionMaxAgeSeconds;
}
async execute(request) {
let email;
try {
email = Email_1.Email.create(request.email);
}
catch {
return (0, Result_1.Err)('Invalid credentials');
}
const user = await this.userRepository.findByEmail(email.value);
if (!user) {
return (0, Result_1.Err)('Invalid credentials');
}
const valid = await this.verifyPassword(request.password, user.passwordHash);
if (!valid) {
return (0, Result_1.Err)('Invalid credentials');
}
const token = (0, crypto_1.randomUUID)();
const expiresAt = new Date(Date.now() + this.sessionMaxAgeSeconds * 1000);
const session = {
id: (0, crypto_1.randomUUID)(),
userId: user.id.toString(),
token,
expiresAt,
createdAt: new Date(),
};
await this.sessionRepository.save(session);
const event = new UserLoggedIn_1.UserLoggedIn(user.id.toString(), {
email: user.email.value,
sessionId: session.id,
});
await this.eventBus.publish(event);
return (0, Result_1.Ok)({
userId: user.id.toString(),
sessionToken: token,
expiresAt,
role: user.role.value,
name: user.name,
});
}
}
exports.LoginCommand = LoginCommand;
@@ -0,0 +1,51 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RegisterCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const User_1 = require("../../domain/entities/User");
const Email_1 = require("../../domain/value-objects/Email");
const Role_1 = require("../../domain/value-objects/Role");
class RegisterCommand {
constructor(userRepository, eventBus, hashPassword) {
this.userRepository = userRepository;
this.eventBus = eventBus;
this.hashPassword = hashPassword;
}
async execute(request) {
let email;
try {
email = Email_1.Email.create(request.email);
}
catch {
return (0, Result_1.Err)('Invalid email address');
}
const existing = await this.userRepository.findByEmail(email.value);
if (existing) {
return (0, Result_1.Err)('Email already registered');
}
if (request.password.length < 8) {
return (0, Result_1.Err)('Password must be at least 8 characters');
}
let role;
try {
role = request.role ? Role_1.Role.create(request.role) : Role_1.Role.member();
}
catch {
return (0, Result_1.Err)('Invalid role');
}
const passwordHash = await this.hashPassword(request.password);
const user = User_1.User.create({ email, name: request.name, passwordHash, role });
await this.userRepository.save(user);
for (const event of user.domainEvents) {
await this.eventBus.publish(event);
}
user.clearEvents();
return (0, Result_1.Ok)({
userId: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
});
}
}
exports.RegisterCommand = RegisterCommand;
@@ -0,0 +1,71 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuthMiddleware = createAuthMiddleware;
const crypto_1 = require("crypto");
function createAuthMiddleware(userRepository, sessionRepository, apiKeyRepository) {
return async function authMiddleware(req, res, next) {
try {
// 1. Check session cookie
const sessionToken = req.cookies?.['abe_session'];
if (sessionToken) {
const session = await sessionRepository.findByToken(sessionToken);
if (session && session.expiresAt > new Date()) {
const user = await userRepository.findById(session.userId);
if (user) {
req.user = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
};
return next();
}
}
}
// 2. Check Bearer JWT (session token in header)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const session = await sessionRepository.findByToken(token);
if (session && session.expiresAt > new Date()) {
const user = await userRepository.findById(session.userId);
if (user) {
req.user = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
};
return next();
}
}
}
// 3. Check API key
const apiKeyHeader = req.headers['x-abe-api-key'];
if (apiKeyHeader && typeof apiKeyHeader === 'string') {
const keyHash = (0, crypto_1.createHash)('sha256').update(apiKeyHeader).digest('hex');
const apiKey = await apiKeyRepository.findByHash(keyHash);
if (apiKey && !apiKey.isExpired()) {
const user = await userRepository.findById(apiKey.userId);
if (user) {
await apiKeyRepository.updateLastUsed(apiKey.id.toString(), new Date());
req.user = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
};
return next();
}
}
}
res.status(401).json({ error: 'Unauthorized' });
}
catch {
res.status(401).json({ error: 'Unauthorized' });
}
};
}
@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.requirePermission = requirePermission;
const AbilityFactory_1 = require("../../infrastructure/casl/AbilityFactory");
function requirePermission(action, subject) {
return function rbacMiddleware(req, res, next) {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const ability = (0, AbilityFactory_1.defineAbilityFor)(req.user.role);
if (!ability.can(action, subject)) {
res.status(403).json({
error: 'Forbidden',
message: `You do not have permission to ${action} ${subject}`,
});
return;
}
next();
};
}
+24
View File
@@ -0,0 +1,24 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GetUserQuery = void 0;
const Result_1 = require("../../../../shared/domain/Result");
class GetUserQuery {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute(request) {
const user = await this.userRepository.findById(request.userId);
if (!user) {
return (0, Result_1.Err)('User not found');
}
return (0, Result_1.Ok)({
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
createdAt: user.createdAt,
});
}
}
exports.GetUserQuery = GetUserQuery;
@@ -0,0 +1,33 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ListOrgMembersQuery = void 0;
const Result_1 = require("../../../../shared/domain/Result");
class ListOrgMembersQuery {
constructor(orgRepository, userRepository) {
this.orgRepository = orgRepository;
this.userRepository = userRepository;
}
async execute(request) {
const org = await this.orgRepository.findById(request.orgId);
if (!org) {
return (0, Result_1.Err)('Organization not found');
}
const members = await this.orgRepository.listMembers(request.orgId);
const dtos = [];
for (const member of members) {
const user = await this.userRepository.findById(member.userId);
if (user) {
dtos.push({
id: member.id,
userId: member.userId,
email: user.email.value,
name: user.name,
role: member.role,
joinedAt: member.joinedAt,
});
}
}
return (0, Result_1.Ok)({ members: dtos, total: dtos.length });
}
}
exports.ListOrgMembersQuery = ListOrgMembersQuery;
+35
View File
@@ -0,0 +1,35 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ApiKey = void 0;
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class ApiKey extends AggregateRoot_1.AggregateRoot {
static create(props, id) {
const keyId = id ?? UniqueId_1.UniqueId.create();
return new ApiKey({
...props,
createdAt: new Date(),
}, keyId);
}
static reconstitute(props, id) {
return new ApiKey(props, id);
}
get userId() { return this.props.userId; }
get orgId() { return this.props.orgId; }
get name() { return this.props.name; }
get keyHash() { return this.props.keyHash; }
get keyPrefix() { return this.props.keyPrefix; }
get permissions() { return this.props.permissions; }
get expiresAt() { return this.props.expiresAt; }
get lastUsedAt() { return this.props.lastUsedAt; }
get createdAt() { return this.props.createdAt; }
isExpired() {
if (!this.props.expiresAt)
return false;
return new Date() > this.props.expiresAt;
}
markUsed() {
this.props.lastUsedAt = new Date();
}
}
exports.ApiKey = ApiKey;
+33
View File
@@ -0,0 +1,33 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Organization = void 0;
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const OrgCreated_1 = require("../events/OrgCreated");
class Organization extends AggregateRoot_1.AggregateRoot {
static create(props, id) {
const orgId = id ?? UniqueId_1.UniqueId.create();
const org = new Organization({
...props,
createdAt: new Date(),
}, orgId);
org.addDomainEvent(new OrgCreated_1.OrgCreated(orgId.toString(), {
name: props.name,
slug: props.slug,
}));
return org;
}
static reconstitute(props, id) {
return new Organization(props, id);
}
static slugify(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
get name() { return this.props.name; }
get slug() { return this.props.slug; }
get createdAt() { return this.props.createdAt; }
}
exports.Organization = Organization;
+42
View File
@@ -0,0 +1,42 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.User = void 0;
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const UserCreated_1 = require("../events/UserCreated");
class User extends AggregateRoot_1.AggregateRoot {
static create(props, id) {
const userId = id ?? UniqueId_1.UniqueId.create();
const now = new Date();
const user = new User({
...props,
createdAt: now,
updatedAt: now,
}, userId);
user.addDomainEvent(new UserCreated_1.UserCreated(userId.toString(), {
email: props.email.value,
name: props.name,
role: props.role.value,
}));
return user;
}
static reconstitute(props, id) {
return new User(props, id);
}
get email() { return this.props.email; }
get name() { return this.props.name; }
get passwordHash() { return this.props.passwordHash; }
get role() { return this.props.role; }
get orgId() { return this.props.orgId; }
get createdAt() { return this.props.createdAt; }
get updatedAt() { return this.props.updatedAt; }
assignToOrg(orgId) {
this.props.orgId = orgId;
this.props.updatedAt = new Date();
}
changeRole(role) {
this.props.role = role;
this.props.updatedAt = new Date();
}
}
exports.User = User;
+14
View File
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MemberInvited = void 0;
const crypto_1 = require("crypto");
class MemberInvited {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'auth.member.invited';
this.occurredOn = new Date();
}
}
exports.MemberInvited = MemberInvited;
+14
View File
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.OrgCreated = void 0;
const crypto_1 = require("crypto");
class OrgCreated {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'auth.org.created';
this.occurredOn = new Date();
}
}
exports.OrgCreated = OrgCreated;
+14
View File
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UserCreated = void 0;
const crypto_1 = require("crypto");
class UserCreated {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'auth.user.created';
this.occurredOn = new Date();
}
}
exports.UserCreated = UserCreated;
+14
View File
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UserLoggedIn = void 0;
const crypto_1 = require("crypto");
class UserLoggedIn {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'auth.user.logged_in';
this.occurredOn = new Date();
}
}
exports.UserLoggedIn = UserLoggedIn;
+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 });
+2
View File
@@ -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 });
+18
View File
@@ -0,0 +1,18 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Email = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class Email extends ValueObject_1.ValueObject {
static create(value) {
const normalized = value.trim().toLowerCase();
if (!Email.EMAIL_REGEX.test(normalized)) {
throw new Error(`Invalid email address: ${value}`);
}
return new Email({ value: normalized });
}
get value() {
return this.props.value;
}
}
exports.Email = Email;
Email.EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+12
View File
@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Permission = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class Permission extends ValueObject_1.ValueObject {
static create(action, subject) {
return new Permission({ action, subject });
}
get action() { return this.props.action; }
get subject() { return this.props.subject; }
}
exports.Permission = Permission;
+29
View File
@@ -0,0 +1,29 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Role = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class Role extends ValueObject_1.ValueObject {
static create(value) {
if (!Role.VALID_ROLES.includes(value)) {
throw new Error(`Invalid role: ${value}. Must be one of: ${Role.VALID_ROLES.join(', ')}`);
}
return new Role({ value: value });
}
static owner() { return new Role({ value: 'owner' }); }
static admin() { return new Role({ value: 'admin' }); }
static member() { return new Role({ value: 'member' }); }
static viewer() { return new Role({ value: 'viewer' }); }
get value() {
return this.props.value;
}
isOwner() { return this.props.value === 'owner'; }
isAdmin() { return this.props.value === 'admin'; }
isMember() { return this.props.value === 'member'; }
isViewer() { return this.props.value === 'viewer'; }
}
exports.Role = Role;
Role.OWNER = 'owner';
Role.ADMIN = 'admin';
Role.MEMBER = 'member';
Role.VIEWER = 'viewer';
Role.VALID_ROLES = ['owner', 'admin', 'member', 'viewer'];
+48
View File
@@ -0,0 +1,48 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuthController = exports.KyselySessionRepository = exports.KyselyApiKeyRepository = exports.KyselyOrganizationRepository = exports.KyselyUserRepository = exports.defineAbilityFor = exports.verifyPassword = exports.hashPassword = exports.requirePermission = exports.createAuthMiddleware = exports.ListOrgMembersQuery = exports.GetUserQuery = exports.CreateApiKeyCommand = exports.InviteMemberCommand = exports.CreateOrganizationCommand = exports.LoginCommand = exports.RegisterCommand = exports.Permission = exports.Role = exports.Email = exports.ApiKey = exports.Organization = exports.User = void 0;
var User_1 = require("./domain/entities/User");
Object.defineProperty(exports, "User", { enumerable: true, get: function () { return User_1.User; } });
var Organization_1 = require("./domain/entities/Organization");
Object.defineProperty(exports, "Organization", { enumerable: true, get: function () { return Organization_1.Organization; } });
var ApiKey_1 = require("./domain/entities/ApiKey");
Object.defineProperty(exports, "ApiKey", { enumerable: true, get: function () { return ApiKey_1.ApiKey; } });
var Email_1 = require("./domain/value-objects/Email");
Object.defineProperty(exports, "Email", { enumerable: true, get: function () { return Email_1.Email; } });
var Role_1 = require("./domain/value-objects/Role");
Object.defineProperty(exports, "Role", { enumerable: true, get: function () { return Role_1.Role; } });
var Permission_1 = require("./domain/value-objects/Permission");
Object.defineProperty(exports, "Permission", { enumerable: true, get: function () { return Permission_1.Permission; } });
var RegisterCommand_1 = require("./application/commands/RegisterCommand");
Object.defineProperty(exports, "RegisterCommand", { enumerable: true, get: function () { return RegisterCommand_1.RegisterCommand; } });
var LoginCommand_1 = require("./application/commands/LoginCommand");
Object.defineProperty(exports, "LoginCommand", { enumerable: true, get: function () { return LoginCommand_1.LoginCommand; } });
var CreateOrganizationCommand_1 = require("./application/commands/CreateOrganizationCommand");
Object.defineProperty(exports, "CreateOrganizationCommand", { enumerable: true, get: function () { return CreateOrganizationCommand_1.CreateOrganizationCommand; } });
var InviteMemberCommand_1 = require("./application/commands/InviteMemberCommand");
Object.defineProperty(exports, "InviteMemberCommand", { enumerable: true, get: function () { return InviteMemberCommand_1.InviteMemberCommand; } });
var CreateApiKeyCommand_1 = require("./application/commands/CreateApiKeyCommand");
Object.defineProperty(exports, "CreateApiKeyCommand", { enumerable: true, get: function () { return CreateApiKeyCommand_1.CreateApiKeyCommand; } });
var GetUserQuery_1 = require("./application/queries/GetUserQuery");
Object.defineProperty(exports, "GetUserQuery", { enumerable: true, get: function () { return GetUserQuery_1.GetUserQuery; } });
var ListOrgMembersQuery_1 = require("./application/queries/ListOrgMembersQuery");
Object.defineProperty(exports, "ListOrgMembersQuery", { enumerable: true, get: function () { return ListOrgMembersQuery_1.ListOrgMembersQuery; } });
var AuthMiddleware_1 = require("./application/middleware/AuthMiddleware");
Object.defineProperty(exports, "createAuthMiddleware", { enumerable: true, get: function () { return AuthMiddleware_1.createAuthMiddleware; } });
var RBACMiddleware_1 = require("./application/middleware/RBACMiddleware");
Object.defineProperty(exports, "requirePermission", { enumerable: true, get: function () { return RBACMiddleware_1.requirePermission; } });
var PasswordService_1 = require("./infrastructure/auth/PasswordService");
Object.defineProperty(exports, "hashPassword", { enumerable: true, get: function () { return PasswordService_1.hashPassword; } });
Object.defineProperty(exports, "verifyPassword", { enumerable: true, get: function () { return PasswordService_1.verifyPassword; } });
var AbilityFactory_1 = require("./infrastructure/casl/AbilityFactory");
Object.defineProperty(exports, "defineAbilityFor", { enumerable: true, get: function () { return AbilityFactory_1.defineAbilityFor; } });
var KyselyUserRepository_1 = require("./infrastructure/repositories/KyselyUserRepository");
Object.defineProperty(exports, "KyselyUserRepository", { enumerable: true, get: function () { return KyselyUserRepository_1.KyselyUserRepository; } });
var KyselyOrganizationRepository_1 = require("./infrastructure/repositories/KyselyOrganizationRepository");
Object.defineProperty(exports, "KyselyOrganizationRepository", { enumerable: true, get: function () { return KyselyOrganizationRepository_1.KyselyOrganizationRepository; } });
var KyselyApiKeyRepository_1 = require("./infrastructure/repositories/KyselyApiKeyRepository");
Object.defineProperty(exports, "KyselyApiKeyRepository", { enumerable: true, get: function () { return KyselyApiKeyRepository_1.KyselyApiKeyRepository; } });
var KyselySessionRepository_1 = require("./infrastructure/repositories/KyselySessionRepository");
Object.defineProperty(exports, "KyselySessionRepository", { enumerable: true, get: function () { return KyselySessionRepository_1.KyselySessionRepository; } });
var AuthController_1 = require("./infrastructure/http/AuthController");
Object.defineProperty(exports, "createAuthController", { enumerable: true, get: function () { return AuthController_1.createAuthController; } });
@@ -0,0 +1,14 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.hashPassword = hashPassword;
exports.verifyPassword = verifyPassword;
const argon2_1 = __importDefault(require("argon2"));
async function hashPassword(password) {
return argon2_1.default.hash(password);
}
async function verifyPassword(password, hash) {
return argon2_1.default.verify(hash, password);
}
+29
View File
@@ -0,0 +1,29 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.defineAbilityFor = defineAbilityFor;
const ability_1 = require("@casl/ability");
function defineAbilityFor(role) {
const { can, cannot, build } = new ability_1.AbilityBuilder(ability_1.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;
default:
break;
}
return build();
}
+192
View File
@@ -0,0 +1,192 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuthController = createAuthController;
const express_1 = require("express");
const AuthMiddleware_1 = require("../../application/middleware/AuthMiddleware");
function createAuthController(registerCommand, loginCommand, createOrgCommand, inviteMemberCommand, createApiKeyCommand, getUserQuery, listOrgMembersQuery, sessionRepository, apiKeyRepository, userRepository) {
const router = (0, express_1.Router)();
const authMiddleware = (0, AuthMiddleware_1.createAuthMiddleware)(userRepository, sessionRepository, apiKeyRepository);
// POST /api/auth/register
router.post('/register', async (req, res) => {
const result = await registerCommand.execute({
email: req.body.email,
password: req.body.password,
name: req.body.name,
role: req.body.role,
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
res.status(201).json(result.value);
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
const result = await loginCommand.execute({
email: req.body.email,
password: req.body.password,
});
if (!result.ok) {
res.status(401).json({ error: result.error });
return;
}
const { sessionToken, expiresAt, ...userData } = result.value;
res.cookie('abe_session', sessionToken, {
httpOnly: true,
secure: process.env['NODE_ENV'] === 'production',
sameSite: 'lax',
expires: expiresAt,
});
res.json({ ...userData, sessionToken });
});
// POST /api/auth/logout
router.post('/logout', authMiddleware, async (req, res) => {
const token = req.cookies?.['abe_session'] ?? req.headers.authorization?.substring(7);
if (token) {
await sessionRepository.deleteByToken(token);
}
res.clearCookie('abe_session');
res.json({ success: true });
});
// GET /api/auth/me
router.get('/me', authMiddleware, async (req, res) => {
const result = await getUserQuery.execute({ userId: req.user.id });
if (!result.ok) {
res.status(404).json({ error: result.error });
return;
}
res.json(result.value);
});
// GET /api/auth/setup-required
router.get('/setup-required', async (_req, res) => {
const count = await userRepository.count();
res.json({ required: count === 0 });
});
// POST /api/auth/setup — first-run setup
router.post('/setup', async (req, res) => {
const count = await userRepository.count();
if (count > 0) {
res.status(400).json({ error: 'Setup already completed' });
return;
}
const registerResult = await registerCommand.execute({
email: req.body.email,
password: req.body.password,
name: req.body.name,
role: 'owner',
});
if (!registerResult.ok) {
res.status(400).json({ error: registerResult.error });
return;
}
const createOrgResult = await createOrgCommand.execute({
name: req.body.orgName ?? 'My Organization',
ownerId: registerResult.value.userId,
});
if (!createOrgResult.ok) {
res.status(400).json({ error: createOrgResult.error });
return;
}
res.status(201).json({
user: registerResult.value,
organization: createOrgResult.value,
});
});
// POST /api/auth/organizations — create org
router.post('/organizations', authMiddleware, async (req, res) => {
const result = await createOrgCommand.execute({
name: req.body.name,
ownerId: req.user.id,
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
res.status(201).json(result.value);
});
// POST /api/auth/organizations/:orgId/members — invite member
router.post('/organizations/:orgId/members', authMiddleware, async (req, res) => {
const result = await inviteMemberCommand.execute({
orgId: String(req.params['orgId']),
inviterUserId: req.user.id,
email: req.body.email,
role: req.body.role ?? 'member',
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
res.status(201).json(result.value);
});
// GET /api/auth/organizations/:orgId/members
router.get('/organizations/:orgId/members', authMiddleware, async (req, res) => {
const result = await listOrgMembersQuery.execute({ orgId: String(req.params['orgId']) });
if (!result.ok) {
res.status(404).json({ error: result.error });
return;
}
res.json(result.value);
});
// POST /api/auth/api-keys — create API key
router.post('/api-keys', authMiddleware, async (req, res) => {
const result = await createApiKeyCommand.execute({
userId: req.user.id,
orgId: req.user.orgId ?? 'default',
name: req.body.name,
permissions: req.body.permissions,
expiresAt: req.body.expiresAt ? new Date(req.body.expiresAt) : undefined,
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
res.status(201).json(result.value);
});
// GET /api/auth/api-keys — list API keys
router.get('/api-keys', authMiddleware, async (req, res) => {
const keys = await apiKeyRepository.listByUser(req.user.id);
res.json(keys.map((k) => ({
id: k.id.toString(),
name: k.name,
keyPrefix: k.keyPrefix,
permissions: k.permissions,
expiresAt: k.expiresAt,
lastUsedAt: k.lastUsedAt,
createdAt: k.createdAt,
})));
});
// DELETE /api/auth/api-keys/:id — revoke API key
router.delete('/api-keys/:id', authMiddleware, async (req, res) => {
const keyId = String(req.params['id']);
const key = await apiKeyRepository.findById(keyId);
if (!key || key.userId !== req.user.id) {
res.status(404).json({ error: 'API key not found' });
return;
}
await apiKeyRepository.delete(keyId);
res.json({ success: true });
});
// GET /api/auth/sessions — list active sessions (session management dashboard)
router.get('/sessions', authMiddleware, async (req, res) => {
const sessions = await sessionRepository.findByUserId(req.user.id);
res.json(sessions.map((s) => ({
id: s.id,
createdAt: new Date(s.createdAt).toISOString(),
expiresAt: new Date(s.expiresAt).toISOString(),
})));
});
// DELETE /api/auth/sessions/:id — revoke a specific session
router.delete('/sessions/:id', authMiddleware, async (req, res) => {
const sessionId = String(req.params['id']);
// Only allow revoking own sessions
const userSessions = await sessionRepository.findByUserId(req.user.id);
const owns = userSessions.some((s) => s.id === sessionId);
if (!owns) {
res.status(404).json({ error: 'Session not found' });
return;
}
await sessionRepository.deleteById(sessionId);
res.json({ success: true });
});
return router;
}
@@ -0,0 +1,75 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyApiKeyRepository = void 0;
const ApiKey_1 = require("../../domain/entities/ApiKey");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class KyselyApiKeyRepository {
constructor(db) {
this.db = db;
}
async save(apiKey) {
await this.db
.insertInto('api_keys')
.values({
id: apiKey.id.toString(),
user_id: apiKey.userId,
org_id: apiKey.orgId,
name: apiKey.name,
key_hash: apiKey.keyHash,
key_prefix: apiKey.keyPrefix,
permissions: JSON.stringify(apiKey.permissions),
expires_at: apiKey.expiresAt ? apiKey.expiresAt.getTime() : null,
last_used_at: apiKey.lastUsedAt ? apiKey.lastUsedAt.getTime() : null,
created_at: apiKey.createdAt.getTime(),
})
.execute();
}
async findById(id) {
const row = await this.db
.selectFrom('api_keys')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findByHash(keyHash) {
const row = await this.db
.selectFrom('api_keys')
.selectAll()
.where('key_hash', '=', keyHash)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async listByUser(userId) {
const rows = await this.db
.selectFrom('api_keys')
.selectAll()
.where('user_id', '=', userId)
.execute();
return rows.map((r) => this.toDomain(r));
}
async delete(id) {
await this.db.deleteFrom('api_keys').where('id', '=', id).execute();
}
async updateLastUsed(id, lastUsedAt) {
await this.db
.updateTable('api_keys')
.set({ last_used_at: lastUsedAt.getTime() })
.where('id', '=', id)
.execute();
}
toDomain(row) {
return ApiKey_1.ApiKey.reconstitute({
userId: row.user_id,
orgId: row.org_id,
name: row.name,
keyHash: row.key_hash,
keyPrefix: row.key_prefix,
permissions: JSON.parse(row.permissions),
expiresAt: row.expires_at ? new Date(row.expires_at) : undefined,
lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : undefined,
createdAt: new Date(row.created_at),
}, UniqueId_1.UniqueId.from(row.id));
}
}
exports.KyselyApiKeyRepository = KyselyApiKeyRepository;
@@ -0,0 +1,98 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyOrganizationRepository = void 0;
const Organization_1 = require("../../domain/entities/Organization");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class KyselyOrganizationRepository {
constructor(db) {
this.db = db;
}
async save(org) {
await this.db
.insertInto('organizations')
.values({
id: org.id.toString(),
name: org.name,
slug: org.slug,
created_at: org.createdAt.getTime(),
})
.onConflict((oc) => oc.column('id').doUpdateSet({ name: org.name }))
.execute();
}
async findById(id) {
const row = await this.db
.selectFrom('organizations')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findBySlug(slug) {
const row = await this.db
.selectFrom('organizations')
.selectAll()
.where('slug', '=', slug)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findAll() {
const rows = await this.db.selectFrom('organizations').selectAll().execute();
return rows.map((r) => this.toDomain(r));
}
async addMember(member) {
await this.db
.insertInto('org_members')
.values({
id: member.id,
org_id: member.orgId,
user_id: member.userId,
role: member.role,
joined_at: member.joinedAt.getTime(),
})
.execute();
}
async getMember(orgId, userId) {
const row = await this.db
.selectFrom('org_members')
.selectAll()
.where('org_id', '=', orgId)
.where('user_id', '=', userId)
.executeTakeFirst();
return row
? { id: row.id, orgId: row.org_id, userId: row.user_id, role: row.role, joinedAt: new Date(row.joined_at) }
: undefined;
}
async listMembers(orgId) {
const rows = await this.db
.selectFrom('org_members')
.selectAll()
.where('org_id', '=', orgId)
.execute();
return rows.map((r) => ({
id: r.id,
orgId: r.org_id,
userId: r.user_id,
role: r.role,
joinedAt: new Date(r.joined_at),
}));
}
async updateMemberRole(orgId, userId, role) {
await this.db
.updateTable('org_members')
.set({ role })
.where('org_id', '=', orgId)
.where('user_id', '=', userId)
.execute();
}
async removeMember(orgId, userId) {
await this.db
.deleteFrom('org_members')
.where('org_id', '=', orgId)
.where('user_id', '=', userId)
.execute();
}
toDomain(row) {
return Organization_1.Organization.reconstitute({ name: row.name, slug: row.slug, createdAt: new Date(row.created_at) }, UniqueId_1.UniqueId.from(row.id));
}
}
exports.KyselyOrganizationRepository = KyselyOrganizationRepository;
@@ -0,0 +1,65 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselySessionRepository = void 0;
class KyselySessionRepository {
constructor(db) {
this.db = db;
}
async save(session) {
await this.db
.insertInto('auth_sessions')
.values({
id: session.id,
user_id: session.userId,
token: session.token,
expires_at: session.expiresAt.getTime(),
created_at: session.createdAt.getTime(),
})
.execute();
}
async findByToken(token) {
const row = await this.db
.selectFrom('auth_sessions')
.selectAll()
.where('token', '=', token)
.executeTakeFirst();
if (!row)
return undefined;
return {
id: row.id,
userId: row.user_id,
token: row.token,
expiresAt: new Date(row.expires_at),
createdAt: new Date(row.created_at),
};
}
async findByUserId(userId) {
const rows = await this.db
.selectFrom('auth_sessions')
.selectAll()
.where('user_id', '=', userId)
.where('expires_at', '>', Date.now())
.orderBy('created_at', 'desc')
.execute();
return rows.map((row) => ({
id: row.id,
userId: row.user_id,
token: row.token,
expiresAt: new Date(row.expires_at),
createdAt: new Date(row.created_at),
}));
}
async deleteByToken(token) {
await this.db.deleteFrom('auth_sessions').where('token', '=', token).execute();
}
async deleteById(id) {
await this.db.deleteFrom('auth_sessions').where('id', '=', id).execute();
}
async deleteExpired() {
await this.db
.deleteFrom('auth_sessions')
.where('expires_at', '<', Date.now())
.execute();
}
}
exports.KyselySessionRepository = KyselySessionRepository;
@@ -0,0 +1,73 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyUserRepository = void 0;
const User_1 = require("../../domain/entities/User");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const Email_1 = require("../../domain/value-objects/Email");
const Role_1 = require("../../domain/value-objects/Role");
class KyselyUserRepository {
constructor(db) {
this.db = db;
}
async save(user) {
const row = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
password_hash: user.passwordHash,
role: user.role.value,
org_id: user.orgId ?? null,
created_at: user.createdAt.getTime(),
updated_at: user.updatedAt.getTime(),
};
await this.db
.insertInto('users')
.values(row)
.onConflict((oc) => oc.column('id').doUpdateSet({
name: row.name,
role: row.role,
org_id: row.org_id,
updated_at: row.updated_at,
}))
.execute();
}
async findById(id) {
const row = await this.db
.selectFrom('users')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findByEmail(email) {
const row = await this.db
.selectFrom('users')
.selectAll()
.where('email', '=', email.toLowerCase())
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findAll() {
const rows = await this.db.selectFrom('users').selectAll().execute();
return rows.map((r) => this.toDomain(r));
}
async count() {
const result = await this.db
.selectFrom('users')
.select((eb) => eb.fn.count('id').as('count'))
.executeTakeFirstOrThrow();
return Number(result.count);
}
toDomain(row) {
return User_1.User.reconstitute({
email: Email_1.Email.create(row.email),
name: row.name,
passwordHash: row.password_hash,
role: Role_1.Role.create(row.role),
orgId: row.org_id ?? undefined,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}, UniqueId_1.UniqueId.from(row.id));
}
}
exports.KyselyUserRepository = KyselyUserRepository;
+12
View File
@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NullAIEnricher = void 0;
/**
* NullAIEnricher — no-op enricher used when AI provider is not configured.
*/
class NullAIEnricher {
async enrich(_finding) {
throw new Error('AI enrichment is not configured. Set ABE_AI_PROVIDER to enable it.');
}
}
exports.NullAIEnricher = NullAIEnricher;
@@ -38,7 +38,7 @@ function createFindingsRouter(deps) {
}
res.json(result.value);
});
// GET /api/findings/:id — finding detail
// GET /api/findings/:id — finding detail (includes actionTrace)
router.get('/:id', async (req, res) => {
const findingId = req.params['id'];
const result = await deps.getFinding.execute({ findingId });
@@ -46,7 +46,8 @@ function createFindingsRouter(deps) {
res.status(404).json({ error: result.error });
return;
}
res.json(toDTO(result.value));
const f = result.value;
res.json({ ...toDTO(f), actionTrace: f.actionTrace });
});
// PATCH /api/findings/:id/status — update status
router.patch('/:id/status', async (req, res) => {
@@ -0,0 +1,39 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RunFuzzCommand = void 0;
const Result_1 = require("../../../../shared/domain/Result");
const FuzzSession_1 = require("../../domain/entities/FuzzSession");
class RunFuzzCommand {
constructor(fuzzerEngine, repository, eventBus) {
this.fuzzerEngine = fuzzerEngine;
this.repository = repository;
this.eventBus = eventBus;
}
async execute(request) {
const sessionResult = FuzzSession_1.FuzzSession.create({
crawlSessionId: request.crawlSessionId,
intensity: request.intensity,
seed: request.seed,
});
if (!sessionResult.ok) {
return (0, Result_1.Err)(sessionResult.error);
}
const session = sessionResult.value;
await this.repository.save(session);
const actions = this.fuzzerEngine.generateFuzzActions(request.state.domSnapshot, request.state);
for (const _action of actions) {
session.incrementActions();
}
session.complete();
await this.repository.update(session);
for (const event of session.domainEvents) {
await this.eventBus.publish(event);
}
session.clearEvents();
return (0, Result_1.Ok)({
fuzzSessionId: session.id.toString(),
actionsGenerated: actions.length,
});
}
}
exports.RunFuzzCommand = RunFuzzCommand;
@@ -0,0 +1,26 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.OnActionExecuted = void 0;
/**
* Listens for action_executed events from crawling module
* and triggers fuzzing on the resulting state's DOM.
*/
class OnActionExecuted {
constructor(runFuzz) {
this.runFuzz = runFuzz;
}
async handle(event) {
const payload = event.payload;
if (!payload.state || !payload.sessionId)
return;
if (!payload.state.domSnapshot)
return;
await this.runFuzz.execute({
crawlSessionId: payload.sessionId,
intensity: payload.intensity ?? 'low',
seed: payload.action?.seed ?? Date.now(),
state: payload.state,
});
}
}
exports.OnActionExecuted = OnActionExecuted;
+22
View File
@@ -0,0 +1,22 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FuzzResult = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
class FuzzResult extends Entity_1.Entity {
static create(props, id) {
return new FuzzResult({ ...props, detectedAt: new Date() }, id);
}
static reconstitute(props, id) {
return new FuzzResult(props, id);
}
get sessionId() { return this.props.sessionId; }
get stateId() { return this.props.stateId; }
get selector() { return this.props.selector; }
get payload() { return this.props.payload; }
get strategy() { return this.props.strategy; }
get anomalyType() { return this.props.anomalyType; }
get severity() { return this.props.severity; }
get description() { return this.props.description; }
get detectedAt() { return this.props.detectedAt; }
}
exports.FuzzResult = FuzzResult;
+96
View File
@@ -0,0 +1,96 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FuzzSession = void 0;
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
const Result_1 = require("../../../../shared/domain/Result");
const FuzzIntensity_1 = require("../value-objects/FuzzIntensity");
const Seed_1 = require("../value-objects/Seed");
const FuzzStarted_1 = require("../events/FuzzStarted");
const FuzzCompleted_1 = require("../events/FuzzCompleted");
const VulnerabilityDetected_1 = require("../events/VulnerabilityDetected");
class FuzzSession extends AggregateRoot_1.AggregateRoot {
constructor(props, id) {
super(props, id);
}
static reconstitute(props, id) {
return new FuzzSession(props, id);
}
static create(request) {
let intensity;
try {
intensity = FuzzIntensity_1.FuzzIntensity.fromString(request.intensity);
}
catch (e) {
return (0, Result_1.Err)(e.message);
}
let seed;
try {
seed = Seed_1.Seed.create(request.seed);
}
catch (e) {
return (0, Result_1.Err)(e.message);
}
const props = {
crawlSessionId: request.crawlSessionId,
intensity,
seed,
status: 'running',
actionsExecuted: 0,
vulnerabilitiesFound: 0,
startedAt: new Date(),
};
const session = new FuzzSession(props);
session.addDomainEvent(new FuzzStarted_1.FuzzStarted(session.id.toString(), {
crawlSessionId: request.crawlSessionId,
intensity: request.intensity,
seed: request.seed,
}));
return (0, Result_1.Ok)(session);
}
get crawlSessionId() { return this.props.crawlSessionId; }
get intensity() { return this.props.intensity; }
get seed() { return this.props.seed; }
get status() { return this.props.status; }
get actionsExecuted() { return this.props.actionsExecuted; }
get vulnerabilitiesFound() { return this.props.vulnerabilitiesFound; }
get startedAt() { return this.props.startedAt; }
get completedAt() { return this.props.completedAt; }
recordVulnerability(result) {
this.props = {
...this.props,
actionsExecuted: this.props.actionsExecuted + 1,
vulnerabilitiesFound: this.props.vulnerabilitiesFound + 1,
};
this.addDomainEvent(new VulnerabilityDetected_1.VulnerabilityDetected(this.id.toString(), {
crawlSessionId: this.props.crawlSessionId,
stateId: result.stateId,
anomalyType: result.anomalyType,
severity: result.severity,
selector: result.selector,
payload: result.payload,
strategy: result.strategy,
}));
}
incrementActions() {
this.props = { ...this.props, actionsExecuted: this.props.actionsExecuted + 1 };
}
complete() {
this.props = { ...this.props, status: 'completed', completedAt: new Date() };
this.addDomainEvent(new FuzzCompleted_1.FuzzCompleted(this.id.toString(), {
crawlSessionId: this.props.crawlSessionId,
actionsExecuted: this.props.actionsExecuted,
vulnerabilitiesFound: this.props.vulnerabilitiesFound,
}));
}
fail(reason) {
this.props = { ...this.props, status: 'failed', completedAt: new Date() };
this.addDomainEvent(new FuzzCompleted_1.FuzzCompleted(this.id.toString(), {
crawlSessionId: this.props.crawlSessionId,
actionsExecuted: this.props.actionsExecuted,
vulnerabilitiesFound: this.props.vulnerabilitiesFound,
failed: true,
reason,
}));
}
}
exports.FuzzSession = FuzzSession;
+14
View File
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FuzzCompleted = void 0;
const crypto_1 = require("crypto");
class FuzzCompleted {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'fuzz.completed';
this.occurredOn = new Date();
}
}
exports.FuzzCompleted = FuzzCompleted;
+14
View File
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FuzzStarted = void 0;
const crypto_1 = require("crypto");
class FuzzStarted {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'fuzz.started';
this.occurredOn = new Date();
}
}
exports.FuzzStarted = FuzzStarted;
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.VulnerabilityDetected = void 0;
const crypto_1 = require("crypto");
class VulnerabilityDetected {
constructor(aggregateId, payload) {
this.aggregateId = aggregateId;
this.payload = payload;
this.eventId = (0, crypto_1.randomUUID)();
this.eventName = 'fuzz.vulnerability_detected';
this.occurredOn = new Date();
}
}
exports.VulnerabilityDetected = VulnerabilityDetected;
+2
View File
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,18 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FuzzIntensity = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class FuzzIntensity extends ValueObject_1.ValueObject {
static low() { return new FuzzIntensity({ value: 'low' }); }
static medium() { return new FuzzIntensity({ value: 'medium' }); }
static high() { return new FuzzIntensity({ value: 'high' }); }
static fromString(s) {
if (!FuzzIntensity.LEVELS.includes(s)) {
throw new Error(`Invalid intensity: ${s}. Must be one of: ${FuzzIntensity.LEVELS.join(', ')}`);
}
return new FuzzIntensity({ value: s });
}
get value() { return this.props.value; }
}
exports.FuzzIntensity = FuzzIntensity;
FuzzIntensity.LEVELS = ['low', 'medium', 'high'];
@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FuzzPayload = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class FuzzPayload extends ValueObject_1.ValueObject {
static create(value, strategy) {
return new FuzzPayload({ value, strategy });
}
get value() { return this.props.value; }
get strategy() { return this.props.strategy; }
}
exports.FuzzPayload = FuzzPayload;
@@ -0,0 +1,20 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FuzzStrategy = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class FuzzStrategy extends ValueObject_1.ValueObject {
static empty() { return new FuzzStrategy({ value: 'empty' }); }
static oversized() { return new FuzzStrategy({ value: 'oversized' }); }
static specialChars() { return new FuzzStrategy({ value: 'special_chars' }); }
static typeMismatch() { return new FuzzStrategy({ value: 'type_mismatch' }); }
static boundary() { return new FuzzStrategy({ value: 'boundary' }); }
static fromString(s) {
if (!FuzzStrategy.ALL.includes(s)) {
throw new Error(`Invalid fuzz strategy: ${s}`);
}
return new FuzzStrategy({ value: s });
}
get value() { return this.props.value; }
}
exports.FuzzStrategy = FuzzStrategy;
FuzzStrategy.ALL = ['empty', 'oversized', 'special_chars', 'type_mismatch', 'boundary'];
+17
View File
@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Seed = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class Seed extends ValueObject_1.ValueObject {
static create(value) {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`Seed must be a non-negative integer, got: ${value}`);
}
return new Seed({ value });
}
static fromTimestamp() {
return new Seed({ value: Date.now() });
}
get value() { return this.props.value; }
}
exports.Seed = Seed;
+26
View File
@@ -0,0 +1,26 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createFuzzingRouter = exports.FuzzingEngineAdapter = exports.OnActionExecuted = exports.RunFuzzCommand = exports.Seed = exports.FuzzPayload = exports.FuzzStrategy = exports.FuzzIntensity = exports.FuzzResult = exports.FuzzSession = void 0;
// Domain
var FuzzSession_1 = require("./domain/entities/FuzzSession");
Object.defineProperty(exports, "FuzzSession", { enumerable: true, get: function () { return FuzzSession_1.FuzzSession; } });
var FuzzResult_1 = require("./domain/entities/FuzzResult");
Object.defineProperty(exports, "FuzzResult", { enumerable: true, get: function () { return FuzzResult_1.FuzzResult; } });
var FuzzIntensity_1 = require("./domain/value-objects/FuzzIntensity");
Object.defineProperty(exports, "FuzzIntensity", { enumerable: true, get: function () { return FuzzIntensity_1.FuzzIntensity; } });
var FuzzStrategy_1 = require("./domain/value-objects/FuzzStrategy");
Object.defineProperty(exports, "FuzzStrategy", { enumerable: true, get: function () { return FuzzStrategy_1.FuzzStrategy; } });
var FuzzPayload_1 = require("./domain/value-objects/FuzzPayload");
Object.defineProperty(exports, "FuzzPayload", { enumerable: true, get: function () { return FuzzPayload_1.FuzzPayload; } });
var Seed_1 = require("./domain/value-objects/Seed");
Object.defineProperty(exports, "Seed", { enumerable: true, get: function () { return Seed_1.Seed; } });
// Application
var RunFuzzCommand_1 = require("./application/commands/RunFuzzCommand");
Object.defineProperty(exports, "RunFuzzCommand", { enumerable: true, get: function () { return RunFuzzCommand_1.RunFuzzCommand; } });
var OnActionExecuted_1 = require("./application/event-handlers/OnActionExecuted");
Object.defineProperty(exports, "OnActionExecuted", { enumerable: true, get: function () { return OnActionExecuted_1.OnActionExecuted; } });
// Infrastructure
var FuzzingEngineAdapter_1 = require("./infrastructure/adapters/FuzzingEngineAdapter");
Object.defineProperty(exports, "FuzzingEngineAdapter", { enumerable: true, get: function () { return FuzzingEngineAdapter_1.FuzzingEngineAdapter; } });
var FuzzingController_1 = require("./infrastructure/http/FuzzingController");
Object.defineProperty(exports, "createFuzzingRouter", { enumerable: true, get: function () { return FuzzingController_1.createFuzzingRouter; } });
@@ -0,0 +1,127 @@
"use strict";
/**
* FuzzingEngineAdapter implements IFuzzerEngine port using the 5 fuzzing strategies.
* Adapts the legacy FuzzingEngine logic to the hexagonal architecture.
*/
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.FuzzingEngineAdapter = void 0;
const crypto = __importStar(require("crypto"));
const InputTypeDetector_1 = require("./InputTypeDetector");
const EmptyValueStrategy_1 = require("../strategies/EmptyValueStrategy");
const OversizedStringStrategy_1 = require("../strategies/OversizedStringStrategy");
const SpecialCharsStrategy_1 = require("../strategies/SpecialCharsStrategy");
const TypeMismatchStrategy_1 = require("../strategies/TypeMismatchStrategy");
const BoundaryValueStrategy_1 = require("../strategies/BoundaryValueStrategy");
const INPUT_RE = /<(input|textarea|select)[^>]*>/gi;
const ATTR_RE = (name) => new RegExp(`${name}="([^"]*)"`, 'i');
function extractFields(domSnapshot) {
const fields = [];
let match;
while ((match = INPUT_RE.exec(domSnapshot)) !== null) {
const tag = match[0] ?? '';
const tagName = match[1] ?? 'input';
const idMatch = ATTR_RE('id').exec(tag);
const nameMatch = ATTR_RE('name').exec(tag);
const typeMatch = ATTR_RE('type').exec(tag);
const placeholderMatch = ATTR_RE('placeholder').exec(tag);
const ariaMatch = ATTR_RE('aria-label').exec(tag);
const selector = idMatch?.[1]
? `#${idMatch[1]}`
: nameMatch?.[1]
? `[name="${nameMatch[1]}"]`
: tagName;
fields.push({
selector,
tagName,
inputType: typeMatch?.[1],
name: nameMatch?.[1],
placeholder: placeholderMatch?.[1],
ariaLabel: ariaMatch?.[1],
});
}
return fields;
}
class FuzzingEngineAdapter {
constructor(config) {
this.intensity = config.intensity;
this.seed = config.seed;
}
generateFuzzActions(domSnapshot, state) {
const fields = extractFields(domSnapshot);
const actions = [];
const now = Date.now();
const strategies = this.selectStrategies();
for (const field of fields) {
const detectedType = (0, InputTypeDetector_1.detectInputType)({
tagName: field.tagName,
inputType: field.inputType,
name: field.name,
placeholder: field.placeholder,
ariaLabel: field.ariaLabel,
});
for (const strategy of strategies) {
if (!strategy.appliesTo(detectedType))
continue;
const values = strategy.values(detectedType);
for (const value of values) {
actions.push({
id: crypto.randomUUID(),
type: 'fill',
selector: field.selector,
value,
timestamp: now,
seed: this.seed,
stateId: state.id,
});
}
}
}
return actions;
}
selectStrategies() {
const empty = new EmptyValueStrategy_1.EmptyValueStrategy();
const typeMismatch = new TypeMismatchStrategy_1.TypeMismatchStrategy();
const oversized = new OversizedStringStrategy_1.OversizedStringStrategy(this.intensity);
const boundary = new BoundaryValueStrategy_1.BoundaryValueStrategy();
const special = new SpecialCharsStrategy_1.SpecialCharsStrategy();
switch (this.intensity) {
case 'low': return [empty, typeMismatch];
case 'medium': return [empty, typeMismatch, oversized, boundary];
case 'high': return [empty, typeMismatch, oversized, boundary, special];
}
}
}
exports.FuzzingEngineAdapter = FuzzingEngineAdapter;
@@ -0,0 +1,47 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectInputType = detectInputType;
function detectInputType(attrs) {
const tag = (attrs.tagName ?? '').toLowerCase();
if (tag === 'textarea')
return 'textarea';
if (tag === 'select')
return 'select';
const inputType = (attrs.inputType ?? '').toLowerCase();
if (inputType === 'email')
return 'email';
if (inputType === 'password')
return 'password';
if (inputType === 'number')
return 'number';
if (inputType === 'date')
return 'date';
if (inputType === 'tel')
return 'phone';
if (inputType === 'url')
return 'url';
if (inputType === 'search')
return 'search';
if (inputType === 'file')
return 'file';
const hints = [
(attrs.name ?? '').toLowerCase(),
(attrs.placeholder ?? '').toLowerCase(),
(attrs.ariaLabel ?? '').toLowerCase(),
].join(' ');
if (/email/.test(hints))
return 'email';
if (/password|pass/.test(hints))
return 'password';
if (/phone|tel|mobile/.test(hints))
return 'phone';
if (/date|birth|dob/.test(hints))
return 'date';
if (/number|qty|quantity|age/.test(hints))
return 'number';
if (/search/.test(hints))
return 'search';
if (/url|website|link/.test(hints))
return 'url';
return 'text';
}
@@ -0,0 +1,50 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createFuzzingRouter = createFuzzingRouter;
const express_1 = require("express");
function createFuzzingRouter(deps) {
const router = (0, express_1.Router)();
// POST /api/fuzz/run — trigger fuzzing for a given state
router.post('/run', async (req, res) => {
const { crawlSessionId, intensity, seed, state } = req.body;
if (!crawlSessionId || !state) {
res.status(400).json({ error: 'crawlSessionId and state are required' });
return;
}
const result = await deps.runFuzz.execute({
crawlSessionId,
intensity: intensity ?? 'low',
seed: seed ?? Date.now(),
state,
});
if (!result.ok) {
res.status(422).json({ error: result.error });
return;
}
res.status(201).json(result.value);
});
// GET /api/fuzz/sessions/:id — get fuzz session
router.get('/sessions/:id', async (req, res) => {
const sessionId = req.params['id'];
const session = await deps.repository.findById(sessionId);
if (!session) {
res.status(404).json({ error: 'Fuzz session not found' });
return;
}
res.json(toDTO(session));
});
return router;
}
function toDTO(s) {
return {
id: s.id.toString(),
crawlSessionId: s.crawlSessionId,
intensity: s.intensity.value,
seed: s.seed.value,
status: s.status,
actionsExecuted: s.actionsExecuted,
vulnerabilitiesFound: s.vulnerabilitiesFound,
startedAt: s.startedAt.toISOString(),
completedAt: s.completedAt?.toISOString() ?? null,
};
}
@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InMemoryFuzzSessionRepository = void 0;
/**
* InMemoryFuzzSessionRepository temporary in-memory store used until Phase 8 adds SQLite persistence.
*/
class InMemoryFuzzSessionRepository {
constructor() {
this.store = new Map();
}
async save(session) {
this.store.set(session.id.toString(), session);
}
async findById(id) {
return this.store.get(id) ?? null;
}
async update(session) {
this.store.set(session.id.toString(), session);
}
}
exports.InMemoryFuzzSessionRepository = InMemoryFuzzSessionRepository;
@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BoundaryValueStrategy = void 0;
class BoundaryValueStrategy {
constructor() {
this.name = 'BoundaryValueStrategy';
}
appliesTo(type) {
return type === 'number' || type === 'date';
}
values(type) {
switch (type) {
case 'number': return ['0', '-1', '2147483647', '2147483648', '-2147483648'];
case 'date': return ['1900-01-01', '2099-12-31', '1970-01-01'];
default: return [];
}
}
}
exports.BoundaryValueStrategy = BoundaryValueStrategy;
@@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EmptyValueStrategy = void 0;
class EmptyValueStrategy {
constructor() {
this.name = 'EmptyValueStrategy';
}
appliesTo(_type) {
return true;
}
values() {
return ['', ' ', '\t'];
}
}
exports.EmptyValueStrategy = EmptyValueStrategy;
@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.OversizedStringStrategy = void 0;
const APPLICABLE_TYPES = ['text', 'email', 'password', 'textarea'];
class OversizedStringStrategy {
constructor(intensity) {
this.intensity = intensity;
this.name = 'OversizedStringStrategy';
}
appliesTo(type) {
return APPLICABLE_TYPES.includes(type);
}
values() {
switch (this.intensity) {
case 'low': return ['A'.repeat(256)];
case 'medium': return ['A'.repeat(1024)];
case 'high': return ['A'.repeat(10000) + '日本語テスト𠮷野家'];
}
}
}
exports.OversizedStringStrategy = OversizedStringStrategy;
@@ -0,0 +1,22 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SpecialCharsStrategy = void 0;
const APPLICABLE_TYPES = ['text', 'email', 'search', 'textarea'];
class SpecialCharsStrategy {
constructor() {
this.name = 'SpecialCharsStrategy';
}
appliesTo(type) {
return APPLICABLE_TYPES.includes(type);
}
values() {
return [
"' OR 1=1 --",
'<script>alert(1)</script>',
'../../etc/passwd',
'${7*7}',
'\x00\x01\x02',
];
}
}
exports.SpecialCharsStrategy = SpecialCharsStrategy;
@@ -0,0 +1,22 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TypeMismatchStrategy = void 0;
class TypeMismatchStrategy {
constructor() {
this.name = 'TypeMismatchStrategy';
}
appliesTo(type) {
return ['email', 'number', 'date', 'url', 'phone'].includes(type);
}
values(type) {
switch (type) {
case 'email': return ['not-an-email', '12345', '@@@'];
case 'number': return ['abc', '-999999', '9.9.9', 'NaN'];
case 'date': return ['yesterday', '32/13/2025', '0000-00-00'];
case 'url': return ['javascript:alert(1)', 'not a url'];
case 'phone': return ['000', '++++', 'abcdefghij'];
default: return [];
}
}
}
exports.TypeMismatchStrategy = TypeMismatchStrategy;
+167
View File
@@ -0,0 +1,167 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const crypto_1 = require("crypto");
const Integration_1 = require("../domain/entities/Integration");
const IntegrationType_1 = require("../domain/value-objects/IntegrationType");
const WebhookEndpoint_1 = require("../domain/entities/WebhookEndpoint");
const WebhookSecret_1 = require("../domain/value-objects/WebhookSecret");
const WebhookDispatcher_1 = require("../infrastructure/webhooks/WebhookDispatcher");
const pino_1 = require("pino");
// ─── Integration Entity ───────────────────────────────────────────────────────
(0, vitest_1.describe)('Integration', () => {
(0, vitest_1.it)('creates with defaults', () => {
const integration = Integration_1.Integration.create({
name: 'My Slack',
type: IntegrationType_1.IntegrationType.slack(),
config: { webhookUrl: 'https://hooks.slack.com/test' },
});
(0, vitest_1.expect)(integration.name).toBe('My Slack');
(0, vitest_1.expect)(integration.type.value).toBe('slack');
(0, vitest_1.expect)(integration.enabled).toBe(true);
(0, vitest_1.expect)(integration.config.webhookUrl).toBe('https://hooks.slack.com/test');
});
(0, vitest_1.it)('enable and disable', () => {
const integration = Integration_1.Integration.create({
name: 'Test',
type: IntegrationType_1.IntegrationType.github(),
config: {},
});
integration.disable();
(0, vitest_1.expect)(integration.enabled).toBe(false);
integration.enable();
(0, vitest_1.expect)(integration.enabled).toBe(true);
});
(0, vitest_1.it)('updateConfig merges config', () => {
const integration = Integration_1.Integration.create({
name: 'Jira',
type: IntegrationType_1.IntegrationType.jira(),
config: { host: 'https://old.atlassian.net' },
});
integration.updateConfig({ host: 'https://new.atlassian.net', token: 'tok' });
(0, vitest_1.expect)(integration.config.host).toBe('https://new.atlassian.net');
(0, vitest_1.expect)(integration.config.token).toBe('tok');
});
});
// ─── IntegrationType ──────────────────────────────────────────────────────────
(0, vitest_1.describe)('IntegrationType', () => {
(0, vitest_1.it)('parses all valid types', () => {
(0, vitest_1.expect)(IntegrationType_1.IntegrationType.fromString('slack').value).toBe('slack');
(0, vitest_1.expect)(IntegrationType_1.IntegrationType.fromString('github').value).toBe('github');
(0, vitest_1.expect)(IntegrationType_1.IntegrationType.fromString('jira').value).toBe('jira');
(0, vitest_1.expect)(IntegrationType_1.IntegrationType.fromString('webhook').value).toBe('webhook');
});
(0, vitest_1.it)('throws on invalid type', () => {
(0, vitest_1.expect)(() => IntegrationType_1.IntegrationType.fromString('unknown')).toThrow();
});
});
// ─── WebhookEndpoint ──────────────────────────────────────────────────────────
(0, vitest_1.describe)('WebhookEndpoint', () => {
(0, vitest_1.it)('creates with auto-generated secret', () => {
const endpoint = WebhookEndpoint_1.WebhookEndpoint.create({ url: 'https://example.com/hook' });
(0, vitest_1.expect)(endpoint.url).toBe('https://example.com/hook');
(0, vitest_1.expect)(endpoint.enabled).toBe(true);
(0, vitest_1.expect)(endpoint.secret.value).toBeTruthy();
(0, vitest_1.expect)(endpoint.secret.value.length).toBeGreaterThan(20);
});
(0, vitest_1.it)('records delivery', () => {
const endpoint = WebhookEndpoint_1.WebhookEndpoint.create({ url: 'https://example.com/hook' });
(0, vitest_1.expect)(endpoint.lastStatus).toBeUndefined();
endpoint.recordDelivery(200);
(0, vitest_1.expect)(endpoint.lastStatus).toBe(200);
(0, vitest_1.expect)(endpoint.lastDeliveredAt).toBeDefined();
});
});
// ─── WebhookSecret ────────────────────────────────────────────────────────────
(0, vitest_1.describe)('WebhookSecret', () => {
(0, vitest_1.it)('generates a secret', () => {
const s = WebhookSecret_1.WebhookSecret.generate();
(0, vitest_1.expect)(s.value.length).toBeGreaterThan(20);
});
(0, vitest_1.it)('fromString round-trips', () => {
const s = WebhookSecret_1.WebhookSecret.fromString('mysecret-at-least-16chars');
(0, vitest_1.expect)(s.value).toBe('mysecret-at-least-16chars');
});
(0, vitest_1.it)('throws when secret too short', () => {
(0, vitest_1.expect)(() => WebhookSecret_1.WebhookSecret.fromString('short')).toThrow();
});
});
// ─── HMAC signature verification ─────────────────────────────────────────────
(0, vitest_1.describe)('HMAC webhook signature', () => {
(0, vitest_1.it)('produces valid sha256 signature', () => {
const secret = 'test-secret-abc123';
const body = JSON.stringify({ event: 'finding.created', data: { id: '1' } });
const sig = (0, crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
(0, vitest_1.expect)(sig).toBeTruthy();
// Verify it's a valid hex string of 64 chars (sha256)
(0, vitest_1.expect)(sig).toMatch(/^[0-9a-f]{64}$/);
});
(0, vitest_1.it)('same body + secret → same signature', () => {
const secret = 'test-secret';
const body = 'hello world';
const sig1 = (0, crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
const sig2 = (0, crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
(0, vitest_1.expect)(sig1).toBe(sig2);
});
(0, vitest_1.it)('different body → different signature', () => {
const secret = 'test-secret';
const sig1 = (0, crypto_1.createHmac)('sha256', secret).update('body1').digest('hex');
const sig2 = (0, crypto_1.createHmac)('sha256', secret).update('body2').digest('hex');
(0, vitest_1.expect)(sig1).not.toBe(sig2);
});
});
// ─── WebhookDispatcher ───────────────────────────────────────────────────────
(0, vitest_1.describe)('WebhookDispatcher', () => {
const logger = (0, pino_1.pino)({ level: 'silent' });
(0, vitest_1.it)('calls fetch for each enabled endpoint', async () => {
const secret = WebhookSecret_1.WebhookSecret.fromString('secret123456789abcdef');
const endpoint = WebhookEndpoint_1.WebhookEndpoint.reconstitute({ url: 'https://example.com/hook', secret, enabled: true, createdAt: new Date() }, { toString: () => 'ep-1', equals: () => false });
const mockRepo = {
save: vitest_1.vi.fn(),
findById: vitest_1.vi.fn(),
findAll: vitest_1.vi.fn(),
findEnabled: vitest_1.vi.fn().mockResolvedValue([endpoint]),
update: vitest_1.vi.fn(),
delete: vitest_1.vi.fn(),
};
const fetchMock = vitest_1.vi.fn().mockResolvedValue({ status: 200, ok: true });
global.fetch = fetchMock;
const dispatcher = new WebhookDispatcher_1.WebhookDispatcher(mockRepo, logger);
const finding = {
id: 'f-1',
title: 'XSS in login form',
severity: 'high',
type: 'xss',
description: 'Reflected XSS',
sessionId: 's-1',
};
await dispatcher.dispatchFinding(finding);
(0, vitest_1.expect)(fetchMock).toHaveBeenCalledOnce();
const [url, opts] = fetchMock.mock.calls[0];
(0, vitest_1.expect)(url).toBe('https://example.com/hook');
(0, vitest_1.expect)(opts.method).toBe('POST');
const headers = opts.headers;
(0, vitest_1.expect)(headers['X-ABE-Event']).toBe('finding.created');
(0, vitest_1.expect)(headers['X-ABE-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/);
});
(0, vitest_1.it)('does not throw when no endpoints', async () => {
const mockRepo = {
save: vitest_1.vi.fn(),
findById: vitest_1.vi.fn(),
findAll: vitest_1.vi.fn(),
findEnabled: vitest_1.vi.fn().mockResolvedValue([]),
update: vitest_1.vi.fn(),
delete: vitest_1.vi.fn(),
};
const dispatcher = new WebhookDispatcher_1.WebhookDispatcher(mockRepo, logger);
const finding = {
id: 'f-1',
title: 'Test',
severity: 'low',
type: 'info',
description: 'Test',
sessionId: 's-1',
};
await (0, vitest_1.expect)(dispatcher.dispatchFinding(finding)).resolves.toBeUndefined();
});
});
@@ -0,0 +1,61 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.OnFindingCreated = void 0;
const SlackProvider_1 = require("../../infrastructure/providers/SlackProvider");
const GitHubIssuesProvider_1 = require("../../infrastructure/providers/GitHubIssuesProvider");
const JiraProvider_1 = require("../../infrastructure/providers/JiraProvider");
class OnFindingCreated {
constructor(integrationRepo, webhookRepo, dispatcher, logger) {
this.integrationRepo = integrationRepo;
this.webhookRepo = webhookRepo;
this.dispatcher = dispatcher;
this.logger = logger;
}
async handle(event) {
const payload = event.payload;
const finding = {
id: payload.findingId,
title: `${payload.type} finding`,
severity: payload.severity,
type: payload.type,
description: payload.description,
sessionId: payload.sessionId,
};
// Dispatch to custom webhooks
await this.dispatcher.dispatchFinding(finding);
// Dispatch to named integrations (Slack, GitHub, Jira)
const integrations = await this.integrationRepo.findEnabled();
for (const integration of integrations) {
try {
const minSev = integration.config.minSeverity ?? 'low';
if (!severityMeetsThreshold(payload.severity, minSev))
continue;
const type = integration.type.value;
if (type === 'slack' && integration.config.webhookUrl) {
const provider = new SlackProvider_1.SlackProvider(integration.config.webhookUrl);
await provider.sendFinding(finding);
}
else if (type === 'github' && integration.config.token && integration.config.repo) {
const provider = new GitHubIssuesProvider_1.GitHubIssuesProvider(integration.config.token, integration.config.repo);
await provider.sendFinding(finding);
}
else if (type === 'jira' &&
integration.config.host &&
integration.config.token &&
integration.config.username &&
integration.config.projectKey) {
const provider = new JiraProvider_1.JiraProvider(integration.config.host, integration.config.token, integration.config.username, integration.config.projectKey);
await provider.sendFinding(finding);
}
}
catch (err) {
this.logger.warn({ integrationId: integration.id.toString(), err }, 'Integration dispatch failed');
}
}
}
}
exports.OnFindingCreated = OnFindingCreated;
const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
function severityMeetsThreshold(severity, min) {
return SEVERITY_ORDER.indexOf(severity) >= SEVERITY_ORDER.indexOf(min);
}
@@ -0,0 +1,22 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Integration = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class Integration extends Entity_1.Entity {
static create(props, id) {
return new Integration({ ...props, enabled: true, createdAt: new Date() }, id ?? UniqueId_1.UniqueId.create());
}
static reconstitute(props, id) {
return new Integration(props, id);
}
get name() { return this.props.name; }
get type() { return this.props.type; }
get enabled() { return this.props.enabled; }
get config() { return this.props.config; }
get createdAt() { return this.props.createdAt; }
enable() { this.props.enabled = true; }
disable() { this.props.enabled = false; }
updateConfig(config) { this.props.config = config; }
}
exports.Integration = Integration;
@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookEndpoint = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const WebhookSecret_1 = require("../value-objects/WebhookSecret");
class WebhookEndpoint extends Entity_1.Entity {
static create(props, id) {
return new WebhookEndpoint({ ...props, secret: WebhookSecret_1.WebhookSecret.generate(), enabled: true, createdAt: new Date() }, id ?? UniqueId_1.UniqueId.create());
}
static reconstitute(props, id) {
return new WebhookEndpoint(props, id);
}
get url() { return this.props.url; }
get secret() { return this.props.secret; }
get enabled() { return this.props.enabled; }
get createdAt() { return this.props.createdAt; }
get lastDeliveredAt() { return this.props.lastDeliveredAt; }
get lastStatus() { return this.props.lastStatus; }
recordDelivery(statusCode) {
this.props.lastDeliveredAt = new Date();
this.props.lastStatus = statusCode;
}
enable() { this.props.enabled = true; }
disable() { this.props.enabled = false; }
}
exports.WebhookEndpoint = WebhookEndpoint;
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

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