diff --git a/src/common/swagger/swagger-response.ts b/src/common/swagger/swagger-response.ts index 6c94b2c..b2ba486 100644 --- a/src/common/swagger/swagger-response.ts +++ b/src/common/swagger/swagger-response.ts @@ -258,6 +258,19 @@ function applyOperationResponseExamples( return; } + if (isRuntimeHealthPath(path)) { + const plainResponse = buildPlainJsonResponse( + runtimeHealthExample(), + 'API 运行时健康检查', + ); + operation.responses['200'] = mergeJsonResponse( + operation.responses['200'], + plainResponse, + ); + applyErrorResponses(operation); + return; + } + const dataExample = getOperationDataExample(path, method, operation); const successSchema = createOperationSuccessSchema( document, @@ -313,6 +326,24 @@ function buildSuccessResponse(dataExample: any, schema: SwaggerSchema) { }; } +function buildPlainJsonResponse(example: any, description: string) { + return { + description, + content: { + 'application/json': { + schema: schemaFromExample(example), + example, + examples: { + success: { + summary: '成功响应', + value: example, + }, + }, + }, + }, + }; +} + function buildErrorResponse(status: number, summary: string, message: string) { return { description: message, @@ -705,6 +736,10 @@ function isBinaryResponsePath(path: string) { return path.includes('/download') || path.includes('/resource-proxy'); } +function isRuntimeHealthPath(path: string) { + return path.toLowerCase() === '/health/runtime'; +} + function itemExampleByPath(path: string) { if (path.includes('/qqbot/account')) return qqbotAccountExample(); if (path.includes('/qqbot/command')) return qqbotCommandExample(); @@ -946,6 +981,95 @@ function pluginHealthExample() { }; } +function runtimeHealthExample() { + return { + service: 'kt-template-online-api', + checkedAt: '2026-06-13T00:00:00.000Z', + status: 'degraded', + checks: [ + { + name: 'process', + status: 'live', + critical: true, + message: 'NestJS process is responding', + }, + { + name: 'config:QQBOT_NAPCAT_IMAGE', + status: 'degraded', + critical: false, + 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', + }, + ], + }, + }; +} + function wordpressArticleExample() { return { id: 1, diff --git a/src/runtime/config/runtime-config.service.ts b/src/runtime/config/runtime-config.service.ts index ebf2299..cf236e1 100644 --- a/src/runtime/config/runtime-config.service.ts +++ b/src/runtime/config/runtime-config.service.ts @@ -51,6 +51,7 @@ const OPTIONAL_CONFIG_CHECKS: ReadonlyArray = [ 'QQBOT_REVERSE_WS_PATH', 'QQBOT_REVERSE_WS_TOKEN', 'QQBOT_NAPCAT_ROOT', + 'QQBOT_NAPCAT_IMAGE', 'QQBOT_NAPCAT_CONTAINER_MODE', 'QQBOT_NAPCAT_SSH_TARGET', 'QQBOT_NAPCAT_SSH_PORT', @@ -152,6 +153,7 @@ export class RuntimeConfigService { 'QQBOT_NAPCAT_ROOT', '/vol1/docker/kt-qqbot/napcat-instances', ), + napcatImage: this.getString('QQBOT_NAPCAT_IMAGE'), napcatContainerMode: this.getString('QQBOT_NAPCAT_CONTAINER_MODE'), napcatSshTarget: this.getString('QQBOT_NAPCAT_SSH_TARGET', 'nas'), napcatSshPort: this.getPositiveNumber('QQBOT_NAPCAT_SSH_PORT', 22), diff --git a/src/runtime/config/runtime-config.types.ts b/src/runtime/config/runtime-config.types.ts index 26b57d6..7265a79 100644 --- a/src/runtime/config/runtime-config.types.ts +++ b/src/runtime/config/runtime-config.types.ts @@ -55,6 +55,7 @@ export interface RuntimeQqbotConfig { reverseWsPath: string; reverseWsToken: string; napcatRoot: string; + napcatImage: string; napcatContainerMode: string; napcatSshTarget: string; napcatSshPort: number; diff --git a/src/runtime/evidence/runtime-evidence.service.ts b/src/runtime/evidence/runtime-evidence.service.ts index fa3046d..29a5667 100644 --- a/src/runtime/evidence/runtime-evidence.service.ts +++ b/src/runtime/evidence/runtime-evidence.service.ts @@ -5,12 +5,30 @@ import { } from './runtime-evidence.types'; const REDACTED_VALUE = ''; +const REDACTED_BASE64_VALUE = ''; const SENSITIVE_KEY_PATTERN = - /password|secret|token|authorization|cookie|privateKey|sshKey|ticket|randstr|replyText/i; + /password|secret|token|authorization|cookie|privatekey|sshkey|ticket|randstr|replytext|base64/i; +const SENSITIVE_TEXT_KEY_PATTERN = + 'password|secret|token|authorization|cookie|private[_-]?key|ssh[_-]?key|ticket|randstr|replyText|sid'; const SENSITIVE_TEXT_REPLACEMENTS: Array<[RegExp, string]> = [ + [ + /data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[a-z0-9+/=\r\n]+/gi, + 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='], [/\b(Cookie)\s*[:=]\s*[^\s,;]+/gi, '$1='], - [/\b(ticket|randstr|token|replyText)\s*=\s*[^\s,;&]+/gi, '$1='], + [ + new RegExp( + `(["'])(${SENSITIVE_TEXT_KEY_PATTERN})\\1\\s*:\\s*(["'])[^"']*\\3`, + 'gi', + ), + '$1$2$1:$3$3', + ], + [ + new RegExp(`\\b(${SENSITIVE_TEXT_KEY_PATTERN})\\s*=\\s*[^\\s,;&]+`, 'gi'), + '$1=', + ], ]; @Injectable() @@ -63,6 +81,7 @@ export class RuntimeEvidenceService { } private isSensitiveKey(key: string) { - return SENSITIVE_KEY_PATTERN.test(key); + const normalizedKey = key.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); + return normalizedKey === 'sid' || SENSITIVE_KEY_PATTERN.test(normalizedKey); } } diff --git a/test/common/swagger-response.spec.ts b/test/common/swagger-response.spec.ts new file mode 100644 index 0000000..604f401 --- /dev/null +++ b/test/common/swagger-response.spec.ts @@ -0,0 +1,49 @@ +import type { OpenAPIObject } from '@nestjs/swagger'; +import { applySwaggerResponseExamples } from '../../src/common/swagger/swagger-response'; + +describe('applySwaggerResponseExamples', () => { + it('keeps runtime health swagger response as plain JSON instead of Vben success wrapper', () => { + const document = { + openapi: '3.0.0', + info: { + title: 'KT API', + version: 'test', + }, + paths: { + '/health/runtime': { + get: { + summary: 'Get machine-readable API runtime health', + responses: {}, + }, + }, + }, + } as unknown as OpenAPIObject; + + applySwaggerResponseExamples(document); + + const operation = (document.paths['/health/runtime'] as any).get; + const jsonContent = operation.responses['200'].content['application/json']; + + expect(jsonContent.example).toEqual( + expect.objectContaining({ + service: 'kt-template-online-api', + status: 'degraded', + checks: expect.any(Array), + config: expect.any(Object), + }), + ); + expect(jsonContent.example).not.toHaveProperty('code'); + expect(jsonContent.example).not.toHaveProperty('msg'); + expect(jsonContent.example).not.toHaveProperty('data'); + expect(jsonContent.schema).toEqual( + expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ + service: expect.any(Object), + checks: expect.any(Object), + config: expect.any(Object), + }), + }), + ); + }); +}); diff --git a/test/runtime/runtime-config.service.spec.ts b/test/runtime/runtime-config.service.spec.ts index 80df944..2cab56d 100644 --- a/test/runtime/runtime-config.service.spec.ts +++ b/test/runtime/runtime-config.service.spec.ts @@ -99,6 +99,7 @@ describe('RuntimeConfigService', () => { LOKI_USERNAME: 'loki-user', LOKI_PASSWORD: 'loki-password', QQBOT_NAPCAT_ROOT: '/vol1/docker/napcat', + QQBOT_NAPCAT_IMAGE: 'mlikiowa/napcat-docker:latest', QQBOT_NAPCAT_CONTAINER_MODE: 'ssh', QQBOT_NAPCAT_SSH_TARGET: 'nas', QQBOT_NAPCAT_SSH_PORT: '2202', @@ -134,6 +135,7 @@ describe('RuntimeConfigService', () => { reverseWsPath: '/qqbot/reverse', reverseWsToken: 'qq***en', napcatRoot: '/vol1/docker/napcat', + napcatImage: 'mlikiowa/napcat-docker:latest', napcatContainerMode: 'ssh', napcatSshTarget: 'nas', napcatSshPort: 2202, @@ -151,6 +153,13 @@ describe('RuntimeConfigService', () => { present: true, }), ); + expect(checks).toContainEqual( + expect.objectContaining({ + key: 'QQBOT_NAPCAT_IMAGE', + level: 'optional', + present: true, + }), + ); expect(checks).toContainEqual( expect.objectContaining({ key: 'QQBOT_NAPCAT_REVERSE_WS_URL|QQBOT_NAPCAT_REVERSE_WS_BASE', diff --git a/test/runtime/runtime-evidence.service.spec.ts b/test/runtime/runtime-evidence.service.spec.ts index 2f97a00..16c5dd0 100644 --- a/test/runtime/runtime-evidence.service.spec.ts +++ b/test/runtime/runtime-evidence.service.spec.ts @@ -179,4 +179,41 @@ describe('RuntimeEvidenceService', () => { expect(input.assertions[0].message).toContain('raw-reply-text'); expect(input.cleanup.message).toContain('raw-cookie'); }); + + it('redacts captcha sid, snake_case secret keys, JSON secret text, and base64 payloads', () => { + const service = new RuntimeEvidenceService(); + const rawBase64Payload = `data:image/png;base64,${'A'.repeat(160)}`; + + const record = service.createRecord({ + title: 'captcha evidence', + taskType: 'api', + project: 'kt-template-online-api', + environment: 'local', + operation: 'qqbot-login', + status: 'blocked', + details: { + sid: 'raw-captcha-sid', + private_key: 'raw-private-key', + ssh_key: 'raw-ssh-key', + imageBase64: rawBase64Payload, + text: 'sid=raw-text-sid private_key=raw-text-private-key ssh_key=raw-text-ssh-key', + jsonText: + '{"sid":"raw-json-sid","token":"raw-json-token","safe":"kept"}', + }, + }); + + const serialized = JSON.stringify(record); + + expect(serialized).not.toContain('raw-captcha-sid'); + expect(serialized).not.toContain('raw-private-key'); + expect(serialized).not.toContain('raw-ssh-key'); + expect(serialized).not.toContain('raw-text-sid'); + expect(serialized).not.toContain('raw-text-private-key'); + expect(serialized).not.toContain('raw-text-ssh-key'); + expect(serialized).not.toContain('raw-json-sid'); + expect(serialized).not.toContain('raw-json-token'); + expect(serialized).not.toContain(rawBase64Payload); + expect(serialized).toContain('captcha evidence'); + expect(serialized).toContain('kept'); + }); }); diff --git a/test/runtime/runtime-health.controller.spec.ts b/test/runtime/runtime-health.controller.spec.ts index 47c1d40..60394a9 100644 --- a/test/runtime/runtime-health.controller.spec.ts +++ b/test/runtime/runtime-health.controller.spec.ts @@ -48,6 +48,7 @@ describe('RuntimeHealthController', () => { reverseWsPath: '/qqbot/onebot/reverse', reverseWsToken: 'qq***en', napcatRoot: '/vol1/docker/napcat', + napcatImage: 'mlikiowa/napcat-docker:latest', napcatContainerMode: 'ssh', napcatSshTarget: 'nas', napcatSshPort: 2202, diff --git a/test/runtime/runtime-health.service.spec.ts b/test/runtime/runtime-health.service.spec.ts index f940e34..4c492f5 100644 --- a/test/runtime/runtime-health.service.spec.ts +++ b/test/runtime/runtime-health.service.spec.ts @@ -44,6 +44,7 @@ function createSnapshot( reverseWsPath: '/qqbot/onebot/reverse', reverseWsToken: 'qq***en', napcatRoot: '/vol1/docker/napcat', + napcatImage: 'mlikiowa/napcat-docker:latest', napcatContainerMode: 'ssh', napcatSshTarget: 'nas', napcatSshPort: 2202,