forked from wangziqi/gongxue-base
262 lines
7.5 KiB
JavaScript
262 lines
7.5 KiB
JavaScript
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();
|