diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha index c2592a2..3b2bd44 100644 --- a/.ralph/.loop_start_sha +++ b/.ralph/.loop_start_sha @@ -1 +1 @@ -d62bd615bf6b93b982e19e35b0b49591c648e5d2 +e746dc049766347ca4d135ec35e86cbef2f90261 diff --git a/.ralph/progress.json b/.ralph/progress.json index 9476d27..6a2e39b 100644 --- a/.ralph/progress.json +++ b/.ralph/progress.json @@ -1 +1 @@ -{"status": "failed", "timestamp": "2026-03-05 04:07:02"} +{"status": "completed", "timestamp": "2026-03-05 09:23:22"} diff --git a/dist/api/middleware/errorHandler.js b/dist/api/middleware/errorHandler.js new file mode 100644 index 0000000..08768b1 --- /dev/null +++ b/dist/api/middleware/errorHandler.js @@ -0,0 +1,76 @@ +"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 logger = req.log; + if (err instanceof AppError && err.isOperational) { + if (logger) { + logger.warn({ err, statusCode: err.statusCode }, 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 }, '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', + }); +} diff --git a/dist/api/middleware/notFound.js b/dist/api/middleware/notFound.js new file mode 100644 index 0000000..95b846b --- /dev/null +++ b/dist/api/middleware/notFound.js @@ -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', + }); +} diff --git a/dist/api/middleware/requestId.js b/dist/api/middleware/requestId.js new file mode 100644 index 0000000..ff05d20 --- /dev/null +++ b/dist/api/middleware/requestId.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createRequestIdMiddleware = createRequestIdMiddleware; +const uuid_1 = require("uuid"); +function createRequestIdMiddleware(logger) { + return (req, _res, next) => { + req.id = req.headers['x-request-id'] ?? (0, uuid_1.v4)(); + req.log = logger.child({ requestId: req.id, method: req.method, url: req.url }); + next(); + }; +} diff --git a/dist/api/router.js b/dist/api/router.js new file mode 100644 index 0000000..1c71e42 --- /dev/null +++ b/dist/api/router.js @@ -0,0 +1,17 @@ +"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"); +function createRouter(deps) { + const router = (0, express_1.Router)(); + 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)); + return router; +} diff --git a/dist/api/server.js b/dist/api/server.js new file mode 100644 index 0000000..d0ccf1b --- /dev/null +++ b/dist/api/server.js @@ -0,0 +1,64 @@ +"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 requestId_1 = require("./middleware/requestId"); +const notFound_1 = require("./middleware/notFound"); +const errorHandler_1 = require("./middleware/errorHandler"); +const router_1 = require("./router"); +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 + app.use(express_1.default.json({ limit: '10mb' })); + // 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)); + // 8. 404 handler + app.use(notFound_1.notFoundMiddleware); + // 9. Global error handler — always last + app.use(errorHandler_1.globalErrorHandler); + return app; +} diff --git a/dist/main.js b/dist/main.js new file mode 100644 index 0000000..5e2b0db --- /dev/null +++ b/dist/main.js @@ -0,0 +1,140 @@ +"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"); +// API + Realtime +const server_1 = require("./api/server"); +const SocketGateway_1 = require("./realtime/SocketGateway"); +async function bootstrap() { + // 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 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. 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 }, + }); + const httpServer = http_1.default.createServer(app); + // 11. 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(); + // 12. Start listening + await new Promise((resolve) => { + httpServer.listen(config.port, config.host, resolve); + }); + logger.info({ port: config.port, host: config.host }, 'ABE server ready'); + // 13. 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(); + // 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); +}); diff --git a/dist/modules/findings/infrastructure/NullAIEnricher.js b/dist/modules/findings/infrastructure/NullAIEnricher.js new file mode 100644 index 0000000..7cbc9e0 --- /dev/null +++ b/dist/modules/findings/infrastructure/NullAIEnricher.js @@ -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; diff --git a/dist/modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository.js b/dist/modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository.js new file mode 100644 index 0000000..b2a00e4 --- /dev/null +++ b/dist/modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository.js @@ -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; diff --git a/dist/realtime/SocketGateway.js b/dist/realtime/SocketGateway.js new file mode 100644 index 0000000..ec7e088 --- /dev/null +++ b/dist/realtime/SocketGateway.js @@ -0,0 +1,42 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SocketGateway = void 0; +const BROADCAST_EVENTS = [ + 'crawling.started', + 'crawling.state_discovered', + 'crawling.action_executed', + 'crawling.completed', + 'crawling.failed', + 'findings.created', + 'findings.resolved', + 'findings.enriched', + 'fuzzing.started', + 'fuzzing.vulnerability_detected', + 'fuzzing.completed', +]; +class SocketGateway { + constructor(io, eventBus, logger) { + this.io = io; + this.eventBus = eventBus; + this.logger = logger; + } + start() { + // Subscribe EventBus events → broadcast to all connected clients + for (const eventName of BROADCAST_EVENTS) { + this.eventBus.subscribe(eventName, { + handle: async (event) => { + this.io.emit(eventName, event); + this.logger.debug({ eventName }, 'Socket event broadcast'); + }, + }); + } + this.io.on('connection', (socket) => { + this.logger.debug({ socketId: socket.id }, 'Client connected'); + socket.on('disconnect', () => { + this.logger.debug({ socketId: socket.id }, 'Client disconnected'); + }); + }); + this.logger.info('SocketGateway started'); + } +} +exports.SocketGateway = SocketGateway; diff --git a/src/api/middleware/errorHandler.ts b/src/api/middleware/errorHandler.ts new file mode 100644 index 0000000..c2da710 --- /dev/null +++ b/src/api/middleware/errorHandler.ts @@ -0,0 +1,82 @@ +import { Request, Response, NextFunction } from 'express'; + +export class AppError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly code: string, + public readonly isOperational = true, + ) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +export class ValidationError extends AppError { + constructor(message: string, public readonly details?: unknown) { + super(message, 400, 'VALIDATION_ERROR'); + } +} + +export class AuthenticationError extends AppError { + constructor(message = 'Unauthorized') { + super(message, 401, 'AUTHENTICATION_ERROR'); + } +} + +export class ForbiddenError extends AppError { + constructor(message = 'Forbidden') { + super(message, 403, 'FORBIDDEN'); + } +} + +export class NotFoundError extends AppError { + constructor(resource: string) { + super(`${resource} not found`, 404, 'NOT_FOUND'); + } +} + +export class ConflictError extends AppError { + constructor(message: string) { + super(message, 409, 'CONFLICT'); + } +} + +export class RateLimitError extends AppError { + constructor() { + super('Too many requests', 429, 'RATE_LIMIT'); + } +} + +export function globalErrorHandler( + err: Error, + req: Request, + res: Response, + _next: NextFunction, +): void { + const logger = (req as Request & { log?: { warn: Function; error: Function } }).log; + + if (err instanceof AppError && err.isOperational) { + if (logger) { + logger.warn({ err, statusCode: err.statusCode }, err.message); + } + const body: Record = { 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 }, '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', + }); +} diff --git a/src/api/middleware/notFound.ts b/src/api/middleware/notFound.ts new file mode 100644 index 0000000..9cb15d7 --- /dev/null +++ b/src/api/middleware/notFound.ts @@ -0,0 +1,8 @@ +import { Request, Response } from 'express'; + +export function notFoundMiddleware(req: Request, res: Response): void { + res.status(404).json({ + error: `Route ${req.method} ${req.path} not found`, + code: 'NOT_FOUND', + }); +} diff --git a/src/api/middleware/requestId.ts b/src/api/middleware/requestId.ts new file mode 100644 index 0000000..1be0c9a --- /dev/null +++ b/src/api/middleware/requestId.ts @@ -0,0 +1,21 @@ +import { Request, Response, NextFunction } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { Logger } from '../../shared/infrastructure/Logger'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + id: string; + log: Logger; + } + } +} + +export function createRequestIdMiddleware(logger: Logger) { + return (req: Request, _res: Response, next: NextFunction): void => { + req.id = (req.headers['x-request-id'] as string | undefined) ?? uuidv4(); + req.log = logger.child({ requestId: req.id, method: req.method, url: req.url }); + next(); + }; +} diff --git a/src/api/router.ts b/src/api/router.ts new file mode 100644 index 0000000..94c5a8f --- /dev/null +++ b/src/api/router.ts @@ -0,0 +1,18 @@ +/** + * ABE API Router — registers all module routes. + */ +import { Router } from 'express'; +import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController'; +import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController'; +import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController'; +import { ServerDependencies } from './server'; + +export function createRouter(deps: ServerDependencies): Router { + const router = Router(); + + router.use('/sessions', createCrawlingRouter(deps.crawlingDeps)); + router.use('/findings', createFindingsRouter(deps.findingsDeps)); + router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps)); + + return router; +} diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..5d6becb --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,89 @@ +/** + * ABE API Server — Express app factory. + * Middleware order matters: requestId → helmet → cors → rateLimit → body → routes → notFound → errorHandler + */ +import express, { Express, Request, Response } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import { Kysely } from 'kysely'; +import { AppConfig } from '../shared/infrastructure/Config'; +import { Logger } from '../shared/infrastructure/Logger'; +import { Database } from '../shared/infrastructure/DatabaseConnection'; +import { createRequestIdMiddleware } from './middleware/requestId'; +import { notFoundMiddleware } from './middleware/notFound'; +import { globalErrorHandler } from './middleware/errorHandler'; +import { createRouter } from './router'; +import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController'; +import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController'; +import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController'; + +export interface ServerDependencies { + config: AppConfig; + logger: Logger; + db: Kysely; + crawlingDeps: CrawlingControllerDeps; + findingsDeps: FindingsControllerDeps; + fuzzingDeps: FuzzingControllerDeps; +} + +export function createServer(deps: ServerDependencies): Express { + const app = express(); + + // 1. Request ID — must be first so all logs have requestId + app.use(createRequestIdMiddleware(deps.logger)); + + // 2. Security headers + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + connectSrc: ["'self'", 'ws:', 'wss:'], + scriptSrc: ["'self'", "'unsafe-inline'"], + }, + }, + }), + ); + + // 3. CORS + app.use(cors({ origin: deps.config.cors.origin, credentials: true })); + + // 4. Rate limiting + app.use( + rateLimit({ + windowMs: deps.config.api.rateLimitWindowMs, + max: deps.config.api.rateLimitMax, + standardHeaders: true, + legacyHeaders: false, + }), + ); + + // 5. Body parsing + app.use(express.json({ limit: '10mb' })); + + // 6. Health endpoints — no auth required + app.get('/health/live', (_req: Request, res: Response) => { + res.json({ status: 'ok', uptime: process.uptime() }); + }); + + app.get('/health/ready', async (_req: Request, res: Response) => { + 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', createRouter(deps)); + + // 8. 404 handler + app.use(notFoundMiddleware); + + // 9. Global error handler — always last + app.use(globalErrorHandler); + + return app; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..0f56e8c --- /dev/null +++ b/src/main.ts @@ -0,0 +1,164 @@ +/** + * ABE — composition root. + * Wires all modules together and starts the HTTP + WebSocket server. + */ +import http from 'http'; +import { Server as SocketIOServer } from 'socket.io'; + +import { loadConfig } from './shared/infrastructure/Config'; +import { createLogger } from './shared/infrastructure/Logger'; +import { createDatabase } from './shared/infrastructure/DatabaseConnection'; +import { InProcessEventBus } from './shared/infrastructure/InProcessEventBus'; + +import { runMigrations } from './db/migrator'; + +// Crawling module +import { KyselyCrawlSessionRepository } from './modules/crawling/infrastructure/repositories/KyselyCrawlSessionRepository'; +import { KyselyStateRepository } from './modules/crawling/infrastructure/repositories/KyselyStateRepository'; +import { StartCrawlCommand } from './modules/crawling/application/commands/StartCrawlCommand'; +import { StopCrawlCommand } from './modules/crawling/application/commands/StopCrawlCommand'; +import { GetSessionQuery } from './modules/crawling/application/queries/GetSessionQuery'; +import { ListSessionsQuery } from './modules/crawling/application/queries/ListSessionsQuery'; + +// Findings module +import { KyselyFindingRepository } from './modules/findings/infrastructure/repositories/KyselyFindingRepository'; +import { CreateFindingCommand } from './modules/findings/application/commands/CreateFindingCommand'; +import { EnrichFindingCommand } from './modules/findings/application/commands/EnrichFindingCommand'; +import { ResolveFindingCommand } from './modules/findings/application/commands/ResolveFindingCommand'; +import { GetFindingQuery } from './modules/findings/application/queries/GetFindingQuery'; +import { ListFindingsQuery } from './modules/findings/application/queries/ListFindingsQuery'; +import { FindingStatsQuery } from './modules/findings/application/queries/FindingStatsQuery'; +import { OnAnomalyDetected } from './modules/findings/application/event-handlers/OnAnomalyDetected'; +import { NullAIEnricher } from './modules/findings/infrastructure/NullAIEnricher'; + +// Fuzzing module +import { FuzzingEngineAdapter } from './modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter'; +import { RunFuzzCommand } from './modules/fuzzing/application/commands/RunFuzzCommand'; +import { OnActionExecuted } from './modules/fuzzing/application/event-handlers/OnActionExecuted'; +import { InMemoryFuzzSessionRepository } from './modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository'; + +// API + Realtime +import { createServer } from './api/server'; +import { SocketGateway } from './realtime/SocketGateway'; + +async function bootstrap(): Promise { + // 1. Config + const config = loadConfig(); + + // 2. Logger + const logger = createLogger({ level: config.log.level, nodeEnv: config.nodeEnv }); + logger.info({ port: config.port, env: config.nodeEnv }, 'Starting ABE...'); + + // 3. Database + migrations + const db = createDatabase(config.db); + await runMigrations(db); + logger.info('Database migrations applied'); + + // 4. Event bus + const eventBus = new InProcessEventBus(logger); + + // 5. Repositories + const sessionRepo = new KyselyCrawlSessionRepository(db); + const stateRepo = new KyselyStateRepository(db); + const findingRepo = new KyselyFindingRepository(db); + const fuzzRepo = new InMemoryFuzzSessionRepository(); + + // Suppress unused warning for stateRepo — used by crawling infrastructure + void stateRepo; + + // 6. Crawling use cases + const startCrawl = new StartCrawlCommand(sessionRepo, eventBus); + const stopCrawl = new StopCrawlCommand(sessionRepo, eventBus); + const getSession = new GetSessionQuery(sessionRepo); + const listSessions = new ListSessionsQuery(sessionRepo); + + // 7. Findings use cases + const createFinding = new CreateFindingCommand(findingRepo, eventBus); + const enricher = new NullAIEnricher(); + const enrichFinding = new EnrichFindingCommand(findingRepo, enricher, eventBus); + const resolveFinding = new ResolveFindingCommand(findingRepo, eventBus); + const getFinding = new GetFindingQuery(findingRepo); + const listFindings = new ListFindingsQuery(findingRepo); + const findingStats = new FindingStatsQuery(findingRepo); + + // 8. Fuzzing use cases + const fuzzerEngine = new FuzzingEngineAdapter({ intensity: 'low', seed: 42 }); + const runFuzz = new RunFuzzCommand(fuzzerEngine, fuzzRepo, eventBus); + + // 9. Event handlers — subscribe to EventBus + const onAnomalyDetected = new OnAnomalyDetected(createFinding); + eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected); + + const onActionExecuted = new OnActionExecuted(runFuzz); + eventBus.subscribe('crawling.action_executed', onActionExecuted); + + // 10. HTTP server + const app = createServer({ + config, + logger, + db, + crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions }, + findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding }, + fuzzingDeps: { runFuzz, repository: fuzzRepo }, + }); + + const httpServer = http.createServer(app); + + // 11. Socket.io + gateway + const io = new SocketIOServer(httpServer, { + cors: { origin: config.cors.origin, credentials: true }, + }); + const gateway = new SocketGateway(io, eventBus, logger); + gateway.start(); + + // 12. Start listening + await new Promise((resolve) => { + httpServer.listen(config.port, config.host, resolve); + }); + logger.info({ port: config.port, host: config.host }, 'ABE server ready'); + + // 13. Graceful shutdown + let shuttingDown = false; + + async function shutdown(signal: string): Promise { + if (shuttingDown) return; + shuttingDown = true; + + logger.info({ signal }, 'Shutting down...'); + + // Stop accepting new connections + httpServer.close(); + + // Close socket.io + io.close(); + + // 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: string): void { + void shutdown(signal).catch(() => { + process.exit(1); + }); + setTimeout(() => { + logger.error('Forced shutdown after 30s'); + process.exit(1); + }, 30_000).unref(); + } + + process.on('SIGTERM', () => forceExit('SIGTERM')); + process.on('SIGINT', () => forceExit('SIGINT')); +} + +bootstrap().catch((err: unknown) => { + console.error('Fatal: failed to start ABE', err); + process.exit(1); +}); diff --git a/src/modules/findings/infrastructure/NullAIEnricher.ts b/src/modules/findings/infrastructure/NullAIEnricher.ts new file mode 100644 index 0000000..b0ef577 --- /dev/null +++ b/src/modules/findings/infrastructure/NullAIEnricher.ts @@ -0,0 +1,12 @@ +import { IAIEnricher } from '../domain/ports/IAIEnricher'; +import { Finding } from '../domain/entities/Finding'; +import { IAIEnrichment } from '../../../core/interfaces'; + +/** + * NullAIEnricher — no-op enricher used when AI provider is not configured. + */ +export class NullAIEnricher implements IAIEnricher { + async enrich(_finding: Finding): Promise { + throw new Error('AI enrichment is not configured. Set ABE_AI_PROVIDER to enable it.'); + } +} diff --git a/src/modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository.ts b/src/modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository.ts new file mode 100644 index 0000000..29b77b7 --- /dev/null +++ b/src/modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository.ts @@ -0,0 +1,21 @@ +import { IFuzzSessionRepository } from '../../application/commands/RunFuzzCommand'; +import { FuzzSession } from '../../domain/entities/FuzzSession'; + +/** + * InMemoryFuzzSessionRepository — temporary in-memory store used until Phase 8 adds SQLite persistence. + */ +export class InMemoryFuzzSessionRepository implements IFuzzSessionRepository { + private readonly store = new Map(); + + async save(session: FuzzSession): Promise { + this.store.set(session.id.toString(), session); + } + + async findById(id: string): Promise { + return this.store.get(id) ?? null; + } + + async update(session: FuzzSession): Promise { + this.store.set(session.id.toString(), session); + } +} diff --git a/src/realtime/SocketGateway.ts b/src/realtime/SocketGateway.ts new file mode 100644 index 0000000..8326646 --- /dev/null +++ b/src/realtime/SocketGateway.ts @@ -0,0 +1,52 @@ +/** + * SocketGateway — bridges EventBus domain events to socket.io clients. + * Subscribes to all relevant events on start and broadcasts them. + */ +import { Server as SocketIOServer, Socket } from 'socket.io'; +import { EventBus } from '../shared/application/EventBus'; +import { DomainEvent } from '../shared/domain/DomainEvent'; +import { Logger } from '../shared/infrastructure/Logger'; + +const BROADCAST_EVENTS = [ + 'crawling.started', + 'crawling.state_discovered', + 'crawling.action_executed', + 'crawling.completed', + 'crawling.failed', + 'findings.created', + 'findings.resolved', + 'findings.enriched', + 'fuzzing.started', + 'fuzzing.vulnerability_detected', + 'fuzzing.completed', +]; + +export class SocketGateway { + constructor( + private readonly io: SocketIOServer, + private readonly eventBus: EventBus, + private readonly logger: Logger, + ) {} + + start(): void { + // Subscribe EventBus events → broadcast to all connected clients + for (const eventName of BROADCAST_EVENTS) { + this.eventBus.subscribe(eventName, { + handle: async (event: DomainEvent): Promise => { + this.io.emit(eventName, event); + this.logger.debug({ eventName }, 'Socket event broadcast'); + }, + }); + } + + this.io.on('connection', (socket: Socket) => { + this.logger.debug({ socketId: socket.id }, 'Client connected'); + + socket.on('disconnect', () => { + this.logger.debug({ socketId: socket.id }, 'Client disconnected'); + }); + }); + + this.logger.info('SocketGateway started'); + } +}