import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; const repoRoot = process.cwd(); const textExtensions = new Set([ '.js', '.jsx', '.ts', '.tsx', '.json', '.md', '.sql', '.toml', '.yml', '.yaml', '.html', '.css', '.scss', '.env', '.example', ]); const ignoredSegments = new Set([ '.git', 'node_modules', 'dist', '.swc', '.temp', '.branches', 'launch-artifacts', 'performance-reports', 'migration-reports', 'pb_export', 'pb_data', 'pb_public', '参考', '新UI参考', 'sao_live_0630', 'whisper_models', ]); const allowlistedFiles = new Set([ '.env.example', 'README.md', 'docs/refactor/ai-development-guardrails.md', 'docs/refactor/api-structure.md', 'docs/refactor/auth-payment-provider-plan.md', 'docs/refactor/frontend-handoff-index.md', 'docs/refactor/multitenant-auth-security-contract.md', 'docs/refactor/object-storage-production-runbook.md', 'docs/refactor/object-storage.md', 'docs/refactor/production-launch-evidence.template.json', 'docs/refactor/supabase-frontend-access-strategy.md', 'docs/refactor/taro-frontend-integration.md', 'docs/refactor/taro-h5-deployment.md', 'docs/refactor/taro-production-integration-checklist.md', 'docs/refactor/web-launch-acceptance-checklist.md', 'scripts/production-readiness-check-test.js', 'scripts/repo-security-scan.js', 'scripts/taro-h5-release-guardrails-test.js', ]); const ruleAllowlistedFiles = { 'postgres-url': new Set([ 'scripts/production-config-failfast-test.js', 'scripts/repo-security-scan-test.js', ]), 'supabase-service-role': new Set([ 'scripts/repo-security-scan-test.js', 'scripts/taro-runtime-config-test.js', ]), 'frontend-legacy-user-header': new Set([ 'scripts/repo-security-scan-test.js', ]), }; const rules = [ { id: 'postgres-url', severity: 'high', pattern: /postgres(?:ql)?:\/\/[^\s"'`<>]+/i, message: 'Database connection strings must not be committed outside approved examples/docs.', validate: (match) => { try { const url = new URL(match[0]); return !['127.0.0.1', 'localhost', 'host.docker.internal'].includes(url.hostname); } catch { return true; } }, }, { id: 'private-key', severity: 'critical', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, message: 'Private keys must not be committed.', }, { id: 'supabase-service-role', severity: 'critical', pattern: /\b(?:SUPABASE_SERVICE_ROLE_KEY|SUPABASE_SECRET_KEY)\s*[:=]\s*["']?([A-Za-z0-9._-]{20,})/i, message: 'Supabase service role or secret keys must never enter the repository.', validate: (match) => { const value = String(match[1] || ''); return /^eyJ/.test(value) || /^sb_secret_/.test(value) || value.length >= 80; }, }, { id: 'provider-secret-token', severity: 'critical', pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/, message: 'Provider secret keys must not be committed.', }, { id: 'cloud-access-key', severity: 'critical', pattern: /\bAKIA[0-9A-Z]{16}\b/, message: 'Cloud access keys must not be committed.', }, { id: 'wechat-pay-private-key', severity: 'critical', pattern: /\b(?:WECHAT_PAY_PRIVATE_KEY|ALIPAY_APP_PRIVATE_KEY)\s*[:=]\s*["']?(?!replace-|<|xxx|your-).{20,}/i, message: 'Payment private keys must stay in backend secrets/KMS.', }, { id: 'frontend-legacy-user-header', severity: 'high', pathPattern: /^apps\/taro\/src\//, pattern: /\bx-user-id\b/i, message: 'Taro source must not use x-user-id; identity comes from Supabase JWT/session.', }, { id: 'frontend-platform-key', severity: 'high', pathPattern: /^apps\/taro\/src\//, pattern: /\bx-platform-admin-key\b/i, message: 'Taro source must not use platform admin key headers.', }, { id: 'frontend-pocketbase', severity: 'high', pathPattern: /^apps\/taro\/src\//, pattern: /\bpocketbase\b/i, message: 'Taro source must not depend on PocketBase.', }, { id: 'runtime-config-committed', severity: 'high', pathPattern: /(^|\/)runtime-config\.json$/, pattern: /./, message: 'Real H5 runtime-config.json files are deployment artifacts and must not be committed.', }, { id: 'production-launch-evidence-committed', severity: 'high', pathPattern: /^docs\/refactor\/production-launch-evidence\.json$/, pattern: /./, message: 'Real production launch evidence may contain internal evidence paths and must not be committed.', }, ]; function normalizeSlashes(value) { return value.replace(/\\/g, '/'); } function relative(filePath) { return normalizeSlashes(path.relative(repoRoot, filePath)); } function isIgnoredPath(filePath) { const rel = relative(filePath); return rel.split('/').some(segment => ignoredSegments.has(segment)); } function shouldRead(filePath) { if (isIgnoredPath(filePath)) return false; const ext = path.extname(filePath).toLowerCase(); if (textExtensions.has(ext)) return true; return ['Dockerfile', '.gitignore', '.dockerignore'].includes(path.basename(filePath)); } function walk(dir) { const result = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); if (isIgnoredPath(fullPath)) continue; if (entry.isDirectory()) result.push(...walk(fullPath)); else if (shouldRead(fullPath)) result.push(fullPath); } return result; } function getTrackedFiles() { const result = spawnSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' }); if (result.status !== 0) return null; return new Set(result.stdout.split(/\r?\n/).filter(Boolean).map(normalizeSlashes)); } function scanFile(filePath, trackedFiles) { const rel = relative(filePath); const text = fs.readFileSync(filePath, 'utf8'); const findings = []; const isTracked = !trackedFiles || trackedFiles.has(rel); for (const rule of rules) { if (rule.pathPattern && !rule.pathPattern.test(rel)) continue; if (!rule.pathPattern && allowlistedFiles.has(rel)) continue; if (ruleAllowlistedFiles[rule.id]?.has(rel)) continue; const match = text.match(rule.pattern); if (!match) continue; if (rule.validate && !rule.validate(match, { file: rel, text })) continue; if ((rule.id === 'runtime-config-committed' || rule.id === 'production-launch-evidence-committed') && !isTracked) continue; findings.push({ id: rule.id, severity: rule.severity, file: rel, message: rule.message, }); } return findings; } function summarize(findings) { return findings.reduce( (summary, item) => { summary[item.severity] = (summary[item.severity] || 0) + 1; return summary; }, { critical: 0, high: 0, medium: 0, low: 0 }, ); } function main() { const json = process.argv.includes('--json'); const trackedFiles = getTrackedFiles(); const files = walk(repoRoot); const findings = files.flatMap(filePath => scanFile(filePath, trackedFiles)); const summary = { ...summarize(findings), scannedFiles: files.length, findings: findings.length, }; const payload = { summary, findings }; if (json) { console.log(JSON.stringify(payload, null, 2)); } else { console.log(`Repository security scan: ${summary.findings} finding(s), ${summary.scannedFiles} file(s) scanned`); for (const item of findings) { console.log(`[${item.severity.toUpperCase()}] ${item.id} ${item.file}: ${item.message}`); } } if (summary.critical > 0 || summary.high > 0) process.exitCode = 1; } main();