diff --git a/webhook-payload-redaction-guard/README.md b/webhook-payload-redaction-guard/README.md new file mode 100644 index 00000000..88c14ac7 --- /dev/null +++ b/webhook-payload-redaction-guard/README.md @@ -0,0 +1,34 @@ +# Webhook Payload Redaction Guard + +Self-contained Enterprise Tooling slice for `SCIBASE-AI/SCIBASE.AI#19`. + +The guard validates outbound institutional webhook/API payloads before delivery. +It checks schema allowlists, private-project field leakage, PII and direct +identifier exposure, private storage links, data-residency routing, event +signature metadata, and dataset access safety. It emits deterministic event +decisions so unsafe payloads are blocked or redacted before institutional sync. + +This is intentionally separate from webhook replay ledgers, admin notification +escalation, connector certification, API change governance, data export approval, +deposit reconciliation, SCIM/HRIS deprovisioning, LMS roster passback, usage +cost allocation, incident response, data residency policy, and secret rotation +slices. Its job is outbound payload minimization and redaction before delivery. + +## Run + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +## Outputs + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.webm` + +All data is synthetic. The module does not call live webhook delivery, +repository sync, LMS sync, identity, storage, or external provider systems. diff --git a/webhook-payload-redaction-guard/demo-video.js b/webhook-payload-redaction-guard/demo-video.js new file mode 100644 index 00000000..1346a1f8 --- /dev/null +++ b/webhook-payload-redaction-guard/demo-video.js @@ -0,0 +1,173 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const reportDir = path.join(__dirname, "reports"); +const outputPath = path.join(reportDir, "demo.webm"); + +const chromeCandidates = [ + process.env.CHROME_PATH, + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" +].filter(Boolean); + +function findBrowser() { + const found = chromeCandidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + throw new Error("Chrome or Edge was not found. Set CHROME_PATH to generate reports/demo.webm."); + } + return found; +} + +function fileUrl(filePath) { + return `file:///${filePath.replace(/\\/g, "/")}`; +} + +const html = String.raw` + + + + Webhook payload redaction guard demo + + + + +
recording
+ + +`; + +fs.mkdirSync(reportDir, { recursive: true }); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "webhook-redaction-demo-")); +const htmlPath = path.join(tempDir, "demo.html"); +const profileDir = path.join(tempDir, "profile"); +fs.writeFileSync(htmlPath, html, "utf8"); + +const stdout = execFileSync( + findBrowser(), + [ + "--headless=new", + "--disable-gpu", + "--disable-dev-shm-usage", + "--autoplay-policy=no-user-gesture-required", + "--run-all-compositor-stages-before-draw", + "--virtual-time-budget=7500", + `--user-data-dir=${profileDir}`, + "--dump-dom", + fileUrl(htmlPath) + ], + { encoding: "utf8", maxBuffer: 30 * 1024 * 1024 } +); + +const match = stdout.match(/data:video\/webm;base64,([A-Za-z0-9+/=]+)/); +if (!match) { + throw new Error(`Demo video generation failed. Browser output ended with: ${stdout.slice(-600)}`); +} + +fs.writeFileSync(outputPath, Buffer.from(match[1], "base64")); +console.log(`Generated ${path.relative(process.cwd(), outputPath)}`); diff --git a/webhook-payload-redaction-guard/demo.js b/webhook-payload-redaction-guard/demo.js new file mode 100644 index 00000000..97c3730c --- /dev/null +++ b/webhook-payload-redaction-guard/demo.js @@ -0,0 +1,18 @@ +const fs = require("fs"); +const path = require("path"); +const { project } = require("./sample-data"); +const { buildReviewPacket, renderMarkdownReport, renderSvgSummary } = require("./index"); + +const reportDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportDir, { recursive: true }); + +const packet = buildReviewPacket(project); + +fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(packet, null, 2)}\n`, "utf8"); +fs.writeFileSync(path.join(reportDir, "reviewer-packet.md"), renderMarkdownReport(packet), "utf8"); +fs.writeFileSync(path.join(reportDir, "summary.svg"), renderSvgSummary(packet), "utf8"); + +console.log(`Generated reports for ${packet.guard}`); +console.log(`Decision: ${packet.decision}`); +console.log(`Score: ${packet.score}`); +console.log(`Findings: ${packet.findings.length}`); diff --git a/webhook-payload-redaction-guard/index.js b/webhook-payload-redaction-guard/index.js new file mode 100644 index 00000000..157fa913 --- /dev/null +++ b/webhook-payload-redaction-guard/index.js @@ -0,0 +1,282 @@ +const SEVERITY_WEIGHTS = { + critical: 34, + high: 20, + medium: 10, + low: 4 +}; + +function addFinding(findings, severity, rule, message, action, refs = []) { + findings.push({ severity, rule, message, action, refs }); +} + +function isPlainObject(value) { + return value && typeof value === "object" && !Array.isArray(value); +} + +function walkObject(value, visitor, path = []) { + if (!isPlainObject(value) && !Array.isArray(value)) { + return; + } + const entries = Array.isArray(value) ? value.entries() : Object.entries(value); + for (const [key, child] of entries) { + const nextPath = path.concat(String(key)); + visitor(String(key), child, nextPath); + walkObject(child, visitor, nextPath); + } +} + +function pathString(path) { + return path.join("."); +} + +function evaluateEvent(event, project, findings) { + if (!project.policy.allowedEventTypes.includes(event.eventType)) { + addFinding( + findings, + "critical", + "webhook-event-type-not-allowlisted", + `${event.eventId} uses non-allowlisted event type ${event.eventType}.`, + "Block delivery until the event type is approved for the destination connector.", + [event.eventId, event.eventType] + ); + } + + for (const field of Object.keys(event)) { + if (!project.policy.allowedTopLevelFields.includes(field) && field !== "destination") { + addFinding( + findings, + "medium", + "webhook-top-level-field-not-allowlisted", + `${event.eventId} includes non-allowlisted top-level field ${field}.`, + "Drop non-contract fields before webhook delivery.", + [event.eventId, field] + ); + } + } + + if (!project.policy.allowedRegions.includes(event.destination.region)) { + addFinding( + findings, + "high", + "webhook-region-not-allowed", + `${event.eventId} targets region ${event.destination.region}.`, + "Hold delivery until data-residency routing is approved.", + [event.eventId, event.destination.region] + ); + } + + const signature = event.signature || {}; + for (const field of project.policy.requiredSignatureFields) { + if (!signature[field]) { + addFinding( + findings, + "high", + "webhook-signature-metadata-incomplete", + `${event.eventId} is missing signature field ${field}.`, + "Regenerate signed event metadata before delivery.", + [event.eventId, field] + ); + } + } + if (signature.algorithm && signature.algorithm !== "HMAC-SHA256") { + addFinding( + findings, + "critical", + "webhook-signature-algorithm-unsafe", + `${event.eventId} uses unsafe signature algorithm ${signature.algorithm}.`, + "Block delivery until a production signing algorithm is used.", + [event.eventId, signature.algorithm] + ); + } + + walkObject(event, (key, value, path) => { + if (project.policy.piiFieldNames.includes(key)) { + addFinding( + findings, + "critical", + "webhook-pii-field-present", + `${event.eventId} includes PII field ${pathString(path)}.`, + "Redact direct identifiers before institutional webhook delivery.", + [event.eventId, pathString(path)] + ); + } + if (project.policy.blockedProjectFields.includes(key)) { + addFinding( + findings, + "high", + "webhook-private-project-field-present", + `${event.eventId} includes private project field ${pathString(path)}.`, + "Remove private workspace fields from outbound payloads.", + [event.eventId, pathString(path)] + ); + } + if (typeof value === "string" && value.includes("storage.example/private")) { + addFinding( + findings, + "critical", + "webhook-private-storage-link-present", + `${event.eventId} exposes a private storage URL at ${pathString(path)}.`, + "Replace private URLs with DOI/metadata links or suppress the field.", + [event.eventId, pathString(path)] + ); + } + }); + + if (event.project && event.project.visibility !== "public" && event.destination.purpose === "public-metadata") { + addFinding( + findings, + "high", + "private-project-routed-to-public-metadata", + `${event.eventId} routes ${event.project.visibility} project metadata to a public destination.`, + "Hold delivery until the workspace visibility and payload purpose agree.", + [event.eventId, event.project.id] + ); + } + + if (event.dataset) { + if (!project.policy.publicDatasetAccess.includes(event.dataset.access)) { + addFinding( + findings, + "critical", + "webhook-dataset-access-not-public-safe", + `${event.eventId} includes dataset ${event.dataset.id} with access ${event.dataset.access}.`, + "Suppress dataset delivery or emit metadata-only redacted payload.", + [event.eventId, event.dataset.id, event.dataset.access] + ); + } + if (event.dataset.access === "embargoed-metadata-only" && event.dataset.downloadUrl) { + addFinding( + findings, + "high", + "embargoed-dataset-download-url-present", + `${event.eventId} includes a download URL for embargoed dataset ${event.dataset.id}.`, + "Remove download links while preserving DOI and metadata.", + [event.eventId, event.dataset.id] + ); + } + } +} + +function evaluateWebhookPayloads(project) { + const findings = []; + for (const event of project.outboundEvents) { + evaluateEvent(event, project, findings); + } + + const eventDecisions = project.outboundEvents.map((event) => { + const eventFindings = findings.filter((finding) => finding.refs.includes(event.eventId)); + const hasCritical = eventFindings.some((finding) => finding.severity === "critical"); + const hasHigh = eventFindings.some((finding) => finding.severity === "high"); + return { + eventId: event.eventId, + decision: hasCritical ? "block-delivery" : hasHigh ? "redact-and-review" : "deliver", + rules: eventFindings.map((finding) => finding.rule) + }; + }); + + const severitySummary = findings.reduce( + (summary, finding) => { + summary[finding.severity] += 1; + return summary; + }, + { critical: 0, high: 0, medium: 0, low: 0 } + ); + const score = Math.max(0, 100 - findings.reduce((sum, finding) => sum + SEVERITY_WEIGHTS[finding.severity], 0)); + + return { findings, eventDecisions, severitySummary, score }; +} + +function decisionFromEvaluation(evaluation) { + if (evaluation.severitySummary.critical > 0) { + return "block-unsafe-webhook-delivery"; + } + if (evaluation.severitySummary.high > 0 || evaluation.score < 75) { + return "redact-and-review-before-delivery"; + } + if (evaluation.score < 90) { + return "manual-webhook-payload-review"; + } + return "webhook-payload-ready"; +} + +function buildReviewPacket(project) { + const evaluation = evaluateWebhookPayloads(project); + return { + guard: "webhook-payload-redaction-guard", + issue: "SCIBASE-AI/SCIBASE.AI#19", + asOfDate: project.asOfDate, + decision: decisionFromEvaluation(evaluation), + score: evaluation.score, + severitySummary: evaluation.severitySummary, + findings: evaluation.findings, + eventDecisions: evaluation.eventDecisions, + safety: [ + "Synthetic webhook, project, dataset, review, and connector data only", + "No live webhook delivery, repository sync, LMS sync, identity, storage, or external provider calls", + "No private institutional payloads, credentials, secrets, real users, or live admin mutations" + ] + }; +} + +function renderMarkdownReport(packet) { + const lines = [ + "# Webhook Payload Redaction Guard", + "", + `Issue: ${packet.issue}`, + `Decision: ${packet.decision}`, + `Score: ${packet.score}`, + "", + "## Event Decisions", + "" + ]; + + for (const decision of packet.eventDecisions) { + lines.push(`- ${decision.eventId}: ${decision.decision}`); + if (decision.rules.length > 0) { + lines.push(` - Rules: ${decision.rules.join(", ")}`); + } + } + + lines.push("", "## Findings", ""); + for (const finding of packet.findings) { + lines.push(`- **${finding.severity} / ${finding.rule}**: ${finding.message}`); + lines.push(` - Action: ${finding.action}`); + lines.push(` - Refs: ${finding.refs.join(", ") || "none"}`); + } + + lines.push("", "## Safety", ""); + for (const item of packet.safety) { + lines.push(`- ${item}`); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(packet) { + const scoreWidth = Math.max(44, Math.min(760, packet.score * 7.6)); + return ` + + Webhook Payload Redaction Guard + SCIBASE #19 enterprise outbound payload review + + ${packet.decision} + Critical ${packet.severitySummary.critical} | High ${packet.severitySummary.high} | Findings ${packet.findings.length} + + Payload readiness score + + + ${packet.score}/100 + + Block unsafe institutional delivery + Checks schema allowlists, PII, private fields, storage links, residency, signatures, and dataset access. + +`; +} + +module.exports = { + buildReviewPacket, + decisionFromEvaluation, + evaluateWebhookPayloads, + renderMarkdownReport, + renderSvgSummary +}; diff --git a/webhook-payload-redaction-guard/package.json b/webhook-payload-redaction-guard/package.json new file mode 100644 index 00000000..32c8cbec --- /dev/null +++ b/webhook-payload-redaction-guard/package.json @@ -0,0 +1,14 @@ +{ + "name": "webhook-payload-redaction-guard", + "version": "1.0.0", + "description": "Deterministic enterprise guard for webhook payload redaction and minimization.", + "main": "index.js", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check demo-video.js && node --check test.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node demo-video.js" + } +} diff --git a/webhook-payload-redaction-guard/reports/demo.webm b/webhook-payload-redaction-guard/reports/demo.webm new file mode 100644 index 00000000..6c4ca731 Binary files /dev/null and b/webhook-payload-redaction-guard/reports/demo.webm differ diff --git a/webhook-payload-redaction-guard/reports/reviewer-packet.md b/webhook-payload-redaction-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..a9bd83dc --- /dev/null +++ b/webhook-payload-redaction-guard/reports/reviewer-packet.md @@ -0,0 +1,61 @@ +# Webhook Payload Redaction Guard + +Issue: SCIBASE-AI/SCIBASE.AI#19 +Decision: block-unsafe-webhook-delivery +Score: 0 + +## Event Decisions + +- evt-001: block-delivery + - Rules: webhook-top-level-field-not-allowlisted, webhook-signature-metadata-incomplete, webhook-private-project-field-present, webhook-pii-field-present, webhook-pii-field-present, webhook-private-storage-link-present, webhook-dataset-access-not-public-safe +- evt-002: deliver +- evt-003: block-delivery + - Rules: webhook-region-not-allowed, webhook-signature-metadata-incomplete, webhook-signature-algorithm-unsafe, webhook-private-project-field-present, webhook-pii-field-present, webhook-pii-field-present + +## Findings + +- **medium / webhook-top-level-field-not-allowlisted**: evt-001 includes non-allowlisted top-level field debugTrace. + - Action: Drop non-contract fields before webhook delivery. + - Refs: evt-001, debugTrace +- **high / webhook-signature-metadata-incomplete**: evt-001 is missing signature field keyId. + - Action: Regenerate signed event metadata before delivery. + - Refs: evt-001, keyId +- **high / webhook-private-project-field-present**: evt-001 includes private project field project.privateNotes. + - Action: Remove private workspace fields from outbound payloads. + - Refs: evt-001, project.privateNotes +- **critical / webhook-pii-field-present**: evt-001 includes PII field project.owner.fullName. + - Action: Redact direct identifiers before institutional webhook delivery. + - Refs: evt-001, project.owner.fullName +- **critical / webhook-pii-field-present**: evt-001 includes PII field project.owner.email. + - Action: Redact direct identifiers before institutional webhook delivery. + - Refs: evt-001, project.owner.email +- **critical / webhook-private-storage-link-present**: evt-001 exposes a private storage URL at dataset.downloadUrl. + - Action: Replace private URLs with DOI/metadata links or suppress the field. + - Refs: evt-001, dataset.downloadUrl +- **critical / webhook-dataset-access-not-public-safe**: evt-001 includes dataset data-raw with access restricted. + - Action: Suppress dataset delivery or emit metadata-only redacted payload. + - Refs: evt-001, data-raw, restricted +- **high / webhook-region-not-allowed**: evt-003 targets region APAC. + - Action: Hold delivery until data-residency routing is approved. + - Refs: evt-003, APAC +- **high / webhook-signature-metadata-incomplete**: evt-003 is missing signature field digest. + - Action: Regenerate signed event metadata before delivery. + - Refs: evt-003, digest +- **critical / webhook-signature-algorithm-unsafe**: evt-003 uses unsafe signature algorithm none. + - Action: Block delivery until a production signing algorithm is used. + - Refs: evt-003, none +- **high / webhook-private-project-field-present**: evt-003 includes private project field project.internalReviewerComments. + - Action: Remove private workspace fields from outbound payloads. + - Refs: evt-003, project.internalReviewerComments +- **critical / webhook-pii-field-present**: evt-003 includes PII field review.reviewer.fullName. + - Action: Redact direct identifiers before institutional webhook delivery. + - Refs: evt-003, review.reviewer.fullName +- **critical / webhook-pii-field-present**: evt-003 includes PII field review.reviewer.employeeId. + - Action: Redact direct identifiers before institutional webhook delivery. + - Refs: evt-003, review.reviewer.employeeId + +## Safety + +- Synthetic webhook, project, dataset, review, and connector data only +- No live webhook delivery, repository sync, LMS sync, identity, storage, or external provider calls +- No private institutional payloads, credentials, secrets, real users, or live admin mutations diff --git a/webhook-payload-redaction-guard/reports/summary.json b/webhook-payload-redaction-guard/reports/summary.json new file mode 100644 index 00000000..d74b8d8c --- /dev/null +++ b/webhook-payload-redaction-guard/reports/summary.json @@ -0,0 +1,183 @@ +{ + "guard": "webhook-payload-redaction-guard", + "issue": "SCIBASE-AI/SCIBASE.AI#19", + "asOfDate": "2026-05-23", + "decision": "block-unsafe-webhook-delivery", + "score": 0, + "severitySummary": { + "critical": 7, + "high": 5, + "medium": 1, + "low": 0 + }, + "findings": [ + { + "severity": "medium", + "rule": "webhook-top-level-field-not-allowlisted", + "message": "evt-001 includes non-allowlisted top-level field debugTrace.", + "action": "Drop non-contract fields before webhook delivery.", + "refs": [ + "evt-001", + "debugTrace" + ] + }, + { + "severity": "high", + "rule": "webhook-signature-metadata-incomplete", + "message": "evt-001 is missing signature field keyId.", + "action": "Regenerate signed event metadata before delivery.", + "refs": [ + "evt-001", + "keyId" + ] + }, + { + "severity": "high", + "rule": "webhook-private-project-field-present", + "message": "evt-001 includes private project field project.privateNotes.", + "action": "Remove private workspace fields from outbound payloads.", + "refs": [ + "evt-001", + "project.privateNotes" + ] + }, + { + "severity": "critical", + "rule": "webhook-pii-field-present", + "message": "evt-001 includes PII field project.owner.fullName.", + "action": "Redact direct identifiers before institutional webhook delivery.", + "refs": [ + "evt-001", + "project.owner.fullName" + ] + }, + { + "severity": "critical", + "rule": "webhook-pii-field-present", + "message": "evt-001 includes PII field project.owner.email.", + "action": "Redact direct identifiers before institutional webhook delivery.", + "refs": [ + "evt-001", + "project.owner.email" + ] + }, + { + "severity": "critical", + "rule": "webhook-private-storage-link-present", + "message": "evt-001 exposes a private storage URL at dataset.downloadUrl.", + "action": "Replace private URLs with DOI/metadata links or suppress the field.", + "refs": [ + "evt-001", + "dataset.downloadUrl" + ] + }, + { + "severity": "critical", + "rule": "webhook-dataset-access-not-public-safe", + "message": "evt-001 includes dataset data-raw with access restricted.", + "action": "Suppress dataset delivery or emit metadata-only redacted payload.", + "refs": [ + "evt-001", + "data-raw", + "restricted" + ] + }, + { + "severity": "high", + "rule": "webhook-region-not-allowed", + "message": "evt-003 targets region APAC.", + "action": "Hold delivery until data-residency routing is approved.", + "refs": [ + "evt-003", + "APAC" + ] + }, + { + "severity": "high", + "rule": "webhook-signature-metadata-incomplete", + "message": "evt-003 is missing signature field digest.", + "action": "Regenerate signed event metadata before delivery.", + "refs": [ + "evt-003", + "digest" + ] + }, + { + "severity": "critical", + "rule": "webhook-signature-algorithm-unsafe", + "message": "evt-003 uses unsafe signature algorithm none.", + "action": "Block delivery until a production signing algorithm is used.", + "refs": [ + "evt-003", + "none" + ] + }, + { + "severity": "high", + "rule": "webhook-private-project-field-present", + "message": "evt-003 includes private project field project.internalReviewerComments.", + "action": "Remove private workspace fields from outbound payloads.", + "refs": [ + "evt-003", + "project.internalReviewerComments" + ] + }, + { + "severity": "critical", + "rule": "webhook-pii-field-present", + "message": "evt-003 includes PII field review.reviewer.fullName.", + "action": "Redact direct identifiers before institutional webhook delivery.", + "refs": [ + "evt-003", + "review.reviewer.fullName" + ] + }, + { + "severity": "critical", + "rule": "webhook-pii-field-present", + "message": "evt-003 includes PII field review.reviewer.employeeId.", + "action": "Redact direct identifiers before institutional webhook delivery.", + "refs": [ + "evt-003", + "review.reviewer.employeeId" + ] + } + ], + "eventDecisions": [ + { + "eventId": "evt-001", + "decision": "block-delivery", + "rules": [ + "webhook-top-level-field-not-allowlisted", + "webhook-signature-metadata-incomplete", + "webhook-private-project-field-present", + "webhook-pii-field-present", + "webhook-pii-field-present", + "webhook-private-storage-link-present", + "webhook-dataset-access-not-public-safe" + ] + }, + { + "eventId": "evt-002", + "decision": "deliver", + "rules": [] + }, + { + "eventId": "evt-003", + "decision": "block-delivery", + "rules": [ + "webhook-region-not-allowed", + "webhook-signature-metadata-incomplete", + "webhook-signature-algorithm-unsafe", + "webhook-private-project-field-present", + "webhook-pii-field-present", + "webhook-pii-field-present" + ] + } + ], + "safety": [ + "Synthetic webhook, project, dataset, review, and connector data only", + "No live webhook delivery, repository sync, LMS sync, identity, storage, or external provider calls", + "No private institutional payloads, credentials, secrets, real users, or live admin mutations" + ] +} diff --git a/webhook-payload-redaction-guard/reports/summary.svg b/webhook-payload-redaction-guard/reports/summary.svg new file mode 100644 index 00000000..7b4ec9bd --- /dev/null +++ b/webhook-payload-redaction-guard/reports/summary.svg @@ -0,0 +1,16 @@ + + + Webhook Payload Redaction Guard + SCIBASE #19 enterprise outbound payload review + + block-unsafe-webhook-delivery + Critical 7 | High 5 | Findings 13 + + Payload readiness score + + + 0/100 + + Block unsafe institutional delivery + Checks schema allowlists, PII, private fields, storage links, residency, signatures, and dataset access. + diff --git a/webhook-payload-redaction-guard/requirements-map.md b/webhook-payload-redaction-guard/requirements-map.md new file mode 100644 index 00000000..51ae5281 --- /dev/null +++ b/webhook-payload-redaction-guard/requirements-map.md @@ -0,0 +1,17 @@ +# Requirements Map + +Issue: `SCIBASE-AI/SCIBASE.AI#19` + +| Issue requirement | Implementation | +| --- | --- | +| Enterprise API and webhooks | Validates outbound webhook/API event payloads before institutional delivery. | +| Secure integrations | Checks event type allowlists, payload schema, signing metadata, data-residency region, and connector destination. | +| Project and dataset governance | Blocks private project fields, direct identifiers, private storage URLs, and non-public-safe dataset access in outgoing payloads. | +| Admin oversight | Emits deterministic event decisions and reviewer packets for delivery, redaction, or blocking. | +| Safe local validation | Includes dependency-free tests and demo generation from synthetic enterprise event metadata only. | + +## Non-goals + +- No live webhook delivery, repository sync, LMS sync, identity, storage, or provider calls. +- No private institutional payloads, credentials, secrets, real users, or live admin mutations. +- No replacement for replay, alerting, connector certification, API-change, SCIM, LMS, deposit, or cost-allocation workflows. diff --git a/webhook-payload-redaction-guard/sample-data.js b/webhook-payload-redaction-guard/sample-data.js new file mode 100644 index 00000000..828a2448 --- /dev/null +++ b/webhook-payload-redaction-guard/sample-data.js @@ -0,0 +1,79 @@ +const project = { + asOfDate: "2026-05-23", + policy: { + allowedEventTypes: ["project.published", "dataset.deposited", "review.completed"], + allowedTopLevelFields: ["eventId", "eventType", "occurredAt", "institutionId", "project", "dataset", "review", "signature"], + piiFieldNames: ["email", "phone", "address", "fullName", "studentId", "employeeId"], + blockedProjectFields: ["privateNotes", "internalReviewerComments", "billingAccountId"], + requiredSignatureFields: ["algorithm", "keyId", "digest"], + allowedRegions: ["US", "EU"], + publicDatasetAccess: ["public", "embargoed-metadata-only"] + }, + outboundEvents: [ + { + eventId: "evt-001", + eventType: "dataset.deposited", + occurredAt: "2026-05-22T18:20:00Z", + institutionId: "inst-northbridge", + destination: { connector: "Invenio", region: "EU", purpose: "repository-sync" }, + project: { + id: "proj-101", + title: "Synthetic Proteomics Collaboration", + visibility: "private", + privateNotes: "IRB reviewer asked for delay", + owner: { fullName: "Maya Chen", email: "maya@example.edu", orcid: "0000-0002-1111-2222" } + }, + dataset: { + id: "data-raw", + access: "restricted", + embargoUntil: "2026-08-01", + downloadUrl: "https://storage.example/private/raw.csv", + doi: "" + }, + signature: { algorithm: "HMAC-SHA256", keyId: "", digest: "sha256:abc" }, + debugTrace: "internal-router-17" + }, + { + eventId: "evt-002", + eventType: "project.published", + occurredAt: "2026-05-22T18:25:00Z", + institutionId: "inst-northbridge", + destination: { connector: "DSpace", region: "US", purpose: "public-metadata" }, + project: { + id: "proj-202", + title: "Open Climate Notebook", + visibility: "public", + owner: { orcid: "0000-0003-3333-4444" } + }, + dataset: { + id: "data-open", + access: "public", + embargoUntil: null, + downloadUrl: "https://doi.org/10.5281/zenodo.20260523", + doi: "10.5281/zenodo.20260523" + }, + signature: { algorithm: "HMAC-SHA256", keyId: "enterprise-prod-2026", digest: "sha256:def" } + }, + { + eventId: "evt-003", + eventType: "review.completed", + occurredAt: "2026-05-22T18:30:00Z", + institutionId: "inst-northbridge", + destination: { connector: "Moodle", region: "APAC", purpose: "course-sync" }, + project: { + id: "proj-303", + title: "Doctoral Research Review", + visibility: "institutional-only", + internalReviewerComments: "Reviewer identity must remain blinded" + }, + review: { + id: "rev-900", + outcome: "approved", + reviewer: { fullName: "Anonymous Reviewer", employeeId: "E-991" } + }, + signature: { algorithm: "none", keyId: "test", digest: "" } + } + ] +}; + +module.exports = { project }; diff --git a/webhook-payload-redaction-guard/test.js b/webhook-payload-redaction-guard/test.js new file mode 100644 index 00000000..a5acd4f8 --- /dev/null +++ b/webhook-payload-redaction-guard/test.js @@ -0,0 +1,75 @@ +const assert = require("assert"); +const { project } = require("./sample-data"); +const { buildReviewPacket, evaluateWebhookPayloads, renderMarkdownReport, renderSvgSummary } = require("./index"); + +const evaluation = evaluateWebhookPayloads(project); +const packet = buildReviewPacket(project); + +assert.strictEqual(packet.guard, "webhook-payload-redaction-guard"); +assert.strictEqual(packet.issue, "SCIBASE-AI/SCIBASE.AI#19"); +assert.strictEqual(packet.decision, "block-unsafe-webhook-delivery"); + +assert.ok( + evaluation.findings.some((finding) => finding.rule === "webhook-pii-field-present"), + "expected PII field finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "webhook-private-project-field-present"), + "expected private field finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "webhook-private-storage-link-present"), + "expected private storage link finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "webhook-region-not-allowed"), + "expected region finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "webhook-signature-algorithm-unsafe"), + "expected signature algorithm finding" +); + +const decisionByEvent = new Map(evaluation.eventDecisions.map((decision) => [decision.eventId, decision.decision])); +assert.strictEqual(decisionByEvent.get("evt-001"), "block-delivery"); +assert.strictEqual(decisionByEvent.get("evt-002"), "deliver"); +assert.strictEqual(decisionByEvent.get("evt-003"), "block-delivery"); + +const cleanProject = JSON.parse(JSON.stringify(project)); +cleanProject.outboundEvents = [ + { + eventId: "evt-clean", + eventType: "project.published", + occurredAt: "2026-05-22T18:25:00Z", + institutionId: "inst-northbridge", + destination: { connector: "DSpace", region: "US", purpose: "public-metadata" }, + project: { + id: "proj-202", + title: "Open Climate Notebook", + visibility: "public", + owner: { orcid: "0000-0003-3333-4444" } + }, + dataset: { + id: "data-open", + access: "public", + embargoUntil: null, + downloadUrl: "https://doi.org/10.5281/zenodo.20260523", + doi: "10.5281/zenodo.20260523" + }, + signature: { algorithm: "HMAC-SHA256", keyId: "enterprise-prod-2026", digest: "sha256:def" } + } +]; + +const cleanPacket = buildReviewPacket(cleanProject); +assert.strictEqual(cleanPacket.decision, "webhook-payload-ready"); +assert.strictEqual(cleanPacket.findings.length, 0); + +const markdown = renderMarkdownReport(packet); +assert.ok(markdown.includes("## Event Decisions")); +assert.ok(markdown.includes("webhook-pii-field-present")); + +const svg = renderSvgSummary(packet); +assert.ok(svg.includes("