Files
novalon-website/scripts/utils/check-security-headers.ts
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
2026-09-28 10:48:08 +08:00

319 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env tsx
/**
* 安全响应头检查脚本
*
* 检查部署网站的安全头配置是否合规,包括:
* - 核心安全头(Content-Security-Policy, X-Content-Type-Options 等)
* - CORS 配置
* - Cookie 安全标记
*/
interface SecurityCheck {
header: string;
expected: string;
actual: string | null;
status: 'pass' | 'fail' | 'warn';
description: string;
}
interface CookieCheck {
name: string;
hasHttpOnly: boolean;
hasSecure: boolean;
hasSameSite: boolean;
status: 'pass' | 'fail' | 'warn';
}
function parseArgs(): string {
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
if (urlIndex !== -1 && args[urlIndex + 1]) {
return args[urlIndex + 1]!;
}
return 'https://novalon.cn';
}
function parseCookies(setCookieHeader: string | null): Array<{ name: string; flags: string[] }> {
if (!setCookieHeader) return [];
const cookies: Array<{ name: string; flags: string[] }> = [];
// Handle multiple Set-Cookie headers (joined by comma-newline)
const cookieStrings = setCookieHeader.split(/\n|,(?=\s*\w+=)/);
for (const cookieStr of cookieStrings) {
const trimmed = cookieStr.trim();
if (!trimmed) continue;
const parts = trimmed.split(';').map((p) => p.trim());
const nameValue = parts[0]!;
const name = nameValue.split('=')[0] || 'unknown';
const flags = parts.slice(1).map((f) => f.toLowerCase());
cookies.push({ name, flags });
}
return cookies;
}
function checkCookies(cookies: Array<{ name: string; flags: string[] }>): CookieCheck[] {
// N-22:此前 status 恒为字面量 'pass',上面三个 flag 全部白算、cookieFailed 永远为 0,
// 而汇总的 hasFailures 只看 headerChecks ⇒ 任何 cookie 属性错误都无法让本门禁变红。
return cookies.map((cookie) => {
const hasHttpOnly = cookie.flags.some((f) => f === 'httponly');
const hasSecure = cookie.flags.some((f) => f === 'secure');
const hasSameSite = cookie.flags.some((f) => f.startsWith('samesite'));
const missing = [!hasHttpOnly, !hasSameSite, !hasSecure];
const isSessionLike = /token|session|auth|jwt/i.test(cookie.name);
let status: CookieCheck['status'];
if (isSessionLike && (!hasHttpOnly || !hasSameSite)) {
status = 'fail';
} else if (missing.some(Boolean)) {
status = 'warn';
} else {
status = 'pass';
}
return { name: cookie.name, hasHttpOnly, hasSecure, hasSameSite, status };
});
}
async function checkSecurityHeaders(url: string): Promise<{
headerChecks: SecurityCheck[];
cookieChecks: CookieCheck[];
}> {
console.log(`\n🔒 安全响应头检查 — ${url}\n`);
const response = await fetch(url, {
redirect: 'follow',
signal: AbortSignal.timeout(15000),
});
const headers = response.headers;
const headerChecks: SecurityCheck[] = [
{
header: 'Content-Security-Policy',
expected: '存在(需包含合理策略)',
actual: headers.get('content-security-policy'),
// N-22:缺 CSP 原本只 warn ⇒ 删掉整条 CSP 也无法让本门禁变红,而同族的
// X-Content-Type-Options 同样缺失却判 fail。站点确实发 CSP(E2E 已在产物上断言),故判红不会误伤。
status: headers.get('content-security-policy') ? ('pass' as const) : ('fail' as const),
description: '防止 XSS 和数据注入攻击的核心策略',
},
{
header: 'X-Content-Type-Options',
expected: 'nosniff',
actual: headers.get('x-content-type-options'),
status: headers.get('x-content-type-options') === 'nosniff' ? 'pass' : 'fail',
description: '防止 MIME 类型嗅探攻击',
},
{
header: 'X-Frame-Options',
expected: 'DENY 或 SAMEORIGIN',
actual: headers.get('x-frame-options'),
status: (() => {
const val = headers.get('x-frame-options');
if (val === 'DENY' || val === 'SAMEORIGIN') return 'pass' as const;
return val ? 'warn' as const : 'fail' as const;
})(),
description: '防止点击劫持(Clickjacking)攻击',
},
{
header: 'Strict-Transport-Security',
expected: '存在(需包含 max-age)',
actual: headers.get('strict-transport-security'),
// N-22:HSTS 由边缘(Nginx/CDN)注入,本地 standalone 产物上没有。无条件判 fail 会让本门禁
// 永远无法在分支产物上通过(只能打线上),故仅在 https 目标上缺失才判 fail。
status: headers.get('strict-transport-security')
? ('pass' as const)
: url.startsWith('https:')
? ('fail' as const)
: ('warn' as const),
description: '强制 HTTPS 连接,防止 SSL Strip 攻击',
},
{
header: 'Referrer-Policy',
expected: '存在(如 strict-origin-when-cross-origin)',
actual: headers.get('referrer-policy'),
status: headers.get('referrer-policy') ? 'pass' : 'fail',
description: '控制 Referer 头信息的发送策略',
},
{
header: 'Permissions-Policy',
expected: '存在(需配置合理权限)',
actual: headers.get('permissions-policy'),
status: headers.get('permissions-policy') ? 'pass' : 'warn',
description: '限制浏览器 API 权限(摄像头、麦克风等)',
},
{
header: 'X-XSS-Protection',
expected: '存在(0 或 1; mode=block)',
actual: headers.get('x-xss-protection'),
status: (() => {
const val = headers.get('x-xss-protection');
if (val === '0' || val === '1; mode=block') return 'pass' as const;
return val ? ('warn' as const) : ('pass' as const);
})(),
description: '已废弃的 XSS 过滤器(现代浏览器不再需要)',
},
];
// CORS check
const corsOrigin = headers.get('access-control-allow-origin');
if (corsOrigin) {
headerChecks.push({
header: 'Access-Control-Allow-Origin',
expected: '非通配符(或仅对特定来源开放)',
actual: corsOrigin,
status: corsOrigin === '*' ? 'warn' : 'pass',
description: 'CORS 跨域配置',
});
} else {
headerChecks.push({
header: 'Access-Control-Allow-Origin',
expected: '无(不暴露 CORS 头)',
actual: null,
status: 'pass',
description: 'CORS 跨域配置(未设置,符合安全预期)',
});
}
// Cookie security check
const setCookie = headers.get('set-cookie');
const parsedCookies = parseCookies(setCookie);
const cookieChecks = checkCookies(parsedCookies);
return { headerChecks, cookieChecks };
}
function printTable(checks: SecurityCheck[]): void {
// Column widths
const headerWidth = 34;
const statusWidth = 6;
const expectedWidth = 38;
const actualWidth = 38;
const separator = `├${'─'.repeat(headerWidth + 2)}┼${'─'.repeat(statusWidth + 2)}┼${'─'.repeat(expectedWidth + 2)}┼${'─'.repeat(actualWidth + 2)}┤`;
const topBorder = `┌${'─'.repeat(headerWidth + 2)}┬${'─'.repeat(statusWidth + 2)}┬${'─'.repeat(expectedWidth + 2)}┬${'─'.repeat(actualWidth + 2)}┐`;
const bottomBorder = `└${'─'.repeat(headerWidth + 2)}┴${'─'.repeat(statusWidth + 2)}┴${'─'.repeat(expectedWidth + 2)}┴${'─'.repeat(actualWidth + 2)}┘`;
const headerRow = `│ ${'Header'.padEnd(headerWidth)} │ ${'Status'.padEnd(statusWidth)} │ ${'Expected'.padEnd(expectedWidth)} │ ${'Actual'.padEnd(actualWidth)} │`;
console.log(topBorder);
console.log(headerRow);
console.log(separator);
for (const check of checks) {
const statusIcon = check.status === 'pass' ? '✅ PASS' : check.status === 'fail' ? '❌ FAIL' : '⚠️ WARN';
const actual = check.actual ?? '(未设置)';
const row = `│ ${check.header.padEnd(headerWidth)} │ ${statusIcon.padEnd(statusWidth + 2)} │ ${check.expected.padEnd(expectedWidth)} │ ${actual.padEnd(actualWidth)} │`;
console.log(row);
}
console.log(bottomBorder);
}
function printCookieTable(cookieChecks: CookieCheck[]): void {
if (cookieChecks.length === 0) {
console.log('\n🍪 Cookie 安全标记: 无 Cookie 设置\n');
return;
}
const nameWidth = 24;
const httpOnlyWidth = 10;
const secureWidth = 8;
const sameSiteWidth = 10;
const topBorder = `┌${'─'.repeat(nameWidth + 2)}┬${'─'.repeat(httpOnlyWidth + 2)}┬${'─'.repeat(secureWidth + 2)}┬${'─'.repeat(sameSiteWidth + 2)}┐`;
const separator = `├${'─'.repeat(nameWidth + 2)}┼${'─'.repeat(httpOnlyWidth + 2)}┼${'─'.repeat(secureWidth + 2)}┼${'─'.repeat(sameSiteWidth + 2)}┤`;
const bottomBorder = `└${'─'.repeat(nameWidth + 2)}┴${'─'.repeat(httpOnlyWidth + 2)}┴${'─'.repeat(secureWidth + 2)}┴${'─'.repeat(sameSiteWidth + 2)}┘`;
const headerRow = `│ ${'Cookie Name'.padEnd(nameWidth)} │ ${'HttpOnly'.padEnd(httpOnlyWidth)} │ ${'Secure'.padEnd(secureWidth)} │ ${'SameSite'.padEnd(sameSiteWidth)} │`;
console.log('\n🍪 Cookie 安全标记\n');
console.log(topBorder);
console.log(headerRow);
console.log(separator);
for (const cookie of cookieChecks) {
const httpOnly = cookie.hasHttpOnly ? '✅' : '❌';
const secure = cookie.hasSecure ? '✅' : '❌';
const sameSite = cookie.hasSameSite ? '✅' : '❌';
const row = `│ ${cookie.name.padEnd(nameWidth)} │ ${httpOnly.padEnd(httpOnlyWidth)} │ ${secure.padEnd(secureWidth)} │ ${sameSite.padEnd(sameSiteWidth)} │`;
console.log(row);
}
console.log(bottomBorder);
}
function printSummary(headerChecks: SecurityCheck[], cookieChecks: CookieCheck[]): void {
const total = headerChecks.length;
const passed = headerChecks.filter((c) => c.status === 'pass').length;
const failed = headerChecks.filter((c) => c.status === 'fail').length;
const warned = headerChecks.filter((c) => c.status === 'warn').length;
const cookiePassed = cookieChecks.filter((c) => c.status === 'pass').length;
const cookieFailed = cookieChecks.filter((c) => c.status === 'fail').length;
console.log('\n📊 检查摘要\n');
console.log(` 安全头检查:`);
console.log(` 总计: ${total}`);
console.log(` ✅ 通过: ${passed}`);
console.log(` ⚠️ 警告: ${warned}`);
console.log(` ❌ 失败: ${failed}`);
if (cookieChecks.length > 0) {
console.log(`\n Cookie 安全标记:`);
console.log(` 总计: ${cookieChecks.length}`);
console.log(` ✅ 通过: ${cookiePassed}`);
console.log(` ❌ 失败: ${cookieFailed}`);
}
}
async function main() {
const url = parseArgs();
try {
const { headerChecks, cookieChecks } = await checkSecurityHeaders(url);
// Print header check table
console.log('安全头检查结果:');
printTable(headerChecks);
// Print cookie check table
printCookieTable(cookieChecks);
// Print descriptions for failed/warned checks
const issues = headerChecks.filter((c) => c.status !== 'pass');
if (issues.length > 0) {
console.log('\n📝 说明:');
for (const issue of issues) {
console.log(` • [${issue.status === 'fail' ? '❌' : '⚠️'}] ${issue.header}: ${issue.description}`);
if (issue.status === 'fail') {
console.log(` 期望: ${issue.expected}`);
console.log(` 实际: ${issue.actual ?? '(未设置)'}`);
}
}
}
// Print summary
printSummary(headerChecks, cookieChecks);
// Determine exit code
const hasFailures =
headerChecks.some((c) => c.status === 'fail') || cookieChecks.some((c) => c.status === 'fail');
if (hasFailures) {
console.log('\n❌ 存在失败的安全头检查项!');
process.exit(1);
} else {
console.log('\n✅ 所有关键安全头检查通过!');
process.exit(0);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`\n❌ 检查执行失败: ${message}`);
process.exit(1);
}
}
main();