forked from wangziqi/gongxue-base
test: strengthen launch security and capacity gates
This commit is contained in:
261
scripts/repo-security-scan.js
Normal file
261
scripts/repo-security-scan.js
Normal file
@@ -0,0 +1,261 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user