forked from wangziqi/gongxue-base
296 lines
15 KiB
JavaScript
296 lines
15 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
|
|
const repoRoot = process.cwd();
|
|
const taroRoot = path.join(repoRoot, 'apps', 'taro');
|
|
const sourceRoot = path.join(taroRoot, 'src');
|
|
const deployRoot = path.join(taroRoot, 'deploy');
|
|
const distRoot = path.join(taroRoot, 'dist');
|
|
|
|
const portals = [
|
|
{ portal: 'student', dist: 'h5-student', runtimeExample: 'h5-student.runtime-config.example.json' },
|
|
{ portal: 'tenant-admin', dist: 'h5-tenant-admin', runtimeExample: 'h5-tenant-admin.runtime-config.example.json' },
|
|
{ portal: 'platform-admin', dist: 'h5-platform-admin', runtimeExample: 'h5-platform-admin.runtime-config.example.json' },
|
|
];
|
|
|
|
const allowedRuntimeConfigKeys = new Set([
|
|
'portal',
|
|
'apiBaseUrl',
|
|
'supabaseUrl',
|
|
'supabasePublishableKey',
|
|
'tenantCode',
|
|
'TARO_APP_PORTAL',
|
|
'TARO_APP_API_BASE_URL',
|
|
'TARO_APP_SUPABASE_URL',
|
|
'TARO_APP_SUPABASE_PUBLISHABLE_KEY',
|
|
'TARO_APP_TENANT_CODE',
|
|
]);
|
|
|
|
const forbiddenRuntimeConfigKeys = [
|
|
'SUPABASE_SERVICE_ROLE_KEY',
|
|
'SUPABASE_SECRET_KEY',
|
|
'DATABASE_URL',
|
|
'ALIYUN_OSS_ACCESS_KEY_SECRET',
|
|
'TENCENT_COS_SECRET_KEY',
|
|
'WECHAT_PAY_PRIVATE_KEY',
|
|
'ALIPAY_APP_PRIVATE_KEY',
|
|
'AUTH_SESSION_SECRET',
|
|
'PLATFORM_ADMIN_API_KEY',
|
|
];
|
|
|
|
const sourceForbiddenPatterns = [
|
|
{ id: 'legacy-user-header', pattern: /\bx-user-id\b/i, message: 'Taro source must not use x-user-id. Identity comes from Supabase JWT/session.' },
|
|
{ id: 'platform-admin-key', pattern: /\bx-platform-admin-key\b/i, message: 'Taro source must not use platform admin key headers.' },
|
|
{ id: 'legacy-pocketbase', pattern: /\bpocketbase\b/i, message: 'Taro source must not depend on PocketBase.' },
|
|
{ id: 'postgres-url', pattern: /postgres(?:ql)?:\/\//i, message: 'Taro source must not contain database connection strings.' },
|
|
{ id: 'private-key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, message: 'Taro source must not contain private keys.' },
|
|
{ id: 'cloud-access-key', pattern: /\bAKIA[0-9A-Z]{16}\b/, message: 'Taro source must not contain cloud access keys.' },
|
|
{ id: 'provider-secret', pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/, message: 'Taro source must not contain provider secret keys.' },
|
|
];
|
|
|
|
const artifactForbiddenPatterns = [
|
|
{ id: 'legacy-user-header', pattern: /\bx-user-id\b/i, message: 'H5 artifact must not contain x-user-id.' },
|
|
{ id: 'platform-admin-key', pattern: /\bx-platform-admin-key\b/i, message: 'H5 artifact must not contain platform admin key headers.' },
|
|
{ id: 'local-platform-admin-key', pattern: /\blocal-platform-admin-key\b/i, message: 'H5 artifact must not contain local platform admin key fallback.' },
|
|
{ id: 'legacy-pocketbase', pattern: /\bpocketbase\b/i, message: 'H5 artifact must not contain PocketBase references.' },
|
|
{ id: 'postgres-url', pattern: /postgres(?:ql)?:\/\//i, message: 'H5 artifact must not contain database connection strings.' },
|
|
{ id: 'private-key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, message: 'H5 artifact must not contain private keys.' },
|
|
{ id: 'cloud-access-key', pattern: /\bAKIA[0-9A-Z]{16}\b/, message: 'H5 artifact must not contain cloud access keys.' },
|
|
{ id: 'provider-secret', pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/, message: 'H5 artifact must not contain provider secret keys.' },
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
return {
|
|
checkDist: argv.includes('--check-dist') || argv.includes('--require-dist'),
|
|
requireDist: argv.includes('--require-dist'),
|
|
requireRuntimeConfig: argv.includes('--require-runtime-config'),
|
|
json: argv.includes('--json'),
|
|
};
|
|
}
|
|
|
|
function normalizeSlashes(value) {
|
|
return value.replace(/\\/g, '/');
|
|
}
|
|
|
|
function relative(filePath) {
|
|
return normalizeSlashes(path.relative(repoRoot, filePath));
|
|
}
|
|
|
|
function readText(filePath) {
|
|
return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(readText(filePath));
|
|
}
|
|
|
|
function walkFiles(dir, extensions = null) {
|
|
if (!fs.existsSync(dir)) return [];
|
|
const result = [];
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
result.push(...walkFiles(entryPath, extensions));
|
|
continue;
|
|
}
|
|
if (!extensions || extensions.some(extension => entry.name.endsWith(extension))) result.push(entryPath);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function createCollector() {
|
|
const checks = [];
|
|
function push(status, id, message, details = {}) {
|
|
checks.push({ status, id, message, details });
|
|
}
|
|
return {
|
|
checks,
|
|
pass: (id, message, details) => push('pass', id, message, details),
|
|
warn: (id, message, details) => push('warn', id, message, details),
|
|
fail: (id, message, details) => push('fail', id, message, details),
|
|
};
|
|
}
|
|
|
|
function summarize(checks) {
|
|
return checks.reduce(
|
|
(summary, item) => {
|
|
summary[item.status] += 1;
|
|
return summary;
|
|
},
|
|
{ fail: 0, warn: 0, pass: 0 },
|
|
);
|
|
}
|
|
|
|
function looksSecretish(value) {
|
|
const text = String(value || '');
|
|
return /postgres(?:ql)?:\/\//i.test(text)
|
|
|| /-----BEGIN [A-Z ]*PRIVATE KEY-----/i.test(text)
|
|
|| /\bAKIA[0-9A-Z]{16}\b/.test(text)
|
|
|| /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/.test(text);
|
|
}
|
|
|
|
function validateRuntimeConfig(filePath, expectedPortal, options, collector) {
|
|
if (!fs.existsSync(filePath)) {
|
|
if (options.required) collector.fail(`runtime.${expectedPortal}.exists`, 'runtime-config.json is required for this check', { file: relative(filePath) });
|
|
else collector.warn(`runtime.${expectedPortal}.exists`, 'runtime-config.json is not present; deploy must provide it beside index.html', { file: relative(filePath) });
|
|
return;
|
|
}
|
|
|
|
let config;
|
|
try {
|
|
config = readJson(filePath);
|
|
} catch (error) {
|
|
collector.fail(`runtime.${expectedPortal}.json`, 'runtime config must be valid JSON', { file: relative(filePath), error: error.message });
|
|
return;
|
|
}
|
|
|
|
const keys = Object.keys(config);
|
|
const unknownKeys = keys.filter(key => !allowedRuntimeConfigKeys.has(key));
|
|
const forbiddenKeys = keys.filter(key => forbiddenRuntimeConfigKeys.includes(key));
|
|
if (unknownKeys.length) collector.fail(`runtime.${expectedPortal}.keys`, 'runtime config has unknown keys', { file: relative(filePath), unknownKeys });
|
|
else collector.pass(`runtime.${expectedPortal}.keys`, 'runtime config uses only approved public keys', { file: relative(filePath) });
|
|
if (forbiddenKeys.length) collector.fail(`runtime.${expectedPortal}.secrets`, 'runtime config includes forbidden server secret keys', { file: relative(filePath), forbiddenKeys });
|
|
else collector.pass(`runtime.${expectedPortal}.secrets`, 'runtime config does not include forbidden server secret keys', { file: relative(filePath) });
|
|
|
|
const portal = config.portal || config.TARO_APP_PORTAL;
|
|
if (portal !== expectedPortal) collector.fail(`runtime.${expectedPortal}.portal`, 'runtime config portal does not match deployment target', { file: relative(filePath), portal, expectedPortal });
|
|
else collector.pass(`runtime.${expectedPortal}.portal`, 'runtime config portal matches deployment target');
|
|
|
|
const apiBaseUrl = String(config.apiBaseUrl || config.TARO_APP_API_BASE_URL || '');
|
|
const supabaseUrl = String(config.supabaseUrl || config.TARO_APP_SUPABASE_URL || '');
|
|
const publishableKey = String(config.supabasePublishableKey || config.TARO_APP_SUPABASE_PUBLISHABLE_KEY || '');
|
|
if (!apiBaseUrl.startsWith('https://')) collector.fail(`runtime.${expectedPortal}.api_https`, 'apiBaseUrl must be HTTPS in release config', { file: relative(filePath), apiBaseUrl });
|
|
else collector.pass(`runtime.${expectedPortal}.api_https`, 'apiBaseUrl is HTTPS');
|
|
if (!supabaseUrl.startsWith('https://')) collector.fail(`runtime.${expectedPortal}.supabase_https`, 'supabaseUrl must be HTTPS in release config', { file: relative(filePath), supabaseUrl });
|
|
else collector.pass(`runtime.${expectedPortal}.supabase_https`, 'supabaseUrl is HTTPS');
|
|
if (!publishableKey) collector.fail(`runtime.${expectedPortal}.publishable_key`, 'supabasePublishableKey is required', { file: relative(filePath) });
|
|
else if (options.allowPlaceholderKey && publishableKey === 'replace-with-supabase-publishable-key') collector.pass(`runtime.${expectedPortal}.publishable_key`, 'runtime example uses explicit publishable-key placeholder');
|
|
else if (publishableKey === 'replace-with-supabase-publishable-key') collector.fail(`runtime.${expectedPortal}.publishable_key`, 'production runtime config still contains placeholder publishable key', { file: relative(filePath) });
|
|
else collector.pass(`runtime.${expectedPortal}.publishable_key`, 'supabase publishable key is present');
|
|
|
|
const secretValues = Object.entries(config).filter(([, value]) => looksSecretish(value));
|
|
if (secretValues.length) {
|
|
collector.fail(`runtime.${expectedPortal}.secret_values`, 'runtime config contains secret-looking values', {
|
|
file: relative(filePath),
|
|
keys: secretValues.map(([key]) => key),
|
|
});
|
|
} else {
|
|
collector.pass(`runtime.${expectedPortal}.secret_values`, 'runtime config values do not look like server secrets');
|
|
}
|
|
}
|
|
|
|
function validateRuntimeExamples(collector) {
|
|
for (const item of portals) {
|
|
validateRuntimeConfig(path.join(deployRoot, item.runtimeExample), item.portal, { required: true, allowPlaceholderKey: true }, collector);
|
|
}
|
|
}
|
|
|
|
function validateSourceRequests(collector) {
|
|
const files = walkFiles(sourceRoot, ['.ts', '.tsx']);
|
|
const directRequestViolations = [];
|
|
const directFetchViolations = [];
|
|
const headerOverrideViolations = [];
|
|
const forbiddenViolations = [];
|
|
|
|
for (const filePath of files) {
|
|
const rel = relative(filePath);
|
|
const text = readText(filePath);
|
|
if (/Taro\.request\s*\(/.test(text) && rel !== 'apps/taro/src/services/api.ts') directRequestViolations.push(rel);
|
|
if (/window\.fetch\s*\(/.test(text) && rel !== 'apps/taro/src/env.ts') directFetchViolations.push(rel);
|
|
if (/['"`]authorization['"`]\s*:|['"`]Authorization['"`]\s*:|['"`]x-tenant-id['"`]\s*:|['"`]X-Tenant-Id['"`]\s*:/i.test(text) && rel !== 'apps/taro/src/services/api-auth.ts') {
|
|
headerOverrideViolations.push(rel);
|
|
}
|
|
for (const rule of sourceForbiddenPatterns) {
|
|
if (rule.pattern.test(text)) forbiddenViolations.push({ file: rel, rule: rule.id, message: rule.message });
|
|
}
|
|
}
|
|
|
|
if (directRequestViolations.length) collector.fail('source.direct_taro_request', 'Only apps/taro/src/services/api.ts may call Taro.request directly', { files: directRequestViolations });
|
|
else collector.pass('source.direct_taro_request', 'Taro.request is centralized in the API client');
|
|
if (directFetchViolations.length) collector.fail('source.direct_fetch', 'Only env.ts may use window.fetch for runtime-config.json', { files: directFetchViolations });
|
|
else collector.pass('source.direct_fetch', 'window.fetch usage is limited to runtime config loading');
|
|
if (headerOverrideViolations.length) collector.fail('source.reserved_headers', 'Page/service code must not hand-build Authorization or x-tenant-id headers outside api-auth.ts', { files: headerOverrideViolations });
|
|
else collector.pass('source.reserved_headers', 'Reserved API headers are owned by api-auth.ts');
|
|
if (forbiddenViolations.length) collector.fail('source.forbidden_patterns', 'Taro source contains forbidden release patterns', { violations: forbiddenViolations });
|
|
else collector.pass('source.forbidden_patterns', 'Taro source has no legacy auth, PocketBase or secret-looking patterns');
|
|
}
|
|
|
|
function validateDistArtifact(portal, distName, options, collector) {
|
|
const dir = path.join(distRoot, distName);
|
|
if (!fs.existsSync(dir)) {
|
|
if (options.requireDist) collector.fail(`dist.${portal}.exists`, 'H5 dist directory is required for release guardrails', { dir: relative(dir) });
|
|
else collector.warn(`dist.${portal}.exists`, 'H5 dist directory not found; build before final deployment validation', { dir: relative(dir) });
|
|
return;
|
|
}
|
|
|
|
collector.pass(`dist.${portal}.exists`, 'H5 dist directory exists', { dir: relative(dir) });
|
|
const indexPath = path.join(dir, 'index.html');
|
|
if (!fs.existsSync(indexPath)) collector.fail(`dist.${portal}.index`, 'H5 dist index.html is missing', { file: relative(indexPath) });
|
|
else {
|
|
const indexHtml = readText(indexPath);
|
|
if (!/<script\b/i.test(indexHtml)) collector.fail(`dist.${portal}.index`, 'H5 index.html has no script tags', { file: relative(indexPath) });
|
|
else collector.pass(`dist.${portal}.index`, 'H5 index.html is present and loads scripts', { file: relative(indexPath) });
|
|
}
|
|
|
|
const textFiles = walkFiles(dir, ['.html', '.js', '.css', '.json', '.txt']).filter(filePath => !filePath.endsWith('.LICENSE.txt'));
|
|
const artifactViolations = [];
|
|
for (const filePath of textFiles) {
|
|
const stat = fs.statSync(filePath);
|
|
if (stat.size > 5 * 1024 * 1024) continue;
|
|
const text = readText(filePath);
|
|
for (const rule of artifactForbiddenPatterns) {
|
|
if (rule.pattern.test(text)) artifactViolations.push({ file: relative(filePath), rule: rule.id, message: rule.message });
|
|
}
|
|
}
|
|
if (artifactViolations.length) collector.fail(`dist.${portal}.forbidden_patterns`, 'H5 artifact contains forbidden release patterns', { violations: artifactViolations.slice(0, 20) });
|
|
else collector.pass(`dist.${portal}.forbidden_patterns`, 'H5 artifact has no legacy auth, PocketBase or secret-looking patterns');
|
|
|
|
validateRuntimeConfig(path.join(dir, 'runtime-config.json'), portal, {
|
|
required: options.requireRuntimeConfig,
|
|
allowPlaceholderKey: false,
|
|
}, collector);
|
|
}
|
|
|
|
function validateDistArtifacts(options, collector) {
|
|
if (!options.checkDist) {
|
|
collector.pass('dist.check_skipped', 'H5 dist artifact scan is skipped; pass --check-dist or --require-dist for release artifact validation');
|
|
return;
|
|
}
|
|
for (const item of portals) validateDistArtifact(item.portal, item.dist, options, collector);
|
|
}
|
|
|
|
function validateBuildScripts(collector) {
|
|
const packageJson = readJson(path.join(taroRoot, 'package.json'));
|
|
const scripts = packageJson.scripts || {};
|
|
const requiredScripts = ['build:h5:student', 'build:h5:tenant', 'build:h5:platform'];
|
|
const missing = requiredScripts.filter(name => !scripts[name]);
|
|
if (missing.length) collector.fail('package.h5_build_scripts', 'Taro package is missing portal-specific H5 build scripts', { missing });
|
|
else collector.pass('package.h5_build_scripts', 'Taro has portal-specific H5 build scripts');
|
|
}
|
|
|
|
function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const collector = createCollector();
|
|
validateBuildScripts(collector);
|
|
validateRuntimeExamples(collector);
|
|
validateSourceRequests(collector);
|
|
validateDistArtifacts(options, collector);
|
|
|
|
const summary = summarize(collector.checks);
|
|
const payload = { summary, checks: collector.checks };
|
|
if (options.json) {
|
|
console.log(JSON.stringify(payload, null, 2));
|
|
} else {
|
|
console.log(`Taro H5 release guardrails: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
|
|
for (const item of collector.checks) {
|
|
const marker = item.status === 'fail' ? 'FAIL' : item.status === 'warn' ? 'WARN' : 'PASS';
|
|
console.log(`[${marker}] ${item.id}: ${item.message}`);
|
|
}
|
|
}
|
|
if (summary.fail > 0) process.exitCode = 1;
|
|
}
|
|
|
|
main();
|