fix: 加强运行时证据脱敏

This commit is contained in:
sunlei 2026-06-13 17:31:58 +08:00
parent 0892fd8048
commit ce20c350cd
3 changed files with 118 additions and 52 deletions

View File

@ -666,69 +666,72 @@ export interface RuntimeEvidenceRecord extends RuntimeEvidenceInput {
```ts
import { Injectable } from '@nestjs/common';
import {
RuntimeEvidenceCleanupResult,
RuntimeEvidenceInput,
RuntimeEvidenceRecord,
} from './runtime-evidence.types';
const REDACTED_VALUE = '<redacted>';
const SENSITIVE_KEY_PATTERN =
/password|secret|token|authorization|cookie|privateKey|sshKey|ticket|randstr|replyText/i;
const SENSITIVE_TEXT_REPLACEMENTS: Array<[RegExp, string]> = [
[/\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>'],
];
@Injectable()
export class RuntimeEvidenceService {
createRecord(input: RuntimeEvidenceInput): RuntimeEvidenceRecord {
const startedAt = input.startedAt ?? new Date();
const endedAt = input.endedAt ?? new Date();
return {
const record: RuntimeEvidenceRecord = {
...input,
details: this.sanitizeRecord(input.details),
cleanup: this.sanitizeCleanup(input.cleanup),
startedAt,
endedAt,
durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()),
schemaVersion: 1,
};
}
sanitizeRecord<T extends Record<string, unknown> | undefined>(value: T): T {
if (!value) return value;
return this.sanitizeValue(value) as T;
}
private sanitizeCleanup(
cleanup: RuntimeEvidenceCleanupResult | undefined,
): RuntimeEvidenceCleanupResult | undefined {
if (!cleanup) return undefined;
return {
...cleanup,
details: this.sanitizeRecord(cleanup.details),
};
return this.sanitizeValue(record) as RuntimeEvidenceRecord;
}
private sanitizeValue(value: unknown): unknown {
if (value instanceof Date) return value;
if (typeof value === 'string') return this.sanitizeText(value);
if (Array.isArray(value)) {
return value.map((item) => this.sanitizeValue(item));
}
if (value instanceof Date) {
return value;
}
if (!value || typeof value !== 'object') {
if (!this.isPlainRecord(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
Object.entries(value).map(([key, item]) => [
key,
this.isSensitiveKey(key) ? '<redacted>' : this.sanitizeValue(item),
this.isSensitiveKey(key) ? REDACTED_VALUE : this.sanitizeValue(item),
]),
);
}
private isSensitiveKey(key: string) {
return /password|secret|token|authorization|cookie|privateKey|sshKey|ticket|randstr|replyText/i.test(
key,
private sanitizeText(value: string) {
return SENSITIVE_TEXT_REPLACEMENTS.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
value,
);
}
private isPlainRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
!(value instanceof Date)
);
}
private isSensitiveKey(key: string) {
return SENSITIVE_KEY_PATTERN.test(key);
}
}
```

View File

@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import {
RuntimeEvidenceCleanupResult,
RuntimeEvidenceInput,
RuntimeEvidenceRecord,
} from './runtime-evidence.types';
@ -8,44 +7,31 @@ import {
const REDACTED_VALUE = '<redacted>';
const SENSITIVE_KEY_PATTERN =
/password|secret|token|authorization|cookie|privateKey|sshKey|ticket|randstr|replyText/i;
const SENSITIVE_TEXT_REPLACEMENTS: Array<[RegExp, string]> = [
[/\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>'],
];
@Injectable()
export class RuntimeEvidenceService {
createRecord(input: RuntimeEvidenceInput): RuntimeEvidenceRecord {
const startedAt = input.startedAt ?? new Date();
const endedAt = input.endedAt ?? new Date();
return {
const record: RuntimeEvidenceRecord = {
...input,
startedAt,
endedAt,
durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()),
schemaVersion: 1,
details: this.sanitizeRecord(input.details),
cleanup: this.sanitizeCleanup(input.cleanup),
};
}
private sanitizeCleanup(
cleanup?: RuntimeEvidenceCleanupResult,
): RuntimeEvidenceCleanupResult | undefined {
if (!cleanup) return undefined;
return {
...cleanup,
details: this.sanitizeRecord(cleanup.details),
};
}
private sanitizeRecord(
value?: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (!value) return undefined;
return this.sanitizeValue(value) as Record<string, unknown>;
return this.sanitizeValue(record) as RuntimeEvidenceRecord;
}
private sanitizeValue(value: unknown): unknown {
if (value instanceof Date) return value;
if (typeof value === 'string') return this.sanitizeText(value);
if (Array.isArray(value)) {
return value.map((item) => this.sanitizeValue(item));
}
@ -60,6 +46,13 @@ export class RuntimeEvidenceService {
return value;
}
private sanitizeText(value: string) {
return SENSITIVE_TEXT_REPLACEMENTS.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
value,
);
}
private isPlainRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&

View File

@ -23,6 +23,25 @@ describe('RuntimeEvidenceService', () => {
expect(record.schemaVersion).toBe(1);
});
it('keeps duration non-negative when evidence ends before it starts', () => {
const service = new RuntimeEvidenceService();
const startedAt = new Date('2026-06-13T00:00:01.250Z');
const endedAt = new Date('2026-06-13T00:00:00.000Z');
const record = service.createRecord({
title: 'runtime config smoke',
taskType: 'backend',
project: 'kt-template-online-api',
environment: 'local',
operation: 'runtime-config',
status: 'failed',
startedAt,
endedAt,
});
expect(record.durationMs).toBe(0);
});
it('redacts nested sensitive fields in details and cleanup details', () => {
const service = new RuntimeEvidenceService();
@ -109,4 +128,55 @@ describe('RuntimeEvidenceService', () => {
},
});
});
it('sanitizes sensitive key-value text across the whole evidence record without mutating input', () => {
const service = new RuntimeEvidenceService();
const input = {
title: 'safe title token=raw-token',
taskType: 'api',
project: 'kt-template-online-api',
environment: 'local',
operation: 'qqbot-login',
target: 'account safe text Authorization=Bearer raw-token',
status: 'failed' as const,
error: {
category: 'operation_failed' as const,
operation: 'captcha',
message: 'safe error message ticket=raw-ticket',
cause:
'safe cause randstr=raw-randstr Authorization: Bearer raw-token Cookie=session=raw-cookie',
retryable: false,
},
assertions: [
{
name: 'reply text check',
passed: false,
message: 'safe assertion replyText=raw-reply-text',
},
],
cleanup: {
status: 'failed' as const,
message: 'safe cleanup Cookie=session=raw-cookie',
},
};
const record = service.createRecord(input);
const serialized = JSON.stringify(record);
expect(serialized).not.toContain('raw-ticket');
expect(serialized).not.toContain('raw-randstr');
expect(serialized).not.toContain('raw-token');
expect(serialized).not.toContain('raw-cookie');
expect(serialized).not.toContain('raw-reply-text');
expect(serialized).toContain('safe title');
expect(serialized).toContain('safe error message');
expect(serialized).toContain('safe cause');
expect(serialized).toContain('safe assertion');
expect(serialized).toContain('safe cleanup');
expect(input.title).toContain('raw-token');
expect(input.error.message).toContain('raw-ticket');
expect(input.error.cause).toContain('raw-randstr');
expect(input.assertions[0].message).toContain('raw-reply-text');
expect(input.cleanup.message).toContain('raw-cookie');
});
});