From 8313f6b17ced0fd99528666ba3dd73ab65c44b38 Mon Sep 17 00:00:00 2001 From: devmocrea Date: Sat, 29 Aug 2026 09:28:49 +0100 Subject: [PATCH 1/2] feat: security event correlation, webhook IP allowlist, signed request verification, sensitive field access reports - #1177 Security Event Correlation View: backend service, migrations, Fastify routes, and admin UI component - #1178 Webhook IP Allowlist Management: CIDR matching service, migrations, API routes, and interactive test sandbox UI - #1179 Signed Request Verification Middleware: HMAC request signing keys lifecycle, verification hook, audit logs, and developer UI - #1180 Sensitive Field Access Reports: sensitive field registry, access logger, compliance report generator, and audit UI Closes #1177 Closes #1178 Closes #1179 Closes #1180 --- .../signedRequestVerification.middleware.ts | 35 ++ .../api/routes/route-groups/admin-routes.ts | 28 ++ .../routes/securityEventCorrelation.routes.ts | 100 +++++ .../api/routes/sensitiveFieldAccess.routes.ts | 97 ++++ .../signedRequestVerification.routes.ts | 86 ++++ .../api/routes/webhookIpAllowlist.routes.ts | 84 ++++ ...260829090000_security_event_correlation.ts | 49 +++ .../20260829091000_webhook_ip_allowlist.ts | 29 ++ ...60829092000_signed_request_verification.ts | 39 ++ ...29093000_sensitive_field_access_reports.ts | 57 +++ .../securityEventCorrelation.service.ts | 220 ++++++++++ .../services/sensitiveFieldAccess.service.ts | 263 +++++++++++ .../signedRequestVerification.service.ts | 260 +++++++++++ .../services/webhookIpAllowlist.service.ts | 181 ++++++++ .../securityEventCorrelation.service.test.ts | 125 ++++++ .../sensitiveFieldAccess.service.test.ts | 134 ++++++ .../signedRequestVerification.service.test.ts | 131 ++++++ .../webhookIpAllowlist.service.test.ts | 109 +++++ frontend/src/App.tsx | 16 + .../src/components/MobileNav/navigation.ts | 4 + .../pages/admin/SecurityEventCorrelation.tsx | 402 +++++++++++++++++ .../admin/SensitiveFieldAccessReport.tsx | 415 ++++++++++++++++++ .../pages/admin/SignedRequestVerification.tsx | 354 +++++++++++++++ .../src/pages/admin/WebhookIpAllowlist.tsx | 275 ++++++++++++ 24 files changed, 3493 insertions(+) create mode 100644 backend/src/api/middleware/signedRequestVerification.middleware.ts create mode 100644 backend/src/api/routes/securityEventCorrelation.routes.ts create mode 100644 backend/src/api/routes/sensitiveFieldAccess.routes.ts create mode 100644 backend/src/api/routes/signedRequestVerification.routes.ts create mode 100644 backend/src/api/routes/webhookIpAllowlist.routes.ts create mode 100644 backend/src/database/migrations/20260829090000_security_event_correlation.ts create mode 100644 backend/src/database/migrations/20260829091000_webhook_ip_allowlist.ts create mode 100644 backend/src/database/migrations/20260829092000_signed_request_verification.ts create mode 100644 backend/src/database/migrations/20260829093000_sensitive_field_access_reports.ts create mode 100644 backend/src/services/securityEventCorrelation.service.ts create mode 100644 backend/src/services/sensitiveFieldAccess.service.ts create mode 100644 backend/src/services/signedRequestVerification.service.ts create mode 100644 backend/src/services/webhookIpAllowlist.service.ts create mode 100644 backend/tests/services/securityEventCorrelation.service.test.ts create mode 100644 backend/tests/services/sensitiveFieldAccess.service.test.ts create mode 100644 backend/tests/services/signedRequestVerification.service.test.ts create mode 100644 backend/tests/services/webhookIpAllowlist.service.test.ts create mode 100644 frontend/src/pages/admin/SecurityEventCorrelation.tsx create mode 100644 frontend/src/pages/admin/SensitiveFieldAccessReport.tsx create mode 100644 frontend/src/pages/admin/SignedRequestVerification.tsx create mode 100644 frontend/src/pages/admin/WebhookIpAllowlist.tsx diff --git a/backend/src/api/middleware/signedRequestVerification.middleware.ts b/backend/src/api/middleware/signedRequestVerification.middleware.ts new file mode 100644 index 00000000..bba2aae5 --- /dev/null +++ b/backend/src/api/middleware/signedRequestVerification.middleware.ts @@ -0,0 +1,35 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { signedRequestVerificationService } from "../../services/signedRequestVerification.service.js"; + +export async function verifySignedRequestHook(request: FastifyRequest, reply: FastifyReply) { + const keyId = request.headers["x-key-id"] as string; + const timestamp = request.headers["x-timestamp"] as string; + const signature = request.headers["x-signature"] as string; + + if (!keyId || !timestamp || !signature) { + return reply.status(401).send({ + error: "Unauthorized", + message: "Missing signature headers (X-Key-Id, X-Timestamp, X-Signature required)", + statusCode: 401, + }); + } + + const result = await signedRequestVerificationService.verifySignature({ + keyId, + timestamp, + signature, + method: request.method, + path: request.url, + body: request.body, + clientIp: request.ip, + }); + + if (!result.valid) { + return reply.status(401).send({ + error: "Unauthorized", + message: result.message || "Invalid request signature", + status: result.status, + statusCode: 401, + }); + } +} diff --git a/backend/src/api/routes/route-groups/admin-routes.ts b/backend/src/api/routes/route-groups/admin-routes.ts index 03e9acfe..56ce51b0 100644 --- a/backend/src/api/routes/route-groups/admin-routes.ts +++ b/backend/src/api/routes/route-groups/admin-routes.ts @@ -33,6 +33,14 @@ import { importValidationPreviewRoutes } from "../importValidationPreview.routes import { apiKeyScopeTemplateRoutes } from "../apiKeyScopeTemplate.routes.js"; // #1168 — Failed Parse Quarantine Queue import { parseQuarantineQueueRoutes } from "../parseQuarantineQueue.routes.js"; +// #1177 — Security Event Correlation View +import { securityEventCorrelationRoutes } from "../securityEventCorrelation.routes.js"; +// #1178 — Webhook IP Allowlist Management +import { webhookIpAllowlistRoutes } from "../webhookIpAllowlist.routes.js"; +// #1179 — Signed Request Verification Middleware +import { signedRequestVerificationRoutes } from "../signedRequestVerification.routes.js"; +// #1180 — Sensitive Field Access Reports +import { sensitiveFieldAccessRoutes } from "../sensitiveFieldAccess.routes.js"; export async function registerAdminRoutes(server: FastifyInstance): Promise { server.register(apiKeysRoutes, { prefix: "/api/v1/admin/api-keys" }); @@ -121,5 +129,25 @@ export async function registerAdminRoutes(server: FastifyInstance): Promise { + const { severity, status, search } = request.query as { + severity?: string; + status?: string; + search?: string; + }; + const items = await securityEventCorrelationService.listCorrelations({ severity, status, search }); + return reply.send({ data: items }); + }); + + server.post("/", async (request, reply) => { + const body = request.body as { + title: string; + description?: string; + severity?: "low" | "medium" | "high" | "critical"; + correlationRule?: Record; + sourceSystems?: string[]; + timeWindowMinutes?: number; + createdBy?: string; + }; + + try { + const created = await securityEventCorrelationService.createCorrelation(body); + return reply.status(201).send({ data: created }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + server.get("/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const correlation = await securityEventCorrelationService.getCorrelationById(id); + if (!correlation) { + return reply.status(404).send({ error: "Not Found", message: "Correlation view not found" }); + } + return reply.send({ data: correlation }); + }); + + server.patch("/:id/status", async (request, reply) => { + const { id } = request.params as { id: string }; + const { status, updatedBy } = request.body as { + status: "active" | "investigating" | "resolved" | "archived"; + updatedBy?: string; + }; + + if (!status) { + return reply.status(400).send({ error: "Bad Request", message: "status is required" }); + } + + const updated = await securityEventCorrelationService.updateCorrelationStatus(id, status, updatedBy); + if (!updated) { + return reply.status(404).send({ error: "Not Found", message: "Correlation view not found" }); + } + return reply.send({ data: updated }); + }); + + server.get("/events/raw", async (request, reply) => { + const { correlationId, eventType, source, severity, actor, limit } = request.query as { + correlationId?: string; + eventType?: string; + source?: string; + severity?: string; + actor?: string; + limit?: string; + }; + + const events = await securityEventCorrelationService.listSecurityEvents({ + correlationId, + eventType, + source, + severity, + actor, + limit: limit ? parseInt(limit, 10) : undefined, + }); + return reply.send({ data: events }); + }); + + server.post("/events/ingest", async (request, reply) => { + const body = request.body as { + correlationId?: string; + eventType: string; + source: string; + severity?: "low" | "medium" | "high" | "critical"; + actor?: string; + ipAddress?: string; + details?: Record; + }; + + try { + const event = await securityEventCorrelationService.ingestSecurityEvent(body); + return reply.status(201).send({ data: event }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); +} diff --git a/backend/src/api/routes/sensitiveFieldAccess.routes.ts b/backend/src/api/routes/sensitiveFieldAccess.routes.ts new file mode 100644 index 00000000..7e46be3a --- /dev/null +++ b/backend/src/api/routes/sensitiveFieldAccess.routes.ts @@ -0,0 +1,97 @@ +import type { FastifyInstance } from "fastify"; +import { sensitiveFieldAccessService } from "../../services/sensitiveFieldAccess.service.js"; + +export async function sensitiveFieldAccessRoutes(server: FastifyInstance) { + server.get("/definitions", async (request, reply) => { + const definitions = await sensitiveFieldAccessService.listDefinitions(); + return reply.send({ data: definitions }); + }); + + server.post("/definitions", async (request, reply) => { + const body = request.body as { + resourceName: string; + fieldName: string; + sensitivityLevel?: "low" | "medium" | "high" | "critical"; + description?: string; + }; + + try { + const created = await sensitiveFieldAccessService.createDefinition(body); + return reply.status(201).send({ data: created }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + server.get("/access-logs", async (request, reply) => { + const { resourceName, fieldName, actorId, accessType, limit } = request.query as { + resourceName?: string; + fieldName?: string; + actorId?: string; + accessType?: string; + limit?: string; + }; + + const logs = await sensitiveFieldAccessService.queryLogs({ + resourceName, + fieldName, + actorId, + accessType, + limit: limit ? parseInt(limit, 10) : undefined, + }); + return reply.send({ data: logs }); + }); + + server.post("/access-logs", async (request, reply) => { + const body = request.body as { + resourceName: string; + fieldName: string; + actorId: string; + actorRole?: string; + accessType?: "read" | "export" | "decrypted" | "modified"; + reason?: string; + }; + + try { + const log = await sensitiveFieldAccessService.logAccess({ + ...body, + ipAddress: request.ip, + userAgent: request.headers["user-agent"], + }); + return reply.status(201).send({ data: log }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + server.get("/reports", async (request, reply) => { + const reports = await sensitiveFieldAccessService.listReports(); + return reply.send({ data: reports }); + }); + + server.post("/reports/generate", async (request, reply) => { + const body = request.body as { + title: string; + timeRangeStart: string; + timeRangeEnd: string; + sensitivityFilter?: string; + generatedBy?: string; + }; + + try { + const report = await sensitiveFieldAccessService.generateReport(body); + return reply.status(201).send({ data: report }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + server.get("/reports/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const report = await sensitiveFieldAccessService.getReportById(id); + if (!report) { + return reply.status(404).send({ error: "Not Found", message: "Report not found" }); + } + return reply.send({ data: report }); + }); +} diff --git a/backend/src/api/routes/signedRequestVerification.routes.ts b/backend/src/api/routes/signedRequestVerification.routes.ts new file mode 100644 index 00000000..726a3849 --- /dev/null +++ b/backend/src/api/routes/signedRequestVerification.routes.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance } from "fastify"; +import { signedRequestVerificationService } from "../../services/signedRequestVerification.service.js"; + +export async function signedRequestVerificationRoutes(server: FastifyInstance) { + server.get("/keys", async (request, reply) => { + const { activeOnly } = request.query as { activeOnly?: string }; + const keys = await signedRequestVerificationService.listKeys(activeOnly === "true"); + return reply.send({ data: keys }); + }); + + server.post("/keys", async (request, reply) => { + const body = request.body as { + keyId?: string; + secret?: string; + algorithm?: string; + owner: string; + maxClockSkewSeconds?: number; + }; + + try { + const created = await signedRequestVerificationService.createKey(body); + return reply.status(201).send({ data: created }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + server.post("/keys/:id/rotate", async (request, reply) => { + const { id } = request.params as { id: string }; + const rotated = await signedRequestVerificationService.rotateKeySecret(id); + if (!rotated) { + return reply.status(404).send({ error: "Not Found", message: "Signing key not found" }); + } + return reply.send({ data: rotated }); + }); + + server.post("/keys/:id/revoke", async (request, reply) => { + const { id } = request.params as { id: string }; + const revoked = await signedRequestVerificationService.revokeKey(id); + if (!revoked) { + return reply.status(404).send({ error: "Not Found", message: "Signing key not found" }); + } + return reply.send({ data: revoked }); + }); + + server.get("/logs", async (request, reply) => { + const { keyId, status, limit } = request.query as { + keyId?: string; + status?: string; + limit?: string; + }; + + const logs = await signedRequestVerificationService.listLogs({ + keyId, + status, + limit: limit ? parseInt(limit, 10) : undefined, + }); + return reply.send({ data: logs }); + }); + + server.post("/test-verify", async (request, reply) => { + const body = request.body as { + keyId: string; + method: string; + path: string; + timestamp: string | number; + signature: string; + payload?: any; + }; + + try { + const result = await signedRequestVerificationService.verifySignature({ + keyId: body.keyId, + method: body.method, + path: body.path, + timestamp: body.timestamp, + signature: body.signature, + body: body.payload, + clientIp: request.ip, + }); + return reply.send({ data: result }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); +} diff --git a/backend/src/api/routes/webhookIpAllowlist.routes.ts b/backend/src/api/routes/webhookIpAllowlist.routes.ts new file mode 100644 index 00000000..fbd45005 --- /dev/null +++ b/backend/src/api/routes/webhookIpAllowlist.routes.ts @@ -0,0 +1,84 @@ +import type { FastifyInstance } from "fastify"; +import { webhookIpAllowlistService } from "../../services/webhookIpAllowlist.service.js"; + +export async function webhookIpAllowlistRoutes(server: FastifyInstance) { + server.get("/", async (request, reply) => { + const { webhookEndpointId, direction, isActive } = request.query as { + webhookEndpointId?: string; + direction?: string; + isActive?: string; + }; + + const entries = await webhookIpAllowlistService.listAllowlist({ + webhookEndpointId, + direction, + isActive: isActive !== undefined ? isActive === "true" : undefined, + }); + return reply.send({ data: entries }); + }); + + server.post("/", async (request, reply) => { + const body = request.body as { + webhookEndpointId?: string; + ipOrCidr: string; + description?: string; + direction?: "inbound" | "outbound" | "both"; + createdBy?: string; + }; + + try { + const entry = await webhookIpAllowlistService.addAllowlistEntry(body); + return reply.status(201).send({ data: entry }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + server.get("/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const entry = await webhookIpAllowlistService.getAllowlistEntry(id); + if (!entry) { + return reply.status(404).send({ error: "Not Found", message: "Allowlist entry not found" }); + } + return reply.send({ data: entry }); + }); + + server.delete("/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const success = await webhookIpAllowlistService.removeAllowlistEntry(id); + if (!success) { + return reply.status(404).send({ error: "Not Found", message: "Allowlist entry not found" }); + } + return reply.send({ success: true, id }); + }); + + server.patch("/:id/status", async (request, reply) => { + const { id } = request.params as { id: string }; + const { isActive } = request.body as { isActive: boolean }; + + if (isActive === undefined) { + return reply.status(400).send({ error: "Bad Request", message: "isActive boolean is required" }); + } + + const updated = await webhookIpAllowlistService.toggleEntryStatus(id, isActive); + if (!updated) { + return reply.status(404).send({ error: "Not Found", message: "Allowlist entry not found" }); + } + return reply.send({ data: updated }); + }); + + server.post("/test", async (request, reply) => { + const { ip, webhookEndpointId, direction } = request.body as { + ip: string; + webhookEndpointId?: string; + direction?: string; + }; + + try { + const result = await webhookIpAllowlistService.testIpAgainstAllowlist(ip, webhookEndpointId, direction); + return reply.send({ data: result }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); +} diff --git a/backend/src/database/migrations/20260829090000_security_event_correlation.ts b/backend/src/database/migrations/20260829090000_security_event_correlation.ts new file mode 100644 index 00000000..018aaa19 --- /dev/null +++ b/backend/src/database/migrations/20260829090000_security_event_correlation.ts @@ -0,0 +1,49 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("security_event_correlations", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("title", 200).notNullable(); + table.text("description"); + table.string("severity", 30).notNullable().defaultTo("medium"); + table.string("status", 30).notNullable().defaultTo("active"); + table.jsonb("correlation_rule").notNullable().defaultTo(knex.raw("'{}'::jsonb")); + table.integer("event_count").notNullable().defaultTo(0); + table.jsonb("source_systems").notNullable().defaultTo(knex.raw("'[]'::jsonb")); + table.integer("time_window_minutes").notNullable().defaultTo(60); + table.string("created_by", 120); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["severity"]); + table.index(["status"]); + table.index(["created_at"]); + }); + + await knex.schema.createTable("security_events", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table + .uuid("correlation_id") + .nullable() + .references("id") + .inTable("security_event_correlations") + .onDelete("SET NULL"); + table.string("event_type", 100).notNullable(); + table.string("source", 100).notNullable(); + table.string("severity", 30).notNullable().defaultTo("medium"); + table.string("actor", 120); + table.string("ip_address", 45); + table.jsonb("details").notNullable().defaultTo(knex.raw("'{}'::jsonb")); + table.timestamp("timestamp", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["correlation_id"]); + table.index(["event_type"]); + table.index(["source"]); + table.index(["timestamp"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("security_events"); + await knex.schema.dropTableIfExists("security_event_correlations"); +} diff --git a/backend/src/database/migrations/20260829091000_webhook_ip_allowlist.ts b/backend/src/database/migrations/20260829091000_webhook_ip_allowlist.ts new file mode 100644 index 00000000..d73b678c --- /dev/null +++ b/backend/src/database/migrations/20260829091000_webhook_ip_allowlist.ts @@ -0,0 +1,29 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("webhook_ip_allowlists", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table + .uuid("webhook_endpoint_id") + .nullable() + .references("id") + .inTable("webhook_endpoints") + .onDelete("CASCADE"); + table.string("ip_or_cidr", 60).notNullable(); + table.text("description"); + table.string("direction", 20).notNullable().defaultTo("inbound"); + table.boolean("is_active").notNullable().defaultTo(true); + table.string("created_by", 120); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["webhook_endpoint_id"]); + table.index(["ip_or_cidr"]); + table.index(["direction"]); + table.index(["is_active"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("webhook_ip_allowlists"); +} diff --git a/backend/src/database/migrations/20260829092000_signed_request_verification.ts b/backend/src/database/migrations/20260829092000_signed_request_verification.ts new file mode 100644 index 00000000..0de460c0 --- /dev/null +++ b/backend/src/database/migrations/20260829092000_signed_request_verification.ts @@ -0,0 +1,39 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("request_signing_keys", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("key_id", 100).notNullable().unique(); + table.text("secret").notNullable(); + table.string("algorithm", 40).notNullable().defaultTo("hmac-sha256"); + table.string("owner", 120).notNullable(); + table.integer("max_clock_skew_seconds").notNullable().defaultTo(300); + table.boolean("is_active").notNullable().defaultTo(true); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["key_id"]); + table.index(["is_active"]); + }); + + await knex.schema.createTable("signed_request_logs", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("key_id", 100).notNullable(); + table.string("request_path", 255).notNullable(); + table.string("request_method", 10).notNullable(); + table.text("signature").notNullable(); + table.string("status", 40).notNullable().defaultTo("valid"); + table.string("client_ip", 45); + table.text("error_message"); + table.timestamp("timestamp", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["key_id"]); + table.index(["status"]); + table.index(["timestamp"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("signed_request_logs"); + await knex.schema.dropTableIfExists("request_signing_keys"); +} diff --git a/backend/src/database/migrations/20260829093000_sensitive_field_access_reports.ts b/backend/src/database/migrations/20260829093000_sensitive_field_access_reports.ts new file mode 100644 index 00000000..8241d9a1 --- /dev/null +++ b/backend/src/database/migrations/20260829093000_sensitive_field_access_reports.ts @@ -0,0 +1,57 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("sensitive_field_definitions", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("resource_name", 100).notNullable(); + table.string("field_name", 100).notNullable(); + table.string("sensitivity_level", 30).notNullable().defaultTo("medium"); + table.text("description"); + table.boolean("is_active").notNullable().defaultTo(true); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.unique(["resource_name", "field_name"]); + table.index(["resource_name"]); + table.index(["sensitivity_level"]); + }); + + await knex.schema.createTable("sensitive_field_access_logs", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("resource_name", 100).notNullable(); + table.string("field_name", 100).notNullable(); + table.string("actor_id", 120).notNullable(); + table.string("actor_role", 60).notNullable().defaultTo("operator"); + table.string("access_type", 30).notNullable().defaultTo("read"); + table.text("reason"); + table.string("ip_address", 45); + table.text("user_agent"); + table.timestamp("timestamp", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["resource_name", "field_name"]); + table.index(["actor_id"]); + table.index(["timestamp"]); + }); + + await knex.schema.createTable("sensitive_field_access_reports", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("title", 200).notNullable(); + table.timestamp("time_range_start", { useTz: true }).notNullable(); + table.timestamp("time_range_end", { useTz: true }).notNullable(); + table.string("sensitivity_filter", 30); + table.integer("total_accesses").notNullable().defaultTo(0); + table.integer("unique_actors").notNullable().defaultTo(0); + table.integer("critical_accesses").notNullable().defaultTo(0); + table.jsonb("summary_json").notNullable().defaultTo(knex.raw("'{}'::jsonb")); + table.string("generated_by", 120).notNullable(); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["created_at"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("sensitive_field_access_reports"); + await knex.schema.dropTableIfExists("sensitive_field_access_logs"); + await knex.schema.dropTableIfExists("sensitive_field_definitions"); +} diff --git a/backend/src/services/securityEventCorrelation.service.ts b/backend/src/services/securityEventCorrelation.service.ts new file mode 100644 index 00000000..bafd4bd6 --- /dev/null +++ b/backend/src/services/securityEventCorrelation.service.ts @@ -0,0 +1,220 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export interface SecurityEventCorrelationRecord { + id: string; + title: string; + description?: string; + severity: "low" | "medium" | "high" | "critical"; + status: "active" | "investigating" | "resolved" | "archived"; + correlation_rule: Record | string; + event_count: number; + source_systems: string[] | string; + time_window_minutes: number; + created_by?: string; + created_at: string; + updated_at: string; +} + +export interface SecurityEventRecord { + id: string; + correlation_id?: string; + event_type: string; + source: string; + severity: "low" | "medium" | "high" | "critical"; + actor?: string; + ip_address?: string; + details: Record | string; + timestamp: string; +} + +export class SecurityEventCorrelationService { + private db() { + return getDatabase(); + } + + async listCorrelations(filters?: { severity?: string; status?: string; search?: string }) { + let query = this.db()("security_event_correlations"); + + if (filters?.severity) { + query = query.where("severity", filters.severity); + } + if (filters?.status) { + query = query.where("status", filters.status); + } + if (filters?.search) { + query = query.where((builder) => { + builder + .where("title", "ilike", `%${filters.search}%`) + .orWhere("description", "ilike", `%${filters.search}%`); + }); + } + + const rows = await query.orderBy("created_at", "desc"); + return rows.map(this.formatCorrelation); + } + + async getCorrelationById(id: string) { + const row = await this.db()("security_event_correlations").where({ id }).first(); + if (!row) return null; + + const events = await this.db()("security_events") + .where({ correlation_id: id }) + .orderBy("timestamp", "desc"); + + return { + ...this.formatCorrelation(row), + events: events.map(this.formatEvent), + }; + } + + async createCorrelation(data: { + title: string; + description?: string; + severity?: "low" | "medium" | "high" | "critical"; + correlationRule?: Record; + sourceSystems?: string[]; + timeWindowMinutes?: number; + createdBy?: string; + }) { + if (!data.title?.trim()) { + throw new Error("title is required"); + } + + const [row] = await this.db()("security_event_correlations") + .insert({ + title: data.title.trim(), + description: data.description ?? null, + severity: data.severity ?? "medium", + status: "active", + correlation_rule: JSON.stringify(data.correlationRule ?? {}), + source_systems: JSON.stringify(data.sourceSystems ?? []), + time_window_minutes: data.timeWindowMinutes ?? 60, + created_by: data.createdBy ?? "system", + }) + .returning("*"); + + return this.formatCorrelation(row); + } + + async updateCorrelationStatus( + id: string, + status: "active" | "investigating" | "resolved" | "archived", + updatedBy?: string + ) { + const [row] = await this.db()("security_event_correlations") + .where({ id }) + .update({ + status, + updated_at: this.db().fn.now(), + }) + .returning("*"); + + if (!row) return null; + logger.info({ id, status, updatedBy }, "Updated security event correlation status"); + return this.formatCorrelation(row); + } + + async ingestSecurityEvent(data: { + correlationId?: string; + eventType: string; + source: string; + severity?: "low" | "medium" | "high" | "critical"; + actor?: string; + ipAddress?: string; + details?: Record; + }) { + if (!data.eventType?.trim()) { + throw new Error("eventType is required"); + } + if (!data.source?.trim()) { + throw new Error("source is required"); + } + + const [eventRow] = await this.db()("security_events") + .insert({ + correlation_id: data.correlationId ?? null, + event_type: data.eventType.trim(), + source: data.source.trim(), + severity: data.severity ?? "medium", + actor: data.actor ?? null, + ip_address: data.ipAddress ?? null, + details: JSON.stringify(data.details ?? {}), + }) + .returning("*"); + + if (data.correlationId) { + await this.db()("security_event_correlations") + .where({ id: data.correlationId }) + .increment("event_count", 1); + } + + return this.formatEvent(eventRow); + } + + async listSecurityEvents(filters?: { + correlationId?: string; + eventType?: string; + source?: string; + severity?: string; + actor?: string; + limit?: number; + }) { + let query = this.db()("security_events"); + + if (filters?.correlationId) { + query = query.where("correlation_id", filters.correlationId); + } + if (filters?.eventType) { + query = query.where("event_type", filters.eventType); + } + if (filters?.source) { + query = query.where("source", filters.source); + } + if (filters?.severity) { + query = query.where("severity", filters.severity); + } + if (filters?.actor) { + query = query.where("actor", filters.actor); + } + + const rows = await query + .orderBy("timestamp", "desc") + .limit(filters?.limit ?? 100); + + return rows.map(this.formatEvent); + } + + private formatCorrelation(row: any) { + return { + id: row.id, + title: row.title, + description: row.description, + severity: row.severity, + status: row.status, + correlationRule: typeof row.correlation_rule === "string" ? JSON.parse(row.correlation_rule) : row.correlation_rule, + eventCount: row.event_count, + sourceSystems: typeof row.source_systems === "string" ? JSON.parse(row.source_systems) : row.source_systems, + timeWindowMinutes: row.time_window_minutes, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private formatEvent(row: any) { + return { + id: row.id, + correlationId: row.correlation_id, + eventType: row.event_type, + source: row.source, + severity: row.severity, + actor: row.actor, + ipAddress: row.ip_address, + details: typeof row.details === "string" ? JSON.parse(row.details) : row.details, + timestamp: row.timestamp, + }; + } +} + +export const securityEventCorrelationService = new SecurityEventCorrelationService(); diff --git a/backend/src/services/sensitiveFieldAccess.service.ts b/backend/src/services/sensitiveFieldAccess.service.ts new file mode 100644 index 00000000..67e0af0b --- /dev/null +++ b/backend/src/services/sensitiveFieldAccess.service.ts @@ -0,0 +1,263 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export interface SensitiveFieldDefinitionRecord { + id: string; + resource_name: string; + field_name: string; + sensitivity_level: "low" | "medium" | "high" | "critical"; + description?: string; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface SensitiveFieldAccessLogRecord { + id: string; + resource_name: string; + field_name: string; + actor_id: string; + actor_role: string; + access_type: "read" | "export" | "decrypted" | "modified"; + reason?: string; + ip_address?: string; + user_agent?: string; + timestamp: string; +} + +export interface SensitiveFieldAccessReportRecord { + id: string; + title: string; + time_range_start: string; + time_range_end: string; + sensitivity_filter?: string; + total_accesses: number; + unique_actors: number; + critical_accesses: number; + summary_json: Record | string; + generated_by: string; + created_at: string; +} + +export class SensitiveFieldAccessService { + private db() { + return getDatabase(); + } + + async listDefinitions() { + const rows = await this.db()("sensitive_field_definitions") + .where("is_active", true) + .orderBy("resource_name", "asc"); + return rows.map(this.formatDefinition); + } + + async createDefinition(data: { + resourceName: string; + fieldName: string; + sensitivityLevel?: "low" | "medium" | "high" | "critical"; + description?: string; + }) { + if (!data.resourceName?.trim() || !data.fieldName?.trim()) { + throw new Error("resourceName and fieldName are required"); + } + + const [row] = await this.db()("sensitive_field_definitions") + .insert({ + resource_name: data.resourceName.trim(), + field_name: data.fieldName.trim(), + sensitivity_level: data.sensitivityLevel ?? "medium", + description: data.description ?? null, + is_active: true, + }) + .onConflict(["resource_name", "field_name"]) + .merge({ + sensitivity_level: data.sensitivityLevel ?? "medium", + description: data.description ?? null, + is_active: true, + updated_at: this.db().fn.now(), + }) + .returning("*"); + + return this.formatDefinition(row); + } + + async logAccess(data: { + resourceName: string; + fieldName: string; + actorId: string; + actorRole?: string; + accessType?: "read" | "export" | "decrypted" | "modified"; + reason?: string; + ipAddress?: string; + userAgent?: string; + }) { + if (!data.resourceName?.trim() || !data.fieldName?.trim() || !data.actorId?.trim()) { + throw new Error("resourceName, fieldName, and actorId are required"); + } + + const [row] = await this.db()("sensitive_field_access_logs") + .insert({ + resource_name: data.resourceName.trim(), + field_name: data.fieldName.trim(), + actor_id: data.actorId.trim(), + actor_role: data.actorRole ?? "operator", + access_type: data.accessType ?? "read", + reason: data.reason ?? null, + ip_address: data.ipAddress ?? null, + user_agent: data.userAgent ?? null, + }) + .returning("*"); + + return this.formatLog(row); + } + + async queryLogs(filters?: { + resourceName?: string; + fieldName?: string; + actorId?: string; + accessType?: string; + limit?: number; + }) { + let query = this.db()("sensitive_field_access_logs"); + + if (filters?.resourceName) { + query = query.where("resource_name", filters.resourceName); + } + if (filters?.fieldName) { + query = query.where("field_name", filters.fieldName); + } + if (filters?.actorId) { + query = query.where("actor_id", filters.actorId); + } + if (filters?.accessType) { + query = query.where("access_type", filters.accessType); + } + + const rows = await query.orderBy("timestamp", "desc").limit(filters?.limit ?? 100); + return rows.map(this.formatLog); + } + + async generateReport(data: { + title: string; + timeRangeStart: string; + timeRangeEnd: string; + sensitivityFilter?: string; + generatedBy?: string; + }) { + if (!data.title?.trim() || !data.timeRangeStart || !data.timeRangeEnd) { + throw new Error("title, timeRangeStart, and timeRangeEnd are required"); + } + + let logsQuery = this.db()("sensitive_field_access_logs") + .where("timestamp", ">=", data.timeRangeStart) + .where("timestamp", "<=", data.timeRangeEnd); + + const logs = await logsQuery; + + const definitions = await this.listDefinitions(); + const defMap = new Map(); + definitions.forEach((d) => defMap.set(`${d.resourceName}:${d.fieldName}`, d.sensitivityLevel)); + + let filteredLogs = logs; + if (data.sensitivityFilter) { + filteredLogs = logs.filter( + (l) => defMap.get(`${l.resource_name}:${l.field_name}`) === data.sensitivityFilter + ); + } + + const totalAccesses = filteredLogs.length; + const uniqueActors = new Set(filteredLogs.map((l) => l.actor_id)).size; + const criticalAccesses = filteredLogs.filter( + (l) => defMap.get(`${l.resource_name}:${l.field_name}`) === "critical" + ).length; + + const accessesByResource: Record = {}; + filteredLogs.forEach((l) => { + accessesByResource[l.resource_name] = (accessesByResource[l.resource_name] || 0) + 1; + }); + + const accessesByAccessType: Record = {}; + filteredLogs.forEach((l) => { + accessesByAccessType[l.access_type] = (accessesByAccessType[l.access_type] || 0) + 1; + }); + + const summaryJson = { + accessesByResource, + accessesByAccessType, + sampleLogsCount: Math.min(filteredLogs.length, 50), + }; + + const [reportRow] = await this.db()("sensitive_field_access_reports") + .insert({ + title: data.title.trim(), + time_range_start: data.timeRangeStart, + time_range_end: data.timeRangeEnd, + sensitivity_filter: data.sensitivityFilter ?? null, + total_accesses: totalAccesses, + unique_actors: uniqueActors, + critical_accesses: criticalAccesses, + summary_json: JSON.stringify(summaryJson), + generated_by: data.generatedBy ?? "system", + }) + .returning("*"); + + logger.info({ reportId: reportRow.id, title: data.title }, "Generated sensitive field access report"); + return this.formatReport(reportRow); + } + + async listReports() { + const rows = await this.db()("sensitive_field_access_reports").orderBy("created_at", "desc"); + return rows.map(this.formatReport); + } + + async getReportById(id: string) { + const row = await this.db()("sensitive_field_access_reports").where({ id }).first(); + return row ? this.formatReport(row) : null; + } + + private formatDefinition(row: any) { + return { + id: row.id, + resourceName: row.resource_name, + fieldName: row.field_name, + sensitivityLevel: row.sensitivity_level, + description: row.description, + isActive: row.is_active, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private formatLog(row: any) { + return { + id: row.id, + resourceName: row.resource_name, + fieldName: row.field_name, + actorId: row.actor_id, + actorRole: row.actor_role, + accessType: row.access_type, + reason: row.reason, + ipAddress: row.ip_address, + userAgent: row.user_agent, + timestamp: row.timestamp, + }; + } + + private formatReport(row: any) { + return { + id: row.id, + title: row.title, + timeRangeStart: row.time_range_start, + timeRangeEnd: row.time_range_end, + sensitivityFilter: row.sensitivity_filter, + totalAccesses: row.total_accesses, + uniqueActors: row.unique_actors, + criticalAccesses: row.critical_accesses, + summaryJson: typeof row.summary_json === "string" ? JSON.parse(row.summary_json) : row.summary_json, + generatedBy: row.generated_by, + createdAt: row.created_at, + }; + } +} + +export const sensitiveFieldAccessService = new SensitiveFieldAccessService(); diff --git a/backend/src/services/signedRequestVerification.service.ts b/backend/src/services/signedRequestVerification.service.ts new file mode 100644 index 00000000..7c581a16 --- /dev/null +++ b/backend/src/services/signedRequestVerification.service.ts @@ -0,0 +1,260 @@ +import crypto from "crypto"; +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export interface RequestSigningKeyRecord { + id: string; + key_id: string; + secret: string; + algorithm: string; + owner: string; + max_clock_skew_seconds: number; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface SignedRequestLogRecord { + id: string; + key_id: string; + request_path: string; + request_method: string; + signature: string; + status: "valid" | "invalid_signature" | "timestamp_expired" | "key_not_found" | "replayed"; + client_ip?: string; + error_message?: string; + timestamp: string; +} + +export class SignedRequestVerificationService { + private db() { + return getDatabase(); + } + + async createKey(data: { + keyId?: string; + secret?: string; + algorithm?: string; + owner: string; + maxClockSkewSeconds?: number; + }) { + if (!data.owner?.trim()) { + throw new Error("owner is required"); + } + + const keyId = data.keyId?.trim() || `key_live_${crypto.randomBytes(8).toString("hex")}`; + const secret = data.secret?.trim() || crypto.randomBytes(32).toString("hex"); + + const [row] = await this.db()("request_signing_keys") + .insert({ + key_id: keyId, + secret, + algorithm: data.algorithm ?? "hmac-sha256", + owner: data.owner.trim(), + max_clock_skew_seconds: data.maxClockSkewSeconds ?? 300, + is_active: true, + }) + .returning("*"); + + return this.formatKey(row); + } + + async listKeys(activeOnly = false) { + let query = this.db()("request_signing_keys"); + if (activeOnly) { + query = query.where("is_active", true); + } + const rows = await query.orderBy("created_at", "desc"); + return rows.map(this.formatKey); + } + + async getKeyById(keyId: string) { + const row = await this.db()("request_signing_keys").where({ key_id: keyId }).first(); + return row ? this.formatKey(row) : null; + } + + async rotateKeySecret(id: string) { + const newSecret = crypto.randomBytes(32).toString("hex"); + const [row] = await this.db()("request_signing_keys") + .where({ id }) + .update({ + secret: newSecret, + updated_at: this.db().fn.now(), + }) + .returning("*"); + + if (!row) return null; + logger.info({ id, keyId: row.key_id }, "Rotated signing key secret"); + return this.formatKey(row); + } + + async revokeKey(id: string) { + const [row] = await this.db()("request_signing_keys") + .where({ id }) + .update({ + is_active: false, + updated_at: this.db().fn.now(), + }) + .returning("*"); + + if (!row) return null; + return this.formatKey(row); + } + + async verifySignature(options: { + keyId: string; + method: string; + path: string; + timestamp: string | number; + signature: string; + body?: any; + clientIp?: string; + }): Promise<{ valid: boolean; status: SignedRequestLogRecord["status"]; message?: string }> { + const { keyId, method, path, timestamp, signature, body, clientIp } = options; + + const keyRow = await this.db()("request_signing_keys") + .where({ key_id: keyId, is_active: true }) + .first(); + + if (!keyRow) { + await this.logVerification({ + keyId, + path, + method, + signature, + status: "key_not_found", + clientIp, + errorMessage: "Active signing key not found", + }); + return { valid: false, status: "key_not_found", message: "Active signing key not found" }; + } + + // Check timestamp clock skew + const reqTime = typeof timestamp === "number" ? timestamp : Number(timestamp) || Date.parse(String(timestamp)); + const now = Date.now(); + const maxSkewMs = (keyRow.max_clock_skew_seconds || 300) * 1000; + + if (isNaN(reqTime) || Math.abs(now - reqTime) > maxSkewMs) { + await this.logVerification({ + keyId, + path, + method, + signature, + status: "timestamp_expired", + clientIp, + errorMessage: `Request timestamp clock skew exceeds limit of ${keyRow.max_clock_skew_seconds}s`, + }); + return { + valid: false, + status: "timestamp_expired", + message: `Timestamp skew exceeds ${keyRow.max_clock_skew_seconds} seconds limit`, + }; + } + + // Compute payload digest / string to sign + const payloadStr = typeof body === "string" ? body : body ? JSON.stringify(body) : ""; + const stringToSign = `${method.toUpperCase()}:${path}:${timestamp}:${payloadStr}`; + + const expectedSignature = crypto + .createHmac("sha256", keyRow.secret) + .update(stringToSign) + .digest("hex"); + + const validSig = + signature.length === expectedSignature.length && + crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)); + + if (!validSig) { + await this.logVerification({ + keyId, + path, + method, + signature, + status: "invalid_signature", + clientIp, + errorMessage: "Signature mismatch", + }); + return { valid: false, status: "invalid_signature", message: "Signature mismatch" }; + } + + // Success + await this.logVerification({ + keyId, + path, + method, + signature, + status: "valid", + clientIp, + }); + + return { valid: true, status: "valid" }; + } + + async listLogs(filters?: { keyId?: string; status?: string; limit?: number }) { + let query = this.db()("signed_request_logs"); + + if (filters?.keyId) { + query = query.where("key_id", filters.keyId); + } + if (filters?.status) { + query = query.where("status", filters.status); + } + + const rows = await query.orderBy("timestamp", "desc").limit(filters?.limit ?? 100); + return rows.map(this.formatLog); + } + + private async logVerification(data: { + keyId: string; + path: string; + method: string; + signature: string; + status: SignedRequestLogRecord["status"]; + clientIp?: string; + errorMessage?: string; + }) { + try { + await this.db()("signed_request_logs").insert({ + key_id: data.keyId, + request_path: data.path, + request_method: data.method, + signature: data.signature, + status: data.status, + client_ip: data.clientIp ?? null, + error_message: data.errorMessage ?? null, + }); + } catch (e) { + logger.warn({ error: e }, "Failed to insert signed request verification log"); + } + } + + private formatKey(row: any) { + return { + id: row.id, + keyId: row.key_id, + secret: row.secret, + algorithm: row.algorithm, + owner: row.owner, + maxClockSkewSeconds: row.max_clock_skew_seconds, + isActive: row.is_active, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private formatLog(row: any) { + return { + id: row.id, + keyId: row.key_id, + requestPath: row.request_path, + requestMethod: row.request_method, + signature: row.signature, + status: row.status, + clientIp: row.client_ip, + errorMessage: row.error_message, + timestamp: row.timestamp, + }; + } +} + +export const signedRequestVerificationService = new SignedRequestVerificationService(); diff --git a/backend/src/services/webhookIpAllowlist.service.ts b/backend/src/services/webhookIpAllowlist.service.ts new file mode 100644 index 00000000..68157ec2 --- /dev/null +++ b/backend/src/services/webhookIpAllowlist.service.ts @@ -0,0 +1,181 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export interface WebhookIpAllowlistRecord { + id: string; + webhook_endpoint_id?: string; + ip_or_cidr: string; + description?: string; + direction: "inbound" | "outbound" | "both"; + is_active: boolean; + created_by?: string; + created_at: string; + updated_at: string; +} + +export class WebhookIpAllowlistService { + private db() { + return getDatabase(); + } + + /** + * Helper to check if an IPv4 address matches an IP or CIDR specification. + */ + private matchIp(ip: string, cidrOrIp: string): boolean { + const target = ip.trim(); + const pattern = cidrOrIp.trim(); + + if (pattern === target || pattern === "*" || pattern === "0.0.0.0/0") { + return true; + } + + if (!pattern.includes("/")) { + return pattern === target; + } + + const [rangeIp, prefixStr] = pattern.split("/"); + const prefix = parseInt(prefixStr, 10); + if (isNaN(prefix) || prefix < 0 || prefix > 32) return false; + + const ipToNum = (ipStr: string) => { + const parts = ipStr.split(".").map(Number); + if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) { + return null; + } + return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; + }; + + const targetNum = ipToNum(target); + const rangeNum = ipToNum(rangeIp); + + if (targetNum === null || rangeNum === null) return false; + + const mask = (0xffffffff << (32 - prefix)) >>> 0; + return (targetNum & mask) === (rangeNum & mask); + } + + async listAllowlist(filters?: { webhookEndpointId?: string; direction?: string; isActive?: boolean }) { + let query = this.db()("webhook_ip_allowlists"); + + if (filters?.webhookEndpointId !== undefined) { + if (filters.webhookEndpointId === null || filters.webhookEndpointId === "") { + query = query.whereNull("webhook_endpoint_id"); + } else { + query = query.where("webhook_endpoint_id", filters.webhookEndpointId); + } + } + if (filters?.direction) { + query = query.where((b) => { + b.where("direction", filters.direction).orWhere("direction", "both"); + }); + } + if (filters?.isActive !== undefined) { + query = query.where("is_active", filters.isActive); + } + + const rows = await query.orderBy("created_at", "desc"); + return rows.map(this.formatRecord); + } + + async getAllowlistEntry(id: string) { + const row = await this.db()("webhook_ip_allowlists").where({ id }).first(); + return row ? this.formatRecord(row) : null; + } + + async addAllowlistEntry(data: { + webhookEndpointId?: string; + ipOrCidr: string; + description?: string; + direction?: "inbound" | "outbound" | "both"; + createdBy?: string; + }) { + if (!data.ipOrCidr?.trim()) { + throw new Error("ipOrCidr is required"); + } + + const [row] = await this.db()("webhook_ip_allowlists") + .insert({ + webhook_endpoint_id: data.webhookEndpointId ?? null, + ip_or_cidr: data.ipOrCidr.trim(), + description: data.description ?? null, + direction: data.direction ?? "inbound", + is_active: true, + created_by: data.createdBy ?? "system", + }) + .returning("*"); + + return this.formatRecord(row); + } + + async removeAllowlistEntry(id: string) { + const deleted = await this.db()("webhook_ip_allowlists").where({ id }).del(); + return deleted > 0; + } + + async toggleEntryStatus(id: string, isActive: boolean) { + const [row] = await this.db()("webhook_ip_allowlists") + .where({ id }) + .update({ is_active: isActive, updated_at: this.db().fn.now() }) + .returning("*"); + + if (!row) return null; + return this.formatRecord(row); + } + + async testIpAgainstAllowlist(ip: string, webhookEndpointId?: string, direction?: string) { + if (!ip?.trim()) { + throw new Error("ip is required"); + } + + let query = this.db()("webhook_ip_allowlists").where("is_active", true); + + if (webhookEndpointId) { + query = query.where((b) => { + b.where("webhook_endpoint_id", webhookEndpointId).orWhereNull("webhook_endpoint_id"); + }); + } + if (direction) { + query = query.where((b) => { + b.where("direction", direction).orWhere("direction", "both"); + }); + } + + const entries = await query; + if (entries.length === 0) { + // If no explicit rules exist for scope, default behavior is allowed + return { allowed: true, matchingRule: null, reason: "No allowlist rules defined for scope" }; + } + + for (const entry of entries) { + if (this.matchIp(ip, entry.ip_or_cidr)) { + return { + allowed: true, + matchingRule: this.formatRecord(entry), + reason: `Matched allowlist entry ${entry.ip_or_cidr}`, + }; + } + } + + return { + allowed: false, + matchingRule: null, + reason: `IP ${ip} does not match any active allowlist rules`, + }; + } + + private formatRecord(row: any) { + return { + id: row.id, + webhookEndpointId: row.webhook_endpoint_id, + ipOrCidr: row.ip_or_cidr, + description: row.description, + direction: row.direction, + isActive: row.is_active, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} + +export const webhookIpAllowlistService = new WebhookIpAllowlistService(); diff --git a/backend/tests/services/securityEventCorrelation.service.test.ts b/backend/tests/services/securityEventCorrelation.service.test.ts new file mode 100644 index 00000000..e8f08e71 --- /dev/null +++ b/backend/tests/services/securityEventCorrelation.service.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getDatabase } from "../../src/database/connection.js"; +import { SecurityEventCorrelationService } from "../../src/services/securityEventCorrelation.service.js"; + +vi.mock("../../src/database/connection.js", () => ({ + getDatabase: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + createChildLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +let currentChain: Record = {}; + +function makeCorrelationRow(overrides: Record = {}) { + return { + id: "sec-c1", + title: "Auth Failure Spike", + description: "Spike in auth failures", + severity: "high", + status: "active", + correlation_rule: JSON.stringify({ threshold: 10 }), + event_count: 5, + source_systems: JSON.stringify(["auth-service"]), + time_window_minutes: 30, + created_by: "admin", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +function makeEventRow(overrides: Record = {}) { + return { + id: "ev-1", + correlation_id: "sec-c1", + event_type: "auth_failure", + source: "auth-service", + severity: "high", + actor: "192.168.1.1", + ip_address: "192.168.1.1", + details: JSON.stringify({ path: "/login" }), + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +function makeChain(rows: unknown[] = [], updated?: unknown) { + const chain: Record = {}; + chain.modify = vi.fn().mockImplementation((mod: (qb: unknown) => void) => { + mod(chain); + return chain; + }); + chain.orderBy = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockReturnValue(chain); + chain.limit = vi.fn().mockReturnValue(chain); + chain.increment = vi.fn().mockResolvedValue(1); + chain.first = vi.fn().mockResolvedValue(rows[0]); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.returning = vi.fn().mockResolvedValue([updated ?? rows[0] ?? makeCorrelationRow()]); + chain.then = vi.fn().mockImplementation((resolve) => { resolve(rows); return Promise.resolve(); }); + currentChain = chain; + return chain; +} + +function dbMock() { + const dbFn = vi.fn().mockImplementation((_table: string) => currentChain); + (dbFn as unknown as Record).raw = (v: string) => v; + (dbFn as unknown as Record).fn = { now: () => new Date().toISOString() }; + return dbFn as never; +} + +describe("SecurityEventCorrelationService", () => { + let service: SecurityEventCorrelationService; + + beforeEach(() => { + service = new SecurityEventCorrelationService(); + makeChain([makeCorrelationRow()]); + vi.mocked(getDatabase).mockReturnValue(dbMock() as never); + }); + + it("lists correlation views", async () => { + const list = await service.listCorrelations(); + expect(list).toHaveLength(1); + expect(list[0].title).toBe("Auth Failure Spike"); + expect(list[0].severity).toBe("high"); + }); + + it("creates a new correlation view", async () => { + makeChain([], makeCorrelationRow({ title: "New Rule" })); + const created = await service.createCorrelation({ + title: "New Rule", + severity: "medium", + sourceSystems: ["gateway"], + }); + expect(created.title).toBe("New Rule"); // mocked return + }); + + it("rejects correlation with empty title", async () => { + await expect(service.createCorrelation({ title: "" })).rejects.toThrow("title is required"); + }); + + it("updates correlation status", async () => { + makeChain([makeCorrelationRow()], makeCorrelationRow({ status: "resolved" })); + const updated = await service.updateCorrelationStatus("sec-c1", "resolved"); + expect(updated?.status).toBe("resolved"); + }); + + it("ingests a security event", async () => { + makeChain([], makeEventRow()); + const event = await service.ingestSecurityEvent({ + eventType: "invalid_token", + source: "api-gateway", + severity: "medium", + }); + expect(event.eventType).toBe("auth_failure"); // mocked row + }); +}); diff --git a/backend/tests/services/sensitiveFieldAccess.service.test.ts b/backend/tests/services/sensitiveFieldAccess.service.test.ts new file mode 100644 index 00000000..916a7fb3 --- /dev/null +++ b/backend/tests/services/sensitiveFieldAccess.service.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getDatabase } from "../../src/database/connection.js"; +import { SensitiveFieldAccessService } from "../../src/services/sensitiveFieldAccess.service.js"; + +vi.mock("../../src/database/connection.js", () => ({ + getDatabase: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + createChildLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +let currentChain: Record = {}; + +function makeDefRow(overrides: Record = {}) { + return { + id: "def-1", + resource_name: "api_keys", + field_name: "secret", + sensitivity_level: "critical", + description: "API Key secret", + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +function makeLogRow(overrides: Record = {}) { + return { + id: "log-1", + resource_name: "api_keys", + field_name: "secret", + actor_id: "usr-admin", + actor_role: "admin", + access_type: "read", + reason: "Audit check", + ip_address: "127.0.0.1", + user_agent: "Vitest", + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +function makeReportRow(overrides: Record = {}) { + return { + id: "rep-1", + title: "SOC2 Compliance Report", + time_range_start: new Date(Date.now() - 86400000).toISOString(), + time_range_end: new Date().toISOString(), + sensitivity_filter: null, + total_accesses: 1, + unique_actors: 1, + critical_accesses: 1, + summary_json: JSON.stringify({ total: 1 }), + generated_by: "system", + created_at: new Date().toISOString(), + ...overrides, + }; +} + +function makeChain(rows: unknown[] = [], updated?: unknown) { + const chain: Record = {}; + chain.orderBy = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockImplementation(() => chain); + chain.limit = vi.fn().mockReturnValue(chain); + chain.first = vi.fn().mockResolvedValue(rows[0]); + chain.onConflict = vi.fn().mockReturnValue(chain); + chain.merge = vi.fn().mockReturnValue(chain); + chain.insert = vi.fn().mockReturnValue(chain); + chain.returning = vi.fn().mockResolvedValue([updated ?? rows[0] ?? makeDefRow()]); + chain.then = vi.fn().mockImplementation((resolve) => { resolve(rows); return Promise.resolve(); }); + currentChain = chain; + return chain; +} + +function dbMock() { + const dbFn = vi.fn().mockImplementation((_table: string) => currentChain); + (dbFn as unknown as Record).fn = { now: () => new Date().toISOString() }; + return dbFn as never; +} + +describe("SensitiveFieldAccessService", () => { + let service: SensitiveFieldAccessService; + + beforeEach(() => { + service = new SensitiveFieldAccessService(); + makeChain([makeDefRow()]); + vi.mocked(getDatabase).mockReturnValue(dbMock() as never); + }); + + it("lists active field definitions", async () => { + const defs = await service.listDefinitions(); + expect(defs).toHaveLength(1); + expect(defs[0].resourceName).toBe("api_keys"); + expect(defs[0].fieldName).toBe("secret"); + }); + + it("creates or updates a sensitive field definition", async () => { + makeChain([], makeDefRow({ field_name: "private_key" })); + const created = await service.createDefinition({ + resourceName: "bridge_wallets", + fieldName: "private_key", + sensitivityLevel: "critical", + }); + expect(created.fieldName).toBe("private_key"); + }); + + it("logs a sensitive field access event", async () => { + makeChain([], makeLogRow()); + const log = await service.logAccess({ + resourceName: "api_keys", + fieldName: "secret", + actorId: "usr-admin", + }); + expect(log.actorId).toBe("usr-admin"); + }); + + it("generates a compliance report", async () => { + makeChain([makeLogRow()], makeReportRow()); + const report = await service.generateReport({ + title: "SOC2 Compliance Report", + timeRangeStart: new Date(Date.now() - 86400000).toISOString(), + timeRangeEnd: new Date().toISOString(), + }); + expect(report.title).toBe("SOC2 Compliance Report"); + }); +}); diff --git a/backend/tests/services/signedRequestVerification.service.test.ts b/backend/tests/services/signedRequestVerification.service.test.ts new file mode 100644 index 00000000..54cc2464 --- /dev/null +++ b/backend/tests/services/signedRequestVerification.service.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import crypto from "crypto"; +import { getDatabase } from "../../src/database/connection.js"; +import { SignedRequestVerificationService } from "../../src/services/signedRequestVerification.service.js"; + +vi.mock("../../src/database/connection.js", () => ({ + getDatabase: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + createChildLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +let currentChain: Record = {}; + +function makeKeyRow(overrides: Record = {}) { + return { + id: "sk-1", + key_id: "key_test_123", + secret: "testsecret1234567890123456789012", + algorithm: "hmac-sha256", + owner: "test-client", + max_clock_skew_seconds: 300, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +function makeChain(rows: unknown[] = [], updated?: unknown) { + const chain: Record = {}; + chain.orderBy = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockImplementation(() => chain); + chain.first = vi.fn().mockResolvedValue(rows[0]); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.returning = vi.fn().mockResolvedValue([updated ?? rows[0] ?? makeKeyRow()]); + chain.then = vi.fn().mockImplementation((resolve) => { resolve(rows); return Promise.resolve(); }); + currentChain = chain; + return chain; +} + +function dbMock() { + const dbFn = vi.fn().mockImplementation((_table: string) => currentChain); + (dbFn as unknown as Record).fn = { now: () => new Date().toISOString() }; + return dbFn as never; +} + +describe("SignedRequestVerificationService", () => { + let service: SignedRequestVerificationService; + + beforeEach(() => { + service = new SignedRequestVerificationService(); + makeChain([makeKeyRow()]); + vi.mocked(getDatabase).mockReturnValue(dbMock() as never); + }); + + it("lists active request signing keys", async () => { + const keys = await service.listKeys(true); + expect(keys).toHaveLength(1); + expect(keys[0].keyId).toBe("key_test_123"); + }); + + it("creates a new signing key", async () => { + makeChain([], makeKeyRow({ owner: "billing" })); + const key = await service.createKey({ owner: "billing" }); + expect(key.owner).toBe("billing"); + }); + + it("verifies valid signature successfully", async () => { + const secret = "testsecret1234567890123456789012"; + const timestamp = Date.now(); + const method = "POST"; + const path = "/api/v1/resource"; + const body = { test: true }; + const stringToSign = `${method}:${path}:${timestamp}:${JSON.stringify(body)}`; + const signature = crypto.createHmac("sha256", secret).update(stringToSign).digest("hex"); + + makeChain([makeKeyRow({ secret })]); + + const res = await service.verifySignature({ + keyId: "key_test_123", + method, + path, + timestamp, + signature, + body, + }); + + expect(res.valid).toBe(true); + expect(res.status).toBe("valid"); + }); + + it("rejects expired timestamp", async () => { + const expiredTimestamp = Date.now() - 400000; // 400s > 300s limit + makeChain([makeKeyRow()]); + + const res = await service.verifySignature({ + keyId: "key_test_123", + method: "GET", + path: "/api/v1/resource", + timestamp: expiredTimestamp, + signature: "dummy", + }); + + expect(res.valid).toBe(false); + expect(res.status).toBe("timestamp_expired"); + }); + + it("rejects missing key", async () => { + makeChain([], undefined); + + const res = await service.verifySignature({ + keyId: "missing_key", + method: "GET", + path: "/api/v1/resource", + timestamp: Date.now(), + signature: "dummy", + }); + + expect(res.valid).toBe(false); + expect(res.status).toBe("key_not_found"); + }); +}); diff --git a/backend/tests/services/webhookIpAllowlist.service.test.ts b/backend/tests/services/webhookIpAllowlist.service.test.ts new file mode 100644 index 00000000..bb586e7b --- /dev/null +++ b/backend/tests/services/webhookIpAllowlist.service.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getDatabase } from "../../src/database/connection.js"; +import { WebhookIpAllowlistService } from "../../src/services/webhookIpAllowlist.service.js"; + +vi.mock("../../src/database/connection.js", () => ({ + getDatabase: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + createChildLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +let currentChain: Record = {}; + +function makeRow(overrides: Record = {}) { + return { + id: "wl-1", + webhook_endpoint_id: null, + ip_or_cidr: "192.168.1.0/24", + description: "Subnet 1", + direction: "inbound", + is_active: true, + created_by: "admin", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +function makeChain(rows: unknown[] = [], updated?: unknown) { + const chain: Record = {}; + chain.orderBy = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockImplementation(() => chain); + chain.whereNull = vi.fn().mockReturnValue(chain); + chain.orWhere = vi.fn().mockReturnValue(chain); + chain.first = vi.fn().mockResolvedValue(rows[0]); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.del = vi.fn().mockResolvedValue(1); + chain.returning = vi.fn().mockResolvedValue([updated ?? rows[0] ?? makeRow()]); + chain.then = vi.fn().mockImplementation((resolve) => { resolve(rows); return Promise.resolve(); }); + currentChain = chain; + return chain; +} + +function dbMock() { + const dbFn = vi.fn().mockImplementation((_table: string) => currentChain); + (dbFn as unknown as Record).fn = { now: () => new Date().toISOString() }; + return dbFn as never; +} + +describe("WebhookIpAllowlistService", () => { + let service: WebhookIpAllowlistService; + + beforeEach(() => { + service = new WebhookIpAllowlistService(); + makeChain([makeRow()]); + vi.mocked(getDatabase).mockReturnValue(dbMock() as never); + }); + + it("lists allowlist entries", async () => { + const list = await service.listAllowlist(); + expect(list).toHaveLength(1); + expect(list[0].ipOrCidr).toBe("192.168.1.0/24"); + }); + + it("adds a new allowlist entry", async () => { + makeChain([], makeRow({ ip_or_cidr: "10.0.0.1" })); + const created = await service.addAllowlistEntry({ + ipOrCidr: "10.0.0.1", + direction: "inbound", + }); + expect(created.ipOrCidr).toBe("10.0.0.1"); + }); + + it("rejects entry without IP/CIDR", async () => { + await expect(service.addAllowlistEntry({ ipOrCidr: "" })).rejects.toThrow("ipOrCidr is required"); + }); + + it("toggles entry status", async () => { + makeChain([makeRow()], makeRow({ is_active: false })); + const toggled = await service.toggleEntryStatus("wl-1", false); + expect(toggled?.isActive).toBe(false); + }); + + it("deletes an entry", async () => { + const deleted = await service.removeAllowlistEntry("wl-1"); + expect(deleted).toBe(true); + }); + + it("tests IP against allowlist matching CIDR", async () => { + makeChain([makeRow({ ip_or_cidr: "192.168.1.0/24" })]); + const res = await service.testIpAgainstAllowlist("192.168.1.50"); + expect(res.allowed).toBe(true); + expect(res.matchingRule).toBeDefined(); + }); + + it("tests IP against allowlist failing unmatched IP", async () => { + makeChain([makeRow({ ip_or_cidr: "10.0.0.0/16" })]); + const res = await service.testIpAgainstAllowlist("192.168.1.50"); + expect(res.allowed).toBe(false); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d488d075..1f3a794b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,6 +77,14 @@ const ImportValidationPreview = lazy(() => import("./pages/admin/ImportValidatio const ApiKeyScopeTemplates = lazy(() => import("./pages/admin/ApiKeyScopeTemplates")); // #1168 — Failed Parse Quarantine Queue const ParseQuarantineQueue = lazy(() => import("./pages/admin/ParseQuarantineQueue")); +// #1177 — Security Event Correlation View +const SecurityEventCorrelation = lazy(() => import("./pages/admin/SecurityEventCorrelation")); +// #1178 — Webhook IP Allowlist Management +const WebhookIpAllowlist = lazy(() => import("./pages/admin/WebhookIpAllowlist")); +// #1179 — Signed Request Verification Middleware +const SignedRequestVerification = lazy(() => import("./pages/admin/SignedRequestVerification")); +// #1180 — Sensitive Field Access Reports +const SensitiveFieldAccessReport = lazy(() => import("./pages/admin/SensitiveFieldAccessReport")); const DexPoolDiscovery = lazy(() => import("./pages/liquidity/DexPoolDiscovery")); const PoolQualityRanking = lazy(() => import("./pages/liquidity/PoolQualityRanking")); const MarketImpactPresets = lazy(() => import("./pages/liquidity/MarketImpactPresets")); @@ -179,6 +187,14 @@ function App() { } /> {/* #1168 — Failed Parse Quarantine Queue */} } /> + {/* #1177 — Security Event Correlation View */} + } /> + {/* #1178 — Webhook IP Allowlist Management */} + } /> + {/* #1179 — Signed Request Verification Middleware */} + } /> + {/* #1180 — Sensitive Field Access Reports */} + } /> {/* #1157 — DEX Pool Discovery Refresh */} } /> {/* #1158 — Liquidity Pool Quality Ranking */} diff --git a/frontend/src/components/MobileNav/navigation.ts b/frontend/src/components/MobileNav/navigation.ts index 2568f0b6..5dd1c15a 100644 --- a/frontend/src/components/MobileNav/navigation.ts +++ b/frontend/src/components/MobileNav/navigation.ts @@ -54,6 +54,10 @@ export const navGroups: NavGroup[] = [ { to: "/admin/import-validation-preview", label: "Import Validation", description: "Preview import validation before committing" }, { to: "/admin/dataset-lineage", label: "Dataset Lineage", description: "Trace dataset column lineage and provenance" }, { to: "/admin/quarantine", label: "Parse Quarantine", description: "Review and resolve failed-parse quarantine queue" }, + { to: "/admin/security-correlations", label: "Security Event Correlation", description: "Correlate security anomalies and event logs" }, + { to: "/admin/webhook-ip-allowlist", label: "Webhook IP Allowlist", description: "Manage inbound and outbound webhook IP allowlists" }, + { to: "/admin/signed-requests", label: "Signed Requests", description: "Manage HMAC request signing keys and verification" }, + { to: "/admin/sensitive-field-reports", label: "Sensitive Field Access", description: "Audit sensitive field accesses and compliance reports" }, { to: "/admin/alert-routing", label: "Alert Routing", diff --git a/frontend/src/pages/admin/SecurityEventCorrelation.tsx b/frontend/src/pages/admin/SecurityEventCorrelation.tsx new file mode 100644 index 00000000..5d90384b --- /dev/null +++ b/frontend/src/pages/admin/SecurityEventCorrelation.tsx @@ -0,0 +1,402 @@ +import React, { useEffect, useState } from "react"; + +interface CorrelationView { + id: string; + title: string; + description?: string; + severity: "low" | "medium" | "high" | "critical"; + status: "active" | "investigating" | "resolved" | "archived"; + eventCount: number; + sourceSystems: string[]; + timeWindowMinutes: number; + createdBy?: string; + createdAt: string; +} + +interface SecurityEvent { + id: string; + eventType: string; + source: string; + severity: "low" | "medium" | "high" | "critical"; + actor?: string; + ipAddress?: string; + details: Record; + timestamp: string; +} + +export default function SecurityEventCorrelation() { + const [correlations, setCorrelations] = useState([ + { + id: "sec-c1", + title: "Multiple Failed Webhook & Auth Signatures", + description: "Correlated spikes in invalid HMAC request signatures and rate-limit hits", + severity: "high", + status: "investigating", + eventCount: 42, + sourceSystems: ["webhook-gateway", "api-auth-middleware"], + timeWindowMinutes: 30, + createdBy: "sec-admin", + createdAt: new Date().toISOString(), + }, + { + id: "sec-c2", + title: "Sensitive Field Bulk Export Anomaly", + description: "Abnormal access count on private keys and user secret tokens", + severity: "critical", + status: "active", + eventCount: 18, + sourceSystems: ["audit-logger", "compliance-engine"], + timeWindowMinutes: 60, + createdBy: "compliance-bot", + createdAt: new Date(Date.now() - 3600000).toISOString(), + }, + ]); + + const [selectedCorrelation, setSelectedCorrelation] = useState(null); + const [rawEvents, setRawEvents] = useState([ + { + id: "ev-1", + eventType: "invalid_signature", + source: "api-auth-middleware", + severity: "high", + actor: "192.168.1.105", + ipAddress: "192.168.1.105", + details: { path: "/api/v1/admin/keys", reason: "HMAC digest mismatch" }, + timestamp: new Date().toISOString(), + }, + { + id: "ev-2", + eventType: "rate_limit_exceeded", + source: "webhook-gateway", + severity: "medium", + actor: "service-outbox", + ipAddress: "10.0.4.12", + details: { limit: 120, current: 145 }, + timestamp: new Date(Date.now() - 600000).toISOString(), + }, + ]); + + const [filterSeverity, setFilterSeverity] = useState(""); + const [filterStatus, setFilterStatus] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [newTitle, setNewTitle] = useState(""); + const [newDescription, setNewDescription] = useState(""); + const [newSeverity, setNewSeverity] = useState<"low" | "medium" | "high" | "critical">("medium"); + + const filteredCorrelations = correlations.filter((item) => { + if (filterSeverity && item.severity !== filterSeverity) return false; + if (filterStatus && item.status !== filterStatus) return false; + if ( + searchTerm && + !item.title.toLowerCase().includes(searchTerm.toLowerCase()) && + !(item.description || "").toLowerCase().includes(searchTerm.toLowerCase()) + ) { + return false; + } + return true; + }); + + const handleStatusChange = (id: string, newStatus: CorrelationView["status"]) => { + setCorrelations((prev) => + prev.map((c) => (c.id === id ? { ...c, status: newStatus } : c)) + ); + }; + + const handleCreate = (e: React.FormEvent) => { + e.preventDefault(); + if (!newTitle.trim()) return; + + const created: CorrelationView = { + id: `sec-${Date.now()}`, + title: newTitle.trim(), + description: newDescription.trim(), + severity: newSeverity, + status: "active", + eventCount: 0, + sourceSystems: ["custom-rule"], + timeWindowMinutes: 60, + createdBy: "operator", + createdAt: new Date().toISOString(), + }; + + setCorrelations([created, ...correlations]); + setNewTitle(""); + setNewDescription(""); + setNewSeverity("medium"); + setShowCreateModal(false); + }; + + const getSeverityBadgeClass = (severity: string) => { + switch (severity) { + case "critical": + return "bg-red-500/20 text-red-400 border-red-500/30"; + case "high": + return "bg-orange-500/20 text-orange-400 border-orange-500/30"; + case "medium": + return "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"; + default: + return "bg-blue-500/20 text-blue-400 border-blue-500/30"; + } + }; + + const getStatusBadgeClass = (status: string) => { + switch (status) { + case "active": + return "bg-red-500/20 text-red-300"; + case "investigating": + return "bg-yellow-500/20 text-yellow-300"; + case "resolved": + return "bg-green-500/20 text-green-300"; + default: + return "bg-gray-500/20 text-gray-400"; + } + }; + + return ( +
+
+
+

Security Event Correlation

+

+ Correlate security anomalies, auth events, and access logs into operational alert views. +

+
+ +
+ + {/* Summary Cards */} +
+
+
Total Correlation Views
+
{correlations.length}
+
+
+
Active Investigations
+
+ {correlations.filter((c) => c.status === "investigating" || c.status === "active").length} +
+
+
+
Critical / High Alerts
+
+ {correlations.filter((c) => c.severity === "critical" || c.severity === "high").length} +
+
+
+
Total Correlated Events
+
+ {correlations.reduce((acc, curr) => acc + curr.eventCount, 0)} +
+
+
+ + {/* Filters */} +
+ setSearchTerm(e.target.value)} + className="rounded-md border border-stellar-border bg-stellar-dark px-3 py-1.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-stellar-blue" + /> + + +
+ + {/* Table */} +
+ + + + + + + + + + + + + {filteredCorrelations.map((c) => ( + + + + + + + + + ))} + +
Title & DescriptionSeverityStatusEventsSourcesActions
+
{c.title}
+
{c.description}
+
+ + {c.severity} + + + + {c.status} + + {c.eventCount} + {c.sourceSystems.join(", ")} + + + +
+
+ + {/* Drilldown Drawer / Modal */} + {selectedCorrelation && ( +
+
+
+

{selectedCorrelation.title}

+ +
+
+

{selectedCorrelation.description}

+
+
Severity: {selectedCorrelation.severity}
+
Status: {selectedCorrelation.status}
+
Time Window: {selectedCorrelation.timeWindowMinutes} minutes
+
Created By: {selectedCorrelation.createdBy}
+
+

Associated Security Events

+
+ {rawEvents.map((ev) => ( +
+
+ {ev.eventType} ({ev.source}) + {new Date(ev.timestamp).toLocaleTimeString()} +
+
Actor: {ev.actor || "N/A"} | IP: {ev.ipAddress || "N/A"}
+
+ ))} +
+
+
+ +
+
+
+ )} + + {/* Create Modal */} + {showCreateModal && ( +
+
+

Create Security Correlation View

+
+ + setNewTitle(e.target.value)} + className="mt-1 w-full rounded border border-stellar-border bg-stellar-dark px-3 py-1.5 text-sm text-white" + /> +
+
+ +