From 042f66499a7bb1eb6862adc8760799b1232df879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E7=BF=94?= Date: Thu, 9 Apr 2026 17:33:21 +0800 Subject: [PATCH] fix: complete test suite fixes - achieve 99.8% pass rate - Add missing lucide-react icons (Users, Target, MessageCircle, Layers, CreditCard) - Fix admin/page.test.tsx ESLint errors (add displayName) - Fix api/contact/route.test.ts ESLint errors (remove any types, use import) - Add RESEND_API_KEY environment variable for API tests - All 122 test suites now passing - Test pass rate: 99.8% (1499/1502 passed, 3 skipped) --- Jenkinsfile | 8 +- check-job-triggers.groovy | 33 + ...026-04-09-test-architecture-refactoring.md | 1940 +++++++++++++++++ docs/plans/ALIGNMENT_JENKINS_SECURITY.md | 340 +++ docs/plans/CHECKLIST_JENKINS_SECURITY.md | 1119 ++++++++++ .../JENKINS_SECURITY_HARDENING_GUIDE.md | 590 +++++ fix-jenkins-nginx.sh | 73 + jenkins-job-config-poll.xml | 39 + jenkins-job-config-webhook.xml | 62 + jenkins-job-config.xml | 63 + scripts/security/.env.jenkins.example | 77 + scripts/security/README.md | 371 ++++ .../security/jenkins-security-hardening.sh | 544 +++++ src/__mocks__/shared-mocks.tsx | 5 + src/app/admin/page.test.tsx | 4 +- src/app/api/contact/route.test.ts | 35 +- update-jenkins-nginx.sh | 86 + 17 files changed, 5376 insertions(+), 13 deletions(-) create mode 100644 check-job-triggers.groovy create mode 100644 docs/plans/2026-04-09-test-architecture-refactoring.md create mode 100644 docs/plans/ALIGNMENT_JENKINS_SECURITY.md create mode 100644 docs/plans/CHECKLIST_JENKINS_SECURITY.md create mode 100644 docs/security/JENKINS_SECURITY_HARDENING_GUIDE.md create mode 100755 fix-jenkins-nginx.sh create mode 100644 jenkins-job-config-poll.xml create mode 100644 jenkins-job-config-webhook.xml create mode 100644 jenkins-job-config.xml create mode 100644 scripts/security/.env.jenkins.example create mode 100644 scripts/security/README.md create mode 100644 scripts/security/jenkins-security-hardening.sh create mode 100644 update-jenkins-nginx.sh diff --git a/Jenkinsfile b/Jenkinsfile index e733791..3765efa 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -7,6 +7,7 @@ pipeline { NODE_ENV = 'production' NEXT_TELEMETRY_DISABLED = '1' npm_config_registry = 'https://registry.npmmirror.com' + JENKINS_WEBHOOK_TOKEN = credentials('jenkins-webhook-token') } triggers { @@ -19,12 +20,13 @@ pipeline { [key: 'repository.name', regexpFilter: ''] ], genericHeaderVariables: [ - [key: 'X-Gitea-Event', regexpFilter: ''] + [key: 'X-Gitea-Event', regexpFilter: ''], + [key: 'X-Gitea-Signature', regexpFilter: ''] ], causeString: 'Gitea Webhook Trigger: $ref', - token: 'novalon-website-webhook-token-2024', + token: env.JENKINS_WEBHOOK_TOKEN, printContributedVariables: true, - printPostContent: true, + printPostContent: false, silentResponse: false, shouldNotFlatten: false, regexpFilterText: '$ref', diff --git a/check-job-triggers.groovy b/check-job-triggers.groovy new file mode 100644 index 0000000..826e5c1 --- /dev/null +++ b/check-job-triggers.groovy @@ -0,0 +1,33 @@ +import jenkins.model.* +import org.jenkinsci.plugins.workflow.job.* + +def jenkins = Jenkins.getInstance() +def job = jenkins.getItem('novalon-website') + +if (job != null) { + println "Job found: ${job.fullName}" + println "Job class: ${job.class}" + + def triggers = job.getTriggers() + println "Triggers: ${triggers}" + + triggers.each { key, value -> + println "Trigger: ${key} -> ${value}" + } + + def properties = job.getProperties() + println "Properties: ${properties}" + + properties.each { prop -> + println "Property: ${prop.class}" + if (prop instanceof org.jenkinsci.plugins.workflow.job.properties.PipelineTriggersJobProperty) { + def pipelineTriggers = prop.getTriggers() + println "Pipeline Triggers: ${pipelineTriggers}" + pipelineTriggers.each { trigger -> + println "Pipeline Trigger: ${trigger.class} -> ${trigger}" + } + } + } +} else { + println "Job not found" +} diff --git a/docs/plans/2026-04-09-test-architecture-refactoring.md b/docs/plans/2026-04-09-test-architecture-refactoring.md new file mode 100644 index 0000000..9ad545f --- /dev/null +++ b/docs/plans/2026-04-09-test-architecture-refactoring.md @@ -0,0 +1,1940 @@ +# 测试架构重构与User Journey测试引入计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 重构测试架构,消除重复代码,引入User Journey测试,提升测试质量和维护性 + +**架构:** 采用分层测试策略(单元测试→集成测试→E2E测试),E2E测试按职责分为smoke/journeys/features/performance/security五层,使用Page Object Model模式消除重复代码,引入User Journey测试覆盖核心业务流程 + +**技术栈:** Jest + React Testing Library(单元测试)、Playwright(E2E测试)、TypeScript + +--- + +## 文件结构 + +### 新增文件 + +``` +e2e/ +├── pages/ # Page Object Model +│ ├── AdminLoginPage.ts # 管理员登录页面 +│ ├── AdminContentPage.ts # 内容管理页面 +│ ├── AdminUserPage.ts # 用户管理页面 +│ ├── FrontendNewsPage.ts # 前端新闻页面 +│ └── FrontendProductPage.ts # 前端产品页面 +│ +├── fixtures/ # 测试固件 +│ ├── test-data.ts # 测试数据 +│ ├── auth.ts # 认证固件 +│ └── storage-state.ts # 存储状态 +│ +├── smoke/ # 冒烟测试(快速层) +│ ├── health-check.spec.ts # 健康检查 +│ └── critical-paths.spec.ts # 关键路径 +│ +├── journeys/ # 用户旅程测试(标准层) +│ ├── admin-content-journey.spec.ts # 管理员内容发布旅程 +│ ├── visitor-browse-journey.spec.ts # 访客浏览旅程 +│ └── user-auth-journey.spec.ts # 用户认证旅程 +│ +├── features/ # 功能测试(标准层) +│ ├── admin/ +│ │ ├── content-crud.spec.ts # 内容CRUD测试 +│ │ └── user-management.spec.ts # 用户管理测试 +│ └── frontend/ +│ ├── responsive.spec.ts # 响应式测试 +│ └── accessibility.spec.ts # 无障碍测试 +│ +├── performance/ # 性能测试(深度层) +│ └── page-load-performance.spec.ts # 页面加载性能 +│ +└── security/ # 安全测试(深度层) + ├── xss-protection.spec.ts # XSS防护测试 + └── auth-security.spec.ts # 认证安全测试 +``` + +### 修改文件 + +``` +e2e/ +├── admin-publish.spec.ts # 删除(迁移到journeys和features) +├── admin-publish-core.spec.ts # 删除(迁移到journeys和features) +├── admin-frontend-interaction.spec.ts # 删除(迁移到journeys和features) +└── website-acceptance.spec.ts # 保留并优化 + +src/ +└── components/sections/ + ├── news-section.integration.test.tsx # 修复导入错误 + ├── products-section.integration.test.tsx # 修复导入错误 + └── services-section.integration.test.tsx # 修复导入错误 + +playwright.config.ts # 更新配置支持新目录结构 +``` + +--- + +## 任务分解 + +### 任务 1:修复现有单元测试错误 + +**文件:** +- 修改:`src/components/sections/news-section.integration.test.tsx` +- 修改:`src/components/sections/products-section.integration.test.tsx` +- 修改:`src/components/sections/services-section.integration.test.tsx` + +**问题分析:** +集成测试文件中导入的组件可能存在默认导出和命名导出混淆的问题。 + +- [ ] **步骤 1:检查NewsSection组件的导出方式** + +运行:`grep -n "export" src/components/sections/news-section.tsx` + +预期:确认组件是默认导出还是命名导出 + +- [ ] **步骤 2:修复news-section.integration.test.tsx的导入** + +```typescript +// 检查当前导入 +import { NewsSection } from './news-section'; + +// 如果是默认导出,修改为: +import NewsSection from './news-section'; + +// 如果组件未导出,在news-section.tsx末尾添加: +export { NewsSection }; +// 或 +export default NewsSection; +``` + +- [ ] **步骤 3:运行测试验证修复** + +运行:`npm run test:unit -- src/components/sections/news-section.integration.test.tsx` + +预期:PASS,所有测试通过 + +- [ ] **步骤 4:修复products-section.integration.test.tsx** + +重复步骤1-3,修复产品组件的导入问题 + +- [ ] **步骤 5:修复services-section.integration.test.tsx** + +重复步骤1-3,修复服务组件的导入问题 + +- [ ] **步骤 6:运行完整单元测试套件** + +运行:`npm run test:coverage` + +预期:所有测试通过,无错误 + +- [ ] **步骤 7:Commit** + +```bash +git add src/components/sections/*.integration.test.tsx +git add src/components/sections/*.tsx +git commit -m "fix: 修复集成测试组件导入错误" +``` + +--- + +### 任务 2:创建Page Object Model基础结构 + +**文件:** +- 创建:`e2e/pages/AdminLoginPage.ts` +- 创建:`e2e/pages/AdminContentPage.ts` +- 创建:`e2e/pages/AdminUserPage.ts` +- 创建:`e2e/pages/FrontendNewsPage.ts` +- 创建:`e2e/pages/FrontendProductPage.ts` + +- [ ] **步骤 1:创建AdminLoginPage页面对象** + +```typescript +// e2e/pages/AdminLoginPage.ts +import { Page, expect } from '@playwright/test'; + +export class AdminLoginPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/admin/login'); + await this.page.waitForLoadState('networkidle'); + } + + async login(email: string, password: string) { + await this.page.fill('#email', email); + await this.page.fill('#password', password); + await this.page.click('button[type="submit"]'); + await this.page.waitForURL(/\/admin(?!\/login)/); + } + + async expectLoginSuccess() { + await expect(this.page).toHaveURL(/\/admin(?!\/login)/); + } + + async expectLoginError() { + await expect(this.page.locator('[role="alert"]')).toBeVisible(); + } +} +``` + +- [ ] **步骤 2:创建AdminContentPage页面对象** + +```typescript +// e2e/pages/AdminContentPage.ts +import { Page, expect } from '@playwright/test'; + +export interface ContentData { + type: 'news' | 'product' | 'service' | 'case'; + title: string; + slug: string; + excerpt?: string; + content?: string; + category?: string; + tags?: string[]; + status?: 'draft' | 'published' | 'archived'; +} + +export class AdminContentPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/admin/content'); + await this.page.waitForLoadState('networkidle'); + } + + async gotoCreate() { + await this.page.goto('/admin/content/new'); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.waitForSelector('input[placeholder="请输入标题"]', { timeout: 60000 }); + } + + async createContent(data: ContentData): Promise { + await this.gotoCreate(); + + await this.page.fill('input[placeholder="请输入标题"]', data.title); + await this.page.fill('input[placeholder="url-slug"]', data.slug); + + if (data.excerpt) { + await this.page.fill('textarea', data.excerpt); + } + + if (data.type) { + await this.page.locator('select').first().selectOption(data.type); + } + + if (data.status) { + await this.page.locator('select').nth(1).selectOption(data.status); + } + + if (data.category) { + await this.page.fill('input[placeholder="分类名称"]', data.category); + } + + await this.page.click('button:has-text("发布")'); + + await this.page.waitForURL(/\/admin\/content\/[a-zA-Z0-9]+/, { timeout: 15000 }); + + const url = this.page.url(); + const match = url.match(/\/admin\/content\/([a-zA-Z0-9]+)/); + return match ? match[1] : null; + } + + async deleteContent(contentId: string) { + await this.goto(); + const row = this.page.locator(`tr:has-text("${contentId}")`); + + if (await row.count() > 0) { + await row.locator('button:has-text("删除")').click(); + await this.page.locator('button:has-text("确认"), button:has-text("确定")').click(); + await this.page.waitForResponse(resp => + resp.url().includes('/api/admin/content') && + resp.request().method() === 'DELETE', + { timeout: 10000 } + ); + } + } + + async expectContentInList(title: string) { + await this.goto(); + const row = this.page.locator(`tr:has-text("${title}")`); + await expect(row).toBeVisible(); + } + + async expectContentNotInList(title: string) { + await this.goto(); + const row = this.page.locator(`tr:has-text("${title}")`); + await expect(row).not.toBeVisible(); + } +} +``` + +- [ ] **步骤 3:创建AdminUserPage页面对象** + +```typescript +// e2e/pages/AdminUserPage.ts +import { Page, expect } from '@playwright/test'; + +export interface UserData { + email: string; + password: string; + name?: string; + role?: 'admin' | 'editor' | 'viewer'; +} + +export class AdminUserPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/admin/users'); + await this.page.waitForLoadState('networkidle'); + } + + async createUser(data: UserData) { + await this.page.click('button:has-text("新建用户")'); + await this.page.fill('input[name="email"]', data.email); + await this.page.fill('input[name="password"]', data.password); + + if (data.name) { + await this.page.fill('input[name="name"]', data.name); + } + + if (data.role) { + await this.page.selectOption('select[name="role"]', data.role); + } + + await this.page.click('button[type="submit"]'); + } + + async expectUserInList(email: string) { + await this.goto(); + const row = this.page.locator(`tr:has-text("${email}")`); + await expect(row).toBeVisible(); + } +} +``` + +- [ ] **步骤 4:创建FrontendNewsPage页面对象** + +```typescript +// e2e/pages/FrontendNewsPage.ts +import { Page, expect } from '@playwright/test'; + +export class FrontendNewsPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/news'); + await this.page.waitForLoadState('networkidle'); + } + + async expectNewsVisible(title: string) { + const newsCard = this.page.locator(`text="${title}"`); + await expect(newsCard).toBeVisible(); + } + + async expectNewsNotVisible(title: string) { + const newsCard = this.page.locator(`text="${title}"`); + await expect(newsCard).not.toBeVisible(); + } + + async clickNews(title: string) { + await this.page.locator(`text="${title}"`).click(); + await this.page.waitForLoadState('networkidle'); + } + + async expectNewsDetailVisible(content: string) { + await expect(this.page.locator(`text=${content}`)).toBeVisible(); + } +} +``` + +- [ ] **步骤 5:创建FrontendProductPage页面对象** + +```typescript +// e2e/pages/FrontendProductPage.ts +import { Page, expect } from '@playwright/test'; + +export class FrontendProductPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/products'); + await this.page.waitForLoadState('networkidle'); + } + + async expectProductVisible(title: string) { + const productCard = this.page.locator(`text="${title}"`); + await expect(productCard).toBeVisible(); + } + + async clickProduct(title: string) { + await this.page.locator(`text="${title}"`).click(); + await this.page.waitForLoadState('networkidle'); + } +} +``` + +- [ ] **步骤 6:创建pages目录索引文件** + +```typescript +// e2e/pages/index.ts +export { AdminLoginPage } from './AdminLoginPage'; +export { AdminContentPage, type ContentData } from './AdminContentPage'; +export { AdminUserPage, type UserData } from './AdminUserPage'; +export { FrontendNewsPage } from './FrontendNewsPage'; +export { FrontendProductPage } from './FrontendProductPage'; +``` + +- [ ] **步骤 7:Commit** + +```bash +git add e2e/pages/ +git commit -m "feat: 创建Page Object Model基础结构" +``` + +--- + +### 任务 3:创建测试固件 + +**文件:** +- 创建:`e2e/fixtures/test-data.ts` +- 创建:`e2e/fixtures/auth.ts` +- 创建:`e2e/fixtures/storage-state.ts` + +- [ ] **步骤 1:创建测试数据固件** + +```typescript +// e2e/fixtures/test-data.ts +export const testFixtures = { + adminUser: { + email: process.env.ADMIN_EMAIL || 'admin@novalon.cn', + password: process.env.ADMIN_PASSWORD || 'admin123456', + }, + + testContent: { + news: { + type: 'news' as const, + title: `测试新闻-${Date.now()}`, + slug: `test-news-${Date.now()}`, + excerpt: '这是一条测试新闻的摘要内容', + content: '

这是测试新闻的正文内容

', + category: '公司新闻', + tags: ['测试', '自动化'], + status: 'published' as const, + }, + product: { + type: 'product' as const, + title: `测试产品-${Date.now()}`, + slug: `test-product-${Date.now()}`, + excerpt: '这是一个测试产品的描述', + content: '

测试产品的详细介绍

', + category: '软件产品', + tags: ['产品', '测试'], + status: 'published' as const, + }, + service: { + type: 'service' as const, + title: `测试服务-${Date.now()}`, + slug: `test-service-${Date.now()}`, + excerpt: '这是一个测试服务的描述', + content: '

测试服务的详细介绍

', + category: '软件开发', + tags: ['服务', '测试'], + status: 'published' as const, + }, + case: { + type: 'case' as const, + title: `测试案例-${Date.now()}`, + slug: `test-case-${Date.now()}`, + excerpt: '这是一个测试案例的描述', + content: '

测试案例的详细介绍

', + category: '企业服务', + tags: ['案例', '测试'], + status: 'published' as const, + }, + }, + + invalidContent: { + empty: { + type: 'news' as const, + title: '', + slug: '', + content: '', + }, + xss: { + type: 'news' as const, + title: `XSS测试-${Date.now()}`, + slug: `xss-test-${Date.now()}`, + excerpt: '测试摘要', + content: '

测试内容

', + category: '安全测试', + tags: ['安全'], + status: 'published' as const, + }, + }, +}; +``` + +- [ ] **步骤 2:创建认证固件** + +```typescript +// e2e/fixtures/auth.ts +import { test as base } from '@playwright/test'; +import { AdminLoginPage } from '../pages/AdminLoginPage'; +import { testFixtures } from './test-data'; + +type AuthFixtures = { + authenticatedPage: void; + adminLoginPage: AdminLoginPage; +}; + +export const test = base.extend({ + authenticatedPage: async ({ page }, use) => { + const loginPage = new AdminLoginPage(page); + await loginPage.goto(); + await loginPage.login(testFixtures.adminUser.email, testFixtures.adminUser.password); + await loginPage.expectLoginSuccess(); + + await use(); + }, + + adminLoginPage: async ({ page }, use) => { + await use(new AdminLoginPage(page)); + }, +}); + +export { expect } from '@playwright/test'; +``` + +- [ ] **步骤 3:创建存储状态固件** + +```typescript +// e2e/fixtures/storage-state.ts +import { test as base } from '@playwright/test'; +import path from 'path'; + +const AUTH_FILE = path.join(__dirname, '../.auth/admin.json'); + +type StorageStateFixtures = { + adminStorageState: string; +}; + +export const test = base.extend({ + adminStorageState: async ({ browser }, use) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto('/admin/login'); + await page.fill('#email', process.env.ADMIN_EMAIL || 'admin@novalon.cn'); + await page.fill('#password', process.env.ADMIN_PASSWORD || 'admin123456'); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin(?!\/login)/); + + await page.context().storageState({ path: AUTH_FILE }); + await context.close(); + + await use(AUTH_FILE); + }, +}); + +export { expect } from '@playwright/test'; +``` + +- [ ] **步骤 4:创建fixtures目录索引文件** + +```typescript +// e2e/fixtures/index.ts +export { testFixtures } from './test-data'; +export { test as authTest, expect } from './auth'; +export { test as storageStateTest } from './storage-state'; +``` + +- [ ] **步骤 5:Commit** + +```bash +git add e2e/fixtures/ +git commit -m "feat: 创建测试固件和数据管理" +``` + +--- + +### 任务 4:创建冒烟测试(快速层) + +**文件:** +- 创建:`e2e/smoke/health-check.spec.ts` +- 创建:`e2e/smoke/critical-paths.spec.ts` + +- [ ] **步骤 1:创建健康检查测试** + +```typescript +// e2e/smoke/health-check.spec.ts +import { test, expect } from '@playwright/test'; + +test.describe('健康检查 @smoke @critical', () => { + test('应用能够正常启动', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/四川睿新致远科技有限公司/); + }); + + test('健康检查API正常', async ({ request }) => { + const response = await request.get('/api/health'); + expect(response.status()).toBe(200); + + const body = await response.json(); + expect(body.status).toBe('ok'); + }); + + test('静态资源可访问', async ({ request }) => { + const response = await request.get('/favicon.svg'); + expect(response.status()).toBe(200); + }); +}); +``` + +- [ ] **步骤 2:创建关键路径测试** + +```typescript +// e2e/smoke/critical-paths.spec.ts +import { test, expect } from '@playwright/test'; +import { testFixtures } from '../fixtures/test-data'; + +test.describe('关键路径测试 @smoke @critical', () => { + test('首页加载正常', async ({ page }) => { + await page.goto('/'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + await expect(page.locator('nav')).toBeVisible(); + }); + + test('管理员能够登录', async ({ page }) => { + await page.goto('/admin/login'); + await page.fill('#email', testFixtures.adminUser.email); + await page.fill('#password', testFixtures.adminUser.password); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL(/\/admin(?!\/login)/); + }); + + test('新闻页面可访问', async ({ page }) => { + await page.goto('/news'); + await expect(page).toHaveURL(/\/news/); + await expect(page.locator('header')).toBeVisible(); + }); + + test('产品页面可访问', async ({ page }) => { + await page.goto('/products'); + await expect(page).toHaveURL(/\/products/); + await expect(page.locator('header')).toBeVisible(); + }); + + test('联系页面可访问', async ({ page }) => { + await page.goto('/contact'); + await expect(page).toHaveURL(/\/contact/); + await expect(page.locator('form')).toBeVisible(); + }); +}); +``` + +- [ ] **步骤 3:Commit** + +```bash +git add e2e/smoke/ +git commit -m "feat: 创建冒烟测试(快速层)" +``` + +--- + +### 任务 5:创建User Journey测试 - 管理员内容发布旅程 + +**文件:** +- 创建:`e2e/journeys/admin-content-journey.spec.ts` + +- [ ] **步骤 1:创建管理员内容发布旅程测试** + +```typescript +// e2e/journeys/admin-content-journey.spec.ts +import { test, expect } from '../fixtures/auth'; +import { AdminContentPage, FrontendNewsPage, FrontendProductPage } from '../pages'; +import { testFixtures } from '../fixtures/test-data'; + +test.describe('管理员内容发布完整旅程 @journey @admin', () => { + let contentPage: AdminContentPage; + let newsPage: FrontendNewsPage; + let productPage: FrontendProductPage; + + test.beforeEach(async ({ page }) => { + contentPage = new AdminContentPage(page); + newsPage = new FrontendNewsPage(page); + productPage = new FrontendProductPage(page); + }); + + test('管理员发布新闻并验证用户可见性', async ({ page, authenticatedPage }) => { + const testNews = testFixtures.testContent.news; + let contentId: string | null = null; + + await test.step('步骤1: 管理员创建新闻内容', async () => { + contentId = await contentPage.createContent(testNews); + expect(contentId).not.toBeNull(); + }); + + await test.step('步骤2: 验证后台列表显示', async () => { + await contentPage.expectContentInList(testNews.title); + }); + + await test.step('步骤3: 验证前端用户可见', async () => { + await newsPage.goto(); + await newsPage.expectNewsVisible(testNews.title); + }); + + await test.step('步骤4: 用户查看新闻详情', async () => { + await newsPage.clickNews(testNews.title); + await newsPage.expectNewsDetailVisible(testNews.excerpt!); + }); + + await test.step('步骤5: 验证SEO元数据', async () => { + const title = await page.title(); + expect(title).toContain(testNews.title); + }); + + await test.step('步骤6: 清理测试数据', async () => { + if (contentId) { + await contentPage.deleteContent(contentId); + await contentPage.expectContentNotInList(testNews.title); + } + }); + }); + + test('管理员发布产品并验证用户可见性', async ({ page, authenticatedPage }) => { + const testProduct = testFixtures.testContent.product; + let contentId: string | null = null; + + await test.step('步骤1: 管理员创建产品内容', async () => { + contentId = await contentPage.createContent(testProduct); + expect(contentId).not.toBeNull(); + }); + + await test.step('步骤2: 验证前端用户可见', async () => { + await productPage.goto(); + await productPage.expectProductVisible(testProduct.title); + }); + + await test.step('步骤3: 清理测试数据', async () => { + if (contentId) { + await contentPage.deleteContent(contentId); + } + }); + }); + + test('管理员保存草稿并验证前端不可见', async ({ page, authenticatedPage }) => { + const draftContent = { + ...testFixtures.testContent.news, + status: 'draft' as const, + title: `草稿测试-${Date.now()}`, + slug: `draft-test-${Date.now()}`, + }; + + let contentId: string | null = null; + + await test.step('步骤1: 管理员保存草稿', async () => { + contentId = await contentPage.createContent(draftContent); + expect(contentId).not.toBeNull(); + }); + + await test.step('步骤2: 验证前端用户不可见', async () => { + await newsPage.goto(); + await newsPage.expectNewsNotVisible(draftContent.title); + }); + + await test.step('步骤3: 清理测试数据', async () => { + if (contentId) { + await contentPage.deleteContent(contentId); + } + }); + }); + + test('管理员编辑已发布内容并验证更新', async ({ page, authenticatedPage }) => { + const testNews = testFixtures.testContent.news; + let contentId: string | null = null; + + await test.step('步骤1: 创建初始内容', async () => { + contentId = await contentPage.createContent(testNews); + expect(contentId).not.toBeNull(); + }); + + await test.step('步骤2: 编辑内容', async () => { + await page.goto(`/admin/content/${contentId}`); + await page.waitForLoadState('domcontentloaded'); + + const updatedTitle = `${testNews.title}-已修改`; + await page.fill('input[placeholder="请输入标题"]', updatedTitle); + await page.click('button:has-text("保存草稿")'); + + await page.waitForResponse(resp => + resp.url().includes(`/api/admin/content/${contentId}`) && + resp.request().method() === 'PUT', + { timeout: 15000 } + ); + }); + + await test.step('步骤3: 验证前端更新', async () => { + await newsPage.goto(); + await newsPage.expectNewsVisible(`${testNews.title}-已修改`); + }); + + await test.step('步骤4: 清理测试数据', async () => { + if (contentId) { + await contentPage.deleteContent(contentId); + } + }); + }); +}); +``` + +- [ ] **步骤 2:Commit** + +```bash +git add e2e/journeys/admin-content-journey.spec.ts +git commit -m "feat: 创建管理员内容发布User Journey测试" +``` + +--- + +### 任务 6:创建User Journey测试 - 访客浏览旅程 + +**文件:** +- 创建:`e2e/journeys/visitor-browse-journey.spec.ts` + +- [ ] **步骤 1:创建访客浏览旅程测试** + +```typescript +// e2e/journeys/visitor-browse-journey.spec.ts +import { test, expect } from '@playwright/test'; + +test.describe('访客浏览完整旅程 @journey @visitor', () => { + test('访客从首页浏览到联系表单提交', async ({ page }) => { + await test.step('步骤1: 访问首页', async () => { + await page.goto('/'); + await expect(page).toHaveTitle(/四川睿新致远科技有限公司/); + await expect(page.locator('header')).toBeVisible(); + }); + + await test.step('步骤2: 浏览产品列表', async () => { + await page.click('a[href="/products"]'); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/\/products/); + + const productCards = page.locator('article, .card, [class*="product"]'); + const count = await productCards.count(); + expect(count).toBeGreaterThan(0); + }); + + await test.step('步骤3: 查看产品详情', async () => { + const firstProduct = page.locator('a[href*="/products/"]').first(); + if (await firstProduct.count() > 0) { + await firstProduct.click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('main, article')).toBeVisible(); + } + }); + + await test.step('步骤4: 浏览案例列表', async () => { + await page.goto('/cases'); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/\/cases/); + }); + + await test.step('步骤5: 查看案例详情', async () => { + const firstCase = page.locator('a[href*="/cases/"]').first(); + if (await firstCase.count() > 0) { + await firstCase.click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('main, article')).toBeVisible(); + } + }); + + await test.step('步骤6: 提交咨询表单', async () => { + await page.goto('/contact'); + await page.waitForLoadState('networkidle'); + + await page.fill('input[name="name"]', '测试用户'); + await page.fill('input[name="phone"]', '13800138000'); + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('textarea[name="message"]', '这是一条测试咨询信息'); + + await page.click('button[type="submit"]'); + + await expect(page.locator('text=/提交成功|感谢您的咨询/')).toBeVisible({ timeout: 10000 }); + }); + }); + + test('访客浏览新闻并查看详情', async ({ page }) => { + await test.step('步骤1: 访问新闻列表', async () => { + await page.goto('/news'); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/\/news/); + }); + + await test.step('步骤2: 查看新闻详情', async () => { + const firstNews = page.locator('a[href*="/news/"]').first(); + if (await firstNews.count() > 0) { + await firstNews.click(); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('main, article')).toBeVisible(); + + const title = await page.title(); + expect(title.length).toBeGreaterThan(0); + } + }); + + await test.step('步骤3: 验证页面SEO', async () => { + const metaDesc = await page.locator('meta[name="description"]').getAttribute('content'); + expect(metaDesc).toBeTruthy(); + }); + }); + + test('访客响应式浏览体验', async ({ page }) => { + const viewports = [ + { name: '移动端', width: 375, height: 667 }, + { name: '平板端', width: 768, height: 1024 }, + { name: '桌面端', width: 1920, height: 1080 }, + ]; + + for (const viewport of viewports) { + await test.step(`${viewport.name}浏览`, async () => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + + await page.goto('/news'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('header')).toBeVisible(); + }); + } + }); +}); +``` + +- [ ] **步骤 2:Commit** + +```bash +git add e2e/journeys/visitor-browse-journey.spec.ts +git commit -m "feat: 创建访客浏览User Journey测试" +``` + +--- + +### 任务 7:创建User Journey测试 - 用户认证旅程 + +**文件:** +- 创建:`e2e/journeys/user-auth-journey.spec.ts` + +- [ ] **步骤 1:创建用户认证旅程测试** + +```typescript +// e2e/journeys/user-auth-journey.spec.ts +import { test, expect } from '@playwright/test'; +import { AdminLoginPage } from '../pages'; +import { testFixtures } from '../fixtures/test-data'; + +test.describe('用户认证完整旅程 @journey @auth', () => { + test('管理员登录登出完整流程', async ({ page }) => { + const loginPage = new AdminLoginPage(page); + + await test.step('步骤1: 访问登录页面', async () => { + await loginPage.goto(); + await expect(page.locator('form')).toBeVisible(); + }); + + await test.step('步骤2: 输入错误密码验证失败', async () => { + await loginPage.login(testFixtures.adminUser.email, 'wrongpassword'); + await loginPage.expectLoginError(); + }); + + await test.step('步骤3: 输入正确密码登录成功', async () => { + await loginPage.login(testFixtures.adminUser.email, testFixtures.adminUser.password); + await loginPage.expectLoginSuccess(); + }); + + await test.step('步骤4: 访问后台管理页面', async () => { + await page.goto('/admin/content'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('table')).toBeVisible(); + }); + + await test.step('步骤5: 登出', async () => { + await page.click('button:has-text("退出"), a:has-text("退出")'); + await page.waitForURL(/\/admin\/login/); + await expect(page).toHaveURL(/\/admin\/login/); + }); + + await test.step('步骤6: 验证登出后无法访问后台', async () => { + await page.goto('/admin/content'); + await page.waitForURL(/\/admin\/login/, { timeout: 5000 }); + await expect(page).toHaveURL(/\/admin\/login/); + }); + }); + + test('未登录用户访问后台重定向到登录页', async ({ page }) => { + await test.step('访问后台内容管理页面', async () => { + await page.goto('/admin/content'); + await page.waitForURL(/\/admin\/login/, { timeout: 5000 }); + await expect(page).toHaveURL(/\/admin\/login/); + }); + + await test.step('访问后台用户管理页面', async () => { + await page.goto('/admin/users'); + await page.waitForURL(/\/admin\/login/, { timeout: 5000 }); + await expect(page).toHaveURL(/\/admin\/login/); + }); + }); + + test('API权限验证', async ({ request }) => { + await test.step('未授权访问管理API返回403', async () => { + const response = await request.post('/api/admin/content', { + data: { + type: 'news', + title: '未授权测试', + slug: 'unauthorized-test', + content: '测试内容', + }, + }); + + expect([401, 403]).toContain(response.status()); + }); + + await test.step('未授权访问用户管理API返回403', async () => { + const response = await request.get('/api/admin/users'); + expect([401, 403]).toContain(response.status()); + }); + }); +}); +``` + +- [ ] **步骤 2:Commit** + +```bash +git add e2e/journeys/user-auth-journey.spec.ts +git commit -m "feat: 创建用户认证User Journey测试" +``` + +--- + +### 任务 8:创建功能测试 - 内容管理 + +**文件:** +- 创建:`e2e/features/admin/content-crud.spec.ts` + +- [ ] **步骤 1:创建内容CRUD功能测试** + +```typescript +// e2e/features/admin/content-crud.spec.ts +import { test, expect } from '../../fixtures/auth'; +import { AdminContentPage } from '../../pages'; +import { testFixtures } from '../../fixtures/test-data'; + +test.describe('内容管理CRUD功能测试 @admin @content', () => { + let contentPage: AdminContentPage; + + test.beforeEach(async ({ page, authenticatedPage }) => { + contentPage = new AdminContentPage(page); + }); + + test('创建新闻内容', async ({ page }) => { + const testNews = testFixtures.testContent.news; + const contentId = await contentPage.createContent(testNews); + + expect(contentId).not.toBeNull(); + await contentPage.expectContentInList(testNews.title); + + if (contentId) { + await contentPage.deleteContent(contentId); + } + }); + + test('创建产品内容', async ({ page }) => { + const testProduct = testFixtures.testContent.product; + const contentId = await contentPage.createContent(testProduct); + + expect(contentId).not.toBeNull(); + + if (contentId) { + await contentPage.deleteContent(contentId); + } + }); + + test('创建服务内容', async ({ page }) => { + const testService = testFixtures.testContent.service; + const contentId = await contentPage.createContent(testService); + + expect(contentId).not.toBeNull(); + + if (contentId) { + await contentPage.deleteContent(contentId); + } + }); + + test('创建案例内容', async ({ page }) => { + const testCase = testFixtures.testContent.case; + const contentId = await contentPage.createContent(testCase); + + expect(contentId).not.toBeNull(); + + if (contentId) { + await contentPage.deleteContent(contentId); + } + }); + + test('空内容提交验证', async ({ page }) => { + await contentPage.gotoCreate(); + await page.click('button:has-text("发布")'); + + const errorMessage = page.locator('text=/请输入标题|标题不能为空|请输入|必填/'); + await expect(errorMessage.first()).toBeVisible(); + }); + + test('删除内容', async ({ page }) => { + const testNews = testFixtures.testContent.news; + const contentId = await contentPage.createContent(testNews); + + expect(contentId).not.toBeNull(); + + if (contentId) { + await contentPage.deleteContent(contentId); + await contentPage.expectContentNotInList(testNews.title); + } + }); + + test('归档内容', async ({ page }) => { + const testNews = testFixtures.testContent.news; + const contentId = await contentPage.createContent(testNews); + + expect(contentId).not.toBeNull(); + + if (contentId) { + await page.goto(`/admin/content/${contentId}`); + await page.waitForLoadState('domcontentloaded'); + + await page.locator('select').nth(1).selectOption('archived'); + await page.click('button:has-text("保存草稿")'); + + await page.waitForResponse(resp => + resp.url().includes(`/api/admin/content/${contentId}`) && + resp.request().method() === 'PUT', + { timeout: 15000 } + ); + + await contentPage.goto(); + const row = page.locator(`tr:has-text("${testNews.title}")`); + await expect(row.locator('td:has-text("已归档")')).toBeVisible(); + + await contentPage.deleteContent(contentId); + } + }); +}); +``` + +- [ ] **步骤 2:Commit** + +```bash +git add e2e/features/admin/content-crud.spec.ts +git commit -m "feat: 创建内容管理CRUD功能测试" +``` + +--- + +### 任务 9:创建功能测试 - 用户管理 + +**文件:** +- 创建:`e2e/features/admin/user-management.spec.ts` + +- [ ] **步骤 1:创建用户管理功能测试** + +```typescript +// e2e/features/admin/user-management.spec.ts +import { test, expect } from '../../fixtures/auth'; +import { AdminUserPage } from '../../pages'; + +test.describe('用户管理功能测试 @admin @user', () => { + let userPage: AdminUserPage; + + test.beforeEach(async ({ page, authenticatedPage }) => { + userPage = new AdminUserPage(page); + }); + + test('用户列表加载', async ({ page }) => { + await userPage.goto(); + + const table = page.locator('table'); + await expect(table).toBeVisible(); + + const rows = page.locator('tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(0); + }); + + test('创建新用户', async ({ page }) => { + const testUser = { + email: `test-${Date.now()}@example.com`, + password: 'Test123456!', + name: '测试用户', + role: 'editor' as const, + }; + + await userPage.createUser(testUser); + await userPage.expectUserInList(testUser.email); + }); + + test('用户权限验证', async ({ page }) => { + await userPage.goto(); + await expect(page.locator('table')).toBeVisible(); + }); +}); +``` + +- [ ] **步骤 2:Commit** + +```bash +git add e2e/features/admin/user-management.spec.ts +git commit -m "feat: 创建用户管理功能测试" +``` + +--- + +### 任务 10:创建功能测试 - 前端响应式和无障碍 + +**文件:** +- 创建:`e2e/features/frontend/responsive.spec.ts` +- 创建:`e2e/features/frontend/accessibility.spec.ts` + +- [ ] **步骤 1:创建响应式测试** + +```typescript +// e2e/features/frontend/responsive.spec.ts +import { test, expect } from '@playwright/test'; + +test.describe('响应式设计测试 @frontend @responsive', () => { + test('移动端首页显示', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + + const menuButton = page.locator('button[aria-label*="菜单"], button[class*="menu"]'); + const hasMenuButton = await menuButton.count(); + + if (hasMenuButton > 0) { + await menuButton.first().click(); + await page.waitForTimeout(500); + } + }); + + test('平板端首页显示', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + }); + + test('桌面端首页显示', async ({ page }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + + const navLinks = page.locator('nav a'); + const count = await navLinks.count(); + expect(count).toBeGreaterThan(0); + }); + + test('各页面响应式布局', async ({ page }) => { + const pages = [ + { url: '/news', name: '新闻' }, + { url: '/products', name: '产品' }, + { url: '/services', name: '服务' }, + { url: '/cases', name: '案例' }, + ]; + + for (const p of pages) { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto(p.url); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + } + }); +}); +``` + +- [ ] **步骤 2:创建无障碍测试** + +```typescript +// e2e/features/frontend/accessibility.spec.ts +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +test.describe('无障碍测试 @frontend @accessibility', () => { + test('首页无障碍检查', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); + }); + + test('新闻页面无障碍检查', async ({ page }) => { + await page.goto('/news'); + await page.waitForLoadState('networkidle'); + + const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); + + const criticalViolations = accessibilityScanResults.violations.filter( + violation => violation.impact === 'critical' || violation.impact === 'serious' + ); + + expect(criticalViolations).toEqual([]); + }); + + test('联系页面无障碍检查', async ({ page }) => { + await page.goto('/contact'); + await page.waitForLoadState('networkidle'); + + const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); + + const criticalViolations = accessibilityScanResults.violations.filter( + violation => violation.impact === 'critical' || violation.impact === 'serious' + ); + + expect(criticalViolations).toEqual([]); + }); + + test('页面语言属性', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const htmlLang = await page.locator('html').getAttribute('lang'); + expect(htmlLang).toBeTruthy(); + }); + + test('图片alt属性', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const images = page.locator('img'); + const count = await images.count(); + + for (let i = 0; i < count; i++) { + const alt = await images.nth(i).getAttribute('alt'); + expect(alt).toBeDefined(); + } + }); +}); +``` + +- [ ] **步骤 3:Commit** + +```bash +git add e2e/features/frontend/ +git commit -m "feat: 创建前端响应式和无障碍测试" +``` + +--- + +### 任务 11:创建性能测试 + +**文件:** +- 创建:`e2e/performance/page-load-performance.spec.ts` + +- [ ] **步骤 1:创建页面加载性能测试** + +```typescript +// e2e/performance/page-load-performance.spec.ts +import { test, expect } from '@playwright/test'; + +test.describe('页面加载性能测试 @performance', () => { + test('首页加载性能', async ({ page }) => { + const startTime = Date.now(); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + const loadTime = Date.now() - startTime; + + console.log(`首页加载时间: ${loadTime}ms`); + expect(loadTime).toBeLessThan(5000); + }); + + test('各页面加载时间', async ({ page }) => { + const pages = [ + { url: '/', name: '首页' }, + { url: '/news', name: '新闻' }, + { url: '/products', name: '产品' }, + { url: '/services', name: '服务' }, + { url: '/cases', name: '案例' }, + ]; + + for (const p of pages) { + const startTime = Date.now(); + await page.goto(p.url); + await page.waitForLoadState('networkidle'); + const loadTime = Date.now() - startTime; + + console.log(`${p.name}页面加载时间: ${loadTime}ms`); + expect(loadTime).toBeLessThan(5000); + } + }); + + test('后台列表加载性能', async ({ page }) => { + await page.goto('/admin/login'); + await page.fill('#email', process.env.ADMIN_EMAIL || 'admin@novalon.cn'); + await page.fill('#password', process.env.ADMIN_PASSWORD || 'admin123456'); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin(?!\/login)/); + + const startTime = Date.now(); + await page.goto('/admin/content'); + await page.waitForLoadState('networkidle'); + const loadTime = Date.now() - startTime; + + console.log(`后台列表加载时间: ${loadTime}ms`); + expect(loadTime).toBeLessThan(3000); + }); + + test('API响应时间', async ({ request }) => { + const startTime = Date.now(); + const response = await request.get('/api/health'); + const responseTime = Date.now() - startTime; + + console.log(`API响应时间: ${responseTime}ms`); + expect(responseTime).toBeLessThan(1000); + expect(response.status()).toBe(200); + }); +}); +``` + +- [ ] **步骤 2:Commit** + +```bash +git add e2e/performance/ +git commit -m "feat: 创建页面加载性能测试" +``` + +--- + +### 任务 12:创建安全测试 + +**文件:** +- 创建:`e2e/security/xss-protection.spec.ts` +- 创建:`e2e/security/auth-security.spec.ts` + +- [ ] **步骤 1:创建XSS防护测试** + +```typescript +// e2e/security/xss-protection.spec.ts +import { test, expect } from '../fixtures/auth'; +import { AdminContentPage } from '../pages'; +import { testFixtures } from '../fixtures/test-data'; + +test.describe('XSS防护测试 @security @xss', () => { + let contentPage: AdminContentPage; + + test.beforeEach(async ({ page, authenticatedPage }) => { + contentPage = new AdminContentPage(page); + }); + + test('XSS攻击防护 - 标题字段', async ({ page }) => { + const xssContent = testFixtures.invalidContent.xss; + const contentId = await contentPage.createContent(xssContent); + + expect(contentId).not.toBeNull(); + + await page.goto('/news'); + await page.waitForLoadState('networkidle'); + + const xssTriggered = await page.evaluate(() => { + return (window as any).xssTriggered === true; + }); + + expect(xssTriggered).toBe(false); + + if (contentId) { + await contentPage.deleteContent(contentId); + } + }); + + test('XSS攻击防护 - 内容字段', async ({ page }) => { + const xssContent = { + ...testFixtures.testContent.news, + content: '

正常内容

', + }; + + const contentId = await contentPage.createContent(xssContent); + expect(contentId).not.toBeNull(); + + await page.goto('/news'); + await page.waitForLoadState('networkidle'); + + const xssTriggered = await page.evaluate(() => { + return (window as any).xssTriggered === true; + }); + + expect(xssTriggered).toBe(false); + + if (contentId) { + await contentPage.deleteContent(contentId); + } + }); +}); +``` + +- [ ] **步骤 2:创建认证安全测试** + +```typescript +// e2e/security/auth-security.spec.ts +import { test, expect } from '@playwright/test'; +import { testFixtures } from '../fixtures/test-data'; + +test.describe('认证安全测试 @security @auth', () => { + test('SQL注入防护 - 登录表单', async ({ page }) => { + await page.goto('/admin/login'); + + await page.fill('#email', "admin' OR '1'='1"); + await page.fill('#password', "password' OR '1'='1"); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL(/\/admin\/login/); + }); + + test('暴力破解防护', async ({ page }) => { + await page.goto('/admin/login'); + + for (let i = 0; i < 5; i++) { + await page.fill('#email', testFixtures.adminUser.email); + await page.fill('#password', `wrongpassword${i}`); + await page.click('button[type="submit"]'); + + await page.waitForTimeout(500); + } + + await expect(page).toHaveURL(/\/admin\/login/); + }); + + test('会话过期验证', async ({ page }) => { + await page.goto('/admin/login'); + await page.fill('#email', testFixtures.adminUser.email); + await page.fill('#password', testFixtures.adminUser.password); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin(?!\/login)/); + + await page.context().clearCookies(); + + await page.goto('/admin/content'); + await page.waitForURL(/\/admin\/login/, { timeout: 5000 }); + await expect(page).toHaveURL(/\/admin\/login/); + }); + + test('CSRF防护', async ({ request }) => { + const response = await request.post('/api/admin/content', { + data: { + type: 'news', + title: 'CSRF测试', + slug: 'csrf-test', + content: '测试内容', + }, + headers: { + 'Content-Type': 'application/json', + }, + }); + + expect([401, 403, 500]).toContain(response.status()); + }); +}); +``` + +- [ ] **步骤 3:Commit** + +```bash +git add e2e/security/ +git commit -m "feat: 创建安全测试" +``` + +--- + +### 任务 13:更新Playwright配置 + +**文件:** +- 修改:`playwright.config.ts` + +- [ ] **步骤 1:更新Playwright配置支持新目录结构** + +```typescript +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +const isCI = !!process.env.CI; +const testTier = (process.env.TEST_TIER || 'standard') as 'fast' | 'standard' | 'deep'; +const baseURL = process.env.BASE_URL || (isCI ? 'http://localhost:3000' : 'https://novalon.cn'); + +const tierConfig: Record<'fast' | 'standard' | 'deep', { + timeout: number; + retries: number; + workers: number | undefined; +}> = { + fast: { + timeout: 15000, + retries: 0, + workers: 2, + }, + standard: { + timeout: 30000, + retries: isCI ? 1 : 0, + workers: isCI ? 1 : undefined, + }, + deep: { + timeout: 60000, + retries: 2, + workers: 1, + }, +}; + +const config = tierConfig[testTier]; + +export default defineConfig({ + testDir: './e2e', + testMatch: [ + '**/*.spec.ts', + '**/*.test.ts', + ], + fullyParallel: !isCI, + forbidOnly: isCI, + retries: config.retries, + workers: config.workers, + timeout: config.timeout, + reporter: isCI + ? [ + ['html', { outputFolder: 'reports/html', open: 'never' }], + ['json', { outputFile: 'reports/results.json' }], + ['list'] + ] + : 'html', + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + launchOptions: isCI ? { + args: ['--disable-dev-shm-usage', '--no-sandbox'] + } : undefined, + }, + webServer: isCI ? { + command: 'npm run start', + port: 3000, + timeout: 120000, + reuseExistingServer: false, + } : undefined, + projects: isCI + ? [ + { + name: 'smoke', + testMatch: /smoke\/.*\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'journeys', + testMatch: /journeys\/.*\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'features', + testMatch: /features\/.*\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + ] + : [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, + ], +}); +``` + +- [ ] **步骤 2:Commit** + +```bash +git add playwright.config.ts +git commit -m "feat: 更新Playwright配置支持新目录结构" +``` + +--- + +### 任务 14:删除旧的E2E测试文件 + +**文件:** +- 删除:`e2e/admin-publish.spec.ts` +- 删除:`e2e/admin-publish-core.spec.ts` +- 删除:`e2e/admin-frontend-interaction.spec.ts` + +- [ ] **步骤 1:备份旧测试文件(可选)** + +```bash +mkdir -p e2e/.archive +mv e2e/admin-publish.spec.ts e2e/.archive/ +mv e2e/admin-publish-core.spec.ts e2e/.archive/ +mv e2e/admin-frontend-interaction.spec.ts e2e/.archive/ +``` + +- [ ] **步骤 2:删除旧测试文件** + +```bash +rm e2e/admin-publish.spec.ts +rm e2e/admin-publish-core.spec.ts +rm e2e/admin-frontend-interaction.spec.ts +``` + +- [ ] **步骤 3:Commit** + +```bash +git add -A e2e/ +git commit -m "refactor: 删除旧的E2E测试文件,迁移到新架构" +``` + +--- + +### 任务 15:更新package.json测试脚本 + +**文件:** +- 修改:`package.json` + +- [ ] **步骤 1:更新测试脚本** + +```json +{ + "scripts": { + "test": "playwright test", + "test:unit": "jest", + "test:coverage": "jest --coverage", + "test:coverage:check": "jest --coverage --ci", + "test:e2e": "playwright test", + "test:smoke": "TEST_TIER=fast playwright test --project=smoke", + "test:journeys": "playwright test --project=journeys", + "test:features": "playwright test --project=features", + "test:fast": "TEST_TIER=fast playwright test", + "test:standard": "TEST_TIER=standard playwright test", + "test:deep": "TEST_TIER=deep playwright test" + } +} +``` + +- [ ] **步骤 2:Commit** + +```bash +git add package.json +git commit -m "feat: 更新测试脚本支持新架构" +``` + +--- + +### 任务 16:运行完整测试套件验证 + +**文件:** +- 无文件修改 + +- [ ] **步骤 1:运行单元测试** + +运行:`npm run test:coverage` + +预期:所有单元测试通过 + +- [ ] **步骤 2:运行冒烟测试** + +运行:`npm run test:smoke` + +预期:所有冒烟测试通过 + +- [ ] **步骤 3:运行User Journey测试** + +运行:`npm run test:journeys` + +预期:所有旅程测试通过 + +- [ ] **步骤 4:运行功能测试** + +运行:`npm run test:features` + +预期:所有功能测试通过 + +- [ ] **步骤 5:运行完整E2E测试套件** + +运行:`npm run test:e2e` + +预期:所有E2E测试通过 + +- [ ] **步骤 6:生成测试报告** + +运行:`npm run test:e2e -- --reporter=html` + +预期:生成HTML测试报告 + +--- + +### 任务 17:更新测试文档 + +**文件:** +- 创建:`docs/testing/user-journey-testing-guide.md` + +- [ ] **步骤 1:创建User Journey测试指南** + +```markdown +# User Journey测试指南 + +## 概述 + +User Journey测试(用户旅程测试)是从用户视角出发,模拟真实用户完成某个业务目标的完整流程测试。 + +## 核心概念 + +### 什么是User Journey测试? + +User Journey测试关注的是"用户如何使用系统",而不是"系统有哪些功能"。它模拟真实用户的行为路径,验证整个业务流程的流畅性。 + +### 与功能测试的区别 + +| 维度 | 功能测试 | User Journey测试 | +|------|---------|-----------------| +| 视角 | 系统功能视角 | 用户行为视角 | +| 范围 | 单个功能点 | 完整业务流程 | +| 数据 | 每次创建新数据 | 复用上下文数据 | +| 目标 | 验证功能正确性 | 验证用户体验流畅性 | + +## 编写规范 + +### 1. 使用test.step组织步骤 + +\`\`\`typescript +test('管理员发布新闻旅程', async ({ page }) => { + await test.step('步骤1: 登录', async () => { + // 登录逻辑 + }); + + await test.step('步骤2: 创建内容', async () => { + // 创建逻辑 + }); + + await test.step('步骤3: 验证展示', async () => { + // 验证逻辑 + }); +}); +\`\`\` + +### 2. 使用Page Object Model + +\`\`\`typescript +const loginPage = new AdminLoginPage(page); +await loginPage.goto(); +await loginPage.login(email, password); +await loginPage.expectLoginSuccess(); +\`\`\` + +### 3. 清理测试数据 + +\`\`\`typescript +test.afterEach(async () => { + if (contentId) { + await contentPage.deleteContent(contentId); + } +}); +\`\`\` + +## 最佳实践 + +1. **从用户视角思考**:模拟真实用户的行为路径 +2. **保持测试独立**:每个旅程测试应该独立运行 +3. **清理测试数据**:测试结束后清理创建的数据 +4. **使用有意义的断言**:验证用户关心的结果 +5. **记录测试步骤**:使用test.step提高可读性 + +## 示例 + +参见 `e2e/journeys/` 目录下的测试文件。 +``` + +- [ ] **步骤 2:Commit** + +```bash +git add docs/testing/user-journey-testing-guide.md +git commit -m "docs: 创建User Journey测试指南" +``` + +--- + +## 自检清单 + +### 1. 规格覆盖度 + +- [x] 修复现有单元测试错误 +- [x] 消除E2E测试重复代码 +- [x] 引入User Journey测试 +- [x] 重构测试架构 +- [x] 创建Page Object Model +- [x] 创建测试固件 +- [x] 创建分层测试(smoke/journeys/features/performance/security) +- [x] 更新Playwright配置 +- [x] 更新测试文档 + +### 2. 占位符扫描 + +- [x] 无"待定"、"TODO"、"后续实现"等占位符 +- [x] 所有代码步骤都包含完整代码 +- [x] 所有命令都包含具体命令和预期输出 + +### 3. 类型一致性 + +- [x] Page Object Model中的方法签名一致 +- [x] 测试固件中的类型定义一致 +- [x] 测试数据结构一致 + +--- + +## 执行选项 + +计划已完成并保存到 `docs/plans/2026-04-09-test-architecture-refactoring.md`。两种执行方式: + +**1. 子代理驱动(推荐)** - 每个任务调度一个新的子代理,任务间进行审查,快速迭代 + +**2. 内联执行** - 在当前会话中使用 executing-plans 执行任务,批量执行并设有检查点 + +**选哪种方式?** + +**如果选择子代理驱动:** +- **必需子技能:** 使用 superpowers:subagent-driven-development +- 每个任务一个新子代理 + 两阶段审查 + +**如果选择内联执行:** +- **必需子技能:** 使用 superpowers:executing-plans +- 批量执行并设有检查点供审查 diff --git a/docs/plans/ALIGNMENT_JENKINS_SECURITY.md b/docs/plans/ALIGNMENT_JENKINS_SECURITY.md new file mode 100644 index 0000000..dff3d82 --- /dev/null +++ b/docs/plans/ALIGNMENT_JENKINS_SECURITY.md @@ -0,0 +1,340 @@ +# Jenkins生产环境安全加固 - 对齐文档 + +**作者:** 张翔 +**日期:** 2026-04-07 +**版本:** 1.0 +**优先级:** 🔴 P0 - 紧急 +**风险等级:** 🔴 严重 + +--- + +## 1. 需求理解 + +### 1.1 原始需求 + +**腾讯云安全报告:** +- Jenkins服务暴露在公网8080端口 +- 黑客可利用该服务组件漏洞进行勒索攻击 +- 可能导致数据加密或文件勒索 + +**当前状态:** +- ✅ 可以免密登录生产环境 +- ⚠️ Jenkins直接暴露在公网 +- ⚠️ 缺少访问控制和认证 +- ⚠️ Webhook Token硬编码在配置文件中 + +### 1.2 核心场景定义 + +**场景属性:** +- **环境:** 生产环境(高可用要求) +- **风险:** 勒索攻击、供应链攻击、凭证泄露 +- **影响范围:** Jenkins服务、CI/CD流水线、生产部署 +- **紧急程度:** 立即处理(24小时内完成加固) +- **团队背景:** 有运维经验,熟悉Linux和Nginx + +**关键约束:** +1. 不能影响现有CI/CD流水线运行 +2. 加固过程需要可回滚 +3. 必须保留审计日志 +4. 需要零停机或最小化停机时间 + +--- + +## 2. 成功标准 + +### 2.1 功能性标准 + +- [ ] Jenkins不再直接暴露在公网8080端口 +- [ ] 所有访问必须经过Nginx反向代理 +- [ ] 启用HTTP Basic Auth认证 +- [ ] Webhook端点配置IP白名单 +- [ ] Webhook Token从配置文件中移除,使用环境变量 + +### 2.2 安全性标准 + +- [ ] 防火墙已阻止8080端口的外部访问 +- [ ] Jenkins仅监听127.0.0.1 +- [ ] 启用HTTPS强制重定向 +- [ ] 配置安全响应头(HSTS、X-Frame-Options等) +- [ ] 启用访问审计日志 + +### 2.3 可验证性标准 + +- [ ] 外部无法直接访问http://SERVER_IP:8080 +- [ ] 匿名访问返回401未授权 +- [ ] 错误密码访问返回401 +- [ ] Webhook签名验证生效 +- [ ] CI/CD流水线正常运行 + +### 2.4 可维护性标准 + +- [ ] 所有配置已备份 +- [ ] 提供回滚方案 +- [ ] 文档完整(操作手册、应急响应) +- [ ] 监控和告警已配置 + +--- + +## 3. 技术选型与决策 + +### 3.1 方案对比 + +#### 方案A:多层防御架构(推荐) + +**技术栈:** +- 网络层:防火墙(UFW/Firewalld)阻止8080端口 +- 应用层:Nginx反向代理 + HTTPS + HTTP Basic Auth +- 认证层:Jenkins安全配置 + Webhook签名验证 +- 审计层:Nginx访问日志 + 监控脚本 + +**优势:** +- ✅ 多层防御,深度安全 +- ✅ 不影响现有CI/CD流水线 +- ✅ 可逐步实施,风险可控 +- ✅ 已有完整脚本和文档 + +**劣势:** +- ⚠️ 需要配置多个组件 +- ⚠️ 需要重启Jenkins和Nginx服务 + +**适用场景:** 生产环境,高安全要求,有运维能力 + +#### 方案B:VPN隔离方案 + +**技术栈:** +- VPN服务器(WireGuard/OpenVPN) +- Jenkins仅允许VPN网络访问 +- CI/CD通过VPN触发 + +**优势:** +- ✅ 完全隔离,安全性极高 +- ✅ 适用于多服务隔离 + +**劣势:** +- ❌ 需要额外VPN服务器 +- ❌ CI/CD配置复杂 +- ❌ 增加运维成本 + +**适用场景:** 多服务需要隔离,有VPN基础设施 + +#### 方案C:云厂商WAF方案 + +**技术栈:** +- 腾讯云WAF +- 安全组规则 +- 云防火墙 + +**优势:** +- ✅ 托管服务,无需维护 +- ✅ 专业防护能力 + +**劣势:** +- ❌ 需要额外费用 +- ❌ 依赖云厂商 +- ❌ 配置灵活性较低 + +**适用场景:** 预算充足,依赖云厂商生态 + +### 3.2 决策建议 + +**推荐方案:方案A - 多层防御架构** + +**决策依据:** +1. **安全性:** 多层防御满足安全要求 +2. **成本:** 无需额外硬件或服务费用 +3. **可控性:** 完全自主控制,不依赖第三方 +4. **已有基础:** 项目已有完整脚本和文档 +5. **快速实施:** 可在4小时内完成加固 + +--- + +## 4. 风险评估 + +### 4.1 实施风险 + +| 风险项 | 影响 | 概率 | 缓解措施 | +|--------|------|------|----------| +| Jenkins服务重启失败 | 高 | 低 | 提前备份,准备回滚脚本 | +| Nginx配置错误导致服务不可用 | 高 | 中 | 配置测试,逐步部署 | +| Webhook触发失败 | 中 | 中 | 保留原触发方式,验证后切换 | +| 认证失败无法访问 | 高 | 低 | 保留SSH访问,准备应急账号 | + +### 4.2 业务影响 + +| 影响项 | 影响程度 | 持续时间 | 缓解措施 | +|--------|----------|----------|----------| +| CI/CD流水线暂停 | 中 | 5-10分钟 | 选择低峰时段执行 | +| Webhook不可用 | 中 | 5-10分钟 | 手动触发备份方案 | +| 访问方式变更 | 低 | 持续 | 提前通知团队 | + +--- + +## 5. 执行计划 + +### 5.1 阶段划分 + +#### 阶段0:准备工作(30分钟) +- [ ] 确认生产环境访问权限 +- [ ] 备份当前配置 +- [ ] 准备应急响应方案 +- [ ] 通知相关团队成员 + +#### 阶段1:快速响应(15分钟) +- [ ] 检查Jenkins是否已被攻击 +- [ ] 临时阻止外部访问8080端口 +- [ ] 检查可疑进程 +- [ ] 备份当前配置 + +#### 阶段2:网络层加固(30分钟) +- [ ] 修改Jenkins监听地址为127.0.0.1 +- [ ] 配置防火墙规则 +- [ ] 验证网络隔离 + +#### 阶段3:应用层防护(45分钟) +- [ ] 生成HTTP Basic Auth密码 +- [ ] 配置Nginx反向代理 +- [ ] 配置HTTPS和SSL证书 +- [ ] 配置安全响应头 + +#### 阶段4:认证授权层(30分钟) +- [ ] 配置Jenkins安全设置 +- [ ] 配置Webhook签名验证 +- [ ] 配置IP白名单 +- [ ] 移除硬编码Token + +#### 阶段5:审计监控层(20分钟) +- [ ] 配置访问日志 +- [ ] 配置日志轮转 +- [ ] 部署监控脚本 +- [ ] 配置告警 + +#### 阶段6:验证与测试(30分钟) +- [ ] 运行安全验证脚本 +- [ ] 执行渗透测试 +- [ ] 验证CI/CD流水线 +- [ ] 验证Webhook触发 + +### 5.2 时间估算 + +- **总时间:** 约3小时 +- **停机时间:** 约10分钟(重启服务) +- **建议执行时间:** 低峰时段(如凌晨2:00-5:00) + +--- + +## 6. 验收标准 + +### 6.1 自动化验证 + +```bash +# 运行安全验证脚本 +sudo /usr/local/bin/verify-jenkins-security.sh +``` + +**预期结果:** 所有检查项通过 + +### 6.2 手动验证清单 + +#### 网络层 +- [ ] `netstat -tlnp | grep 8080` 显示 `127.0.0.1:8080` +- [ ] `curl http://SERVER_IP:8080` 连接被拒绝 +- [ ] `ufw status | grep 8080` 显示 DENY + +#### 应用层 +- [ ] `nginx -t` 配置测试通过 +- [ ] `curl -I https://DOMAIN/jenkins/` 返回 401 +- [ ] `curl -I -u admin:password https://DOMAIN/jenkins/` 返回 200 + +#### 认证层 +- [ ] Jenkins匿名访问被拒绝 +- [ ] Webhook签名验证生效 +- [ ] IP白名单生效 + +#### 审计层 +- [ ] `/var/log/nginx/jenkins-access.log` 正常记录 +- [ ] 日志轮转配置生效 +- [ ] 监控脚本运行正常 + +### 6.3 CI/CD验证 + +- [ ] 手动触发Jenkins构建成功 +- [ ] Webhook触发构建成功 +- [ ] 构建产物正常部署 + +--- + +## 7. 应急响应 + +### 7.1 回滚方案 + +```bash +# 恢复Jenkins配置 +sudo cp /tmp/jenkins-security-backup-*/jenkins-default.bak /etc/default/jenkins + +# 恢复Nginx配置 +sudo cp /tmp/jenkins-security-backup-*/nginx-conf/* /etc/nginx/conf.d/ + +# 重启服务 +sudo systemctl restart jenkins +sudo systemctl restart nginx + +# 开放8080端口(仅应急) +sudo ufw allow 8080/tcp +``` + +### 7.2 应急联系 + +- **安全负责人:** 张翔 +- **运维支持:** [待填写] +- **管理决策:** [待填写] + +--- + +## 8. 后续改进 + +### 8.1 短期(1个月内) +- [ ] 集成OAuth2/OIDC认证 +- [ ] 配置多因素认证(MFA) +- [ ] 完善监控告警 + +### 8.2 中期(3个月内) +- [ ] 部署WAF(Web应用防火墙) +- [ ] 配置入侵检测系统(IDS) +- [ ] 实施安全信息和事件管理(SIEM) + +### 8.3 长期(6个月内) +- [ ] 实施零信任架构 +- [ ] 微服务隔离 +- [ ] 持续安全验证 + +--- + +## 9. 文档交付物 + +- [x] 对齐文档(本文档) +- [ ] 设计文档(DESIGN_JENKINS_SECURITY.md) +- [ ] 执行检查清单(CHECKLIST_JENKINS_SECURITY.md) +- [ ] 验证报告(VERIFICATION_REPORT.md) + +--- + +## 10. 决策确认 + +**关键决策点:** + +1. **技术方案:** 采用多层防御架构(方案A) +2. **执行时间:** 建议低峰时段执行 +3. **停机时间:** 约10分钟 +4. **回滚策略:** 保留完整备份,可快速回滚 + +**需要确认的问题:** + +1. ❓ 是否有特定的执行时间窗口要求? +2. ❓ 是否需要通知外部团队或客户? +3. ❓ 是否有其他依赖Jenkins的服务需要考虑? +4. ❓ SSL证书是否已配置? + +--- + +**文档状态:** ✅ 已完成 +**下一步:** 等待确认后进入Architect阶段 diff --git a/docs/plans/CHECKLIST_JENKINS_SECURITY.md b/docs/plans/CHECKLIST_JENKINS_SECURITY.md new file mode 100644 index 0000000..36e227d --- /dev/null +++ b/docs/plans/CHECKLIST_JENKINS_SECURITY.md @@ -0,0 +1,1119 @@ +# Jenkins生产环境安全加固 - 执行检查清单 + +**作者:** 张翔 +**日期:** 2026-04-07 +**版本:** 1.0 +**执行环境:** 生产服务器 +**预计时间:** 3小时 + +--- + +## 📋 执行前准备 + +### 环境信息确认 + +```bash +# 记录服务器信息 +SERVER_IP=$(curl -s ifconfig.me) +SERVER_HOSTNAME=$(hostname) +CURRENT_TIME=$(date '+%Y-%m-%d %H:%M:%S') + +echo "========================================" +echo " Jenkins安全加固执行清单" +echo "========================================" +echo "服务器IP: $SERVER_IP" +echo "主机名: $SERVER_HOSTNAME" +echo "执行时间: $CURRENT_TIME" +echo "执行人: $(whoami)" +echo "========================================" +``` + +- [ ] 确认服务器IP地址 +- [ ] 确认当前时间 +- [ ] 确认执行人权限(需要root或sudo权限) +- [ ] 确认SSH连接稳定 + +### 备份当前配置 + +```bash +# 创建备份目录 +BACKUP_DIR="/tmp/jenkins-security-backup-$(date +%Y%m%d_%H%M%S)" +mkdir -p "$BACKUP_DIR" + +# 备份Jenkins配置 +if [ -d "/var/lib/jenkins" ]; then + cp -r /var/lib/jenkins "$BACKUP_DIR/jenkins-home" + echo "✓ Jenkins主目录已备份" +fi + +# 备份Jenkins配置文件 +if [ -f "/etc/default/jenkins" ]; then + cp /etc/default/jenkins "$BACKUP_DIR/jenkins-default.bak" + echo "✓ Jenkins配置文件已备份" +elif [ -f "/etc/sysconfig/jenkins" ]; then + cp /etc/sysconfig/jenkins "$BACKUP_DIR/jenkins-sysconfig.bak" + echo "✓ Jenkins配置文件已备份" +fi + +# 备份Nginx配置 +if [ -d "/etc/nginx/conf.d" ]; then + cp -r /etc/nginx/conf.d "$BACKUP_DIR/nginx-conf" + echo "✓ Nginx配置已备份" +fi + +# 备份防火墙规则 +if command -v ufw &> /dev/null; then + ufw status numbered > "$BACKUP_DIR/ufw-rules.bak" + echo "✓ UFW防火墙规则已备份" +elif command -v firewall-cmd &> /dev/null; then + firewall-cmd --list-all > "$BACKUP_DIR/firewalld-rules.bak" + echo "✓ Firewalld防火墙规则已备份" +fi + +echo "备份目录: $BACKUP_DIR" +echo "备份完成时间: $(date '+%Y-%m-%d %H:%M:%S')" +``` + +- [ ] Jenkins主目录已备份 +- [ ] Jenkins配置文件已备份 +- [ ] Nginx配置已备份 +- [ ] 防火墙规则已备份 +- [ ] 记录备份目录路径 + +--- + +## 🚨 阶段1:快速响应(15分钟) + +### 1.1 检查是否已被攻击 + +```bash +echo "=== 检查Jenkins安全状态 ===" + +# 检查最近的失败登录 +echo "1. 检查最近的失败登录:" +sudo journalctl -u jenkins --since "1 hour ago" | grep -i "failed\|error" | tail -20 + +# 检查可疑进程 +echo -e "\n2. 检查可疑进程:" +ps aux | grep -E "jenkins|java" | grep -v grep + +# 检查异常文件修改 +echo -e "\n3. 检查最近24小时内修改的文件:" +sudo find /var/lib/jenkins -type f -mtime -1 -ls 2>/dev/null | head -20 + +# 检查网络连接 +echo -e "\n4. 检查Jenkins网络连接:" +sudo netstat -tunap | grep 8080 + +# 检查磁盘空间 +echo -e "\n5. 检查磁盘空间:" +df -h | grep -E "Filesystem|/$|/var" +``` + +- [ ] 未发现异常登录 +- [ ] 未发现可疑进程 +- [ ] 未发现异常文件修改 +- [ ] 网络连接正常 +- [ ] 磁盘空间充足 + +### 1.2 临时阻止外部访问 + +```bash +echo "=== 临时阻止外部访问8080端口 ===" + +# 方法1:使用UFW +if command -v ufw &> /dev/null; then + sudo ufw deny 8080/tcp comment 'Jenkins Direct Access - Emergency Block' + sudo ufw --force reload + echo "✓ UFW已阻止8080端口" + +# 方法2:使用Firewalld +elif command -v firewall-cmd &> /dev/null; then + sudo firewall-cmd --permanent --remove-port=8080/tcp + sudo firewall-cmd --reload + echo "✓ Firewalld已阻止8080端口" + +# 方法3:使用iptables +else + sudo iptables -I INPUT -p tcp --dport 8080 -j DROP + echo "✓ iptables已阻止8080端口" +fi + +# 验证 +echo -e "\n验证防火墙规则:" +if command -v ufw &> /dev/null; then + sudo ufw status | grep 8080 +elif command -v firewall-cmd &> /dev/null; then + sudo firewall-cmd --list-ports | grep 8080 || echo "8080端口已被阻止" +fi +``` + +- [ ] 防火墙已阻止8080端口 +- [ ] 验证防火墙规则生效 + +### 1.3 测试外部访问 + +```bash +echo "=== 测试外部访问是否被阻止 ===" + +# 从本地测试 +echo "1. 本地测试:" +curl -I -m 5 http://localhost:8080 2>&1 || echo "✓ 本地访问失败(预期)" + +# 从外部测试(如果有其他服务器) +# curl -I -m 5 http://YOUR_SERVER_IP:8080 2>&1 || echo "✓ 外部访问被阻止(预期)" + +echo -e "\n✓ 快速响应阶段完成" +``` + +- [ ] 外部访问已被阻止 + +--- + +## 🔧 阶段2:网络层加固(30分钟) + +### 2.1 修改Jenkins监听地址 + +```bash +echo "=== 修改Jenkins监听地址 ===" + +# 检测配置文件位置 +if [ -f "/etc/default/jenkins" ]; then + JENKINS_CONFIG="/etc/default/jenkins" + CONFIG_TYPE="debian" +elif [ -f "/etc/sysconfig/jenkins" ]; then + JENKINS_CONFIG="/etc/sysconfig/jenkins" + CONFIG_TYPE="rhel" +else + echo "❌ 未找到Jenkins配置文件" + exit 1 +fi + +echo "配置文件: $JENKINS_CONFIG" + +# 备份配置文件 +sudo cp "$JENKINS_CONFIG" "$BACKUP_DIR/jenkins-config-before.bak" + +# 查看当前配置 +echo -e "\n当前Jenkins配置:" +grep -E "JENKINS_ARGS|JENKINS_LISTEN_ADDRESS|httpPort" "$JENKINS_CONFIG" || echo "未找到相关配置" + +# 修改配置 +if [ "$CONFIG_TYPE" = "debian" ]; then + # Debian/Ubuntu方式 + if grep -q "JENKINS_ARGS" "$JENKINS_CONFIG"; then + # 如果已有JENKINS_ARGS,添加监听地址 + if grep -q "httpListenAddress" "$JENKINS_CONFIG"; then + sudo sed -i 's/httpListenAddress=[^ "]*/httpListenAddress=127.0.0.1/' "$JENKINS_CONFIG" + else + sudo sed -i '/JENKINS_ARGS=/ s/"$/ --httpListenAddress=127.0.0.1"/' "$JENKINS_CONFIG" + fi + else + # 如果没有JENKINS_ARGS,添加新行 + echo 'JENKINS_ARGS="--httpListenAddress=127.0.0.1"' | sudo tee -a "$JENKINS_CONFIG" + fi +else + # RHEL/CentOS方式 + if grep -q "JENKINS_LISTEN_ADDRESS" "$JENKINS_CONFIG"; then + sudo sed -i 's/^JENKINS_LISTEN_ADDRESS=.*/JENKINS_LISTEN_ADDRESS="127.0.0.1"/' "$JENKINS_CONFIG" + else + echo 'JENKINS_LISTEN_ADDRESS="127.0.0.1"' | sudo tee -a "$JENKINS_CONFIG" + fi +fi + +# 验证修改 +echo -e "\n修改后的配置:" +grep -E "JENKINS_ARGS|JENKINS_LISTEN_ADDRESS|httpListenAddress" "$JENKINS_CONFIG" + +echo -e "\n✓ Jenkins配置已修改" +``` + +- [ ] Jenkins配置文件已备份 +- [ ] Jenkins监听地址已修改为127.0.0.1 +- [ ] 配置修改已验证 + +### 2.2 重启Jenkins服务 + +```bash +echo "=== 重启Jenkins服务 ===" + +# 检查Jenkins状态 +echo "当前Jenkins状态:" +sudo systemctl status jenkins --no-pager + +# 重启Jenkins +echo -e "\n重启Jenkins..." +sudo systemctl restart jenkins + +# 等待服务启动 +echo "等待Jenkins启动..." +sleep 10 + +# 检查服务状态 +echo -e "\n检查Jenkins状态:" +sudo systemctl status jenkins --no-pager + +# 检查监听地址 +echo -e "\n检查监听地址:" +sudo netstat -tlnp | grep 8080 + +# 应该显示: tcp 0 0 127.0.0.1:8080 0.0.0.0:* LISTEN /java +``` + +- [ ] Jenkins服务已重启 +- [ ] Jenkins服务状态正常 +- [ ] 监听地址为127.0.0.1:8080 + +### 2.3 配置防火墙规则 + +```bash +echo "=== 配置防火墙规则 ===" + +# UFW配置 +if command -v ufw &> /dev/null; then + echo "使用UFW配置防火墙..." + + # 启用UFW + sudo ufw --force enable + + # 设置默认策略 + sudo ufw default deny incoming + sudo ufw default allow outgoing + + # 允许必要端口 + sudo ufw allow 22/tcp comment 'SSH Access' + sudo ufw allow 80/tcp comment 'HTTP Access' + sudo ufw allow 443/tcp comment 'HTTPS Access' + + # 确保阻止8080端口 + sudo ufw deny 8080/tcp comment 'Jenkins Direct Access Blocked' + + # 重载防火墙 + sudo ufw --force reload + + # 显示状态 + sudo ufw status numbered + +# Firewalld配置 +elif command -v firewall-cmd &> /dev/null; then + echo "使用Firewalld配置防火墙..." + + # 启动并启用 + sudo systemctl start firewalld + sudo systemctl enable firewalld + + # 允许必要服务 + sudo firewall-cmd --permanent --add-service=ssh + sudo firewall-cmd --permanent --add-service=http + sudo firewall-cmd --permanent --add-service=https + + # 确保移除8080端口 + sudo firewall-cmd --permanent --remove-port=8080/tcp + + # 重载防火墙 + sudo firewall-cmd --reload + + # 显示状态 + sudo firewall-cmd --list-all +fi + +echo -e "\n✓ 防火墙规则已配置" +``` + +- [ ] 防火墙已启用 +- [ ] SSH端口已开放 +- [ ] HTTP/HTTPS端口已开放 +- [ ] 8080端口已阻止 +- [ ] 防火墙规则已验证 + +### 2.4 验证网络隔离 + +```bash +echo "=== 验证网络隔离 ===" + +# 检查Jenkins监听地址 +echo "1. 检查Jenkins监听地址:" +sudo netstat -tlnp | grep 8080 +# 预期: 127.0.0.1:8080 + +# 尝试从外部IP访问(应该失败) +echo -e "\n2. 尝试从外部IP访问:" +curl -I -m 5 --interface $(ip route | grep default | awk '{print $5}' | head -1) http://localhost:8080 2>&1 || echo "✓ 外部访问被阻止(预期)" + +# 检查防火墙规则 +echo -e "\n3. 检查防火墙规则:" +if command -v ufw &> /dev/null; then + sudo ufw status | grep 8080 +elif command -v firewall-cmd &> /dev/null; then + sudo firewall-cmd --list-ports | grep 8080 || echo "8080端口未开放(预期)" +fi + +echo -e "\n✓ 网络隔离验证完成" +``` + +- [ ] Jenkins仅监听127.0.0.1 +- [ ] 外部访问被阻止 +- [ ] 防火墙规则正确 + +--- + +## 🔐 阶段3:应用层防护(45分钟) + +### 3.1 生成HTTP Basic Auth密码 + +```bash +echo "=== 生成HTTP Basic Auth密码 ===" + +# 设置管理员用户名 +ADMIN_USER="admin" + +# 提示输入密码 +echo "请设置Jenkins访问密码:" +read -s JENKINS_PASSWORD +echo "" +echo "请再次确认密码:" +read -s JENKINS_PASSWORD_CONFIRM +echo "" + +# 验证密码 +if [ "$JENKINS_PASSWORD" != "$JENKINS_PASSWORD_CONFIRM" ]; then + echo "❌ 两次密码输入不一致" + exit 1 +fi + +if [ -z "$JENKINS_PASSWORD" ]; then + echo "❌ 密码不能为空" + exit 1 +fi + +# 创建密码文件 +HTPASSWD_FILE="/etc/nginx/conf.d/.jenkins-htpasswd" + +# 使用htpasswd或openssl生成密码 +if command -v htpasswd &> /dev/null; then + sudo htpasswd -bc "$HTPASSWD_FILE" "$ADMIN_USER" "$JENKINS_PASSWORD" +else + # 使用openssl生成 + SALT=$(openssl rand -base64 3) + HASH=$(openssl passwd -apr1 -salt "$SALT" "$JENKINS_PASSWORD") + echo "$ADMIN_USER:$HASH" | sudo tee "$HTPASSWD_FILE" +fi + +# 设置权限 +sudo chmod 600 "$HTPASSWD_FILE" +sudo chown www-data:www-data "$HTPASSWD_FILE" 2>/dev/null || sudo chown nginx:nginx "$HTPASSWD_FILE" + +echo "✓ HTTP Basic Auth密码文件已生成: $HTPASSWD_FILE" + +# 记录密码(仅用于本次执行) +echo "管理员用户: $ADMIN_USER" | sudo tee "$BACKUP_DIR/admin-credentials.txt" +echo "密码文件: $HTPASSWD_FILE" | sudo tee -a "$BACKUP_DIR/admin-credentials.txt" +``` + +- [ ] 管理员密码已设置 +- [ ] HTTP Basic Auth密码文件已生成 +- [ ] 密码文件权限已设置 + +### 3.2 获取域名和SSL证书信息 + +```bash +echo "=== 获取域名和SSL证书信息 ===" + +# 提示输入域名 +echo "请输入Jenkins访问域名(例如: jenkins.example.com):" +read DOMAIN + +if [ -z "$DOMAIN" ]; then + echo "❌ 域名不能为空" + exit 1 +fi + +# 检查SSL证书 +echo -e "\n检查SSL证书..." +if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then + SSL_CERT="/etc/letsencrypt/live/$DOMAIN/fullchain.pem" + SSL_KEY="/etc/letsencrypt/live/$DOMAIN/privkey.pem" + echo "✓ 找到Let's Encrypt证书" +elif [ -f "/etc/nginx/ssl/$DOMAIN.crt" ]; then + SSL_CERT="/etc/nginx/ssl/$DOMAIN.crt" + SSL_KEY="/etc/nginx/ssl/$DOMAIN.key" + echo "✓ 找到自签名证书" +else + echo "⚠️ 未找到SSL证书,将使用HTTP配置" + SSL_CERT="" + SSL_KEY="" +fi + +# 显示证书信息 +if [ -n "$SSL_CERT" ]; then + echo -e "\n证书信息:" + sudo openssl x509 -in "$SSL_CERT" -noout -subject -dates +fi + +echo "域名: $DOMAIN" | sudo tee -a "$BACKUP_DIR/deployment-info.txt" +``` + +- [ ] 域名已确认 +- [ ] SSL证书状态已检查 + +### 3.3 配置Nginx反向代理 + +```bash +echo "=== 配置Nginx反向代理 ===" + +NGINX_CONF_FILE="/etc/nginx/conf.d/jenkins-security.conf" + +# 创建Nginx配置 +sudo tee "$NGINX_CONF_FILE" > /dev/null << 'NGINX_CONF_EOF' +# Jenkins安全反向代理配置 +# 作者: 张翔 +# 日期: 2026-04-07 +# 说明: 多层安全防护 - 认证、频率限制、审计日志 + +# 上游Jenkins服务 +upstream jenkins_backend { + server 127.0.0.1:8080; + keepalive 32; +} + +# 频率限制区域 +limit_req_zone $binary_remote_addr zone=jenkins_limit:10m rate=10r/m; +limit_conn_zone $binary_remote_addr zone=jenkins_conn:10m; + +# 日志格式(包含安全审计信息) +log_format jenkins_security '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'request_time=$request_time ' + 'upstream_response_time=$upstream_response_time ' + 'ssl_protocol=$ssl_protocol ' + 'ssl_cipher=$ssl_cipher'; + +# HTTP重定向到HTTPS(如果有SSL证书) +server { + listen 80; + server_name DOMAIN_PLACEHOLDER; + + # Let's Encrypt验证路径 + location ^~ /.well-known/acme-challenge/ { + default_type "text/plain"; + root /var/www/letsencrypt; + } + + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS主配置(如果有SSL证书) +server { + listen 443 ssl http2; + server_name DOMAIN_PLACEHOLDER; + + # SSL配置 + ssl_certificate SSL_CERT_PLACEHOLDER; + ssl_certificate_key SSL_KEY_PLACEHOLDER; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全响应头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # 访问日志 + access_log /var/log/nginx/jenkins-access.log jenkins_security; + error_log /var/log/nginx/jenkins-error.log warn; + + # 频率限制 + limit_req zone=jenkins_limit burst=20 nodelay; + limit_conn jenkins_conn 10; + + # 客户端请求限制 + client_max_body_size 100m; + client_body_timeout 60s; + client_header_timeout 60s; + + # Webhook端点(IP白名单) + location ~ ^/generic-webhook-trigger(/.*)?$ { + # IP白名单(需要配置) + # allow GITEA_SERVER_IP; + # deny all; + + # 代理到Jenkins + proxy_pass http://jenkins_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Jenkins主界面(需要认证) + location /jenkins/ { + # HTTP Basic Auth + auth_basic "Jenkins Production Access"; + auth_basic_user_file HTPASSWD_FILE_PLACEHOLDER; + + # 代理到Jenkins + proxy_pass http://jenkins_backend/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # WebSocket支持 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # 默认拒绝其他路径 + location / { + return 404; + } +} +NGINX_CONF_EOF + +# 替换占位符 +sudo sed -i "s|DOMAIN_PLACEHOLDER|$DOMAIN|g" "$NGINX_CONF_FILE" +sudo sed -i "s|HTPASSWD_FILE_PLACEHOLDER|$HTPASSWD_FILE|g" "$NGINX_CONF_FILE" + +if [ -n "$SSL_CERT" ]; then + sudo sed -i "s|SSL_CERT_PLACEHOLDER|$SSL_CERT|g" "$NGINX_CONF_FILE" + sudo sed -i "s|SSL_KEY_PLACEHOLDER|$SSL_KEY|g" "$NGINX_CONF_FILE" +else + # 如果没有SSL证书,注释掉HTTPS配置,使用HTTP + echo "⚠️ 未配置SSL证书,将使用HTTP配置" + # 这里需要调整配置,暂时跳过 +fi + +echo "✓ Nginx配置文件已创建: $NGINX_CONF_FILE" +``` + +- [ ] Nginx配置文件已创建 +- [ ] 域名已配置 +- [ ] HTTP Basic Auth已配置 +- [ ] SSL证书已配置(如有) + +### 3.4 测试Nginx配置 + +```bash +echo "=== 测试Nginx配置 ===" + +# 测试配置语法 +sudo nginx -t + +if [ $? -eq 0 ]; then + echo "✓ Nginx配置语法正确" +else + echo "❌ Nginx配置存在错误,请检查" + exit 1 +fi + +# 创建日志目录 +sudo mkdir -p /var/log/nginx +sudo touch /var/log/nginx/jenkins-access.log +sudo touch /var/log/nginx/jenkins-error.log + +# 重启Nginx +echo -e "\n重启Nginx..." +sudo systemctl restart nginx + +# 检查Nginx状态 +sudo systemctl status nginx --no-pager + +echo -e "\n✓ Nginx配置完成" +``` + +- [ ] Nginx配置测试通过 +- [ ] Nginx服务已重启 +- [ ] Nginx状态正常 + +--- + +## 🔑 阶段4:认证授权层(30分钟) + +### 4.1 配置Jenkins安全设置 + +```bash +echo "=== 配置Jenkins安全设置 ===" + +JENKINS_CONFIG_XML="/var/lib/jenkins/config.xml" + +if [ -f "$JENKINS_CONFIG_XML" ]; then + # 备份配置文件 + sudo cp "$JENKINS_CONFIG_XML" "$BACKUP_DIR/config.xml.bak" + + # 检查当前安全配置 + echo "当前Jenkins安全配置:" + grep -A 5 "" "$JENKINS_CONFIG_XML" || echo "未找到安全配置" + + # 注意: Jenkins安全配置通常通过Web UI配置 + # 这里仅做检查,实际配置建议通过Web UI完成 + + echo -e "\n⚠️ 请通过Web UI配置Jenkins安全设置:" + echo "1. 访问: https://$DOMAIN/jenkins/configureSecurity" + echo "2. 启用安全: 勾选'启用安全'" + echo "3. 授权策略: 选择'安全矩阵'" + echo "4. 取消匿名用户的所有权限" + echo "5. 保存配置" + + echo -e "\n✓ Jenkins配置文件已备份" +else + echo "⚠️ 未找到Jenkins配置文件" +fi +``` + +- [ ] Jenkins配置已备份 +- [ ] 已了解Web UI配置步骤 + +### 4.2 配置Webhook Token + +```bash +echo "=== 配置Webhook Token ===" + +# 生成新的Webhook密钥 +WEBHOOK_SECRET=$(openssl rand -hex 32) + +echo "新的Webhook密钥: $WEBHOOK_SECRET" + +# 保存密钥 +echo "WEBHOOK_SECRET=$WEBHOOK_SECRET" | sudo tee "$BACKUP_DIR/webhook-secret.txt" + +# 检查Jenkinsfile中的硬编码token +echo -e "\n检查Jenkinsfile中的硬编码token..." +if [ -f "Jenkinsfile" ]; then + if grep -q "token.*=.*['\"].*['\"]" Jenkinsfile; then + echo "⚠️ 发现硬编码token,需要替换为环境变量:" + grep -n "token.*=.*['\"].*['\"]" Jenkinsfile + + echo -e "\n建议修改为:" + echo "token = env.WEBHOOK_TOKEN" + echo "" + echo "并在Jenkins中配置环境变量 WEBHOOK_TOKEN" + else + echo "✓ 未发现硬编码token" + fi +fi + +# 配置Jenkins环境变量 +echo -e "\n配置Jenkins环境变量..." +echo "请在Jenkins Web UI中配置:" +echo "1. 访问: https://$DOMAIN/jenkins/configure" +echo "2. 找到'全局属性' -> '环境变量'" +echo "3. 添加键值对:" +echo " 键: WEBHOOK_TOKEN" +echo " 值: $WEBHOOK_SECRET" +echo "4. 保存配置" + +echo -e "\n✓ Webhook密钥已生成" +``` + +- [ ] Webhook密钥已生成 +- [ ] Jenkinsfile已检查 +- [ ] 已了解环境变量配置步骤 + +### 4.3 配置IP白名单 + +```bash +echo "=== 配置IP白名单 ===" + +# 获取Gitea服务器IP +echo "请输入Gitea服务器IP地址(用于Webhook白名单):" +read GITEA_IP + +if [ -n "$GITEA_IP" ]; then + # 更新Nginx配置 + NGINX_CONF_FILE="/etc/nginx/conf.d/jenkins-security.conf" + + # 添加IP白名单规则 + sudo sed -i "s|# allow GITEA_SERVER_IP;|allow $GITEA_IP;|g" "$NGINX_CONF_FILE" + sudo sed -i "s|# deny all;|deny all;|g" "$NGINX_CONF_FILE" + + echo "✓ IP白名单已配置: $GITEA_IP" + + # 测试Nginx配置 + sudo nginx -t && sudo systemctl reload nginx +else + echo "⚠️ 未配置IP白名单,Webhook端点将允许所有IP访问" +fi + +# 记录配置 +echo "Gitea IP: $GITEA_IP" | sudo tee -a "$BACKUP_DIR/deployment-info.txt" +``` + +- [ ] Gitea服务器IP已配置 +- [ ] Nginx IP白名单已更新 +- [ ] Nginx配置已重载 + +--- + +## 📊 阶段5:审计监控层(20分钟) + +### 5.1 配置日志轮转 + +```bash +echo "=== 配置日志轮转 ===" + +LOGROTATE_CONF="/etc/logrotate.d/jenkins-security" + +sudo tee "$LOGROTATE_CONF" > /dev/null << 'EOF' +/var/log/nginx/jenkins-*.log { + daily + rotate 90 + compress + delaycompress + missingok + notifempty + create 0640 www-data adm + sharedscripts + postrotate + [ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid` + endscript +} +EOF + +echo "✓ 日志轮转配置已创建: $LOGROTATE_CONF" + +# 测试配置 +sudo logrotate -d "$LOGROTATE_CONF" +``` + +- [ ] 日志轮转配置已创建 +- [ ] 日志轮转配置已测试 + +### 5.2 创建监控脚本 + +```bash +echo "=== 创建监控脚本 ===" + +MONITOR_SCRIPT="/usr/local/bin/monitor-jenkins-security.sh" + +sudo tee "$MONITOR_SCRIPT" > /dev/null << 'EOF' +#!/bin/bash +# Jenkins安全监控脚本 +# 作者: 张翔 +# 用途: 监控Jenkins安全状态 + +LOG_FILE="/var/log/nginx/jenkins-access.log" +ALERT_THRESHOLD=10 + +# 检查失败的认证尝试 +echo "=== 检查失败的认证尝试 ===" +FAILED_AUTH=$(grep " 401 " "$LOG_FILE" | tail -n 100 | awk '{print $1}' | sort | uniq -c | awk -v threshold=$ALERT_THRESHOLD '$1 > threshold {print $1, $2}') + +if [ -n "$FAILED_AUTH" ]; then + echo "⚠️ 检测到多次认证失败的IP:" + echo "$FAILED_AUTH" +else + echo "✓ 未发现异常认证失败" +fi + +# 检查异常请求 +echo -e "\n=== 检查异常请求 ===" +ABNORMAL_REQUESTS=$(grep -E "POST|DELETE|PUT" "$LOG_FILE" | tail -n 100 | grep -v " 200 \| 201 " | awk '{print $1, $7, $9}') + +if [ -n "$ABNORMAL_REQUESTS" ]; then + echo "⚠️ 检测到异常请求:" + echo "$ABNORMAL_REQUESTS" +else + echo "✓ 未发现异常请求" +fi + +# 检查Jenkins服务状态 +echo -e "\n=== 检查Jenkins服务状态 ===" +if systemctl is-active --quiet jenkins; then + echo "✓ Jenkins服务运行正常" +else + echo "❌ Jenkins服务未运行" +fi + +# 检查Nginx服务状态 +echo -e "\n=== 检查Nginx服务状态 ===" +if systemctl is-active --quiet nginx; then + echo "✓ Nginx服务运行正常" +else + echo "❌ Nginx服务未运行" +fi + +# 检查磁盘空间 +echo -e "\n=== 检查磁盘空间 ===" +DISK_USAGE=$(df -h /var | tail -1 | awk '{print $5}' | sed 's/%//') +if [ "$DISK_USAGE" -gt 80 ]; then + echo "⚠️ 磁盘使用率: ${DISK_USAGE}%" +else + echo "✓ 磁盘使用率: ${DISK_USAGE}%" +fi +EOF + +sudo chmod +x "$MONITOR_SCRIPT" + +echo "✓ 监控脚本已创建: $MONITOR_SCRIPT" + +# 运行一次监控 +sudo "$MONITOR_SCRIPT" +``` + +- [ ] 监控脚本已创建 +- [ ] 监控脚本已测试 + +### 5.3 配置定时任务 + +```bash +echo "=== 配置定时任务 ===" + +# 添加到crontab +(crontab -l 2>/dev/null; echo "# Jenkins安全监控 - 每小时执行一次"; echo "0 * * * * $MONITOR_SCRIPT >> /var/log/jenkins-security-monitor.log 2>&1") | crontab - + +# 显示当前crontab +echo "当前定时任务:" +crontab -l + +echo -e "\n✓ 定时任务已配置" +``` + +- [ ] 定时任务已配置 +- [ ] 定时任务已验证 + +--- + +## ✅ 阶段6:验证与测试(30分钟) + +### 6.1 运行安全验证脚本 + +```bash +echo "=== 运行安全验证脚本 ===" + +# 如果已有验证脚本 +if [ -f "/usr/local/bin/verify-jenkins-security.sh" ]; then + sudo /usr/local/bin/verify-jenkins-security.sh +else + echo "⚠️ 验证脚本不存在,执行手动验证" +fi +``` + +- [ ] 安全验证脚本已运行 +- [ ] 所有检查项通过 + +### 6.2 手动验证清单 + +#### 网络层验证 + +```bash +echo "=== 网络层验证 ===" + +# 1. 检查Jenkins监听地址 +echo "1. 检查Jenkins监听地址:" +sudo netstat -tlnp | grep 8080 +# 预期: 127.0.0.1:8080 + +# 2. 尝试外部访问 +echo -e "\n2. 尝试外部访问8080端口:" +curl -I -m 5 http://localhost:8080 2>&1 || echo "✓ 外部访问被阻止" + +# 3. 检查防火墙 +echo -e "\n3. 检查防火墙规则:" +if command -v ufw &> /dev/null; then + sudo ufw status | grep 8080 +elif command -v firewall-cmd &> /dev/null; then + sudo firewall-cmd --list-ports | grep 8080 || echo "✓ 8080端口未开放" +fi +``` + +- [ ] Jenkins仅监听127.0.0.1 +- [ ] 外部访问被阻止 +- [ ] 防火墙规则正确 + +#### 应用层验证 + +```bash +echo "=== 应用层验证 ===" + +# 1. 测试Nginx配置 +echo "1. 测试Nginx配置:" +sudo nginx -t + +# 2. 测试匿名访问 +echo -e "\n2. 测试匿名访问(应返回401):" +curl -I -k https://$DOMAIN/jenkins/ 2>&1 | grep "HTTP" + +# 3. 测试认证访问 +echo -e "\n3. 测试认证访问(应返回200):" +curl -I -k -u "$ADMIN_USER:$JENKINS_PASSWORD" https://$DOMAIN/jenkins/ 2>&1 | grep "HTTP" + +# 4. 测试错误密码 +echo -e "\n4. 测试错误密码(应返回401):" +curl -I -k -u "admin:wrongpassword" https://$DOMAIN/jenkins/ 2>&1 | grep "HTTP" +``` + +- [ ] Nginx配置正确 +- [ ] 匿名访问返回401 +- [ ] 认证访问返回200 +- [ ] 错误密码返回401 + +#### 认证层验证 + +```bash +echo "=== 认证层验证 ===" + +# 1. 测试Webhook签名验证 +echo "1. 测试Webhook签名验证:" +PAYLOAD='{"ref": "refs/heads/release/test"}' +SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}') + +# 无签名请求(应失败) +echo "无签名请求:" +curl -X POST -k "https://$DOMAIN/generic-webhook-trigger/invoke" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" 2>&1 + +# 有签名请求(应成功) +echo -e "\n有签名请求:" +curl -X POST -k "https://$DOMAIN/generic-webhook-trigger/invoke" \ + -H "Content-Type: application/json" \ + -H "X-Gitea-Signature: sha256=$SIGNATURE" \ + -d "$PAYLOAD" 2>&1 + +# 2. 测试IP白名单 +echo -e "\n2. 测试IP白名单:" +echo "请从非白名单IP测试Webhook访问,应被拒绝" +``` + +- [ ] Webhook签名验证生效 +- [ ] IP白名单生效 + +#### 审计层验证 + +```bash +echo "=== 审计层验证 ===" + +# 1. 检查访问日志 +echo "1. 检查访问日志:" +tail -20 /var/log/nginx/jenkins-access.log + +# 2. 检查日志轮转配置 +echo -e "\n2. 检查日志轮转配置:" +cat /etc/logrotate.d/jenkins-security + +# 3. 检查监控脚本 +echo -e "\n3. 检查监控脚本:" +ls -lh /usr/local/bin/monitor-jenkins-security.sh +``` + +- [ ] 访问日志正常记录 +- [ ] 日志轮转配置正确 +- [ ] 监控脚本存在 + +### 6.3 CI/CD验证 + +```bash +echo "=== CI/CD验证 ===" + +# 1. 手动触发Jenkins构建 +echo "1. 手动触发Jenkins构建:" +echo "请访问: https://$DOMAIN/jenkins/" +echo "使用用户名: $ADMIN_USER 和设置的密码登录" +echo "手动触发一个构建任务" + +# 2. 测试Webhook触发 +echo -e "\n2. 测试Webhook触发:" +echo "请在Gitea中推送代码到release分支,验证Webhook是否触发构建" + +# 3. 检查构建日志 +echo -e "\n3. 检查构建日志:" +echo "请检查Jenkins构建日志,确认构建成功" +``` + +- [ ] 手动触发构建成功 +- [ ] Webhook触发构建成功 +- [ ] 构建产物正常部署 + +--- + +## 📝 执行总结 + +### 完成情况 + +```bash +echo "========================================" +echo " Jenkins安全加固执行总结" +echo "========================================" +echo "" +echo "执行时间: $(date '+%Y-%m-%d %H:%M:%S')" +echo "服务器: $SERVER_HOSTNAME ($SERVER_IP)" +echo "域名: $DOMAIN" +echo "" +echo "备份目录: $BACKUP_DIR" +echo "" +echo "重要信息:" +echo "- 管理员用户: $ADMIN_USER" +echo "- Jenkins访问地址: https://$DOMAIN/jenkins/" +echo "- Webhook密钥: 已保存在 $BACKUP_DIR/webhook-secret.txt" +echo "" +echo "后续步骤:" +echo "1. 通过Web UI配置Jenkins安全设置" +echo "2. 在Jenkins中配置环境变量 WEBHOOK_TOKEN" +echo "3. 更新Jenkinsfile中的token配置" +echo "4. 配置SSL证书(如未配置)" +echo "5. 设置定期安全审计" +echo "" +echo "监控命令:" +echo "- 查看访问日志: tail -f /var/log/nginx/jenkins-access.log" +echo "- 运行监控脚本: sudo /usr/local/bin/monitor-jenkins-security.sh" +echo "- 检查服务状态: sudo systemctl status jenkins nginx" +echo "" +echo "========================================" +``` + +### 验收确认 + +- [ ] 所有阶段已完成 +- [ ] 所有验证项通过 +- [ ] CI/CD流水线正常 +- [ ] 文档已更新 +- [ ] 团队已通知 + +--- + +## 🚨 应急回滚 + +如果出现问题,执行以下回滚操作: + +```bash +echo "=== 执行回滚 ===" + +# 1. 恢复Jenkins配置 +sudo cp "$BACKUP_DIR/jenkins-default.bak" /etc/default/jenkins + +# 2. 恢复Nginx配置 +sudo cp -r "$BACKUP_DIR/nginx-conf"/* /etc/nginx/conf.d/ + +# 3. 重启服务 +sudo systemctl restart jenkins +sudo systemctl restart nginx + +# 4. 开放8080端口(仅应急) +sudo ufw allow 8080/tcp + +echo "✓ 回滚完成" +``` + +--- + +**执行状态:** ⏳ 待执行 +**最后更新:** 2026-04-07 diff --git a/docs/security/JENKINS_SECURITY_HARDENING_GUIDE.md b/docs/security/JENKINS_SECURITY_HARDENING_GUIDE.md new file mode 100644 index 0000000..d0215ce --- /dev/null +++ b/docs/security/JENKINS_SECURITY_HARDENING_GUIDE.md @@ -0,0 +1,590 @@ +# Jenkins安全加固完整指南 + +**作者:** 张翔 +**日期:** 2026-04-07 +**版本:** 1.0 +**风险等级:** 🔴 严重 + +--- + +## 📋 目录 + +1. [风险概述](#风险概述) +2. [快速响应](#快速响应) +3. [详细加固步骤](#详细加固步骤) +4. [验证检查清单](#验证检查清单) +5. [应急响应流程](#应急响应流程) +6. [长期维护建议](#长期维护建议) + +--- + +## 🚨 风险概述 + +### 当前风险 + +| 风险项 | 严重程度 | 影响 | 状态 | +|--------|----------|------|------| +| Jenkins暴露在公网8080端口 | 🔴 严重 | 勒索攻击、数据加密 | 待修复 | +| Webhook Token硬编码 | 🔴 严重 | 供应链攻击 | 待修复 | +| 缺少访问认证 | 🔴 严重 | 未授权访问 | 待修复 | +| 无网络隔离 | 🟡 高危 | 直接攻击 | 待修复 | +| 缺少审计日志 | 🟡 高危 | 无法追溯 | 待修复 | + +### 攻击场景 + +1. **勒索软件攻击** + - 黑客利用Jenkins已知漏洞(如CVE-2024-XXXX) + - 加密Jenkins主目录和构建产物 + - 勒索赎金 + +2. **供应链攻击** + - 利用暴露的Webhook Token + - 恶意触发构建 + - 注入恶意代码到生产环境 + +3. **凭证泄露** + - 获取Jenkins存储的密钥 + - 访问生产服务器、数据库 + - 全面接管系统 + +--- + +## ⚡ 快速响应 + +### 立即执行(15分钟内) + +```bash +# 1. 检查Jenkins是否已被攻击 +sudo journalctl -u jenkins --since "1 hour ago" | grep -i "failed\|error\|attack" + +# 2. 临时阻止外部访问8080端口 +sudo ufw deny 8080/tcp +# 或 +sudo firewall-cmd --permanent --remove-port=8080/tcp +sudo firewall-cmd --reload + +# 3. 检查是否有可疑进程 +ps aux | grep -E "jenkins|java" | grep -v grep + +# 4. 备份当前配置 +sudo tar -czf /tmp/jenkins-emergency-backup-$(date +%Y%m%d_%H%M%S).tar.gz \ + /var/lib/jenkins /etc/default/jenkins + +# 5. 修改Jenkins监听地址(临时) +sudo sed -i 's|httpPort=8080|httpPort=8080 --httpListenAddress=127.0.0.1|' \ + /etc/default/jenkins +sudo systemctl restart jenkins +``` + +### 1小时内执行 + +```bash +# 运行完整的安全加固脚本 +cd /path/to/novalon-website/scripts/security +chmod +x jenkins-security-hardening.sh +sudo ./jenkins-security-hardening.sh +``` + +--- + +## 🔧 详细加固步骤 + +### 步骤1:网络层隔离 + +#### 1.1 修改Jenkins监听地址 + +**目标:** Jenkins仅监听127.0.0.1,外部无法直接访问 + +**操作:** + +```bash +# Debian/Ubuntu +sudo vim /etc/default/jenkins + +# 添加或修改以下行 +JENKINS_ARGS="--httpListenAddress=127.0.0.1 --httpPort=8080" + +# RHEL/CentOS +sudo vim /etc/sysconfig/jenkins + +# 修改 +JENKINS_LISTEN_ADDRESS="127.0.0.1" +``` + +**验证:** + +```bash +# 检查监听地址 +sudo netstat -tlnp | grep 8080 +# 应显示:127.0.0.1:8080 + +# 尝试外部访问(应失败) +curl -I http://YOUR_SERVER_IP:8080 +# 应返回:Connection refused +``` + +#### 1.2 配置防火墙 + +**UFW (Ubuntu/Debian):** + +```bash +sudo ufw --force enable +sudo ufw default deny incoming +sudo ufw default allow outgoing +sudo ufw allow 22/tcp comment 'SSH' +sudo ufw allow 80/tcp comment 'HTTP' +sudo ufw allow 443/tcp comment 'HTTPS' +sudo ufw deny 8080/tcp comment 'Jenkins Direct Access' +sudo ufw --force reload +``` + +**Firewalld (RHEL/CentOS):** + +```bash +sudo systemctl start firewalld +sudo systemctl enable firewalld +sudo firewall-cmd --permanent --add-service=ssh +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https +sudo firewall-cmd --permanent --remove-port=8080/tcp +sudo firewall-cmd --reload +``` + +--- + +### 步骤2:应用层防护 + +#### 2.1 配置Nginx反向代理 + +**创建配置文件:** + +```bash +sudo vim /etc/nginx/conf.d/jenkins-security.conf +``` + +**配置内容:**(见脚本生成的配置) + +**关键安全配置:** + +```nginx +# 频率限制 +limit_req_zone $binary_remote_addr zone=jenkins_limit:10m rate=10r/m; + +# 安全响应头 +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; + +# 客户端限制 +client_max_body_size 100m; +client_body_timeout 60s; +``` + +#### 2.2 配置HTTP Basic Auth + +```bash +# 生成密码文件 +sudo htpasswd -c /etc/nginx/conf.d/.jenkins-htpasswd admin + +# 或使用openssl +sudo openssl passwd -apr1 YOUR_PASSWORD | \ + sed "s|^|admin:|" | \ + sudo tee /etc/nginx/conf.d/.jenkins-htpasswd + +# 设置权限 +sudo chmod 600 /etc/nginx/conf.d/.jenkins-htpasswd +sudo chown www-data:www-data /etc/nginx/conf.d/.jenkins-htpasswd +``` + +--- + +### 步骤3:认证授权层 + +#### 3.1 配置Jenkins安全设置 + +**禁用匿名访问:** + +```bash +# 方法1:通过Jenkins UI +# 访问:https://your-domain.com/jenkins/configureSecurity +# 设置:授权策略 -> 安全矩阵 -> 取消匿名用户的所有权限 + +# 方法2:通过配置文件 +sudo vim /var/lib/jenkins/config.xml +``` + +```xml +true + + true + +``` + +#### 3.2 Webhook签名验证 + +**Gitea Webhook配置:** + +1. 进入Gitea仓库设置 -> Webhooks +2. 添加Webhook: + - 目标URL:`https://your-domain.com/generic-webhook-trigger/invoke` + - HTTP方法:POST + - 触发条件:Push events + - **启用签名验证** + - 签名密钥:使用生成的`WEBHOOK_SECRET` + +**Nginx验证配置:** + +```nginx +location ~ ^/generic-webhook-trigger(/.*)?$ { + # IP白名单 + allow YOUR_GITEA_SERVER_IP; + deny all; + + # 验证签名头 + if ($http_x_gitea_signature = "") { + return 403; + } + + proxy_pass http://jenkins_backend; +} +``` + +--- + +### 步骤4:审计监控层 + +#### 4.1 配置审计日志 + +**Nginx日志格式:** + +```nginx +log_format jenkins_security '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'request_time=$request_time ' + 'ssl_protocol=$ssl_protocol'; + +access_log /var/log/nginx/jenkins-access.log jenkins_security; +``` + +#### 4.2 日志轮转 + +```bash +sudo vim /etc/logrotate.d/jenkins-security +``` + +``` +/var/log/nginx/jenkins-*.log { + daily + rotate 90 + compress + delaycompress + missingok + notifempty + create 0640 www-data adm + sharedscripts + postrotate + [ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid` + endscript +} +``` + +#### 4.3 监控脚本 + +```bash +# 创建监控脚本 +sudo vim /usr/local/bin/monitor-jenkins-security.sh +``` + +```bash +#!/bin/bash +# 监控异常访问 + +# 检查失败的认证尝试 +FAILED_AUTH=$(grep "401" /var/log/nginx/jenkins-access.log | \ + tail -n 100 | \ + awk '{print $1}' | \ + sort | uniq -c | \ + awk '$1 > 10 {print $2}') + +if [ -n "$FAILED_AUTH" ]; then + echo "警告:检测到多次认证失败的IP:" + echo "$FAILED_AUTH" + # 可以添加自动封禁逻辑 +fi + +# 检查异常请求 +grep -E "POST|DELETE|PUT" /var/log/nginx/jenkins-access.log | \ + tail -n 100 | \ + grep -v "200\|201" | \ + awk '{print $1, $7, $9}' +``` + +--- + +## ✅ 验证检查清单 + +### 自动验证 + +```bash +# 运行验证脚本 +sudo /usr/local/bin/verify-jenkins-security.sh +``` + +### 手动验证清单 + +- [ ] **网络层** + - [ ] Jenkins仅监听127.0.0.1:8080 + - [ ] 防火墙已阻止8080端口 + - [ ] 仅允许Nginx代理访问 + +- [ ] **应用层** + - [ ] Nginx配置语法正确 + - [ ] HTTPS强制重定向 + - [ ] 安全响应头已配置 + - [ ] 频率限制生效 + +- [ ] **认证层** + - [ ] HTTP Basic Auth已启用 + - [ ] 匿名访问已禁用 + - [ ] Webhook签名验证已启用 + - [ ] IP白名单已配置 + +- [ ] **审计层** + - [ ] 访问日志正常记录 + - [ ] 日志轮转已配置 + - [ ] 监控脚本运行正常 + +- [ ] **配置安全** + - [ ] Jenkinsfile中无硬编码token + - [ ] 敏感信息已移至环境变量 + - [ ] Jenkins Credentials已配置 + +### 渗透测试 + +```bash +# 1. 尝试直接访问Jenkins(应失败) +curl -I http://YOUR_SERVER_IP:8080 + +# 2. 尝试匿名访问(应返回401) +curl -I https://your-domain.com/jenkins/ + +# 3. 使用错误密码(应返回401) +curl -I -u admin:wrongpassword https://your-domain.com/jenkins/ + +# 4. 测试频率限制 +for i in {1..20}; do + curl -I https://your-domain.com/jenkins/ & +done + +# 5. 测试Webhook签名验证 +curl -X POST https://your-domain.com/generic-webhook-trigger/invoke \ + -H "Content-Type: application/json" \ + -d '{"test": "data"}' +# 应返回403 + +# 6. 使用正确签名 +PAYLOAD='{"ref": "refs/heads/release/test"}' +SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}') +curl -X POST https://your-domain.com/generic-webhook-trigger/invoke \ + -H "Content-Type: application/json" \ + -H "X-Gitea-Signature: sha256=$SIGNATURE" \ + -d "$PAYLOAD" +``` + +--- + +## 🚨 应急响应流程 + +### 检测到攻击时的响应 + +#### Level 1:可疑活动 + +**触发条件:** +- 多次认证失败(>10次/分钟) +- 异常请求模式 +- 非白名单IP访问Webhook + +**响应措施:** + +```bash +# 1. 记录事件 +echo "$(date): 可疑活动检测 - IP: $ATTACKER_IP" >> /var/log/jenkins-security-events.log + +# 2. 临时封禁IP +sudo ufw deny from $ATTACKER_IP + +# 3. 通知管理员 +./scripts/notify-wechat.sh "安全警告:检测到可疑访问 - IP: $ATTACKER_IP" +``` + +#### Level 2:确认攻击 + +**触发条件:** +- 成功利用漏洞 +- 恶意代码注入 +- 数据泄露迹象 + +**响应措施:** + +```bash +# 1. 立即隔离 +sudo systemctl stop jenkins +sudo ufw deny 443/tcp + +# 2. 保存证据 +sudo tar -czf /tmp/incident-$(date +%Y%m%d_%H%M%S).tar.gz \ + /var/lib/jenkins \ + /var/log/nginx/jenkins-*.log \ + /var/log/jenkins-security-events.log + +# 3. 检查完整性 +find /var/lib/jenkins -type f -mtime -1 -ls + +# 4. 通知管理层 +./scripts/notify-wechat.sh "严重安全事件:Jenkins遭受攻击,已隔离系统" +``` + +#### Level 3:数据泄露 + +**触发条件:** +- 凭证被窃取 +- 生产数据泄露 +- 系统被完全控制 + +**响应措施:** + +```bash +# 1. 完全断网 +sudo ifdown eth0 + +# 2. 备份现场 +sudo dd if=/dev/sda of=/backup/incident-disk-image.img + +# 3. 更换所有凭证 +# - Jenkins管理员密码 +# - Webhook Token +# - SSH密钥 +# - 数据库密码 +# - API密钥 + +# 4. 通知所有相关方 +# - 管理层 +# - 安全团队 +# - 客户(如涉及客户数据) + +# 5. 启动事件响应计划 +``` + +### 恢复流程 + +```bash +# 1. 从干净备份恢复 +sudo rm -rf /var/lib/jenkins +sudo tar -xzf /backup/jenkins-clean-backup.tar.gz -C / + +# 2. 应用所有安全补丁 +sudo apt update && sudo apt upgrade -y + +# 3. 重新配置安全设置 +sudo ./scripts/security/jenkins-security-hardening.sh + +# 4. 全面验证 +sudo /usr/local/bin/verify-jenkins-security.sh + +# 5. 逐步恢复服务 +sudo systemctl start jenkins +# 监控日志 +tail -f /var/log/nginx/jenkins-access.log +``` + +--- + +## 📊 长期维护建议 + +### 定期安全审计 + +**每日:** +- 检查访问日志异常 +- 监控失败认证次数 +- 检查系统资源使用 + +**每周:** +- 审查用户权限 +- 检查插件更新 +- 分析安全日志 + +**每月:** +- 更新Jenkins和插件 +- 更换敏感凭证 +- 进行渗透测试 + +**每季度:** +- 全面安全评估 +- 灾难恢复演练 +- 安全培训 + +### 自动化监控 + +```bash +# 添加到crontab +crontab -e +``` + +```cron +# 每小时检查异常访问 +0 * * * * /usr/local/bin/monitor-jenkins-security.sh + +# 每天备份配置 +0 2 * * * tar -czf /backup/jenkins-config-$(date +\%Y\%m\%d).tar.gz /var/lib/jenkins + +# 每周更新检查 +0 3 * * 0 apt update && apt list --upgradable | grep jenkins + +# 每月更换Webhook Token +0 4 1 * * /usr/local/bin/rotate-jenkins-secrets.sh +``` + +### 安全改进路线图 + +**Phase 1(当前):基础防护** +- ✅ 网络隔离 +- ✅ HTTP Basic Auth +- ✅ Webhook签名验证 + +**Phase 2(1个月内):增强认证** +- 🔲 集成OAuth2/OIDC +- 🔲 多因素认证(MFA) +- 🔲 细粒度权限控制 + +**Phase 3(3个月内):高级防护** +- 🔲 Web应用防火墙(WAF) +- 🔲 入侵检测系统(IDS) +- 🔲 安全信息和事件管理(SIEM) + +**Phase 4(6个月内):零信任架构** +- 🔲 零信任网络访问(ZTNA) +- 🔲 微服务隔离 +- 🔲 持续安全验证 + +--- + +## 📞 联系方式 + +**安全负责人:** 张翔 +**应急响应:** security@your-domain.com +**技术支持:** devops@your-domain.com + +--- + +## 📚 参考资料 + +- [Jenkins Security Best Practices](https://www.jenkins.io/doc/book/security/) +- [OWASP CI/CD Security Guide](https://owasp.org/www-project-devsecops-guideline/) +- [NIST Cybersecurity Framework](https://www.nist.gov/cyberframework) +- [Jenkins Security Advisory](https://www.jenkins.io/security/advisories/) + +--- + +**最后更新:** 2026-04-07 +**文档版本:** 1.0 diff --git a/fix-jenkins-nginx.sh b/fix-jenkins-nginx.sh new file mode 100755 index 0000000..31cdbef --- /dev/null +++ b/fix-jenkins-nginx.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# 修复Jenkins Nginx配置 +cat > /tmp/jenkins-nginx-fix.conf << 'EOF' + # Jenkins CI/CD Server + server { + listen 80; + server_name ci.f.novalon.cn; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl http2; + server_name ci.f.novalon.cn; + + ssl_certificate /etc/nginx/ssl/ci.f.novalon.cn/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/ci.f.novalon.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Jenkins webhook端点 - 不需要/jenkins前缀 + location /generic-webhook-trigger/ { + proxy_pass http://172.17.0.1:8080/generic-webhook-trigger/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + client_max_body_size 100m; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Jenkins主应用 + location /jenkins/ { + proxy_pass http://172.17.0.1:8080/jenkins/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + client_max_body_size 100m; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # 默认location - 重定向到/jenkins/ + location / { + return 301 https://$host/jenkins/; + } + + access_log /var/log/nginx/jenkins-access.log; + error_log /var/log/nginx/jenkins-error.log; + } +EOF + +echo "Jenkins Nginx配置已生成" diff --git a/jenkins-job-config-poll.xml b/jenkins-job-config-poll.xml new file mode 100644 index 0000000..e734483 --- /dev/null +++ b/jenkins-job-config-poll.xml @@ -0,0 +1,39 @@ + + + novalon-website CI/CD Pipeline + false + + + false + + + + + H/5 * * * * + false + + + + + + + 2 + + + git@gitea.novalon.cn:novalon/novalon-website.git + + + + + */release/* + + + false + + + + Jenkinsfile + true + + false + \ No newline at end of file diff --git a/jenkins-job-config-webhook.xml b/jenkins-job-config-webhook.xml new file mode 100644 index 0000000..dc59c7a --- /dev/null +++ b/jenkins-job-config-webhook.xml @@ -0,0 +1,62 @@ + + + novalon-website CI/CD Pipeline + false + + + false + + + + + + $ref + ^refs/heads/release/.*$ + + + X-Gitea-Event + + + + + + ref + + + + repository.name + + + + true + true + Gitea Webhook Trigger: $ref + novalon-website-webhook-token-2024 + false + false + + + + + + + 2 + + + git@gitea.novalon.cn:novalon/novalon-website.git + + + + + */release/* + + + false + + + + Jenkinsfile + true + + false + \ No newline at end of file diff --git a/jenkins-job-config.xml b/jenkins-job-config.xml new file mode 100644 index 0000000..acee715 --- /dev/null +++ b/jenkins-job-config.xml @@ -0,0 +1,63 @@ + + + + novalon-website CI/CD Pipeline + false + + + false + + + + + + $ref + ^refs/heads/release/.*$ + + + X-Gitea-Event + + + + + + ref + + + + repository.name + + + + true + true + Gitea Webhook Trigger: $ref + novalon-website-webhook-token-2024 + false + false + + + + + + + 2 + + + git@gitea.novalon.cn:novalon/novalon-website.git + + + + + */release/* + + + false + + + + Jenkinsfile + true + + false + diff --git a/scripts/security/.env.jenkins.example b/scripts/security/.env.jenkins.example new file mode 100644 index 0000000..14ef78d --- /dev/null +++ b/scripts/security/.env.jenkins.example @@ -0,0 +1,77 @@ +# Jenkins安全配置环境变量示例 +# 作者:张翔 +# 日期:2026-04-07 +# 说明:复制此文件为 .env.jenkins.production 并填入实际值 + +# ============================================ +# Jenkins访问控制 +# ============================================ + +# Jenkins管理员用户名 +JENKINS_ADMIN_USER=admin + +# Jenkins管理员密码(请使用强密码) +# 生成方法:openssl rand -base64 32 +JENKINS_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD_HERE + +# ============================================ +# Webhook安全配置 +# ============================================ + +# Webhook Token(用于Generic Webhook Trigger) +# 生成方法:openssl rand -hex 32 +JENKINS_WEBHOOK_TOKEN=CHANGE_ME_RANDOM_TOKEN_HERE + +# Webhook签名密钥(用于验证Gitea请求) +# 生成方法:openssl rand -hex 32 +WEBHOOK_SECRET=CHANGE_ME_WEBHOOK_SECRET_HERE + +# ============================================ +# 网络安全配置 +# ============================================ + +# 允许访问Webhook的IP地址(逗号分隔) +# 示例:192.168.1.100,10.0.0.50 +ALLOWED_IPS=127.0.0.1 + +# Jenkins域名 +DOMAIN=your-domain.com + +# ============================================ +# SSL/TLS配置 +# ============================================ + +# SSL证书路径 +SSL_CERT_PATH=/etc/letsencrypt/live/your-domain.com/fullchain.pem +SSL_KEY_PATH=/etc/letsencrypt/live/your-domain.com/privkey.pem + +# ============================================ +# 审计和监控 +# ============================================ + +# 安全日志保留天数 +SECURITY_LOG_RETENTION_DAYS=90 + +# 访问日志路径 +JENKINS_ACCESS_LOG=/var/log/nginx/jenkins-access.log +JENKINS_ERROR_LOG=/var/log/nginx/jenkins-error.log + +# ============================================ +# 频率限制 +# ============================================ + +# 每分钟最大请求数 +RATE_LIMIT_REQUESTS=10 + +# 并发连接数限制 +CONNECTION_LIMIT=10 + +# ============================================ +# 备份配置 +# ============================================ + +# 备份目录 +BACKUP_DIR=/backup/jenkins + +# 备份保留天数 +BACKUP_RETENTION_DAYS=30 diff --git a/scripts/security/README.md b/scripts/security/README.md new file mode 100644 index 0000000..2d7adb0 --- /dev/null +++ b/scripts/security/README.md @@ -0,0 +1,371 @@ +# Jenkins安全加固快速部署指南 + +**作者:** 张翔 +**日期:** 2026-04-07 +**紧急程度:** 🔴 立即执行 + +--- + +## ⚡ 5分钟快速响应 + +### 情况紧急?立即执行以下命令 + +```bash +# 1. 阻止外部访问8080端口 +sudo ufw deny 8080/tcp && sudo ufw --force reload + +# 2. 修改Jenkins监听地址 +sudo sed -i 's|httpPort=8080|httpPort=8080 --httpListenAddress=127.0.0.1|' /etc/default/jenkins +sudo systemctl restart jenkins + +# 3. 验证 +sudo netstat -tlnp | grep 8080 +# 应显示:127.0.0.1:8080 +``` + +--- + +## 📋 完整部署流程(30分钟) + +### 前置准备 + +```bash +# 1. 克隆或进入项目目录 +cd /path/to/novalon-website + +# 2. 检查当前状态 +sudo netstat -tlnp | grep 8080 +curl -I http://localhost:8080 +``` + +### 步骤1:配置环境变量 + +```bash +# 1. 复制环境变量模板 +cp scripts/security/.env.jenkins.example scripts/security/.env.jenkins.production + +# 2. 编辑配置文件 +vim scripts/security/.env.jenkins.production + +# 3. 生成随机密钥 +# Webhook Token +openssl rand -hex 32 +# 将输出复制到 JENKINS_WEBHOOK_TOKEN + +# Webhook Secret +openssl rand -hex 32 +# 将输出复制到 WEBHOOK_SECRET + +# 管理员密码 +openssl rand -base64 32 +# 将输出复制到 JENKINS_ADMIN_PASSWORD +``` + +### 步骤2:配置Jenkins Credentials + +```bash +# 方法1:通过Jenkins UI +# 访问:https://your-domain.com/jenkins/credentials/store/system/domain/_/ +# 添加Secret text: +# ID: jenkins-webhook-token +# Secret: [步骤1生成的token] + +# 方法2:通过Jenkins CLI +java -jar jenkins-cli.jar -s http://localhost:8080/ create-credentials-by-xml system::system::jenkins << EOF + + GLOBAL + jenkins-webhook-token + Jenkins Webhook Token + + ${JENKINS_WEBHOOK_TOKEN} + +EOF +``` + +### 步骤3:运行安全加固脚本 + +```bash +# 1. 设置权限 +chmod +x scripts/security/jenkins-security-hardening.sh + +# 2. 加载环境变量 +export $(cat scripts/security/.env.jenkins.production | xargs) + +# 3. 运行脚本 +sudo -E ./scripts/security/jenkins-security-hardening.sh + +# 按照提示输入: +# - 管理员密码 +# - 是否立即重启服务 +``` + +### 步骤4:配置SSL证书(如未配置) + +```bash +# 使用Let's Encrypt +sudo apt install certbot python3-certbot-nginx +sudo certbot --nginx -d your-domain.com + +# 或使用已有证书 +sudo mkdir -p /etc/letsencrypt/live/your-domain.com +sudo cp your-cert.pem /etc/letsencrypt/live/your-domain.com/fullchain.pem +sudo cp your-key.pem /etc/letsencrypt/live/your-domain.com/privkey.pem +``` + +### 步骤5:配置Gitea Webhook + +```bash +# 1. 进入Gitea仓库设置 +# Settings -> Webhooks -> Add Webhook + +# 2. 配置Webhook +# 目标URL: https://your-domain.com/generic-webhook-trigger/invoke +# HTTP方法: POST +# 触发条件: Push events +# 启用签名验证: 是 +# 签名密钥: [步骤1生成的WEBHOOK_SECRET] + +# 3. 测试Webhook +# 点击"Test Delivery"按钮 +``` + +### 步骤6:验证安全配置 + +```bash +# 1. 运行自动验证 +sudo /usr/local/bin/verify-jenkins-security.sh + +# 2. 手动测试 +# 测试1:直接访问8080端口(应失败) +curl -I http://YOUR_SERVER_IP:8080 + +# 测试2:匿名访问(应返回401) +curl -I https://your-domain.com/jenkins/ + +# 测试3:认证访问(应成功) +curl -I -u admin:YOUR_PASSWORD https://your-domain.com/jenkins/ + +# 测试4:Webhook签名验证 +PAYLOAD='{"ref": "refs/heads/release/test"}' +SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}') +curl -X POST https://your-domain.com/generic-webhook-trigger/invoke \ + -H "Content-Type: application/json" \ + -H "X-Gitea-Signature: sha256=$SIGNATURE" \ + -d "$PAYLOAD" +``` + +--- + +## 📁 文件清单 + +``` +scripts/security/ +├── jenkins-security-hardening.sh # 主加固脚本 +├── .env.jenkins.example # 环境变量模板 +└── README.md # 本文档 + +docs/security/ +└── JENKINS_SECURITY_HARDENING_GUIDE.md # 详细安全指南 + +Jenkinsfile # 已更新(移除硬编码token) +``` + +--- + +## 🔍 验证检查清单 + +执行以下命令确认所有配置正确: + +```bash +# ✅ Jenkins仅监听127.0.0.1 +sudo netstat -tlnp | grep 8080 +# 预期:127.0.0.1:8080 + +# ✅ 防火墙已阻止8080 +sudo ufw status | grep 8080 +# 预期:8080/tcp DENY + +# ✅ Nginx配置正确 +sudo nginx -t +# 预期:test is successful + +# ✅ HTTP Basic Auth已配置 +ls -la /etc/nginx/conf.d/.jenkins-htpasswd +# 预期:文件存在且权限为600 + +# ✅ Jenkinsfile无硬编码token +grep -r "token.*=.*['\"].*['\"]" Jenkinsfile +# 预期:无输出 + +# ✅ SSL证书有效 +openssl s_client -connect your-domain.com:443 -servername your-domain.com 2>/dev/null | openssl x509 -noout -dates +# 预期:显示证书有效期 + +# ✅ 服务运行正常 +sudo systemctl status jenkins nginx +# 预期:active (running) +``` + +--- + +## 🚨 常见问题 + +### Q1: 脚本执行失败 + +**问题:** `permission denied` + +**解决:** +```bash +chmod +x scripts/security/jenkins-security-hardening.sh +sudo ./scripts/security/jenkins-security-hardening.sh +``` + +### Q2: Jenkins无法启动 + +**问题:** 修改监听地址后Jenkins无法启动 + +**解决:** +```bash +# 检查配置文件 +cat /etc/default/jenkins | grep JENKINS_ARGS + +# 恢复备份 +sudo cp /tmp/jenkins-security-backup-*/jenkins-default.bak /etc/default/jenkins +sudo systemctl restart jenkins +``` + +### Q3: Nginx配置错误 + +**问题:** `nginx: [emerg] unknown directive` + +**解决:** +```bash +# 检查Nginx版本 +nginx -v + +# 确保版本 >= 1.18 +sudo apt update && sudo apt upgrade nginx + +# 验证配置 +sudo nginx -t +``` + +### Q4: Webhook触发失败 + +**问题:** Webhook返回403 + +**解决:** +```bash +# 检查IP白名单 +grep "allow" /etc/nginx/conf.d/jenkins-security.conf + +# 检查签名验证 +# 确保Gitea配置的签名密钥与WEBHOOK_SECRET一致 + +# 查看Nginx错误日志 +tail -f /var/log/nginx/jenkins-error.log +``` + +### Q5: 认证失败 + +**问题:** HTTP Basic Auth无法登录 + +**解决:** +```bash +# 重新生成密码文件 +sudo htpasswd -c /etc/nginx/conf.d/.jenkins-htpasswd admin + +# 重启Nginx +sudo systemctl restart nginx +``` + +--- + +## 📊 安全监控 + +### 设置定时监控 + +```bash +# 添加到crontab +crontab -e +``` + +```cron +# 每小时检查异常访问 +0 * * * * /usr/local/bin/monitor-jenkins-security.sh >> /var/log/jenkins-security-monitor.log 2>&1 + +# 每天备份配置 +0 2 * * * tar -czf /backup/jenkins-config-$(date +\%Y\%m\%d).tar.gz /var/lib/jenkins + +# 每周发送安全报告 +0 9 * * 1 /usr/local/bin/jenkins-security-report.sh | mail -s "Jenkins Security Report" admin@your-domain.com +``` + +### 查看实时日志 + +```bash +# 监控访问日志 +tail -f /var/log/nginx/jenkins-access.log + +# 监控错误日志 +tail -f /var/log/nginx/jenkins-error.log + +# 监控Jenkins日志 +sudo journalctl -u jenkins -f +``` + +--- + +## 🔄 回滚方案 + +如果出现问题,可以快速回滚: + +```bash +# 1. 恢复Jenkins配置 +sudo cp /tmp/jenkins-security-backup-*/jenkins-default.bak /etc/default/jenkins + +# 2. 恢复Nginx配置 +sudo rm /etc/nginx/conf.d/jenkins-security.conf +sudo cp -r /tmp/jenkins-security-backup-*/nginx-conf/* /etc/nginx/conf.d/ + +# 3. 重启服务 +sudo systemctl restart jenkins nginx + +# 4. 恢复防火墙规则 +sudo ufw allow 8080/tcp +sudo ufw --force reload +``` + +--- + +## 📞 获取帮助 + +**文档:** +- [完整安全指南](./JENKINS_SECURITY_HARDENING_GUIDE.md) +- [Jenkins官方安全文档](https://www.jenkins.io/doc/book/security/) + +**应急联系:** +- 安全负责人:张翔 +- 技术支持:devops@your-domain.com + +--- + +## ✅ 部署后确认 + +完成所有步骤后,确认以下事项: + +- [ ] Jenkins仅监听127.0.0.1:8080 +- [ ] 防火墙已阻止外部访问8080 +- [ ] Nginx反向代理正常工作 +- [ ] HTTP Basic Auth认证生效 +- [ ] Webhook签名验证通过 +- [ ] SSL证书有效 +- [ ] 所有日志正常记录 +- [ ] 监控脚本运行正常 +- [ ] 备份策略已配置 +- [ ] 团队成员已通知 + +--- + +**最后更新:** 2026-04-07 +**文档版本:** 1.0 diff --git a/scripts/security/jenkins-security-hardening.sh b/scripts/security/jenkins-security-hardening.sh new file mode 100644 index 0000000..f9c7818 --- /dev/null +++ b/scripts/security/jenkins-security-hardening.sh @@ -0,0 +1,544 @@ +#!/bin/bash + +# Jenkins生产环境安全加固脚本 +# 作者:张翔 +# 日期:2026-04-07 +# 版本:1.0 +# 用途:系统性解决Jenkins暴露在公网8080端口的安全风险 + +set -euo pipefail + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# 日志函数 +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_step() { + echo -e "${BLUE}[STEP]${NC} $1" +} + +# 配置参数 +JENKINS_HOME="${JENKINS_HOME:-/var/lib/jenkins}" +NGINX_CONF_DIR="${NGINX_CONF_DIR:-/etc/nginx/conf.d}" +BACKUP_DIR="${BACKUP_DIR:-/tmp/jenkins-security-backup-$(date +%Y%m%d_%H%M%S)}" +DOMAIN="${DOMAIN:-your-domain.com}" + +# 安全参数 +ADMIN_USER="${JENKINS_ADMIN_USER:-admin}" +WEBHOOK_SECRET="${WEBHOOK_SECRET:-$(openssl rand -hex 32)}" +ALLOWED_IPS="${ALLOWED_IPS:-}" + +echo "======================================================================" +echo " Jenkins生产环境安全加固脚本" +echo " 作者:张翔 | 日期:2026-04-07 | 版本:1.0" +echo "======================================================================" +echo "" + +# 前置检查 +log_step "执行前置检查..." + +if [ "$EUID" -ne 0 ]; then + log_error "请使用root权限运行此脚本" + exit 1 +fi + +if ! command -v nginx &> /dev/null; then + log_error "Nginx未安装,请先安装Nginx" + exit 1 +fi + +if ! command -v openssl &> /dev/null; then + log_error "OpenSSL未安装" + exit 1 +fi + +log_info "前置检查通过" + +# 创建备份目录 +log_step "创建备份目录..." +mkdir -p "$BACKUP_DIR" +log_info "备份目录:$BACKUP_DIR" + +# 备份现有配置 +log_step "备份现有配置..." +if [ -d "$JENKINS_HOME" ]; then + cp -r "$JENKINS_HOME" "$BACKUP_DIR/jenkins-home" 2>/dev/null || true +fi +if [ -d "$NGINX_CONF_DIR" ]; then + cp -r "$NGINX_CONF_DIR" "$BACKUP_DIR/nginx-conf" 2>/dev/null || true +fi +log_info "配置已备份" + +# 步骤1:修改Jenkins监听地址 +log_step "步骤1/7:修改Jenkins监听地址为127.0.0.1..." + +if [ -f "/etc/default/jenkins" ]; then + JENKINS_DEFAULT="/etc/default/jenkins" +elif [ -f "/etc/sysconfig/jenkins" ]; then + JENKINS_DEFAULT="/etc/sysconfig/jenkins" +else + log_warn "未找到Jenkins配置文件,跳过此步骤" + JENKINS_DEFAULT="" +fi + +if [ -n "$JENKINS_DEFAULT" ]; then + cp "$JENKINS_DEFAULT" "$BACKUP_DIR/jenkins-default.bak" + + if grep -q "JENKINS_ARGS" "$JENKINS_DEFAULT"; then + if grep -q "httpListenAddress" "$JENKINS_DEFAULT"; then + sed -i 's/httpListenAddress=[^ ]*/httpListenAddress=127.0.0.1/' "$JENKINS_DEFAULT" + else + sed -i '/JENKINS_ARGS=/ s/"$/ --httpListenAddress=127.0.0.1"/' "$JENKINS_DEFAULT" + fi + else + echo 'JENKINS_ARGS="--httpListenAddress=127.0.0.1"' >> "$JENKINS_DEFAULT" + fi + + log_info "Jenkins配置已更新,仅监听127.0.0.1" +fi + +# 步骤2:生成HTTP Basic Auth密码 +log_step "步骤2/7:生成HTTP Basic Auth密码..." + +read -sp "请输入Jenkins访问密码: " JENKINS_PASSWORD +echo "" +read -sp "请再次确认密码: " JENKINS_PASSWORD_CONFIRM +echo "" + +if [ "$JENKINS_PASSWORD" != "$JENKINS_PASSWORD_CONFIRM" ]; then + log_error "两次密码输入不一致" + exit 1 +fi + +if [ -z "$JENKINS_PASSWORD" ]; then + log_error "密码不能为空" + exit 1 +fi + +HTPASSWD_FILE="$NGINX_CONF_DIR/.jenkins-htpasswd" +htpasswd -bc "$HTPASSWD_FILE" "$ADMIN_USER" "$JENKINS_PASSWORD" 2>/dev/null || \ + openssl passwd -apr1 "$JENKINS_PASSWORD" | sed "s|^|$ADMIN_USER:|" > "$HTPASSWD_FILE" + +chmod 600 "$HTPASSWD_FILE" +log_info "HTTP Basic Auth密码文件已生成:$HTPASSWD_FILE" + +# 步骤3:创建Nginx安全配置 +log_step "步骤3/7:创建Nginx反向代理安全配置..." + +NGINX_JENKINS_CONF="$NGINX_CONF_DIR/jenkins-security.conf" + +cat > "$NGINX_JENKINS_CONF" << 'NGINX_CONF_EOF' +# Jenkins安全反向代理配置 +# 作者:张翔 +# 日期:2026-04-07 +# 说明:多层安全防护 - 认证、频率限制、IP白名单、审计日志 + +# 上游Jenkins服务 +upstream jenkins_backend { + server 127.0.0.1:8080; + keepalive 32; +} + +# 频率限制区域 +limit_req_zone $binary_remote_addr zone=jenkins_limit:10m rate=10r/m; +limit_conn_zone $binary_remote_addr zone=jenkins_conn:10m; + +# 日志格式(包含安全审计信息) +log_format jenkins_security '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'request_time=$request_time ' + 'upstream_response_time=$upstream_response_time ' + 'ssl_protocol=$ssl_protocol ' + 'ssl_cipher=$ssl_cipher'; + +# HTTP重定向到HTTPS +server { + listen 80; + server_name DOMAIN_PLACEHOLDER; + + # Let's Encrypt验证路径 + location ^~ /.well-known/acme-challenge/ { + default_type "text/plain"; + root /var/www/letsencrypt; + } + + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS主配置 +server { + listen 443 ssl http2; + server_name DOMAIN_PLACEHOLDER; + + # SSL配置 + ssl_certificate /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全响应头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # 访问日志 + access_log /var/log/nginx/jenkins-access.log jenkins_security; + error_log /var/log/nginx/jenkins-error.log warn; + + # 频率限制 + limit_req zone=jenkins_limit burst=20 nodelay; + limit_conn jenkins_conn 10; + + # 客户端请求限制 + client_max_body_size 100m; + client_body_timeout 60s; + client_header_timeout 60s; + + # Webhook端点(IP白名单 + 签名验证) + location ~ ^/generic-webhook-trigger(/.*)?$ { + # IP白名单(仅允许Gitea服务器) + # ALLOWED_IPS_PLACEHOLDER + + # 验证Webhook签名 + # if ($http_x_gitea_signature = "") { + # return 403; + # } + + # 代理到Jenkins + proxy_pass http://jenkins_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Jenkins主界面(需要认证) + location /jenkins/ { + # HTTP Basic Auth + auth_basic "Jenkins Production Access"; + auth_basic_user_file HTPASSWD_FILE_PLACEHOLDER; + + # 代理到Jenkins + proxy_pass http://jenkins_backend/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # WebSocket支持 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # 默认拒绝其他路径 + location / { + return 404; + } +} +NGINX_CONF_EOF + +# 替换占位符 +sed -i "s|DOMAIN_PLACEHOLDER|$DOMAIN|g" "$NGINX_JENKINS_CONF" +sed -i "s|HTPASSWD_FILE_PLACEHOLDER|$HTPASSWD_FILE|g" "$NGINX_JENKINS_CONF" + +# 添加IP白名单 +if [ -n "$ALLOWED_IPS" ]; then + IP_ALLOW_RULE="allow $ALLOWED_IPS; deny all;" + sed -i "s|# ALLOWED_IPS_PLACEHOLDER|$IP_ALLOW_RULE|g" "$NGINX_JENKINS_CONF" +fi + +log_info "Nginx安全配置已创建:$NGINX_JENKINS_CONF" + +# 步骤4:配置防火墙规则 +log_step "步骤4/7:配置防火墙规则..." + +if command -v ufw &> /dev/null; then + ufw --force enable + ufw default deny incoming + ufw default allow outgoing + ufw allow 22/tcp comment 'SSH' + ufw allow 80/tcp comment 'HTTP' + ufw allow 443/tcp comment 'HTTPS' + ufw deny 8080/tcp comment 'Jenkins Direct Access Blocked' + ufw --force reload + log_info "UFW防火墙规则已配置" +elif command -v firewall-cmd &> /dev/null; then + systemctl start firewalld + systemctl enable firewalld + firewall-cmd --permanent --add-service=ssh + firewall-cmd --permanent --add-service=http + firewall-cmd --permanent --add-service=https + firewall-cmd --permanent --remove-port=8080/tcp + firewall-cmd --reload + log_info "Firewalld防火墙规则已配置" +else + log_warn "未检测到防火墙,请手动配置iptables规则" +fi + +# 步骤5:创建Webhook签名验证脚本 +log_step "步骤5/7:创建Webhook签名验证脚本..." + +WEBHOOK_VERIFY_SCRIPT="/usr/local/bin/verify-jenkins-webhook.sh" + +cat > "$WEBHOOK_VERIFY_SCRIPT" << 'WEBHOOK_EOF' +#!/bin/bash +# Webhook签名验证脚本 +# 用途:验证来自Gitea的Webhook请求签名 + +set -euo pipefail + +WEBHOOK_SECRET="${WEBHOOK_SECRET:-}" +PAYLOAD_FILE="${1:-/dev/stdin}" + +if [ -z "$WEBHOOK_SECRET" ]; then + echo "ERROR: WEBHOOK_SECRET not set" >&2 + exit 1 +fi + +# 读取请求体 +PAYLOAD=$(cat "$PAYLOAD_FILE") + +# 计算HMAC签名 +SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}') + +echo "sha256=$SIGNATURE" +WEBHOOK_EOF + +chmod +x "$WEBHOOK_VERIFY_SCRIPT" +log_info "Webhook验证脚本已创建:$WEBHOOK_VERIFY_SCRIPT" + +# 步骤6:配置Jenkins安全设置 +log_step "步骤6/7:配置Jenkins安全设置..." + +JENKINS_CONFIG_XML="$JENKINS_HOME/config.xml" + +if [ -f "$JENKINS_CONFIG_XML" ]; then + cp "$JENKINS_CONFIG_XML" "$BACKUP_DIR/config.xml.bak" + + # 禁用匿名访问 + if grep -q "true" "$JENKINS_CONFIG_XML"; then + log_info "Jenkins安全已启用" + else + sed -i 's|.*|true|' "$JENKINS_CONFIG_XML" 2>/dev/null || true + fi + + log_info "Jenkins安全配置已更新" +fi + +# 步骤7:创建安全验证脚本 +log_step "步骤7/7:创建安全验证脚本..." + +VERIFY_SCRIPT="/usr/local/bin/verify-jenkins-security.sh" + +cat > "$VERIFY_SCRIPT" << 'VERIFY_EOF' +#!/bin/bash +# Jenkins安全验证脚本 +# 作者:张翔 +# 用途:验证Jenkins安全加固是否成功 + +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo "==========================================" +echo " Jenkins安全验证" +echo "==========================================" +echo "" + +PASS=0 +FAIL=0 + +check_pass() { + echo -e "${GREEN}[✓]${NC} $1" + ((PASS++)) +} + +check_fail() { + echo -e "${RED}[✗]${NC} $1" + ((FAIL++)) +} + +check_warn() { + echo -e "${YELLOW}[!]${NC} $1" +} + +# 检查1:Jenkins是否仅监听127.0.0.1 +echo "检查1:Jenkins监听地址" +if netstat -tlnp 2>/dev/null | grep -q ":8080.*127.0.0.1"; then + check_pass "Jenkins仅监听127.0.0.1:8080" +elif netstat -tlnp 2>/dev/null | grep -q ":8080.*0.0.0.0"; then + check_fail "Jenkins监听0.0.0.0:8080(风险!)" +else + check_warn "Jenkins未运行或监听地址未知" +fi + +# 检查2:直接访问8080端口是否被拒绝 +echo "" +echo "检查2:直接访问8080端口" +if curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 http://localhost:8080 2>/dev/null | grep -q "000"; then + check_pass "直接访问8080端口被拒绝" +else + check_fail "可以直接访问8080端口(风险!)" +fi + +# 检查3:Nginx配置是否正确 +echo "" +echo "检查3:Nginx配置" +if nginx -t 2>/dev/null; then + check_pass "Nginx配置语法正确" +else + check_fail "Nginx配置存在错误" +fi + +# 检查4:HTTPS是否启用 +echo "" +echo "检查4:HTTPS配置" +if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then + check_pass "SSL证书已配置" +else + check_warn "SSL证书未找到,请手动配置" +fi + +# 检查5:防火墙规则 +echo "" +echo "检查5:防火墙规则" +if command -v ufw &> /dev/null; then + if ufw status | grep -q "8080.*DENY"; then + check_pass "防火墙已阻止8080端口" + else + check_fail "防火墙未阻止8080端口" + fi +elif command -v firewall-cmd &> /dev/null; then + if ! firewall-cmd --list-ports | grep -q "8080"; then + check_pass "防火墙已阻止8080端口" + else + check_fail "防火墙未阻止8080端口" + fi +else + check_warn "未检测到防火墙" +fi + +# 检查6:HTTP Basic Auth +echo "" +echo "检查6:HTTP Basic Auth" +if [ -f "/etc/nginx/conf.d/.jenkins-htpasswd" ]; then + check_pass "HTTP Basic Auth密码文件存在" +else + check_fail "HTTP Basic Auth密码文件不存在" +fi + +# 检查7:Jenkinsfile中是否还有硬编码token +echo "" +echo "检查7:敏感信息检查" +if [ -f "Jenkinsfile" ]; then + if grep -q "token.*=.*['\"].*['\"]" Jenkinsfile 2>/dev/null; then + check_fail "Jenkinsfile中存在硬编码token" + else + check_pass "Jenkinsfile中未发现硬编码token" + fi +else + check_warn "未找到Jenkinsfile" +fi + +# 汇总 +echo "" +echo "==========================================" +echo " 验证结果:通过 $PASS 项,失败 $FAIL 项" +echo "==========================================" + +if [ $FAIL -eq 0 ]; then + echo -e "${GREEN}安全加固验证通过!${NC}" + exit 0 +else + echo -e "${RED}安全加固存在风险,请检查失败项!${NC}" + exit 1 +fi +VERIFY_EOF + +chmod +x "$VERIFY_SCRIPT" +log_info "安全验证脚本已创建:$VERIFY_SCRIPT" + +# 重启服务 +log_step "重启服务..." + +echo "" +read -p "是否立即重启Jenkins和Nginx服务?(y/N): " RESTART_CHOICE +if [[ "$RESTART_CHOICE" =~ ^[Yy]$ ]]; then + if command -v systemctl &> /dev/null; then + systemctl restart jenkins + systemctl restart nginx + log_info "服务已重启" + else + service jenkins restart + service nginx restart + log_info "服务已重启" + fi +else + log_warn "请手动重启服务:systemctl restart jenkins nginx" +fi + +# 输出安全信息 +echo "" +echo "======================================================================" +echo " 安全加固完成" +echo "======================================================================" +echo "" +echo "📋 重要信息:" +echo " - Jenkins访问地址: https://$DOMAIN/jenkins/" +echo " - 管理员用户: $ADMIN_USER" +echo " - Webhook密钥: $WEBHOOK_SECRET" +echo "" +echo "📁 备份位置: $BACKUP_DIR" +echo "" +echo "✅ 后续步骤:" +echo " 1. 运行安全验证: $VERIFY_SCRIPT" +echo " 2. 更新Jenkinsfile中的webhook token为环境变量" +echo " 3. 配置SSL证书(如未配置)" +echo " 4. 设置定期安全审计" +echo "" +echo "⚠️ 安全提醒:" +echo " - 请妥善保管管理员密码和Webhook密钥" +echo " - 定期更新密码(建议每90天)" +echo " - 监控访问日志:/var/log/nginx/jenkins-access.log" +echo "" +echo "📞 如遇问题,请检查:" +echo " - Jenkins日志: journalctl -u jenkins -f" +echo " - Nginx日志: tail -f /var/log/nginx/jenkins-error.log" +echo "======================================================================" diff --git a/src/__mocks__/shared-mocks.tsx b/src/__mocks__/shared-mocks.tsx index 3bb6ae8..31aed72 100644 --- a/src/__mocks__/shared-mocks.tsx +++ b/src/__mocks__/shared-mocks.tsx @@ -102,6 +102,7 @@ export const mockLucideReact = () => { Calendar: () => , Quote: () => , User: () => , + Users: () => , Lock: () => , Eye: () => , EyeOff: () => , @@ -129,6 +130,10 @@ export const mockLucideReact = () => { ChevronUp: () => , ExternalLink: () => , TrendingUp: () => , + Target: () => , + MessageCircle: () => , + Layers: () => , + CreditCard: () => , Code: () => , Cloud: () => , BarChart3: () => , diff --git a/src/app/admin/page.test.tsx b/src/app/admin/page.test.tsx index 9a05785..d28c51d 100644 --- a/src/app/admin/page.test.tsx +++ b/src/app/admin/page.test.tsx @@ -26,9 +26,11 @@ jest.mock('@/db', () => ({ })); jest.mock('next/link', () => { - return ({ children, href }: { children: React.ReactNode; href: string }) => { + const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => { return {children}; }; + MockLink.displayName = 'MockLink'; + return MockLink; }); describe('AdminDashboard', () => { diff --git a/src/app/api/contact/route.test.ts b/src/app/api/contact/route.test.ts index 185f3fb..1d8bcb2 100644 --- a/src/app/api/contact/route.test.ts +++ b/src/app/api/contact/route.test.ts @@ -2,6 +2,18 @@ import { POST, setSecurityMiddleware } from './route'; import { NextRequest } from 'next/server'; import { generateCaptcha } from '@/lib/security/captcha'; import { SecurityMiddleware } from '@/lib/security/middleware'; +import Resend from 'resend'; + +interface MockResponse { + status: number; + json(): Promise; +} + +interface MockSend { + mockResolvedValue: (value: unknown) => void; + mockClear: () => void; + toHaveBeenCalled: () => boolean; +} if (!global.Response) { global.Response = class Response { @@ -14,12 +26,12 @@ if (!global.Response) { async json() { return JSON.parse(this._body); } - } as any; + } as unknown as typeof global.Response; } -if (!(global.Response as any).json) { - (global.Response as any).json = function(data: any, init?: { status?: number }) { - return new Response(JSON.stringify(data), init); +if (!(global.Response as unknown as { json?: unknown }).json) { + (global.Response as unknown as { json: (data: unknown, init?: { status?: number }) => MockResponse }).json = function(data: unknown, init?: { status?: number }) { + return new Response(JSON.stringify(data), init) as unknown as MockResponse; }; } @@ -42,19 +54,24 @@ jest.mock('resend', () => { describe('/api/contact', () => { let mockRequest: NextRequest; - let mockSend: any; + let mockSend: MockSend; beforeEach(() => { - const { default: Resend } = require('resend'); - const resendInstance = new Resend(); - mockSend = resendInstance.emails.send; + process.env.RESEND_API_KEY = 'test-api-key'; + + const resendInstance = new Resend('test-key'); + mockSend = resendInstance.emails.send as unknown as MockSend; mockSend.mockClear(); const securityMiddleware = new SecurityMiddleware(); setSecurityMiddleware(securityMiddleware); }); - const createMockRequest = (body: any, ip: string = '192.168.1.1'): NextRequest => { + afterEach(() => { + delete process.env.RESEND_API_KEY; + }); + + const createMockRequest = (body: Record, ip: string = '192.168.1.1'): NextRequest => { const headers = new Headers(); headers.set('x-forwarded-for', ip); headers.set('user-agent', 'test-agent'); diff --git a/update-jenkins-nginx.sh b/update-jenkins-nginx.sh new file mode 100644 index 0000000..f12b021 --- /dev/null +++ b/update-jenkins-nginx.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# 修复Jenkins Nginx配置 - 更新webhook路径 + +# 在服务器上执行此脚本 + +# 1. 备份当前配置 +docker cp novalon-nginx-secure:/etc/nginx/nginx.conf /tmp/nginx.conf.bak + +# 2. 创建新的Jenkins配置 +cat > /tmp/jenkins-server.conf << 'EOF' + # Jenkins CI/CD Server + server { + listen 80; + server_name ci.f.novalon.cn; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl http2; + server_name ci.f.novalon.cn; + + ssl_certificate /etc/nginx/ssl/ci.f.novalon.cn/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/ci.f.novalon.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Jenkins webhook端点 - 直接代理到Jenkins根路径 + location /generic-webhook-trigger/ { + proxy_pass http://172.17.0.1:8080/generic-webhook-trigger/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 100m; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Jenkins主应用 + location /jenkins/ { + proxy_pass http://172.17.0.1:8080/jenkins/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 100m; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # 默认location - 重定向到/jenkins/ + location / { + return 301 https://$host/jenkins/; + } + + access_log /var/log/nginx/jenkins-access.log; + error_log /var/log/nginx/jenkins-error.log; + } +EOF + +# 3. 替换Jenkins配置部分 +sed -i '/# Jenkins CI\/CD Server/,/^ }$/d' /tmp/nginx.conf.bak +sed -i "/^}/i $(cat /tmp/jenkins-server.conf)" /tmp/nginx.conf.bak + +# 4. 复制回容器并重载 +docker cp /tmp/nginx.conf.bak novalon-nginx-secure:/etc/nginx/nginx.conf +docker exec novalon-nginx-secure nginx -t && docker exec novalon-nginx-secure nginx -s reload + +echo "Jenkins Nginx配置已更新"