#!/usr/bin/env node import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const appRoot = path.resolve(__dirname, '..') const defaultTarget = path.resolve(appRoot, '..', 'web', 'app-v2') const targetDir = path.resolve(process.argv[2] || defaultTarget) const TEXT_FILE_EXTENSIONS = new Set(['.html', '.js', '.css', '.json', '.txt', '.svg', '.map']) const MAX_CONTEXT = 110 const checks = [ { id: 'forbidden-global-api-key-copy', severity: 'error', pattern: /全局\s*API\s*Key|Global\s+API\s+Key/gi, description: 'UI/build output must not expose the global API Key copy.', }, { id: 'private-key-block', severity: 'error', pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]{0,4096}?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, description: 'Private key material must never be present in app-v2 output.', }, { id: 'certificate-request-private-material', severity: 'error', pattern: /-----BEGIN CERTIFICATE REQUEST-----[\s\S]{0,4096}?-----END CERTIFICATE REQUEST-----/g, description: 'CSR/PEM material should not be bundled into app-v2 output.', }, { id: 'jwt-like-token', severity: 'error', pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, description: 'JWT-like token detected in app-v2 output.', }, { id: 'openai-or-generic-sk-token', severity: 'error', pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g, description: 'High-confidence secret key token detected.', }, { id: 'github-token', severity: 'error', pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b|\bgithub_pat_[A-Za-z0-9_]{30,}\b/g, description: 'GitHub token detected.', }, { id: 'aws-access-key', severity: 'error', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, description: 'AWS access key id detected.', }, { id: 'bearer-token', severity: 'error', pattern: /\bBearer\s+(?!\[REDACTED\]|REDACTED|)[A-Za-z0-9._~+\/-]{24,}/gi, description: 'Bearer token detected.', }, { id: 'tmp-login-token-value', severity: 'error', pattern: /\btmp_(?:login|token)=(?!\[REDACTED\]|REDACTED|)[^&\s"'<>]{8,}/gi, description: 'Baota temporary login token value detected.', }, { id: 'url-basic-auth-credentials', severity: 'error', pattern: /https?:\/\/[^\s\/:'"<>@]{1,80}:[^\s\/"'<>@]{1,120}@/gi, description: 'URL contains embedded username/password credentials.', }, { id: 'inline-secret-assignment', severity: 'error', pattern: /\b(?:password|passwd|pwd|api[_-]?key|access[_-]?key|secret|private[_-]?key|bt[_-]?key)\b["'\s:=]{1,8}(?!\[REDACTED\]|REDACTED||隐藏|脱敏|null|false|true|undefined)[A-Za-z0-9_./+=:@-]{16,}/gi, description: 'High-confidence inline secret assignment detected.', }, ] function walk(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }) const files = [] for (const entry of entries) { const full = path.join(dir, entry.name) if (entry.isDirectory()) files.push(...walk(full)) else if (entry.isFile() && TEXT_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full) } return files } function contextOf(text, index, length) { const start = Math.max(0, index - MAX_CONTEXT) const end = Math.min(text.length, index + length + MAX_CONTEXT) return text .slice(start, end) .replace(/[\r\n\t]+/g, ' ') .replace(/\s{2,}/g, ' ') } function redactMatch(match) { if (match.length <= 18) return match return match.slice(0, 8) + '...[' + match.length + ' chars]...' + match.slice(-6) } if (!fs.existsSync(targetDir)) { console.error('[app-v2-sensitive-scan] target directory does not exist: ' + targetDir) process.exit(2) } const files = walk(targetDir) const findings = [] for (const file of files) { const rel = path.relative(targetDir, file).replace(/\\/g, '/') const text = fs.readFileSync(file, 'utf8') for (const check of checks) { check.pattern.lastIndex = 0 for (const match of text.matchAll(check.pattern)) { findings.push({ file: rel, check: check.id, severity: check.severity, description: check.description, offset: match.index ?? 0, match: redactMatch(match[0]), context: contextOf(text, match.index ?? 0, match[0].length), }) } } } if (findings.length > 0) { console.error('[app-v2-sensitive-scan] FAILED: ' + findings.length + ' finding(s) in ' + targetDir) for (const finding of findings) { console.error('- [' + finding.severity + '] ' + finding.check + ' ' + finding.file + '@' + finding.offset) console.error(' ' + finding.description) console.error(' match: ' + finding.match) console.error(' context: ' + finding.context) } process.exit(1) } console.log('[app-v2-sensitive-scan] OK: scanned ' + files.length + ' file(s), no high-confidence secrets found in ' + targetDir)