fase(16): integrations module
This commit is contained in:
@@ -6,6 +6,7 @@ import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/Cr
|
||||
import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController';
|
||||
import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController';
|
||||
import { createReportingRouter } from '../modules/reporting/infrastructure/http/ReportingController';
|
||||
import { createIntegrationsRouter } from '../modules/integrations/infrastructure/http/IntegrationsController';
|
||||
import { createAuthController } from '../modules/auth/infrastructure/http/AuthController';
|
||||
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
|
||||
import { ServerDependencies } from './server';
|
||||
@@ -66,6 +67,7 @@ export function createRouter(deps: ServerDependencies): Router {
|
||||
router.use('/findings', createFindingsRouter(deps.findingsDeps));
|
||||
router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps));
|
||||
router.use('/reports', createReportingRouter(deps.reportingDeps));
|
||||
router.use('/integrations', createIntegrationsRouter(deps.integrationsDeps));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/
|
||||
import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController';
|
||||
import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController';
|
||||
import { ReportingControllerDeps } from '../modules/reporting/infrastructure/http/ReportingController';
|
||||
import { IntegrationsDeps } from '../modules/integrations/infrastructure/http/IntegrationsController';
|
||||
import { AuthControllerDeps } from './router';
|
||||
|
||||
export interface ServerDependencies {
|
||||
@@ -29,6 +30,7 @@ export interface ServerDependencies {
|
||||
findingsDeps: FindingsControllerDeps;
|
||||
fuzzingDeps: FuzzingControllerDeps;
|
||||
reportingDeps: ReportingControllerDeps;
|
||||
integrationsDeps: IntegrationsDeps;
|
||||
authDeps: AuthControllerDeps;
|
||||
}
|
||||
|
||||
|
||||
45
src/db/migrations/006_integrations_tables.ts
Normal file
45
src/db/migrations/006_integrations_tables.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
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
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('webhook_deliveries').ifExists().execute();
|
||||
await db.schema.dropTable('webhook_endpoints').ifExists().execute();
|
||||
await db.schema.dropTable('integrations').ifExists().execute();
|
||||
}
|
||||
14
src/main.ts
14
src/main.ts
@@ -55,6 +55,12 @@ import { hashPassword, verifyPassword } from './modules/auth/infrastructure/auth
|
||||
import { KyselyReportRepository } from './modules/reporting/infrastructure/repositories/KyselyReportRepository';
|
||||
import { GenerateReportCommand } from './modules/reporting/application/commands/GenerateReportCommand';
|
||||
|
||||
// Integrations module
|
||||
import { KyselyIntegrationRepository } from './modules/integrations/infrastructure/repositories/KyselyIntegrationRepository';
|
||||
import { KyselyWebhookEndpointRepository } from './modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository';
|
||||
import { WebhookDispatcher } from './modules/integrations/infrastructure/webhooks/WebhookDispatcher';
|
||||
import { OnFindingCreated } from './modules/integrations/application/event-handlers/OnFindingCreated';
|
||||
|
||||
// Job queue
|
||||
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
|
||||
import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker';
|
||||
@@ -133,6 +139,13 @@ async function bootstrap(): Promise<void> {
|
||||
// 11. Reporting use cases
|
||||
const generateReport = new GenerateReportCommand(reportRepo, eventBus);
|
||||
|
||||
// 11b. Integrations
|
||||
const integrationRepo = new KyselyIntegrationRepository(db);
|
||||
const webhookRepo = new KyselyWebhookEndpointRepository(db);
|
||||
const webhookDispatcher = new WebhookDispatcher(webhookRepo, logger);
|
||||
const onFindingCreated = new 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(db, logger, config.jobs.pollIntervalMs);
|
||||
jobQueue.registerHandler(
|
||||
@@ -151,6 +164,7 @@ async function bootstrap(): Promise<void> {
|
||||
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
|
||||
fuzzingDeps: { runFuzz, repository: fuzzRepo },
|
||||
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
|
||||
integrationsDeps: { integrationRepo, webhookRepo },
|
||||
authDeps: {
|
||||
registerCommand,
|
||||
loginCommand,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { EventHandler } from '../../../../shared/application/EventHandler';
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
import { IIntegrationRepository } from '../../domain/ports/IIntegrationRepository';
|
||||
import { IWebhookEndpointRepository } from '../../domain/ports/IWebhookEndpointRepository';
|
||||
import { SlackProvider } from '../../infrastructure/providers/SlackProvider';
|
||||
import { GitHubIssuesProvider } from '../../infrastructure/providers/GitHubIssuesProvider';
|
||||
import { JiraProvider } from '../../infrastructure/providers/JiraProvider';
|
||||
import { WebhookDispatcher } from '../../infrastructure/webhooks/WebhookDispatcher';
|
||||
import { FindingPayload } from '../../domain/ports/IIntegrationProvider';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
interface FindingCreatedPayload {
|
||||
findingId: string;
|
||||
sessionId: string;
|
||||
type: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class OnFindingCreated implements EventHandler {
|
||||
constructor(
|
||||
private readonly integrationRepo: IIntegrationRepository,
|
||||
private readonly webhookRepo: IWebhookEndpointRepository,
|
||||
private readonly dispatcher: WebhookDispatcher,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async handle(event: DomainEvent): Promise<void> {
|
||||
const payload = event.payload as unknown as FindingCreatedPayload;
|
||||
const finding: FindingPayload = {
|
||||
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(integration.config.webhookUrl);
|
||||
await provider.sendFinding(finding);
|
||||
} else if (type === 'github' && integration.config.token && integration.config.repo) {
|
||||
const provider = new GitHubIssuesProvider(
|
||||
integration.config.token as string,
|
||||
integration.config.repo as string
|
||||
);
|
||||
await provider.sendFinding(finding);
|
||||
} else if (
|
||||
type === 'jira' &&
|
||||
integration.config.host &&
|
||||
integration.config.token &&
|
||||
integration.config.username &&
|
||||
integration.config.projectKey
|
||||
) {
|
||||
const provider = new JiraProvider(
|
||||
integration.config.host as string,
|
||||
integration.config.token as string,
|
||||
integration.config.username as string,
|
||||
integration.config.projectKey as string
|
||||
);
|
||||
await provider.sendFinding(finding);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn({ integrationId: integration.id.toString(), err }, 'Integration dispatch failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
|
||||
|
||||
function severityMeetsThreshold(severity: string, min: string): boolean {
|
||||
return SEVERITY_ORDER.indexOf(severity) >= SEVERITY_ORDER.indexOf(min);
|
||||
}
|
||||
49
src/modules/integrations/domain/entities/Integration.ts
Normal file
49
src/modules/integrations/domain/entities/Integration.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Entity } from '../../../../shared/domain/Entity';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { IntegrationType } from '../value-objects/IntegrationType';
|
||||
|
||||
export interface IntegrationConfig {
|
||||
webhookUrl?: string;
|
||||
token?: string;
|
||||
repo?: string;
|
||||
projectKey?: string;
|
||||
host?: string;
|
||||
username?: string;
|
||||
minSeverity?: 'low' | 'medium' | 'high' | 'critical';
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IntegrationProps {
|
||||
name: string;
|
||||
type: IntegrationType;
|
||||
enabled: boolean;
|
||||
config: IntegrationConfig;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class Integration extends Entity<IntegrationProps> {
|
||||
static create(props: {
|
||||
name: string;
|
||||
type: IntegrationType;
|
||||
config: IntegrationConfig;
|
||||
}, id?: UniqueId): Integration {
|
||||
return new Integration(
|
||||
{ ...props, enabled: true, createdAt: new Date() },
|
||||
id ?? UniqueId.create()
|
||||
);
|
||||
}
|
||||
|
||||
static reconstitute(props: IntegrationProps, id: UniqueId): Integration {
|
||||
return new Integration(props, id);
|
||||
}
|
||||
|
||||
get name(): string { return this.props.name; }
|
||||
get type(): IntegrationType { return this.props.type; }
|
||||
get enabled(): boolean { return this.props.enabled; }
|
||||
get config(): IntegrationConfig { return this.props.config; }
|
||||
get createdAt(): Date { return this.props.createdAt; }
|
||||
|
||||
enable(): void { this.props.enabled = true; }
|
||||
disable(): void { this.props.enabled = false; }
|
||||
updateConfig(config: IntegrationConfig): void { this.props.config = config; }
|
||||
}
|
||||
40
src/modules/integrations/domain/entities/WebhookEndpoint.ts
Normal file
40
src/modules/integrations/domain/entities/WebhookEndpoint.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Entity } from '../../../../shared/domain/Entity';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { WebhookSecret } from '../value-objects/WebhookSecret';
|
||||
|
||||
export interface WebhookEndpointProps {
|
||||
url: string;
|
||||
secret: WebhookSecret;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
lastDeliveredAt?: Date;
|
||||
lastStatus?: number;
|
||||
}
|
||||
|
||||
export class WebhookEndpoint extends Entity<WebhookEndpointProps> {
|
||||
static create(props: { url: string }, id?: UniqueId): WebhookEndpoint {
|
||||
return new WebhookEndpoint(
|
||||
{ ...props, secret: WebhookSecret.generate(), enabled: true, createdAt: new Date() },
|
||||
id ?? UniqueId.create()
|
||||
);
|
||||
}
|
||||
|
||||
static reconstitute(props: WebhookEndpointProps, id: UniqueId): WebhookEndpoint {
|
||||
return new WebhookEndpoint(props, id);
|
||||
}
|
||||
|
||||
get url(): string { return this.props.url; }
|
||||
get secret(): WebhookSecret { return this.props.secret; }
|
||||
get enabled(): boolean { return this.props.enabled; }
|
||||
get createdAt(): Date { return this.props.createdAt; }
|
||||
get lastDeliveredAt(): Date | undefined { return this.props.lastDeliveredAt; }
|
||||
get lastStatus(): number | undefined { return this.props.lastStatus; }
|
||||
|
||||
recordDelivery(statusCode: number): void {
|
||||
this.props.lastDeliveredAt = new Date();
|
||||
this.props.lastStatus = statusCode;
|
||||
}
|
||||
|
||||
enable(): void { this.props.enabled = true; }
|
||||
disable(): void { this.props.enabled = false; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface FindingPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
type: string;
|
||||
description: string;
|
||||
sessionId: string;
|
||||
url?: string;
|
||||
steps?: string[];
|
||||
}
|
||||
|
||||
export interface IIntegrationProvider {
|
||||
sendFinding(finding: FindingPayload): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Integration } from '../entities/Integration';
|
||||
|
||||
export interface IIntegrationRepository {
|
||||
save(integration: Integration): Promise<void>;
|
||||
findById(id: string): Promise<Integration | undefined>;
|
||||
findAll(): Promise<Integration[]>;
|
||||
findEnabled(): Promise<Integration[]>;
|
||||
update(integration: Integration): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebhookEndpoint } from '../entities/WebhookEndpoint';
|
||||
|
||||
export interface IWebhookEndpointRepository {
|
||||
save(endpoint: WebhookEndpoint): Promise<void>;
|
||||
findById(id: string): Promise<WebhookEndpoint | undefined>;
|
||||
findAll(): Promise<WebhookEndpoint[]>;
|
||||
findEnabled(): Promise<WebhookEndpoint[]>;
|
||||
update(endpoint: WebhookEndpoint): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ValueObject } from '../../../../shared/domain/ValueObject';
|
||||
|
||||
type IntegrationTypeValue = 'jira' | 'slack' | 'github' | 'webhook';
|
||||
|
||||
interface IntegrationTypeProps {
|
||||
value: IntegrationTypeValue;
|
||||
}
|
||||
|
||||
export class IntegrationType extends ValueObject<IntegrationTypeProps> {
|
||||
get value(): IntegrationTypeValue { return this.props.value; }
|
||||
|
||||
static fromString(s: string): IntegrationType {
|
||||
if (s === 'jira' || s === 'slack' || s === 'github' || s === 'webhook') {
|
||||
return new IntegrationType({ value: s });
|
||||
}
|
||||
throw new Error(`Invalid integration type: ${s}`);
|
||||
}
|
||||
|
||||
static jira(): IntegrationType { return new IntegrationType({ value: 'jira' }); }
|
||||
static slack(): IntegrationType { return new IntegrationType({ value: 'slack' }); }
|
||||
static github(): IntegrationType { return new IntegrationType({ value: 'github' }); }
|
||||
static webhook(): IntegrationType { return new IntegrationType({ value: 'webhook' }); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ValueObject } from '../../../../shared/domain/ValueObject';
|
||||
import { createHmac, randomBytes } from 'crypto';
|
||||
|
||||
interface WebhookSecretProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class WebhookSecret extends ValueObject<WebhookSecretProps> {
|
||||
get value(): string { return this.props.value; }
|
||||
|
||||
static generate(): WebhookSecret {
|
||||
const secret = randomBytes(32).toString('hex');
|
||||
return new WebhookSecret({ value: secret });
|
||||
}
|
||||
|
||||
static fromString(s: string): WebhookSecret {
|
||||
if (!s || s.length < 16) throw new Error('Webhook secret must be at least 16 characters');
|
||||
return new WebhookSecret({ value: s });
|
||||
}
|
||||
|
||||
sign(payload: string): string {
|
||||
return createHmac('sha256', this.props.value).update(payload).digest('hex');
|
||||
}
|
||||
}
|
||||
17
src/modules/integrations/index.ts
Normal file
17
src/modules/integrations/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Integrations module — public facade
|
||||
|
||||
export { Integration } from './domain/entities/Integration';
|
||||
export type { IntegrationConfig, IntegrationProps } from './domain/entities/Integration';
|
||||
export { WebhookEndpoint } from './domain/entities/WebhookEndpoint';
|
||||
export type { WebhookEndpointProps } from './domain/entities/WebhookEndpoint';
|
||||
export { IntegrationType } from './domain/value-objects/IntegrationType';
|
||||
export { WebhookSecret } from './domain/value-objects/WebhookSecret';
|
||||
export type { IIntegrationRepository } from './domain/ports/IIntegrationRepository';
|
||||
export type { IWebhookEndpointRepository } from './domain/ports/IWebhookEndpointRepository';
|
||||
export type { IIntegrationProvider, FindingPayload } from './domain/ports/IIntegrationProvider';
|
||||
export { KyselyIntegrationRepository } from './infrastructure/repositories/KyselyIntegrationRepository';
|
||||
export { KyselyWebhookEndpointRepository } from './infrastructure/repositories/KyselyWebhookEndpointRepository';
|
||||
export { WebhookDispatcher } from './infrastructure/webhooks/WebhookDispatcher';
|
||||
export { OnFindingCreated } from './application/event-handlers/OnFindingCreated';
|
||||
export { createIntegrationsRouter } from './infrastructure/http/IntegrationsController';
|
||||
export type { IntegrationsDeps } from './infrastructure/http/IntegrationsController';
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { IIntegrationRepository } from '../../domain/ports/IIntegrationRepository';
|
||||
import { IWebhookEndpointRepository } from '../../domain/ports/IWebhookEndpointRepository';
|
||||
import { Integration } from '../../domain/entities/Integration';
|
||||
import { WebhookEndpoint } from '../../domain/entities/WebhookEndpoint';
|
||||
import { IntegrationType } from '../../domain/value-objects/IntegrationType';
|
||||
|
||||
export interface IntegrationsDeps {
|
||||
integrationRepo: IIntegrationRepository;
|
||||
webhookRepo: IWebhookEndpointRepository;
|
||||
}
|
||||
|
||||
export function createIntegrationsRouter(deps: IntegrationsDeps): Router {
|
||||
const router = Router();
|
||||
const { integrationRepo, webhookRepo } = deps;
|
||||
|
||||
// ─── Integrations CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const items = await integrationRepo.findAll();
|
||||
res.json(items.map(serializeIntegration));
|
||||
});
|
||||
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const { name, type, config } = req.body as { name?: string; type?: string; config?: Record<string, unknown> };
|
||||
if (!name || !type) {
|
||||
res.status(400).json({ error: 'name and type are required' });
|
||||
return;
|
||||
}
|
||||
let intType: IntegrationType;
|
||||
try {
|
||||
intType = IntegrationType.fromString(type);
|
||||
} catch {
|
||||
res.status(400).json({ error: `Invalid integration type: ${type}` });
|
||||
return;
|
||||
}
|
||||
const integration = Integration.create({ name, type: intType, config: config ?? {} });
|
||||
await integrationRepo.save(integration);
|
||||
res.status(201).json(serializeIntegration(integration));
|
||||
});
|
||||
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const item = await integrationRepo.findById(req.params['id'] as string);
|
||||
if (!item) { res.status(404).json({ error: 'Integration not found' }); return; }
|
||||
res.json(serializeIntegration(item));
|
||||
});
|
||||
|
||||
router.patch('/:id', async (req: Request, res: Response) => {
|
||||
const item = await integrationRepo.findById(req.params['id'] as string);
|
||||
if (!item) { res.status(404).json({ error: 'Integration not found' }); return; }
|
||||
|
||||
const { enabled, config } = req.body as { enabled?: boolean; config?: Record<string, unknown> };
|
||||
if (enabled === true) item.enable();
|
||||
else if (enabled === false) item.disable();
|
||||
if (config) item.updateConfig(config);
|
||||
|
||||
await integrationRepo.update(item);
|
||||
res.json(serializeIntegration(item));
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
await integrationRepo.delete(req.params['id'] as string);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ─── Webhook Endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/webhooks/endpoints', async (_req: Request, res: Response) => {
|
||||
const endpoints = await webhookRepo.findAll();
|
||||
res.json(endpoints.map(serializeWebhook));
|
||||
});
|
||||
|
||||
router.post('/webhooks/endpoints', async (req: Request, res: Response) => {
|
||||
const { url } = req.body as { url?: string };
|
||||
if (!url) {
|
||||
res.status(400).json({ error: 'url is required' });
|
||||
return;
|
||||
}
|
||||
const endpoint = WebhookEndpoint.create({ url });
|
||||
await webhookRepo.save(endpoint);
|
||||
res.status(201).json(serializeWebhook(endpoint));
|
||||
});
|
||||
|
||||
router.delete('/webhooks/endpoints/:id', async (req: Request, res: Response) => {
|
||||
await webhookRepo.delete(req.params['id'] as string);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function serializeIntegration(i: Integration) {
|
||||
return {
|
||||
id: i.id.toString(),
|
||||
name: i.name,
|
||||
type: i.type.value,
|
||||
enabled: i.enabled,
|
||||
config: maskSecrets(i.config),
|
||||
createdAt: i.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeWebhook(ep: WebhookEndpoint) {
|
||||
return {
|
||||
id: ep.id.toString(),
|
||||
url: ep.url,
|
||||
enabled: ep.enabled,
|
||||
createdAt: ep.createdAt.toISOString(),
|
||||
lastDeliveredAt: ep.lastDeliveredAt?.toISOString() ?? null,
|
||||
lastStatus: ep.lastStatus ?? null,
|
||||
// Return the secret only once at creation (caller can see it from the first POST)
|
||||
};
|
||||
}
|
||||
|
||||
function maskSecrets(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const masked = { ...config };
|
||||
for (const key of ['token', 'secret', 'password', 'apiKey']) {
|
||||
if (masked[key]) masked[key] = '***';
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { IIntegrationProvider, FindingPayload } from '../../domain/ports/IIntegrationProvider';
|
||||
|
||||
export class GitHubIssuesProvider implements IIntegrationProvider {
|
||||
private readonly octokit: Octokit;
|
||||
private readonly owner: string;
|
||||
private readonly repo: string;
|
||||
|
||||
constructor(token: string, repo: string) {
|
||||
this.octokit = new Octokit({ auth: token });
|
||||
const [owner, repoName] = repo.split('/') as [string, string];
|
||||
this.owner = owner;
|
||||
this.repo = repoName;
|
||||
}
|
||||
|
||||
async sendFinding(finding: FindingPayload): Promise<void> {
|
||||
const stepsSection = finding.steps && finding.steps.length > 0
|
||||
? `\n\n## Reproduction Steps\n${finding.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
const body = `## ABE Security Finding
|
||||
|
||||
**Severity:** ${finding.severity.toUpperCase()}
|
||||
**Type:** ${finding.type}
|
||||
**Session:** ${finding.sessionId}
|
||||
|
||||
## Description
|
||||
${finding.description}${stepsSection}
|
||||
|
||||
---
|
||||
*Generated by [ABE — Autonomous Bug Explorer](https://github.com/your-org/abe)*`;
|
||||
|
||||
await this.octokit.issues.create({
|
||||
owner: this.owner,
|
||||
repo: this.repo,
|
||||
title: `[ABE] [${finding.severity.toUpperCase()}] ${finding.title}`,
|
||||
body,
|
||||
labels: ['bug', 'abe-finding', `severity:${finding.severity}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { IIntegrationProvider, FindingPayload } from '../../domain/ports/IIntegrationProvider';
|
||||
|
||||
const PRIORITY_MAP: Record<string, string> = {
|
||||
critical: 'Highest',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
};
|
||||
|
||||
export class JiraProvider implements IIntegrationProvider {
|
||||
constructor(
|
||||
private readonly host: string,
|
||||
private readonly token: string,
|
||||
private readonly username: string,
|
||||
private readonly projectKey: string
|
||||
) {}
|
||||
|
||||
async sendFinding(finding: FindingPayload): Promise<void> {
|
||||
const stepsSection = finding.steps && finding.steps.length > 0
|
||||
? `\n\nReproduction Steps:\n${finding.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
const body = {
|
||||
fields: {
|
||||
project: { key: this.projectKey },
|
||||
summary: `[ABE] [${finding.severity.toUpperCase()}] ${finding.title}`,
|
||||
description: {
|
||||
type: 'doc',
|
||||
version: 1,
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: `${finding.description}${stepsSection}` }],
|
||||
},
|
||||
],
|
||||
},
|
||||
issuetype: { name: 'Bug' },
|
||||
priority: { name: PRIORITY_MAP[finding.severity] ?? 'Medium' },
|
||||
labels: ['abe-finding', `severity-${finding.severity}`],
|
||||
},
|
||||
};
|
||||
|
||||
const auth = Buffer.from(`${this.username}:${this.token}`).toString('base64');
|
||||
const url = `${this.host.replace(/\/$/, '')}/rest/api/3/issue`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${auth}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Jira API error ${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { IIntegrationProvider, FindingPayload } from '../../domain/ports/IIntegrationProvider';
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: '#dc2626',
|
||||
high: '#ea580c',
|
||||
medium: '#ca8a04',
|
||||
low: '#2563eb',
|
||||
};
|
||||
|
||||
export class SlackProvider implements IIntegrationProvider {
|
||||
constructor(private readonly webhookUrl: string) {}
|
||||
|
||||
async sendFinding(finding: FindingPayload): Promise<void> {
|
||||
const color = SEVERITY_COLORS[finding.severity] ?? '#6b7280';
|
||||
const payload = {
|
||||
blocks: [
|
||||
{
|
||||
type: 'header',
|
||||
text: { type: 'plain_text', text: `ABE Finding: ${finding.title}`, emoji: true },
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{ type: 'mrkdwn', text: `*Severity:*\n${finding.severity.toUpperCase()}` },
|
||||
{ type: 'mrkdwn', text: `*Type:*\n${finding.type}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: { type: 'mrkdwn', text: `*Description:*\n${finding.description}` },
|
||||
},
|
||||
{
|
||||
type: 'context',
|
||||
elements: [
|
||||
{ type: 'mrkdwn', text: `Session: ${finding.sessionId}` },
|
||||
],
|
||||
},
|
||||
],
|
||||
attachments: [{ color, fallback: `${finding.severity.toUpperCase()} finding: ${finding.description}` }],
|
||||
};
|
||||
|
||||
const res = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Slack webhook failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Database, IntegrationTable } from '../../../../shared/infrastructure/DatabaseConnection';
|
||||
import { IIntegrationRepository } from '../../domain/ports/IIntegrationRepository';
|
||||
import { Integration, IntegrationConfig, IntegrationProps } from '../../domain/entities/Integration';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { IntegrationType } from '../../domain/value-objects/IntegrationType';
|
||||
|
||||
export class KyselyIntegrationRepository implements IIntegrationRepository {
|
||||
constructor(private readonly db: Kysely<Database>) {}
|
||||
|
||||
async save(integration: Integration): Promise<void> {
|
||||
const row: IntegrationTable = {
|
||||
id: integration.id.toString(),
|
||||
name: integration.name,
|
||||
type: integration.type.value,
|
||||
enabled: integration.enabled ? 1 : 0,
|
||||
config_json: JSON.stringify(integration.config),
|
||||
created_at: integration.createdAt.getTime(),
|
||||
};
|
||||
await this.db.insertInto('integrations').values(row).execute();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Integration | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('integrations')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Integration[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('integrations')
|
||||
.selectAll()
|
||||
.orderBy('created_at', 'desc')
|
||||
.execute();
|
||||
return rows.map(r => this.toDomain(r));
|
||||
}
|
||||
|
||||
async findEnabled(): Promise<Integration[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('integrations')
|
||||
.selectAll()
|
||||
.where('enabled', '=', 1)
|
||||
.execute();
|
||||
return rows.map(r => this.toDomain(r));
|
||||
}
|
||||
|
||||
async update(integration: Integration): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('integrations')
|
||||
.set({
|
||||
name: integration.name,
|
||||
enabled: integration.enabled ? 1 : 0,
|
||||
config_json: JSON.stringify(integration.config),
|
||||
})
|
||||
.where('id', '=', integration.id.toString())
|
||||
.execute();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.db.deleteFrom('integrations').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
private toDomain(row: IntegrationTable): Integration {
|
||||
const config = JSON.parse(row.config_json) as IntegrationConfig;
|
||||
const props: IntegrationProps = {
|
||||
name: row.name,
|
||||
type: IntegrationType.fromString(row.type),
|
||||
enabled: row.enabled === 1,
|
||||
config,
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
return Integration.reconstitute(props, UniqueId.from(row.id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Database, WebhookEndpointTable } from '../../../../shared/infrastructure/DatabaseConnection';
|
||||
import { IWebhookEndpointRepository } from '../../domain/ports/IWebhookEndpointRepository';
|
||||
import { WebhookEndpoint, WebhookEndpointProps } from '../../domain/entities/WebhookEndpoint';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { WebhookSecret } from '../../domain/value-objects/WebhookSecret';
|
||||
|
||||
export class KyselyWebhookEndpointRepository implements IWebhookEndpointRepository {
|
||||
constructor(private readonly db: Kysely<Database>) {}
|
||||
|
||||
async save(endpoint: WebhookEndpoint): Promise<void> {
|
||||
const row: WebhookEndpointTable = {
|
||||
id: endpoint.id.toString(),
|
||||
url: endpoint.url,
|
||||
secret: endpoint.secret.value,
|
||||
enabled: endpoint.enabled ? 1 : 0,
|
||||
created_at: endpoint.createdAt.getTime(),
|
||||
last_delivered_at: endpoint.lastDeliveredAt ? endpoint.lastDeliveredAt.getTime() : null,
|
||||
last_status: endpoint.lastStatus ?? null,
|
||||
};
|
||||
await this.db.insertInto('webhook_endpoints').values(row).execute();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WebhookEndpoint | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('webhook_endpoints')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async findAll(): Promise<WebhookEndpoint[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('webhook_endpoints')
|
||||
.selectAll()
|
||||
.orderBy('created_at', 'desc')
|
||||
.execute();
|
||||
return rows.map(r => this.toDomain(r));
|
||||
}
|
||||
|
||||
async findEnabled(): Promise<WebhookEndpoint[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('webhook_endpoints')
|
||||
.selectAll()
|
||||
.where('enabled', '=', 1)
|
||||
.execute();
|
||||
return rows.map(r => this.toDomain(r));
|
||||
}
|
||||
|
||||
async update(endpoint: WebhookEndpoint): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('webhook_endpoints')
|
||||
.set({
|
||||
enabled: endpoint.enabled ? 1 : 0,
|
||||
last_delivered_at: endpoint.lastDeliveredAt ? endpoint.lastDeliveredAt.getTime() : null,
|
||||
last_status: endpoint.lastStatus ?? null,
|
||||
})
|
||||
.where('id', '=', endpoint.id.toString())
|
||||
.execute();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.db.deleteFrom('webhook_endpoints').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
private toDomain(row: WebhookEndpointTable): WebhookEndpoint {
|
||||
const props: WebhookEndpointProps = {
|
||||
url: row.url,
|
||||
secret: WebhookSecret.fromString(row.secret),
|
||||
enabled: row.enabled === 1,
|
||||
createdAt: new Date(row.created_at),
|
||||
lastDeliveredAt: row.last_delivered_at ? new Date(row.last_delivered_at) : undefined,
|
||||
lastStatus: row.last_status ?? undefined,
|
||||
};
|
||||
return WebhookEndpoint.reconstitute(props, UniqueId.from(row.id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { IWebhookEndpointRepository } from '../../domain/ports/IWebhookEndpointRepository';
|
||||
import { FindingPayload } from '../../domain/ports/IIntegrationProvider';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const BASE_DELAY_MS = 1000;
|
||||
|
||||
export class WebhookDispatcher {
|
||||
constructor(
|
||||
private readonly endpointRepo: IWebhookEndpointRepository,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async dispatchFinding(finding: FindingPayload): Promise<void> {
|
||||
const endpoints = await this.endpointRepo.findEnabled();
|
||||
await Promise.allSettled(endpoints.map(ep => this.deliverWithRetry(ep.url, ep.secret.value, finding)));
|
||||
}
|
||||
|
||||
private async deliverWithRetry(url: string, secret: string, payload: FindingPayload): Promise<void> {
|
||||
const body = JSON.stringify({ event: 'finding.created', data: payload });
|
||||
const { createHmac } = await import('crypto');
|
||||
const signature = createHmac('sha256', secret).update(body).digest('hex');
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-ABE-Signature': `sha256=${signature}`,
|
||||
'X-ABE-Event': 'finding.created',
|
||||
'User-Agent': 'ABE-Webhook/1.0',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
this.logger.info({ url, status: res.status, attempt }, 'Webhook delivered');
|
||||
return;
|
||||
} catch (err) {
|
||||
this.logger.warn({ url, attempt, err }, 'Webhook delivery failed');
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
await sleep(BASE_DELAY_MS * 2 ** (attempt - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error({ url }, 'Webhook delivery failed after max attempts');
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -214,6 +214,34 @@ export interface ReportTable {
|
||||
completed_at: number | null;
|
||||
}
|
||||
|
||||
export interface IntegrationTable {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
enabled: number;
|
||||
config_json: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface WebhookEndpointTable {
|
||||
id: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
enabled: number;
|
||||
created_at: number;
|
||||
last_delivered_at: number | null;
|
||||
last_status: number | null;
|
||||
}
|
||||
|
||||
export interface WebhookDeliveryTable {
|
||||
id: string;
|
||||
endpoint_id: string;
|
||||
event: string;
|
||||
payload_json: string;
|
||||
status: number;
|
||||
attempted_at: number;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
sessions: SessionTable;
|
||||
states: StateTable;
|
||||
@@ -232,6 +260,9 @@ export interface Database {
|
||||
api_keys: ApiKeyTable;
|
||||
auth_sessions: AuthSessionTable;
|
||||
reports: ReportTable;
|
||||
integrations: IntegrationTable;
|
||||
webhook_endpoints: WebhookEndpointTable;
|
||||
webhook_deliveries: WebhookDeliveryTable;
|
||||
}
|
||||
|
||||
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {
|
||||
|
||||
Reference in New Issue
Block a user