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