From 1f1678af17637b190210f6a2f16acff4b0ee2427 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Mar 2026 07:22:00 -0500 Subject: [PATCH] fase(16): integrations module --- .ralph/.loop_start_sha | 2 +- .ralph/progress.json | 8 +- dist/api/router.js | 2 + dist/db/migrations/006_integrations_tables.js | 44 ++ dist/main.js | 12 + .../event-handlers/OnFindingCreated.js | 61 +++ .../domain/entities/Integration.js | 22 + .../domain/entities/WebhookEndpoint.js | 27 ++ .../domain/ports/IIntegrationProvider.js | 2 + .../domain/ports/IIntegrationRepository.js | 2 + .../ports/IWebhookEndpointRepository.js | 2 + .../domain/value-objects/IntegrationType.js | 18 + .../domain/value-objects/WebhookSecret.js | 21 + dist/modules/integrations/index.js | 22 + .../http/IntegrationsController.js | 111 +++++ .../providers/GitHubIssuesProvider.js | 36 ++ .../infrastructure/providers/JiraProvider.js | 57 +++ .../infrastructure/providers/SlackProvider.js | 53 +++ .../KyselyIntegrationRepository.js | 72 +++ .../KyselyWebhookEndpointRepository.js | 73 +++ .../webhooks/WebhookDispatcher.js | 81 ++++ frontend/src/App.tsx | 2 + .../pages/settings/IntegrationsSection.tsx | 419 ++++++++++++++++++ .../src/pages/settings/SettingsLayout.tsx | 3 +- package-lock.json | 361 ++++++++++++++- package.json | 2 + src/api/router.ts | 2 + src/api/server.ts | 2 + src/db/migrations/006_integrations_tables.ts | 45 ++ src/main.ts | 14 + .../event-handlers/OnFindingCreated.ts | 85 ++++ .../domain/entities/Integration.ts | 49 ++ .../domain/entities/WebhookEndpoint.ts | 40 ++ .../domain/ports/IIntegrationProvider.ts | 14 + .../domain/ports/IIntegrationRepository.ts | 10 + .../ports/IWebhookEndpointRepository.ts | 10 + .../domain/value-objects/IntegrationType.ts | 23 + .../domain/value-objects/WebhookSecret.ts | 24 + src/modules/integrations/index.ts | 17 + .../http/IntegrationsController.ts | 121 +++++ .../providers/GitHubIssuesProvider.ts | 41 ++ .../infrastructure/providers/JiraProvider.ts | 61 +++ .../infrastructure/providers/SlackProvider.ts | 53 +++ .../KyselyIntegrationRepository.ts | 77 ++++ .../KyselyWebhookEndpointRepository.ts | 78 ++++ .../webhooks/WebhookDispatcher.ts | 54 +++ .../infrastructure/DatabaseConnection.ts | 31 ++ tests/modules/integrations.test.ts | 203 +++++++++ tsconfig.json | 2 +- 49 files changed, 2558 insertions(+), 13 deletions(-) create mode 100644 dist/db/migrations/006_integrations_tables.js create mode 100644 dist/modules/integrations/application/event-handlers/OnFindingCreated.js create mode 100644 dist/modules/integrations/domain/entities/Integration.js create mode 100644 dist/modules/integrations/domain/entities/WebhookEndpoint.js create mode 100644 dist/modules/integrations/domain/ports/IIntegrationProvider.js create mode 100644 dist/modules/integrations/domain/ports/IIntegrationRepository.js create mode 100644 dist/modules/integrations/domain/ports/IWebhookEndpointRepository.js create mode 100644 dist/modules/integrations/domain/value-objects/IntegrationType.js create mode 100644 dist/modules/integrations/domain/value-objects/WebhookSecret.js create mode 100644 dist/modules/integrations/index.js create mode 100644 dist/modules/integrations/infrastructure/http/IntegrationsController.js create mode 100644 dist/modules/integrations/infrastructure/providers/GitHubIssuesProvider.js create mode 100644 dist/modules/integrations/infrastructure/providers/JiraProvider.js create mode 100644 dist/modules/integrations/infrastructure/providers/SlackProvider.js create mode 100644 dist/modules/integrations/infrastructure/repositories/KyselyIntegrationRepository.js create mode 100644 dist/modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository.js create mode 100644 dist/modules/integrations/infrastructure/webhooks/WebhookDispatcher.js create mode 100644 frontend/src/pages/settings/IntegrationsSection.tsx create mode 100644 src/db/migrations/006_integrations_tables.ts create mode 100644 src/modules/integrations/application/event-handlers/OnFindingCreated.ts create mode 100644 src/modules/integrations/domain/entities/Integration.ts create mode 100644 src/modules/integrations/domain/entities/WebhookEndpoint.ts create mode 100644 src/modules/integrations/domain/ports/IIntegrationProvider.ts create mode 100644 src/modules/integrations/domain/ports/IIntegrationRepository.ts create mode 100644 src/modules/integrations/domain/ports/IWebhookEndpointRepository.ts create mode 100644 src/modules/integrations/domain/value-objects/IntegrationType.ts create mode 100644 src/modules/integrations/domain/value-objects/WebhookSecret.ts create mode 100644 src/modules/integrations/index.ts create mode 100644 src/modules/integrations/infrastructure/http/IntegrationsController.ts create mode 100644 src/modules/integrations/infrastructure/providers/GitHubIssuesProvider.ts create mode 100644 src/modules/integrations/infrastructure/providers/JiraProvider.ts create mode 100644 src/modules/integrations/infrastructure/providers/SlackProvider.ts create mode 100644 src/modules/integrations/infrastructure/repositories/KyselyIntegrationRepository.ts create mode 100644 src/modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository.ts create mode 100644 src/modules/integrations/infrastructure/webhooks/WebhookDispatcher.ts create mode 100644 tests/modules/integrations.test.ts diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha index 51ce34c..81c7f2f 100644 --- a/.ralph/.loop_start_sha +++ b/.ralph/.loop_start_sha @@ -1 +1 @@ -3ff36f0b6a2c3e92b24febd488ef6abfe37ada6a +cffa1aeea99f01504bc6c016e12fc62ba63977c7 diff --git a/.ralph/progress.json b/.ralph/progress.json index ba5c6d3..077093e 100644 --- a/.ralph/progress.json +++ b/.ralph/progress.json @@ -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" +} diff --git a/dist/api/router.js b/dist/api/router.js index c7b4f2b..9b6f1a0 100644 --- a/dist/api/router.js +++ b/dist/api/router.js @@ -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; } diff --git a/dist/db/migrations/006_integrations_tables.js b/dist/db/migrations/006_integrations_tables.js new file mode 100644 index 0000000..5b5f594 --- /dev/null +++ b/dist/db/migrations/006_integrations_tables.js @@ -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(); +} diff --git a/dist/main.js b/dist/main.js index c71c5d0..722edef 100644 --- a/dist/main.js +++ b/dist/main.js @@ -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, diff --git a/dist/modules/integrations/application/event-handlers/OnFindingCreated.js b/dist/modules/integrations/application/event-handlers/OnFindingCreated.js new file mode 100644 index 0000000..3c487b2 --- /dev/null +++ b/dist/modules/integrations/application/event-handlers/OnFindingCreated.js @@ -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); +} diff --git a/dist/modules/integrations/domain/entities/Integration.js b/dist/modules/integrations/domain/entities/Integration.js new file mode 100644 index 0000000..5170558 --- /dev/null +++ b/dist/modules/integrations/domain/entities/Integration.js @@ -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; diff --git a/dist/modules/integrations/domain/entities/WebhookEndpoint.js b/dist/modules/integrations/domain/entities/WebhookEndpoint.js new file mode 100644 index 0000000..89637e1 --- /dev/null +++ b/dist/modules/integrations/domain/entities/WebhookEndpoint.js @@ -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; diff --git a/dist/modules/integrations/domain/ports/IIntegrationProvider.js b/dist/modules/integrations/domain/ports/IIntegrationProvider.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/integrations/domain/ports/IIntegrationProvider.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/integrations/domain/ports/IIntegrationRepository.js b/dist/modules/integrations/domain/ports/IIntegrationRepository.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/integrations/domain/ports/IIntegrationRepository.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/integrations/domain/ports/IWebhookEndpointRepository.js b/dist/modules/integrations/domain/ports/IWebhookEndpointRepository.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/integrations/domain/ports/IWebhookEndpointRepository.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/integrations/domain/value-objects/IntegrationType.js b/dist/modules/integrations/domain/value-objects/IntegrationType.js new file mode 100644 index 0000000..0416954 --- /dev/null +++ b/dist/modules/integrations/domain/value-objects/IntegrationType.js @@ -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; diff --git a/dist/modules/integrations/domain/value-objects/WebhookSecret.js b/dist/modules/integrations/domain/value-objects/WebhookSecret.js new file mode 100644 index 0000000..ecf1dda --- /dev/null +++ b/dist/modules/integrations/domain/value-objects/WebhookSecret.js @@ -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; diff --git a/dist/modules/integrations/index.js b/dist/modules/integrations/index.js new file mode 100644 index 0000000..fb00b0e --- /dev/null +++ b/dist/modules/integrations/index.js @@ -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; } }); diff --git a/dist/modules/integrations/infrastructure/http/IntegrationsController.js b/dist/modules/integrations/infrastructure/http/IntegrationsController.js new file mode 100644 index 0000000..d632fc0 --- /dev/null +++ b/dist/modules/integrations/infrastructure/http/IntegrationsController.js @@ -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; +} diff --git a/dist/modules/integrations/infrastructure/providers/GitHubIssuesProvider.js b/dist/modules/integrations/infrastructure/providers/GitHubIssuesProvider.js new file mode 100644 index 0000000..b179e85 --- /dev/null +++ b/dist/modules/integrations/infrastructure/providers/GitHubIssuesProvider.js @@ -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; diff --git a/dist/modules/integrations/infrastructure/providers/JiraProvider.js b/dist/modules/integrations/infrastructure/providers/JiraProvider.js new file mode 100644 index 0000000..15b3dfe --- /dev/null +++ b/dist/modules/integrations/infrastructure/providers/JiraProvider.js @@ -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; diff --git a/dist/modules/integrations/infrastructure/providers/SlackProvider.js b/dist/modules/integrations/infrastructure/providers/SlackProvider.js new file mode 100644 index 0000000..62af5b8 --- /dev/null +++ b/dist/modules/integrations/infrastructure/providers/SlackProvider.js @@ -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; diff --git a/dist/modules/integrations/infrastructure/repositories/KyselyIntegrationRepository.js b/dist/modules/integrations/infrastructure/repositories/KyselyIntegrationRepository.js new file mode 100644 index 0000000..976d5c2 --- /dev/null +++ b/dist/modules/integrations/infrastructure/repositories/KyselyIntegrationRepository.js @@ -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; diff --git a/dist/modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository.js b/dist/modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository.js new file mode 100644 index 0000000..d4e32dc --- /dev/null +++ b/dist/modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository.js @@ -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; diff --git a/dist/modules/integrations/infrastructure/webhooks/WebhookDispatcher.js b/dist/modules/integrations/infrastructure/webhooks/WebhookDispatcher.js new file mode 100644 index 0000000..12302b6 --- /dev/null +++ b/dist/modules/integrations/infrastructure/webhooks/WebhookDispatcher.js @@ -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)); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13ee5d7..67378d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/settings/IntegrationsSection.tsx b/frontend/src/pages/settings/IntegrationsSection.tsx new file mode 100644 index 0000000..f36bdf9 --- /dev/null +++ b/frontend/src/pages/settings/IntegrationsSection.tsx @@ -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 + createdAt: string +} + +interface WebhookEndpoint { + id: string + url: string + enabled: boolean + createdAt: string + lastDeliveredAt: string | null + lastStatus: number | null +} + +const TYPE_LABELS: Record = { + slack: 'Slack', + github: 'GitHub Issues', + jira: 'Jira', + webhook: 'Custom Webhook', +} + +export function IntegrationsSection() { + const queryClient = useQueryClient() + + const { data: integrations = [], isLoading: loadingInt } = useQuery({ + queryKey: ['integrations'], + queryFn: () => apiFetch('/api/integrations'), + }) + + const { data: webhooks = [], isLoading: loadingWebhooks } = useQuery({ + queryKey: ['webhooks'], + queryFn: () => apiFetch('/api/integrations/webhooks/endpoints'), + }) + + const [addDialog, setAddDialog] = useState(false) + const [addWebhookDialog, setAddWebhookDialog] = useState(false) + const [newIntType, setNewIntType] = useState('slack') + const [newIntName, setNewIntName] = useState('') + const [newIntConfig, setNewIntConfig] = useState>({}) + 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

Loading...

+ + return ( +
+
+

Integrations

+

+ Connect ABE to Slack, GitHub, Jira, or custom webhooks to receive findings automatically. +

+
+ + {/* Named integrations (Slack, GitHub, Jira) */} +
+
+

Configured Integrations

+ +
+ + {integrations.length === 0 ? ( + + + No integrations configured. Add Slack, GitHub, or Jira to route findings automatically. + + + ) : ( +
+ {integrations.map(integration => ( + + +
+ +
+

{integration.name}

+

+ {TYPE_LABELS[integration.type] ?? integration.type} +

+
+
+
+ + {integration.enabled ? 'Active' : 'Disabled'} + + handleToggle(integration)} + /> + +
+
+
+ ))} +
+ )} +
+ + {/* Custom webhook endpoints */} +
+
+

Custom Webhook Endpoints

+ +
+

+ ABE sends a signed POST request to these URLs for every new finding. Verify with the{' '} + X-ABE-Signature header (HMAC-SHA256). +

+ + {webhooks.length === 0 ? ( + + + No webhook endpoints configured. + + + ) : ( +
+ {webhooks.map(ep => ( + + +
+ +
+

{ep.url}

+

+ {ep.lastDeliveredAt + ? `Last delivered: ${new Date(ep.lastDeliveredAt).toLocaleString()} — HTTP ${ep.lastStatus ?? '?'}` + : 'No deliveries yet'} +

+
+
+ +
+
+ ))} +
+ )} +
+ + {/* Add Integration Dialog */} + + + + Add Integration + +
+
+ + +
+
+ + setNewIntName(e.target.value)} + /> +
+ + {newIntType === 'slack' && ( +
+ + setNewIntConfig({ ...newIntConfig, webhookUrl: e.target.value })} + /> +
+ )} + + {newIntType === 'github' && ( +
+
+ + setNewIntConfig({ ...newIntConfig, token: e.target.value })} + /> +
+
+ + setNewIntConfig({ ...newIntConfig, repo: e.target.value })} + /> +
+
+ )} + + {newIntType === 'jira' && ( +
+
+ + setNewIntConfig({ ...newIntConfig, host: e.target.value })} + /> +
+
+ + setNewIntConfig({ ...newIntConfig, username: e.target.value })} + /> +
+
+ + setNewIntConfig({ ...newIntConfig, token: e.target.value })} + /> +
+
+ + setNewIntConfig({ ...newIntConfig, projectKey: e.target.value })} + /> +
+
+ )} + + + + Minimum Severity + + + + + +
+ + + + +
+
+ + {/* Add Webhook Endpoint Dialog */} + + + + Add Webhook Endpoint + +
+
+ + setNewWebhookUrl(e.target.value)} + /> +

+ ABE will POST a JSON payload signed with HMAC-SHA256 to this URL. +

+
+
+ + + + +
+
+
+ ) +} diff --git a/frontend/src/pages/settings/SettingsLayout.tsx b/frontend/src/pages/settings/SettingsLayout.tsx index 1c7e681..0151638 100644 --- a/frontend/src/pages/settings/SettingsLayout.tsx +++ b/frontend/src/pages/settings/SettingsLayout.tsx @@ -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 }, ] diff --git a/package-lock.json b/package-lock.json index 1d8afac..f92ee8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5013643..76fb991 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/router.ts b/src/api/router.ts index ec34400..febd901 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -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; } diff --git a/src/api/server.ts b/src/api/server.ts index 5dad985..633088e 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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; } diff --git a/src/db/migrations/006_integrations_tables.ts b/src/db/migrations/006_integrations_tables.ts new file mode 100644 index 0000000..f0d7fa2 --- /dev/null +++ b/src/db/migrations/006_integrations_tables.ts @@ -0,0 +1,45 @@ +import { Kysely } from 'kysely'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function up(db: Kysely): Promise { + 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): Promise { + await db.schema.dropTable('webhook_deliveries').ifExists().execute(); + await db.schema.dropTable('webhook_endpoints').ifExists().execute(); + await db.schema.dropTable('integrations').ifExists().execute(); +} diff --git a/src/main.ts b/src/main.ts index d4d7cad..64ac514 100644 --- a/src/main.ts +++ b/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 { // 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 { findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding }, fuzzingDeps: { runFuzz, repository: fuzzRepo }, reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue }, + integrationsDeps: { integrationRepo, webhookRepo }, authDeps: { registerCommand, loginCommand, diff --git a/src/modules/integrations/application/event-handlers/OnFindingCreated.ts b/src/modules/integrations/application/event-handlers/OnFindingCreated.ts new file mode 100644 index 0000000..904ee38 --- /dev/null +++ b/src/modules/integrations/application/event-handlers/OnFindingCreated.ts @@ -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 { + 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); +} diff --git a/src/modules/integrations/domain/entities/Integration.ts b/src/modules/integrations/domain/entities/Integration.ts new file mode 100644 index 0000000..2f8187b --- /dev/null +++ b/src/modules/integrations/domain/entities/Integration.ts @@ -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 { + 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; } +} diff --git a/src/modules/integrations/domain/entities/WebhookEndpoint.ts b/src/modules/integrations/domain/entities/WebhookEndpoint.ts new file mode 100644 index 0000000..89ecff9 --- /dev/null +++ b/src/modules/integrations/domain/entities/WebhookEndpoint.ts @@ -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 { + 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; } +} diff --git a/src/modules/integrations/domain/ports/IIntegrationProvider.ts b/src/modules/integrations/domain/ports/IIntegrationProvider.ts new file mode 100644 index 0000000..fc2ba55 --- /dev/null +++ b/src/modules/integrations/domain/ports/IIntegrationProvider.ts @@ -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; +} diff --git a/src/modules/integrations/domain/ports/IIntegrationRepository.ts b/src/modules/integrations/domain/ports/IIntegrationRepository.ts new file mode 100644 index 0000000..8d1161b --- /dev/null +++ b/src/modules/integrations/domain/ports/IIntegrationRepository.ts @@ -0,0 +1,10 @@ +import { Integration } from '../entities/Integration'; + +export interface IIntegrationRepository { + save(integration: Integration): Promise; + findById(id: string): Promise; + findAll(): Promise; + findEnabled(): Promise; + update(integration: Integration): Promise; + delete(id: string): Promise; +} diff --git a/src/modules/integrations/domain/ports/IWebhookEndpointRepository.ts b/src/modules/integrations/domain/ports/IWebhookEndpointRepository.ts new file mode 100644 index 0000000..6cad329 --- /dev/null +++ b/src/modules/integrations/domain/ports/IWebhookEndpointRepository.ts @@ -0,0 +1,10 @@ +import { WebhookEndpoint } from '../entities/WebhookEndpoint'; + +export interface IWebhookEndpointRepository { + save(endpoint: WebhookEndpoint): Promise; + findById(id: string): Promise; + findAll(): Promise; + findEnabled(): Promise; + update(endpoint: WebhookEndpoint): Promise; + delete(id: string): Promise; +} diff --git a/src/modules/integrations/domain/value-objects/IntegrationType.ts b/src/modules/integrations/domain/value-objects/IntegrationType.ts new file mode 100644 index 0000000..27ad68d --- /dev/null +++ b/src/modules/integrations/domain/value-objects/IntegrationType.ts @@ -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 { + 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' }); } +} diff --git a/src/modules/integrations/domain/value-objects/WebhookSecret.ts b/src/modules/integrations/domain/value-objects/WebhookSecret.ts new file mode 100644 index 0000000..be818ab --- /dev/null +++ b/src/modules/integrations/domain/value-objects/WebhookSecret.ts @@ -0,0 +1,24 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { createHmac, randomBytes } from 'crypto'; + +interface WebhookSecretProps { + value: string; +} + +export class WebhookSecret extends ValueObject { + 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'); + } +} diff --git a/src/modules/integrations/index.ts b/src/modules/integrations/index.ts new file mode 100644 index 0000000..489eb23 --- /dev/null +++ b/src/modules/integrations/index.ts @@ -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'; diff --git a/src/modules/integrations/infrastructure/http/IntegrationsController.ts b/src/modules/integrations/infrastructure/http/IntegrationsController.ts new file mode 100644 index 0000000..e0fe46c --- /dev/null +++ b/src/modules/integrations/infrastructure/http/IntegrationsController.ts @@ -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 }; + 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 }; + 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): Record { + const masked = { ...config }; + for (const key of ['token', 'secret', 'password', 'apiKey']) { + if (masked[key]) masked[key] = '***'; + } + return masked; +} diff --git a/src/modules/integrations/infrastructure/providers/GitHubIssuesProvider.ts b/src/modules/integrations/infrastructure/providers/GitHubIssuesProvider.ts new file mode 100644 index 0000000..8884ef2 --- /dev/null +++ b/src/modules/integrations/infrastructure/providers/GitHubIssuesProvider.ts @@ -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 { + 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}`], + }); + } +} diff --git a/src/modules/integrations/infrastructure/providers/JiraProvider.ts b/src/modules/integrations/infrastructure/providers/JiraProvider.ts new file mode 100644 index 0000000..414fe50 --- /dev/null +++ b/src/modules/integrations/infrastructure/providers/JiraProvider.ts @@ -0,0 +1,61 @@ +import { IIntegrationProvider, FindingPayload } from '../../domain/ports/IIntegrationProvider'; + +const PRIORITY_MAP: Record = { + 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 { + 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}`); + } + } +} diff --git a/src/modules/integrations/infrastructure/providers/SlackProvider.ts b/src/modules/integrations/infrastructure/providers/SlackProvider.ts new file mode 100644 index 0000000..a33f5b7 --- /dev/null +++ b/src/modules/integrations/infrastructure/providers/SlackProvider.ts @@ -0,0 +1,53 @@ +import { IIntegrationProvider, FindingPayload } from '../../domain/ports/IIntegrationProvider'; + +const SEVERITY_COLORS: Record = { + critical: '#dc2626', + high: '#ea580c', + medium: '#ca8a04', + low: '#2563eb', +}; + +export class SlackProvider implements IIntegrationProvider { + constructor(private readonly webhookUrl: string) {} + + async sendFinding(finding: FindingPayload): Promise { + 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()}`); + } + } +} diff --git a/src/modules/integrations/infrastructure/repositories/KyselyIntegrationRepository.ts b/src/modules/integrations/infrastructure/repositories/KyselyIntegrationRepository.ts new file mode 100644 index 0000000..7adea73 --- /dev/null +++ b/src/modules/integrations/infrastructure/repositories/KyselyIntegrationRepository.ts @@ -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) {} + + async save(integration: Integration): Promise { + 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 { + const row = await this.db + .selectFrom('integrations') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + + async findAll(): Promise { + const rows = await this.db + .selectFrom('integrations') + .selectAll() + .orderBy('created_at', 'desc') + .execute(); + return rows.map(r => this.toDomain(r)); + } + + async findEnabled(): Promise { + const rows = await this.db + .selectFrom('integrations') + .selectAll() + .where('enabled', '=', 1) + .execute(); + return rows.map(r => this.toDomain(r)); + } + + async update(integration: Integration): Promise { + 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 { + 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)); + } +} diff --git a/src/modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository.ts b/src/modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository.ts new file mode 100644 index 0000000..f9be419 --- /dev/null +++ b/src/modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository.ts @@ -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) {} + + async save(endpoint: WebhookEndpoint): Promise { + 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 { + const row = await this.db + .selectFrom('webhook_endpoints') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + + async findAll(): Promise { + const rows = await this.db + .selectFrom('webhook_endpoints') + .selectAll() + .orderBy('created_at', 'desc') + .execute(); + return rows.map(r => this.toDomain(r)); + } + + async findEnabled(): Promise { + 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 { + 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 { + 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)); + } +} diff --git a/src/modules/integrations/infrastructure/webhooks/WebhookDispatcher.ts b/src/modules/integrations/infrastructure/webhooks/WebhookDispatcher.ts new file mode 100644 index 0000000..21b8c30 --- /dev/null +++ b/src/modules/integrations/infrastructure/webhooks/WebhookDispatcher.ts @@ -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 { + 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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/shared/infrastructure/DatabaseConnection.ts b/src/shared/infrastructure/DatabaseConnection.ts index 5aa0aac..e40c4e4 100644 --- a/src/shared/infrastructure/DatabaseConnection.ts +++ b/src/shared/infrastructure/DatabaseConnection.ts @@ -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 { diff --git a/tests/modules/integrations.test.ts b/tests/modules/integrations.test.ts new file mode 100644 index 0000000..dbe05b0 --- /dev/null +++ b/tests/modules/integrations.test.ts @@ -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; + 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(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index ffa0a29..e9935f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] }