加固生产调试入口和许可证审计

This commit is contained in:
WuYongJie 2026-06-02 22:37:52 +08:00
parent 73b367e75e
commit 504a1d93e3
8 changed files with 313 additions and 4 deletions

1
.gitignore vendored
View File

@ -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

View File

@ -1 +1 @@
1.0.10
1.0.13

View File

@ -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 = [
{

View File

@ -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<string, unknown>;
}): Promise<void> {
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<voi
async function enforceLocalClockState(localData: LocalLicenseData): Promise<LocalLicenseData | null> {
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<Loca
const lastVerifiedMs = new Date(localData.lastVerified).getTime();
if (Number.isNaN(lastVerifiedMs)) {
await writeLicenseAuditLog({
action: 'license_clock_state_invalid',
previousConfig: localData.cachedConfig || null,
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
severity: 'high',
source: 'local_license_load',
details: { field: 'lastVerified' },
});
console.warn('[License] 本地许可证验证时间格式异常,已拒绝使用');
return null;
}
@ -152,6 +213,15 @@ async function enforceLocalClockState(localData: LocalLicenseData): Promise<Loca
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? nowIso,
});
await persistLocalLicenseData(rollbackState);
await writeLicenseAuditLog({
action: 'license_clock_rollback_detected',
previousConfig: localData.cachedConfig || null,
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
severity: 'high',
source: 'local_license_load',
details: { field: 'lastVerified' },
});
console.warn('[License] 本地许可证验证时间晚于当前系统时间,已拒绝使用');
return null;
}
@ -168,6 +238,15 @@ async function enforceLocalClockState(localData: LocalLicenseData): Promise<Loca
const lastSeenMs = new Date(localData.lastSeenAt).getTime();
if (Number.isNaN(lastSeenMs)) {
await writeLicenseAuditLog({
action: 'license_clock_state_invalid',
previousConfig: localData.cachedConfig || null,
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
severity: 'high',
source: 'local_license_load',
details: { field: 'lastSeenAt' },
});
console.warn('[License] 本地许可证时间状态格式异常,已拒绝使用');
return null;
}
@ -178,6 +257,15 @@ async function enforceLocalClockState(localData: LocalLicenseData): Promise<Loca
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? nowIso,
});
await persistLocalLicenseData(rollbackState);
await writeLicenseAuditLog({
action: 'license_clock_rollback_detected',
previousConfig: localData.cachedConfig || null,
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
severity: 'high',
source: 'local_license_load',
details: { field: 'lastSeenAt' },
});
console.warn('[License] 检测到系统时间明显回拨,已拒绝使用本地许可证');
return null;
}
@ -459,6 +547,19 @@ export async function runLicenseVerification(): Promise<{ valid: boolean; error?
};
}
await writeLicenseAuditLog({
action: 'license_server_verification_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'warning',
source: 'license_server_verification',
details: {
status: response.status,
error: data.error || null,
},
});
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
return { valid: true };
}
@ -467,6 +568,14 @@ export async function runLicenseVerification(): Promise<{ valid: boolean; error?
}
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
await writeLicenseAuditLog({
action: 'license_server_signature_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'high',
source: 'license_server_verification',
});
return { valid: false, error: '许可证签名验证失败' };
}
@ -481,6 +590,18 @@ export async function runLicenseVerification(): Promise<{ valid: boolean; error?
console.error('验证失败:', error);
const localLicense = await getLocalLicense();
if (localLicense) {
await writeLicenseAuditLog({
action: 'license_server_verification_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'warning',
source: 'license_server_verification',
details: { error: 'network_error' },
});
}
if (
localLicense &&
!isLicenseConfigExpired(localLicense.cachedConfig) &&
@ -580,6 +701,14 @@ export async function runLicenseHeartbeat(): Promise<{ acknowledged: boolean; er
if (data.acknowledged && data.license) {
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
await writeLicenseAuditLog({
action: 'license_server_signature_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'high',
source: 'license_server_heartbeat',
});
return {
acknowledged: false,
error: '许可证签名验证失败',
@ -593,11 +722,35 @@ export async function runLicenseHeartbeat(): Promise<{ acknowledged: boolean; er
}
}
if (!data.acknowledged) {
await writeLicenseAuditLog({
action: 'license_heartbeat_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'warning',
source: 'license_server_heartbeat',
details: { error: 'error' in data ? data.error || null : null },
});
}
return {
acknowledged: data.acknowledged || false,
};
} catch (error) {
console.error('心跳发送失败:', error);
const localLicense = await getLocalLicense();
if (localLicense) {
await writeLicenseAuditLog({
action: 'license_heartbeat_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'warning',
source: 'license_server_heartbeat',
details: { error: 'network_error' },
});
}
return {
acknowledged: false,
error: '网络错误',
@ -715,6 +868,7 @@ export async function saveLicenseLocally(licenseKey: string, license: SignedLice
throw new Error(bindingResult.error);
}
const previous = await getLocalLicenseReadOnly();
const currentConfig = extractLicenseConfig(license);
const nowIso = new Date().toISOString();
@ -735,6 +889,15 @@ export async function saveLicenseLocally(licenseKey: string, license: SignedLice
create: { key: 'license', value: JSON.stringify(signedLocalData) },
});
await writeLicenseAuditLog({
action: previous ? 'license_replaced' : 'license_saved',
previousConfig: previous?.cachedConfig || null,
nextConfig: currentConfig,
licenseKey,
instanceId,
source: 'local_activation',
});
cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig;
lastVerified = new Date(signedLocalData.lastVerified);
}
@ -755,6 +918,7 @@ export async function updateLocalLicense(license: SignedLicense): Promise<void>
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<void>
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<LocalLicenseData | null> {
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<LocalLicenseData | null
*/
export async function clearLocalLicense(): Promise<void> {
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);

View File

@ -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");

View File

@ -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")
}

View File

@ -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

View File

@ -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"