diff --git a/dist/lib/logger.js b/dist/lib/logger.js index 21ac601..8438530 100644 --- a/dist/lib/logger.js +++ b/dist/lib/logger.js @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } fr import { dirname, join } from 'path'; import { PAI_DIR, getHistoryFilePath, HISTORY_DIR } from './paths'; import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './metadata-extraction'; +import { redactString, redactObject } from './redaction'; export class Logger { sessionId; toolsUsed = new Set(); @@ -89,7 +90,7 @@ export class Logger { if (tool === 'Bash' || tool === 'bash') { const command = props?.input?.command || props?.tool_input?.command; if (command) - this.commandsExecuted.push(command); + this.commandsExecuted.push(redactString(command)); } if (['Edit', 'Write', 'edit', 'write'].includes(tool)) { const path = props?.input?.file_path || props?.input?.path || @@ -99,7 +100,7 @@ export class Logger { } } } - this.writeEvent(anyEvent.type, payload); + this.writeEvent(anyEvent.type, redactObject(payload)); } /** * Log tool execution from tool.execute.after hook @@ -127,7 +128,7 @@ export class Logger { tool_metadata: metadata, call_id: input.callID, }; - this.writeEvent('ToolUse', payload, toolName, metadata); + this.writeEvent('ToolUse', redactObject(payload), toolName, metadata); } async generateSessionSummary() { try { diff --git a/dist/lib/redaction.d.ts b/dist/lib/redaction.d.ts new file mode 100644 index 0000000..165787a --- /dev/null +++ b/dist/lib/redaction.d.ts @@ -0,0 +1,5 @@ +/** + * Redaction utility to scrub sensitive data from logs + */ +export declare function redactString(str: string): string; +export declare function redactObject(obj: any): any; diff --git a/dist/lib/redaction.js b/dist/lib/redaction.js new file mode 100644 index 0000000..3e30082 --- /dev/null +++ b/dist/lib/redaction.js @@ -0,0 +1,65 @@ +/** + * Redaction utility to scrub sensitive data from logs + */ +const SENSITIVE_KEYS = [ + 'api_key', 'apikey', 'secret', 'token', 'password', 'passwd', 'pwd', + 'auth', 'credential', 'private_key', 'client_secret', 'access_key' +]; +const SECRET_PATTERNS = [ + // AWS Access Key ID + /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, + // GitHub Personal Access Token (classic) + /\bghp_[a-zA-Z0-9]{36}\b/g, + // Generic Private Key + /-----BEGIN [A-Z ]+ PRIVATE KEY-----/g, + // Bearer Token (simple heuristic - starts with Bearer, followed by base64-ish chars) + /\bBearer\s+[a-zA-Z0-9\-\._~+/]+=*/g, +]; +// Regex for Key-Value assignments like "key=value" or "key: value" where key is sensitive +// This catches "export AWS_SECRET_KEY=..." or JSON "password": "..." +// We construct this dynamically from SENSITIVE_KEYS +const SENSITIVE_KEY_PATTERN = new RegExp(`\\b([a-zA-Z0-9_]*(${SENSITIVE_KEYS.join('|')})[a-zA-Z0-9_]*)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, 'gi'); +export function redactString(str) { + if (!str) + return str; + let redacted = str; + // 1. Redact specific patterns (like AWS keys) + for (const pattern of SECRET_PATTERNS) { + redacted = redacted.replace(pattern, '[REDACTED]'); + } + // 2. Redact key-value pairs where key suggests sensitivity + // We use a callback to preserve the key and redact the value + redacted = redacted.replace(SENSITIVE_KEY_PATTERN, (match, key, keyword, value) => { + // If value is already redacted, skip + if (value === '[REDACTED]') + return match; + // Replace the value part with [REDACTED] + return match.replace(value, '[REDACTED]'); + }); + return redacted; +} +export function redactObject(obj) { + if (obj === null || obj === undefined) + return obj; + if (typeof obj === 'string') { + return redactString(obj); + } + if (Array.isArray(obj)) { + return obj.map(item => redactObject(item)); + } + if (typeof obj === 'object') { + const newObj = {}; + for (const [key, value] of Object.entries(obj)) { + // If the key itself is sensitive, redact the value blindly if it's a string/number + const isSensitiveKey = SENSITIVE_KEYS.some(k => key.toLowerCase().includes(k)); + if (isSensitiveKey && (typeof value === 'string' || typeof value === 'number')) { + newObj[key] = '[REDACTED]'; + } + else { + newObj[key] = redactObject(value); + } + } + return newObj; + } + return obj; +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 7054c1c..73598a9 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'path'; import type { Event } from '@opencode-ai/sdk'; import { PAI_DIR, getHistoryFilePath, HISTORY_DIR } from './paths'; import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './metadata-extraction'; +import { redactString, redactObject } from './redaction'; interface HookEvent { source_app: string; @@ -115,7 +116,7 @@ export class Logger { if (tool === 'Bash' || tool === 'bash') { const command = props?.input?.command || props?.tool_input?.command; - if (command) this.commandsExecuted.push(command); + if (command) this.commandsExecuted.push(redactString(command)); } if (['Edit', 'Write', 'edit', 'write'].includes(tool)) { @@ -126,7 +127,7 @@ export class Logger { } } - this.writeEvent(anyEvent.type, payload); + this.writeEvent(anyEvent.type, redactObject(payload)); } /** @@ -162,7 +163,7 @@ export class Logger { call_id: input.callID, }; - this.writeEvent('ToolUse', payload, toolName, metadata); + this.writeEvent('ToolUse', redactObject(payload), toolName, metadata); } public async generateSessionSummary(): Promise { diff --git a/src/lib/redaction.ts b/src/lib/redaction.ts new file mode 100644 index 0000000..d75036e --- /dev/null +++ b/src/lib/redaction.ts @@ -0,0 +1,90 @@ +/** + * Redaction utility to scrub sensitive data from logs + */ + +const SENSITIVE_KEYS = [ + 'api_key', 'apikey', 'secret', 'token', 'password', 'passwd', 'pwd', + 'auth', 'credential', 'private_key', 'client_secret', 'access_key' +]; + +const SECRET_PATTERNS = [ + // AWS Access Key ID + /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, + // GitHub Personal Access Token (classic) + /\bghp_[a-zA-Z0-9]{36}\b/g, + // Generic Private Key + /-----BEGIN [A-Z ]+ PRIVATE KEY-----/g, + // Bearer Token (simple heuristic - starts with Bearer, followed by base64-ish chars) + /\bBearer\s+[a-zA-Z0-9\-\._~+/]+=*/g, +]; + +// Regex for Key-Value assignments like "key=value" or "key: value" where key is sensitive +// This catches "export AWS_SECRET_KEY=..." or JSON "password": "..." +// We construct this dynamically from SENSITIVE_KEYS +const SENSITIVE_KEY_PATTERN = new RegExp( + `\\b([a-zA-Z0-9_]*(${SENSITIVE_KEYS.join('|')})[a-zA-Z0-9_]*)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, + 'gi' +); + +export function redactString(str: string): string { + if (!str) return str; + + let redacted = str; + + // 1. Redact specific patterns (like AWS keys) + for (const pattern of SECRET_PATTERNS) { + redacted = redacted.replace(pattern, '[REDACTED]'); + } + + // 2. Redact key-value pairs where key suggests sensitivity + // We use a callback to preserve the key and redact the value + redacted = redacted.replace(SENSITIVE_KEY_PATTERN, (match, key, keyword, value) => { + // If value is already redacted, skip + if (value === '[REDACTED]') return match; + // Replace the value part with [REDACTED] + return match.replace(value, '[REDACTED]'); + }); + + return redacted; +} + +export function redactObject(obj: any, visited = new WeakSet()): any { + if (obj === null || obj === undefined) return obj; + + if (typeof obj === 'string') { + return redactString(obj); + } + + if (typeof obj !== 'object') { + return obj; + } + + if (obj instanceof Date) { + return obj; + } + + if (visited.has(obj)) { + return '[CIRCULAR]'; + } + visited.add(obj); + + if (Array.isArray(obj)) { + return obj.map(item => redactObject(item, visited)); + } + + if (typeof obj === 'object') { + const newObj: any = {}; + for (const [key, value] of Object.entries(obj)) { + // If the key itself is sensitive, redact the value blindly if it's a string/number + const isSensitiveKey = SENSITIVE_KEYS.some(k => key.toLowerCase().includes(k)); + if (isSensitiveKey && (typeof value === 'string' || typeof value === 'number')) { + newObj[key] = '[REDACTED]'; + } else { + newObj[key] = redactObject(value, visited); + } + } + return newObj; + } + + return obj; +} diff --git a/src/lib/security.ts b/src/lib/security.ts index 11cca4c..b2fffcd 100644 --- a/src/lib/security.ts +++ b/src/lib/security.ts @@ -1,3 +1,5 @@ +import { redactString } from './redaction'; + /** * Security Library for PAI Plugin * Ported from legacy security-validator.ts @@ -54,7 +56,7 @@ export function validateCommand(command: string): SecurityResult { return { status: 'deny', category, - feedback: `🚨 SECURITY: Blocked ${category} pattern. Command: ${command.slice(0, 50)}...`, + feedback: `🚨 SECURITY: Blocked ${category} pattern. Command: ${redactString(command).slice(0, 50)}...`, }; } } diff --git a/tests/redaction.test.ts b/tests/redaction.test.ts new file mode 100644 index 0000000..8224ac7 --- /dev/null +++ b/tests/redaction.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'bun:test'; +import { redactString, redactObject } from '../src/lib/redaction'; + +describe('Redaction Utility', () => { + it('should redact AWS keys', () => { + const input = 'export AWS_SECRET_KEY=AKIAIOSFODNN7EXAMPLE'; + const output = redactString(input); + expect(output).toContain('AWS_SECRET_KEY=[REDACTED]'); + expect(output).not.toContain('AKIAIOSFODNN7EXAMPLE'); + }); + + it('should redact sensitive key-value pairs', () => { + const input = 'password="superSecretPassword"'; + const output = redactString(input); + expect(output).toContain('password="[REDACTED]"'); + expect(output).not.toContain('superSecretPassword'); + }); + + it('should redact generic secrets', () => { + const input = 'my_secret_token = abcdef1234567890'; + const output = redactString(input); + expect(output).toContain('my_secret_token = [REDACTED]'); + expect(output).not.toContain('abcdef1234567890'); + }); + + it('should redact inside objects', () => { + const input = { + user: 'alice', + credentials: { + password: 'password123', + apiKey: 'AKIAIOSFODNN7EXAMPLE' + } + }; + const output = redactObject(input); + expect(output.user).toBe('alice'); + expect(output.credentials.password).toBe('[REDACTED]'); + expect(output.credentials.apiKey).toBe('[REDACTED]'); + }); + + it('should not redact harmless values', () => { + const input = 'user_id=123'; + const output = redactString(input); + expect(output).toBe(input); + }); + + it('should redact short values in objects if key is sensitive', () => { + const obj = { password: '123' }; + const output = redactObject(obj); + expect(output.password).toBe('[REDACTED]'); + }); + + it('should handle circular references', () => { + const obj: any = { name: 'circular' }; + obj.self = obj; + const output = redactObject(obj); + expect(output.name).toBe('circular'); + expect(output.self).toBe('[CIRCULAR]'); + }); + + it('should preserve Date objects', () => { + const date = new Date('2023-01-01'); + const obj = { created_at: date }; + const output = redactObject(obj); + expect(output.created_at).toEqual(date); + }); +});