forked from wangziqi/gongxue-base
test: strengthen launch security and capacity gates
This commit is contained in:
@@ -153,6 +153,29 @@ assert.ok(
|
||||
'slow API benchmark should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingMixedBenchmark = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.checks = evidence.checks.filter(item => item.id !== 'performance.api-real-data-mixed');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(missingMixedBenchmark.status, 0, 'missing mixed read/write benchmark should fail launch gate');
|
||||
assert.ok(
|
||||
missingMixedBenchmark.payload.checks?.some(item => item.id === 'check.performance.api-real-data-mixed' && item.status === 'blocker'),
|
||||
'missing mixed benchmark should be reported as a blocker',
|
||||
);
|
||||
|
||||
const slowMixedBenchmark = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'performance.api-real-data-mixed');
|
||||
item.summary.p95Ms = 501;
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(slowMixedBenchmark.status, 0, 'slow mixed API benchmark should fail launch gate');
|
||||
assert.ok(
|
||||
slowMixedBenchmark.payload.checks?.some(item => item.id === 'check.performance.api-real-data-mixed.summary' && item.status === 'blocker'),
|
||||
'slow mixed benchmark should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingBusinessSampling = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.checks = evidence.checks.filter(item => item.id !== 'migration.pb-import-sample');
|
||||
@@ -186,6 +209,17 @@ assert.ok(
|
||||
'missing H5 interaction smoke should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingRepoSecurityScan = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.checks = evidence.checks.filter(item => item.id !== 'security.repo-scan');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(missingRepoSecurityScan.status, 0, 'missing repository security scan should fail launch gate');
|
||||
assert.ok(
|
||||
missingRepoSecurityScan.payload.checks?.some(item => item.id === 'check.security.repo-scan' && item.status === 'blocker'),
|
||||
'missing repository security scan should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingAttestation = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot');
|
||||
|
||||
@@ -82,6 +82,20 @@ const gateChecks = [
|
||||
includeWrites: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'performance.api-real-data-mixed',
|
||||
label: 'Real-data API mixed read/write benchmark',
|
||||
commandIncludes: 'perf:api:local',
|
||||
summary: {
|
||||
errors: 0,
|
||||
errorRate: { lte: 0.001 },
|
||||
p95Ms: { lte: 500 },
|
||||
p99Ms: { lte: 1200 },
|
||||
concurrency: { gte: 50 },
|
||||
durationSeconds: { gte: 60 },
|
||||
includeWrites: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'api.integration',
|
||||
label: 'API integration regression',
|
||||
@@ -152,10 +166,12 @@ const gateChecks = [
|
||||
commandIncludes: 'smoke:taro:h5:interaction',
|
||||
summary: {
|
||||
fail: 0,
|
||||
pass: { gte: 10 },
|
||||
pass: { gte: 26 },
|
||||
'mockApi.keyRequests.answers': { gte: 1 },
|
||||
'mockApi.keyRequests.favorites': { gte: 1 },
|
||||
'mockApi.keyRequests.tenantResolve': { gte: 3 },
|
||||
'mockApi.keyRequests.ordersCreated': { gte: 1 },
|
||||
'mockApi.keyRequests.paymentsCreated': { gte: 1 },
|
||||
'mockApi.keyRequests.orderStatus': { gte: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -173,6 +189,12 @@ const gateChecks = [
|
||||
commandIncludes: 'audit:runtime',
|
||||
summary: { critical: 0, high: 0 },
|
||||
},
|
||||
{
|
||||
id: 'security.repo-scan',
|
||||
label: 'Repository static security scan',
|
||||
commandIncludes: 'security:repo',
|
||||
summary: { critical: 0, high: 0 },
|
||||
},
|
||||
{
|
||||
id: 'security.codex-scan',
|
||||
label: 'Codex Security scan',
|
||||
|
||||
59
scripts/repo-security-scan-test.js
Normal file
59
scripts/repo-security-scan-test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const scriptPath = path.join(repoRoot, 'scripts', 'repo-security-scan.js');
|
||||
|
||||
function run(cwd) {
|
||||
return spawnSync(process.execPath, [scriptPath, '--json'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
PATH: process.env.PATH || '',
|
||||
Path: process.env.Path || '',
|
||||
SystemRoot: process.env.SystemRoot || '',
|
||||
ComSpec: process.env.ComSpec || '',
|
||||
TEMP: process.env.TEMP || os.tmpdir(),
|
||||
TMP: process.env.TMP || os.tmpdir(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const clean = run(repoRoot);
|
||||
assert.equal(clean.status, 0, `current repository should pass security scan: ${clean.stdout} ${clean.stderr}`);
|
||||
const cleanPayload = JSON.parse(clean.stdout);
|
||||
assert.equal(cleanPayload.summary.critical, 0);
|
||||
assert.equal(cleanPayload.summary.high, 0);
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-security-scan-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tempDir, 'apps', 'taro', 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'apps', 'taro', 'src', 'unsafe.ts'),
|
||||
"export const headers = { 'x-user-id': '123' };\n",
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'secret.env'),
|
||||
[
|
||||
'DATABASE_URL=postgresql://postgres:real-password@db.example.com:5432/postgres',
|
||||
'SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake-service-role-token-that-should-not-ship',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const unsafe = run(tempDir);
|
||||
assert.notEqual(unsafe.status, 0, 'unsafe fixture should fail security scan');
|
||||
const payload = JSON.parse(unsafe.stdout);
|
||||
assert.ok(payload.findings.some(item => item.id === 'frontend-legacy-user-header'), 'x-user-id should be detected');
|
||||
assert.ok(payload.findings.some(item => item.id === 'postgres-url'), 'database URL should be detected');
|
||||
assert.ok(payload.findings.some(item => item.id === 'supabase-service-role'), 'service role key should be detected');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('[PASS] repository security scan');
|
||||
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();
|
||||
@@ -1015,6 +1015,15 @@ async function runStudentJourney(browser, portal, api) {
|
||||
await waitForText(page, '会员收银台');
|
||||
checks.push({ id: 'student.profile.to_checkout', status: 'pass', detail: await currentPath(page) });
|
||||
|
||||
await clickText(page, '提交订单');
|
||||
await waitForApiRequest(api, '/api/commerce/orders', 'POST');
|
||||
await waitForApiRequest(api, '/api/commerce/payments/create', 'POST');
|
||||
await waitForText(page, '支付参数已生成');
|
||||
await waitForText(page, 'SMOKE202607010001');
|
||||
await clickText(page, '刷新状态');
|
||||
await waitForApiRequest(api, '/api/commerce/orders/status', 'GET');
|
||||
checks.push({ id: 'student.checkout.order_payment', status: 'pass', detail: '收银台下单、支付参数生成和状态刷新 API 已触发' });
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/review/index?type=wrong', '错题本');
|
||||
await waitForText(page, '开始复习');
|
||||
await waitForText(page, '这是 H5 交互烟测题目');
|
||||
@@ -1213,6 +1222,9 @@ async function main() {
|
||||
keyRequests: {
|
||||
answers: api.requests.filter(item => item.path === '/api/learning/answers').length,
|
||||
favorites: api.requests.filter(item => item.path === '/api/learning/favorites/questions').length,
|
||||
ordersCreated: api.requests.filter(item => item.path === '/api/commerce/orders' && item.method === 'POST').length,
|
||||
paymentsCreated: api.requests.filter(item => item.path === '/api/commerce/payments/create' && item.method === 'POST').length,
|
||||
orderStatus: api.requests.filter(item => item.path === '/api/commerce/orders/status' && item.method === 'GET').length,
|
||||
tenantResolve: api.requests.filter(item => item.path === '/api/tenant/resolve').length,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user