加固生产调试入口和许可证审计
This commit is contained in:
parent
73b367e75e
commit
504a1d93e3
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
||||
@ -1 +1 @@
|
||||
1.0.10
|
||||
1.0.13
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user