diff --git a/scientific-bounty-system/anonymous-review-packet-guard/README.md b/scientific-bounty-system/anonymous-review-packet-guard/README.md new file mode 100644 index 00000000..593892db --- /dev/null +++ b/scientific-bounty-system/anonymous-review-packet-guard/README.md @@ -0,0 +1,36 @@ +# Anonymous Review Packet Guard + +This module is a focused Scientific Bounty System slice for issue #18. It validates anonymous challenge review packets before sponsor or reviewer access so solver identity does not leak through metadata, files, notebooks, commits, or access grants. + +It checks: + +- commit author names and emails +- local file paths, lab folders, and institution references +- notebook outputs containing usernames, paths, or emails +- document author/company/editor metadata +- EXIF GPS, device-owner, and serial metadata +- acknowledgements and team-bio sections +- sponsor/reviewer grants to unredacted packets +- missing redaction receipts +- audit logs that expose identity outside arbitrators + +All fixtures are synthetic. The module does not use credentials, private submissions, live challenge data, external APIs, payment rails, or platform secrets. + +## Run + +```sh +node scientific-bounty-system/anonymous-review-packet-guard/test.js +node scientific-bounty-system/anonymous-review-packet-guard/demo.js +node scientific-bounty-system/anonymous-review-packet-guard/make-demo-video.js +``` + +The demo writes reviewer artifacts to `reports/`: + +- `anonymous-review-packet-audit.json` +- `anonymous-review-packet-audit.md` +- `anonymous-review-packet-summary.svg` +- `demo.mp4` + +## Bounty Fit + +Issue #18 calls for anonymous or named participation, secure submission workspaces, standardized submission packages, sponsor review, arbitration, and IP-safe release. This guard implements the identity-redaction gate that has to pass before anonymous submissions can be reviewed without compromising solver anonymity or arbitration evidence. diff --git a/scientific-bounty-system/anonymous-review-packet-guard/demo.js b/scientific-bounty-system/anonymous-review-packet-guard/demo.js new file mode 100644 index 00000000..d3b56daf --- /dev/null +++ b/scientific-bounty-system/anonymous-review-packet-guard/demo.js @@ -0,0 +1,14 @@ +const path = require("path"); +const { auditAnonymousReviewPacket, writeAuditReports } = require("./index"); +const { samplePacket } = require("./sample-data"); + +const report = auditAnonymousReviewPacket(samplePacket); +const reportsDir = path.join(__dirname, "reports"); +const outputs = writeAuditReports(report, reportsDir); + +console.log(`decision=${report.decision} riskScore=${report.riskScore} findings=${report.findingCount}`); +console.log(`digest=${report.evidenceDigest}`); +console.log(`reports=${reportsDir}`); +console.log(`json=${outputs.jsonPath}`); +console.log(`markdown=${outputs.markdownPath}`); +console.log(`svg=${outputs.svgPath}`); diff --git a/scientific-bounty-system/anonymous-review-packet-guard/index.js b/scientific-bounty-system/anonymous-review-packet-guard/index.js new file mode 100644 index 00000000..988f2a64 --- /dev/null +++ b/scientific-bounty-system/anonymous-review-packet-guard/index.js @@ -0,0 +1,396 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const RULES = { + COMMIT_AUTHOR_LEAK: { + severity: "high", + penalty: 18, + recommendation: "Rewrite or mask commit author metadata before the sponsor review packet is released.", + }, + FILE_PATH_IDENTITY_LEAK: { + severity: "high", + penalty: 16, + recommendation: "Replace local usernames, lab folders, and institution paths with neutral artifact paths.", + }, + NOTEBOOK_OUTPUT_IDENTITY_LEAK: { + severity: "high", + penalty: 15, + recommendation: "Clear notebook outputs or redact user, host, and local path fragments before reviewer access.", + }, + DOCUMENT_METADATA_LEAK: { + severity: "high", + penalty: 15, + recommendation: "Strip document author, company, editor, and template metadata from review exports.", + }, + EXIF_GPS_OR_DEVICE_LEAK: { + severity: "medium", + penalty: 10, + recommendation: "Remove EXIF GPS, serial, device owner, and capture metadata from figures and media.", + }, + ACKNOWLEDGEMENT_IDENTITY_LEAK: { + severity: "medium", + penalty: 9, + recommendation: "Move acknowledgements and institution references to the post-award disclosure packet.", + }, + UNBLINDED_REVIEWER_ROLE: { + severity: "high", + penalty: 17, + recommendation: "Remove sponsor/reviewer access until all anonymous review roles use the redacted packet.", + }, + REDACTION_EVIDENCE_MISSING: { + severity: "medium", + penalty: 8, + recommendation: "Attach redaction receipts for every transformed artifact before review release.", + }, + ANONYMITY_MODE_MISMATCH: { + severity: "high", + penalty: 18, + recommendation: "Hold the submission until the challenge anonymity setting matches the generated packet.", + }, + AUDIT_LOG_UNPROTECTED: { + severity: "medium", + penalty: 7, + recommendation: "Keep full identity audit logs restricted to arbitrators until award or dispute resolution.", + }, +}; + +const IDENTITY_PATTERNS = [ + { id: "email", pattern: /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i }, + { id: "home-directory", pattern: /\/Users\/[A-Za-z0-9._-]+|\/home\/[A-Za-z0-9._-]+/i }, + { id: "windows-profile", pattern: /C:\\Users\\[A-Za-z0-9._-]+/i }, + { id: "institution", pattern: /\b(Stanford|MIT|Harvard|Oxford|Cambridge|Berkeley|Caltech|UCLA|University|Institute)\b/i }, + { id: "orcid", pattern: /\b\d{4}-\d{4}-\d{4}-\d{3}[\dX]\b/i }, + { id: "github-handle", pattern: /@[A-Za-z0-9-]{3,39}\b/ }, +]; + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function digest(value, length = 16) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, length); +} + +function addFinding(findings, code, message, evidence) { + const rule = RULES[code]; + findings.push({ + code, + severity: rule.severity, + penalty: rule.penalty, + message, + evidence, + recommendation: rule.recommendation, + }); +} + +function detectIdentity(text) { + const value = String(text || ""); + return IDENTITY_PATTERNS.filter((entry) => entry.pattern.test(value)).map((entry) => entry.id); +} + +function auditChallengeMode(packet, findings) { + if (packet.challenge.anonymityMode !== packet.reviewPacket.anonymityMode) { + addFinding( + findings, + "ANONYMITY_MODE_MISMATCH", + "Challenge anonymity mode does not match the generated review packet.", + { + challengeMode: packet.challenge.anonymityMode, + reviewPacketMode: packet.reviewPacket.anonymityMode, + }, + ); + } +} + +function auditArtifacts(packet, findings) { + for (const artifact of packet.artifacts || []) { + const pathHits = detectIdentity(artifact.path); + if (pathHits.length > 0) { + addFinding(findings, "FILE_PATH_IDENTITY_LEAK", `Artifact ${artifact.id} path contains identity-bearing text.`, { + artifactId: artifact.id, + path: artifact.path, + detectedSignals: pathHits, + }); + } + + const metadataText = JSON.stringify(artifact.metadata || {}); + const metadataHits = detectIdentity(metadataText); + if (metadataHits.length > 0 || artifact.metadata.author || artifact.metadata.company || artifact.metadata.lastModifiedBy) { + addFinding(findings, "DOCUMENT_METADATA_LEAK", `Artifact ${artifact.id} metadata can identify the solver team.`, { + artifactId: artifact.id, + metadata: artifact.metadata, + detectedSignals: metadataHits, + }); + } + + if (artifact.type === "notebook") { + const outputHits = (artifact.outputs || []) + .map((output, index) => ({ index, signals: detectIdentity(output), value: output })) + .filter((entry) => entry.signals.length > 0); + if (outputHits.length > 0) { + addFinding(findings, "NOTEBOOK_OUTPUT_IDENTITY_LEAK", `Notebook ${artifact.id} output contains solver identity signals.`, { + artifactId: artifact.id, + outputHits, + }); + } + } + + if (artifact.type === "image" || artifact.type === "media") { + const exif = artifact.metadata.exif || {}; + if (exif.gps || exif.deviceOwner || exif.serialNumber) { + addFinding(findings, "EXIF_GPS_OR_DEVICE_LEAK", `Media artifact ${artifact.id} contains identifying EXIF metadata.`, { + artifactId: artifact.id, + exif, + }); + } + } + } +} + +function auditCommits(packet, findings) { + for (const commit of packet.commitHistory || []) { + const signals = [ + ...detectIdentity(commit.authorName), + ...detectIdentity(commit.authorEmail), + ...detectIdentity(commit.message), + ]; + const redactedAuthor = /^(anon|anonymous|redacted)([-_\s]solver)?$/i.test(String(commit.authorName || "")); + const hasUnredactedAuthorName = Boolean(commit.authorName) && !redactedAuthor; + if (signals.length > 0 || commit.authorEmail || hasUnredactedAuthorName) { + addFinding(findings, "COMMIT_AUTHOR_LEAK", `Commit ${commit.sha} exposes solver identity metadata.`, { + sha: commit.sha, + authorName: commit.authorName, + authorEmail: commit.authorEmail, + detectedSignals: Array.from(new Set(signals)), + }); + } + } +} + +function auditNarrative(packet, findings) { + for (const section of packet.reviewPacket.sections || []) { + if (!["acknowledgements", "conflicts", "team-bio"].includes(section.kind)) continue; + const signals = detectIdentity(section.text); + if (signals.length > 0 || !section.redacted) { + addFinding(findings, "ACKNOWLEDGEMENT_IDENTITY_LEAK", `Review packet section ${section.id} can identify the solver team.`, { + sectionId: section.id, + kind: section.kind, + redacted: Boolean(section.redacted), + detectedSignals: signals, + }); + } + } +} + +function auditAccess(packet, findings) { + for (const grant of packet.accessGrants || []) { + if (grant.role === "sponsor" || grant.role === "reviewer") { + if (packet.challenge.anonymityMode === "anonymous" && grant.packetVariant !== "redacted") { + addFinding(findings, "UNBLINDED_REVIEWER_ROLE", `Access grant ${grant.id} exposes an unredacted packet to ${grant.role}.`, { + grantId: grant.id, + role: grant.role, + principal: grant.principal, + packetVariant: grant.packetVariant, + }); + } + } + } +} + +function auditRedactionEvidence(packet, findings) { + const redactionReceipts = new Set((packet.redactionReceipts || []).map((receipt) => receipt.artifactId)); + for (const artifact of packet.artifacts || []) { + if (artifact.requiresRedaction && !redactionReceipts.has(artifact.id)) { + addFinding(findings, "REDACTION_EVIDENCE_MISSING", `Artifact ${artifact.id} requires redaction but has no receipt.`, { + artifactId: artifact.id, + requiredReason: artifact.redactionReason || "identity-bearing metadata", + }); + } + } +} + +function auditAuditLogs(packet, findings) { + for (const log of packet.auditLogs || []) { + if (log.containsIdentity && log.visibility !== "arbitrator-only") { + addFinding(findings, "AUDIT_LOG_UNPROTECTED", `Audit log ${log.id} contains identity data outside arbitrator-only visibility.`, { + logId: log.id, + visibility: log.visibility, + containsIdentity: log.containsIdentity, + }); + } + } +} + +function summarize(findings) { + const severityCounts = { high: 0, medium: 0, low: 0 }; + for (const finding of findings) severityCounts[finding.severity] += 1; + const riskScore = Math.min(100, findings.reduce((sum, finding) => sum + finding.penalty, 0)); + let decision = "release-redacted-packet"; + if (severityCounts.high > 0 || riskScore >= 50) decision = "hold-review-packet"; + else if (severityCounts.medium > 0) decision = "needs-redaction-review"; + return { severityCounts, riskScore, decision }; +} + +function auditAnonymousReviewPacket(packet, options = {}) { + if (!packet || typeof packet !== "object") throw new TypeError("packet must be an object"); + const normalized = { + challenge: {}, + reviewPacket: { sections: [] }, + artifacts: [], + commitHistory: [], + accessGrants: [], + redactionReceipts: [], + auditLogs: [], + ...packet, + }; + const findings = []; + + auditChallengeMode(normalized, findings); + auditArtifacts(normalized, findings); + auditCommits(normalized, findings); + auditNarrative(normalized, findings); + auditAccess(normalized, findings); + auditRedactionEvidence(normalized, findings); + auditAuditLogs(normalized, findings); + + const summary = summarize(findings); + const evidenceDigest = digest({ + challengeId: normalized.challenge.id, + submissionId: normalized.submissionId, + reviewPacket: normalized.reviewPacket, + findings, + }); + + return { + submissionId: normalized.submissionId, + challengeId: normalized.challenge.id, + title: normalized.challenge.title, + generatedAt: options.now || "2026-05-28T21:10:00.000Z", + decision: summary.decision, + riskScore: summary.riskScore, + severityCounts: summary.severityCounts, + findingCount: findings.length, + evidenceDigest, + summary: { + artifacts: normalized.artifacts.length, + commits: normalized.commitHistory.length, + accessGrants: normalized.accessGrants.length, + redactionReceipts: normalized.redactionReceipts.length, + auditLogs: normalized.auditLogs.length, + }, + findings, + }; +} + +function createMarkdownReport(report) { + const findings = report.findings.length + ? report.findings + .map( + (finding, index) => [ + `### ${index + 1}. ${finding.code} (${finding.severity})`, + finding.message, + "", + `Recommendation: ${finding.recommendation}`, + "", + "Evidence:", + "```json", + JSON.stringify(finding.evidence, null, 2), + "```", + ].join("\n"), + ) + .join("\n\n") + : "No blocking findings."; + + return [ + "# Anonymous Review Packet Guard", + "", + `Challenge: ${report.title || report.challengeId}`, + `Submission: ${report.submissionId}`, + `Decision: ${report.decision}`, + `Risk score: ${report.riskScore}/100`, + `Evidence digest: ${report.evidenceDigest}`, + "", + "## Scope", + "", + `Artifacts: ${report.summary.artifacts}`, + `Commits: ${report.summary.commits}`, + `Access grants: ${report.summary.accessGrants}`, + `Redaction receipts: ${report.summary.redactionReceipts}`, + `Audit logs: ${report.summary.auditLogs}`, + "", + "## Findings", + "", + findings, + "", + ].join("\n"); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function createSummarySvg(report) { + const riskWidth = Math.max(0, Math.min(1, report.riskScore / 100)) * 560; + const color = report.decision === "release-redacted-packet" ? "#168a4a" : report.decision === "needs-redaction-review" ? "#b7791f" : "#b91c1c"; + const rows = report.findings + .slice(0, 5) + .map( + (finding, index) => + `${escapeXml( + finding.code, + )}: ${escapeXml(finding.message).slice(0, 92)}`, + ) + .join("\n"); + + return ` + + + + Anonymous Review Packet Guard + ${escapeXml(report.title || report.challengeId)} + ${escapeXml(report.decision)} + Risk ${report.riskScore}/100 · findings ${ + report.findingCount + } · digest ${escapeXml(report.evidenceDigest)} + + + Top findings + ${rows} + Synthetic challenge packet only. No private submissions, credentials, or external services. + +`; +} + +function writeAuditReports(report, outputDir) { + fs.mkdirSync(outputDir, { recursive: true }); + const jsonPath = path.join(outputDir, "anonymous-review-packet-audit.json"); + const markdownPath = path.join(outputDir, "anonymous-review-packet-audit.md"); + const svgPath = path.join(outputDir, "anonymous-review-packet-summary.svg"); + fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`); + fs.writeFileSync(markdownPath, createMarkdownReport(report)); + fs.writeFileSync(svgPath, createSummarySvg(report)); + return { jsonPath, markdownPath, svgPath }; +} + +module.exports = { + RULES, + auditAnonymousReviewPacket, + createMarkdownReport, + createSummarySvg, + detectIdentity, + digest, + stableStringify, + writeAuditReports, +}; diff --git a/scientific-bounty-system/anonymous-review-packet-guard/make-demo-video.js b/scientific-bounty-system/anonymous-review-packet-guard/make-demo-video.js new file mode 100644 index 00000000..29567e05 --- /dev/null +++ b/scientific-bounty-system/anonymous-review-packet-guard/make-demo-video.js @@ -0,0 +1,154 @@ +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const { auditAnonymousReviewPacket } = require("./index"); +const { samplePacket } = require("./sample-data"); + +const WIDTH = 1280; +const HEIGHT = 720; +const FPS = 12; +const FRAMES = 48; + +const FONT = { + " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"], + "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"], + "/": ["00001", "00010", "00010", "00100", "01000", "01000", "10000"], + ":": ["00000", "01100", "01100", "00000", "01100", "01100", "00000"], + "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"], + "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"], + "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"], + "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"], + "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"], + "5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"], + "6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"], + "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], + "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], + "9": ["01110", "10001", "10001", "01111", "00001", "00010", "11100"], + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], + C: ["01110", "10001", "10000", "10000", "10000", "10001", "01110"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], + G: ["01110", "10001", "10000", "10111", "10001", "10001", "01111"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"], + J: ["00111", "00010", "00010", "00010", "00010", "10010", "01100"], + K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], + N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"], + W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"], + X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"], + Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], + Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"], +}; + +function color(hex) { + return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; +} + +function fill(buffer, fillColor) { + for (let index = 0; index < buffer.length; index += 3) { + buffer[index] = fillColor[0]; + buffer[index + 1] = fillColor[1]; + buffer[index + 2] = fillColor[2]; + } +} + +function rect(buffer, x, y, width, height, fillColor) { + const x0 = Math.max(0, Math.floor(x)); + const y0 = Math.max(0, Math.floor(y)); + const x1 = Math.min(WIDTH, Math.floor(x + width)); + const y1 = Math.min(HEIGHT, Math.floor(y + height)); + for (let row = y0; row < y1; row += 1) { + for (let col = x0; col < x1; col += 1) { + const index = (row * WIDTH + col) * 3; + buffer[index] = fillColor[0]; + buffer[index + 1] = fillColor[1]; + buffer[index + 2] = fillColor[2]; + } + } +} + +function text(buffer, phrase, x, y, scale, fillColor) { + let cursor = x; + for (const rawChar of phrase.toUpperCase()) { + const glyph = FONT[rawChar] || FONT[" "]; + for (let row = 0; row < glyph.length; row += 1) { + for (let col = 0; col < glyph[row].length; col += 1) { + if (glyph[row][col] === "1") rect(buffer, cursor + col * scale, y + row * scale, scale, scale, fillColor); + } + } + cursor += 6 * scale; + } +} + +function writePpm(filePath, buffer) { + fs.writeFileSync(filePath, Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`), buffer])); +} + +function frame(frameIndex, report) { + const buffer = Buffer.alloc(WIDTH * HEIGHT * 3); + fill(buffer, color("#f8fafc")); + rect(buffer, 56, 52, 1168, 616, color("#ffffff")); + rect(buffer, 56, 52, 1168, 8, color("#b91c1c")); + rect(buffer, 92, 112, 18 + frameIndex * 12, 18, color("#b91c1c")); + text(buffer, "ANONYMOUS REVIEW PACKET GUARD", 92, 96, 6, color("#0f172a")); + text(buffer, `DECISION ${report.decision}`, 92, 184, 5, color("#b91c1c")); + text(buffer, `RISK ${report.riskScore}/100`, 92, 250, 5, color("#334155")); + text(buffer, `FINDINGS ${report.findingCount} DIGEST ${report.evidenceDigest}`, 92, 310, 4, color("#475569")); + rect(buffer, 92, 365, 700, 34, color("#e2e8f0")); + rect(buffer, 92, 365, Math.round((report.riskScore / 100) * 700), 34, color("#b91c1c")); + + const lines = ["COMMIT AUTHOR LEAK", "NOTEBOOK PATH LEAK", "DOCUMENT METADATA", "UNBLINDED SPONSOR ACCESS"]; + for (let index = 0; index < lines.length; index += 1) { + const visible = frameIndex > 8 + index * 7; + rect(buffer, 92, 442 + index * 44, visible ? 20 : 8, 20, visible ? color("#b91c1c") : color("#cbd5e1")); + text(buffer, lines[index], 130, 434 + index * 44, 3, color("#334155")); + } + text(buffer, "SYNTHETIC CHALLENGE PACKET ONLY", 92, 632, 3, color("#64748b")); + return buffer; +} + +const report = auditAnonymousReviewPacket(samplePacket); +const reportsDir = path.join(__dirname, "reports"); +const frameDir = path.join(reportsDir, "ppm-frames"); +fs.mkdirSync(frameDir, { recursive: true }); + +for (let index = 0; index < FRAMES; index += 1) { + writePpm(path.join(frameDir, `frame-${String(index + 1).padStart(3, "0")}.ppm`), frame(index, report)); +} + +const outputPath = path.join(reportsDir, "demo.mp4"); +execFileSync( + "ffmpeg", + [ + "-y", + "-framerate", + String(FPS), + "-i", + path.join(frameDir, "frame-%03d.ppm"), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + outputPath, + ], + { stdio: "inherit" }, +); + +for (const file of fs.readdirSync(frameDir)) fs.unlinkSync(path.join(frameDir, file)); +fs.rmdirSync(frameDir); + +console.log(`demo video=${outputPath}`); diff --git a/scientific-bounty-system/anonymous-review-packet-guard/reports/anonymous-review-packet-audit.json b/scientific-bounty-system/anonymous-review-packet-guard/reports/anonymous-review-packet-audit.json new file mode 100644 index 00000000..e565e787 --- /dev/null +++ b/scientific-bounty-system/anonymous-review-packet-guard/reports/anonymous-review-packet-audit.json @@ -0,0 +1,202 @@ +{ + "submissionId": "submission-anon-biomarker-042", + "challengeId": "challenge-biomarker-single-cell", + "title": "Anonymous Biomarker Discovery Challenge", + "generatedAt": "2026-05-28T21:10:00.000Z", + "decision": "hold-review-packet", + "riskScore": 100, + "severityCounts": { + "high": 6, + "medium": 6, + "low": 0 + }, + "findingCount": 12, + "evidenceDigest": "2f3128eec95e4284", + "summary": { + "artifacts": 4, + "commits": 2, + "accessGrants": 2, + "redactionReceipts": 1, + "auditLogs": 2 + }, + "findings": [ + { + "code": "FILE_PATH_IDENTITY_LEAK", + "severity": "high", + "penalty": 16, + "message": "Artifact notebook-main path contains identity-bearing text.", + "evidence": { + "artifactId": "notebook-main", + "path": "/Users/alice-nguyen/stanford-lab/bounty/submission/main.ipynb", + "detectedSignals": [ + "home-directory", + "institution" + ] + }, + "recommendation": "Replace local usernames, lab folders, and institution paths with neutral artifact paths." + }, + { + "code": "DOCUMENT_METADATA_LEAK", + "severity": "high", + "penalty": 15, + "message": "Artifact notebook-main metadata can identify the solver team.", + "evidence": { + "artifactId": "notebook-main", + "metadata": { + "author": "Alice Nguyen", + "kernelspec": "python3", + "lastModifiedBy": "alice.nguyen@stanford.edu" + }, + "detectedSignals": [ + "email", + "institution", + "github-handle" + ] + }, + "recommendation": "Strip document author, company, editor, and template metadata from review exports." + }, + { + "code": "NOTEBOOK_OUTPUT_IDENTITY_LEAK", + "severity": "high", + "penalty": 15, + "message": "Notebook notebook-main output contains solver identity signals.", + "evidence": { + "artifactId": "notebook-main", + "outputHits": [ + { + "index": 0, + "signals": [ + "home-directory" + ], + "value": "Loaded cohort from /Users/alice-nguyen/private/cohort.csv" + } + ] + }, + "recommendation": "Clear notebook outputs or redact user, host, and local path fragments before reviewer access." + }, + { + "code": "DOCUMENT_METADATA_LEAK", + "severity": "high", + "penalty": 15, + "message": "Artifact manuscript-pdf metadata can identify the solver team.", + "evidence": { + "artifactId": "manuscript-pdf", + "metadata": { + "author": "A. Nguyen", + "company": "Stanford University", + "template": "lab-letterhead-v4" + }, + "detectedSignals": [ + "institution" + ] + }, + "recommendation": "Strip document author, company, editor, and template metadata from review exports." + }, + { + "code": "EXIF_GPS_OR_DEVICE_LEAK", + "severity": "medium", + "penalty": 10, + "message": "Media artifact figure-workflow contains identifying EXIF metadata.", + "evidence": { + "artifactId": "figure-workflow", + "exif": { + "gps": "37.4275,-122.1697", + "deviceOwner": "Alice Nguyen", + "serialNumber": "IMG-ALICE-9981" + } + }, + "recommendation": "Remove EXIF GPS, serial, device owner, and capture metadata from figures and media." + }, + { + "code": "COMMIT_AUTHOR_LEAK", + "severity": "high", + "penalty": 18, + "message": "Commit a1b2c3d exposes solver identity metadata.", + "evidence": { + "sha": "a1b2c3d", + "authorName": "Alice Nguyen", + "authorEmail": "alice.nguyen@stanford.edu", + "detectedSignals": [ + "email", + "institution", + "github-handle" + ] + }, + "recommendation": "Rewrite or mask commit author metadata before the sponsor review packet is released." + }, + { + "code": "ACKNOWLEDGEMENT_IDENTITY_LEAK", + "severity": "medium", + "penalty": 9, + "message": "Review packet section acknowledgements can identify the solver team.", + "evidence": { + "sectionId": "acknowledgements", + "kind": "acknowledgements", + "redacted": false, + "detectedSignals": [ + "institution", + "github-handle" + ] + }, + "recommendation": "Move acknowledgements and institution references to the post-award disclosure packet." + }, + { + "code": "UNBLINDED_REVIEWER_ROLE", + "severity": "high", + "penalty": 17, + "message": "Access grant grant-sponsor-review exposes an unredacted packet to sponsor.", + "evidence": { + "grantId": "grant-sponsor-review", + "role": "sponsor", + "principal": "pharma-sponsor", + "packetVariant": "unredacted" + }, + "recommendation": "Remove sponsor/reviewer access until all anonymous review roles use the redacted packet." + }, + { + "code": "REDACTION_EVIDENCE_MISSING", + "severity": "medium", + "penalty": 8, + "message": "Artifact notebook-main requires redaction but has no receipt.", + "evidence": { + "artifactId": "notebook-main", + "requiredReason": "local username and lab path in notebook metadata" + }, + "recommendation": "Attach redaction receipts for every transformed artifact before review release." + }, + { + "code": "REDACTION_EVIDENCE_MISSING", + "severity": "medium", + "penalty": 8, + "message": "Artifact manuscript-pdf requires redaction but has no receipt.", + "evidence": { + "artifactId": "manuscript-pdf", + "requiredReason": "office metadata contains company field" + }, + "recommendation": "Attach redaction receipts for every transformed artifact before review release." + }, + { + "code": "REDACTION_EVIDENCE_MISSING", + "severity": "medium", + "penalty": 8, + "message": "Artifact figure-workflow requires redaction but has no receipt.", + "evidence": { + "artifactId": "figure-workflow", + "requiredReason": "EXIF GPS/device owner present" + }, + "recommendation": "Attach redaction receipts for every transformed artifact before review release." + }, + { + "code": "AUDIT_LOG_UNPROTECTED", + "severity": "medium", + "penalty": 7, + "message": "Audit log identity-map contains identity data outside arbitrator-only visibility.", + "evidence": { + "logId": "identity-map", + "visibility": "reviewer-visible", + "containsIdentity": true + }, + "recommendation": "Keep full identity audit logs restricted to arbitrators until award or dispute resolution." + } + ] +} diff --git a/scientific-bounty-system/anonymous-review-packet-guard/reports/anonymous-review-packet-audit.md b/scientific-bounty-system/anonymous-review-packet-guard/reports/anonymous-review-packet-audit.md new file mode 100644 index 00000000..8aff54ee --- /dev/null +++ b/scientific-bounty-system/anonymous-review-packet-guard/reports/anonymous-review-packet-audit.md @@ -0,0 +1,219 @@ +# Anonymous Review Packet Guard + +Challenge: Anonymous Biomarker Discovery Challenge +Submission: submission-anon-biomarker-042 +Decision: hold-review-packet +Risk score: 100/100 +Evidence digest: 2f3128eec95e4284 + +## Scope + +Artifacts: 4 +Commits: 2 +Access grants: 2 +Redaction receipts: 1 +Audit logs: 2 + +## Findings + +### 1. FILE_PATH_IDENTITY_LEAK (high) +Artifact notebook-main path contains identity-bearing text. + +Recommendation: Replace local usernames, lab folders, and institution paths with neutral artifact paths. + +Evidence: +```json +{ + "artifactId": "notebook-main", + "path": "/Users/alice-nguyen/stanford-lab/bounty/submission/main.ipynb", + "detectedSignals": [ + "home-directory", + "institution" + ] +} +``` + +### 2. DOCUMENT_METADATA_LEAK (high) +Artifact notebook-main metadata can identify the solver team. + +Recommendation: Strip document author, company, editor, and template metadata from review exports. + +Evidence: +```json +{ + "artifactId": "notebook-main", + "metadata": { + "author": "Alice Nguyen", + "kernelspec": "python3", + "lastModifiedBy": "alice.nguyen@stanford.edu" + }, + "detectedSignals": [ + "email", + "institution", + "github-handle" + ] +} +``` + +### 3. NOTEBOOK_OUTPUT_IDENTITY_LEAK (high) +Notebook notebook-main output contains solver identity signals. + +Recommendation: Clear notebook outputs or redact user, host, and local path fragments before reviewer access. + +Evidence: +```json +{ + "artifactId": "notebook-main", + "outputHits": [ + { + "index": 0, + "signals": [ + "home-directory" + ], + "value": "Loaded cohort from /Users/alice-nguyen/private/cohort.csv" + } + ] +} +``` + +### 4. DOCUMENT_METADATA_LEAK (high) +Artifact manuscript-pdf metadata can identify the solver team. + +Recommendation: Strip document author, company, editor, and template metadata from review exports. + +Evidence: +```json +{ + "artifactId": "manuscript-pdf", + "metadata": { + "author": "A. Nguyen", + "company": "Stanford University", + "template": "lab-letterhead-v4" + }, + "detectedSignals": [ + "institution" + ] +} +``` + +### 5. EXIF_GPS_OR_DEVICE_LEAK (medium) +Media artifact figure-workflow contains identifying EXIF metadata. + +Recommendation: Remove EXIF GPS, serial, device owner, and capture metadata from figures and media. + +Evidence: +```json +{ + "artifactId": "figure-workflow", + "exif": { + "gps": "37.4275,-122.1697", + "deviceOwner": "Alice Nguyen", + "serialNumber": "IMG-ALICE-9981" + } +} +``` + +### 6. COMMIT_AUTHOR_LEAK (high) +Commit a1b2c3d exposes solver identity metadata. + +Recommendation: Rewrite or mask commit author metadata before the sponsor review packet is released. + +Evidence: +```json +{ + "sha": "a1b2c3d", + "authorName": "Alice Nguyen", + "authorEmail": "alice.nguyen@stanford.edu", + "detectedSignals": [ + "email", + "institution", + "github-handle" + ] +} +``` + +### 7. ACKNOWLEDGEMENT_IDENTITY_LEAK (medium) +Review packet section acknowledgements can identify the solver team. + +Recommendation: Move acknowledgements and institution references to the post-award disclosure packet. + +Evidence: +```json +{ + "sectionId": "acknowledgements", + "kind": "acknowledgements", + "redacted": false, + "detectedSignals": [ + "institution", + "github-handle" + ] +} +``` + +### 8. UNBLINDED_REVIEWER_ROLE (high) +Access grant grant-sponsor-review exposes an unredacted packet to sponsor. + +Recommendation: Remove sponsor/reviewer access until all anonymous review roles use the redacted packet. + +Evidence: +```json +{ + "grantId": "grant-sponsor-review", + "role": "sponsor", + "principal": "pharma-sponsor", + "packetVariant": "unredacted" +} +``` + +### 9. REDACTION_EVIDENCE_MISSING (medium) +Artifact notebook-main requires redaction but has no receipt. + +Recommendation: Attach redaction receipts for every transformed artifact before review release. + +Evidence: +```json +{ + "artifactId": "notebook-main", + "requiredReason": "local username and lab path in notebook metadata" +} +``` + +### 10. REDACTION_EVIDENCE_MISSING (medium) +Artifact manuscript-pdf requires redaction but has no receipt. + +Recommendation: Attach redaction receipts for every transformed artifact before review release. + +Evidence: +```json +{ + "artifactId": "manuscript-pdf", + "requiredReason": "office metadata contains company field" +} +``` + +### 11. REDACTION_EVIDENCE_MISSING (medium) +Artifact figure-workflow requires redaction but has no receipt. + +Recommendation: Attach redaction receipts for every transformed artifact before review release. + +Evidence: +```json +{ + "artifactId": "figure-workflow", + "requiredReason": "EXIF GPS/device owner present" +} +``` + +### 12. AUDIT_LOG_UNPROTECTED (medium) +Audit log identity-map contains identity data outside arbitrator-only visibility. + +Recommendation: Keep full identity audit logs restricted to arbitrators until award or dispute resolution. + +Evidence: +```json +{ + "logId": "identity-map", + "visibility": "reviewer-visible", + "containsIdentity": true +} +``` diff --git a/scientific-bounty-system/anonymous-review-packet-guard/reports/anonymous-review-packet-summary.svg b/scientific-bounty-system/anonymous-review-packet-guard/reports/anonymous-review-packet-summary.svg new file mode 100644 index 00000000..6129ac7e --- /dev/null +++ b/scientific-bounty-system/anonymous-review-packet-guard/reports/anonymous-review-packet-summary.svg @@ -0,0 +1,18 @@ + + + + + Anonymous Review Packet Guard + Anonymous Biomarker Discovery Challenge + hold-review-packet + Risk 100/100 · findings 12 · digest 2f3128eec95e4284 + + + Top findings + FILE_PATH_IDENTITY_LEAK: Artifact notebook-main path contains identity-bearing text. +DOCUMENT_METADATA_LEAK: Artifact notebook-main metadata can identify the solver team. +NOTEBOOK_OUTPUT_IDENTITY_LEAK: Notebook notebook-main output contains solver identity signals. +DOCUMENT_METADATA_LEAK: Artifact manuscript-pdf metadata can identify the solver team. +EXIF_GPS_OR_DEVICE_LEAK: Media artifact figure-workflow contains identifying EXIF metadata. + Synthetic challenge packet only. No private submissions, credentials, or external services. + diff --git a/scientific-bounty-system/anonymous-review-packet-guard/reports/demo.mp4 b/scientific-bounty-system/anonymous-review-packet-guard/reports/demo.mp4 new file mode 100644 index 00000000..dc13de92 Binary files /dev/null and b/scientific-bounty-system/anonymous-review-packet-guard/reports/demo.mp4 differ diff --git a/scientific-bounty-system/anonymous-review-packet-guard/sample-data.js b/scientific-bounty-system/anonymous-review-packet-guard/sample-data.js new file mode 100644 index 00000000..1f5ae0c8 --- /dev/null +++ b/scientific-bounty-system/anonymous-review-packet-guard/sample-data.js @@ -0,0 +1,125 @@ +const samplePacket = { + submissionId: "submission-anon-biomarker-042", + challenge: { + id: "challenge-biomarker-single-cell", + title: "Anonymous Biomarker Discovery Challenge", + anonymityMode: "anonymous", + sponsorVisibility: "redacted-until-award", + }, + reviewPacket: { + id: "review-packet-042", + anonymityMode: "anonymous", + variant: "mixed", + sections: [ + { + id: "methods-summary", + kind: "methods", + text: "Pipeline summary for the blinded biomarker model.", + redacted: true, + }, + { + id: "acknowledgements", + kind: "acknowledgements", + text: "We thank the Stanford Genomics Core and @labsolver for sample preparation.", + redacted: false, + }, + ], + }, + artifacts: [ + { + id: "notebook-main", + type: "notebook", + path: "/Users/alice-nguyen/stanford-lab/bounty/submission/main.ipynb", + requiresRedaction: true, + redactionReason: "local username and lab path in notebook metadata", + metadata: { + author: "Alice Nguyen", + kernelspec: "python3", + lastModifiedBy: "alice.nguyen@stanford.edu", + }, + outputs: [ + "Loaded cohort from /Users/alice-nguyen/private/cohort.csv", + "Final AUROC 0.83", + ], + }, + { + id: "manuscript-pdf", + type: "document", + path: "review/manuscript.pdf", + requiresRedaction: true, + redactionReason: "office metadata contains company field", + metadata: { + author: "A. Nguyen", + company: "Stanford University", + template: "lab-letterhead-v4", + }, + outputs: [], + }, + { + id: "figure-workflow", + type: "image", + path: "figures/workflow.png", + requiresRedaction: true, + redactionReason: "EXIF GPS/device owner present", + metadata: { + exif: { + gps: "37.4275,-122.1697", + deviceOwner: "Alice Nguyen", + serialNumber: "IMG-ALICE-9981", + }, + }, + outputs: [], + }, + { + id: "metrics-json", + type: "dataset", + path: "review/metrics.json", + requiresRedaction: false, + metadata: {}, + outputs: [], + }, + ], + commitHistory: [ + { + sha: "a1b2c3d", + authorName: "Alice Nguyen", + authorEmail: "alice.nguyen@stanford.edu", + message: "finalize biomarker model", + }, + { + sha: "b2c3d4e", + authorName: "anon-solver", + authorEmail: "", + message: "redacted metric export", + }, + ], + accessGrants: [ + { + id: "grant-sponsor-review", + role: "sponsor", + principal: "pharma-sponsor", + packetVariant: "unredacted", + }, + { + id: "grant-arbitrator", + role: "arbitrator", + principal: "platform-arbitrator", + packetVariant: "identity-ledger", + }, + ], + redactionReceipts: [{ artifactId: "metrics-json", transformDigest: "sha256:metrics-noop" }], + auditLogs: [ + { + id: "identity-map", + containsIdentity: true, + visibility: "reviewer-visible", + }, + { + id: "arbitrator-ledger", + containsIdentity: true, + visibility: "arbitrator-only", + }, + ], +}; + +module.exports = { samplePacket }; diff --git a/scientific-bounty-system/anonymous-review-packet-guard/test.js b/scientific-bounty-system/anonymous-review-packet-guard/test.js new file mode 100644 index 00000000..18ba07b1 --- /dev/null +++ b/scientific-bounty-system/anonymous-review-packet-guard/test.js @@ -0,0 +1,81 @@ +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { auditAnonymousReviewPacket, detectIdentity, writeAuditReports } = require("./index"); +const { samplePacket } = require("./sample-data"); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function makeReadyPacket() { + const packet = clone(samplePacket); + packet.reviewPacket.variant = "redacted"; + packet.reviewPacket.sections = packet.reviewPacket.sections.map((section) => ({ + ...section, + text: section.kind === "acknowledgements" ? "Acknowledgements withheld until award disclosure." : section.text, + redacted: true, + })); + packet.artifacts = packet.artifacts.map((artifact) => ({ + ...artifact, + path: `review/${artifact.id}`, + requiresRedaction: false, + metadata: artifact.type === "notebook" ? { kernelspec: "python3" } : {}, + outputs: artifact.type === "notebook" ? ["Loaded blinded cohort from review/cohort.csv", "Final AUROC 0.83"] : [], + })); + packet.commitHistory = packet.commitHistory.map((commit) => ({ + ...commit, + authorName: "anonymous-solver", + authorEmail: "", + message: "redacted submission commit", + })); + packet.accessGrants = packet.accessGrants.map((grant) => + grant.role === "sponsor" ? { ...grant, packetVariant: "redacted" } : grant, + ); + packet.redactionReceipts = packet.artifacts.map((artifact) => ({ artifactId: artifact.id, transformDigest: `sha256:${artifact.id}` })); + packet.auditLogs = packet.auditLogs.map((log) => (log.containsIdentity ? { ...log, visibility: "arbitrator-only" } : log)); + return packet; +} + +function codes(report) { + return new Set(report.findings.map((finding) => finding.code)); +} + +assert.deepStrictEqual(detectIdentity("alice.nguyen@stanford.edu /Users/alice @labsolver").sort(), [ + "email", + "github-handle", + "home-directory", + "institution", +]); + +const report = auditAnonymousReviewPacket(samplePacket); +const findingCodes = codes(report); +assert.strictEqual(report.decision, "hold-review-packet"); +assert.strictEqual(report.riskScore, 100); +assert(findingCodes.has("COMMIT_AUTHOR_LEAK")); +assert(findingCodes.has("FILE_PATH_IDENTITY_LEAK")); +assert(findingCodes.has("NOTEBOOK_OUTPUT_IDENTITY_LEAK")); +assert(findingCodes.has("DOCUMENT_METADATA_LEAK")); +assert(findingCodes.has("EXIF_GPS_OR_DEVICE_LEAK")); +assert(findingCodes.has("ACKNOWLEDGEMENT_IDENTITY_LEAK")); +assert(findingCodes.has("UNBLINDED_REVIEWER_ROLE")); +assert(findingCodes.has("REDACTION_EVIDENCE_MISSING")); +assert(findingCodes.has("AUDIT_LOG_UNPROTECTED")); + +const repeatedReport = auditAnonymousReviewPacket(samplePacket); +assert.strictEqual(report.evidenceDigest, repeatedReport.evidenceDigest, "audit digest should be deterministic"); + +const readyReport = auditAnonymousReviewPacket(makeReadyPacket()); +assert.strictEqual(readyReport.decision, "release-redacted-packet"); +assert.strictEqual(readyReport.findingCount, 0); +assert.strictEqual(readyReport.riskScore, 0); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "anonymous-review-packet-")); +const paths = writeAuditReports(report, tempDir); +for (const outputPath of Object.values(paths)) { + assert(fs.existsSync(outputPath), `expected report at ${outputPath}`); +} +assert(fs.readFileSync(paths.markdownPath, "utf8").includes("Anonymous Review Packet Guard")); + +console.log("anonymous-review-packet-guard tests passed");