fase(16): integrations module

This commit is contained in:
Claude
2026-03-06 07:22:00 -05:00
committed by debian
parent cffa1aeea9
commit 1f1678af17
49 changed files with 2558 additions and 13 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View 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();
}

View File

@@ -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,

View File

@@ -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);
}

View 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; }
}

View 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; }
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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' }); }
}

View File

@@ -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');
}
}

View 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';

View File

@@ -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;
}

View File

@@ -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}`],
});
}
}

View File

@@ -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}`);
}
}
}

View File

@@ -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()}`);
}
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}

View File

@@ -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> {