diff --git a/enterprise-admin-session-redaction-guard/README.md b/enterprise-admin-session-redaction-guard/README.md new file mode 100644 index 00000000..d0b509b5 --- /dev/null +++ b/enterprise-admin-session-redaction-guard/README.md @@ -0,0 +1,31 @@ +# Enterprise Admin Session Redaction Guard + +Issue: [#19 Enterprise Tooling](https://github.com/SCIBASE-AI/SCIBASE.AI/issues/19) + +This module adds an enterprise governance slice for recorded admin sessions. It checks whether privileged session replays are safe to export or share with institutional auditors by validating redaction, retention, access scope, and evidence completeness. + +## Why this fits enterprise tooling + +Universities and R&D organizations need auditability without exposing credentials, patient identifiers, billing records, embargoed project data, or identity-provider secrets. Session replay is useful for incident review, but only if the replay package proves that sensitive frames were masked and that reviewer access is bounded. + +## Guard coverage + +- Detects unredacted secrets, tokens, passwords, patient identifiers, billing accounts, private emails, and embargo markers in admin-session events. +- Requires ticket, reason, and reviewer approval for critical privileged actions. +- Blocks raw playback retention above 30 days unless an explicit legal hold is attached. +- Requires export bundles to include an audit digest, redaction manifest, and viewer access policy. +- Limits shared replay links to approved groups and short-lived expiry windows. + +## Local verification + +```bash +node test.js +node demo.js +``` + +Demo artifacts: + +- `demo.svg` shows the guard scope and expected verification result. +- `demo.mp4` is a short video rendering of the same verification summary for bounty review. + +No external services, private data, credentials, package installs, or live payment integrations are required. diff --git a/enterprise-admin-session-redaction-guard/demo.js b/enterprise-admin-session-redaction-guard/demo.js new file mode 100644 index 00000000..5861a181 --- /dev/null +++ b/enterprise-admin-session-redaction-guard/demo.js @@ -0,0 +1,42 @@ +"use strict"; + +const { evaluateSessionRecording } = require("./index"); + +const demoRecording = { + recordingId: "rec-demo-019", + workspaceId: "enterprise-research-tenant", + actor: "it-admin@research.example", + events: [ + { + id: "open-idp-settings", + type: "identity_provider_change", + reason: "Migrate institution SSO metadata", + ticketId: "IAM-2048", + reviewerApproval: "review-113", + contains: ["token", "email"], + redaction: { mode: "tokenized", evidenceId: "redact-idp-2048" }, + }, + { + id: "export-audit-trail", + type: "data_export", + reason: "External auditor requested tenant admin activity packet", + ticketId: "AUD-552", + reviewerApproval: "review-114", + contains: ["billing_account"], + redaction: { mode: "masked", evidenceId: "redact-aud-552" }, + }, + ], + retention: { rawDays: 21, metadataDays: 730 }, + exportBundle: { + auditDigestId: "digest-demo-019", + redactionManifestId: "manifest-demo-019", + viewerAccessPolicyId: "viewer-policy-demo-019", + }, + share: { + enabled: true, + groups: ["security-reviewers", "incident-commanders"], + expiresInHours: 24, + }, +}; + +console.log(JSON.stringify(evaluateSessionRecording(demoRecording), null, 2)); diff --git a/enterprise-admin-session-redaction-guard/demo.mp4 b/enterprise-admin-session-redaction-guard/demo.mp4 new file mode 100644 index 00000000..53d6188e Binary files /dev/null and b/enterprise-admin-session-redaction-guard/demo.mp4 differ diff --git a/enterprise-admin-session-redaction-guard/demo.svg b/enterprise-admin-session-redaction-guard/demo.svg new file mode 100644 index 00000000..411b6cbe --- /dev/null +++ b/enterprise-admin-session-redaction-guard/demo.svg @@ -0,0 +1,20 @@ + + Enterprise admin session redaction guard demo + Demo artifact for SCIBASE issue 19 showing checks, commands, and release decision. + + + SCIBASE.AI issue #19 + Enterprise admin session redaction guard + Checks privileged replay exports before institutional audit sharing. + - Sensitive events require masked or tokenized redaction evidence + - Critical admin actions require ticket, reason, and reviewer approval + - Raw playback retention and shared replay links are bounded + + Verification + node enterprise-admin-session-redaction-guard/test.js + node enterprise-admin-session-redaction-guard/demo.js + + decision: release + score: 100 + No external services, private data, credentials, package installs, or payment integrations are required. + diff --git a/enterprise-admin-session-redaction-guard/index.js b/enterprise-admin-session-redaction-guard/index.js new file mode 100644 index 00000000..e0dff90b --- /dev/null +++ b/enterprise-admin-session-redaction-guard/index.js @@ -0,0 +1,195 @@ +"use strict"; + +const SENSITIVE_MARKERS = new Set([ + "api_key", + "billing_account", + "email", + "embargoed_project", + "password", + "patient_identifier", + "secret", + "token", +]); + +const CRITICAL_ACTIONS = new Set([ + "billing_export", + "data_export", + "identity_provider_change", + "policy_change", + "role_change", +]); + +const ALLOWED_SHARE_GROUPS = new Set([ + "security-reviewers", + "compliance-auditors", + "incident-commanders", +]); + +function evaluateSessionRecording(recording, options = {}) { + const findings = []; + const maxRawRetentionDays = options.maxRawRetentionDays ?? 30; + const maxShareHours = options.maxShareHours ?? 72; + + requireField(recording, "recordingId", findings); + requireField(recording, "workspaceId", findings); + requireField(recording, "actor", findings); + + const events = Array.isArray(recording.events) ? recording.events : []; + if (events.length === 0) { + findings.push(finding("hold", "events.empty", "No admin-session events were provided.")); + } + + for (const event of events) { + inspectSensitiveEvent(event, findings); + inspectCriticalAction(event, findings); + } + + inspectRetention(recording.retention || {}, maxRawRetentionDays, findings); + inspectExportBundle(recording.exportBundle || {}, findings); + inspectSharing(recording.share || {}, maxShareHours, findings); + + const severityRank = { info: 0, revise: 1, hold: 2 }; + const highest = findings.reduce((max, item) => Math.max(max, severityRank[item.severity]), 0); + const decision = highest === 2 ? "hold" : highest === 1 ? "revise" : "release"; + const score = Math.max(0, 100 - findings.reduce((total, item) => total + (item.severity === "hold" ? 30 : 12), 0)); + + return { + decision, + score, + findings, + requiredActions: findings + .filter((item) => item.severity !== "info") + .map((item) => item.action), + auditPacket: { + recordingId: recording.recordingId || "unknown", + workspaceId: recording.workspaceId || "unknown", + checkedAt: new Date("2026-07-05T00:00:00.000Z").toISOString(), + sensitiveEventCount: events.filter((event) => hasSensitiveMarkers(event)).length, + criticalActionCount: events.filter((event) => CRITICAL_ACTIONS.has(event.type)).length, + exportReady: decision === "release", + }, + }; +} + +function requireField(recording, field, findings) { + if (!recording || !recording[field]) { + findings.push(finding("revise", `recording.${field}.missing`, `Missing ${field}.`)); + } +} + +function inspectSensitiveEvent(event, findings) { + const markers = sensitiveMarkers(event); + if (markers.length === 0) return; + + if (!event.redaction || !["masked", "tokenized"].includes(event.redaction.mode)) { + findings.push(finding( + "hold", + "redaction.missing", + `Event ${event.id || event.type || "unknown"} exposes sensitive markers: ${markers.join(", ")}.`, + "Mask or tokenize all sensitive fields before session replay can be exported." + )); + } + + if (!event.redaction || !event.redaction.evidenceId) { + findings.push(finding( + "revise", + "redaction.evidence.missing", + `Event ${event.id || event.type || "unknown"} lacks redaction evidence.`, + "Attach a redaction evidence id for auditor traceability." + )); + } +} + +function inspectCriticalAction(event, findings) { + if (!CRITICAL_ACTIONS.has(event.type)) return; + + if (!event.reason || !event.ticketId) { + findings.push(finding( + "hold", + "critical_action.context.missing", + `Critical action ${event.type} lacks reason or ticket id.`, + "Add a business reason and linked ticket for the privileged action." + )); + } + + if (!event.reviewerApproval) { + findings.push(finding( + "revise", + "critical_action.approval.missing", + `Critical action ${event.type} lacks reviewer approval evidence.`, + "Attach reviewer approval before sharing the replay with enterprise auditors." + )); + } +} + +function inspectRetention(retention, maxRawRetentionDays, findings) { + if (typeof retention.rawDays !== "number") { + findings.push(finding("revise", "retention.raw_days.missing", "Raw playback retention is not declared.")); + return; + } + + if (retention.rawDays > maxRawRetentionDays && !retention.legalHoldId) { + findings.push(finding( + "hold", + "retention.raw_days.too_long", + `Raw playback retention is ${retention.rawDays} days without legal hold.`, + `Reduce raw playback retention to ${maxRawRetentionDays} days or attach a legal hold id.` + )); + } +} + +function inspectExportBundle(bundle, findings) { + for (const field of ["auditDigestId", "redactionManifestId", "viewerAccessPolicyId"]) { + if (!bundle[field]) { + findings.push(finding( + "revise", + `export.${field}.missing`, + `Export bundle is missing ${field}.`, + "Include a complete export bundle for enterprise review." + )); + } + } +} + +function inspectSharing(share, maxShareHours, findings) { + if (!share.enabled) return; + + const groups = Array.isArray(share.groups) ? share.groups : []; + const unknownGroups = groups.filter((group) => !ALLOWED_SHARE_GROUPS.has(group)); + if (unknownGroups.length > 0) { + findings.push(finding( + "hold", + "sharing.group.unapproved", + `Replay shared with unapproved groups: ${unknownGroups.join(", ")}.`, + "Restrict replay sharing to approved security, compliance, or incident groups." + )); + } + + if (typeof share.expiresInHours !== "number" || share.expiresInHours > maxShareHours) { + findings.push(finding( + "revise", + "sharing.expiry.invalid", + "Replay share expiry is missing or too long.", + `Set replay sharing expiry to ${maxShareHours} hours or less.` + )); + } +} + +function sensitiveMarkers(event) { + const markers = Array.isArray(event.contains) ? event.contains : []; + return markers.filter((marker) => SENSITIVE_MARKERS.has(marker)); +} + +function hasSensitiveMarkers(event) { + return sensitiveMarkers(event).length > 0; +} + +function finding(severity, code, message, action = "Update the session recording package and rerun the guard.") { + return { severity, code, message, action }; +} + +module.exports = { + evaluateSessionRecording, + SENSITIVE_MARKERS, + CRITICAL_ACTIONS, +}; diff --git a/enterprise-admin-session-redaction-guard/test.js b/enterprise-admin-session-redaction-guard/test.js new file mode 100644 index 00000000..7b8f2610 --- /dev/null +++ b/enterprise-admin-session-redaction-guard/test.js @@ -0,0 +1,86 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { evaluateSessionRecording } = require("./index"); + +const baseRecording = { + recordingId: "rec-enterprise-001", + workspaceId: "institution-lab-7", + actor: "admin@example.edu", + events: [ + { + id: "evt-1", + type: "role_change", + reason: "Temporary incident commander access", + ticketId: "SEC-1842", + reviewerApproval: "approval-771", + contains: ["email"], + redaction: { mode: "masked", evidenceId: "redact-evt-1" }, + }, + { + id: "evt-2", + type: "identity_provider_change", + reason: "Rotate SAML signing certificate", + ticketId: "IAM-390", + reviewerApproval: "approval-772", + contains: ["token"], + redaction: { mode: "tokenized", evidenceId: "redact-evt-2" }, + }, + ], + retention: { rawDays: 14, metadataDays: 365 }, + exportBundle: { + auditDigestId: "audit-digest-1", + redactionManifestId: "manifest-1", + viewerAccessPolicyId: "policy-1", + }, + share: { + enabled: true, + groups: ["security-reviewers", "compliance-auditors"], + expiresInHours: 48, + }, +}; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +{ + const result = evaluateSessionRecording(baseRecording); + assert.equal(result.decision, "release"); + assert.equal(result.auditPacket.exportReady, true); + assert.equal(result.findings.length, 0); +} + +{ + const recording = clone(baseRecording); + delete recording.events[1].redaction; + const result = evaluateSessionRecording(recording); + assert.equal(result.decision, "hold"); + assert(result.findings.some((item) => item.code === "redaction.missing")); +} + +{ + const recording = clone(baseRecording); + recording.retention.rawDays = 90; + const result = evaluateSessionRecording(recording); + assert.equal(result.decision, "hold"); + assert(result.requiredActions.some((action) => action.includes("legal hold"))); +} + +{ + const recording = clone(baseRecording); + delete recording.exportBundle.viewerAccessPolicyId; + const result = evaluateSessionRecording(recording); + assert.equal(result.decision, "revise"); + assert(result.findings.some((item) => item.code === "export.viewerAccessPolicyId.missing")); +} + +{ + const recording = clone(baseRecording); + recording.share.groups.push("all-staff"); + const result = evaluateSessionRecording(recording); + assert.equal(result.decision, "hold"); + assert(result.findings.some((item) => item.code === "sharing.group.unapproved")); +} + +console.log("enterprise admin session redaction guard tests passed");