- 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
299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
#!/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(); |