fix: 修复运行时审查问题
This commit is contained in:
parent
c7c5822f14
commit
7f4ff45938
@ -258,6 +258,19 @@ function applyOperationResponseExamples(
|
|||||||
return;
|
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 dataExample = getOperationDataExample(path, method, operation);
|
||||||
const successSchema = createOperationSuccessSchema(
|
const successSchema = createOperationSuccessSchema(
|
||||||
document,
|
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) {
|
function buildErrorResponse(status: number, summary: string, message: string) {
|
||||||
return {
|
return {
|
||||||
description: message,
|
description: message,
|
||||||
@ -705,6 +736,10 @@ function isBinaryResponsePath(path: string) {
|
|||||||
return path.includes('/download') || path.includes('/resource-proxy');
|
return path.includes('/download') || path.includes('/resource-proxy');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRuntimeHealthPath(path: string) {
|
||||||
|
return path.toLowerCase() === '/health/runtime';
|
||||||
|
}
|
||||||
|
|
||||||
function itemExampleByPath(path: string) {
|
function itemExampleByPath(path: string) {
|
||||||
if (path.includes('/qqbot/account')) return qqbotAccountExample();
|
if (path.includes('/qqbot/account')) return qqbotAccountExample();
|
||||||
if (path.includes('/qqbot/command')) return qqbotCommandExample();
|
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() {
|
function wordpressArticleExample() {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@ -51,6 +51,7 @@ const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
|
|||||||
'QQBOT_REVERSE_WS_PATH',
|
'QQBOT_REVERSE_WS_PATH',
|
||||||
'QQBOT_REVERSE_WS_TOKEN',
|
'QQBOT_REVERSE_WS_TOKEN',
|
||||||
'QQBOT_NAPCAT_ROOT',
|
'QQBOT_NAPCAT_ROOT',
|
||||||
|
'QQBOT_NAPCAT_IMAGE',
|
||||||
'QQBOT_NAPCAT_CONTAINER_MODE',
|
'QQBOT_NAPCAT_CONTAINER_MODE',
|
||||||
'QQBOT_NAPCAT_SSH_TARGET',
|
'QQBOT_NAPCAT_SSH_TARGET',
|
||||||
'QQBOT_NAPCAT_SSH_PORT',
|
'QQBOT_NAPCAT_SSH_PORT',
|
||||||
@ -152,6 +153,7 @@ export class RuntimeConfigService {
|
|||||||
'QQBOT_NAPCAT_ROOT',
|
'QQBOT_NAPCAT_ROOT',
|
||||||
'/vol1/docker/kt-qqbot/napcat-instances',
|
'/vol1/docker/kt-qqbot/napcat-instances',
|
||||||
),
|
),
|
||||||
|
napcatImage: this.getString('QQBOT_NAPCAT_IMAGE'),
|
||||||
napcatContainerMode: this.getString('QQBOT_NAPCAT_CONTAINER_MODE'),
|
napcatContainerMode: this.getString('QQBOT_NAPCAT_CONTAINER_MODE'),
|
||||||
napcatSshTarget: this.getString('QQBOT_NAPCAT_SSH_TARGET', 'nas'),
|
napcatSshTarget: this.getString('QQBOT_NAPCAT_SSH_TARGET', 'nas'),
|
||||||
napcatSshPort: this.getPositiveNumber('QQBOT_NAPCAT_SSH_PORT', 22),
|
napcatSshPort: this.getPositiveNumber('QQBOT_NAPCAT_SSH_PORT', 22),
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export interface RuntimeQqbotConfig {
|
|||||||
reverseWsPath: string;
|
reverseWsPath: string;
|
||||||
reverseWsToken: string;
|
reverseWsToken: string;
|
||||||
napcatRoot: string;
|
napcatRoot: string;
|
||||||
|
napcatImage: string;
|
||||||
napcatContainerMode: string;
|
napcatContainerMode: string;
|
||||||
napcatSshTarget: string;
|
napcatSshTarget: string;
|
||||||
napcatSshPort: number;
|
napcatSshPort: number;
|
||||||
|
|||||||
@ -5,12 +5,30 @@ import {
|
|||||||
} from './runtime-evidence.types';
|
} from './runtime-evidence.types';
|
||||||
|
|
||||||
const REDACTED_VALUE = '<redacted>';
|
const REDACTED_VALUE = '<redacted>';
|
||||||
|
const REDACTED_BASE64_VALUE = '<redacted-base64>';
|
||||||
const SENSITIVE_KEY_PATTERN =
|
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]> = [
|
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(Authorization)\s*[:=]\s*Bearer\s+[^\s,;]+/gi, '$1=<redacted>'],
|
||||||
[/\b(Cookie)\s*[:=]\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()
|
@Injectable()
|
||||||
@ -63,6 +81,7 @@ export class RuntimeEvidenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isSensitiveKey(key: string) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
test/common/swagger-response.spec.ts
Normal file
49
test/common/swagger-response.spec.ts
Normal 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),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -99,6 +99,7 @@ describe('RuntimeConfigService', () => {
|
|||||||
LOKI_USERNAME: 'loki-user',
|
LOKI_USERNAME: 'loki-user',
|
||||||
LOKI_PASSWORD: 'loki-password',
|
LOKI_PASSWORD: 'loki-password',
|
||||||
QQBOT_NAPCAT_ROOT: '/vol1/docker/napcat',
|
QQBOT_NAPCAT_ROOT: '/vol1/docker/napcat',
|
||||||
|
QQBOT_NAPCAT_IMAGE: 'mlikiowa/napcat-docker:latest',
|
||||||
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
|
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
|
||||||
QQBOT_NAPCAT_SSH_TARGET: 'nas',
|
QQBOT_NAPCAT_SSH_TARGET: 'nas',
|
||||||
QQBOT_NAPCAT_SSH_PORT: '2202',
|
QQBOT_NAPCAT_SSH_PORT: '2202',
|
||||||
@ -134,6 +135,7 @@ describe('RuntimeConfigService', () => {
|
|||||||
reverseWsPath: '/qqbot/reverse',
|
reverseWsPath: '/qqbot/reverse',
|
||||||
reverseWsToken: 'qq***en',
|
reverseWsToken: 'qq***en',
|
||||||
napcatRoot: '/vol1/docker/napcat',
|
napcatRoot: '/vol1/docker/napcat',
|
||||||
|
napcatImage: 'mlikiowa/napcat-docker:latest',
|
||||||
napcatContainerMode: 'ssh',
|
napcatContainerMode: 'ssh',
|
||||||
napcatSshTarget: 'nas',
|
napcatSshTarget: 'nas',
|
||||||
napcatSshPort: 2202,
|
napcatSshPort: 2202,
|
||||||
@ -151,6 +153,13 @@ describe('RuntimeConfigService', () => {
|
|||||||
present: true,
|
present: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(checks).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'QQBOT_NAPCAT_IMAGE',
|
||||||
|
level: 'optional',
|
||||||
|
present: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(checks).toContainEqual(
|
expect(checks).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
key: 'QQBOT_NAPCAT_REVERSE_WS_URL|QQBOT_NAPCAT_REVERSE_WS_BASE',
|
key: 'QQBOT_NAPCAT_REVERSE_WS_URL|QQBOT_NAPCAT_REVERSE_WS_BASE',
|
||||||
|
|||||||
@ -179,4 +179,41 @@ describe('RuntimeEvidenceService', () => {
|
|||||||
expect(input.assertions[0].message).toContain('raw-reply-text');
|
expect(input.assertions[0].message).toContain('raw-reply-text');
|
||||||
expect(input.cleanup.message).toContain('raw-cookie');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -48,6 +48,7 @@ describe('RuntimeHealthController', () => {
|
|||||||
reverseWsPath: '/qqbot/onebot/reverse',
|
reverseWsPath: '/qqbot/onebot/reverse',
|
||||||
reverseWsToken: 'qq***en',
|
reverseWsToken: 'qq***en',
|
||||||
napcatRoot: '/vol1/docker/napcat',
|
napcatRoot: '/vol1/docker/napcat',
|
||||||
|
napcatImage: 'mlikiowa/napcat-docker:latest',
|
||||||
napcatContainerMode: 'ssh',
|
napcatContainerMode: 'ssh',
|
||||||
napcatSshTarget: 'nas',
|
napcatSshTarget: 'nas',
|
||||||
napcatSshPort: 2202,
|
napcatSshPort: 2202,
|
||||||
|
|||||||
@ -44,6 +44,7 @@ function createSnapshot(
|
|||||||
reverseWsPath: '/qqbot/onebot/reverse',
|
reverseWsPath: '/qqbot/onebot/reverse',
|
||||||
reverseWsToken: 'qq***en',
|
reverseWsToken: 'qq***en',
|
||||||
napcatRoot: '/vol1/docker/napcat',
|
napcatRoot: '/vol1/docker/napcat',
|
||||||
|
napcatImage: 'mlikiowa/napcat-docker:latest',
|
||||||
napcatContainerMode: 'ssh',
|
napcatContainerMode: 'ssh',
|
||||||
napcatSshTarget: 'nas',
|
napcatSshTarget: 'nas',
|
||||||
napcatSshPort: 2202,
|
napcatSshPort: 2202,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user