From 504a1d93e32be972f37ecf43c1f10862e771df10 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 2 Jun 2026 22:37:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E7=94=9F=E4=BA=A7=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E5=85=A5=E5=8F=A3=E5=92=8C=E8=AE=B8=E5=8F=AF=E8=AF=81?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + VERSION.txt | 2 +- lib/__tests__/license-route-coverage.test.ts | 21 ++ lib/license/license-service.ts | 201 +++++++++++++++++- .../migration.sql | 18 ++ prisma/schema.prisma | 19 ++ scripts/__tests__/docker-entrypoint.test.ts | 41 ++++ scripts/docker-entrypoint.sh | 14 +- 8 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20260602130000_add_license_audit_logs/migration.sql diff --git a/.gitignore b/.gitignore index 690b4ed..687cca9 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,7 @@ next-env.d.ts # Local deployment/security test artifacts deploy-artifacts/ hack_*.js +hook.js hack_*.sql reset_pw.sql update_pw.sql diff --git a/VERSION.txt b/VERSION.txt index 437d26b..2ac9634 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.0.10 \ No newline at end of file +1.0.13 diff --git a/lib/__tests__/license-route-coverage.test.ts b/lib/__tests__/license-route-coverage.test.ts index 3e95238..3bf07be 100644 --- a/lib/__tests__/license-route-coverage.test.ts +++ b/lib/__tests__/license-route-coverage.test.ts @@ -135,6 +135,27 @@ describe('许可证业务入口覆盖', () => { expect(source).toContain('授权限额模块不可用'); }); + it('本地许可证变更必须写入审计日志', () => { + const source = readProjectFile('lib/license/license-service.ts'); + + expect(source).toContain('writeLicenseAuditLog'); + expect(source).toContain('license_saved'); + expect(source).toContain('license_updated'); + expect(source).toContain('license_cleared'); + expect(source).toContain("nextType === 'PRO'"); + }); + + it('许可证异常必须写入审计日志', () => { + const source = readProjectFile('lib/license/license-service.ts'); + + expect(source).toContain('license_validation_failed'); + expect(source).toContain('license_clock_integrity_failed'); + expect(source).toContain('license_clock_rollback_detected'); + expect(source).toContain('license_server_signature_failed'); + expect(source).toContain('license_server_verification_failed'); + expect(source).toContain('license_heartbeat_failed'); + }); + it('高风险后台维护入口必须要求确认短语', () => { const destructiveRoutes = [ { diff --git a/lib/license/license-service.ts b/lib/license/license-service.ts index 23abca0..3ac105e 100644 --- a/lib/license/license-service.ts +++ b/lib/license/license-service.ts @@ -6,6 +6,7 @@ import { decryptLocal, signLocalIntegrity, verifyLocalIntegrity, + hashData, } from './crypto-service'; import { initializeSystem as initializeSystemCore } from '../system-init'; import type { @@ -38,6 +39,49 @@ let cachedLicense: LicenseConfig | null = null; let lastVerified: Date | null = null; let heartbeatTimer: NodeJS.Timeout | null = null; +type LicenseAuditSeverity = 'info' | 'warning' | 'high'; + +function getLicenseTransitionSeverity( + action: string, + previousType: string | null | undefined, + nextType: string | null | undefined +): LicenseAuditSeverity { + if (action === 'license_cleared') return 'warning'; + if (!previousType || !nextType || previousType === nextType) return 'info'; + if (nextType === 'PRO') return 'high'; + return 'warning'; +} + +async function writeLicenseAuditLog(params: { + action: string; + previousConfig?: LicenseConfig | null; + nextConfig?: LicenseConfig | null; + licenseKey?: string | null; + instanceId?: string | null; + severity?: LicenseAuditSeverity; + source: string; + details?: Record; +}): Promise { + try { + const previousType = params.previousConfig?.type || null; + const nextType = params.nextConfig?.type || null; + await (prisma as any).licenseAuditLog.create({ + data: { + action: params.action, + severity: params.severity || getLicenseTransitionSeverity(params.action, previousType, nextType), + previousType, + nextType, + licenseKeyHash: params.licenseKey ? hashData(params.licenseKey) : null, + instanceId: params.instanceId || null, + source: params.source, + details: params.details || undefined, + }, + }); + } catch (error) { + console.warn('[License] 许可证审计日志写入失败:', error); + } +} + function isDateStringExpired(value: string | null | undefined): boolean { if (!value) return false; @@ -132,6 +176,14 @@ async function persistLocalLicenseData(localData: LocalLicenseData): Promise { if (!isClockStateIntact(localData)) { + await writeLicenseAuditLog({ + action: 'license_clock_integrity_failed', + previousConfig: localData.cachedConfig || null, + licenseKey: localData.licenseKey, + instanceId: localData.instanceId, + severity: 'high', + source: 'local_license_load', + }); console.warn('[License] 本地许可证时间状态校验失败,已拒绝使用'); return null; } @@ -141,6 +193,15 @@ async function enforceLocalClockState(localData: LocalLicenseData): Promise throw new Error(bindingResult.error); } + const previousConfig = existing.cachedConfig; const currentConfig = extractLicenseConfig(license); const nowIso = new Date().toISOString(); @@ -773,6 +937,15 @@ export async function updateLocalLicense(license: SignedLicense): Promise data: { value: JSON.stringify(signedLocalData) }, }); + await writeLicenseAuditLog({ + action: 'license_updated', + previousConfig, + nextConfig: currentConfig, + licenseKey: existing.licenseKey, + instanceId: existing.instanceId, + source: 'license_server_verification', + }); + cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig; lastVerified = new Date(signedLocalData.lastVerified); } @@ -801,10 +974,25 @@ export async function getLocalLicense(): Promise { const parsed = JSON.parse(config.value) as LocalLicenseData; const normalized = normalizeLocalLicenseData(parsed); - if (!normalized) return null; + if (!normalized) { + await writeLicenseAuditLog({ + action: 'license_validation_failed', + licenseKey: typeof parsed?.licenseKey === 'string' ? parsed.licenseKey : null, + instanceId: typeof parsed?.instanceId === 'string' ? parsed.instanceId : null, + severity: 'high', + source: 'local_license_load', + }); + return null; + } return await enforceLocalClockState(normalized); - } catch { + } catch (error) { + await writeLicenseAuditLog({ + action: 'license_validation_failed', + severity: 'high', + source: 'local_license_load', + details: { error: error instanceof Error ? error.name : 'unknown_error' }, + }); return null; } } @@ -832,9 +1020,18 @@ export async function getLocalLicenseReadOnly(): Promise { try { + const existing = await getLocalLicenseReadOnly(); await prisma.systemConfig.delete({ where: { key: 'license' }, }); + await writeLicenseAuditLog({ + action: 'license_cleared', + previousConfig: existing?.cachedConfig || null, + nextConfig: null, + licenseKey: existing?.licenseKey || null, + instanceId: existing?.instanceId || null, + source: 'license_revocation', + }); console.log('[License] 本地许可证缓存已清除'); } catch (error) { console.error('[License] 清除本地许可证缓存失败:', error); diff --git a/prisma/migrations/20260602130000_add_license_audit_logs/migration.sql b/prisma/migrations/20260602130000_add_license_audit_logs/migration.sql new file mode 100644 index 0000000..3cd007f --- /dev/null +++ b/prisma/migrations/20260602130000_add_license_audit_logs/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "license_audit_logs" ( + "id" TEXT NOT NULL, + "action" TEXT NOT NULL, + "severity" TEXT NOT NULL DEFAULT 'info', + "previousType" TEXT, + "nextType" TEXT, + "licenseKeyHash" TEXT, + "instanceId" TEXT, + "source" TEXT, + "details" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "license_audit_logs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "license_audit_logs_action_idx" ON "license_audit_logs"("action"); +CREATE INDEX "license_audit_logs_severity_idx" ON "license_audit_logs"("severity"); +CREATE INDEX "license_audit_logs_createdAt_idx" ON "license_audit_logs"("createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f7aa41c..343c8fd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -456,3 +456,22 @@ model SystemConfig { @@map("system_configs") } + +// 许可证本地审计表 +model LicenseAuditLog { + id String @id @default(uuid()) + action String + severity String @default("info") + previousType String? + nextType String? + licenseKeyHash String? + instanceId String? + source String? + details Json? + createdAt DateTime @default(now()) + + @@index([action]) + @@index([severity]) + @@index([createdAt]) + @@map("license_audit_logs") +} diff --git a/scripts/__tests__/docker-entrypoint.test.ts b/scripts/__tests__/docker-entrypoint.test.ts index 21ce1f5..1592f01 100644 --- a/scripts/__tests__/docker-entrypoint.test.ts +++ b/scripts/__tests__/docker-entrypoint.test.ts @@ -32,6 +32,47 @@ describe('scripts/docker-entrypoint.sh', () => { expect(entrypoint).not.toMatch(/\b\d{1,3}(?:\.\d{1,3}){3}\b/) }) + it('生产启动必须禁用 Node inspector', () => { + expect(entrypoint).toContain('NODE_OPTIONS') + expect(entrypoint).toContain('--inspect') + expect(entrypoint).toContain('生产环境禁止启用 Node inspector') + expect(entrypoint).toContain("trap '' USR1") + expect(entrypoint).toContain('node server.js &') + expect(entrypoint).toContain('wait "$app_pid"') + expect(entrypoint).not.toContain('--disable-sigusr1') + }) + + it('NODE_OPTIONS 启用 inspector 时必须在迁移前拦截', () => { + if (process.platform !== 'linux') { + return + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flnxi-entrypoint-inspect-')) + const binDir = path.join(tmpDir, 'bin') + fs.mkdirSync(binDir) + + fs.writeFileSync( + path.join(binDir, 'npx'), + ['#!/bin/sh', 'echo npx-should-not-run', 'exit 99', ''].join('\n'), + { mode: 0o755 } + ) + + const scriptPath = path.join(process.cwd(), 'scripts/docker-entrypoint.sh') + const result = spawnSync('sh', [scriptPath], { + cwd: tmpDir, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + NODE_OPTIONS: '--inspect=0.0.0.0:9229', + }, + encoding: 'utf8', + }) + + expect(result.status).toBe(1) + expect(`${result.stdout}\n${result.stderr}`).toContain('生产环境禁止启用 Node inspector') + expect(`${result.stdout}\n${result.stderr}`).not.toContain('npx-should-not-run') + }) + it('生产占位环境变量必须在连接数据库前被拦截', () => { if (process.platform !== 'linux') { return diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index e9efe61..f7cc6bc 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -31,6 +31,13 @@ require_real_env() { fi } +case "${NODE_OPTIONS:-}" in + *--inspect*) + echo "✗ 生产环境禁止启用 Node inspector,请移除 NODE_OPTIONS 中的 --inspect 参数" + exit 1 + ;; +esac + echo "==========================================" echo "FLNXI 素材库系统启动中..." echo "==========================================" @@ -89,4 +96,9 @@ echo "系统启动完成!" echo "访问地址: http://localhost:${PORT:-3000}" echo "==========================================" -exec node server.js +trap '' USR1 +node server.js & +app_pid="$!" + +trap 'kill -TERM "$app_pid" 2>/dev/null || true' TERM INT +wait "$app_pid"