Files
novalon-website/scripts/utils/check-security-headers.ts
T
zhangxiang dd088a5ee1 chore(test): add test infrastructure, CI config, and security scanning
- Add Jenkinsfile with E2E/visual regression/security scan stages
- Add Stryker mutation testing configuration
- Add security header scanning script (check-security-headers.ts)
- Remove old playwright.config.ts (migrated to e2e/playwright.config.ts)
- Remove obsolete .claude/skills/impeccable
- Update jest config for test path patterns
- Update package.json with comprehensive test scripts
- Update .gitignore for stryker-tmp/ and .pi/ temp files
2026-07-31 20:24:01 +08:00

299 lines
10 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[] {
return cookies.map((cookie) => ({
name: cookie.name,
hasHttpOnly: cookie.flags.some((f) => f === 'httponly'),
hasSecure: cookie.flags.some((f) => f === 'secure'),
hasSameSite: cookie.flags.some((f) => f.startsWith('samesite')),
status: 'pass' as const,
}));
}
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'),
status: headers.get('content-security-policy') ? 'pass' : 'warn',
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'),
status: headers.get('strict-transport-security') ? 'pass' : 'fail',
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 : 'warn' 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');
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();