diff --git a/enterprise-incident-response-governance/README.md b/enterprise-incident-response-governance/README.md new file mode 100644 index 0000000..acfcd16 --- /dev/null +++ b/enterprise-incident-response-governance/README.md @@ -0,0 +1,43 @@ +# Enterprise Incident Response Governance + +Self-contained milestone for SCIBASE.AI issue #19, Enterprise Tooling. + +This module gives institutional admins an incident governance layer for +integration, export, access, and privacy events. It is synthetic-data-only and +has no network calls, credentials, or external service dependencies. + +## What It Covers + +- Incident severity scoring across privacy, integration, export, access, and + compliance events. +- Breach-notification and escalation clocks for restricted or personal data. +- Owner routing for research offices, security teams, integration owners, and + compliance admins. +- Export and webhook holds when incident evidence is incomplete. +- Dashboard-ready incident metrics for institutional admins. +- Signed, webhook-ready event packets and stable audit digests. + +## Files + +- `index.js` - incident response governance engine. +- `demo.js` - terminal demo for admin incident packets. +- `test.js` - dependency-free regression tests. +- `demo.mp4` - short demo artifact for bounty review. + +## Run + +```sh +node enterprise-incident-response-governance/test.js +node enterprise-incident-response-governance/demo.js +``` + +## Requirement Map + +| Issue #19 Requirement | Implementation | +| --- | --- | +| Admin dashboards | `buildIncidentDashboard()` returns open counts, critical incidents, overdue clocks, team queues, and export holds. | +| Contributor and usage oversight | Incidents include project, department, integration, actor, affected-record, and evidence fields for admin review. | +| API and webhooks | Each packet includes a signed webhook event with deterministic digest and replay-safe incident ID. | +| Export pipelines | Export incidents can hold repository/journal/funder deliveries until evidence and owner approvals are complete. | +| Compliance tracking | Breach clocks, data classification, notification deadlines, and required evidence gates are modeled directly. | +| Enterprise operations | Routing actions assign security, research office, integration owner, and compliance workstreams. | diff --git a/enterprise-incident-response-governance/demo.js b/enterprise-incident-response-governance/demo.js new file mode 100644 index 0000000..58aef7b --- /dev/null +++ b/enterprise-incident-response-governance/demo.js @@ -0,0 +1,63 @@ +const { buildIncidentDashboard } = require("./index") + +const incidents = [ + { + id: "inc-privacy-001", + category: "privacy", + status: "open", + projectId: "project-genomics", + department: "Precision Medicine", + integration: "institutional-repository", + dataClass: "regulated", + affectedRecords: 3200, + detectedAt: "2026-05-19T01:00:00.000Z", + evidence: { + owner: "privacy-office", + affectedRecords: true, + timeline: true, + }, + }, + { + id: "inc-export-014", + category: "export", + status: "investigating", + projectId: "project-climate", + department: "Earth Systems", + integration: "journal-export", + dataClass: "internal", + affectedRecords: 85, + detectedAt: "2026-05-19T18:00:00.000Z", + evidence: { + owner: "research-office", + affectedRecords: true, + timeline: true, + mitigation: true, + }, + }, + { + id: "inc-integration-022", + category: "integration", + status: "mitigated", + projectId: "project-materials", + department: "Materials Lab", + integration: "eln-webhook", + dataClass: "restricted", + affectedRecords: 140, + detectedAt: "2026-05-18T16:30:00.000Z", + evidence: { + owner: "integration-team", + affectedRecords: true, + timeline: true, + mitigation: true, + rootCause: true, + }, + }, +] + +const dashboard = buildIncidentDashboard(incidents, { + nowIso: "2026-05-20T01:30:00.000Z", + generatedFor: "institution_admin_console", +}) + +console.log("Enterprise incident response governance") +console.log(JSON.stringify(dashboard, null, 2)) diff --git a/enterprise-incident-response-governance/demo.mp4 b/enterprise-incident-response-governance/demo.mp4 new file mode 100644 index 0000000..0c63e65 Binary files /dev/null and b/enterprise-incident-response-governance/demo.mp4 differ diff --git a/enterprise-incident-response-governance/demo.svg b/enterprise-incident-response-governance/demo.svg new file mode 100644 index 0000000..6aefb35 --- /dev/null +++ b/enterprise-incident-response-governance/demo.svg @@ -0,0 +1,22 @@ + + + + Enterprise Incident Response Governance + Triage privacy, access, export, and integration incidents for institutional admins + + Breach Clock + Regulated data + 24h notification window + Executive bridge + + Admin Routing + Privacy officer + Research office + Integration owner + + Signed Events + Webhook packet + Export hold digest + Audit evidence + Result: institutional admins get severity queues, overdue notifications, held exports, and signed incident evidence. + diff --git a/enterprise-incident-response-governance/index.js b/enterprise-incident-response-governance/index.js new file mode 100644 index 0000000..f973c30 --- /dev/null +++ b/enterprise-incident-response-governance/index.js @@ -0,0 +1,235 @@ +const crypto = require("node:crypto") + +const SEVERITY_WEIGHTS = { + privacy: 0.35, + access: 0.28, + export: 0.22, + integration: 0.18, + compliance: 0.24, +} + +const DATA_CLASS_WEIGHTS = { + public: 0, + internal: 0.18, + restricted: 0.45, + personal: 0.65, + regulated: 0.8, +} + +const STATUS_WEIGHTS = { + open: 0.25, + investigating: 0.18, + mitigated: 0.08, + closed: 0, +} + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]` + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}` + } + return JSON.stringify(value) +} + +function digest(value) { + return crypto.createHash("sha256").update(stableJson(value)).digest("hex") +} + +function signPacket(packet, signingKey = "synthetic-enterprise-incident-demo-key") { + return crypto.createHmac("sha256", signingKey).update(stableJson(packet)).digest("hex") +} + +function clamp(value, min = 0, max = 1) { + return Math.min(Math.max(value, min), max) +} + +function hoursBetween(startIso, endIso) { + return Math.max((Date.parse(endIso) - Date.parse(startIso)) / 36e5, 0) +} + +function addHours(startIso, hours) { + return new Date(Date.parse(startIso) + hours * 36e5).toISOString() +} + +function notificationWindowHours(incident) { + if (incident.dataClass === "regulated") return 24 + if (incident.dataClass === "personal") return 72 + if (incident.dataClass === "restricted") return 96 + return null +} + +function evidenceCompleteness(incident) { + const required = [ + "owner", + "affectedRecords", + "timeline", + "mitigation", + "rootCause", + ] + const present = required.filter((field) => Boolean(incident.evidence?.[field])) + return Number((present.length / required.length).toFixed(4)) +} + +function scoreIncident(incident, nowIso = new Date().toISOString()) { + const categoryScore = SEVERITY_WEIGHTS[incident.category] || 0.15 + const dataClassScore = DATA_CLASS_WEIGHTS[incident.dataClass] || 0.1 + const statusScore = STATUS_WEIGHTS[incident.status] ?? 0.2 + const affectedRecordScore = clamp((incident.affectedRecords || 0) / 5000) + const evidenceGapScore = 1 - evidenceCompleteness(incident) + const clockHours = notificationWindowHours(incident) + const clockScore = + clockHours == null + ? 0 + : clamp(hoursBetween(incident.detectedAt, nowIso) / clockHours) + + return Number( + clamp( + categoryScore + + dataClassScore * 0.28 + + statusScore + + affectedRecordScore * 0.18 + + evidenceGapScore * 0.16 + + clockScore * 0.13, + ).toFixed(4), + ) +} + +function severityTier(score) { + if (score >= 0.85) return "critical" + if (score >= 0.65) return "high" + if (score >= 0.4) return "medium" + return "low" +} + +function notificationClock(incident, nowIso) { + const windowHours = notificationWindowHours(incident) + if (windowHours == null) { + return { + required: false, + dueAt: null, + hoursRemaining: null, + overdue: false, + } + } + const dueAt = addHours(incident.detectedAt, windowHours) + const hoursRemaining = Number(((Date.parse(dueAt) - Date.parse(nowIso)) / 36e5).toFixed(2)) + return { + required: true, + dueAt, + hoursRemaining, + overdue: hoursRemaining < 0, + } +} + +function requiredOwnerQueues(incident, score) { + const owners = new Set() + if (incident.category === "privacy" || incident.dataClass === "regulated" || incident.dataClass === "personal") { + owners.add("privacy_officer") + } + if (incident.category === "access") owners.add("security_admin") + if (incident.category === "integration") owners.add("integration_owner") + if (incident.category === "export") owners.add("research_office") + if (score >= 0.65) owners.add("institution_admin") + if (score >= 0.85) owners.add("executive_sponsor") + return [...owners] +} + +function incidentActions(incident, score, clock) { + const actions = [] + if (clock.required && clock.overdue) actions.push("Escalate overdue breach-notification clock.") + if (clock.required && !clock.overdue) actions.push("Prepare notification decision before deadline.") + if (evidenceCompleteness(incident) < 1) actions.push("Complete incident evidence packet.") + if (incident.category === "export") actions.push("Hold affected export pipeline until review closes.") + if (incident.category === "access") actions.push("Run access review for affected project and actor.") + if (incident.category === "integration") actions.push("Rotate or verify connector credentials before replay.") + if (score >= 0.85) actions.push("Open executive incident bridge.") + return actions +} + +function exportHoldDecision(incident, score) { + if (incident.category === "export" && score >= 0.4) return "hold_export" + if ((incident.dataClass === "regulated" || incident.dataClass === "personal") && score >= 0.65) return "hold_sensitive_exports" + return "allow_exports" +} + +function buildIncidentPacket(incident, options = {}) { + const nowIso = options.nowIso || new Date().toISOString() + const score = scoreIncident(incident, nowIso) + const clock = notificationClock(incident, nowIso) + const packet = { + incidentId: incident.id, + category: incident.category, + status: incident.status, + severityScore: score, + severityTier: severityTier(score), + projectId: incident.projectId, + department: incident.department, + integration: incident.integration, + dataClass: incident.dataClass, + affectedRecords: incident.affectedRecords || 0, + detectedAt: incident.detectedAt, + notificationClock: clock, + evidenceCompleteness: evidenceCompleteness(incident), + ownerQueues: requiredOwnerQueues(incident, score), + actions: incidentActions(incident, score, clock), + exportDecision: exportHoldDecision(incident, score), + } + const auditDigest = digest(packet) + const webhookEvent = { + type: "enterprise.incident.governance", + incidentId: packet.incidentId, + severityTier: packet.severityTier, + exportDecision: packet.exportDecision, + auditDigest, + } + + return { + ...packet, + auditDigest, + webhookEvent: { + ...webhookEvent, + signature: signPacket(webhookEvent, options.signingKey), + }, + } +} + +function buildIncidentDashboard(incidents, options = {}) { + const packets = incidents + .map((incident) => buildIncidentPacket(incident, options)) + .sort((a, b) => b.severityScore - a.severityScore) + const teamQueues = {} + for (const packet of packets) { + for (const queue of packet.ownerQueues) { + teamQueues[queue] = (teamQueues[queue] || 0) + 1 + } + } + + const dashboard = { + generatedFor: options.generatedFor || "enterprise_admin_dashboard", + incidentCount: packets.length, + openIncidents: packets.filter((packet) => packet.status !== "closed").length, + criticalIncidents: packets.filter((packet) => packet.severityTier === "critical").map((packet) => packet.incidentId), + overdueNotifications: packets.filter((packet) => packet.notificationClock.overdue).map((packet) => packet.incidentId), + heldExports: packets.filter((packet) => packet.exportDecision !== "allow_exports").map((packet) => packet.incidentId), + teamQueues, + packets, + } + + return { + ...dashboard, + auditDigest: digest(packets.map((packet) => packet.auditDigest)), + } +} + +module.exports = { + buildIncidentDashboard, + buildIncidentPacket, + digest, + evidenceCompleteness, + scoreIncident, + signPacket, + stableJson, +} diff --git a/enterprise-incident-response-governance/test.js b/enterprise-incident-response-governance/test.js new file mode 100644 index 0000000..e387e34 --- /dev/null +++ b/enterprise-incident-response-governance/test.js @@ -0,0 +1,130 @@ +const assert = require("node:assert/strict") +const { + buildIncidentDashboard, + buildIncidentPacket, + digest, + evidenceCompleteness, + scoreIncident, + signPacket, +} = require("./index") + +const NOW = "2026-05-20T01:30:00.000Z" + +function privacyIncident() { + return { + id: "privacy-1", + category: "privacy", + status: "open", + projectId: "project-a", + department: "Biology", + integration: "dspace", + dataClass: "regulated", + affectedRecords: 5000, + detectedAt: "2026-05-18T00:00:00.000Z", + evidence: { + owner: "privacy", + affectedRecords: true, + timeline: true, + }, + } +} + +function exportIncident() { + return { + id: "export-1", + category: "export", + status: "investigating", + projectId: "project-b", + department: "Physics", + integration: "journal-export", + dataClass: "internal", + affectedRecords: 100, + detectedAt: "2026-05-19T18:00:00.000Z", + evidence: { + owner: "research-office", + affectedRecords: true, + timeline: true, + mitigation: true, + }, + } +} + +function testPrivacyIncidentEscalatesOverdueClock() { + const packet = buildIncidentPacket(privacyIncident(), { nowIso: NOW }) + + assert.equal(packet.severityTier, "critical") + assert.equal(packet.notificationClock.required, true) + assert.equal(packet.notificationClock.overdue, true) + assert.ok(packet.ownerQueues.includes("privacy_officer")) + assert.ok(packet.ownerQueues.includes("executive_sponsor")) + assert.ok(packet.actions.some((action) => action.includes("overdue"))) +} + +function testExportIncidentHoldsPipeline() { + const packet = buildIncidentPacket(exportIncident(), { nowIso: NOW }) + + assert.equal(packet.exportDecision, "hold_export") + assert.ok(packet.ownerQueues.includes("research_office")) + assert.ok(packet.actions.some((action) => action.includes("export pipeline"))) +} + +function testCompleteEvidenceLowersScore() { + const incomplete = scoreIncident(exportIncident(), NOW) + const completeIncident = { + ...exportIncident(), + evidence: { + owner: "research-office", + affectedRecords: true, + timeline: true, + mitigation: true, + rootCause: true, + }, + } + const complete = scoreIncident(completeIncident, NOW) + + assert.equal(evidenceCompleteness(completeIncident), 1) + assert.ok(complete < incomplete) +} + +function testDashboardAggregatesQueuesAndHolds() { + const dashboard = buildIncidentDashboard([privacyIncident(), exportIncident()], { nowIso: NOW }) + + assert.equal(dashboard.incidentCount, 2) + assert.deepEqual(dashboard.criticalIncidents, ["privacy-1"]) + assert.ok(dashboard.heldExports.includes("privacy-1")) + assert.ok(dashboard.heldExports.includes("export-1")) + assert.equal(dashboard.teamQueues.privacy_officer, 1) +} + +function testWebhookSignatureIsDeterministic() { + const packet = buildIncidentPacket(exportIncident(), { nowIso: NOW, signingKey: "demo-key" }) + const event = { + type: packet.webhookEvent.type, + incidentId: packet.webhookEvent.incidentId, + severityTier: packet.webhookEvent.severityTier, + exportDecision: packet.webhookEvent.exportDecision, + auditDigest: packet.webhookEvent.auditDigest, + } + + assert.equal(packet.webhookEvent.signature, signPacket(event, "demo-key")) +} + +function testStableDigestIgnoresObjectKeyOrder() { + assert.equal(digest({ b: 2, a: 1 }), digest({ a: 1, b: 2 })) +} + +const tests = [ + testPrivacyIncidentEscalatesOverdueClock, + testExportIncidentHoldsPipeline, + testCompleteEvidenceLowersScore, + testDashboardAggregatesQueuesAndHolds, + testWebhookSignatureIsDeterministic, + testStableDigestIgnoresObjectKeyOrder, +] + +for (const test of tests) { + test() + console.log(`ok - ${test.name}`) +} + +console.log(`${tests.length} tests passed`)