fix: 收敛运行时健康公开响应

This commit is contained in:
sunlei 2026-06-13 18:39:10 +08:00
parent 340cb073be
commit e480c0edcb
10 changed files with 50 additions and 136 deletions

5
API.md
View File

@ -54,7 +54,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
| 方法 | 路径 | 认证 | 说明 | | 方法 | 路径 | 认证 | 说明 |
| ----- | ----------------- | ---- | ---------------------------------- | | ----- | ----------------- | ---- | ---------------------------------- |
| `GET` | `/health/runtime` | 否 | API 运行时健康和脱敏配置快照 | | `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
该接口返回 plain JSON不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic` 该接口返回 plain JSON不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`
@ -66,7 +66,8 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
| `checkedAt` | ISO 时间字符串 | | `checkedAt` | ISO 时间字符串 |
| `status` | `live`、`ready`、`degraded` 或 `blocked` | | `status` | `live`、`ready`、`degraded` 或 `blocked` |
| `checks` | 进程和配置检查列表 | | `checks` | 进程和配置检查列表 |
| `config` | 脱敏后的运行时配置快照,不包含原始 secret/token |
公开响应不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。
状态含义: 状态含义:

View File

@ -128,10 +128,9 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
返回内容包括: 返回内容包括:
- `status``live`、`ready`、`degraded` 或 `blocked` - `status``live`、`ready`、`degraded` 或 `blocked`
- `checks`:进程存活和运行时配置检查。 - `checks`:进程存活和运行时配置检查状态。
- `config`已脱敏的运行时配置快照不包含原始密码、Token、Cookie、SSH key 或验证码票据。
`blocked` 表示关键配置缺失;`degraded` 表示可选运行时配置缺失,核心 API 仍可继续工作。本地未配置 Loki、WordPress、NapCat 等可选依赖时,健康状态可能保持 `degraded` 该公开入口不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。`blocked` 表示关键配置缺失;`degraded` 表示可选运行时配置缺失,核心 API 仍可继续工作。本地未配置 Loki、WordPress、NapCat 等可选依赖时,健康状态可能保持 `degraded`
## 核心规则 ## 核心规则

View File

@ -1000,73 +1000,6 @@ function runtimeHealthExample() {
message: 'QQBOT_NAPCAT_IMAGE is not configured', message: 'QQBOT_NAPCAT_IMAGE is not configured',
}, },
], ],
config: {
app: {
nodeEnv: 'production',
port: 48085,
},
database: {
host: 'mysql',
port: 3306,
database: 'kt_template',
username: 'kt',
synchronize: false,
},
loki: {
transportEnabled: false,
httpRequestPushEnabled: false,
queryConfigured: false,
host: '',
queryHost: '',
environment: 'production',
tenantId: '',
username: '',
passwordConfigured: false,
},
minio: {
endpoint: 'minio',
port: 9000,
useSSL: false,
accessKey: 'mi***ey',
bucket: 'kt-template-online',
},
wordpress: {
baseUrl: 'https://blog.example.test',
hostHeader: 'blog.example.test',
adminUsername: 'wordpress-admin',
passwordConfigured: true,
timeoutMs: 15000,
loginTimeoutMs: 3000,
availabilityTtlMs: 60000,
},
qqbot: {
reverseWsPath: '/qqbot/onebot/reverse',
reverseWsToken: 'qq***en',
napcatRoot: '/vol1/docker/kt-qqbot/napcat-instances',
napcatImage: '',
napcatContainerMode: 'ssh',
napcatSshTarget: 'nas',
napcatSshPort: 2202,
napcatSshKeyPath: '/home/kt/.ssh/napcat',
napcatReverseWsBase: 'ws://api.example.test/qqbot/onebot/reverse',
napcatWebuiBaseUrl: 'http://127.0.0.1:6099',
napcatWebuiToken: 'na***en',
},
checks: [
{
key: 'DB_HOST',
level: 'required',
present: true,
maskedValue: 'my***ql',
},
{
key: 'QQBOT_NAPCAT_IMAGE',
level: 'optional',
present: false,
message: 'QQBOT_NAPCAT_IMAGE is not configured',
},
],
},
}; };
} }

View File

@ -16,14 +16,21 @@ const SENSITIVE_TEXT_REPLACEMENTS: Array<[RegExp, string]> = [
REDACTED_BASE64_VALUE, REDACTED_BASE64_VALUE,
], ],
[/\b[A-Za-z0-9+/]{120,}={0,2}\b/g, REDACTED_BASE64_VALUE], [/\b[A-Za-z0-9+/]{120,}={0,2}\b/g, REDACTED_BASE64_VALUE],
[/\b(Authorization)\s*[:=]\s*Bearer\s+[^\s,;]+/gi, '$1=<redacted>'], [/\b(Authorization)\s*[:=]\s*[^\r\n]+/gi, '$1=<redacted>'],
[/\b(Cookie)\s*[:=]\s*[^\s,;]+/gi, '$1=<redacted>'], [/\b(Cookie)\s*[:=]\s*[^\r\n]+/gi, '$1=<redacted>'],
[ [
new RegExp( new RegExp(
`(["'])(${SENSITIVE_TEXT_KEY_PATTERN})\\1\\s*:\\s*(["'])[^"']*\\3`, `(["'])(${SENSITIVE_TEXT_KEY_PATTERN})\\1\\s*:\\s*(?:(["'])[^"']*\\3|[-+]?\\d+(?:\\.\\d+)?|true|false|null)`,
'gi', 'gi',
), ),
'$1$2$1:$3<redacted>$3', '$1$2$1:"<redacted>"',
],
[
new RegExp(
`\\b(${SENSITIVE_TEXT_KEY_PATTERN})(\\s*[:=]\\s*)(["'])[^"']*\\3`,
'gi',
),
'$1$2$3<redacted>$3',
], ],
[ [
new RegExp( new RegExp(

View File

@ -34,7 +34,6 @@ export class RuntimeHealthService {
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
status: this.aggregateStatus(checks), status: this.aggregateStatus(checks),
checks, checks,
config,
}; };
} }

View File

@ -1,5 +1,3 @@
import type { RuntimeSafeConfigSnapshot } from '../config/runtime-config.types';
export type RuntimeHealthStatus = 'live' | 'ready' | 'degraded' | 'blocked'; export type RuntimeHealthStatus = 'live' | 'ready' | 'degraded' | 'blocked';
export interface RuntimeHealthCheck { export interface RuntimeHealthCheck {
@ -15,5 +13,4 @@ export interface RuntimeHealthReport {
checkedAt: string; checkedAt: string;
status: RuntimeHealthStatus; status: RuntimeHealthStatus;
checks: RuntimeHealthCheck[]; checks: RuntimeHealthCheck[];
config: RuntimeSafeConfigSnapshot;
} }

View File

@ -29,19 +29,18 @@ describe('applySwaggerResponseExamples', () => {
service: 'kt-template-online-api', service: 'kt-template-online-api',
status: 'degraded', status: 'degraded',
checks: expect.any(Array), checks: expect.any(Array),
config: expect.any(Object),
}), }),
); );
expect(jsonContent.example).not.toHaveProperty('code'); expect(jsonContent.example).not.toHaveProperty('code');
expect(jsonContent.example).not.toHaveProperty('msg'); expect(jsonContent.example).not.toHaveProperty('msg');
expect(jsonContent.example).not.toHaveProperty('data'); expect(jsonContent.example).not.toHaveProperty('data');
expect(jsonContent.example).not.toHaveProperty('config');
expect(jsonContent.schema).toEqual( expect(jsonContent.schema).toEqual(
expect.objectContaining({ expect.objectContaining({
type: 'object', type: 'object',
properties: expect.objectContaining({ properties: expect.objectContaining({
service: expect.any(Object), service: expect.any(Object),
checks: expect.any(Object), checks: expect.any(Object),
config: expect.any(Object),
}), }),
}), }),
); );

View File

@ -249,4 +249,34 @@ describe('RuntimeEvidenceService', () => {
expect(serialized).not.toContain('raw-json-client-secret'); expect(serialized).not.toContain('raw-json-client-secret');
expect(serialized).toContain('KT_SCAN_SAFE'); expect(serialized).toContain('KT_SCAN_SAFE');
}); });
it('redacts cookie headers, quoted private key values, and non-string JSON secrets', () => {
const service = new RuntimeEvidenceService();
const record = service.createRecord({
title: 'realistic secret text evidence',
taskType: 'api',
project: 'kt-template-online-api',
environment: 'local',
operation: 'runtime-evidence',
status: 'failed',
details: {
cookieHeader:
'Cookie: admin_access_token=raw-cookie-token; wordpress_logged_in=raw-wordpress-cookie; theme=light',
quotedPrivateKey:
'private_key="-----BEGIN PRIVATE KEY----- raw pem body -----END PRIVATE KEY-----"',
jsonText:
'{"accessToken":12345,"sid":67890,"client_secret":true,"sessionId":"KT_SCAN_SAFE"}',
},
});
const serialized = JSON.stringify(record);
expect(serialized).not.toContain('raw-cookie-token');
expect(serialized).not.toContain('raw-wordpress-cookie');
expect(serialized).not.toContain('raw pem body');
expect(serialized).not.toContain('12345');
expect(serialized).not.toContain('67890');
expect(serialized).toContain('KT_SCAN_SAFE');
});
}); });

View File

@ -8,57 +8,6 @@ describe('RuntimeHealthController', () => {
checkedAt: '2026-06-13T00:00:00.000Z', checkedAt: '2026-06-13T00:00:00.000Z',
status: 'ready', status: 'ready',
checks: [], checks: [],
config: {
app: { nodeEnv: 'test', port: 48085 },
database: {
host: 'mysql',
port: 3306,
database: 'kt',
username: 'root',
synchronize: false,
},
loki: {
transportEnabled: false,
httpRequestPushEnabled: false,
queryConfigured: false,
host: '',
queryHost: '',
environment: 'test',
tenantId: '',
username: '',
passwordConfigured: false,
},
minio: {
endpoint: 'minio',
port: 9000,
useSSL: false,
accessKey: 'mi***ey',
bucket: 'kt-template-online',
},
wordpress: {
baseUrl: 'https://blog.example.test',
hostHeader: 'blog.example.test',
adminUsername: 'wordpress-admin',
passwordConfigured: true,
timeoutMs: 15000,
loginTimeoutMs: 3000,
availabilityTtlMs: 60000,
},
qqbot: {
reverseWsPath: '/qqbot/onebot/reverse',
reverseWsToken: 'qq***en',
napcatRoot: '/vol1/docker/napcat',
napcatImage: 'mlikiowa/napcat-docker:latest',
napcatContainerMode: 'ssh',
napcatSshTarget: 'nas',
napcatSshPort: 2202,
napcatSshKeyPath: '/home/kt/.ssh/napcat',
napcatReverseWsBase: 'ws://api.example.test/onebot',
napcatWebuiBaseUrl: 'http://127.0.0.1:6099',
napcatWebuiToken: 'na***en',
},
checks: [],
},
}; };
const service = { getRuntimeHealth: jest.fn(() => report) }; const service = { getRuntimeHealth: jest.fn(() => report) };
const controller = new RuntimeHealthController(service as any); const controller = new RuntimeHealthController(service as any);

View File

@ -78,11 +78,11 @@ describe('RuntimeHealthService', () => {
expect.objectContaining({ expect.objectContaining({
service: 'kt-template-online-api', service: 'kt-template-online-api',
status: 'ready', status: 'ready',
config: expect.objectContaining({
minio: expect.objectContaining({ bucket: 'kt-template-online' }),
}),
}), }),
); );
expect(report).not.toHaveProperty('config');
expect(JSON.stringify(report)).not.toContain('mysql');
expect(JSON.stringify(report)).not.toContain('/vol1/docker/napcat');
expect(new Date(report.checkedAt).toISOString()).toBe(report.checkedAt); expect(new Date(report.checkedAt).toISOString()).toBe(report.checkedAt);
expect(report.checks).toContainEqual({ expect(report.checks).toContainEqual({
name: 'process', name: 'process',