#!/usr/bin/env node /* eslint-disable no-console */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; export const REQUIRED_DOCUMENTS = [ { path: "SECURITY.md", label: "security disclosure policy", sections: [ { id: "security-title", description: "Security Policy title", pattern: /^#\s+Security Policy\s*$/im }, { id: "supported-scope", description: "supported vulnerability scope", pattern: /^##\s+Supported scope\s*$/im }, { id: "reporting", description: "vulnerability reporting instructions", pattern: /^##\s+Reporting a vulnerability\s*$/im }, { id: "safe-evidence", description: "safe evidence guidance", pattern: /^##\s+Safe evidence\s*$/im }, { id: "do-not-include", description: "unsafe disclosure and redaction rules", pattern: /^##\s+Do not include\s*$/im }, { id: "triage-expectations", description: "maintainer triage expectations", pattern: /^##\s+Triage expectations\s*$/im }, ], terms: [ { id: "runbook-link", description: "link to docs/security/runbook.md", pattern: /docs\/security\/runbook\.md/ }, { id: "security-overview-link", description: "link to docs/security.md", pattern: /docs\/security\.md/ }, { id: "private-reporting", description: "private reporting channel guidance", pattern: /private (?:maintainer contact path|issue process|channel)/i }, { id: "redaction", description: "redaction guidance for sensitive evidence", pattern: /redact(?:ed|ion)?/i }, { id: "secrets", description: "secret and credential exclusion", pattern: /secrets?|credentials?|tokens?|API keys?/i }, { id: "tenant-customer-data", description: "tenant and customer data exclusion", pattern: /tenant identifiers?|customer data/i }, ], }, { path: "docs/data-retention.md", label: "data retention defaults", sections: [ { id: "retention-title", description: "Data Retention Defaults title", pattern: /^#\s+Data Retention Defaults\s*$/im }, { id: "scope", description: "retention defaults scope", pattern: /^##\s+Scope\s*$/im }, { id: "default-classes", description: "default retention classes", pattern: /^##\s+Default retention classes\s*$/im }, { id: "surface-defaults", description: "surface-by-surface defaults", pattern: /^##\s+Surface-by-surface defaults\s*$/im }, { id: "erasure-handling", description: "erasure and deletion handling", pattern: /^##\s+Erasure and deletion handling\s*$/im }, { id: "operational-evidence", description: "operational evidence guidance", pattern: /^##\s+Operational evidence\s*$/im }, { id: "manual-boundaries", description: "manual approval boundaries", pattern: /^##\s+Manual approval boundaries\s*$/im }, { id: "implementation-followups", description: "implementation follow-ups", pattern: /^##\s+Implementation follow-ups\s*$/im }, ], terms: [ { id: "export-artifacts", description: "export artifact expiration and deletion coverage", pattern: /export(?: files as sensitive generated artifacts| artifact expiration|s?[^\n]*(?:artifact|expiration|deletion))/i }, { id: "import-reports", description: "import report and error artifact deletion coverage", pattern: /import[^\n]*(?:reports?|error artifacts?|error-report deletion|artifact TTL)/i }, { id: "object-store-removal", description: "object-store blob deletion coverage", pattern: /object-store[^\n]*(?:blobs?|deletion|artifacts?|cleanup|remov(?:al|ed))/i }, { id: "provider-deletion", description: "external provider deletion or minimization coverage", pattern: /provider[^\n]*(?:deletion|messages?|cleanup|sync|reconciliation|minimization)/i }, { id: "queue-removal", description: "queue payload purge or dead-letter cleanup coverage", pattern: /(?:queue[^\n]*(?:TTLs|dead-letter cleanup|purge|payloads?|remov(?:al|ed))|purge stale payloads)/i }, { id: "billing-exceptions", description: "billing/accounting exception coverage", pattern: /(?:billing|accounting|chargeback|tax|legal-hold)[^\n]*(?:exception|requirement|limits?|minimization)/i }, { id: "audit-minimization", description: "audit evidence minimization coverage", pattern: /(?:audit[^\n]*minimi[sz]e|minimi[sz]e[^\n]*audit|minimal non-secret audit references)/i }, { id: "plugin-candidate-classification", description: "plugin-candidate classification coverage", pattern: /plugin-candidate[^\n]*(?:review|classification|surfaces?|ownership)/i }, { id: "safe-evidence", description: "safe evidence and redaction guidance", pattern: /Safe evidence includes/i }, { id: "unsafe-evidence", description: "unsafe evidence exclusion guidance", pattern: /Unsafe evidence includes/i }, ], }, { path: "docs/compliance/gdpr.md", label: "GDPR data inventory", sections: [ { id: "gdpr-title", description: "GDPR Data Inventory title", pattern: /^#\s+GDPR Data Inventory\s*$/im }, { id: "scope", description: "inventory scope", pattern: /^##\s+Scope\s*$/im }, { id: "data-categories", description: "personal data categories", pattern: /^##\s+Data categories\s*$/im }, { id: "processing-surfaces", description: "processing surfaces", pattern: /^##\s+Processing surfaces\s*$/im }, { id: "storage-evidence", description: "storage and schema evidence", pattern: /^##\s+Storage evidence\s*$/im }, { id: "export-surfaces", description: "export surfaces and hooks", pattern: /^##\s+Export surfaces\s*$/im }, { id: "deletion-erasure", description: "deletion and erasure surfaces", pattern: /^##\s+Deletion and erasure surfaces\s*$/im }, { id: "retention-status", description: "retention status and gaps", pattern: /^##\s+Retention status\s*$/im }, { id: "redaction-rules", description: "operational redaction rules", pattern: /^##\s+Operational redaction rules\s*$/im }, { id: "downstream-followups", description: "manual downstream follow-up boundaries", pattern: /^##\s+Downstream follow-ups\s*$/im }, ], terms: [ { id: "schema-references", description: "Prisma schema storage references", pattern: /prisma\/schema\/[\w-]+\.prisma/ }, { id: "export-hook", description: "data export hook coverage", pattern: /export (?:surfaces?|artifacts?|scope|job|inputs?)/i }, { id: "deletion-hook", description: "deletion or erasure hook coverage", pattern: /(?:deletion|erasure) (?:hooks?|surfaces?|workflows?|evidence)/i }, { id: "retention-gap", description: "explicit retention implementation gap", pattern: /retention (?:status|periods?|duration|gap|policy)/i }, { id: "safe-evidence", description: "safe evidence boundaries", pattern: /safe evidence/i }, { id: "unsafe-evidence", description: "unsafe evidence boundaries", pattern: /unsafe evidence/i }, { id: "manual-runtime-boundary", description: "manual boundary for live/runtime checks", pattern: /manual|human explicitly approves/i }, ], }, ]; export const FORBIDDEN_PLACEHOLDER_PATTERN = /\b(?:TODO|TBD|FIXME)\b/g; function normalizePath(relativePath) { return relativePath.split(path.sep).join("/"); } function lineAndColumnForIndex(content, index) { const prefix = content.slice(0, index); const lines = prefix.split(/\r?\n/); return { line: lines.length, column: lines.at(-1).length + 1 }; } function readRequiredDocument(rootDir, relativePath) { const normalized = normalizePath(relativePath); const absolutePath = path.join(rootDir, relativePath); if (!fs.existsSync(absolutePath)) { return { relativePath: normalized, content: null, failures: [`${normalized}: missing required documentation file`] }; } const content = fs.readFileSync(absolutePath, "utf8"); if (content.trim().length === 0) { return { relativePath: normalized, content, failures: [`${normalized}: documentation file is empty`] }; } return { relativePath: normalized, content, failures: [] }; } function checkRequiredPatterns(relativePath, content, contractKey, patterns) { return patterns .filter(({ pattern }) => !pattern.test(content)) .map(({ id, description }) => `${relativePath}: missing ${contractKey} "${id}" (${description})`); } function checkForbiddenPlaceholders(relativePath, content) { const failures = []; const pattern = new RegExp(FORBIDDEN_PLACEHOLDER_PATTERN.source, FORBIDDEN_PLACEHOLDER_PATTERN.flags); let match; while ((match = pattern.exec(content)) !== null) { const { line, column } = lineAndColumnForIndex(content, match.index); failures.push(`${relativePath}:${line}:${column}: forbidden placeholder marker "${match[0]}"`); } return failures; } export function verifyComplianceRetentionSecurityGdprDocs(rootDir = process.cwd(), documents = REQUIRED_DOCUMENTS) { const failures = []; const checkedFiles = []; for (const document of documents) { const { relativePath, content, failures: readFailures } = readRequiredDocument(rootDir, document.path); checkedFiles.push(relativePath); failures.push(...readFailures); if (content === null || content.trim().length === 0) continue; failures.push(...checkForbiddenPlaceholders(relativePath, content)); failures.push(...checkRequiredPatterns(relativePath, content, "section", document.sections)); failures.push(...checkRequiredPatterns(relativePath, content, "contract field", document.terms)); } return { passes: failures.length === 0 ? [`${checkedFiles.length} documentation files satisfy the Compliance Retention security/GDPR contract`] : [], failures, observations: [`checked files: ${checkedFiles.join(", ")}`], }; } export function formatComplianceRetentionSecurityGdprDocsResult(result) { const status = result.failures.length > 0 ? "FAIL" : "PASS"; return [ `${status} Compliance Retention security/GDPR documentation verification`, ...result.passes.map((pass) => `PASS ${pass}`), ...result.observations.map((observation) => `OBSERVE ${observation}`), ...result.failures.map((failure) => `FAIL ${failure}`), `Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`, "", ].join("\n"); } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isCli) { const result = verifyComplianceRetentionSecurityGdprDocs(process.cwd()); const output = formatComplianceRetentionSecurityGdprDocsResult(result); if (result.failures.length > 0) { console.error(output); process.exitCode = 1; } else { console.log(output); } }