test: strengthen launch security and capacity gates

This commit is contained in:
Codex
2026-07-01 04:54:07 +08:00
parent 69b4d3b62d
commit 1eb5a13df7
13 changed files with 452 additions and 31 deletions

View File

@@ -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');

View File

@@ -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',

View 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');

View 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();

View File

@@ -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,
},
},