From 5f9067cf5756b894241e3211ed70d5651f2bd911 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 17:00:52 -0500 Subject: [PATCH] fix(tess): redact sensitive runtime content --- .../commands/command-executor-p8012.spec.ts | 15 +++----- .../src/commands/command-executor.service.ts | 18 ++++++---- packages/log/src/index.ts | 5 +++ packages/log/src/redaction.spec.ts | 14 ++++++++ packages/log/src/redaction.ts | 35 +++++++++++++++++++ 5 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 packages/log/src/redaction.spec.ts create mode 100644 packages/log/src/redaction.ts diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index 242d2ac..fc5d966 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -106,8 +106,8 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(result.command).toBe('provider'); }); - // /provider login anthropic — success with URL containing poll token - it('/provider login returns success with URL and poll token', async () => { + // /provider login anthropic — no bearer token or auth URL reaches chat output + it('/provider login keeps its one-time token out of chat output', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login anthropic', @@ -117,14 +117,9 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(result.success).toBe(true); expect(result.command).toBe('provider'); expect(result.message).toContain('anthropic'); - expect(result.message).toContain('http'); - // data should contain loginUrl and pollToken - expect(result.data).toBeDefined(); - const data = result.data as Record; - expect(typeof data['loginUrl']).toBe('string'); - expect(typeof data['pollToken']).toBe('string'); - expect(data['loginUrl'] as string).toContain('anthropic'); - expect(data['loginUrl'] as string).toContain(data['pollToken'] as string); + expect(result.message).not.toContain('http'); + expect(result.message).not.toContain('token='); + expect(result.data).toEqual({ provider: 'anthropic' }); // Verify Valkey was called expect(mockRedis.set).toHaveBeenCalledOnce(); const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number]; diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index a7a455a..6c2a2a7 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -436,22 +436,28 @@ export class CommandExecutorService { }; } const pollToken = crypto.randomUUID(); - const key = `mosaic:auth:poll:${pollToken}`; - // Store pending state in Valkey (TTL 5 minutes) + const tokenDigest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(pollToken), + ); + const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string => + byte.toString(16).padStart(2, '0'), + ).join(''); + const key = `mosaic:auth:poll:${tokenHash}`; + // Persist only a short-lived token digest. The raw token is delivered only by + // the authenticated dashboard flow, never in chat output or command metadata. await this.redis.set( key, JSON.stringify({ status: 'pending', provider: providerName, userId }), 'EX', 300, ); - // In production this would construct an OAuth URL - const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`; return { command: 'provider', success: true, - message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`, + message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`, conversationId, - data: { loginUrl, pollToken, provider: providerName }, + data: { provider: providerName }, }; } diff --git a/packages/log/src/index.ts b/packages/log/src/index.ts index 86bdffe..9b49ccf 100644 --- a/packages/log/src/index.ts +++ b/packages/log/src/index.ts @@ -10,3 +10,8 @@ export { type LogQuery, } from './agent-logs.js'; export { registerLogCommand } from './cli.js'; +export { + redactSensitiveContent, + type RedactionResult, + type SensitiveClassification, +} from './redaction.js'; diff --git a/packages/log/src/redaction.spec.ts b/packages/log/src/redaction.spec.ts new file mode 100644 index 0000000..9016398 --- /dev/null +++ b/packages/log/src/redaction.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { redactSensitiveContent } from './redaction.js'; + +describe('redactSensitiveContent', (): void => { + it('redacts seeded secret and PII canaries before persistence or egress', (): void => { + const result = redactSensitiveContent( + 'email canary@example.test token=sk_CANARY12345678 phone +1 555 555 1212', + ); + expect(result.content).not.toContain('canary@example.test'); + expect(result.content).not.toContain('sk_CANARY12345678'); + expect(result.content).not.toContain('+1 555 555 1212'); + expect(result.classifications).toEqual(['secret', 'pii']); + }); +}); diff --git a/packages/log/src/redaction.ts b/packages/log/src/redaction.ts new file mode 100644 index 0000000..2bbbc0f --- /dev/null +++ b/packages/log/src/redaction.ts @@ -0,0 +1,35 @@ +export type SensitiveClassification = 'secret' | 'pii'; + +export interface RedactionResult { + content: string; + classifications: SensitiveClassification[]; +} + +const SECRET_PATTERNS: RegExp[] = [ + /\b(?:sk|ghp|gitea)_[A-Za-z0-9_-]{8,}\b/g, + /\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi, +]; +const PII_PATTERNS: RegExp[] = [ + /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, + /\b\+?\d[\d(). -]{7,}\d\b/g, +]; + +export function redactSensitiveContent(content: string): RedactionResult { + let redacted = content; + const classifications: SensitiveClassification[] = []; + for (const pattern of SECRET_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('secret'); + redacted = redacted.replace(pattern, '[REDACTED_SECRET]'); + } + pattern.lastIndex = 0; + } + for (const pattern of PII_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('pii'); + redacted = redacted.replace(pattern, '[REDACTED_PII]'); + } + pattern.lastIndex = 0; + } + return { content: redacted, classifications: [...new Set(classifications)] }; +}