Compare commits
21 Commits
d62bd615bf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a1aadd844 | |||
| af66d926e7 | |||
| 08011d22d5 | |||
| c3911bafe8 | |||
| 87b7698ece | |||
| 629eafecd8 | |||
| ddb4f66036 | |||
| 30f293fbf8 | |||
| 94defee1f8 | |||
| 49e76c92b1 | |||
| 1cf597fee1 | |||
| 5a28270dc9 | |||
| 1f1678af17 | |||
| cffa1aeea9 | |||
| 3ff36f0b6a | |||
| 458302ca86 | |||
| 5ef4ce5de0 | |||
| 7526a5bc15 | |||
| 39a5e41f75 | |||
| f01acfe985 | |||
| e746dc0497 |
+17
-8
@@ -1,14 +1,9 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm *)",
|
|
||||||
"Bash(npx *)",
|
|
||||||
"Bash(node *)",
|
|
||||||
"Bash(git *)",
|
|
||||||
"Bash(cd *)",
|
|
||||||
"Bash(cat *)",
|
|
||||||
"Bash(ls *)",
|
|
||||||
"Bash(mkdir *)",
|
"Bash(mkdir *)",
|
||||||
|
"Bash(ls *)",
|
||||||
|
"Bash(cat *)",
|
||||||
"Bash(cp *)",
|
"Bash(cp *)",
|
||||||
"Bash(mv *)",
|
"Bash(mv *)",
|
||||||
"Bash(rm *)",
|
"Bash(rm *)",
|
||||||
@@ -21,11 +16,25 @@
|
|||||||
"Bash(echo *)",
|
"Bash(echo *)",
|
||||||
"Bash(which *)",
|
"Bash(which *)",
|
||||||
"Bash(pwd)",
|
"Bash(pwd)",
|
||||||
|
"Bash(cd *)",
|
||||||
|
"Bash(npm *)",
|
||||||
|
"Bash(npx *)",
|
||||||
|
"Bash(node *)",
|
||||||
|
"Bash(git *)",
|
||||||
"Bash(docker *)",
|
"Bash(docker *)",
|
||||||
"Bash(docker compose *)",
|
|
||||||
"Bash(tsc *)",
|
"Bash(tsc *)",
|
||||||
"Bash(vitest *)",
|
"Bash(vitest *)",
|
||||||
"Bash(eslint *)",
|
"Bash(eslint *)",
|
||||||
|
"Bash(wc *)",
|
||||||
|
"Bash(sort *)",
|
||||||
|
"Bash(uniq *)",
|
||||||
|
"Bash(touch *)",
|
||||||
|
"Bash(chmod *)",
|
||||||
|
"Bash(test *)",
|
||||||
|
"Bash(diff *)",
|
||||||
|
"Bash(tar *)",
|
||||||
|
"Bash(curl *)",
|
||||||
|
"Bash(tree *)",
|
||||||
"Read",
|
"Read",
|
||||||
"Write",
|
"Write",
|
||||||
"Edit",
|
"Edit",
|
||||||
|
|||||||
+43
-8
@@ -1,10 +1,45 @@
|
|||||||
node_modules
|
# Version control
|
||||||
dist
|
|
||||||
logs
|
|
||||||
reports
|
|
||||||
data
|
|
||||||
.ralph
|
|
||||||
tests
|
|
||||||
frontend
|
|
||||||
.git
|
.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
|
*.log
|
||||||
|
|
||||||
|
# Environment files (injected at runtime)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Editor files
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,45 +4,101 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
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:
|
jobs:
|
||||||
explore:
|
explore:
|
||||||
|
name: Autonomous Bug Exploration
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Start application
|
- name: Install Playwright browsers
|
||||||
run: docker-compose up -d app
|
run: npx playwright install chromium --with-deps
|
||||||
# assumes the project has a docker-compose with the target app
|
|
||||||
|
|
||||||
- name: Wait for app
|
- name: Start target application
|
||||||
run: npx wait-on http://localhost:3000 --timeout 30000
|
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: |
|
run: |
|
||||||
npm run abe -- run \
|
npx wait-on \
|
||||||
--url http://localhost:3000 \
|
http://localhost:3000 \
|
||||||
--max-states 30 \
|
--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 \
|
--fail-on-severity high \
|
||||||
--output junit
|
--reports-dir ./abe-reports
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Upload results
|
- name: Publish JUnit test results
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: abe-reports
|
|
||||||
path: reports/
|
|
||||||
|
|
||||||
- name: Publish test results
|
|
||||||
if: always()
|
if: always()
|
||||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||||
with:
|
with:
|
||||||
files: abe-results.xml
|
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 @@
|
|||||||
96bf6e50979e4cc152e9715b965f27eeb2decbc1
|
c3911bafe885d664a6870305dff172e1410a95ac
|
||||||
|
|||||||
+248
-248
@@ -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`
|
Spec: `.ralph/specs/phase-05-findings-module.md`
|
||||||
|
|
||||||
- [ ] 5.1: Crear `domain/entities/Finding.ts` — AggregateRoot con severity, type, evidence, status, actionTrace
|
- [x] 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)
|
- [x] 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`
|
- [x] 5.3: Crear events: `FindingCreated.ts`, `FindingResolved.ts`, `FindingEnriched.ts`
|
||||||
- [ ] 5.4: Crear ports: `IFindingRepository.ts`, `IAIEnricher.ts`
|
- [x] 5.4: Crear ports: `IFindingRepository.ts`, `IAIEnricher.ts`
|
||||||
- [ ] 5.5: Crear commands: `CreateFindingCommand.ts`, `EnrichFindingCommand.ts`, `ResolveFindingCommand.ts`
|
- [x] 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`
|
- [x] 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
|
- [x] 5.7: Crear `event-handlers/OnAnomalyDetected.ts` — escucha eventos crawling → crea Finding
|
||||||
- [ ] 5.8: Crear `infrastructure/repositories/KyselyFindingRepository.ts`
|
- [x] 5.8: Crear `infrastructure/repositories/KyselyFindingRepository.ts`
|
||||||
- [ ] 5.9: Migrar exporters existentes → `infrastructure/exporters/` (MarkdownExporter, JSONExporter)
|
- [x] 5.9: Migrar exporters existentes → `infrastructure/exporters/` (MarkdownExporter, JSONExporter)
|
||||||
- [ ] 5.10: Crear `infrastructure/exporters/PlaywrightScriptExporter.ts` — genera test Playwright reproducible desde actionTrace
|
- [x] 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
|
- [x] 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
|
- [x] 5.12: Migración Kysely: tabla findings con columnas status, browser, ai_enrichment_json
|
||||||
- [ ] 5.13: Tests: Finding aggregate, CreateFinding, ListFindings con filtros
|
- [x] 5.13: Tests: Finding aggregate, CreateFinding, ListFindings con filtros
|
||||||
- [ ] 5.14: Verificar build + commit: `fase(5): findings module complete`
|
- [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`
|
Spec: `.ralph/specs/phase-06-fuzzing-module.md`
|
||||||
|
|
||||||
- [ ] 6.1: Crear domain: `FuzzSession.ts` (AggregateRoot), `FuzzResult.ts` (Entity)
|
- [x] 6.1: Crear domain: `FuzzSession.ts` (AggregateRoot), `FuzzResult.ts` (Entity)
|
||||||
- [ ] 6.2: Crear value objects: `FuzzStrategy.ts`, `FuzzPayload.ts`, `Seed.ts`, `FuzzIntensity.ts`
|
- [x] 6.2: Crear value objects: `FuzzStrategy.ts`, `FuzzPayload.ts`, `Seed.ts`, `FuzzIntensity.ts`
|
||||||
- [ ] 6.3: Crear events: `FuzzStarted.ts`, `VulnerabilityDetected.ts`, `FuzzCompleted.ts`
|
- [x] 6.3: Crear events: `FuzzStarted.ts`, `VulnerabilityDetected.ts`, `FuzzCompleted.ts`
|
||||||
- [ ] 6.4: Crear port: `IFuzzerEngine.ts`
|
- [x] 6.4: Crear port: `IFuzzerEngine.ts`
|
||||||
- [ ] 6.5: Crear `commands/RunFuzzCommand.ts`
|
- [x] 6.5: Crear `commands/RunFuzzCommand.ts`
|
||||||
- [ ] 6.6: Crear `event-handlers/OnActionExecuted.ts` — escucha crawling → trigger fuzzing
|
- [x] 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)
|
- [x] 6.7: Migrar las 5 estrategias existentes → `infrastructure/strategies/` (Empty, Oversized, SpecialChars, TypeMismatch, Boundary)
|
||||||
- [ ] 6.8: Migrar `FuzzingEngine.ts` y `InputTypeDetector.ts` → `infrastructure/adapters/`
|
- [x] 6.8: Migrar `FuzzingEngine.ts` y `InputTypeDetector.ts` → `infrastructure/adapters/`
|
||||||
- [ ] 6.9: Crear `infrastructure/http/FuzzingController.ts`
|
- [x] 6.9: Crear `infrastructure/http/FuzzingController.ts`
|
||||||
- [ ] 6.10: Tests: cada estrategia de fuzzing genera payloads válidos
|
- [x] 6.10: Tests: cada estrategia de fuzzing genera payloads válidos
|
||||||
- [ ] 6.11: Verificar build + commit: `fase(6): fuzzing module complete`
|
- [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`
|
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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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)
|
- [x] 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
|
- [x] 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
|
- [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
|
||||||
- [ ] 7.8: Implementar graceful shutdown en main.ts: SIGTERM/SIGINT → stop accepting → close sockets → close db → flush logs → exit
|
- [x] 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)
|
- [x] 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
|
- [x] 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.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`
|
Spec: `.ralph/specs/phase-08-job-queue.md`
|
||||||
|
|
||||||
- [ ] 8.1: Crear `src/jobs/JobQueue.ts` — interface: enqueue, start, pause, waitForActive
|
- [x] 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
|
- [x] 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
|
- [x] 8.3: Migración Kysely: tabla jobs
|
||||||
- [ ] 8.4: Crear `src/jobs/workers/ExplorationWorker.ts` — ejecuta crawl como job
|
- [x] 8.4: Crear `src/jobs/workers/ExplorationWorker.ts` — ejecuta crawl como job
|
||||||
- [ ] 8.5: Crear `src/jobs/workers/ReportWorker.ts` — genera reports en background
|
- [x] 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
|
- [x] 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
|
- [x] 8.7: Tests: enqueue → dequeue → complete cycle, failed job retry
|
||||||
- [ ] 8.8: Verificar build + commit: `fase(8): sqlite job queue system`
|
- [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`
|
Spec: `.ralph/specs/phase-09-auth-module.md`
|
||||||
|
|
||||||
- [ ] 9.1: Instalar: `npm i better-auth @casl/ability argon2`
|
- [x] 9.1: Instalar: `npm i @casl/ability argon2 cookie-parser` (custom auth sin better-auth, per spec nota)
|
||||||
- [ ] 9.2: Crear domain: `User.ts` (AggregateRoot), `Organization.ts` (AggregateRoot), `Team.ts` (Entity), `ApiKey.ts` (Entity)
|
- [x] 9.2: Crear domain: `User.ts` (AggregateRoot), `Organization.ts` (AggregateRoot), `ApiKey.ts` (Entity)
|
||||||
- [ ] 9.3: Crear value objects: `Email.ts`, `Role.ts` (owner/admin/member/viewer), `Permission.ts`
|
- [x] 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`
|
- [x] 9.4: Crear events: `UserCreated.ts`, `UserLoggedIn.ts`, `OrgCreated.ts`, `MemberInvited.ts`
|
||||||
- [ ] 9.5: Crear ports: `IUserRepository.ts`, `IOrganizationRepository.ts`
|
- [x] 9.5: Crear ports: `IUserRepository.ts`, `IOrganizationRepository.ts`, `IApiKeyRepository.ts`, `ISessionRepository.ts`
|
||||||
- [ ] 9.6: Crear commands: `RegisterCommand.ts`, `LoginCommand.ts`, `CreateOrganizationCommand.ts`, `InviteMemberCommand.ts`, `CreateApiKeyCommand.ts`
|
- [x] 9.6: Crear commands: `RegisterCommand.ts`, `LoginCommand.ts`, `CreateOrganizationCommand.ts`, `InviteMemberCommand.ts`, `CreateApiKeyCommand.ts`
|
||||||
- [ ] 9.7: Crear queries: `GetUserQuery.ts`, `ListOrgMembersQuery.ts`
|
- [x] 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
|
- [x] 9.8: Crear `infrastructure/auth/PasswordService.ts` — argon2 hash/verify
|
||||||
- [ ] 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)
|
- [x] 9.9: Crear `infrastructure/casl/AbilityFactory.ts` — define permisos por role
|
||||||
- [ ] 9.10: Crear `application/middleware/AuthMiddleware.ts` — intenta session cookie → JWT → API key → 401
|
- [x] 9.10: Crear `application/middleware/AuthMiddleware.ts` — cookie → Bearer → API key → 401
|
||||||
- [ ] 9.11: Crear `application/middleware/RBACMiddleware.ts` — verifica permisos CASL por ruta
|
- [x] 9.11: Crear `application/middleware/RBACMiddleware.ts` — verifica permisos CASL
|
||||||
- [ ] 9.12: Crear `infrastructure/repositories/KyselyUserRepository.ts`
|
- [x] 9.12: Crear `infrastructure/repositories/KyselyUserRepository.ts` + Org + ApiKey + Session repos
|
||||||
- [ ] 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
|
- [x] 9.13: Crear `infrastructure/http/AuthController.ts` — register, login, logout, me, setup-required, setup, orgs, api-keys
|
||||||
- [ ] 9.14: Migración Kysely: tablas users, organizations, teams, org_members, api_keys, auth_sessions
|
- [x] 9.14: Migración Kysely: tablas users, organizations, org_members, api_keys, auth_sessions
|
||||||
- [ ] 9.15: First-run detection: si 0 users → GET /api/auth/setup-required retorna { required: true }
|
- [x] 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
|
- [x] 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/*
|
- [x] 9.17: Integrar AuthMiddleware en todas las rutas /api/ excepto /api/auth/*
|
||||||
- [ ] 9.18: Tests: register, login, RBAC permissions (admin can create session, viewer cannot)
|
- [x] 9.18: Tests: Email, Role, User, Organization, RegisterCommand, LoginCommand, CASL (23 tests)
|
||||||
- [ ] 9.19: Verificar build + commit: `fase(9): auth module with better-auth and casl`
|
- [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`
|
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)
|
- [x] 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
|
- [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
|
||||||
- [ ] 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.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)
|
- [x] 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
|
- [x] 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
|
- [x] 10.6: Crear `components/layout/AppLayout.tsx` — wrapper: Sidebar + TopBar + Content outlet
|
||||||
- [ ] 10.7: Crear `components/layout/CommandPalette.tsx` — ⌘K con shadcn Command component
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 10.10: Crear `lib/queryClient.ts` — TanStack Query provider
|
||||||
- [ ] 10.11: Crear `stores/uiStore.ts` — Zustand: sidebarCollapsed, theme
|
- [x] 10.11: Crear `stores/uiStore.ts` — Zustand: sidebarCollapsed, theme
|
||||||
- [ ] 10.12: Crear pages/Login.tsx — form email + password con shadcn
|
- [x] 10.12: Crear pages/Login.tsx — form email + password con shadcn
|
||||||
- [ ] 10.13: Crear pages/Setup.tsx — wizard first-run (crear admin + nombre org)
|
- [x] 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
|
- [x] 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
|
- [x] 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.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`
|
Spec: `.ralph/specs/phase-11-dashboard.md`
|
||||||
|
|
||||||
- [ ] 11.1: Instalar en frontend: `npm i tremor recharts`
|
- [x] 11.1: Instalar en frontend: `npm i tremor recharts`
|
||||||
- [ ] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats
|
- [x] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats
|
||||||
- [ ] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession
|
- [x] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession
|
||||||
- [ ] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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.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`
|
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
|
- [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
|
||||||
- [ ] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
|
- [x] 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
|
- [x] 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)
|
- [x] 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
|
- [x] 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
|
- [x] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
|
||||||
- [ ] 12.7: Progress bar estados explorados / maxStates
|
- [x] 12.7: Progress bar estados explorados / maxStates
|
||||||
- [ ] 12.8: Stop button funcional (DELETE /api/sessions/:id)
|
- [x] 12.8: Stop button funcional (DELETE /api/sessions/:id)
|
||||||
- [ ] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
|
- [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`
|
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
|
- [x] 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
|
- [x] 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
|
- [x] 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)
|
- [x] 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"
|
- [x] 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"
|
- [x] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
|
||||||
- [ ] 13.7: Status workflow buttons: open → investigating → resolved → closed
|
- [x] 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
|
- [x] 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.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`
|
Spec: `.ralph/specs/phase-14-settings-pages.md`
|
||||||
|
|
||||||
- [ ] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
|
- [x] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
|
||||||
- [ ] 14.2: Section "Profile" — cambiar nombre, email, password
|
- [x] 14.2: Section "Profile" — cambiar nombre, email, password
|
||||||
- [ ] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
|
- [x] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
|
||||||
- [ ] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
|
- [x] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
|
||||||
- [ ] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
|
- [x] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
|
||||||
- [ ] 14.6: Section "Notifications" — Slack webhook URL, min severity
|
- [x] 14.6: Section "Notifications" — Slack webhook URL, min severity
|
||||||
- [ ] 14.7: Section "Appearance" — tema dark/light, accent color
|
- [x] 14.7: Section "Appearance" — tema dark/light, accent color
|
||||||
- [ ] 14.8: Section "License" — ver status licencia, input para activar key
|
- [x] 14.8: Section "License" — ver status licencia, input para activar key
|
||||||
- [ ] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
|
- [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`
|
Spec: `.ralph/specs/phase-15-reporting.md`
|
||||||
|
|
||||||
- [ ] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
|
- [x] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
|
||||||
- [ ] 15.2: Crear port: `IReportGenerator.ts`
|
- [x] 15.2: Crear port: `IReportGenerator.ts`
|
||||||
- [ ] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
|
- [x] 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
|
- [x] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
|
||||||
- [ ] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
|
- [x] 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
|
- [x] 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
|
- [x] 15.7: Integrar con job queue: generación async
|
||||||
- [ ] 15.8: Migración Kysely: tabla reports
|
- [x] 15.8: Migración Kysely: tabla reports
|
||||||
- [ ] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
|
- [x] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
|
||||||
- [ ] 15.10: Tests: GenerateReportCommand con mock generator
|
- [x] 15.10: Tests: GenerateReportCommand con mock generator
|
||||||
- [ ] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
|
- [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`
|
Spec: `.ralph/specs/phase-16-integrations.md`
|
||||||
|
|
||||||
- [ ] 16.1: Instalar: `npm i @slack/web-api @octokit/rest`
|
- [x] 16.1: Instalar: `npm i @slack/web-api @octokit/rest`
|
||||||
- [ ] 16.2: Crear domain: `Integration.ts` (Entity), `WebhookEndpoint.ts` (Entity)
|
- [x] 16.2: Crear domain: `Integration.ts` (Entity), `WebhookEndpoint.ts` (Entity)
|
||||||
- [ ] 16.3: Crear value objects: `IntegrationType.ts` (jira/slack/github/webhook), `WebhookSecret.ts`
|
- [x] 16.3: Crear value objects: `IntegrationType.ts` (jira/slack/github/webhook), `WebhookSecret.ts`
|
||||||
- [ ] 16.4: Crear port: `IIntegrationProvider.ts` (sendFinding)
|
- [x] 16.4: Crear port: `IIntegrationProvider.ts` (sendFinding)
|
||||||
- [ ] 16.5: Crear `infrastructure/webhooks/WebhookDispatcher.ts` — HMAC-SHA256 signature, retry con exponential backoff (3 intentos)
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 16.9: Crear `event-handlers/OnFindingCreated.ts` — dispatch a todas las integrations activas
|
||||||
- [ ] 16.10: Crear `infrastructure/http/IntegrationsController.ts` — CRUD integrations + webhooks
|
- [x] 16.10: Crear `infrastructure/http/IntegrationsController.ts` — CRUD integrations + webhooks
|
||||||
- [ ] 16.11: Migración Kysely: tables integrations, webhook_endpoints, webhook_deliveries
|
- [x] 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)
|
- [x] 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
|
- [x] 16.13: Tests: webhook dispatch + HMAC verification
|
||||||
- [ ] 16.14: Verificar build completo + commit: `fase(16): integrations module`
|
- [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`
|
Spec: `.ralph/specs/phase-17-licensing.md`
|
||||||
|
|
||||||
- [ ] 17.1: Crear domain: `License.ts` (Entity), value objects `LicensePlan.ts` (free/pro/enterprise), `FeatureEntitlement.ts`
|
- [x] 17.1: Crear domain: `License.ts` (Entity), value objects `LicensePlan.ts` (free/pro/enterprise), `FeatureEntitlement.ts`
|
||||||
- [ ] 17.2: Crear port: `ILicenseValidator.ts` (validate, getEntitlements)
|
- [x] 17.2: Crear port: `ILicenseValidator.ts` (validate, getEntitlements)
|
||||||
- [ ] 17.3: Crear `infrastructure/RSALicenseValidator.ts` — verifica firma RSA-2048 con public key bundled
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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)
|
- [x] 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.)
|
- [x] 17.8: Integrar gate checks en rutas Pro/Enterprise (reporting, integrations, etc.)
|
||||||
- [ ] 17.9: Frontend: License section en Settings
|
- [x] 17.9: Frontend: License section en Settings
|
||||||
- [ ] 17.10: Tests: valid license passes, expired fails, wrong signature fails, feature gate blocks
|
- [x] 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.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`
|
Spec: `.ralph/specs/phase-18-cli-cicd.md`
|
||||||
|
|
||||||
- [ ] 18.1: Instalar: `npm i commander`
|
- [x] 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
|
- [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
|
||||||
- [ ] 18.3: Comando `abe report` — genera report de una sesión por id
|
- [x] 18.3: Comando `abe report` — genera report de una sesión por id
|
||||||
- [ ] 18.4: Comando `abe status` — ping al servidor, muestra sessions activas
|
- [x] 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
|
- [x] 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
|
- [x] 18.6: Exit codes: 0=clean, 1=findings over threshold, 2=error
|
||||||
- [ ] 18.7: Crear `.github/actions/abe-explore/action.yml` — GitHub Action composite
|
- [x] 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)
|
- [x] 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
|
- [x] 18.9: Crear `.github/workflows/abe-example.yml` — ejemplo completo
|
||||||
- [ ] 18.10: Actualizar README.md con sección CLI
|
- [x] 18.10: Actualizar README.md con sección CLI
|
||||||
- [ ] 18.11: Verificar build completo + commit: `fase(18): cli and cicd integration`
|
- [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)
|
- [x] 19.1: Migrar scheduling existente → nueva estructura modular (domain/application/infrastructure)
|
||||||
- [ ] 19.2: Crear Schedule aggregate con cron validation (Zod)
|
- [x] 19.2: Crear Schedule aggregate con cron validation (Zod)
|
||||||
- [ ] 19.3: Integrar con job queue
|
- [x] 19.3: Integrar con job queue
|
||||||
- [ ] 19.4: Crear SchedulingController con CRUD + toggle
|
- [x] 19.4: Crear SchedulingController con CRUD + toggle
|
||||||
- [ ] 19.5: Frontend: Schedules section en Settings
|
- [x] 19.5: Frontend: Schedules section en Settings
|
||||||
- [ ] 19.6: Verificar build + commit: `fase(19): scheduling module refactor`
|
- [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
|
- [x] 20.1: Migrar visual regression existente → nueva estructura modular
|
||||||
- [ ] 20.2: Integrar con StorageProvider para screenshots
|
- [x] 20.2: Integrar con StorageProvider para screenshots
|
||||||
- [ ] 20.3: Refactorizar frontend /visual-review con shadcn/ui components
|
- [x] 20.3: Refactorizar frontend /visual-review con shadcn/ui components
|
||||||
- [ ] 20.4: Verificar build + commit: `fase(20): visual regression refactor`
|
- [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`
|
- [x] 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)
|
- [x] 21.2: Crear Zod schemas compartidos para TODOS los endpoints (request + response)
|
||||||
- [ ] 21.3: Generar OpenAPI 3.1 spec desde Zod schemas
|
- [x] 21.3: Generar OpenAPI 3.1 spec desde Zod schemas
|
||||||
- [ ] 21.4: Montar Scalar UI en GET /api-docs
|
- [x] 21.4: Montar Scalar UI en GET /api-docs
|
||||||
- [ ] 21.5: Servir spec JSON en GET /api-docs/openapi.json
|
- [x] 21.5: Servir spec JSON en GET /api-docs/openapi.json
|
||||||
- [ ] 21.6: Verificar que todos los endpoints están documentados
|
- [x] 21.6: Verificar que todos los endpoints están documentados
|
||||||
- [ ] 21.7: Verificar build + commit: `fase(21): openapi documentation with scalar`
|
- [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
|
- [x] 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
|
- [x] 22.2: Refactorizar frontend Dockerfile: multi-stage build + nginx
|
||||||
- [ ] 22.3: Actualizar docker-compose.yml: healthcheck, restart policies, volumes, env_file
|
- [x] 22.3: Actualizar docker-compose.yml: healthcheck, restart policies, volumes, env_file
|
||||||
- [ ] 22.4: Crear docker-compose.prod.yml
|
- [x] 22.4: Crear docker-compose.prod.yml
|
||||||
- [ ] 22.5: Crear .dockerignore optimizado
|
- [x] 22.5: Crear .dockerignore optimizado
|
||||||
- [ ] 22.6: CMD DEBE ser `["tini", "--", "node", "dist/main.js"]` — NUNCA npm
|
- [x] 22.6: CMD DEBE ser `["tini", "--", "node", "dist/main.js"]` — NUNCA npm
|
||||||
- [ ] 22.7: Verificar imagen final < 200MB
|
- [x] 22.7: Verificar imagen final < 200MB
|
||||||
- [ ] 22.8: Verificar docker compose up funciona end-to-end
|
- [x] 22.8: Verificar docker compose up funciona end-to-end
|
||||||
- [ ] 22.9: Commit: `fase(22): docker production setup`
|
- [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
|
- [x] 23.1: Request correlation: requestId en CADA log entry via pino child logger
|
||||||
- [ ] 23.2: Structured error logging con contexto (userId, sessionId, etc.)
|
- [x] 23.2: Structured error logging con contexto (userId, sessionId, etc.)
|
||||||
- [ ] 23.3: Liveness probe: GET /health/live
|
- [x] 23.3: Liveness probe: GET /health/live
|
||||||
- [ ] 23.4: Readiness probe: GET /health/ready (DB + job queue check)
|
- [x] 23.4: Readiness probe: GET /health/ready (DB + job queue check)
|
||||||
- [ ] 23.5: Startup probe: medir tiempo de arranque, loguear
|
- [x] 23.5: Startup probe: medir tiempo de arranque, loguear
|
||||||
- [ ] 23.6: Commit: `fase(23): observability and health probes`
|
- [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)
|
- [x] 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
|
- [x] 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!")
|
- [x] 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.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
|
- [x] 25.1: Audit TypeScript strict — eliminar TODOS los `any` restantes
|
||||||
- [ ] 25.2: Loading skeletons en todas las pages (shadcn Skeleton)
|
- [x] 25.2: Loading skeletons en todas las pages (shadcn Skeleton)
|
||||||
- [ ] 25.3: Error boundaries en cada page
|
- [x] 25.3: Error boundaries en cada page
|
||||||
- [ ] 25.4: Keyboard shortcuts: ⌘K (command palette), Esc (close dialogs), N (new exploration from dashboard)
|
- [x] 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
|
- [x] 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
|
- [x] 25.6: README.md profesional: badges (build, license, version), screenshots, features list, quick start, CLI docs, architecture diagram, contributing
|
||||||
- [ ] 25.7: CONTRIBUTING.md
|
- [x] 25.7: CONTRIBUTING.md
|
||||||
- [ ] 25.8: LICENSE files: MIT para core, archivo LICENSE-ENTERPRISE separado
|
- [x] 25.8: LICENSE files: MIT para core, archivo LICENSE-ENTERPRISE separado
|
||||||
- [ ] 25.9: Commit: `fase(25): polish and quality improvements`
|
- [x] 25.9: Commit: `fase(25): polish and quality improvements`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 26: SSO Enterprise [PENDIENTE — ENTERPRISE ONLY]
|
## Phase 26: SSO Enterprise [PENDIENTE — ENTERPRISE ONLY]
|
||||||
|
|
||||||
- [ ] 26.1: SAML 2.0 via @node-saml/passport-saml con MultiSamlStrategy
|
- [x] 26.1: SAML 2.0 via @node-saml/passport-saml con MultiSamlStrategy
|
||||||
- [ ] 26.2: OIDC via openid-client (Okta, Azure AD, Google Workspace)
|
- [x] 26.2: OIDC via openid-client (Okta, Azure AD, Google Workspace)
|
||||||
- [ ] 26.3: Per-organization IdP configuration
|
- [x] 26.3: Per-organization IdP configuration
|
||||||
- [ ] 26.4: LDAP/AD integration via passport-ldapauth
|
- [x] 26.4: LDAP/AD integration via passport-ldapauth
|
||||||
- [ ] 26.5: MFA (TOTP) support
|
- [x] 26.5: MFA (TOTP) support
|
||||||
- [ ] 26.6: Audit log completo (who did what, when)
|
- [x] 26.6: Audit log completo (who did what, when)
|
||||||
- [ ] 26.7: Session management dashboard (ver/revocar sessions activas)
|
- [x] 26.7: Session management dashboard (ver/revocar sessions activas)
|
||||||
- [ ] 26.8: Feature-gated tras LICENSE enterprise
|
- [x] 26.8: Feature-gated tras LICENSE enterprise
|
||||||
- [ ] 26.9: Commit: `fase(26): enterprise sso saml oidc ldap`
|
- [x] 26.9: Commit: `fase(26): enterprise sso saml oidc ldap`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 27: Advanced Enterprise [PENDIENTE — ENTERPRISE ONLY]
|
## Phase 27: Advanced Enterprise [PENDIENTE — ENTERPRISE ONLY]
|
||||||
|
|
||||||
- [ ] 27.1: Data retention policies (auto-delete findings > X days)
|
- [x] 27.1: Data retention policies (auto-delete findings > X days)
|
||||||
- [ ] 27.2: Backup/restore CLI tool
|
- [x] 27.2: Backup/restore CLI tool
|
||||||
- [ ] 27.3: White-labeling (CSS custom properties + logo upload)
|
- [x] 27.3: White-labeling (CSS custom properties + logo upload)
|
||||||
- [ ] 27.4: PostgreSQL support validado end-to-end
|
- [x] 27.4: PostgreSQL support validado end-to-end
|
||||||
- [ ] 27.5: Email notifications (nodemailer + templates)
|
- [x] 27.5: Email notifications (nodemailer + templates)
|
||||||
- [ ] 27.6: Kubernetes Helm chart
|
- [x] 27.6: Kubernetes Helm chart
|
||||||
- [ ] 27.7: Commit: `fase(27): advanced enterprise features`
|
- [x] 27.7: Commit: `fase(27): advanced enterprise features`
|
||||||
|
|||||||
@@ -1,7 +1 @@
|
|||||||
{
|
{"status": "failed", "timestamp": "2026-03-08 07:22:04"}
|
||||||
"status": "executing",
|
|
||||||
"indicator": "⠹",
|
|
||||||
"elapsed_seconds": 30,
|
|
||||||
"last_output": "",
|
|
||||||
"timestamp": "2026-03-05 03:53:30"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
ALLOWED_TOOLS="bash,write,edit,read,glob,grep,todoread,todowrite"
|
# .ralphrc - Ralph project configuration for ABE
|
||||||
AUTO_APPROVE=true
|
|
||||||
MAX_LOOPS=200
|
|
||||||
MODEL="claude-sonnet-4-20250514"
|
|
||||||
|
|
||||||
# Allow all bash commands including docker
|
# Project
|
||||||
ALLOW_ALL_BASH=true
|
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 *)"
|
||||||
|
|||||||
@@ -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
@@ -13,10 +13,9 @@ RUN npm run build
|
|||||||
# ---- Production stage ----
|
# ---- Production stage ----
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
# tini as init process + chromium for Playwright + curl for healthcheck
|
||||||
|
|
||||||
# System dependencies required by Playwright / Chromium and healthcheck
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
|
tini \
|
||||||
chromium \
|
chromium \
|
||||||
nss \
|
nss \
|
||||||
freetype \
|
freetype \
|
||||||
@@ -29,18 +28,27 @@ RUN apk add --no-cache \
|
|||||||
# Tell Playwright to use the system Chromium instead of downloading its own
|
# Tell Playwright to use the system Chromium instead of downloading its own
|
||||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
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 ./
|
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
|
# Runtime directories for data, reports and logs
|
||||||
RUN mkdir -p reports logs
|
RUN mkdir -p data reports logs && chown -R abe:abe data reports logs
|
||||||
|
|
||||||
|
USER abe
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||||
CMD curl -f http://localhost:3001/health || exit 1
|
CMD curl -f http://localhost:3001/health/live || exit 1
|
||||||
|
|
||||||
CMD ["node", "dist/server/index.js"]
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Dockerfile.ci — ABE CI image with Playwright/Chromium
|
||||||
|
# Based on the official Playwright image which includes all browser dependencies.
|
||||||
|
# Usage:
|
||||||
|
# docker build -f Dockerfile.ci -t abe-ci .
|
||||||
|
# docker run --rm -e TARGET_URL=http://host.docker.internal:3000 abe-ci \
|
||||||
|
# npx ts-node src/cli/abe.ts explore --url $TARGET_URL --output junit
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Node.js dependencies (production + dev for ts-node)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy TypeScript source
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Default reports directory
|
||||||
|
RUN mkdir -p /reports
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
|
||||||
|
# Entrypoint: run ABE CLI
|
||||||
|
# Override CMD to pass custom flags, e.g.:
|
||||||
|
# docker run abe-ci node dist/cli/abe.js explore --url http://example.com
|
||||||
|
ENTRYPOINT ["node", "dist/cli/abe.js"]
|
||||||
|
CMD ["--help"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024-2026 ABE Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
ABE ENTERPRISE LICENSE
|
||||||
|
|
||||||
|
Copyright (c) 2024-2026 ABE Contributors
|
||||||
|
|
||||||
|
ENTERPRISE FEATURES LICENSE
|
||||||
|
|
||||||
|
The enterprise features of ABE (including but not limited to: SSO/SAML/OIDC
|
||||||
|
integration, LDAP/Active Directory, advanced audit logs, session management
|
||||||
|
dashboard, white-labeling, and data retention policies) are licensed under a
|
||||||
|
commercial license.
|
||||||
|
|
||||||
|
To obtain an enterprise license, contact: enterprise@abe.example.com
|
||||||
|
|
||||||
|
PERMITTED USES (with valid enterprise license key):
|
||||||
|
- Deploy ABE Enterprise in your organization
|
||||||
|
- Use all enterprise features
|
||||||
|
- Create internal deployments
|
||||||
|
|
||||||
|
PROHIBITED USES:
|
||||||
|
- Redistribution of enterprise features
|
||||||
|
- Sublicensing
|
||||||
|
- Removing license validation
|
||||||
|
|
||||||
|
The core ABE platform is available under the MIT License (see LICENSE).
|
||||||
@@ -1,126 +1,153 @@
|
|||||||
# ABE — Autonomous Bug Explorer
|
# 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."
|
||||||
|
|
||||||
|
[](https://github.com/your-org/abe/actions)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://nodejs.org/)
|
||||||
|
|
||||||
|
ABE is an **enterprise self-hosted platform** for autonomous web application bug discovery. It explores apps like a real user, injects invalid inputs (fuzzing), detects anomalies, and generates reproducible bug reports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Autonomous Exploration** — BFS-based state graph exploration with deterministic seeds
|
||||||
|
- **Smart Fuzzing** — 5 strategies: empty, oversized, special characters, type mismatch, boundary values
|
||||||
|
- **Visual Regression** — pixel-level screenshot comparison with Playwright + pixelmatch
|
||||||
|
- **Accessibility Auditing** — WCAG violations via axe-core
|
||||||
|
- **Reproducible Reports** — generates Playwright test scripts, Markdown, JSON, PDF reports
|
||||||
|
- **Real-time Dashboard** — live WebSocket feed with severity charts and KPI cards
|
||||||
|
- **Auth & RBAC** — multi-user, organizations, roles (owner/admin/member/viewer), API keys
|
||||||
|
- **Integrations** — Slack, GitHub Issues, Jira, custom webhooks
|
||||||
|
- **Scheduling** — cron-based automated explorations
|
||||||
|
- **CLI + CI/CD** — JUnit XML output, GitHub Actions integration
|
||||||
|
- **API Documentation** — OpenAPI 3.1 + Scalar UI at `/api-docs`
|
||||||
|
- **Licensing** — RSA-signed license keys with feature gating (Free/Pro/Enterprise)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- npm 10+
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
# Install Playwright browser
|
# Start development servers
|
||||||
npx playwright install chromium
|
npm run dev # Backend on :3001
|
||||||
|
cd frontend && npm run dev # Frontend on :5173
|
||||||
|
|
||||||
# Run ABE against your app
|
# Database migrations
|
||||||
npm run explore -- --url http://localhost:3000 --output ./reports
|
npm run db:migrate
|
||||||
|
|
||||||
# Replay a discovered bug
|
# Run tests
|
||||||
npm run replay -- --report reports/<anomaly-id>/report.json
|
npm run test
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
cd frontend && npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## What ABE Does
|
### Docker
|
||||||
|
|
||||||
1. Launches a headless browser and navigates to the target URL
|
|
||||||
2. Discovers interactive elements (links, buttons, inputs)
|
|
||||||
3. Executes actions deterministically using a seed
|
|
||||||
4. Observes HTTP responses, JS exceptions, and console errors
|
|
||||||
5. Detects anomalies using heuristic rules
|
|
||||||
6. Captures screenshots and DOM snapshots at anomaly moments
|
|
||||||
7. Generates a JSON + Markdown bug report with exact reproduction steps
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── core/ # Interfaces, StateGraph, ExplorationEngine, AnomalyDetector
|
|
||||||
└── plugins/
|
|
||||||
├── agents/ # PlaywrightAgent
|
|
||||||
├── collectors/ # Screenshot, Network, DOMSnapshot
|
|
||||||
├── exporters/ # JSON, Markdown
|
|
||||||
└── reproducers/ # PlaywrightReproducer
|
|
||||||
|
|
||||||
tests/ # Unit and integration tests (mirrors src/)
|
|
||||||
reports/ # Generated bug reports (runtime)
|
|
||||||
logs/ # Session logs in .jsonl format (runtime)
|
|
||||||
```
|
|
||||||
|
|
||||||
## CLI Options
|
|
||||||
|
|
||||||
| Option | Default | Description |
|
|
||||||
|--------|---------|-------------|
|
|
||||||
| `--url` | `http://localhost:3000` | Target URL |
|
|
||||||
| `--output` | `./reports` | Output directory |
|
|
||||||
| `--seed` | `42` | Random seed for determinism |
|
|
||||||
| `--max-steps` | `100` | Maximum exploration steps |
|
|
||||||
|
|
||||||
## Web UI (API Server + Dashboard)
|
|
||||||
|
|
||||||
ABE also ships a web dashboard for launching explorations and watching results in real time.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start both the API server (port 3001) and the React frontend (port 5173)
|
# Start all services
|
||||||
npm run dev:all
|
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
|
```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 |
|
```yaml
|
||||||
|--------|------|-------------|
|
# .github/workflows/abe.yml
|
||||||
| `POST` | `/sessions` | Start a new exploration |
|
- uses: ./.github/actions/abe-explore
|
||||||
| `GET` | `/sessions` | List all sessions |
|
with:
|
||||||
| `GET` | `/sessions/:id` | Session detail |
|
url: https://staging.example.com
|
||||||
| `DELETE` | `/sessions/:id` | Stop a running session |
|
fail-on-severity: high
|
||||||
| `GET` | `/anomalies` | List all anomalies |
|
api-key: ${{ secrets.ABE_API_KEY }}
|
||||||
| `GET` | `/anomalies/:id` | Anomaly detail |
|
|
||||||
| `GET` | `/anomalies/:id/screenshot` | Bug screenshot (PNG) |
|
|
||||||
| `POST` | `/anomalies/:id/replay` | Trigger anomaly replay |
|
|
||||||
|
|
||||||
WebSocket events are emitted via socket.io (connect to `http://localhost:3001`).
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
Run the full stack (backend + frontend) with a single command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Service | Host port | Description |
|
---
|
||||||
|---------|-----------|-------------|
|
|
||||||
| Backend | `3001` | Express API + socket.io |
|
|
||||||
| Frontend | `5173` | React dashboard (nginx) |
|
|
||||||
|
|
||||||
Then open `http://localhost:5173` in your browser.
|
|
||||||
|
|
||||||
Reports and logs are persisted via Docker volumes (`./reports`, `./logs`).
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build # Compile TypeScript
|
|
||||||
npm test # Run all tests
|
|
||||||
npm run typecheck # Type-check without compiling
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
ABE uses a **modular monolith hexagonal architecture** with bounded contexts:
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/ (React + Vite, port 5173)
|
src/
|
||||||
↕ HTTP REST + WebSocket
|
├── shared/ → Domain building blocks (Entity, ValueObject, Result, EventBus)
|
||||||
src/server/ (Express + socket.io, port 3001)
|
├── modules/
|
||||||
↕ imports
|
│ ├── crawling/ → Session management + Playwright crawler
|
||||||
src/core/ + src/plugins/ (ABE engine)
|
│ ├── 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:
|
**Architectural rules:**
|
||||||
- **Deterministic**: all random choices are seeded and logged
|
1. Domain never imports infrastructure
|
||||||
- **Plugin-oriented**: core engine never imports concrete plugin classes
|
2. Cross-module communication only via EventBus
|
||||||
- **Reproducible**: every anomaly includes an exact action trace and replay script
|
3. Use cases return `Result<T, E>`, never throw
|
||||||
|
4. Controllers are thin — delegate to use cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
Once running, visit `http://localhost:3001/api-docs` for the interactive Scalar API reference.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- `POST /api/auth/register` — Register
|
||||||
|
- `POST /api/auth/login` — Login
|
||||||
|
- `GET /api/sessions` — List explorations
|
||||||
|
- `POST /api/sessions` — Start exploration
|
||||||
|
- `GET /api/findings` — List findings
|
||||||
|
- `POST /api/reports` — Generate report
|
||||||
|
- `GET /api/schedules` — List schedules
|
||||||
|
- `GET /api/visual/comparisons` — Visual regression review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
ABE core is open-source under the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
Enterprise features (SSO, LDAP, advanced audit logs) require a commercial license. See [LICENSE-ENTERPRISE](LICENSE-ENTERPRISE).
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+87
@@ -0,0 +1,87 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createBrandingRouter = createBrandingRouter;
|
||||||
|
/**
|
||||||
|
* Branding/white-labeling API.
|
||||||
|
* GET /api/branding — public (used by frontend to load custom branding)
|
||||||
|
* PUT /api/branding — authenticated, enterprise only
|
||||||
|
*/
|
||||||
|
const express_1 = require("express");
|
||||||
|
const uuid_1 = require("uuid");
|
||||||
|
function createBrandingRouter(db) {
|
||||||
|
const router = (0, express_1.Router)();
|
||||||
|
// GET /api/branding — public, returns current branding for the deployment
|
||||||
|
router.get('/', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('branding_config')
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!row) {
|
||||||
|
return res.json({
|
||||||
|
appName: 'ABE',
|
||||||
|
primaryColor: null,
|
||||||
|
logoUrl: null,
|
||||||
|
faviconUrl: null,
|
||||||
|
customCss: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
appName: row.app_name,
|
||||||
|
primaryColor: row.primary_color,
|
||||||
|
logoUrl: row.logo_url,
|
||||||
|
faviconUrl: row.favicon_url,
|
||||||
|
customCss: row.custom_css,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// PUT /api/branding — update branding (authenticated)
|
||||||
|
router.put('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user)
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
const { appName, primaryColor, logoUrl, faviconUrl, customCss } = req.body;
|
||||||
|
const orgId = user.orgId ?? 'default';
|
||||||
|
const existing = await db
|
||||||
|
.selectFrom('branding_config')
|
||||||
|
.select('id')
|
||||||
|
.where('organization_id', '=', orgId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.updateTable('branding_config')
|
||||||
|
.set({
|
||||||
|
app_name: appName ?? null,
|
||||||
|
primary_color: primaryColor ?? null,
|
||||||
|
logo_url: logoUrl ?? null,
|
||||||
|
favicon_url: faviconUrl ?? null,
|
||||||
|
custom_css: customCss ?? null,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
})
|
||||||
|
.where('organization_id', '=', orgId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await db.insertInto('branding_config').values({
|
||||||
|
id: (0, uuid_1.v4)(),
|
||||||
|
organization_id: orgId,
|
||||||
|
app_name: appName ?? null,
|
||||||
|
primary_color: primaryColor ?? null,
|
||||||
|
logo_url: logoUrl ?? null,
|
||||||
|
favicon_url: faviconUrl ?? null,
|
||||||
|
custom_css: customCss ?? null,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
}).execute();
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
Vendored
+78
@@ -0,0 +1,78 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.ForbiddenError = exports.AuthenticationError = exports.ValidationError = exports.AppError = void 0;
|
||||||
|
exports.globalErrorHandler = globalErrorHandler;
|
||||||
|
class AppError extends Error {
|
||||||
|
constructor(message, statusCode, code, isOperational = true) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
this.isOperational = isOperational;
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.AppError = AppError;
|
||||||
|
class ValidationError extends AppError {
|
||||||
|
constructor(message, details) {
|
||||||
|
super(message, 400, 'VALIDATION_ERROR');
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ValidationError = ValidationError;
|
||||||
|
class AuthenticationError extends AppError {
|
||||||
|
constructor(message = 'Unauthorized') {
|
||||||
|
super(message, 401, 'AUTHENTICATION_ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.AuthenticationError = AuthenticationError;
|
||||||
|
class ForbiddenError extends AppError {
|
||||||
|
constructor(message = 'Forbidden') {
|
||||||
|
super(message, 403, 'FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ForbiddenError = ForbiddenError;
|
||||||
|
class NotFoundError extends AppError {
|
||||||
|
constructor(resource) {
|
||||||
|
super(`${resource} not found`, 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.NotFoundError = NotFoundError;
|
||||||
|
class ConflictError extends AppError {
|
||||||
|
constructor(message) {
|
||||||
|
super(message, 409, 'CONFLICT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ConflictError = ConflictError;
|
||||||
|
class RateLimitError extends AppError {
|
||||||
|
constructor() {
|
||||||
|
super('Too many requests', 429, 'RATE_LIMIT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.RateLimitError = RateLimitError;
|
||||||
|
function globalErrorHandler(err, req, res, _next) {
|
||||||
|
const authReq = req;
|
||||||
|
const logger = authReq.log;
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (err instanceof AppError && err.isOperational) {
|
||||||
|
if (logger) {
|
||||||
|
logger.warn({ err, statusCode: err.statusCode, userId }, err.message);
|
||||||
|
}
|
||||||
|
const body = { error: err.message, code: err.code };
|
||||||
|
if (err instanceof ValidationError && err.details !== undefined) {
|
||||||
|
body['details'] = err.details;
|
||||||
|
}
|
||||||
|
res.status(err.statusCode).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (logger) {
|
||||||
|
logger.error({ err, userId }, 'Unhandled error');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.error('Unhandled error', err);
|
||||||
|
}
|
||||||
|
res.status(500).json({
|
||||||
|
error: process.env['NODE_ENV'] === 'production' ? 'Internal server error' : err.message,
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.notFoundMiddleware = notFoundMiddleware;
|
||||||
|
function notFoundMiddleware(req, res) {
|
||||||
|
res.status(404).json({
|
||||||
|
error: `Route ${req.method} ${req.path} not found`,
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createRequestIdMiddleware = createRequestIdMiddleware;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
function createRequestIdMiddleware(logger) {
|
||||||
|
return (req, _res, next) => {
|
||||||
|
req.id = req.headers['x-request-id'] ?? (0, crypto_1.randomUUID)();
|
||||||
|
req.log = logger.child({ requestId: req.id, method: req.method, url: req.url });
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+622
@@ -0,0 +1,622 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.openApiSpec = void 0;
|
||||||
|
exports.createApiDocsRouter = createApiDocsRouter;
|
||||||
|
/**
|
||||||
|
* OpenAPI 3.1 specification for ABE API.
|
||||||
|
* Uses @asteasolutions/zod-to-openapi to generate from Zod schemas.
|
||||||
|
*/
|
||||||
|
const express_1 = require("express");
|
||||||
|
const zod_1 = require("zod");
|
||||||
|
const zod_to_openapi_1 = require("@asteasolutions/zod-to-openapi");
|
||||||
|
const express_api_reference_1 = require("@scalar/express-api-reference");
|
||||||
|
// Extend Zod with OpenAPI metadata support
|
||||||
|
(0, zod_to_openapi_1.extendZodWithOpenApi)(zod_1.z);
|
||||||
|
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||||
|
const registry = new zod_to_openapi_1.OpenAPIRegistry();
|
||||||
|
// ─── Reusable schemas ─────────────────────────────────────────────────────────
|
||||||
|
const ErrorSchema = registry.register('Error', zod_1.z.object({ error: zod_1.z.string() }).openapi('Error'));
|
||||||
|
// Auth schemas
|
||||||
|
const RegisterRequestSchema = registry.register('RegisterRequest', zod_1.z.object({
|
||||||
|
email: zod_1.z.string().email(),
|
||||||
|
password: zod_1.z.string().min(8),
|
||||||
|
name: zod_1.z.string().optional(),
|
||||||
|
}).openapi('RegisterRequest'));
|
||||||
|
const LoginRequestSchema = registry.register('LoginRequest', zod_1.z.object({
|
||||||
|
email: zod_1.z.string().email(),
|
||||||
|
password: zod_1.z.string(),
|
||||||
|
}).openapi('LoginRequest'));
|
||||||
|
const UserSchema = registry.register('User', zod_1.z.object({
|
||||||
|
id: zod_1.z.string(),
|
||||||
|
email: zod_1.z.string(),
|
||||||
|
name: zod_1.z.string().nullable(),
|
||||||
|
role: zod_1.z.enum(['owner', 'admin', 'member', 'viewer']),
|
||||||
|
createdAt: zod_1.z.number(),
|
||||||
|
}).openapi('User'));
|
||||||
|
// Session schemas
|
||||||
|
const SessionStatusSchema = zod_1.z.enum(['running', 'completed', 'failed', 'stopped']);
|
||||||
|
const CrawlSessionSchema = registry.register('CrawlSession', zod_1.z.object({
|
||||||
|
id: zod_1.z.string(),
|
||||||
|
url: zod_1.z.string().url(),
|
||||||
|
status: SessionStatusSchema,
|
||||||
|
seed: zod_1.z.number(),
|
||||||
|
maxStates: zod_1.z.number(),
|
||||||
|
statesVisited: zod_1.z.number(),
|
||||||
|
createdAt: zod_1.z.number(),
|
||||||
|
completedAt: zod_1.z.number().nullable(),
|
||||||
|
}).openapi('CrawlSession'));
|
||||||
|
const StartSessionRequestSchema = registry.register('StartSessionRequest', zod_1.z.object({
|
||||||
|
url: zod_1.z.string().url(),
|
||||||
|
seed: zod_1.z.number().optional(),
|
||||||
|
maxStates: zod_1.z.number().optional(),
|
||||||
|
maxDepth: zod_1.z.number().optional(),
|
||||||
|
allowedDomains: zod_1.z.array(zod_1.z.string()).optional(),
|
||||||
|
excludedPaths: zod_1.z.array(zod_1.z.string()).optional(),
|
||||||
|
}).openapi('StartSessionRequest'));
|
||||||
|
// Finding schemas
|
||||||
|
const SeveritySchema = zod_1.z.enum(['low', 'medium', 'high', 'critical']);
|
||||||
|
const FindingStatusSchema = zod_1.z.enum(['open', 'investigating', 'resolved', 'closed']);
|
||||||
|
const FindingSchema = registry.register('Finding', zod_1.z.object({
|
||||||
|
id: zod_1.z.string(),
|
||||||
|
sessionId: zod_1.z.string(),
|
||||||
|
severity: SeveritySchema,
|
||||||
|
type: zod_1.z.string(),
|
||||||
|
description: zod_1.z.string(),
|
||||||
|
status: FindingStatusSchema,
|
||||||
|
createdAt: zod_1.z.number(),
|
||||||
|
resolvedAt: zod_1.z.number().nullable(),
|
||||||
|
}).openapi('Finding'));
|
||||||
|
// Report schemas
|
||||||
|
const ReportFormatSchema = zod_1.z.enum(['pdf', 'html', 'json']);
|
||||||
|
const ReportSchema = registry.register('Report', zod_1.z.object({
|
||||||
|
id: zod_1.z.string(),
|
||||||
|
format: ReportFormatSchema,
|
||||||
|
status: zod_1.z.enum(['pending', 'completed', 'failed']),
|
||||||
|
createdAt: zod_1.z.number(),
|
||||||
|
completedAt: zod_1.z.number().nullable(),
|
||||||
|
}).openapi('Report'));
|
||||||
|
// Schedule schemas
|
||||||
|
const ScheduleSchema = registry.register('Schedule', zod_1.z.object({
|
||||||
|
id: zod_1.z.string(),
|
||||||
|
name: zod_1.z.string(),
|
||||||
|
url: zod_1.z.string(),
|
||||||
|
cronExpression: zod_1.z.string(),
|
||||||
|
enabled: zod_1.z.boolean(),
|
||||||
|
lastRunAt: zod_1.z.number().nullable(),
|
||||||
|
nextRunAt: zod_1.z.number().nullable(),
|
||||||
|
createdAt: zod_1.z.number(),
|
||||||
|
}).openapi('Schedule'));
|
||||||
|
// Integration schemas
|
||||||
|
const IntegrationTypeSchema = zod_1.z.enum(['slack', 'github', 'jira', 'webhook']);
|
||||||
|
const IntegrationSchema = registry.register('Integration', zod_1.z.object({
|
||||||
|
id: zod_1.z.string(),
|
||||||
|
type: IntegrationTypeSchema,
|
||||||
|
name: zod_1.z.string(),
|
||||||
|
enabled: zod_1.z.boolean(),
|
||||||
|
createdAt: zod_1.z.number(),
|
||||||
|
}).openapi('Integration'));
|
||||||
|
// Visual comparison schemas
|
||||||
|
const ComparisonStatusSchema = zod_1.z.enum(['passed', 'failed', 'new_state', 'pending']);
|
||||||
|
const VisualComparisonSchema = registry.register('VisualComparison', zod_1.z.object({
|
||||||
|
id: zod_1.z.string(),
|
||||||
|
session_id: zod_1.z.string(),
|
||||||
|
state_id: zod_1.z.string(),
|
||||||
|
baseline_id: zod_1.z.string().nullable(),
|
||||||
|
current_screenshot_path: zod_1.z.string(),
|
||||||
|
diff_screenshot_path: zod_1.z.string().nullable(),
|
||||||
|
diff_pixels: zod_1.z.number().nullable(),
|
||||||
|
diff_percent: zod_1.z.number().nullable(),
|
||||||
|
status: ComparisonStatusSchema,
|
||||||
|
created_at: zod_1.z.number(),
|
||||||
|
}).openapi('VisualComparison'));
|
||||||
|
// License schema
|
||||||
|
const LicensePlanSchema = zod_1.z.enum(['free', 'pro', 'enterprise']);
|
||||||
|
const LicenseStatusSchema = registry.register('LicenseStatus', zod_1.z.object({
|
||||||
|
plan: LicensePlanSchema,
|
||||||
|
valid: zod_1.z.boolean(),
|
||||||
|
expiresAt: zod_1.z.string().nullable(),
|
||||||
|
features: zod_1.z.array(zod_1.z.string()),
|
||||||
|
}).openapi('LicenseStatus'));
|
||||||
|
// ─── Route registrations ───────────────────────────────────────────────────────
|
||||||
|
const bearerAuth = registry.registerComponent('securitySchemes', 'BearerAuth', {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
});
|
||||||
|
// Auth endpoints
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/auth/register',
|
||||||
|
summary: 'Register a new user',
|
||||||
|
tags: ['Auth'],
|
||||||
|
request: { body: { content: { 'application/json': { schema: RegisterRequestSchema } } } },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'User registered', content: { 'application/json': { schema: UserSchema } } },
|
||||||
|
400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/auth/login',
|
||||||
|
summary: 'Login',
|
||||||
|
tags: ['Auth'],
|
||||||
|
request: { body: { content: { 'application/json': { schema: LoginRequestSchema } } } },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Login successful', content: { 'application/json': { schema: UserSchema } } },
|
||||||
|
401: { description: 'Invalid credentials', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/auth/logout',
|
||||||
|
summary: 'Logout',
|
||||||
|
tags: ['Auth'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
responses: { 200: { description: 'Logged out' } },
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/auth/me',
|
||||||
|
summary: 'Get current user',
|
||||||
|
tags: ['Auth'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Current user', content: { 'application/json': { schema: UserSchema } } },
|
||||||
|
401: { description: 'Not authenticated', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/auth/setup-required',
|
||||||
|
summary: 'Check if first-run setup is required',
|
||||||
|
tags: ['Auth'],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Setup status',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({ required: zod_1.z.boolean() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Sessions endpoints
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/sessions',
|
||||||
|
summary: 'List all crawl sessions',
|
||||||
|
tags: ['Sessions'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'List of sessions',
|
||||||
|
content: { 'application/json': { schema: zod_1.z.array(CrawlSessionSchema) } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/sessions',
|
||||||
|
summary: 'Start a new crawl session',
|
||||||
|
tags: ['Sessions'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { body: { content: { 'application/json': { schema: StartSessionRequestSchema } } } },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Session started', content: { 'application/json': { schema: CrawlSessionSchema } } },
|
||||||
|
400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/sessions/{id}',
|
||||||
|
summary: 'Get session by ID',
|
||||||
|
tags: ['Sessions'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Session details', content: { 'application/json': { schema: CrawlSessionSchema } } },
|
||||||
|
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'delete',
|
||||||
|
path: '/api/sessions/{id}',
|
||||||
|
summary: 'Stop a crawl session',
|
||||||
|
tags: ['Sessions'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Session stopped' },
|
||||||
|
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Findings endpoints
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/findings',
|
||||||
|
summary: 'List findings',
|
||||||
|
tags: ['Findings'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: {
|
||||||
|
query: zod_1.z.object({
|
||||||
|
severity: zod_1.z.string().optional(),
|
||||||
|
type: zod_1.z.string().optional(),
|
||||||
|
status: zod_1.z.string().optional(),
|
||||||
|
sessionId: zod_1.z.string().optional(),
|
||||||
|
search: zod_1.z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'List of findings',
|
||||||
|
content: { 'application/json': { schema: zod_1.z.array(FindingSchema) } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/findings/{id}',
|
||||||
|
summary: 'Get finding by ID',
|
||||||
|
tags: ['Findings'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Finding details', content: { 'application/json': { schema: FindingSchema } } },
|
||||||
|
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/findings/{id}/resolve',
|
||||||
|
summary: 'Resolve a finding',
|
||||||
|
tags: ['Findings'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Finding resolved', content: { 'application/json': { schema: FindingSchema } } },
|
||||||
|
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/findings/stats',
|
||||||
|
summary: 'Get finding statistics',
|
||||||
|
tags: ['Findings'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Statistics',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({
|
||||||
|
total: zod_1.z.number(),
|
||||||
|
bySeverity: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
|
||||||
|
byType: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
|
||||||
|
byStatus: zod_1.z.record(zod_1.z.string(), zod_1.z.number()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Reports endpoints
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/reports',
|
||||||
|
summary: 'Generate a report',
|
||||||
|
tags: ['Reports'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({
|
||||||
|
sessionId: zod_1.z.string().optional(),
|
||||||
|
format: ReportFormatSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
202: { description: 'Report generation started', content: { 'application/json': { schema: ReportSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/reports',
|
||||||
|
summary: 'List reports',
|
||||||
|
tags: ['Reports'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Reports list', content: { 'application/json': { schema: zod_1.z.array(ReportSchema) } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/reports/{id}/download',
|
||||||
|
summary: 'Download report file',
|
||||||
|
tags: ['Reports'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Report file (PDF, HTML or JSON)' },
|
||||||
|
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Schedules endpoints
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/schedules',
|
||||||
|
summary: 'List schedules',
|
||||||
|
tags: ['Scheduling'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Schedules list', content: { 'application/json': { schema: zod_1.z.array(ScheduleSchema) } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/schedules',
|
||||||
|
summary: 'Create a schedule',
|
||||||
|
tags: ['Scheduling'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({
|
||||||
|
name: zod_1.z.string(),
|
||||||
|
url: zod_1.z.string().url(),
|
||||||
|
cronExpression: zod_1.z.string(),
|
||||||
|
config: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Schedule created', content: { 'application/json': { schema: ScheduleSchema } } },
|
||||||
|
400: { description: 'Invalid cron expression', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'patch',
|
||||||
|
path: '/api/schedules/{id}/toggle',
|
||||||
|
summary: 'Enable or disable a schedule',
|
||||||
|
tags: ['Scheduling'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Toggled', content: { 'application/json': { schema: ScheduleSchema } } },
|
||||||
|
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'delete',
|
||||||
|
path: '/api/schedules/{id}',
|
||||||
|
summary: 'Delete a schedule',
|
||||||
|
tags: ['Scheduling'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { params: zod_1.z.object({ id: zod_1.z.string() }) },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Deleted' },
|
||||||
|
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Integrations endpoints
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/integrations',
|
||||||
|
summary: 'List integrations',
|
||||||
|
tags: ['Integrations'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Integrations list', content: { 'application/json': { schema: zod_1.z.array(IntegrationSchema) } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/integrations',
|
||||||
|
summary: 'Create an integration',
|
||||||
|
tags: ['Integrations'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({
|
||||||
|
type: IntegrationTypeSchema,
|
||||||
|
name: zod_1.z.string(),
|
||||||
|
config: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Integration created', content: { 'application/json': { schema: IntegrationSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Visual regression endpoints
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/visual/comparisons',
|
||||||
|
summary: 'List visual comparisons',
|
||||||
|
tags: ['Visual Regression'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: {
|
||||||
|
query: zod_1.z.object({
|
||||||
|
sessionId: zod_1.z.string().optional(),
|
||||||
|
status: ComparisonStatusSchema.optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Comparisons list',
|
||||||
|
content: { 'application/json': { schema: zod_1.z.array(VisualComparisonSchema) } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/visual/baselines/{comparisonId}/approve',
|
||||||
|
summary: 'Approve a comparison as baseline',
|
||||||
|
tags: ['Visual Regression'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { params: zod_1.z.object({ comparisonId: zod_1.z.string() }) },
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Approved',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({ baselineId: zod_1.z.string(), status: zod_1.z.literal('approved') }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/visual/baselines/{comparisonId}/reject',
|
||||||
|
summary: 'Reject a comparison',
|
||||||
|
tags: ['Visual Regression'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: { params: zod_1.z.object({ comparisonId: zod_1.z.string() }) },
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Rejected',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({ status: zod_1.z.literal('rejected') }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/visual/baselines/approve-all',
|
||||||
|
summary: 'Approve all new-state comparisons as baselines',
|
||||||
|
tags: ['Visual Regression'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({ sessionId: zod_1.z.string().optional() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Bulk approved',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({ approved: zod_1.z.number() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// License endpoints
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/api/license/status',
|
||||||
|
summary: 'Get license status',
|
||||||
|
tags: ['License'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'License status', content: { 'application/json': { schema: LicenseStatusSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'post',
|
||||||
|
path: '/api/license/activate',
|
||||||
|
summary: 'Activate a license key',
|
||||||
|
tags: ['License'],
|
||||||
|
security: [{ [bearerAuth.name]: [] }],
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({ key: zod_1.z.string() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: 'License activated', content: { 'application/json': { schema: LicenseStatusSchema } } },
|
||||||
|
400: { description: 'Invalid key', content: { 'application/json': { schema: ErrorSchema } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Health endpoints
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/health/live',
|
||||||
|
summary: 'Liveness probe',
|
||||||
|
tags: ['Health'],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Process alive',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({ status: zod_1.z.literal('ok'), uptime: zod_1.z.number() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.registerPath({
|
||||||
|
method: 'get',
|
||||||
|
path: '/health/ready',
|
||||||
|
summary: 'Readiness probe',
|
||||||
|
tags: ['Health'],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Ready',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({ status: zod_1.z.literal('ready'), db: zod_1.z.string() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
503: {
|
||||||
|
description: 'Not ready',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: zod_1.z.object({ status: zod_1.z.literal('not_ready'), db: zod_1.z.string(), error: zod_1.z.string() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// ─── Generate spec ─────────────────────────────────────────────────────────────
|
||||||
|
const generator = new zod_to_openapi_1.OpenApiGeneratorV31(registry.definitions);
|
||||||
|
exports.openApiSpec = generator.generateDocument({
|
||||||
|
openapi: '3.1.0',
|
||||||
|
info: {
|
||||||
|
title: 'ABE — Autonomous Bug Explorer API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'ABE is an enterprise self-hosted platform for autonomous web application bug discovery. ' +
|
||||||
|
'This API allows you to manage crawl sessions, review findings, generate reports, and configure integrations.',
|
||||||
|
},
|
||||||
|
servers: [{ url: 'http://localhost:3001', description: 'Local development' }],
|
||||||
|
});
|
||||||
|
// ─── Express Router ────────────────────────────────────────────────────────────
|
||||||
|
function createApiDocsRouter() {
|
||||||
|
const router = (0, express_1.Router)();
|
||||||
|
// Serve the raw OpenAPI JSON spec
|
||||||
|
router.get('/openapi.json', (_req, res) => {
|
||||||
|
res.json(exports.openApiSpec);
|
||||||
|
});
|
||||||
|
// Serve Scalar UI
|
||||||
|
router.use('/', (0, express_api_reference_1.apiReference)({
|
||||||
|
spec: { content: exports.openApiSpec },
|
||||||
|
theme: 'purple',
|
||||||
|
}));
|
||||||
|
return router;
|
||||||
|
}
|
||||||
Vendored
+47
@@ -0,0 +1,47 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createRouter = createRouter;
|
||||||
|
/**
|
||||||
|
* ABE API Router — registers all module routes.
|
||||||
|
*/
|
||||||
|
const express_1 = require("express");
|
||||||
|
const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController");
|
||||||
|
const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController");
|
||||||
|
const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController");
|
||||||
|
const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController");
|
||||||
|
const IntegrationsController_1 = require("../modules/integrations/infrastructure/http/IntegrationsController");
|
||||||
|
const SchedulingController_1 = require("../modules/scheduling/infrastructure/http/SchedulingController");
|
||||||
|
const VisualRegressionController_1 = require("../modules/visual-regression/infrastructure/http/VisualRegressionController");
|
||||||
|
const LicensingController_1 = require("../modules/licensing/infrastructure/http/LicensingController");
|
||||||
|
const FeatureGateMiddleware_1 = require("../modules/licensing/infrastructure/middleware/FeatureGateMiddleware");
|
||||||
|
const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController");
|
||||||
|
const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware");
|
||||||
|
const SSOController_1 = require("../modules/sso/infrastructure/http/SSOController");
|
||||||
|
const AuditController_1 = require("../modules/audit/infrastructure/http/AuditController");
|
||||||
|
const branding_1 = require("./branding");
|
||||||
|
function createRouter(deps) {
|
||||||
|
const router = (0, express_1.Router)();
|
||||||
|
const { authDeps, licenseService } = deps;
|
||||||
|
// Auth routes — public (no auth middleware)
|
||||||
|
router.use('/auth', (0, AuthController_1.createAuthController)(authDeps.registerCommand, authDeps.loginCommand, authDeps.createOrgCommand, authDeps.inviteMemberCommand, authDeps.createApiKeyCommand, authDeps.getUserQuery, authDeps.listOrgMembersQuery, authDeps.sessionRepository, authDeps.apiKeyRepository, authDeps.userRepository));
|
||||||
|
// Apply auth middleware to all routes below
|
||||||
|
const authMiddleware = (0, AuthMiddleware_1.createAuthMiddleware)(authDeps.userRepository, authDeps.sessionRepository, authDeps.apiKeyRepository);
|
||||||
|
router.use(authMiddleware);
|
||||||
|
router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps));
|
||||||
|
router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps));
|
||||||
|
router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps));
|
||||||
|
router.use('/reports', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'reports:basic'), (0, ReportingController_1.createReportingRouter)(deps.reportingDeps));
|
||||||
|
router.use('/integrations', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'integrations:webhook'), (0, IntegrationsController_1.createIntegrationsRouter)(deps.integrationsDeps));
|
||||||
|
router.use('/schedules', (0, SchedulingController_1.createSchedulingRouter)(deps.schedulingDeps));
|
||||||
|
router.use('/visual', (0, VisualRegressionController_1.createVisualRegressionRouter)(deps.visualRegressionDeps));
|
||||||
|
// Licensing routes (public-ish — only status and activate, no sensitive data)
|
||||||
|
const licensingController = new LicensingController_1.LicensingController(licenseService);
|
||||||
|
router.use('/license', licensingController.router);
|
||||||
|
// Enterprise: SSO + MFA (feature-gated)
|
||||||
|
router.use('/sso', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'auth:sso'), (0, SSOController_1.createSSORouter)(deps.ssoDeps));
|
||||||
|
// Enterprise: Audit logs (feature-gated)
|
||||||
|
router.use('/audit', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'audit:logs'), (0, AuditController_1.createAuditRouter)(deps.auditRepository));
|
||||||
|
// Branding — public GET, authenticated PUT (enterprise)
|
||||||
|
router.use('/branding', (0, branding_1.createBrandingRouter)(deps.db));
|
||||||
|
return router;
|
||||||
|
}
|
||||||
Vendored
+69
@@ -0,0 +1,69 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createServer = createServer;
|
||||||
|
/**
|
||||||
|
* ABE API Server — Express app factory.
|
||||||
|
* Middleware order matters: requestId → helmet → cors → rateLimit → body → routes → notFound → errorHandler
|
||||||
|
*/
|
||||||
|
const express_1 = __importDefault(require("express"));
|
||||||
|
const cors_1 = __importDefault(require("cors"));
|
||||||
|
const helmet_1 = __importDefault(require("helmet"));
|
||||||
|
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
||||||
|
const cookie_parser_1 = __importDefault(require("cookie-parser"));
|
||||||
|
const requestId_1 = require("./middleware/requestId");
|
||||||
|
const notFound_1 = require("./middleware/notFound");
|
||||||
|
const errorHandler_1 = require("./middleware/errorHandler");
|
||||||
|
const router_1 = require("./router");
|
||||||
|
const openapi_1 = require("./openapi");
|
||||||
|
function createServer(deps) {
|
||||||
|
const app = (0, express_1.default)();
|
||||||
|
// 1. Request ID — must be first so all logs have requestId
|
||||||
|
app.use((0, requestId_1.createRequestIdMiddleware)(deps.logger));
|
||||||
|
// 2. Security headers
|
||||||
|
app.use((0, helmet_1.default)({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
connectSrc: ["'self'", 'ws:', 'wss:'],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
// 3. CORS
|
||||||
|
app.use((0, cors_1.default)({ origin: deps.config.cors.origin, credentials: true }));
|
||||||
|
// 4. Rate limiting
|
||||||
|
app.use((0, express_rate_limit_1.default)({
|
||||||
|
windowMs: deps.config.api.rateLimitWindowMs,
|
||||||
|
max: deps.config.api.rateLimitMax,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
}));
|
||||||
|
// 5. Body parsing + cookies
|
||||||
|
app.use(express_1.default.json({ limit: '10mb' }));
|
||||||
|
app.use((0, cookie_parser_1.default)());
|
||||||
|
// 6. Health endpoints — no auth required
|
||||||
|
app.get('/health/live', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', uptime: process.uptime() });
|
||||||
|
});
|
||||||
|
app.get('/health/ready', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await deps.db.selectFrom('sessions').select('id').limit(1).execute();
|
||||||
|
res.json({ status: 'ready', db: 'connected' });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(503).json({ status: 'not_ready', db: 'disconnected', error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 7. Module routes
|
||||||
|
app.use('/api', (0, router_1.createRouter)(deps));
|
||||||
|
// 7b. API documentation (no auth required)
|
||||||
|
app.use('/api-docs', (0, openapi_1.createApiDocsRouter)());
|
||||||
|
// 8. 404 handler
|
||||||
|
app.use(notFound_1.notFoundMiddleware);
|
||||||
|
// 9. Global error handler — always last
|
||||||
|
app.use(errorHandler_1.globalErrorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
Vendored
+602
@@ -0,0 +1,602 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* ABE CLI — command-line interface for autonomous bug exploration.
|
||||||
|
*
|
||||||
|
* Commands:
|
||||||
|
* explore Run an exploration session
|
||||||
|
* report Generate a report for a session
|
||||||
|
* status Ping the ABE server and show active sessions
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* abe explore --url http://localhost:3000
|
||||||
|
* abe explore --url http://localhost:3000 --server http://localhost:3001 --api-key <key>
|
||||||
|
* abe report --session <id> --server http://localhost:3001
|
||||||
|
* abe status --server http://localhost:3001
|
||||||
|
*/
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const commander_1 = require("commander");
|
||||||
|
const ExplorationEngine_1 = require("../core/ExplorationEngine");
|
||||||
|
const StateGraph_1 = require("../core/StateGraph");
|
||||||
|
const PlaywrightAgent_1 = require("../plugins/agents/PlaywrightAgent");
|
||||||
|
const ScreenshotCollector_1 = require("../plugins/collectors/ScreenshotCollector");
|
||||||
|
const NetworkCollector_1 = require("../plugins/collectors/NetworkCollector");
|
||||||
|
const DOMSnapshotCollector_1 = require("../plugins/collectors/DOMSnapshotCollector");
|
||||||
|
const MarkdownExporter_1 = require("../plugins/exporters/MarkdownExporter");
|
||||||
|
const JSONExporter_1 = require("../plugins/exporters/JSONExporter");
|
||||||
|
const PlaywrightReproducer_1 = require("../plugins/reproducers/PlaywrightReproducer");
|
||||||
|
const ExplorationConfig_1 = require("../core/ExplorationConfig");
|
||||||
|
const fs = __importStar(require("fs"));
|
||||||
|
const path = __importStar(require("path"));
|
||||||
|
const program = new commander_1.Command();
|
||||||
|
program
|
||||||
|
.name('abe')
|
||||||
|
.description('Autonomous Bug Explorer — explore web apps and find bugs')
|
||||||
|
.version('0.1.0');
|
||||||
|
// ─── explore ────────────────────────────────────────────────────────────────
|
||||||
|
program
|
||||||
|
.command('explore')
|
||||||
|
.description('Run an autonomous exploration session against a target URL')
|
||||||
|
.requiredOption('--url <url>', 'Target URL to explore')
|
||||||
|
.option('--config <file>', 'Path to JSON config file (merged with flags)')
|
||||||
|
.option('--seed <seed>', 'Deterministic seed', parseInt, 42)
|
||||||
|
.option('--max-states <n>', 'Max states to explore', parseInt, 50)
|
||||||
|
.option('--max-depth <n>', 'Max click depth', parseInt, 5)
|
||||||
|
.option('--allowed-domains <domains>', 'Comma-separated allowed domains')
|
||||||
|
.option('--excluded-paths <paths>', 'Comma-separated excluded paths')
|
||||||
|
.option('--action-delay <ms>', 'Delay between actions in ms', parseInt, 500)
|
||||||
|
.option('--session-timeout <ms>', 'Session timeout in ms', parseInt, 300000)
|
||||||
|
// Auth options
|
||||||
|
.option('--auth-type <type>', 'Auth type: cookies | headers | login_flow')
|
||||||
|
.option('--login-url <url>', 'Login page URL (for login_flow)')
|
||||||
|
.option('--username <user>', 'Username (for login_flow)')
|
||||||
|
.option('--password <pass>', 'Password (for login_flow)')
|
||||||
|
.option('--username-selector <sel>', 'Username field selector (for login_flow)')
|
||||||
|
.option('--password-selector <sel>', 'Password field selector (for login_flow)')
|
||||||
|
.option('--submit-selector <sel>', 'Submit button selector (for login_flow)')
|
||||||
|
// Output
|
||||||
|
.option('--output <format>', 'Output format: human | json | junit | markdown', 'human')
|
||||||
|
.option('--reports-dir <dir>', 'Output directory for reports', './reports')
|
||||||
|
// CI flags
|
||||||
|
.option('--fail-on-anomaly', 'Exit 1 if any anomaly found')
|
||||||
|
.option('--fail-on-severity <level>', 'Exit 1 if finding at or above severity (low|medium|high|critical)')
|
||||||
|
// Remote server
|
||||||
|
.option('--server <url>', 'Connect to remote ABE server instead of running inline')
|
||||||
|
.option('--api-key <key>', 'API key for remote server')
|
||||||
|
.action(async (opts) => {
|
||||||
|
// Load config file if provided
|
||||||
|
let fileConfig = {};
|
||||||
|
if (opts['config']) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(opts['config'], 'utf8');
|
||||||
|
fileConfig = JSON.parse(raw);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Failed to read config file: ${err.message}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Merge file config with CLI flags (CLI flags take precedence)
|
||||||
|
const seed = opts['seed'] ?? fileConfig['seed'] ?? 42;
|
||||||
|
const maxStates = opts['maxStates'] ?? fileConfig['maxStates'] ?? 50;
|
||||||
|
const maxDepth = opts['maxDepth'] ?? fileConfig['maxDepth'] ?? 5;
|
||||||
|
const reportsDir = opts['reportsDir'] ?? './reports';
|
||||||
|
// Remote server mode
|
||||||
|
if (opts['server']) {
|
||||||
|
await exploreRemote(opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Inline mode — build auth config
|
||||||
|
let auth = null;
|
||||||
|
if (opts['authType'] === 'login_flow') {
|
||||||
|
auth = {
|
||||||
|
type: 'login_flow',
|
||||||
|
loginUrl: opts['loginUrl'] ?? '',
|
||||||
|
usernameSelector: opts['usernameSelector'] ?? 'input[type="email"]',
|
||||||
|
passwordSelector: opts['passwordSelector'] ?? 'input[type="password"]',
|
||||||
|
submitSelector: opts['submitSelector'] ?? 'button[type="submit"]',
|
||||||
|
username: opts['username'] ?? '',
|
||||||
|
password: opts['password'] ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (opts['authType'] === 'headers') {
|
||||||
|
auth = { type: 'headers', headers: {} };
|
||||||
|
}
|
||||||
|
else if (opts['authType'] === 'cookies') {
|
||||||
|
auth = { type: 'cookies', cookies: [] };
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG,
|
||||||
|
...fileConfig,
|
||||||
|
maxStates,
|
||||||
|
maxDepth,
|
||||||
|
actionDelayMs: opts['actionDelay'] ?? ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG.actionDelayMs,
|
||||||
|
sessionTimeoutMs: opts['sessionTimeout'] ?? ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG.sessionTimeoutMs,
|
||||||
|
allowedDomains: opts['allowedDomains']
|
||||||
|
? opts['allowedDomains'].split(',').map((d) => d.trim())
|
||||||
|
: [new URL(opts['url']).hostname],
|
||||||
|
excludedPaths: opts['excludedPaths']
|
||||||
|
? opts['excludedPaths'].split(',').map((p) => p.trim())
|
||||||
|
: [],
|
||||||
|
auth,
|
||||||
|
};
|
||||||
|
const anomalies = [];
|
||||||
|
const discoveredStates = [];
|
||||||
|
let statesVisited = 0;
|
||||||
|
let exitCode = 0;
|
||||||
|
let explorationError;
|
||||||
|
const startMs = Date.now();
|
||||||
|
try {
|
||||||
|
const graph = new StateGraph_1.StateGraph();
|
||||||
|
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed, explorationConfig: config });
|
||||||
|
const engine = new ExplorationEngine_1.ExplorationEngine({
|
||||||
|
graph,
|
||||||
|
agent,
|
||||||
|
seed,
|
||||||
|
url: opts['url'],
|
||||||
|
maxSteps: maxStates,
|
||||||
|
outputDir: reportsDir,
|
||||||
|
explorationConfig: config,
|
||||||
|
collectors: [
|
||||||
|
new ScreenshotCollector_1.ScreenshotCollector(reportsDir),
|
||||||
|
new NetworkCollector_1.NetworkCollector(),
|
||||||
|
new DOMSnapshotCollector_1.DOMSnapshotCollector(reportsDir),
|
||||||
|
],
|
||||||
|
exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()],
|
||||||
|
reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(),
|
||||||
|
events: {
|
||||||
|
onStateDiscovered: (_sessionId, stateId, stateUrl, title) => {
|
||||||
|
discoveredStates.push({ id: stateId, url: stateUrl, title });
|
||||||
|
},
|
||||||
|
onAnomalyDetected: (_sessionId, anomaly) => {
|
||||||
|
anomalies.push(anomaly);
|
||||||
|
},
|
||||||
|
onSessionCompleted: (_sessionId, visited) => {
|
||||||
|
statesVisited = visited;
|
||||||
|
},
|
||||||
|
onSessionError: (_sessionId, error) => {
|
||||||
|
explorationError = error;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await engine.run();
|
||||||
|
statesVisited = result.statesVisited;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
explorationError = err instanceof Error ? err.message : String(err);
|
||||||
|
exitCode = 2;
|
||||||
|
}
|
||||||
|
if (explorationError && exitCode === 0)
|
||||||
|
exitCode = 2;
|
||||||
|
// Determine exit code from CI flags
|
||||||
|
if (exitCode === 0 && opts['failOnAnomaly'] && anomalies.length > 0) {
|
||||||
|
exitCode = 1;
|
||||||
|
}
|
||||||
|
if (exitCode === 0 && opts['failOnSeverity']) {
|
||||||
|
const severityRank = { low: 0, medium: 1, high: 2, critical: 3 };
|
||||||
|
const threshold = severityRank[opts['failOnSeverity']] ?? 0;
|
||||||
|
const failing = anomalies.filter((a) => (severityRank[a.severity] ?? 0) >= threshold);
|
||||||
|
if (failing.length > 0)
|
||||||
|
exitCode = 1;
|
||||||
|
}
|
||||||
|
const durationMs = Date.now() - startMs;
|
||||||
|
const output = opts['output'];
|
||||||
|
if (output === 'json') {
|
||||||
|
const summary = {
|
||||||
|
url: opts['url'],
|
||||||
|
seed,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
states_visited: statesVisited,
|
||||||
|
findings: anomalies.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
type: a.type,
|
||||||
|
severity: a.severity,
|
||||||
|
description: a.description,
|
||||||
|
report_path: path.join(reportsDir, a.id, 'report.json'),
|
||||||
|
})),
|
||||||
|
exit_code: exitCode,
|
||||||
|
error: explorationError,
|
||||||
|
};
|
||||||
|
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
else if (output === 'junit') {
|
||||||
|
const xml = buildJunit({
|
||||||
|
url: opts['url'],
|
||||||
|
statesVisited,
|
||||||
|
discoveredStates,
|
||||||
|
anomalies,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
const outPath = path.join(process.cwd(), 'abe-results.xml');
|
||||||
|
fs.writeFileSync(outPath, xml, 'utf8');
|
||||||
|
console.log(`JUnit results written to ${outPath}`);
|
||||||
|
}
|
||||||
|
else if (output === 'markdown') {
|
||||||
|
printMarkdownSummary({ url: opts['url'], statesVisited, anomalies, durationMs, explorationError });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// human-readable
|
||||||
|
if (anomalies.length === 0 && !explorationError) {
|
||||||
|
console.log(`✓ ABE finished. No findings. ${statesVisited} states explored. (${durationMs}ms)`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (explorationError) {
|
||||||
|
console.error(`✗ ABE error: ${explorationError}`);
|
||||||
|
}
|
||||||
|
if (anomalies.length > 0) {
|
||||||
|
console.log(`⚠ ABE finished. ${anomalies.length} finding(s) in ${statesVisited} states (${durationMs}ms):`);
|
||||||
|
for (const a of anomalies) {
|
||||||
|
console.log(` [${a.severity.toUpperCase()}] ${a.type}: ${a.description}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(exitCode);
|
||||||
|
});
|
||||||
|
// ─── report ─────────────────────────────────────────────────────────────────
|
||||||
|
program
|
||||||
|
.command('report')
|
||||||
|
.description('Generate a report for a completed exploration session')
|
||||||
|
.requiredOption('--session <id>', 'Session ID to generate report for')
|
||||||
|
.option('--server <url>', 'ABE server URL', 'http://localhost:3001')
|
||||||
|
.option('--api-key <key>', 'API key for authentication')
|
||||||
|
.option('--format <format>', 'Report format: pdf | html | json', 'pdf')
|
||||||
|
.option('--output <file>', 'Output file path (default: ./abe-report-<session>.pdf)')
|
||||||
|
.action(async (opts) => {
|
||||||
|
const server = opts['server'];
|
||||||
|
const sessionId = opts['session'];
|
||||||
|
const apiKey = opts['apiKey'];
|
||||||
|
const format = opts['format'];
|
||||||
|
const outputFile = opts['output'] ?? `./abe-report-${sessionId}.${format}`;
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (apiKey)
|
||||||
|
headers['x-abe-api-key'] = apiKey;
|
||||||
|
console.log(`Generating ${format} report for session ${sessionId}...`);
|
||||||
|
try {
|
||||||
|
// Request report generation
|
||||||
|
const genRes = await fetch(`${server}/api/reports`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ sessionId, format }),
|
||||||
|
});
|
||||||
|
if (!genRes.ok) {
|
||||||
|
console.error(`Error generating report: ${genRes.status} ${await genRes.text()}`);
|
||||||
|
process.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const report = await genRes.json();
|
||||||
|
console.log(`Report queued: ${report.id}`);
|
||||||
|
// Poll until ready
|
||||||
|
let ready = false;
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 30;
|
||||||
|
while (!ready && attempts < maxAttempts) {
|
||||||
|
await sleep(2000);
|
||||||
|
attempts++;
|
||||||
|
const statusRes = await fetch(`${server}/api/reports/${report.id}`, { headers });
|
||||||
|
if (!statusRes.ok)
|
||||||
|
break;
|
||||||
|
const status = await statusRes.json();
|
||||||
|
if (status.status === 'completed') {
|
||||||
|
ready = true;
|
||||||
|
}
|
||||||
|
else if (status.status === 'failed') {
|
||||||
|
console.error('Report generation failed');
|
||||||
|
process.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.stdout.write('.');
|
||||||
|
}
|
||||||
|
if (!ready) {
|
||||||
|
console.error('\nTimeout waiting for report');
|
||||||
|
process.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('\nDownloading...');
|
||||||
|
// Download the report
|
||||||
|
const dlRes = await fetch(`${server}/api/reports/${report.id}/download`, { headers });
|
||||||
|
if (!dlRes.ok) {
|
||||||
|
console.error(`Download failed: ${dlRes.status}`);
|
||||||
|
process.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await dlRes.arrayBuffer());
|
||||||
|
fs.writeFileSync(outputFile, buffer);
|
||||||
|
console.log(`Report saved to ${outputFile}`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Error: ${err.message}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ─── status ─────────────────────────────────────────────────────────────────
|
||||||
|
program
|
||||||
|
.command('status')
|
||||||
|
.description('Ping the ABE server and show active sessions')
|
||||||
|
.option('--server <url>', 'ABE server URL', 'http://localhost:3001')
|
||||||
|
.option('--api-key <key>', 'API key for authentication')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (opts) => {
|
||||||
|
const server = opts['server'];
|
||||||
|
const apiKey = opts['apiKey'];
|
||||||
|
const asJson = opts['json'];
|
||||||
|
const headers = {};
|
||||||
|
if (apiKey)
|
||||||
|
headers['x-abe-api-key'] = apiKey;
|
||||||
|
try {
|
||||||
|
// Health check
|
||||||
|
const healthRes = await fetch(`${server}/health/ready`, { headers });
|
||||||
|
const healthy = healthRes.ok;
|
||||||
|
if (!healthy) {
|
||||||
|
if (asJson) {
|
||||||
|
console.log(JSON.stringify({ status: 'down', server }));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.error(`✗ Server at ${server} is not ready (${healthRes.status})`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fetch active sessions
|
||||||
|
const sessionsRes = await fetch(`${server}/api/sessions`, { headers });
|
||||||
|
const sessions = sessionsRes.ok
|
||||||
|
? await sessionsRes.json()
|
||||||
|
: [];
|
||||||
|
const active = sessions.filter((s) => s.status === 'running');
|
||||||
|
if (asJson) {
|
||||||
|
console.log(JSON.stringify({ status: 'up', server, activeSessions: active.length, sessions: active }));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.log(`✓ ABE server is ready at ${server}`);
|
||||||
|
if (active.length === 0) {
|
||||||
|
console.log(' No active sessions');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.log(` ${active.length} active session(s):`);
|
||||||
|
for (const s of active) {
|
||||||
|
console.log(` [${s.id}] ${s.url} — ${s.statesVisited} states explored`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (asJson) {
|
||||||
|
console.log(JSON.stringify({ status: 'down', server, error: err.message }));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.error(`✗ Cannot reach ABE server at ${server}: ${err.message}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
async function exploreRemote(opts) {
|
||||||
|
const serverUrl = opts['server'];
|
||||||
|
const apiKey = opts['apiKey'];
|
||||||
|
const url = opts['url'];
|
||||||
|
const failOnSeverity = opts['failOnSeverity'];
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (apiKey)
|
||||||
|
headers['x-abe-api-key'] = apiKey;
|
||||||
|
console.log(`Starting remote exploration of ${url} via ${serverUrl}...`);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${serverUrl}/api/sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
seed: opts['seed'],
|
||||||
|
maxStates: opts['maxStates'],
|
||||||
|
maxDepth: opts['maxDepth'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`Server error: ${res.status} ${await res.text()}`);
|
||||||
|
process.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = await res.json();
|
||||||
|
const sessionId = session.sessionId ?? session.id ?? '';
|
||||||
|
console.log(`Session started: ${sessionId}`);
|
||||||
|
// Poll for completion
|
||||||
|
let done = false;
|
||||||
|
let anomalyCount = 0;
|
||||||
|
while (!done) {
|
||||||
|
await sleep(3000);
|
||||||
|
const statusRes = await fetch(`${serverUrl}/api/sessions/${sessionId}`, { headers });
|
||||||
|
if (!statusRes.ok)
|
||||||
|
break;
|
||||||
|
const status = await statusRes.json();
|
||||||
|
if (status.status === 'completed' || status.status === 'failed' || status.status === 'stopped') {
|
||||||
|
done = true;
|
||||||
|
anomalyCount = status.findingsCount ?? 0;
|
||||||
|
console.log(`Session ${status.status}. States: ${status.statesVisited ?? 0}, Findings: ${anomalyCount}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
process.stdout.write('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let exitCode = 0;
|
||||||
|
if (opts['failOnAnomaly'] && anomalyCount > 0)
|
||||||
|
exitCode = 1;
|
||||||
|
if (failOnSeverity) {
|
||||||
|
// We can't filter by severity without fetching findings — conservative: exit 1 if any
|
||||||
|
if (anomalyCount > 0)
|
||||||
|
exitCode = 1;
|
||||||
|
}
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Error: ${err.message}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function buildJunit(input) {
|
||||||
|
const { url, statesVisited, discoveredStates, anomalies, durationMs } = input;
|
||||||
|
// One passing test case per discovered state (states without findings pass)
|
||||||
|
const stateCases = discoveredStates.map((s) => ` <testcase name="${escapeXml(s.title || s.url)}" classname="abe.state.${escapeXml(s.id)}" />`);
|
||||||
|
// One failing test case per anomaly
|
||||||
|
const anomalyCases = anomalies.map((a) => ` <testcase name="${escapeXml(a.description)}" classname="abe.anomaly.${escapeXml(a.type)}">\n` +
|
||||||
|
` <failure message="${escapeXml(a.description)}" type="${escapeXml(a.severity)}">${escapeXml(a.id)}</failure>\n` +
|
||||||
|
` </testcase>`);
|
||||||
|
const totalTests = Math.max(statesVisited, discoveredStates.length) + anomalies.length;
|
||||||
|
const totalFailures = anomalies.length;
|
||||||
|
const durationSec = (durationMs / 1000).toFixed(3);
|
||||||
|
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||||
|
`<testsuite name="ABE: ${escapeXml(url)}" tests="${totalTests}" failures="${totalFailures}" time="${durationSec}">\n` +
|
||||||
|
[...stateCases, ...anomalyCases].join('\n') +
|
||||||
|
'\n</testsuite>\n');
|
||||||
|
}
|
||||||
|
function printMarkdownSummary(input) {
|
||||||
|
const { url, statesVisited, anomalies, durationMs, explorationError } = input;
|
||||||
|
const lines = [
|
||||||
|
`# ABE Exploration Report`,
|
||||||
|
``,
|
||||||
|
`**Target:** ${url}`,
|
||||||
|
`**States explored:** ${statesVisited}`,
|
||||||
|
`**Duration:** ${(durationMs / 1000).toFixed(1)}s`,
|
||||||
|
`**Findings:** ${anomalies.length}`,
|
||||||
|
``,
|
||||||
|
];
|
||||||
|
if (explorationError) {
|
||||||
|
lines.push(`> ⚠ **Error:** ${explorationError}`, ``);
|
||||||
|
}
|
||||||
|
if (anomalies.length === 0) {
|
||||||
|
lines.push(`✅ No findings detected.`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push(`## Findings`, ``);
|
||||||
|
for (const a of anomalies) {
|
||||||
|
lines.push(`### [${a.severity.toUpperCase()}] ${a.type}`, ``, `**ID:** ${a.id}`, `**Description:** ${a.description}`, ``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(lines.join('\n'));
|
||||||
|
}
|
||||||
|
function escapeXml(s) {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
program.parse(process.argv);
|
||||||
|
// ─── backup ─────────────────────────────────────────────────────────────────
|
||||||
|
program
|
||||||
|
.command('backup')
|
||||||
|
.description('Backup ABE database to a file')
|
||||||
|
.option('--db <path>', 'Path to ABE database', './data/abe.db')
|
||||||
|
.option('--output <file>', 'Backup file path', `./abe-backup-${new Date().toISOString().slice(0, 10)}.db`)
|
||||||
|
.action((opts) => {
|
||||||
|
const src = opts.db;
|
||||||
|
const dest = opts.output;
|
||||||
|
if (!fs.existsSync(src)) {
|
||||||
|
console.error(`Database not found: ${src}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
const size = fs.statSync(dest).size;
|
||||||
|
console.log(`✅ Backup created: ${dest} (${Math.round(size / 1024)} KB)`);
|
||||||
|
});
|
||||||
|
// ─── restore ────────────────────────────────────────────────────────────────
|
||||||
|
program
|
||||||
|
.command('restore')
|
||||||
|
.description('Restore ABE database from a backup file')
|
||||||
|
.requiredOption('--from <file>', 'Backup file to restore from')
|
||||||
|
.option('--db <path>', 'Path to ABE database', './data/abe.db')
|
||||||
|
.option('--confirm', 'Skip confirmation prompt')
|
||||||
|
.action((opts) => {
|
||||||
|
if (!fs.existsSync(opts.from)) {
|
||||||
|
console.error(`Backup file not found: ${opts.from}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (!opts.confirm) {
|
||||||
|
console.warn(`⚠️ This will overwrite the database at: ${opts.db}`);
|
||||||
|
console.warn(`Run with --confirm to proceed.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const dir = path.dirname(opts.db);
|
||||||
|
if (!fs.existsSync(dir))
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.copyFileSync(opts.from, opts.db);
|
||||||
|
const size = fs.statSync(opts.db).size;
|
||||||
|
console.log(`✅ Database restored from: ${opts.from} (${Math.round(size / 1024)} KB)`);
|
||||||
|
});
|
||||||
|
// ─── retention ──────────────────────────────────────────────────────────────
|
||||||
|
program
|
||||||
|
.command('retention')
|
||||||
|
.description('Run data retention cleanup (enterprise feature)')
|
||||||
|
.option('--db <path>', 'Path to ABE database', './data/abe.db')
|
||||||
|
.option('--findings-days <n>', 'Delete findings older than N days', parseInt, 365)
|
||||||
|
.option('--sessions-days <n>', 'Delete sessions older than N days', parseInt, 90)
|
||||||
|
.option('--audit-days <n>', 'Delete audit logs older than N days', parseInt, 365)
|
||||||
|
.option('--jobs-days <n>', 'Delete completed jobs older than N days', parseInt, 30)
|
||||||
|
.option('--dry-run', 'Show what would be deleted without deleting')
|
||||||
|
.action(async (opts) => {
|
||||||
|
if (!fs.existsSync(opts.db)) {
|
||||||
|
console.error(`Database not found: ${opts.db}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (opts.dryRun) {
|
||||||
|
console.log('🔍 Dry run mode — nothing will be deleted');
|
||||||
|
console.log(` Findings older than ${opts.findingsDays} days`);
|
||||||
|
console.log(` Sessions older than ${opts.sessionsDays} days`);
|
||||||
|
console.log(` Audit logs older than ${opts.auditDays} days`);
|
||||||
|
console.log(` Jobs older than ${opts.jobsDays} days`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Dynamically import to avoid loading DB in non-DB commands
|
||||||
|
const { Kysely, SqliteDialect } = await Promise.resolve().then(() => __importStar(require('kysely')));
|
||||||
|
const SQLite = (await Promise.resolve().then(() => __importStar(require('better-sqlite3')))).default;
|
||||||
|
const { DataRetentionService } = await Promise.resolve().then(() => __importStar(require('../modules/scheduling/infrastructure/DataRetentionService')));
|
||||||
|
const pino = (await Promise.resolve().then(() => __importStar(require('pino')))).default;
|
||||||
|
const logger = pino({ level: 'info' });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const db = new Kysely({ dialect: new SqliteDialect({ database: new SQLite(opts.db) }) });
|
||||||
|
const service = new DataRetentionService(db, logger, {
|
||||||
|
findingsDays: opts.findingsDays,
|
||||||
|
sessionsDays: opts.sessionsDays,
|
||||||
|
auditLogsDays: opts.auditDays,
|
||||||
|
jobsDays: opts.jobsDays,
|
||||||
|
});
|
||||||
|
const results = await service.runRetention();
|
||||||
|
await db.destroy();
|
||||||
|
console.log('✅ Data retention completed:');
|
||||||
|
for (const [key, count] of Object.entries(results)) {
|
||||||
|
console.log(` ${key}: ${count} rows deleted`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Vendored
+36
@@ -0,0 +1,36 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.up = up;
|
||||||
|
exports.down = down;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function up(db) {
|
||||||
|
await db.schema.createTable('jobs')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', col => col.primaryKey())
|
||||||
|
.addColumn('type', 'text', col => col.notNull())
|
||||||
|
.addColumn('status', 'text', col => col.notNull().defaultTo('pending'))
|
||||||
|
.addColumn('payload', 'text', col => col.notNull())
|
||||||
|
.addColumn('result', 'text')
|
||||||
|
.addColumn('error', 'text')
|
||||||
|
.addColumn('attempts', 'integer', col => col.notNull().defaultTo(0))
|
||||||
|
.addColumn('max_attempts', 'integer', col => col.notNull().defaultTo(3))
|
||||||
|
.addColumn('priority', 'integer', col => col.notNull().defaultTo(0))
|
||||||
|
.addColumn('run_at', 'text', col => col.notNull())
|
||||||
|
.addColumn('started_at', 'text')
|
||||||
|
.addColumn('completed_at', 'text')
|
||||||
|
.addColumn('created_at', 'text', col => col.notNull())
|
||||||
|
.addColumn('updated_at', 'text', col => col.notNull())
|
||||||
|
.execute();
|
||||||
|
// Index for efficient polling
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_jobs_poll')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('jobs')
|
||||||
|
.columns(['status', 'run_at', 'priority'])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function down(db) {
|
||||||
|
await db.schema.dropIndex('idx_jobs_poll').ifExists().execute();
|
||||||
|
await db.schema.dropTable('jobs').ifExists().execute();
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.up = up;
|
||||||
|
exports.down = down;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function up(db) {
|
||||||
|
await db.schema
|
||||||
|
.createTable('users')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('email', 'text', (col) => col.notNull().unique())
|
||||||
|
.addColumn('name', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('password_hash', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
|
||||||
|
.addColumn('org_id', 'text')
|
||||||
|
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||||
|
.addColumn('updated_at', 'integer', (col) => col.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createTable('organizations')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('name', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('slug', 'text', (col) => col.notNull().unique())
|
||||||
|
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createTable('org_members')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('org_id', 'text', (col) => col.notNull().references('organizations.id'))
|
||||||
|
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
|
||||||
|
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
|
||||||
|
.addColumn('joined_at', 'integer', (col) => col.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createTable('api_keys')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
|
||||||
|
.addColumn('org_id', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('name', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('key_hash', 'text', (col) => col.notNull().unique())
|
||||||
|
.addColumn('key_prefix', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('permissions', 'text', (col) => col.notNull().defaultTo('["member"]'))
|
||||||
|
.addColumn('expires_at', 'integer')
|
||||||
|
.addColumn('last_used_at', 'integer')
|
||||||
|
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createTable('auth_sessions')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
|
||||||
|
.addColumn('token', 'text', (col) => col.notNull().unique())
|
||||||
|
.addColumn('expires_at', 'integer', (col) => col.notNull())
|
||||||
|
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_auth_sessions_token')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('auth_sessions')
|
||||||
|
.columns(['token'])
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_users_email')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('users')
|
||||||
|
.columns(['email'])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function down(db) {
|
||||||
|
await db.schema.dropIndex('idx_users_email').ifExists().execute();
|
||||||
|
await db.schema.dropIndex('idx_auth_sessions_token').ifExists().execute();
|
||||||
|
await db.schema.dropTable('auth_sessions').ifExists().execute();
|
||||||
|
await db.schema.dropTable('api_keys').ifExists().execute();
|
||||||
|
await db.schema.dropTable('org_members').ifExists().execute();
|
||||||
|
await db.schema.dropTable('organizations').ifExists().execute();
|
||||||
|
await db.schema.dropTable('users').ifExists().execute();
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.up = up;
|
||||||
|
exports.down = down;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function up(db) {
|
||||||
|
await db.schema
|
||||||
|
.createTable('reports')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('title', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('format', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
|
||||||
|
.addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}'))
|
||||||
|
.addColumn('file_path', 'text')
|
||||||
|
.addColumn('error_message', 'text')
|
||||||
|
.addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0))
|
||||||
|
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||||
|
.addColumn('completed_at', 'integer')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function down(db) {
|
||||||
|
await db.schema.dropTable('reports').ifExists().execute();
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.up = up;
|
||||||
|
exports.down = down;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function up(db) {
|
||||||
|
await db.schema
|
||||||
|
.createTable('integrations')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('name', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('type', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('enabled', 'integer', (col) => col.notNull().defaultTo(1))
|
||||||
|
.addColumn('config_json', 'text', (col) => col.notNull().defaultTo('{}'))
|
||||||
|
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createTable('webhook_endpoints')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('url', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('secret', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('enabled', 'integer', (col) => col.notNull().defaultTo(1))
|
||||||
|
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||||
|
.addColumn('last_delivered_at', 'integer')
|
||||||
|
.addColumn('last_status', 'integer')
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createTable('webhook_deliveries')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||||
|
.addColumn('endpoint_id', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('event', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('payload_json', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('status', 'integer', (col) => col.notNull())
|
||||||
|
.addColumn('attempted_at', 'integer', (col) => col.notNull())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function down(db) {
|
||||||
|
await db.schema.dropTable('webhook_deliveries').ifExists().execute();
|
||||||
|
await db.schema.dropTable('webhook_endpoints').ifExists().execute();
|
||||||
|
await db.schema.dropTable('integrations').ifExists().execute();
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.up = up;
|
||||||
|
exports.down = down;
|
||||||
|
const kysely_1 = require("kysely");
|
||||||
|
async function up(db) {
|
||||||
|
// SSO configurations per organization
|
||||||
|
await db.schema
|
||||||
|
.createTable('sso_configs')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('organization_id', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('provider', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('enabled', 'integer', (c) => c.notNull().defaultTo(1))
|
||||||
|
.addColumn('config_json', 'text', (c) => c.notNull().defaultTo('{}'))
|
||||||
|
.addColumn('created_at', 'integer', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
// TOTP secrets for MFA
|
||||||
|
await db.schema
|
||||||
|
.createTable('totp_secrets')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('user_id', 'text', (c) => c.notNull().unique())
|
||||||
|
.addColumn('secret', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('verified', 'integer', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('created_at', 'integer', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
// Audit logs
|
||||||
|
await db.schema
|
||||||
|
.createTable('audit_logs')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('user_id', 'text')
|
||||||
|
.addColumn('organization_id', 'text')
|
||||||
|
.addColumn('action', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('resource', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('resource_id', 'text')
|
||||||
|
.addColumn('ip_address', 'text')
|
||||||
|
.addColumn('user_agent', 'text')
|
||||||
|
.addColumn('details_json', 'text', (c) => c.notNull().defaultTo('{}'))
|
||||||
|
.addColumn('occurred_at', 'integer', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
await (0, kysely_1.sql) `CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs (user_id)`.execute(db);
|
||||||
|
await (0, kysely_1.sql) `CREATE INDEX IF NOT EXISTS idx_audit_logs_occurred ON audit_logs (occurred_at)`.execute(db);
|
||||||
|
}
|
||||||
|
async function down(db) {
|
||||||
|
await db.schema.dropTable('audit_logs').ifExists().execute();
|
||||||
|
await db.schema.dropTable('totp_secrets').ifExists().execute();
|
||||||
|
await db.schema.dropTable('sso_configs').ifExists().execute();
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.up = up;
|
||||||
|
exports.down = down;
|
||||||
|
async function up(db) {
|
||||||
|
await db.schema
|
||||||
|
.createTable('branding_config')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('organization_id', 'text', (c) => c.notNull().unique())
|
||||||
|
.addColumn('app_name', 'text')
|
||||||
|
.addColumn('primary_color', 'text')
|
||||||
|
.addColumn('logo_url', 'text')
|
||||||
|
.addColumn('favicon_url', 'text')
|
||||||
|
.addColumn('custom_css', 'text')
|
||||||
|
.addColumn('updated_at', 'integer', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
async function down(db) {
|
||||||
|
await db.schema.dropTable('branding_config').ifExists().execute();
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
Vendored
+172
@@ -0,0 +1,172 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.SQLiteJobQueue = void 0;
|
||||||
|
/**
|
||||||
|
* SQLiteJobQueue — SQLite-backed job queue with exponential backoff retry.
|
||||||
|
* Zero external dependencies: uses Kysely + better-sqlite3.
|
||||||
|
*/
|
||||||
|
const kysely_1 = require("kysely");
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class SQLiteJobQueue {
|
||||||
|
constructor(db, logger, pollIntervalMs = 1000) {
|
||||||
|
this.db = db;
|
||||||
|
this.logger = logger;
|
||||||
|
this.pollIntervalMs = pollIntervalMs;
|
||||||
|
this.running = false;
|
||||||
|
this.activeJobs = 0;
|
||||||
|
this.pollTimer = null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.handlers = new Map();
|
||||||
|
}
|
||||||
|
registerHandler(type, handler) {
|
||||||
|
this.handlers.set(type, handler);
|
||||||
|
}
|
||||||
|
async enqueue(type, payload, opts) {
|
||||||
|
const id = (0, crypto_1.randomUUID)();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const runAt = (opts?.runAt ?? new Date()).toISOString();
|
||||||
|
await this.db
|
||||||
|
.insertInto('jobs')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
status: 'pending',
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
result: null,
|
||||||
|
error: null,
|
||||||
|
attempts: 0,
|
||||||
|
max_attempts: opts?.maxAttempts ?? 3,
|
||||||
|
priority: opts?.priority ?? 0,
|
||||||
|
run_at: runAt,
|
||||||
|
started_at: null,
|
||||||
|
completed_at: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
this.logger.debug({ jobId: id, type, runAt }, 'Job enqueued');
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
start() {
|
||||||
|
if (this.running)
|
||||||
|
return;
|
||||||
|
this.running = true;
|
||||||
|
this.logger.info('Job queue started');
|
||||||
|
this.scheduleNextPoll();
|
||||||
|
}
|
||||||
|
pause() {
|
||||||
|
this.running = false;
|
||||||
|
if (this.pollTimer !== null) {
|
||||||
|
clearTimeout(this.pollTimer);
|
||||||
|
this.pollTimer = null;
|
||||||
|
}
|
||||||
|
this.logger.info('Job queue paused');
|
||||||
|
}
|
||||||
|
async waitForActive(timeoutMs) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (this.activeJobs > 0 && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleNextPoll() {
|
||||||
|
if (!this.running)
|
||||||
|
return;
|
||||||
|
this.pollTimer = setTimeout(() => {
|
||||||
|
this.pollOnce()
|
||||||
|
.catch((err) => {
|
||||||
|
this.logger.error({ err }, 'Job queue poll error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.scheduleNextPoll();
|
||||||
|
});
|
||||||
|
}, this.pollIntervalMs);
|
||||||
|
}
|
||||||
|
async pollOnce() {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
// Find one pending job that is due
|
||||||
|
const row = await this.db
|
||||||
|
.selectFrom('jobs')
|
||||||
|
.selectAll()
|
||||||
|
.where('status', '=', 'pending')
|
||||||
|
.where('run_at', '<=', now)
|
||||||
|
.orderBy('priority', 'desc')
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!row)
|
||||||
|
return;
|
||||||
|
// Optimistic lock: claim the job atomically
|
||||||
|
const claimTime = new Date().toISOString();
|
||||||
|
const updateResult = await this.db
|
||||||
|
.updateTable('jobs')
|
||||||
|
.set({
|
||||||
|
status: 'running',
|
||||||
|
started_at: claimTime,
|
||||||
|
attempts: (0, kysely_1.sql) `attempts + 1`,
|
||||||
|
updated_at: claimTime,
|
||||||
|
})
|
||||||
|
.where('id', '=', row.id)
|
||||||
|
.where('status', '=', 'pending')
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!updateResult || Number(updateResult.numUpdatedRows) === 0) {
|
||||||
|
return; // Another worker claimed this job
|
||||||
|
}
|
||||||
|
this.activeJobs++;
|
||||||
|
this.logger.info({ jobId: row.id, type: row.type, attempt: row.attempts + 1 }, 'Job started');
|
||||||
|
try {
|
||||||
|
const handler = this.handlers.get(row.type);
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error(`No handler registered for job type: ${row.type}`);
|
||||||
|
}
|
||||||
|
const payload = JSON.parse(row.payload);
|
||||||
|
const result = await handler(payload);
|
||||||
|
const completedAt = new Date().toISOString();
|
||||||
|
await this.db
|
||||||
|
.updateTable('jobs')
|
||||||
|
.set({
|
||||||
|
status: 'completed',
|
||||||
|
result: JSON.stringify(result),
|
||||||
|
completed_at: completedAt,
|
||||||
|
updated_at: completedAt,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
.where('id', '=', row.id)
|
||||||
|
.execute();
|
||||||
|
this.logger.info({ jobId: row.id, type: row.type }, 'Job completed');
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const failedAt = new Date().toISOString();
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
// Fetch current attempts count (was incremented above)
|
||||||
|
const current = await this.db
|
||||||
|
.selectFrom('jobs')
|
||||||
|
.select(['attempts', 'max_attempts'])
|
||||||
|
.where('id', '=', row.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
const attempts = current?.attempts ?? row.attempts + 1;
|
||||||
|
const maxAttempts = current?.max_attempts ?? row.max_attempts;
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
await this.db
|
||||||
|
.updateTable('jobs')
|
||||||
|
.set({ status: 'failed', error: errorMsg, updated_at: failedAt })
|
||||||
|
.where('id', '=', row.id)
|
||||||
|
.execute();
|
||||||
|
this.logger.error({ jobId: row.id, type: row.type, attempts, err }, 'Job failed permanently');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const backoffMs = Math.min(1000 * Math.pow(2, attempts), 60000);
|
||||||
|
const retryAt = new Date(Date.now() + backoffMs).toISOString();
|
||||||
|
await this.db
|
||||||
|
.updateTable('jobs')
|
||||||
|
.set({ status: 'pending', run_at: retryAt, error: errorMsg, updated_at: failedAt })
|
||||||
|
.where('id', '=', row.id)
|
||||||
|
.execute();
|
||||||
|
this.logger.warn({ jobId: row.id, type: row.type, attempts, backoffMs }, 'Job failed, will retry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
this.activeJobs--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.SQLiteJobQueue = SQLiteJobQueue;
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.EXPLORATION_JOB_TYPE = void 0;
|
||||||
|
exports.createExplorationJobHandler = createExplorationJobHandler;
|
||||||
|
const UniqueId_1 = require("../../shared/domain/UniqueId");
|
||||||
|
exports.EXPLORATION_JOB_TYPE = 'exploration:run';
|
||||||
|
function createExplorationJobHandler(deps) {
|
||||||
|
return async (payload) => {
|
||||||
|
const { sessionId, url, seed, maxStates } = payload;
|
||||||
|
const log = deps.logger.child({ jobType: exports.EXPLORATION_JOB_TYPE, sessionId });
|
||||||
|
log.info({ url, seed, maxStates }, 'Exploration job executing');
|
||||||
|
const id = UniqueId_1.UniqueId.from(sessionId);
|
||||||
|
const session = await deps.sessionRepo.findById(id);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session not found: ${sessionId}`);
|
||||||
|
}
|
||||||
|
// In this phase the actual Playwright crawl is handled by the ExplorationOrchestrator
|
||||||
|
// which is wired separately. Here we mark the session as running and publish an event.
|
||||||
|
// Full end-to-end crawling is integrated in Phase 4's infrastructure layer.
|
||||||
|
log.info({ statesVisited: session.statesVisited }, 'Exploration job complete (orchestration delegated)');
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
statesVisited: session.statesVisited,
|
||||||
|
anomaliesFound: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+50
@@ -0,0 +1,50 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.REPORT_JOB_TYPE = void 0;
|
||||||
|
exports.createReportJobHandler = createReportJobHandler;
|
||||||
|
const HTMLReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/HTMLReportGenerator");
|
||||||
|
const JSONReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/JSONReportGenerator");
|
||||||
|
const PDFReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/PDFReportGenerator");
|
||||||
|
exports.REPORT_JOB_TYPE = 'report:generate';
|
||||||
|
function createReportJobHandler(deps) {
|
||||||
|
const htmlGen = new HTMLReportGenerator_1.HTMLReportGenerator();
|
||||||
|
const jsonGen = new JSONReportGenerator_1.JSONReportGenerator();
|
||||||
|
const pdfGen = new PDFReportGenerator_1.PDFReportGenerator();
|
||||||
|
return async (payload) => {
|
||||||
|
const log = deps.logger.child({ jobType: exports.REPORT_JOB_TYPE, reportId: payload.reportId });
|
||||||
|
log.info({ format: payload.format }, 'Report generation job executing');
|
||||||
|
const report = await deps.reportRepository.findById(payload.reportId);
|
||||||
|
if (!report) {
|
||||||
|
throw new Error(`Report not found: ${payload.reportId}`);
|
||||||
|
}
|
||||||
|
report.markGenerating();
|
||||||
|
await deps.reportRepository.update(report);
|
||||||
|
// Load findings with filters from report
|
||||||
|
const findings = await deps.findingRepository.findAll({
|
||||||
|
sessionId: report.filters.sessionId,
|
||||||
|
severity: report.filters.severity,
|
||||||
|
});
|
||||||
|
let filePath;
|
||||||
|
try {
|
||||||
|
if (payload.format === 'pdf') {
|
||||||
|
filePath = await pdfGen.generate(report, findings);
|
||||||
|
}
|
||||||
|
else if (payload.format === 'json') {
|
||||||
|
filePath = await jsonGen.generate(report, findings);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
filePath = await htmlGen.generate(report, findings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
report.markFailed(msg);
|
||||||
|
await deps.reportRepository.update(report);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
report.markReady(filePath, findings.length);
|
||||||
|
await deps.reportRepository.update(report);
|
||||||
|
log.info({ filePath, totalFindings: findings.length }, 'Report job complete');
|
||||||
|
return { reportId: payload.reportId, filePath };
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+278
@@ -0,0 +1,278 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
/**
|
||||||
|
* ABE — composition root.
|
||||||
|
* Wires all modules together and starts the HTTP + WebSocket server.
|
||||||
|
*/
|
||||||
|
const http_1 = __importDefault(require("http"));
|
||||||
|
const socket_io_1 = require("socket.io");
|
||||||
|
const Config_1 = require("./shared/infrastructure/Config");
|
||||||
|
const Logger_1 = require("./shared/infrastructure/Logger");
|
||||||
|
const DatabaseConnection_1 = require("./shared/infrastructure/DatabaseConnection");
|
||||||
|
const InProcessEventBus_1 = require("./shared/infrastructure/InProcessEventBus");
|
||||||
|
const migrator_1 = require("./db/migrator");
|
||||||
|
// Crawling module
|
||||||
|
const KyselyCrawlSessionRepository_1 = require("./modules/crawling/infrastructure/repositories/KyselyCrawlSessionRepository");
|
||||||
|
const KyselyStateRepository_1 = require("./modules/crawling/infrastructure/repositories/KyselyStateRepository");
|
||||||
|
const StartCrawlCommand_1 = require("./modules/crawling/application/commands/StartCrawlCommand");
|
||||||
|
const StopCrawlCommand_1 = require("./modules/crawling/application/commands/StopCrawlCommand");
|
||||||
|
const GetSessionQuery_1 = require("./modules/crawling/application/queries/GetSessionQuery");
|
||||||
|
const ListSessionsQuery_1 = require("./modules/crawling/application/queries/ListSessionsQuery");
|
||||||
|
// Findings module
|
||||||
|
const KyselyFindingRepository_1 = require("./modules/findings/infrastructure/repositories/KyselyFindingRepository");
|
||||||
|
const CreateFindingCommand_1 = require("./modules/findings/application/commands/CreateFindingCommand");
|
||||||
|
const EnrichFindingCommand_1 = require("./modules/findings/application/commands/EnrichFindingCommand");
|
||||||
|
const ResolveFindingCommand_1 = require("./modules/findings/application/commands/ResolveFindingCommand");
|
||||||
|
const GetFindingQuery_1 = require("./modules/findings/application/queries/GetFindingQuery");
|
||||||
|
const ListFindingsQuery_1 = require("./modules/findings/application/queries/ListFindingsQuery");
|
||||||
|
const FindingStatsQuery_1 = require("./modules/findings/application/queries/FindingStatsQuery");
|
||||||
|
const OnAnomalyDetected_1 = require("./modules/findings/application/event-handlers/OnAnomalyDetected");
|
||||||
|
const NullAIEnricher_1 = require("./modules/findings/infrastructure/NullAIEnricher");
|
||||||
|
// Fuzzing module
|
||||||
|
const FuzzingEngineAdapter_1 = require("./modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter");
|
||||||
|
const RunFuzzCommand_1 = require("./modules/fuzzing/application/commands/RunFuzzCommand");
|
||||||
|
const OnActionExecuted_1 = require("./modules/fuzzing/application/event-handlers/OnActionExecuted");
|
||||||
|
const InMemoryFuzzSessionRepository_1 = require("./modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository");
|
||||||
|
// Auth module
|
||||||
|
const KyselyUserRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyUserRepository");
|
||||||
|
const KyselyOrganizationRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyOrganizationRepository");
|
||||||
|
const KyselyApiKeyRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyApiKeyRepository");
|
||||||
|
const KyselySessionRepository_1 = require("./modules/auth/infrastructure/repositories/KyselySessionRepository");
|
||||||
|
const RegisterCommand_1 = require("./modules/auth/application/commands/RegisterCommand");
|
||||||
|
const LoginCommand_1 = require("./modules/auth/application/commands/LoginCommand");
|
||||||
|
const CreateOrganizationCommand_1 = require("./modules/auth/application/commands/CreateOrganizationCommand");
|
||||||
|
const InviteMemberCommand_1 = require("./modules/auth/application/commands/InviteMemberCommand");
|
||||||
|
const CreateApiKeyCommand_1 = require("./modules/auth/application/commands/CreateApiKeyCommand");
|
||||||
|
const GetUserQuery_1 = require("./modules/auth/application/queries/GetUserQuery");
|
||||||
|
const ListOrgMembersQuery_1 = require("./modules/auth/application/queries/ListOrgMembersQuery");
|
||||||
|
const PasswordService_1 = require("./modules/auth/infrastructure/auth/PasswordService");
|
||||||
|
// Reporting module
|
||||||
|
const KyselyReportRepository_1 = require("./modules/reporting/infrastructure/repositories/KyselyReportRepository");
|
||||||
|
const GenerateReportCommand_1 = require("./modules/reporting/application/commands/GenerateReportCommand");
|
||||||
|
// Integrations module
|
||||||
|
const KyselyIntegrationRepository_1 = require("./modules/integrations/infrastructure/repositories/KyselyIntegrationRepository");
|
||||||
|
const KyselyWebhookEndpointRepository_1 = require("./modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository");
|
||||||
|
const WebhookDispatcher_1 = require("./modules/integrations/infrastructure/webhooks/WebhookDispatcher");
|
||||||
|
const OnFindingCreated_1 = require("./modules/integrations/application/event-handlers/OnFindingCreated");
|
||||||
|
// Licensing module
|
||||||
|
const RSALicenseValidator_1 = require("./modules/licensing/infrastructure/validators/RSALicenseValidator");
|
||||||
|
const LicenseService_1 = require("./modules/licensing/application/LicenseService");
|
||||||
|
// Visual regression module
|
||||||
|
const KyselyVisualRepository_1 = require("./modules/visual-regression/infrastructure/repositories/KyselyVisualRepository");
|
||||||
|
const VisualRegressionAdapter_1 = require("./modules/visual-regression/infrastructure/adapters/VisualRegressionAdapter");
|
||||||
|
const ApproveBaselineCommand_1 = require("./modules/visual-regression/application/commands/ApproveBaselineCommand");
|
||||||
|
const RejectComparisonCommand_1 = require("./modules/visual-regression/application/commands/RejectComparisonCommand");
|
||||||
|
const ApproveAllNewStatesCommand_1 = require("./modules/visual-regression/application/commands/ApproveAllNewStatesCommand");
|
||||||
|
const ListComparisonsQuery_1 = require("./modules/visual-regression/application/queries/ListComparisonsQuery");
|
||||||
|
const StorageProvider_1 = require("./shared/infrastructure/StorageProvider");
|
||||||
|
const path_1 = __importDefault(require("path"));
|
||||||
|
// SSO + Audit modules (enterprise)
|
||||||
|
const KyselySSOConfigRepository_1 = require("./modules/sso/infrastructure/repositories/KyselySSOConfigRepository");
|
||||||
|
const KyselyTOTPRepository_1 = require("./modules/sso/infrastructure/repositories/KyselyTOTPRepository");
|
||||||
|
const TOTPService_1 = require("./modules/sso/infrastructure/providers/TOTPService");
|
||||||
|
const KyselyAuditRepository_1 = require("./modules/audit/infrastructure/repositories/KyselyAuditRepository");
|
||||||
|
// Scheduling module
|
||||||
|
const KyselyScheduleRepository_1 = require("./modules/scheduling/infrastructure/repositories/KyselyScheduleRepository");
|
||||||
|
const CreateScheduleCommand_1 = require("./modules/scheduling/application/commands/CreateScheduleCommand");
|
||||||
|
const ToggleScheduleCommand_1 = require("./modules/scheduling/application/commands/ToggleScheduleCommand");
|
||||||
|
const DeleteScheduleCommand_1 = require("./modules/scheduling/application/commands/DeleteScheduleCommand");
|
||||||
|
const ListSchedulesQuery_1 = require("./modules/scheduling/application/queries/ListSchedulesQuery");
|
||||||
|
const SchedulingService_1 = require("./modules/scheduling/application/SchedulingService");
|
||||||
|
const DataRetentionService_1 = require("./modules/scheduling/infrastructure/DataRetentionService");
|
||||||
|
// Job queue
|
||||||
|
const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue");
|
||||||
|
const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker");
|
||||||
|
const ReportWorker_1 = require("./jobs/workers/ReportWorker");
|
||||||
|
// API + Realtime
|
||||||
|
const server_1 = require("./api/server");
|
||||||
|
const SocketGateway_1 = require("./realtime/SocketGateway");
|
||||||
|
async function bootstrap() {
|
||||||
|
// Startup probe — measure total boot time
|
||||||
|
const startupAt = Date.now();
|
||||||
|
// 1. Config
|
||||||
|
const config = (0, Config_1.loadConfig)();
|
||||||
|
// 2. Logger
|
||||||
|
const logger = (0, Logger_1.createLogger)({ level: config.log.level, nodeEnv: config.nodeEnv });
|
||||||
|
logger.info({ port: config.port, env: config.nodeEnv }, 'Starting ABE...');
|
||||||
|
// 3. Database + migrations
|
||||||
|
const db = (0, DatabaseConnection_1.createDatabase)(config.db);
|
||||||
|
await (0, migrator_1.runMigrations)(db);
|
||||||
|
logger.info('Database migrations applied');
|
||||||
|
// 4. Event bus
|
||||||
|
const eventBus = new InProcessEventBus_1.InProcessEventBus(logger);
|
||||||
|
// 5. Repositories
|
||||||
|
const sessionRepo = new KyselyCrawlSessionRepository_1.KyselyCrawlSessionRepository(db);
|
||||||
|
const stateRepo = new KyselyStateRepository_1.KyselyStateRepository(db);
|
||||||
|
const findingRepo = new KyselyFindingRepository_1.KyselyFindingRepository(db);
|
||||||
|
const reportRepo = new KyselyReportRepository_1.KyselyReportRepository(db);
|
||||||
|
const fuzzRepo = new InMemoryFuzzSessionRepository_1.InMemoryFuzzSessionRepository();
|
||||||
|
// Suppress unused warning for stateRepo — used by crawling infrastructure
|
||||||
|
void stateRepo;
|
||||||
|
// 6. Crawling use cases
|
||||||
|
const startCrawl = new StartCrawlCommand_1.StartCrawlCommand(sessionRepo, eventBus);
|
||||||
|
const stopCrawl = new StopCrawlCommand_1.StopCrawlCommand(sessionRepo, eventBus);
|
||||||
|
const getSession = new GetSessionQuery_1.GetSessionQuery(sessionRepo);
|
||||||
|
const listSessions = new ListSessionsQuery_1.ListSessionsQuery(sessionRepo);
|
||||||
|
// 7. Findings use cases
|
||||||
|
const createFinding = new CreateFindingCommand_1.CreateFindingCommand(findingRepo, eventBus);
|
||||||
|
const enricher = new NullAIEnricher_1.NullAIEnricher();
|
||||||
|
const enrichFinding = new EnrichFindingCommand_1.EnrichFindingCommand(findingRepo, enricher, eventBus);
|
||||||
|
const resolveFinding = new ResolveFindingCommand_1.ResolveFindingCommand(findingRepo, eventBus);
|
||||||
|
const getFinding = new GetFindingQuery_1.GetFindingQuery(findingRepo);
|
||||||
|
const listFindings = new ListFindingsQuery_1.ListFindingsQuery(findingRepo);
|
||||||
|
const findingStats = new FindingStatsQuery_1.FindingStatsQuery(findingRepo);
|
||||||
|
// 8. Fuzzing use cases
|
||||||
|
const fuzzerEngine = new FuzzingEngineAdapter_1.FuzzingEngineAdapter({ intensity: 'low', seed: 42 });
|
||||||
|
const runFuzz = new RunFuzzCommand_1.RunFuzzCommand(fuzzerEngine, fuzzRepo, eventBus);
|
||||||
|
// 9. Event handlers — subscribe to EventBus
|
||||||
|
const onAnomalyDetected = new OnAnomalyDetected_1.OnAnomalyDetected(createFinding);
|
||||||
|
eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected);
|
||||||
|
const onActionExecuted = new OnActionExecuted_1.OnActionExecuted(runFuzz);
|
||||||
|
eventBus.subscribe('crawling.action_executed', onActionExecuted);
|
||||||
|
// 10. Auth module
|
||||||
|
const userRepo = new KyselyUserRepository_1.KyselyUserRepository(db);
|
||||||
|
const orgRepo = new KyselyOrganizationRepository_1.KyselyOrganizationRepository(db);
|
||||||
|
const apiKeyRepo = new KyselyApiKeyRepository_1.KyselyApiKeyRepository(db);
|
||||||
|
const authSessionRepo = new KyselySessionRepository_1.KyselySessionRepository(db);
|
||||||
|
const registerCommand = new RegisterCommand_1.RegisterCommand(userRepo, eventBus, PasswordService_1.hashPassword);
|
||||||
|
const loginCommand = new LoginCommand_1.LoginCommand(userRepo, authSessionRepo, eventBus, PasswordService_1.verifyPassword);
|
||||||
|
const createOrgCommand = new CreateOrganizationCommand_1.CreateOrganizationCommand(orgRepo, userRepo, eventBus);
|
||||||
|
const inviteMemberCommand = new InviteMemberCommand_1.InviteMemberCommand(orgRepo, userRepo, eventBus);
|
||||||
|
const createApiKeyCommand = new CreateApiKeyCommand_1.CreateApiKeyCommand(apiKeyRepo, userRepo);
|
||||||
|
const getUserQuery = new GetUserQuery_1.GetUserQuery(userRepo);
|
||||||
|
const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, userRepo);
|
||||||
|
// 11. Reporting use cases
|
||||||
|
const generateReport = new GenerateReportCommand_1.GenerateReportCommand(reportRepo, eventBus);
|
||||||
|
// 11b. Licensing
|
||||||
|
const licenseValidator = new RSALicenseValidator_1.RSALicenseValidator();
|
||||||
|
const licenseService = new LicenseService_1.LicenseService(licenseValidator);
|
||||||
|
// 11c. Integrations (moved from 11d)
|
||||||
|
const integrationRepo = new KyselyIntegrationRepository_1.KyselyIntegrationRepository(db);
|
||||||
|
const webhookRepo = new KyselyWebhookEndpointRepository_1.KyselyWebhookEndpointRepository(db);
|
||||||
|
const webhookDispatcher = new WebhookDispatcher_1.WebhookDispatcher(webhookRepo, logger);
|
||||||
|
const onFindingCreated = new OnFindingCreated_1.OnFindingCreated(integrationRepo, webhookRepo, webhookDispatcher, logger);
|
||||||
|
eventBus.subscribe('findings.finding_created', onFindingCreated);
|
||||||
|
// 12. Job queue (created before HTTP server so it can be injected)
|
||||||
|
const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
|
||||||
|
jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger }));
|
||||||
|
jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
|
||||||
|
jobQueue.start();
|
||||||
|
// 11d. Visual regression module
|
||||||
|
const storageBasePath = path_1.default.join(process.cwd(), 'data');
|
||||||
|
const storageProvider = new StorageProvider_1.LocalStorageProvider(storageBasePath);
|
||||||
|
const visualBaselineRepo = new KyselyVisualRepository_1.KyselyVisualBaselineRepository(db);
|
||||||
|
const visualComparisonRepo = new KyselyVisualRepository_1.KyselyVisualComparisonRepository(db);
|
||||||
|
const visualRegressionAdapter = new VisualRegressionAdapter_1.VisualRegressionAdapter(storageProvider, visualBaselineRepo, visualComparisonRepo, eventBus);
|
||||||
|
void visualRegressionAdapter; // used by ExplorationOrchestrator in crawling infra
|
||||||
|
const listComparisons = new ListComparisonsQuery_1.ListComparisonsQuery(visualComparisonRepo);
|
||||||
|
const approveBaseline = new ApproveBaselineCommand_1.ApproveBaselineCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
|
||||||
|
const rejectComparison = new RejectComparisonCommand_1.RejectComparisonCommand(visualComparisonRepo);
|
||||||
|
const approveAllNewStates = new ApproveAllNewStatesCommand_1.ApproveAllNewStatesCommand(visualComparisonRepo, visualBaselineRepo, eventBus);
|
||||||
|
// 12b. Scheduling module (after job queue, since it enqueues jobs)
|
||||||
|
const scheduleRepo = new KyselyScheduleRepository_1.KyselyScheduleRepository(db);
|
||||||
|
const createSchedule = new CreateScheduleCommand_1.CreateScheduleCommand(scheduleRepo, eventBus);
|
||||||
|
const toggleSchedule = new ToggleScheduleCommand_1.ToggleScheduleCommand(scheduleRepo, eventBus);
|
||||||
|
const deleteSchedule = new DeleteScheduleCommand_1.DeleteScheduleCommand(scheduleRepo, eventBus);
|
||||||
|
const listSchedules = new ListSchedulesQuery_1.ListSchedulesQuery(scheduleRepo);
|
||||||
|
const schedulingService = new SchedulingService_1.SchedulingService(scheduleRepo, jobQueue, eventBus, logger);
|
||||||
|
await schedulingService.start();
|
||||||
|
// 12b.1. Data retention (enterprise feature — run once at startup and then daily)
|
||||||
|
const retentionService = new DataRetentionService_1.DataRetentionService(db, logger);
|
||||||
|
void retentionService.runRetention().catch((err) => logger.warn({ err }, 'Retention run failed'));
|
||||||
|
const DAILY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const retentionInterval = setInterval(() => {
|
||||||
|
void retentionService.runRetention().catch((err) => logger.warn({ err }, 'Retention run failed'));
|
||||||
|
}, DAILY_MS);
|
||||||
|
retentionInterval.unref(); // Don't keep process alive just for retention
|
||||||
|
// 12c. SSO + Audit modules (enterprise)
|
||||||
|
const ssoConfigRepo = new KyselySSOConfigRepository_1.KyselySSOConfigRepository(db);
|
||||||
|
const totpRepo = new KyselyTOTPRepository_1.KyselyTOTPRepository(db);
|
||||||
|
const totpService = new TOTPService_1.TOTPService();
|
||||||
|
const auditRepo = new KyselyAuditRepository_1.KyselyAuditRepository(db);
|
||||||
|
// 13. HTTP server
|
||||||
|
const app = (0, server_1.createServer)({
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
db,
|
||||||
|
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
|
||||||
|
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
|
||||||
|
fuzzingDeps: { runFuzz, repository: fuzzRepo },
|
||||||
|
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
|
||||||
|
integrationsDeps: { integrationRepo, webhookRepo },
|
||||||
|
schedulingDeps: { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo },
|
||||||
|
visualRegressionDeps: { listComparisons, approveBaseline, rejectComparison, approveAllNewStates },
|
||||||
|
licenseService,
|
||||||
|
authDeps: {
|
||||||
|
registerCommand,
|
||||||
|
loginCommand,
|
||||||
|
createOrgCommand,
|
||||||
|
inviteMemberCommand,
|
||||||
|
createApiKeyCommand,
|
||||||
|
getUserQuery,
|
||||||
|
listOrgMembersQuery,
|
||||||
|
sessionRepository: authSessionRepo,
|
||||||
|
apiKeyRepository: apiKeyRepo,
|
||||||
|
userRepository: userRepo,
|
||||||
|
},
|
||||||
|
ssoDeps: { ssoConfigRepository: ssoConfigRepo, totpRepository: totpRepo, totpService },
|
||||||
|
auditRepository: auditRepo,
|
||||||
|
});
|
||||||
|
const httpServer = http_1.default.createServer(app);
|
||||||
|
// 12. Socket.io + gateway
|
||||||
|
const io = new socket_io_1.Server(httpServer, {
|
||||||
|
cors: { origin: config.cors.origin, credentials: true },
|
||||||
|
});
|
||||||
|
const gateway = new SocketGateway_1.SocketGateway(io, eventBus, logger);
|
||||||
|
gateway.start();
|
||||||
|
// 13. Start listening
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
httpServer.listen(config.port, config.host, resolve);
|
||||||
|
});
|
||||||
|
const startupMs = Date.now() - startupAt;
|
||||||
|
logger.info({ port: config.port, host: config.host, startupMs }, 'ABE server ready');
|
||||||
|
// 14. Graceful shutdown
|
||||||
|
let shuttingDown = false;
|
||||||
|
async function shutdown(signal) {
|
||||||
|
if (shuttingDown)
|
||||||
|
return;
|
||||||
|
shuttingDown = true;
|
||||||
|
logger.info({ signal }, 'Shutting down...');
|
||||||
|
// Stop accepting new connections
|
||||||
|
httpServer.close();
|
||||||
|
// Close socket.io
|
||||||
|
io.close();
|
||||||
|
// Stop scheduling service
|
||||||
|
schedulingService.stop();
|
||||||
|
// Stop job queue and wait for active jobs
|
||||||
|
jobQueue.pause();
|
||||||
|
await jobQueue.waitForActive(30000);
|
||||||
|
// Close database
|
||||||
|
try {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.warn({ err }, 'Error closing database');
|
||||||
|
}
|
||||||
|
logger.info('Shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
// Force-exit if graceful shutdown takes too long
|
||||||
|
function forceExit(signal) {
|
||||||
|
void shutdown(signal).catch(() => {
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.error('Forced shutdown after 30s');
|
||||||
|
process.exit(1);
|
||||||
|
}, 30000).unref();
|
||||||
|
}
|
||||||
|
process.on('SIGTERM', () => forceExit('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => forceExit('SIGINT'));
|
||||||
|
}
|
||||||
|
bootstrap().catch((err) => {
|
||||||
|
console.error('Fatal: failed to start ABE', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.AuditLog = void 0;
|
||||||
|
const Entity_1 = require("../../../../shared/domain/Entity");
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
class AuditLog extends Entity_1.Entity {
|
||||||
|
static create(props, id) {
|
||||||
|
return new AuditLog(props, id ?? UniqueId_1.UniqueId.create());
|
||||||
|
}
|
||||||
|
static reconstitute(props, id) {
|
||||||
|
return new AuditLog(props, id);
|
||||||
|
}
|
||||||
|
get userId() { return this.props.userId; }
|
||||||
|
get organizationId() { return this.props.organizationId; }
|
||||||
|
get action() { return this.props.action; }
|
||||||
|
get resource() { return this.props.resource; }
|
||||||
|
get resourceId() { return this.props.resourceId; }
|
||||||
|
get ipAddress() { return this.props.ipAddress; }
|
||||||
|
get userAgent() { return this.props.userAgent; }
|
||||||
|
get details() { return this.props.details; }
|
||||||
|
get occurredAt() { return this.props.occurredAt; }
|
||||||
|
}
|
||||||
|
exports.AuditLog = AuditLog;
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createAuditRouter = exports.KyselyAuditRepository = exports.AuditLog = void 0;
|
||||||
|
var AuditLog_1 = require("./domain/entities/AuditLog");
|
||||||
|
Object.defineProperty(exports, "AuditLog", { enumerable: true, get: function () { return AuditLog_1.AuditLog; } });
|
||||||
|
var KyselyAuditRepository_1 = require("./infrastructure/repositories/KyselyAuditRepository");
|
||||||
|
Object.defineProperty(exports, "KyselyAuditRepository", { enumerable: true, get: function () { return KyselyAuditRepository_1.KyselyAuditRepository; } });
|
||||||
|
var AuditController_1 = require("./infrastructure/http/AuditController");
|
||||||
|
Object.defineProperty(exports, "createAuditRouter", { enumerable: true, get: function () { return AuditController_1.createAuditRouter; } });
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createAuditRouter = createAuditRouter;
|
||||||
|
const express_1 = require("express");
|
||||||
|
function createAuditRouter(repo) {
|
||||||
|
const router = (0, express_1.Router)();
|
||||||
|
// GET /api/audit — list audit logs (enterprise only)
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const filters = {
|
||||||
|
userId: req.query['userId'],
|
||||||
|
organizationId: req.query['organizationId'],
|
||||||
|
action: req.query['action'],
|
||||||
|
resource: req.query['resource'],
|
||||||
|
limit: req.query['limit'] ? Number(req.query['limit']) : 100,
|
||||||
|
};
|
||||||
|
if (req.query['from'])
|
||||||
|
filters.from = new Date(req.query['from']);
|
||||||
|
if (req.query['to'])
|
||||||
|
filters.to = new Date(req.query['to']);
|
||||||
|
const logs = await repo.findAll(filters);
|
||||||
|
res.json(logs.map((l) => ({
|
||||||
|
id: l.id.toString(),
|
||||||
|
userId: l.userId,
|
||||||
|
organizationId: l.organizationId,
|
||||||
|
action: l.action,
|
||||||
|
resource: l.resource,
|
||||||
|
resourceId: l.resourceId,
|
||||||
|
ipAddress: l.ipAddress,
|
||||||
|
details: l.details,
|
||||||
|
occurredAt: l.occurredAt.toISOString(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.KyselyAuditRepository = void 0;
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
const AuditLog_1 = require("../../domain/entities/AuditLog");
|
||||||
|
class KyselyAuditRepository {
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
async save(log) {
|
||||||
|
await this.db.insertInto('audit_logs').values({
|
||||||
|
id: log.id.toString(),
|
||||||
|
user_id: log.userId,
|
||||||
|
organization_id: log.organizationId,
|
||||||
|
action: log.action,
|
||||||
|
resource: log.resource,
|
||||||
|
resource_id: log.resourceId,
|
||||||
|
ip_address: log.ipAddress,
|
||||||
|
user_agent: log.userAgent,
|
||||||
|
details_json: JSON.stringify(log.details),
|
||||||
|
occurred_at: log.occurredAt.getTime(),
|
||||||
|
}).execute();
|
||||||
|
}
|
||||||
|
async findAll(filters = {}) {
|
||||||
|
let query = this.db.selectFrom('audit_logs').selectAll();
|
||||||
|
if (filters.userId)
|
||||||
|
query = query.where('user_id', '=', filters.userId);
|
||||||
|
if (filters.organizationId)
|
||||||
|
query = query.where('organization_id', '=', filters.organizationId);
|
||||||
|
if (filters.action)
|
||||||
|
query = query.where('action', '=', filters.action);
|
||||||
|
if (filters.resource)
|
||||||
|
query = query.where('resource', '=', filters.resource);
|
||||||
|
if (filters.from)
|
||||||
|
query = query.where('occurred_at', '>=', filters.from.getTime());
|
||||||
|
if (filters.to)
|
||||||
|
query = query.where('occurred_at', '<=', filters.to.getTime());
|
||||||
|
const rows = await query
|
||||||
|
.orderBy('occurred_at', 'desc')
|
||||||
|
.limit(filters.limit ?? 100)
|
||||||
|
.execute();
|
||||||
|
return rows.map((row) => AuditLog_1.AuditLog.reconstitute({
|
||||||
|
userId: row.user_id,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
action: row.action,
|
||||||
|
resource: row.resource,
|
||||||
|
resourceId: row.resource_id,
|
||||||
|
ipAddress: row.ip_address,
|
||||||
|
userAgent: row.user_agent,
|
||||||
|
details: JSON.parse(row.details_json),
|
||||||
|
occurredAt: new Date(row.occurred_at),
|
||||||
|
}, UniqueId_1.UniqueId.from(row.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.KyselyAuditRepository = KyselyAuditRepository;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CreateApiKeyCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const ApiKey_1 = require("../../domain/entities/ApiKey");
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class CreateApiKeyCommand {
|
||||||
|
constructor(apiKeyRepository, userRepository) {
|
||||||
|
this.apiKeyRepository = apiKeyRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const user = await this.userRepository.findById(request.userId);
|
||||||
|
if (!user) {
|
||||||
|
return (0, Result_1.Err)('User not found');
|
||||||
|
}
|
||||||
|
if (!request.name.trim()) {
|
||||||
|
return (0, Result_1.Err)('API key name is required');
|
||||||
|
}
|
||||||
|
const rawKey = `abe_${(0, crypto_1.randomBytes)(32).toString('hex')}`;
|
||||||
|
const keyHash = (0, crypto_1.createHash)('sha256').update(rawKey).digest('hex');
|
||||||
|
const keyPrefix = rawKey.substring(0, 12);
|
||||||
|
const apiKey = ApiKey_1.ApiKey.create({
|
||||||
|
userId: request.userId,
|
||||||
|
orgId: request.orgId,
|
||||||
|
name: request.name.trim(),
|
||||||
|
keyHash,
|
||||||
|
keyPrefix,
|
||||||
|
permissions: request.permissions ?? ['member'],
|
||||||
|
expiresAt: request.expiresAt,
|
||||||
|
});
|
||||||
|
await this.apiKeyRepository.save(apiKey);
|
||||||
|
return (0, Result_1.Ok)({
|
||||||
|
id: apiKey.id.toString(),
|
||||||
|
key: rawKey,
|
||||||
|
keyPrefix,
|
||||||
|
name: apiKey.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CreateApiKeyCommand = CreateApiKeyCommand;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CreateOrganizationCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const Organization_1 = require("../../domain/entities/Organization");
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class CreateOrganizationCommand {
|
||||||
|
constructor(orgRepository, userRepository, eventBus) {
|
||||||
|
this.orgRepository = orgRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const user = await this.userRepository.findById(request.ownerId);
|
||||||
|
if (!user) {
|
||||||
|
return (0, Result_1.Err)('User not found');
|
||||||
|
}
|
||||||
|
const slug = Organization_1.Organization.slugify(request.name);
|
||||||
|
if (!slug) {
|
||||||
|
return (0, Result_1.Err)('Invalid organization name');
|
||||||
|
}
|
||||||
|
const existing = await this.orgRepository.findBySlug(slug);
|
||||||
|
if (existing) {
|
||||||
|
return (0, Result_1.Err)('Organization name already taken');
|
||||||
|
}
|
||||||
|
const org = Organization_1.Organization.create({ name: request.name, slug });
|
||||||
|
await this.orgRepository.save(org);
|
||||||
|
await this.orgRepository.addMember({
|
||||||
|
id: (0, crypto_1.randomUUID)(),
|
||||||
|
orgId: org.id.toString(),
|
||||||
|
userId: request.ownerId,
|
||||||
|
role: 'owner',
|
||||||
|
joinedAt: new Date(),
|
||||||
|
});
|
||||||
|
user.assignToOrg(org.id.toString());
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
for (const event of org.domainEvents) {
|
||||||
|
await this.eventBus.publish(event);
|
||||||
|
}
|
||||||
|
org.clearEvents();
|
||||||
|
return (0, Result_1.Ok)({
|
||||||
|
orgId: org.id.toString(),
|
||||||
|
name: org.name,
|
||||||
|
slug: org.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CreateOrganizationCommand = CreateOrganizationCommand;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.InviteMemberCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const Email_1 = require("../../domain/value-objects/Email");
|
||||||
|
const Role_1 = require("../../domain/value-objects/Role");
|
||||||
|
const MemberInvited_1 = require("../../domain/events/MemberInvited");
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class InviteMemberCommand {
|
||||||
|
constructor(orgRepository, userRepository, eventBus) {
|
||||||
|
this.orgRepository = orgRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const org = await this.orgRepository.findById(request.orgId);
|
||||||
|
if (!org) {
|
||||||
|
return (0, Result_1.Err)('Organization not found');
|
||||||
|
}
|
||||||
|
let email;
|
||||||
|
try {
|
||||||
|
email = Email_1.Email.create(request.email);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return (0, Result_1.Err)('Invalid email address');
|
||||||
|
}
|
||||||
|
let role;
|
||||||
|
try {
|
||||||
|
role = Role_1.Role.create(request.role);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return (0, Result_1.Err)('Invalid role');
|
||||||
|
}
|
||||||
|
const user = await this.userRepository.findByEmail(email.value);
|
||||||
|
if (!user) {
|
||||||
|
return (0, Result_1.Err)('User with this email not found. They must register first.');
|
||||||
|
}
|
||||||
|
const existing = await this.orgRepository.getMember(request.orgId, user.id.toString());
|
||||||
|
if (existing) {
|
||||||
|
return (0, Result_1.Err)('User is already a member of this organization');
|
||||||
|
}
|
||||||
|
const memberId = (0, crypto_1.randomUUID)();
|
||||||
|
await this.orgRepository.addMember({
|
||||||
|
id: memberId,
|
||||||
|
orgId: request.orgId,
|
||||||
|
userId: user.id.toString(),
|
||||||
|
role: role.value,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
});
|
||||||
|
const event = new MemberInvited_1.MemberInvited(request.orgId, {
|
||||||
|
email: email.value,
|
||||||
|
role: role.value,
|
||||||
|
inviterUserId: request.inviterUserId,
|
||||||
|
});
|
||||||
|
await this.eventBus.publish(event);
|
||||||
|
return (0, Result_1.Ok)({
|
||||||
|
memberId,
|
||||||
|
email: email.value,
|
||||||
|
role: role.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.InviteMemberCommand = InviteMemberCommand;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.LoginCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const Email_1 = require("../../domain/value-objects/Email");
|
||||||
|
const UserLoggedIn_1 = require("../../domain/events/UserLoggedIn");
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
class LoginCommand {
|
||||||
|
constructor(userRepository, sessionRepository, eventBus, verifyPassword, sessionMaxAgeSeconds = 7 * 24 * 60 * 60) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.sessionRepository = sessionRepository;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
this.verifyPassword = verifyPassword;
|
||||||
|
this.sessionMaxAgeSeconds = sessionMaxAgeSeconds;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
let email;
|
||||||
|
try {
|
||||||
|
email = Email_1.Email.create(request.email);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return (0, Result_1.Err)('Invalid credentials');
|
||||||
|
}
|
||||||
|
const user = await this.userRepository.findByEmail(email.value);
|
||||||
|
if (!user) {
|
||||||
|
return (0, Result_1.Err)('Invalid credentials');
|
||||||
|
}
|
||||||
|
const valid = await this.verifyPassword(request.password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return (0, Result_1.Err)('Invalid credentials');
|
||||||
|
}
|
||||||
|
const token = (0, crypto_1.randomUUID)();
|
||||||
|
const expiresAt = new Date(Date.now() + this.sessionMaxAgeSeconds * 1000);
|
||||||
|
const session = {
|
||||||
|
id: (0, crypto_1.randomUUID)(),
|
||||||
|
userId: user.id.toString(),
|
||||||
|
token,
|
||||||
|
expiresAt,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
await this.sessionRepository.save(session);
|
||||||
|
const event = new UserLoggedIn_1.UserLoggedIn(user.id.toString(), {
|
||||||
|
email: user.email.value,
|
||||||
|
sessionId: session.id,
|
||||||
|
});
|
||||||
|
await this.eventBus.publish(event);
|
||||||
|
return (0, Result_1.Ok)({
|
||||||
|
userId: user.id.toString(),
|
||||||
|
sessionToken: token,
|
||||||
|
expiresAt,
|
||||||
|
role: user.role.value,
|
||||||
|
name: user.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.LoginCommand = LoginCommand;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.RegisterCommand = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
const User_1 = require("../../domain/entities/User");
|
||||||
|
const Email_1 = require("../../domain/value-objects/Email");
|
||||||
|
const Role_1 = require("../../domain/value-objects/Role");
|
||||||
|
class RegisterCommand {
|
||||||
|
constructor(userRepository, eventBus, hashPassword) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
this.hashPassword = hashPassword;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
let email;
|
||||||
|
try {
|
||||||
|
email = Email_1.Email.create(request.email);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return (0, Result_1.Err)('Invalid email address');
|
||||||
|
}
|
||||||
|
const existing = await this.userRepository.findByEmail(email.value);
|
||||||
|
if (existing) {
|
||||||
|
return (0, Result_1.Err)('Email already registered');
|
||||||
|
}
|
||||||
|
if (request.password.length < 8) {
|
||||||
|
return (0, Result_1.Err)('Password must be at least 8 characters');
|
||||||
|
}
|
||||||
|
let role;
|
||||||
|
try {
|
||||||
|
role = request.role ? Role_1.Role.create(request.role) : Role_1.Role.member();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return (0, Result_1.Err)('Invalid role');
|
||||||
|
}
|
||||||
|
const passwordHash = await this.hashPassword(request.password);
|
||||||
|
const user = User_1.User.create({ email, name: request.name, passwordHash, role });
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
for (const event of user.domainEvents) {
|
||||||
|
await this.eventBus.publish(event);
|
||||||
|
}
|
||||||
|
user.clearEvents();
|
||||||
|
return (0, Result_1.Ok)({
|
||||||
|
userId: user.id.toString(),
|
||||||
|
email: user.email.value,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.RegisterCommand = RegisterCommand;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createAuthMiddleware = createAuthMiddleware;
|
||||||
|
const crypto_1 = require("crypto");
|
||||||
|
function createAuthMiddleware(userRepository, sessionRepository, apiKeyRepository) {
|
||||||
|
return async function authMiddleware(req, res, next) {
|
||||||
|
try {
|
||||||
|
// 1. Check session cookie
|
||||||
|
const sessionToken = req.cookies?.['abe_session'];
|
||||||
|
if (sessionToken) {
|
||||||
|
const session = await sessionRepository.findByToken(sessionToken);
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
const user = await userRepository.findById(session.userId);
|
||||||
|
if (user) {
|
||||||
|
req.user = {
|
||||||
|
id: user.id.toString(),
|
||||||
|
email: user.email.value,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.value,
|
||||||
|
orgId: user.orgId,
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Check Bearer JWT (session token in header)
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const session = await sessionRepository.findByToken(token);
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
const user = await userRepository.findById(session.userId);
|
||||||
|
if (user) {
|
||||||
|
req.user = {
|
||||||
|
id: user.id.toString(),
|
||||||
|
email: user.email.value,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.value,
|
||||||
|
orgId: user.orgId,
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Check API key
|
||||||
|
const apiKeyHeader = req.headers['x-abe-api-key'];
|
||||||
|
if (apiKeyHeader && typeof apiKeyHeader === 'string') {
|
||||||
|
const keyHash = (0, crypto_1.createHash)('sha256').update(apiKeyHeader).digest('hex');
|
||||||
|
const apiKey = await apiKeyRepository.findByHash(keyHash);
|
||||||
|
if (apiKey && !apiKey.isExpired()) {
|
||||||
|
const user = await userRepository.findById(apiKey.userId);
|
||||||
|
if (user) {
|
||||||
|
await apiKeyRepository.updateLastUsed(apiKey.id.toString(), new Date());
|
||||||
|
req.user = {
|
||||||
|
id: user.id.toString(),
|
||||||
|
email: user.email.value,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.value,
|
||||||
|
orgId: user.orgId,
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.requirePermission = requirePermission;
|
||||||
|
const AbilityFactory_1 = require("../../infrastructure/casl/AbilityFactory");
|
||||||
|
function requirePermission(action, subject) {
|
||||||
|
return function rbacMiddleware(req, res, next) {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ability = (0, AbilityFactory_1.defineAbilityFor)(req.user.role);
|
||||||
|
if (!ability.can(action, subject)) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: `You do not have permission to ${action} ${subject}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.GetUserQuery = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class GetUserQuery {
|
||||||
|
constructor(userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const user = await this.userRepository.findById(request.userId);
|
||||||
|
if (!user) {
|
||||||
|
return (0, Result_1.Err)('User not found');
|
||||||
|
}
|
||||||
|
return (0, Result_1.Ok)({
|
||||||
|
id: user.id.toString(),
|
||||||
|
email: user.email.value,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.value,
|
||||||
|
orgId: user.orgId,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.GetUserQuery = GetUserQuery;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ListOrgMembersQuery = void 0;
|
||||||
|
const Result_1 = require("../../../../shared/domain/Result");
|
||||||
|
class ListOrgMembersQuery {
|
||||||
|
constructor(orgRepository, userRepository) {
|
||||||
|
this.orgRepository = orgRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
async execute(request) {
|
||||||
|
const org = await this.orgRepository.findById(request.orgId);
|
||||||
|
if (!org) {
|
||||||
|
return (0, Result_1.Err)('Organization not found');
|
||||||
|
}
|
||||||
|
const members = await this.orgRepository.listMembers(request.orgId);
|
||||||
|
const dtos = [];
|
||||||
|
for (const member of members) {
|
||||||
|
const user = await this.userRepository.findById(member.userId);
|
||||||
|
if (user) {
|
||||||
|
dtos.push({
|
||||||
|
id: member.id,
|
||||||
|
userId: member.userId,
|
||||||
|
email: user.email.value,
|
||||||
|
name: user.name,
|
||||||
|
role: member.role,
|
||||||
|
joinedAt: member.joinedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (0, Result_1.Ok)({ members: dtos, total: dtos.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ListOrgMembersQuery = ListOrgMembersQuery;
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ApiKey = void 0;
|
||||||
|
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
class ApiKey extends AggregateRoot_1.AggregateRoot {
|
||||||
|
static create(props, id) {
|
||||||
|
const keyId = id ?? UniqueId_1.UniqueId.create();
|
||||||
|
return new ApiKey({
|
||||||
|
...props,
|
||||||
|
createdAt: new Date(),
|
||||||
|
}, keyId);
|
||||||
|
}
|
||||||
|
static reconstitute(props, id) {
|
||||||
|
return new ApiKey(props, id);
|
||||||
|
}
|
||||||
|
get userId() { return this.props.userId; }
|
||||||
|
get orgId() { return this.props.orgId; }
|
||||||
|
get name() { return this.props.name; }
|
||||||
|
get keyHash() { return this.props.keyHash; }
|
||||||
|
get keyPrefix() { return this.props.keyPrefix; }
|
||||||
|
get permissions() { return this.props.permissions; }
|
||||||
|
get expiresAt() { return this.props.expiresAt; }
|
||||||
|
get lastUsedAt() { return this.props.lastUsedAt; }
|
||||||
|
get createdAt() { return this.props.createdAt; }
|
||||||
|
isExpired() {
|
||||||
|
if (!this.props.expiresAt)
|
||||||
|
return false;
|
||||||
|
return new Date() > this.props.expiresAt;
|
||||||
|
}
|
||||||
|
markUsed() {
|
||||||
|
this.props.lastUsedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ApiKey = ApiKey;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.Organization = void 0;
|
||||||
|
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
const OrgCreated_1 = require("../events/OrgCreated");
|
||||||
|
class Organization extends AggregateRoot_1.AggregateRoot {
|
||||||
|
static create(props, id) {
|
||||||
|
const orgId = id ?? UniqueId_1.UniqueId.create();
|
||||||
|
const org = new Organization({
|
||||||
|
...props,
|
||||||
|
createdAt: new Date(),
|
||||||
|
}, orgId);
|
||||||
|
org.addDomainEvent(new OrgCreated_1.OrgCreated(orgId.toString(), {
|
||||||
|
name: props.name,
|
||||||
|
slug: props.slug,
|
||||||
|
}));
|
||||||
|
return org;
|
||||||
|
}
|
||||||
|
static reconstitute(props, id) {
|
||||||
|
return new Organization(props, id);
|
||||||
|
}
|
||||||
|
static slugify(name) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
get name() { return this.props.name; }
|
||||||
|
get slug() { return this.props.slug; }
|
||||||
|
get createdAt() { return this.props.createdAt; }
|
||||||
|
}
|
||||||
|
exports.Organization = Organization;
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.User = void 0;
|
||||||
|
const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
|
||||||
|
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||||
|
const UserCreated_1 = require("../events/UserCreated");
|
||||||
|
class User extends AggregateRoot_1.AggregateRoot {
|
||||||
|
static create(props, id) {
|
||||||
|
const userId = id ?? UniqueId_1.UniqueId.create();
|
||||||
|
const now = new Date();
|
||||||
|
const user = new User({
|
||||||
|
...props,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}, userId);
|
||||||
|
user.addDomainEvent(new UserCreated_1.UserCreated(userId.toString(), {
|
||||||
|
email: props.email.value,
|
||||||
|
name: props.name,
|
||||||
|
role: props.role.value,
|
||||||
|
}));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
static reconstitute(props, id) {
|
||||||
|
return new User(props, id);
|
||||||
|
}
|
||||||
|
get email() { return this.props.email; }
|
||||||
|
get name() { return this.props.name; }
|
||||||
|
get passwordHash() { return this.props.passwordHash; }
|
||||||
|
get role() { return this.props.role; }
|
||||||
|
get orgId() { return this.props.orgId; }
|
||||||
|
get createdAt() { return this.props.createdAt; }
|
||||||
|
get updatedAt() { return this.props.updatedAt; }
|
||||||
|
assignToOrg(orgId) {
|
||||||
|
this.props.orgId = orgId;
|
||||||
|
this.props.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
changeRole(role) {
|
||||||
|
this.props.role = role;
|
||||||
|
this.props.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.User = User;
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
+18
@@ -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@]+$/;
|
||||||
@@ -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
@@ -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'];
|
||||||
Vendored
+48
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+98
@@ -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;
|
||||||
@@ -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);
|
res.json(result.value);
|
||||||
});
|
});
|
||||||
// GET /api/findings/:id — finding detail
|
// GET /api/findings/:id — finding detail (includes actionTrace)
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
const findingId = req.params['id'];
|
const findingId = req.params['id'];
|
||||||
const result = await deps.getFinding.execute({ findingId });
|
const result = await deps.getFinding.execute({ findingId });
|
||||||
@@ -46,7 +46,8 @@ function createFindingsRouter(deps) {
|
|||||||
res.status(404).json({ error: result.error });
|
res.status(404).json({ error: result.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json(toDTO(result.value));
|
const f = result.value;
|
||||||
|
res.json({ ...toDTO(f), actionTrace: f.actionTrace });
|
||||||
});
|
});
|
||||||
// PATCH /api/findings/:id/status — update status
|
// PATCH /api/findings/:id/status — update status
|
||||||
router.patch('/:id/status', async (req, res) => {
|
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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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'];
|
||||||
@@ -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;
|
||||||
Vendored
+26
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+21
@@ -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;
|
||||||
@@ -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
Reference in New Issue
Block a user