diff --git a/src/runtime/evidence/runtime-evidence.service.ts b/src/runtime/evidence/runtime-evidence.service.ts new file mode 100644 index 0000000..a02ce74 --- /dev/null +++ b/src/runtime/evidence/runtime-evidence.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { + RuntimeEvidenceCleanupResult, + RuntimeEvidenceInput, + RuntimeEvidenceRecord, +} from './runtime-evidence.types'; + +const REDACTED_VALUE = ''; +const SENSITIVE_KEY_PATTERN = + /password|secret|token|authorization|cookie|privateKey|sshKey|ticket|randstr|replyText/i; + +@Injectable() +export class RuntimeEvidenceService { + createRecord(input: RuntimeEvidenceInput): RuntimeEvidenceRecord { + const startedAt = input.startedAt ?? new Date(); + const endedAt = input.endedAt ?? new Date(); + + return { + ...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, + ): Record | undefined { + if (!value) return undefined; + return this.sanitizeValue(value) as Record; + } + + private sanitizeValue(value: unknown): unknown { + if (value instanceof Date) return value; + if (Array.isArray(value)) { + return value.map((item) => this.sanitizeValue(item)); + } + if (this.isPlainRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + this.isSensitiveKey(key) ? REDACTED_VALUE : this.sanitizeValue(entry), + ]), + ); + } + return value; + } + + private isPlainRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + !(value instanceof Date) + ); + } + + private isSensitiveKey(key: string) { + return SENSITIVE_KEY_PATTERN.test(key); + } +} diff --git a/src/runtime/evidence/runtime-evidence.types.ts b/src/runtime/evidence/runtime-evidence.types.ts new file mode 100644 index 0000000..e248a88 --- /dev/null +++ b/src/runtime/evidence/runtime-evidence.types.ts @@ -0,0 +1,42 @@ +import type { RuntimeClassifiedError } from '../errors/runtime-error.types'; + +export type RuntimeEvidenceStatus = + | 'passed' + | 'failed' + | 'blocked' + | 'skipped'; + +export interface RuntimeEvidenceCleanupResult { + status: RuntimeEvidenceStatus; + message: string; + details?: Record; +} + +export interface RuntimeEvidenceAssertion { + name: string; + passed: boolean; + message: string; +} + +export interface RuntimeEvidenceInput { + title: string; + taskType: string; + project: string; + environment: string; + operation: string; + target?: string; + status: RuntimeEvidenceStatus; + startedAt?: Date; + endedAt?: Date; + details?: Record; + assertions?: RuntimeEvidenceAssertion[]; + cleanup?: RuntimeEvidenceCleanupResult; + error?: RuntimeClassifiedError; +} + +export interface RuntimeEvidenceRecord extends RuntimeEvidenceInput { + startedAt: Date; + endedAt: Date; + durationMs: number; + schemaVersion: 1; +} diff --git a/test/runtime/runtime-evidence.service.spec.ts b/test/runtime/runtime-evidence.service.spec.ts new file mode 100644 index 0000000..0ab9dd7 --- /dev/null +++ b/test/runtime/runtime-evidence.service.spec.ts @@ -0,0 +1,112 @@ +import { RuntimeEvidenceService } from '../../src/runtime/evidence/runtime-evidence.service'; + +describe('RuntimeEvidenceService', () => { + it('creates records with explicit timing, duration, and schema version', () => { + const service = new RuntimeEvidenceService(); + const startedAt = new Date('2026-06-13T00:00:00.000Z'); + const endedAt = new Date('2026-06-13T00:00:01.250Z'); + + const record = service.createRecord({ + title: 'runtime config smoke', + taskType: 'backend', + project: 'kt-template-online-api', + environment: 'local', + operation: 'runtime-config', + status: 'passed', + startedAt, + endedAt, + }); + + expect(record.startedAt).toBe(startedAt); + expect(record.endedAt).toBe(endedAt); + expect(record.durationMs).toBe(1250); + expect(record.schemaVersion).toBe(1); + }); + + it('redacts nested sensitive fields in details and cleanup details', () => { + const service = new RuntimeEvidenceService(); + + const record = service.createRecord({ + title: 'qqbot captcha smoke', + taskType: 'api', + project: 'kt-template-online-api', + environment: 'local', + operation: 'qqbot-login', + target: 'account:2637330537', + status: 'blocked', + details: { + username: 'kept-user', + password: 1, + nested: { + token: true, + message: 'kept-message', + replyText: 'raw-reply-text', + }, + }, + cleanup: { + status: 'failed', + message: 'cleanup failed', + details: { + ticket: 'raw-ticket', + randstr: 'raw-randstr', + removed: false, + }, + }, + }); + + expect(record.details).toEqual({ + username: 'kept-user', + password: '', + nested: { + token: '', + message: 'kept-message', + replyText: '', + }, + }); + expect(record.cleanup?.details).toEqual({ + ticket: '', + randstr: '', + removed: false, + }); + }); + + it('sanitizes arrays and case-insensitive sensitive keys while preserving dates', () => { + const service = new RuntimeEvidenceService(); + const createdAt = new Date('2026-06-13T00:00:00.000Z'); + + const record = service.createRecord({ + title: 'array evidence', + taskType: 'backend', + project: 'kt-template-online-api', + environment: 'local', + operation: 'runtime-evidence', + status: 'failed', + details: { + events: [ + { Authorization: 'Bearer raw', name: 'kept' }, + { nested: { Cookie: 'session=raw', count: 2 } }, + ], + createdAt, + }, + }); + + const details = record.details as { + events: Array>; + createdAt: Date; + }; + + expect(details.createdAt).toBe(createdAt); + expect(details.createdAt).toBeInstanceOf(Date); + expect(details.events).toHaveLength(2); + expect(details.events[0]).toEqual({ + Authorization: '', + name: 'kept', + }); + expect(details.events[1]).toEqual({ + nested: { + Cookie: '', + count: 2, + }, + }); + }); +});