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

@@ -1 +1 @@
3ff36f0b6a2c3e92b24febd488ef6abfe37ada6a
cffa1aeea99f01504bc6c016e12fc62ba63977c7

View File

@@ -1 +1,7 @@
{"status": "failed", "timestamp": "2026-03-06 04:11:47"}
{
"status": "executing",
"indicator": "⠹",
"elapsed_seconds": 630,
"last_output": "",
"timestamp": "2026-03-06 07:21:36"
}

2
dist/api/router.js vendored
View File

@@ -9,6 +9,7 @@ const CrawlingController_1 = require("../modules/crawling/infrastructure/http/Cr
const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController");
const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController");
const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController");
const IntegrationsController_1 = require("../modules/integrations/infrastructure/http/IntegrationsController");
const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController");
const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware");
function createRouter(deps) {
@@ -23,5 +24,6 @@ function createRouter(deps) {
router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps));
router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps));
router.use('/reports', (0, ReportingController_1.createReportingRouter)(deps.reportingDeps));
router.use('/integrations', (0, IntegrationsController_1.createIntegrationsRouter)(deps.integrationsDeps));
return router;
}

View File

@@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function up(db) {
await db.schema
.createTable('integrations')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('enabled', 'integer', (col) => col.notNull().defaultTo(1))
.addColumn('config_json', 'text', (col) => col.notNull().defaultTo('{}'))
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('webhook_endpoints')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('url', 'text', (col) => col.notNull())
.addColumn('secret', 'text', (col) => col.notNull())
.addColumn('enabled', 'integer', (col) => col.notNull().defaultTo(1))
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('last_delivered_at', 'integer')
.addColumn('last_status', 'integer')
.execute();
await db.schema
.createTable('webhook_deliveries')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('endpoint_id', 'text', (col) => col.notNull())
.addColumn('event', 'text', (col) => col.notNull())
.addColumn('payload_json', 'text', (col) => col.notNull())
.addColumn('status', 'integer', (col) => col.notNull())
.addColumn('attempted_at', 'integer', (col) => col.notNull())
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function down(db) {
await db.schema.dropTable('webhook_deliveries').ifExists().execute();
await db.schema.dropTable('webhook_endpoints').ifExists().execute();
await db.schema.dropTable('integrations').ifExists().execute();
}

12
dist/main.js vendored
View File

@@ -52,6 +52,11 @@ const PasswordService_1 = require("./modules/auth/infrastructure/auth/PasswordSe
// Reporting module
const KyselyReportRepository_1 = require("./modules/reporting/infrastructure/repositories/KyselyReportRepository");
const GenerateReportCommand_1 = require("./modules/reporting/application/commands/GenerateReportCommand");
// Integrations module
const KyselyIntegrationRepository_1 = require("./modules/integrations/infrastructure/repositories/KyselyIntegrationRepository");
const KyselyWebhookEndpointRepository_1 = require("./modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository");
const WebhookDispatcher_1 = require("./modules/integrations/infrastructure/webhooks/WebhookDispatcher");
const OnFindingCreated_1 = require("./modules/integrations/application/event-handlers/OnFindingCreated");
// Job queue
const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue");
const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker");
@@ -114,6 +119,12 @@ async function bootstrap() {
const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, userRepo);
// 11. Reporting use cases
const generateReport = new GenerateReportCommand_1.GenerateReportCommand(reportRepo, eventBus);
// 11b. Integrations
const integrationRepo = new KyselyIntegrationRepository_1.KyselyIntegrationRepository(db);
const webhookRepo = new KyselyWebhookEndpointRepository_1.KyselyWebhookEndpointRepository(db);
const webhookDispatcher = new WebhookDispatcher_1.WebhookDispatcher(webhookRepo, logger);
const onFindingCreated = new OnFindingCreated_1.OnFindingCreated(integrationRepo, webhookRepo, webhookDispatcher, logger);
eventBus.subscribe('findings.finding_created', onFindingCreated);
// 12. Job queue (created before HTTP server so it can be injected)
const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger }));
@@ -128,6 +139,7 @@ async function bootstrap() {
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,61 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.OnFindingCreated = void 0;
const SlackProvider_1 = require("../../infrastructure/providers/SlackProvider");
const GitHubIssuesProvider_1 = require("../../infrastructure/providers/GitHubIssuesProvider");
const JiraProvider_1 = require("../../infrastructure/providers/JiraProvider");
class OnFindingCreated {
constructor(integrationRepo, webhookRepo, dispatcher, logger) {
this.integrationRepo = integrationRepo;
this.webhookRepo = webhookRepo;
this.dispatcher = dispatcher;
this.logger = logger;
}
async handle(event) {
const payload = event.payload;
const finding = {
id: payload.findingId,
title: `${payload.type} finding`,
severity: payload.severity,
type: payload.type,
description: payload.description,
sessionId: payload.sessionId,
};
// Dispatch to custom webhooks
await this.dispatcher.dispatchFinding(finding);
// Dispatch to named integrations (Slack, GitHub, Jira)
const integrations = await this.integrationRepo.findEnabled();
for (const integration of integrations) {
try {
const minSev = integration.config.minSeverity ?? 'low';
if (!severityMeetsThreshold(payload.severity, minSev))
continue;
const type = integration.type.value;
if (type === 'slack' && integration.config.webhookUrl) {
const provider = new SlackProvider_1.SlackProvider(integration.config.webhookUrl);
await provider.sendFinding(finding);
}
else if (type === 'github' && integration.config.token && integration.config.repo) {
const provider = new GitHubIssuesProvider_1.GitHubIssuesProvider(integration.config.token, integration.config.repo);
await provider.sendFinding(finding);
}
else if (type === 'jira' &&
integration.config.host &&
integration.config.token &&
integration.config.username &&
integration.config.projectKey) {
const provider = new JiraProvider_1.JiraProvider(integration.config.host, integration.config.token, integration.config.username, integration.config.projectKey);
await provider.sendFinding(finding);
}
}
catch (err) {
this.logger.warn({ integrationId: integration.id.toString(), err }, 'Integration dispatch failed');
}
}
}
}
exports.OnFindingCreated = OnFindingCreated;
const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
function severityMeetsThreshold(severity, min) {
return SEVERITY_ORDER.indexOf(severity) >= SEVERITY_ORDER.indexOf(min);
}

View File

@@ -0,0 +1,22 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Integration = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class Integration extends Entity_1.Entity {
static create(props, id) {
return new Integration({ ...props, enabled: true, createdAt: new Date() }, id ?? UniqueId_1.UniqueId.create());
}
static reconstitute(props, id) {
return new Integration(props, id);
}
get name() { return this.props.name; }
get type() { return this.props.type; }
get enabled() { return this.props.enabled; }
get config() { return this.props.config; }
get createdAt() { return this.props.createdAt; }
enable() { this.props.enabled = true; }
disable() { this.props.enabled = false; }
updateConfig(config) { this.props.config = config; }
}
exports.Integration = Integration;

View File

@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookEndpoint = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const WebhookSecret_1 = require("../value-objects/WebhookSecret");
class WebhookEndpoint extends Entity_1.Entity {
static create(props, id) {
return new WebhookEndpoint({ ...props, secret: WebhookSecret_1.WebhookSecret.generate(), enabled: true, createdAt: new Date() }, id ?? UniqueId_1.UniqueId.create());
}
static reconstitute(props, id) {
return new WebhookEndpoint(props, id);
}
get url() { return this.props.url; }
get secret() { return this.props.secret; }
get enabled() { return this.props.enabled; }
get createdAt() { return this.props.createdAt; }
get lastDeliveredAt() { return this.props.lastDeliveredAt; }
get lastStatus() { return this.props.lastStatus; }
recordDelivery(statusCode) {
this.props.lastDeliveredAt = new Date();
this.props.lastStatus = statusCode;
}
enable() { this.props.enabled = true; }
disable() { this.props.enabled = false; }
}
exports.WebhookEndpoint = WebhookEndpoint;

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,18 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.IntegrationType = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
class IntegrationType extends ValueObject_1.ValueObject {
get value() { return this.props.value; }
static fromString(s) {
if (s === 'jira' || s === 'slack' || s === 'github' || s === 'webhook') {
return new IntegrationType({ value: s });
}
throw new Error(`Invalid integration type: ${s}`);
}
static jira() { return new IntegrationType({ value: 'jira' }); }
static slack() { return new IntegrationType({ value: 'slack' }); }
static github() { return new IntegrationType({ value: 'github' }); }
static webhook() { return new IntegrationType({ value: 'webhook' }); }
}
exports.IntegrationType = IntegrationType;

View File

@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookSecret = void 0;
const ValueObject_1 = require("../../../../shared/domain/ValueObject");
const crypto_1 = require("crypto");
class WebhookSecret extends ValueObject_1.ValueObject {
get value() { return this.props.value; }
static generate() {
const secret = (0, crypto_1.randomBytes)(32).toString('hex');
return new WebhookSecret({ value: secret });
}
static fromString(s) {
if (!s || s.length < 16)
throw new Error('Webhook secret must be at least 16 characters');
return new WebhookSecret({ value: s });
}
sign(payload) {
return (0, crypto_1.createHmac)('sha256', this.props.value).update(payload).digest('hex');
}
}
exports.WebhookSecret = WebhookSecret;

22
dist/modules/integrations/index.js vendored Normal file
View File

@@ -0,0 +1,22 @@
"use strict";
// Integrations module — public facade
Object.defineProperty(exports, "__esModule", { value: true });
exports.createIntegrationsRouter = exports.OnFindingCreated = exports.WebhookDispatcher = exports.KyselyWebhookEndpointRepository = exports.KyselyIntegrationRepository = exports.WebhookSecret = exports.IntegrationType = exports.WebhookEndpoint = exports.Integration = void 0;
var Integration_1 = require("./domain/entities/Integration");
Object.defineProperty(exports, "Integration", { enumerable: true, get: function () { return Integration_1.Integration; } });
var WebhookEndpoint_1 = require("./domain/entities/WebhookEndpoint");
Object.defineProperty(exports, "WebhookEndpoint", { enumerable: true, get: function () { return WebhookEndpoint_1.WebhookEndpoint; } });
var IntegrationType_1 = require("./domain/value-objects/IntegrationType");
Object.defineProperty(exports, "IntegrationType", { enumerable: true, get: function () { return IntegrationType_1.IntegrationType; } });
var WebhookSecret_1 = require("./domain/value-objects/WebhookSecret");
Object.defineProperty(exports, "WebhookSecret", { enumerable: true, get: function () { return WebhookSecret_1.WebhookSecret; } });
var KyselyIntegrationRepository_1 = require("./infrastructure/repositories/KyselyIntegrationRepository");
Object.defineProperty(exports, "KyselyIntegrationRepository", { enumerable: true, get: function () { return KyselyIntegrationRepository_1.KyselyIntegrationRepository; } });
var KyselyWebhookEndpointRepository_1 = require("./infrastructure/repositories/KyselyWebhookEndpointRepository");
Object.defineProperty(exports, "KyselyWebhookEndpointRepository", { enumerable: true, get: function () { return KyselyWebhookEndpointRepository_1.KyselyWebhookEndpointRepository; } });
var WebhookDispatcher_1 = require("./infrastructure/webhooks/WebhookDispatcher");
Object.defineProperty(exports, "WebhookDispatcher", { enumerable: true, get: function () { return WebhookDispatcher_1.WebhookDispatcher; } });
var OnFindingCreated_1 = require("./application/event-handlers/OnFindingCreated");
Object.defineProperty(exports, "OnFindingCreated", { enumerable: true, get: function () { return OnFindingCreated_1.OnFindingCreated; } });
var IntegrationsController_1 = require("./infrastructure/http/IntegrationsController");
Object.defineProperty(exports, "createIntegrationsRouter", { enumerable: true, get: function () { return IntegrationsController_1.createIntegrationsRouter; } });

View File

@@ -0,0 +1,111 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createIntegrationsRouter = createIntegrationsRouter;
const express_1 = require("express");
const Integration_1 = require("../../domain/entities/Integration");
const WebhookEndpoint_1 = require("../../domain/entities/WebhookEndpoint");
const IntegrationType_1 = require("../../domain/value-objects/IntegrationType");
function createIntegrationsRouter(deps) {
const router = (0, express_1.Router)();
const { integrationRepo, webhookRepo } = deps;
// ─── Integrations CRUD ──────────────────────────────────────────────────────
router.get('/', async (_req, res) => {
const items = await integrationRepo.findAll();
res.json(items.map(serializeIntegration));
});
router.post('/', async (req, res) => {
const { name, type, config } = req.body;
if (!name || !type) {
res.status(400).json({ error: 'name and type are required' });
return;
}
let intType;
try {
intType = IntegrationType_1.IntegrationType.fromString(type);
}
catch {
res.status(400).json({ error: `Invalid integration type: ${type}` });
return;
}
const integration = Integration_1.Integration.create({ name, type: intType, config: config ?? {} });
await integrationRepo.save(integration);
res.status(201).json(serializeIntegration(integration));
});
router.get('/:id', async (req, res) => {
const item = await integrationRepo.findById(req.params['id']);
if (!item) {
res.status(404).json({ error: 'Integration not found' });
return;
}
res.json(serializeIntegration(item));
});
router.patch('/:id', async (req, res) => {
const item = await integrationRepo.findById(req.params['id']);
if (!item) {
res.status(404).json({ error: 'Integration not found' });
return;
}
const { enabled, config } = req.body;
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, res) => {
await integrationRepo.delete(req.params['id']);
res.status(204).end();
});
// ─── Webhook Endpoints ───────────────────────────────────────────────────────
router.get('/webhooks/endpoints', async (_req, res) => {
const endpoints = await webhookRepo.findAll();
res.json(endpoints.map(serializeWebhook));
});
router.post('/webhooks/endpoints', async (req, res) => {
const { url } = req.body;
if (!url) {
res.status(400).json({ error: 'url is required' });
return;
}
const endpoint = WebhookEndpoint_1.WebhookEndpoint.create({ url });
await webhookRepo.save(endpoint);
res.status(201).json(serializeWebhook(endpoint));
});
router.delete('/webhooks/endpoints/:id', async (req, res) => {
await webhookRepo.delete(req.params['id']);
res.status(204).end();
});
return router;
}
function serializeIntegration(i) {
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) {
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) {
const masked = { ...config };
for (const key of ['token', 'secret', 'password', 'apiKey']) {
if (masked[key])
masked[key] = '***';
}
return masked;
}

View File

@@ -0,0 +1,36 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GitHubIssuesProvider = void 0;
const rest_1 = require("@octokit/rest");
class GitHubIssuesProvider {
constructor(token, repo) {
this.octokit = new rest_1.Octokit({ auth: token });
const [owner, repoName] = repo.split('/');
this.owner = owner;
this.repo = repoName;
}
async sendFinding(finding) {
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}`],
});
}
}
exports.GitHubIssuesProvider = GitHubIssuesProvider;

View File

@@ -0,0 +1,57 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.JiraProvider = void 0;
const PRIORITY_MAP = {
critical: 'Highest',
high: 'High',
medium: 'Medium',
low: 'Low',
};
class JiraProvider {
constructor(host, token, username, projectKey) {
this.host = host;
this.token = token;
this.username = username;
this.projectKey = projectKey;
}
async sendFinding(finding) {
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(15000),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Jira API error ${res.status}: ${text}`);
}
}
}
exports.JiraProvider = JiraProvider;

View File

@@ -0,0 +1,53 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SlackProvider = void 0;
const SEVERITY_COLORS = {
critical: '#dc2626',
high: '#ea580c',
medium: '#ca8a04',
low: '#2563eb',
};
class SlackProvider {
constructor(webhookUrl) {
this.webhookUrl = webhookUrl;
}
async sendFinding(finding) {
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(10000),
});
if (!res.ok) {
throw new Error(`Slack webhook failed: ${res.status} ${await res.text()}`);
}
}
}
exports.SlackProvider = SlackProvider;

View File

@@ -0,0 +1,72 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyIntegrationRepository = void 0;
const Integration_1 = require("../../domain/entities/Integration");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const IntegrationType_1 = require("../../domain/value-objects/IntegrationType");
class KyselyIntegrationRepository {
constructor(db) {
this.db = db;
}
async save(integration) {
const row = {
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) {
const row = await this.db
.selectFrom('integrations')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findAll() {
const rows = await this.db
.selectFrom('integrations')
.selectAll()
.orderBy('created_at', 'desc')
.execute();
return rows.map(r => this.toDomain(r));
}
async findEnabled() {
const rows = await this.db
.selectFrom('integrations')
.selectAll()
.where('enabled', '=', 1)
.execute();
return rows.map(r => this.toDomain(r));
}
async update(integration) {
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) {
await this.db.deleteFrom('integrations').where('id', '=', id).execute();
}
toDomain(row) {
const config = JSON.parse(row.config_json);
const props = {
name: row.name,
type: IntegrationType_1.IntegrationType.fromString(row.type),
enabled: row.enabled === 1,
config,
createdAt: new Date(row.created_at),
};
return Integration_1.Integration.reconstitute(props, UniqueId_1.UniqueId.from(row.id));
}
}
exports.KyselyIntegrationRepository = KyselyIntegrationRepository;

View File

@@ -0,0 +1,73 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyWebhookEndpointRepository = void 0;
const WebhookEndpoint_1 = require("../../domain/entities/WebhookEndpoint");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const WebhookSecret_1 = require("../../domain/value-objects/WebhookSecret");
class KyselyWebhookEndpointRepository {
constructor(db) {
this.db = db;
}
async save(endpoint) {
const row = {
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) {
const row = await this.db
.selectFrom('webhook_endpoints')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findAll() {
const rows = await this.db
.selectFrom('webhook_endpoints')
.selectAll()
.orderBy('created_at', 'desc')
.execute();
return rows.map(r => this.toDomain(r));
}
async findEnabled() {
const rows = await this.db
.selectFrom('webhook_endpoints')
.selectAll()
.where('enabled', '=', 1)
.execute();
return rows.map(r => this.toDomain(r));
}
async update(endpoint) {
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) {
await this.db.deleteFrom('webhook_endpoints').where('id', '=', id).execute();
}
toDomain(row) {
const props = {
url: row.url,
secret: WebhookSecret_1.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_1.WebhookEndpoint.reconstitute(props, UniqueId_1.UniqueId.from(row.id));
}
}
exports.KyselyWebhookEndpointRepository = KyselyWebhookEndpointRepository;

View File

@@ -0,0 +1,81 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookDispatcher = void 0;
const MAX_ATTEMPTS = 3;
const BASE_DELAY_MS = 1000;
class WebhookDispatcher {
constructor(endpointRepo, logger) {
this.endpointRepo = endpointRepo;
this.logger = logger;
}
async dispatchFinding(finding) {
const endpoints = await this.endpointRepo.findEnabled();
await Promise.allSettled(endpoints.map(ep => this.deliverWithRetry(ep.url, ep.secret.value, finding)));
}
async deliverWithRetry(url, secret, payload) {
const body = JSON.stringify({ event: 'finding.created', data: payload });
const { createHmac } = await Promise.resolve().then(() => __importStar(require('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(10000),
});
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');
}
}
exports.WebhookDispatcher = WebhookDispatcher;
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -19,6 +19,7 @@ import { OrganizationSection } from '@/pages/settings/OrganizationSection'
import { ApiKeysSection } from '@/pages/settings/ApiKeysSection'
import { ExplorationDefaultsSection } from '@/pages/settings/ExplorationDefaultsSection'
import { NotificationsSection } from '@/pages/settings/NotificationsSection'
import { IntegrationsSection } from '@/pages/settings/IntegrationsSection'
import { AppearanceSection } from '@/pages/settings/AppearanceSection'
import { LicenseSection } from '@/pages/settings/LicenseSection'
import { Reports } from '@/pages/Reports'
@@ -57,6 +58,7 @@ export default function App() {
<Route path="api-keys" element={<ApiKeysSection />} />
<Route path="defaults" element={<ExplorationDefaultsSection />} />
<Route path="notifications" element={<NotificationsSection />} />
<Route path="integrations" element={<IntegrationsSection />} />
<Route path="appearance" element={<AppearanceSection />} />
<Route path="license" element={<LicenseSection />} />
</Route>

View File

@@ -0,0 +1,419 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Trash2, Plus, Webhook, ExternalLink } from 'lucide-react'
import { toast } from 'sonner'
interface Integration {
id: string
name: string
type: 'slack' | 'github' | 'jira' | 'webhook'
enabled: boolean
config: Record<string, unknown>
createdAt: string
}
interface WebhookEndpoint {
id: string
url: string
enabled: boolean
createdAt: string
lastDeliveredAt: string | null
lastStatus: number | null
}
const TYPE_LABELS: Record<string, string> = {
slack: 'Slack',
github: 'GitHub Issues',
jira: 'Jira',
webhook: 'Custom Webhook',
}
export function IntegrationsSection() {
const queryClient = useQueryClient()
const { data: integrations = [], isLoading: loadingInt } = useQuery<Integration[]>({
queryKey: ['integrations'],
queryFn: () => apiFetch<Integration[]>('/api/integrations'),
})
const { data: webhooks = [], isLoading: loadingWebhooks } = useQuery<WebhookEndpoint[]>({
queryKey: ['webhooks'],
queryFn: () => apiFetch<WebhookEndpoint[]>('/api/integrations/webhooks/endpoints'),
})
const [addDialog, setAddDialog] = useState(false)
const [addWebhookDialog, setAddWebhookDialog] = useState(false)
const [newIntType, setNewIntType] = useState<string>('slack')
const [newIntName, setNewIntName] = useState('')
const [newIntConfig, setNewIntConfig] = useState<Record<string, string>>({})
const [newWebhookUrl, setNewWebhookUrl] = useState('')
const [saving, setSaving] = useState(false)
async function handleAddIntegration() {
setSaving(true)
try {
await apiFetch('/api/integrations', {
method: 'POST',
body: JSON.stringify({ name: newIntName, type: newIntType, config: newIntConfig }),
})
await queryClient.invalidateQueries({ queryKey: ['integrations'] })
setAddDialog(false)
setNewIntName('')
setNewIntConfig({})
toast.success('Integration added')
} catch {
toast.error('Failed to add integration')
} finally {
setSaving(false)
}
}
async function handleToggle(integration: Integration) {
await apiFetch(`/api/integrations/${integration.id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled: !integration.enabled }),
})
await queryClient.invalidateQueries({ queryKey: ['integrations'] })
}
async function handleDelete(id: string) {
await apiFetch(`/api/integrations/${id}`, { method: 'DELETE' })
await queryClient.invalidateQueries({ queryKey: ['integrations'] })
toast.success('Integration removed')
}
async function handleAddWebhook() {
setSaving(true)
try {
await apiFetch('/api/integrations/webhooks/endpoints', {
method: 'POST',
body: JSON.stringify({ url: newWebhookUrl }),
})
await queryClient.invalidateQueries({ queryKey: ['webhooks'] })
setAddWebhookDialog(false)
setNewWebhookUrl('')
toast.success('Webhook endpoint added')
} catch {
toast.error('Failed to add webhook')
} finally {
setSaving(false)
}
}
async function handleDeleteWebhook(id: string) {
await apiFetch(`/api/integrations/webhooks/endpoints/${id}`, { method: 'DELETE' })
await queryClient.invalidateQueries({ queryKey: ['webhooks'] })
toast.success('Webhook endpoint removed')
}
const isLoading = loadingInt || loadingWebhooks
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>
return (
<div className="space-y-8 max-w-2xl">
<div>
<h2 className="text-lg font-semibold">Integrations</h2>
<p className="text-sm text-muted-foreground">
Connect ABE to Slack, GitHub, Jira, or custom webhooks to receive findings automatically.
</p>
</div>
{/* Named integrations (Slack, GitHub, Jira) */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Configured Integrations</h3>
<Button size="sm" variant="outline" onClick={() => setAddDialog(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Integration
</Button>
</div>
{integrations.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No integrations configured. Add Slack, GitHub, or Jira to route findings automatically.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{integrations.map(integration => (
<Card key={integration.id}>
<CardContent className="py-3 px-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{integration.name}</p>
<p className="text-xs text-muted-foreground">
{TYPE_LABELS[integration.type] ?? integration.type}
</p>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<Badge variant={integration.enabled ? 'default' : 'secondary'}>
{integration.enabled ? 'Active' : 'Disabled'}
</Badge>
<Switch
checked={integration.enabled}
onCheckedChange={() => handleToggle(integration)}
/>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(integration.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Custom webhook endpoints */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Custom Webhook Endpoints</h3>
<Button size="sm" variant="outline" onClick={() => setAddWebhookDialog(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Endpoint
</Button>
</div>
<p className="text-xs text-muted-foreground -mt-2">
ABE sends a signed POST request to these URLs for every new finding. Verify with the{' '}
<code className="font-mono">X-ABE-Signature</code> header (HMAC-SHA256).
</p>
{webhooks.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No webhook endpoints configured.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{webhooks.map(ep => (
<Card key={ep.id}>
<CardContent className="py-3 px-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Webhook className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-mono truncate">{ep.url}</p>
<p className="text-xs text-muted-foreground">
{ep.lastDeliveredAt
? `Last delivered: ${new Date(ep.lastDeliveredAt).toLocaleString()} — HTTP ${ep.lastStatus ?? '?'}`
: 'No deliveries yet'}
</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive shrink-0"
onClick={() => handleDeleteWebhook(ep.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Add Integration Dialog */}
<Dialog open={addDialog} onOpenChange={setAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Integration</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Integration Type</Label>
<Select value={newIntType} onValueChange={t => { setNewIntType(t); setNewIntConfig({}) }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="github">GitHub Issues</SelectItem>
<SelectItem value="jira">Jira</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="int-name">Name</Label>
<Input
id="int-name"
placeholder="e.g. Security alerts"
value={newIntName}
onChange={e => setNewIntName(e.target.value)}
/>
</div>
{newIntType === 'slack' && (
<div className="space-y-1">
<Label htmlFor="slack-url">Webhook URL</Label>
<Input
id="slack-url"
type="url"
placeholder="https://hooks.slack.com/services/..."
value={(newIntConfig.webhookUrl ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, webhookUrl: e.target.value })}
/>
</div>
)}
{newIntType === 'github' && (
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="gh-token">Personal Access Token</Label>
<Input
id="gh-token"
type="password"
placeholder="ghp_..."
value={(newIntConfig.token ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, token: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="gh-repo">Repository (owner/repo)</Label>
<Input
id="gh-repo"
placeholder="myorg/myrepo"
value={(newIntConfig.repo ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, repo: e.target.value })}
/>
</div>
</div>
)}
{newIntType === 'jira' && (
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="jira-host">Jira Host</Label>
<Input
id="jira-host"
type="url"
placeholder="https://yourorg.atlassian.net"
value={(newIntConfig.host ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, host: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="jira-user">Username (email)</Label>
<Input
id="jira-user"
placeholder="user@example.com"
value={(newIntConfig.username ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, username: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="jira-token">API Token</Label>
<Input
id="jira-token"
type="password"
value={(newIntConfig.token ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, token: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="jira-project">Project Key</Label>
<Input
id="jira-project"
placeholder="SEC"
value={(newIntConfig.projectKey ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, projectKey: e.target.value })}
/>
</div>
</div>
)}
<Card className="border-dashed">
<CardHeader className="pb-2 pt-3 px-3">
<CardTitle className="text-xs text-muted-foreground">Minimum Severity</CardTitle>
</CardHeader>
<CardContent className="pb-3 px-3">
<Select
value={(newIntConfig.minSeverity ?? 'low') as string}
onValueChange={v => setNewIntConfig({ ...newIntConfig, minSeverity: v })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low+</SelectItem>
<SelectItem value="medium">Medium+</SelectItem>
<SelectItem value="high">High+</SelectItem>
<SelectItem value="critical">Critical only</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialog(false)}>Cancel</Button>
<Button onClick={handleAddIntegration} disabled={saving || !newIntName}>
{saving ? 'Adding...' : 'Add Integration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Webhook Endpoint Dialog */}
<Dialog open={addWebhookDialog} onOpenChange={setAddWebhookDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Webhook Endpoint</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label htmlFor="webhook-url">Endpoint URL</Label>
<Input
id="webhook-url"
type="url"
placeholder="https://your-server.com/webhooks/abe"
value={newWebhookUrl}
onChange={e => setNewWebhookUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
ABE will POST a JSON payload signed with HMAC-SHA256 to this URL.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddWebhookDialog(false)}>Cancel</Button>
<Button onClick={handleAddWebhook} disabled={saving || !newWebhookUrl}>
{saving ? 'Adding...' : 'Add Endpoint'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { NavLink, Outlet } from 'react-router-dom'
import { User, Building, Key, Sliders, Bell, Palette, Shield } from 'lucide-react'
import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug } from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
@@ -8,6 +8,7 @@ const navItems = [
{ label: 'API Keys', href: '/settings/api-keys', icon: Key },
{ label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders },
{ label: 'Notifications', href: '/settings/notifications', icon: Bell },
{ label: 'Integrations', href: '/settings/integrations', icon: Plug },
{ label: 'Appearance', href: '/settings/appearance', icon: Palette },
{ label: 'License', href: '/settings/license', icon: Shield },
]

361
package-lock.json generated
View File

@@ -11,7 +11,9 @@
"dependencies": {
"@axe-core/playwright": "^4.11.1",
"@casl/ability": "^6.8.0",
"@octokit/rest": "^22.0.1",
"@playwright/test": "^1.40.0",
"@slack/web-api": "^7.14.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-rate-limit": "^5.1.3",
@@ -1507,6 +1509,161 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@octokit/auth-token": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
"integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/core": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz",
"integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==",
"license": "MIT",
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.3",
"@octokit/request": "^10.0.6",
"@octokit/request-error": "^7.0.2",
"@octokit/types": "^16.0.0",
"before-after-hook": "^4.0.0",
"universal-user-agent": "^7.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/endpoint": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz",
"integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==",
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0",
"universal-user-agent": "^7.0.2"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/graphql": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz",
"integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==",
"license": "MIT",
"dependencies": {
"@octokit/request": "^10.0.6",
"@octokit/types": "^16.0.0",
"universal-user-agent": "^7.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/openapi-types": {
"version": "27.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz",
"integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==",
"license": "MIT"
},
"node_modules/@octokit/plugin-paginate-rest": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz",
"integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==",
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"@octokit/core": ">=6"
}
},
"node_modules/@octokit/plugin-request-log": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz",
"integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"@octokit/core": ">=6"
}
},
"node_modules/@octokit/plugin-rest-endpoint-methods": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz",
"integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==",
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"@octokit/core": ">=6"
}
},
"node_modules/@octokit/request": {
"version": "10.0.8",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz",
"integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==",
"license": "MIT",
"dependencies": {
"@octokit/endpoint": "^11.0.3",
"@octokit/request-error": "^7.0.2",
"@octokit/types": "^16.0.0",
"fast-content-type-parse": "^3.0.0",
"json-with-bigint": "^3.5.3",
"universal-user-agent": "^7.0.2"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/request-error": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz",
"integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==",
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/rest": {
"version": "22.0.1",
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz",
"integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==",
"license": "MIT",
"dependencies": {
"@octokit/core": "^7.0.6",
"@octokit/plugin-paginate-rest": "^14.0.0",
"@octokit/plugin-request-log": "^6.0.0",
"@octokit/plugin-rest-endpoint-methods": "^17.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@octokit/types": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz",
"integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==",
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^27.0.0"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
@@ -1610,6 +1767,53 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@slack/logger": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz",
"integrity": "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==",
"license": "MIT",
"dependencies": {
"@types/node": ">=18.0.0"
},
"engines": {
"node": ">= 18",
"npm": ">= 8.6.0"
}
},
"node_modules/@slack/types": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/@slack/types/-/types-2.20.0.tgz",
"integrity": "sha512-PVF6P6nxzDMrzPC8fSCsnwaI+kF8YfEpxf3MqXmdyjyWTYsZQURpkK7WWUWvP5QpH55pB7zyYL9Qem/xSgc5VA==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0",
"npm": ">= 6.12.0"
}
},
"node_modules/@slack/web-api": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.14.1.tgz",
"integrity": "sha512-RoygyteJeFswxDPJjUMESn9dldWVMD2xUcHHd9DenVavSfVC6FeVnSdDerOO7m8LLvw4Q132nQM4hX8JiF7dng==",
"license": "MIT",
"dependencies": {
"@slack/logger": "^4.0.0",
"@slack/types": "^2.20.0",
"@types/node": ">=18.0.0",
"@types/retry": "0.12.0",
"axios": "^1.13.5",
"eventemitter3": "^5.0.1",
"form-data": "^4.0.4",
"is-electron": "2.2.2",
"is-stream": "^2",
"p-queue": "^6",
"p-retry": "^4",
"retry": "^0.13.1"
},
"engines": {
"node": ">= 18",
"npm": ">= 8.6.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -1948,6 +2152,12 @@
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"license": "MIT"
},
"node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -2202,7 +2412,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/atomic-sleep": {
@@ -2223,6 +2432,17 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -2388,6 +2608,12 @@
"node": ">=6.0.0"
}
},
"node_modules/before-after-hook": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
"integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==",
"license": "Apache-2.0"
},
"node_modules/better-sqlite3": {
"version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
@@ -2765,7 +2991,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -3156,7 +3381,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -3414,7 +3638,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3611,6 +3834,22 @@
"express": ">= 4.11"
}
},
"node_modules/fast-content-type-parse": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
"integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/fast-copy": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
@@ -3694,11 +3933,30 @@
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -3715,7 +3973,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3725,7 +3982,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -3982,7 +4238,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -4208,6 +4463,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-electron": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
"integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==",
"license": "MIT"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -4248,7 +4509,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4993,6 +5253,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-with-bigint": {
"version": "3.5.7",
"resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz",
"integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -5459,6 +5725,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -5504,6 +5779,53 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-queue/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"license": "MIT",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -5867,6 +6189,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -6153,6 +6481,15 @@
"node": ">=10"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -7107,6 +7444,12 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/universal-user-agent": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
"integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==",
"license": "ISC"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -38,7 +38,9 @@
"dependencies": {
"@axe-core/playwright": "^4.11.1",
"@casl/ability": "^6.8.0",
"@octokit/rest": "^22.0.1",
"@playwright/test": "^1.40.0",
"@slack/web-api": "^7.14.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-rate-limit": "^5.1.3",

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

View File

@@ -0,0 +1,203 @@
import { createHmac } from 'crypto';
import { Integration } from '../../src/modules/integrations/domain/entities/Integration';
import { IntegrationType } from '../../src/modules/integrations/domain/value-objects/IntegrationType';
import { WebhookEndpoint } from '../../src/modules/integrations/domain/entities/WebhookEndpoint';
import { WebhookSecret } from '../../src/modules/integrations/domain/value-objects/WebhookSecret';
import { WebhookDispatcher } from '../../src/modules/integrations/infrastructure/webhooks/WebhookDispatcher';
import { FindingPayload } from '../../src/modules/integrations/domain/ports/IIntegrationProvider';
import { IWebhookEndpointRepository } from '../../src/modules/integrations/domain/ports/IWebhookEndpointRepository';
import { Logger } from 'pino';
// ─── Integration Entity ───────────────────────────────────────────────────────
describe('Integration', () => {
it('creates with defaults', () => {
const integration = Integration.create({
name: 'My Slack',
type: IntegrationType.slack(),
config: { webhookUrl: 'https://hooks.slack.com/test' },
});
expect(integration.name).toBe('My Slack');
expect(integration.type.value).toBe('slack');
expect(integration.enabled).toBe(true);
expect(integration.config.webhookUrl).toBe('https://hooks.slack.com/test');
});
it('enable and disable', () => {
const integration = Integration.create({
name: 'Test',
type: IntegrationType.github(),
config: {},
});
integration.disable();
expect(integration.enabled).toBe(false);
integration.enable();
expect(integration.enabled).toBe(true);
});
it('updateConfig merges config', () => {
const integration = Integration.create({
name: 'Jira',
type: IntegrationType.jira(),
config: { host: 'https://old.atlassian.net' },
});
integration.updateConfig({ host: 'https://new.atlassian.net', token: 'tok' });
expect(integration.config.host).toBe('https://new.atlassian.net');
expect(integration.config.token).toBe('tok');
});
});
// ─── IntegrationType ──────────────────────────────────────────────────────────
describe('IntegrationType', () => {
it('parses all valid types', () => {
expect(IntegrationType.fromString('slack').value).toBe('slack');
expect(IntegrationType.fromString('github').value).toBe('github');
expect(IntegrationType.fromString('jira').value).toBe('jira');
expect(IntegrationType.fromString('webhook').value).toBe('webhook');
});
it('throws on invalid type', () => {
expect(() => IntegrationType.fromString('unknown')).toThrow();
});
});
// ─── WebhookEndpoint ──────────────────────────────────────────────────────────
describe('WebhookEndpoint', () => {
it('creates with auto-generated secret', () => {
const endpoint = WebhookEndpoint.create({ url: 'https://example.com/hook' });
expect(endpoint.url).toBe('https://example.com/hook');
expect(endpoint.enabled).toBe(true);
expect(endpoint.secret.value).toBeTruthy();
expect(endpoint.secret.value.length).toBeGreaterThan(20);
});
it('records delivery', () => {
const endpoint = WebhookEndpoint.create({ url: 'https://example.com/hook' });
expect(endpoint.lastStatus).toBeUndefined();
endpoint.recordDelivery(200);
expect(endpoint.lastStatus).toBe(200);
expect(endpoint.lastDeliveredAt).toBeDefined();
});
});
// ─── WebhookSecret ────────────────────────────────────────────────────────────
describe('WebhookSecret', () => {
it('generates a secret', () => {
const s = WebhookSecret.generate();
expect(s.value.length).toBeGreaterThan(20);
});
it('fromString round-trips', () => {
const s = WebhookSecret.fromString('mysecret-at-least-16chars');
expect(s.value).toBe('mysecret-at-least-16chars');
});
it('throws when secret too short', () => {
expect(() => WebhookSecret.fromString('short')).toThrow();
});
});
// ─── HMAC signature verification ─────────────────────────────────────────────
describe('HMAC webhook signature', () => {
it('produces valid sha256 signature', () => {
const secret = 'test-secret-abc123';
const body = JSON.stringify({ event: 'finding.created', data: { id: '1' } });
const sig = createHmac('sha256', secret).update(body).digest('hex');
expect(sig).toBeTruthy();
expect(sig).toMatch(/^[0-9a-f]{64}$/);
});
it('same body + secret → same signature', () => {
const secret = 'test-secret';
const body = 'hello world';
const sig1 = createHmac('sha256', secret).update(body).digest('hex');
const sig2 = createHmac('sha256', secret).update(body).digest('hex');
expect(sig1).toBe(sig2);
});
it('different body → different signature', () => {
const secret = 'test-secret';
const sig1 = createHmac('sha256', secret).update('body1').digest('hex');
const sig2 = createHmac('sha256', secret).update('body2').digest('hex');
expect(sig1).not.toBe(sig2);
});
});
// ─── WebhookDispatcher ───────────────────────────────────────────────────────
describe('WebhookDispatcher', () => {
const silentLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as Logger;
it('calls fetch for each enabled endpoint', async () => {
const secret = WebhookSecret.fromString('secret123456789abcdef');
const endpoint = WebhookEndpoint.reconstitute(
{ url: 'https://example.com/hook', secret, enabled: true, createdAt: new Date() },
{ toString: () => 'ep-1', equals: () => false } as never
);
const mockRepo: IWebhookEndpointRepository = {
save: jest.fn(),
findById: jest.fn(),
findAll: jest.fn(),
findEnabled: jest.fn().mockResolvedValue([endpoint]),
update: jest.fn(),
delete: jest.fn(),
};
const fetchMock = jest.fn().mockResolvedValue({ status: 200, ok: true });
global.fetch = fetchMock;
const dispatcher = new WebhookDispatcher(mockRepo, silentLogger);
const finding: FindingPayload = {
id: 'f-1',
title: 'XSS in login form',
severity: 'high',
type: 'xss',
description: 'Reflected XSS',
sessionId: 's-1',
};
await dispatcher.dispatchFinding(finding);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://example.com/hook');
expect(opts.method).toBe('POST');
const headers = opts.headers as Record<string, string>;
expect(headers['X-ABE-Event']).toBe('finding.created');
expect(headers['X-ABE-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/);
});
it('does not throw when no endpoints', async () => {
const mockRepo: IWebhookEndpointRepository = {
save: jest.fn(),
findById: jest.fn(),
findAll: jest.fn(),
findEnabled: jest.fn().mockResolvedValue([]),
update: jest.fn(),
delete: jest.fn(),
};
const dispatcher = new WebhookDispatcher(mockRepo, silentLogger);
const finding: FindingPayload = {
id: 'f-1',
title: 'Test',
severity: 'low',
type: 'info',
description: 'Test',
sessionId: 's-1',
};
await expect(dispatcher.dispatchFinding(finding)).resolves.toBeUndefined();
});
});

View File

@@ -11,5 +11,5 @@
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
"exclude": ["node_modules", "dist", "tests", "src/**/__tests__/**", "src/**/*.test.ts", "src/**/*.spec.ts"]
}