fix: 修复运行时审查问题

This commit is contained in:
sunlei 2026-06-13 18:19:05 +08:00
parent c7c5822f14
commit 7f4ff45938
9 changed files with 246 additions and 3 deletions

View File

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

View File

@ -51,6 +51,7 @@ const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
'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),

View File

@ -55,6 +55,7 @@ export interface RuntimeQqbotConfig {
reverseWsPath: string;
reverseWsToken: string;
napcatRoot: string;
napcatImage: string;
napcatContainerMode: string;
napcatSshTarget: string;
napcatSshPort: number;

View File

@ -5,12 +5,30 @@ import {
} from './runtime-evidence.types';
const REDACTED_VALUE = '<redacted>';
const REDACTED_BASE64_VALUE = '<redacted-base64>';
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=<redacted>'],
[/\b(Cookie)\s*[:=]\s*[^\s,;]+/gi, '$1=<redacted>'],
[/\b(ticket|randstr|token|replyText)\s*=\s*[^\s,;&]+/gi, '$1=<redacted>'],
[
new RegExp(
`(["'])(${SENSITIVE_TEXT_KEY_PATTERN})\\1\\s*:\\s*(["'])[^"']*\\3`,
'gi',
),
'$1$2$1:$3<redacted>$3',
],
[
new RegExp(`\\b(${SENSITIVE_TEXT_KEY_PATTERN})\\s*=\\s*[^\\s,;&]+`, 'gi'),
'$1=<redacted>',
],
];
@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);
}
}

View File

@ -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),
}),
}),
);
});
});

View File

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

View File

@ -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');
});
});

View File

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

View File

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