diff --git a/enterprise-integration-secret-rotation/README.md b/enterprise-integration-secret-rotation/README.md new file mode 100644 index 0000000..334d9a5 --- /dev/null +++ b/enterprise-integration-secret-rotation/README.md @@ -0,0 +1,24 @@ +# Enterprise Integration Secret Rotation + +This module adds an Enterprise Tooling slice for institutional API and webhook governance. It is intentionally self-contained and synthetic-data-only so reviewers can validate the behavior without credentials, third-party services, or local platform setup. + +It covers the issue's enterprise API and webhook requirements by evaluating: + +- institutional API clients for stale credentials, unauthorized scopes, owner gaps, expiry, and break-glass misuse +- webhook destinations for signing-secret age, unsafe overlap windows, missing HMAC policy, weak idempotency, dead-letter gaps, and recipient verification +- dashboard-ready risk metrics for admins +- deterministic audit evidence packets suitable for compliance exports + +## Local Validation + +```sh +node enterprise-integration-secret-rotation/test.js +node enterprise-integration-secret-rotation/demo.js +``` + +## Demo Evidence + +- [demo.mp4](demo.mp4) shows the problem, implementation scope, acceptance behavior, and validation commands. +- [demo.svg](demo.svg) provides a static preview of the admin risk queue. +- [requirements-map.md](requirements-map.md) maps the implementation to issue #19. +- [acceptance-notes.md](acceptance-notes.md) lists the reviewer checks. diff --git a/enterprise-integration-secret-rotation/acceptance-notes.md b/enterprise-integration-secret-rotation/acceptance-notes.md new file mode 100644 index 0000000..35c0091 --- /dev/null +++ b/enterprise-integration-secret-rotation/acceptance-notes.md @@ -0,0 +1,12 @@ +# Acceptance Notes + +Reviewer checklist: + +1. Run `node enterprise-integration-secret-rotation/test.js`. +2. Run `node enterprise-integration-secret-rotation/demo.js`. +3. Confirm the DSpace production API client is marked critical because it is expired, over-scoped, and has unjustified break-glass access. +4. Confirm the ELN webhook is marked critical because its signing secret is overdue, its overlap window is too long, and its idempotency/dead-letter policy is incomplete. +5. Confirm the Canvas/NIH-style low-risk integrations remain in monitor state. +6. Confirm the evidence packet includes deterministic `sourceDigest`, `findingDigest`, and `packetDigest` values. + +This is a narrow Enterprise Tooling implementation rather than a broad placeholder. It targets a payment-relevant gap for real institutions: keeping API credentials and webhook signing secrets safe while still producing admin and compliance evidence. diff --git a/enterprise-integration-secret-rotation/demo.js b/enterprise-integration-secret-rotation/demo.js new file mode 100644 index 0000000..37f2831 --- /dev/null +++ b/enterprise-integration-secret-rotation/demo.js @@ -0,0 +1,79 @@ +"use strict"; + +const { evaluateEnterpriseIntegrationGovernance } = require("./index"); + +const input = { + generatedAt: "2026-05-17T01:25:00.000Z", + apiClients: [ + { + id: "api-dspace-prod", + name: "DSpace institutional archive sync", + systems: ["DSpace", "ORCID"], + environment: "production", + scopes: ["repository:read", "publication:write", "admin:*"], + allowedScopes: ["repository:read", "publication:write"], + owner: { name: "Research IT", email: "research-it@example.edu" }, + credentialLastRotatedAt: "2026-01-01T00:00:00.000Z", + credentialLastUsedAt: "2026-05-16T10:00:00.000Z", + expiresAt: "2026-05-10T00:00:00.000Z", + hasBreakGlassAccess: true, + }, + { + id: "api-orcid-prod", + name: "ORCID affiliation updater", + systems: ["ORCID", "HRIS"], + environment: "production", + scopes: ["person:read", "affiliation:write"], + allowedScopes: ["person:read", "affiliation:write"], + owner: { name: "Identity Team", email: "identity@example.edu" }, + credentialLastRotatedAt: "2026-04-28T00:00:00.000Z", + credentialLastUsedAt: "2026-05-16T09:30:00.000Z", + expiresAt: "2026-10-01T00:00:00.000Z", + hasBreakGlassAccess: false, + }, + ], + webhooks: [ + { + id: "hook-eln-publication", + name: "ELN publication webhook", + destinationSystem: "Benchling ELN", + eventTypes: ["project.published", "review.completed"], + allowedEventTypes: ["project.published", "review.completed"], + transport: "https", + signatureAlgorithm: "hmac-sha256", + signingSecretLastRotatedAt: "2026-03-01T00:00:00.000Z", + activeSecretCount: 2, + activeSecretWindowStartedAt: "2026-05-13T00:00:00.000Z", + idempotencyKeyPolicy: "optional", + deadLetterQueue: false, + recipientVerification: true, + }, + { + id: "hook-funder-report", + name: "Funder compliance report webhook", + destinationSystem: "NIH RePORTER", + eventTypes: ["export.ready"], + allowedEventTypes: ["export.ready"], + transport: "https", + signatureAlgorithm: "hmac-sha256", + signingSecretLastRotatedAt: "2026-05-05T00:00:00.000Z", + activeSecretCount: 1, + idempotencyKeyPolicy: "required", + deadLetterQueue: true, + recipientVerification: true, + }, + ], +}; + +const result = evaluateEnterpriseIntegrationGovernance(input); + +console.log(JSON.stringify({ + dashboard: result.dashboard, + topFindings: result.findings.slice(0, 3).map((finding) => ({ + id: finding.id, + severity: finding.severity, + action: finding.action, + issues: finding.issues, + })), + evidencePacket: result.evidencePacket, +}, null, 2)); diff --git a/enterprise-integration-secret-rotation/demo.mp4 b/enterprise-integration-secret-rotation/demo.mp4 new file mode 100644 index 0000000..0094096 Binary files /dev/null and b/enterprise-integration-secret-rotation/demo.mp4 differ diff --git a/enterprise-integration-secret-rotation/demo.svg b/enterprise-integration-secret-rotation/demo.svg new file mode 100644 index 0000000..05d456f --- /dev/null +++ b/enterprise-integration-secret-rotation/demo.svg @@ -0,0 +1,43 @@ + + Enterprise Integration Secret Rotation Dashboard + Static preview of enterprise API and webhook governance metrics. + + + Enterprise Integration Secret Rotation + Admin risk queue for institutional API clients and webhook destinations + + + Compliance score + 50 + + + + Critical findings + 2 + + + + Systems watched + 5 + + + + Evidence digests + 3 + + + + api-dspace-prod + Suspend and rotate immediately: expired credential, unauthorized admin scope, unjustified break-glass access. + + + + hook-eln-publication + Pause delivery and rotate secret: overdue signing secret, long overlap window, weak retry controls. + + + + hook-funder-report + Monitor: HTTPS, HMAC-SHA256, idempotency, dead-letter queue, and verified recipient are in place. + + diff --git a/enterprise-integration-secret-rotation/index.js b/enterprise-integration-secret-rotation/index.js new file mode 100644 index 0000000..32a1094 --- /dev/null +++ b/enterprise-integration-secret-rotation/index.js @@ -0,0 +1,319 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const DAY_MS = 24 * 60 * 60 * 1000; + +const DEFAULT_POLICY = { + apiCredentialMaxAgeDays: 90, + apiCredentialWarnDays: 14, + inactiveCredentialMaxDays: 60, + webhookSecretMaxAgeDays: 45, + webhookSecretWarnDays: 7, + maximumSecretOverlapHours: 48, + requiredWebhookTransport: "https", + minimumComplianceScore: 80, +}; + +function parseDate(value, fieldName) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date for ${fieldName}: ${value}`); + } + return date; +} + +function wholeDaysBetween(start, end) { + return Math.floor((parseDate(end, "end") - parseDate(start, "start")) / DAY_MS); +} + +function hoursBetween(start, end) { + return Math.floor((parseDate(end, "end") - parseDate(start, "start")) / (60 * 60 * 1000)); +} + +function canonicalize(value) { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value && typeof value === "object") { + return Object.keys(value) + .sort() + .reduce((result, key) => { + result[key] = canonicalize(value[key]); + return result; + }, {}); + } + return value; +} + +function stableDigest(value) { + return crypto + .createHash("sha256") + .update(JSON.stringify(canonicalize(value))) + .digest("hex"); +} + +function normalizeList(value) { + if (!Array.isArray(value)) { + return []; + } + return value.filter(Boolean).map(String).sort(); +} + +function compareScopes(grantedScopes, allowedScopes) { + const granted = normalizeList(grantedScopes); + const allowed = new Set(normalizeList(allowedScopes)); + return granted.filter((scope) => scope === "*" || !allowed.has(scope)); +} + +function severityFromScore(score) { + if (score >= 80) { + return "critical"; + } + if (score >= 50) { + return "high"; + } + if (score >= 25) { + return "medium"; + } + return "low"; +} + +function sortByRisk(a, b) { + if (b.riskScore !== a.riskScore) { + return b.riskScore - a.riskScore; + } + return a.id.localeCompare(b.id); +} + +function evaluateApiClient(client, now, policy) { + const issues = []; + const rotatedAgeDays = wholeDaysBetween(client.credentialLastRotatedAt, now); + const inactiveDays = wholeDaysBetween(client.credentialLastUsedAt, now); + const expiresInDays = wholeDaysBetween(now, client.expiresAt); + const unauthorizedScopes = compareScopes(client.scopes, client.allowedScopes); + + let riskScore = 0; + + if (!client.owner || !client.owner.email) { + issues.push("missing accountable owner"); + riskScore += 20; + } + + if (rotatedAgeDays > policy.apiCredentialMaxAgeDays) { + issues.push(`credential rotation overdue by ${rotatedAgeDays - policy.apiCredentialMaxAgeDays} days`); + riskScore += 35; + } else if (policy.apiCredentialMaxAgeDays - rotatedAgeDays <= policy.apiCredentialWarnDays) { + issues.push("credential rotation due soon"); + riskScore += 12; + } + + if (expiresInDays < 0) { + issues.push(`credential expired ${Math.abs(expiresInDays)} days ago`); + riskScore += 45; + } else if (expiresInDays <= policy.apiCredentialWarnDays) { + issues.push("credential expires within warning window"); + riskScore += 20; + } + + if (inactiveDays > policy.inactiveCredentialMaxDays) { + issues.push(`credential inactive for ${inactiveDays} days`); + riskScore += 25; + } + + if (unauthorizedScopes.length > 0) { + issues.push(`unauthorized scopes: ${unauthorizedScopes.join(", ")}`); + riskScore += unauthorizedScopes.includes("*") ? 45 : 30; + } + + if (client.environment === "production" && client.hasBreakGlassAccess && !client.breakGlassJustification) { + issues.push("production break-glass credential lacks justification"); + riskScore += 30; + } + + const action = + riskScore >= 80 + ? "suspend and rotate immediately" + : riskScore >= 50 + ? "rotate before next export window" + : riskScore >= 25 + ? "queue owner review" + : "monitor"; + + return { + id: client.id, + name: client.name, + kind: "api-client", + systems: normalizeList(client.systems), + owner: client.owner || null, + rotatedAgeDays, + inactiveDays, + expiresInDays, + unauthorizedScopes, + issues, + riskScore, + severity: severityFromScore(riskScore), + action, + }; +} + +function evaluateWebhook(webhook, now, policy) { + const issues = []; + const secretAgeDays = wholeDaysBetween(webhook.signingSecretLastRotatedAt, now); + const overlapHours = webhook.activeSecretWindowStartedAt + ? hoursBetween(webhook.activeSecretWindowStartedAt, now) + : 0; + + let riskScore = 0; + + if (webhook.transport !== policy.requiredWebhookTransport) { + issues.push(`non-compliant transport: ${webhook.transport || "missing"}`); + riskScore += 40; + } + + if (secretAgeDays > policy.webhookSecretMaxAgeDays) { + issues.push(`webhook signing secret overdue by ${secretAgeDays - policy.webhookSecretMaxAgeDays} days`); + riskScore += 35; + } else if (policy.webhookSecretMaxAgeDays - secretAgeDays <= policy.webhookSecretWarnDays) { + issues.push("webhook signing secret rotation due soon"); + riskScore += 12; + } + + if (webhook.activeSecretCount > 1 && overlapHours > policy.maximumSecretOverlapHours) { + issues.push(`secret overlap window exceeds ${policy.maximumSecretOverlapHours} hours`); + riskScore += 35; + } + + if (webhook.signatureAlgorithm !== "hmac-sha256") { + issues.push("missing hmac-sha256 signature policy"); + riskScore += 30; + } + + if (webhook.idempotencyKeyPolicy !== "required") { + issues.push("idempotency key is not required"); + riskScore += 25; + } + + if (!webhook.deadLetterQueue) { + issues.push("missing dead-letter queue"); + riskScore += 20; + } + + if (!webhook.recipientVerification) { + issues.push("recipient endpoint is not verified"); + riskScore += 20; + } + + const unsupportedEvents = compareScopes(webhook.eventTypes, webhook.allowedEventTypes); + if (unsupportedEvents.length > 0) { + issues.push(`unsupported event types: ${unsupportedEvents.join(", ")}`); + riskScore += 25; + } + + const action = + riskScore >= 80 + ? "pause delivery and rotate secret" + : riskScore >= 50 + ? "rotate secret and replay failed deliveries" + : riskScore >= 25 + ? "queue integration owner review" + : "monitor"; + + return { + id: webhook.id, + name: webhook.name, + kind: "webhook", + systems: [webhook.destinationSystem].filter(Boolean), + secretAgeDays, + overlapHours, + unsupportedEvents, + issues, + riskScore, + severity: severityFromScore(riskScore), + action, + }; +} + +function buildDashboard(apiFindings, webhookFindings, policy) { + const findings = [...apiFindings, ...webhookFindings].sort(sortByRisk); + const highRiskCount = findings.filter((item) => item.riskScore >= 50).length; + const criticalCount = findings.filter((item) => item.riskScore >= 80).length; + const monitoredSystems = new Set(findings.flatMap((item) => item.systems)); + const maximumPenalty = findings.length * 100 || 1; + const actualPenalty = findings.reduce((sum, item) => sum + Math.min(100, item.riskScore), 0); + const complianceScore = Math.max(0, Math.round(100 - (actualPenalty / maximumPenalty) * 100)); + + return { + monitoredSystems: monitoredSystems.size, + apiClients: apiFindings.length, + webhookDestinations: webhookFindings.length, + highRiskCount, + criticalCount, + complianceScore, + belowPolicyFloor: complianceScore < policy.minimumComplianceScore, + nextActions: findings.slice(0, 5).map((item) => ({ + id: item.id, + severity: item.severity, + action: item.action, + issues: item.issues, + })), + }; +} + +function buildEvidencePacket(input, dashboard, findings, now) { + const packet = { + generatedAt: new Date(now).toISOString(), + scope: "enterprise-api-webhook-secret-rotation", + integrationCount: findings.length, + complianceScore: dashboard.complianceScore, + criticalCount: dashboard.criticalCount, + highRiskCount: dashboard.highRiskCount, + requirementCoverage: [ + "admin-dashboard-risk-queue", + "secure-api-credential-governance", + "webhook-secret-rotation", + "institutional-integration-audit-export", + ], + findingDigest: stableDigest(findings.map((item) => ({ + id: item.id, + issues: item.issues, + riskScore: item.riskScore, + }))), + sourceDigest: stableDigest({ + apiClients: input.apiClients || [], + webhooks: input.webhooks || [], + }), + }; + + return { + ...packet, + packetDigest: stableDigest(packet), + }; +} + +function evaluateEnterpriseIntegrationGovernance(input, options = {}) { + const policy = { ...DEFAULT_POLICY, ...(input.policy || {}), ...(options.policy || {}) }; + const now = options.now || input.generatedAt || new Date().toISOString(); + parseDate(now, "now"); + + const apiFindings = (input.apiClients || []).map((client) => evaluateApiClient(client, now, policy)); + const webhookFindings = (input.webhooks || []).map((webhook) => evaluateWebhook(webhook, now, policy)); + const findings = [...apiFindings, ...webhookFindings].sort(sortByRisk); + const dashboard = buildDashboard(apiFindings, webhookFindings, policy); + const evidencePacket = buildEvidencePacket(input, dashboard, findings, now); + + return { + generatedAt: new Date(now).toISOString(), + policy, + dashboard, + findings, + evidencePacket, + }; +} + +module.exports = { + DEFAULT_POLICY, + evaluateEnterpriseIntegrationGovernance, + evaluateApiClient, + evaluateWebhook, +}; diff --git a/enterprise-integration-secret-rotation/requirements-map.md b/enterprise-integration-secret-rotation/requirements-map.md new file mode 100644 index 0000000..2746060 --- /dev/null +++ b/enterprise-integration-secret-rotation/requirements-map.md @@ -0,0 +1,19 @@ +# Requirements Map + +Issue #19 asks for Enterprise Tooling around admin dashboards, APIs and webhooks, and export pipelines. This slice focuses on a concrete institutional security control needed before those integrations can safely run at scale. + +| Issue area | Implementation | +| --- | --- | +| Admin dashboards | `dashboard` summarizes monitored systems, API clients, webhook destinations, high-risk items, critical items, compliance score, and top next actions. | +| API integrations | API client findings check DSpace, Canvas, HRIS, ORCID, and repository-style clients for owner accountability, least-privilege scopes, expiry, inactivity, and rotation age. | +| Webhook support | Webhook findings validate HMAC signing, HTTPS transport, secret rotation age, overlap windows, idempotency policy, dead-letter queues, recipient verification, and allowed event types. | +| Compliance tracking | The evidence packet emits stable digests, requirement coverage, high-risk counts, and source/finding hashes for audit export. | +| Export readiness | The module produces deterministic JSON output that can be attached to institutional compliance exports or admin review packets. | + +## Acceptance Coverage + +- Flags expired API credentials and unauthorized enterprise scopes. +- Identifies unsafe webhook secret overlap windows. +- Produces actionable risk ordering for admins. +- Keeps low-risk integrations in monitor state. +- Emits stable SHA-256 evidence digests for compliance packets. diff --git a/enterprise-integration-secret-rotation/test.js b/enterprise-integration-secret-rotation/test.js new file mode 100644 index 0000000..f0c9dab --- /dev/null +++ b/enterprise-integration-secret-rotation/test.js @@ -0,0 +1,105 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { evaluateEnterpriseIntegrationGovernance } = require("./index"); + +const now = "2026-05-17T01:25:00.000Z"; + +const sampleInput = { + generatedAt: now, + apiClients: [ + { + id: "api-dspace-prod", + name: "DSpace institutional archive sync", + systems: ["DSpace", "ORCID"], + environment: "production", + scopes: ["repository:read", "publication:write", "admin:*"], + allowedScopes: ["repository:read", "publication:write"], + owner: { name: "Research IT", email: "research-it@example.edu" }, + credentialLastRotatedAt: "2026-01-01T00:00:00.000Z", + credentialLastUsedAt: "2026-05-16T10:00:00.000Z", + expiresAt: "2026-05-10T00:00:00.000Z", + hasBreakGlassAccess: true, + }, + { + id: "api-canvas-stage", + name: "Canvas course roster import", + systems: ["Canvas"], + environment: "staging", + scopes: ["course:read", "user:read"], + allowedScopes: ["course:read", "user:read"], + owner: { name: "Learning Systems", email: "canvas@example.edu" }, + credentialLastRotatedAt: "2026-05-01T00:00:00.000Z", + credentialLastUsedAt: "2026-05-16T00:00:00.000Z", + expiresAt: "2026-09-01T00:00:00.000Z", + hasBreakGlassAccess: false, + }, + ], + webhooks: [ + { + id: "hook-eln-publication", + name: "ELN publication webhook", + destinationSystem: "Benchling ELN", + eventTypes: ["project.published", "review.completed"], + allowedEventTypes: ["project.published", "review.completed"], + transport: "https", + signatureAlgorithm: "hmac-sha256", + signingSecretLastRotatedAt: "2026-03-01T00:00:00.000Z", + activeSecretCount: 2, + activeSecretWindowStartedAt: "2026-05-13T00:00:00.000Z", + idempotencyKeyPolicy: "optional", + deadLetterQueue: false, + recipientVerification: true, + }, + { + id: "hook-funder-report", + name: "Funder compliance report webhook", + destinationSystem: "NIH RePORTER", + eventTypes: ["export.ready"], + allowedEventTypes: ["export.ready"], + transport: "https", + signatureAlgorithm: "hmac-sha256", + signingSecretLastRotatedAt: "2026-05-05T00:00:00.000Z", + activeSecretCount: 1, + idempotencyKeyPolicy: "required", + deadLetterQueue: true, + recipientVerification: true, + }, + ], +}; + +const result = evaluateEnterpriseIntegrationGovernance(sampleInput, { now }); + +assert.equal(result.dashboard.apiClients, 2); +assert.equal(result.dashboard.webhookDestinations, 2); +assert.equal(result.dashboard.monitoredSystems, 5); +assert.equal(result.dashboard.criticalCount, 2); +assert.equal(result.dashboard.highRiskCount, 2); +assert.equal(result.dashboard.belowPolicyFloor, true); + +const dspace = result.findings.find((item) => item.id === "api-dspace-prod"); +assert.ok(dspace.riskScore >= 80); +assert.equal(dspace.severity, "critical"); +assert.deepEqual(dspace.unauthorizedScopes, ["admin:*"]); +assert.ok(dspace.issues.some((issue) => issue.includes("expired"))); +assert.ok(dspace.issues.some((issue) => issue.includes("break-glass"))); + +const canvas = result.findings.find((item) => item.id === "api-canvas-stage"); +assert.equal(canvas.severity, "low"); +assert.equal(canvas.action, "monitor"); + +const eln = result.findings.find((item) => item.id === "hook-eln-publication"); +assert.equal(eln.severity, "critical"); +assert.ok(eln.issues.some((issue) => issue.includes("overlap"))); +assert.ok(eln.issues.some((issue) => issue.includes("dead-letter"))); +assert.ok(eln.issues.some((issue) => issue.includes("idempotency"))); + +const reporter = result.findings.find((item) => item.id === "hook-funder-report"); +assert.equal(reporter.severity, "low"); +assert.equal(reporter.action, "monitor"); + +assert.equal(result.evidencePacket.scope, "enterprise-api-webhook-secret-rotation"); +assert.match(result.evidencePacket.packetDigest, /^[a-f0-9]{64}$/); +assert.match(result.evidencePacket.sourceDigest, /^[a-f0-9]{64}$/); + +console.log("enterprise integration secret rotation tests passed");