import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { redactSecrets, sanitizeSessionIdForFilename, writeAuditLog } from '../src/core/audit.ts'; import type { AuditLogEntry } from '../src/types.ts'; describe('sanitizeSessionIdForFilename', () => { test('returns valid session id unchanged', () => { expect(sanitizeSessionIdForFilename('test-session-123')).toBe('test-session-123'); }); test('replaces invalid characters with underscores', () => { expect(sanitizeSessionIdForFilename('test/session')).toBe('test_session'); expect(sanitizeSessionIdForFilename('test\\session')).toBe('test_session'); expect(sanitizeSessionIdForFilename('test:session')).toBe('test_session'); }); test('strips leading/trailing special chars', () => { expect(sanitizeSessionIdForFilename('.session')).toBe('session'); expect(sanitizeSessionIdForFilename('session.')).toBe('session'); expect(sanitizeSessionIdForFilename('-session-')).toBe('session'); expect(sanitizeSessionIdForFilename('_session_')).toBe('session'); }); test('returns null for empty or invalid input', () => { expect(sanitizeSessionIdForFilename('')).toBeNull(); expect(sanitizeSessionIdForFilename(' ')).toBeNull(); expect(sanitizeSessionIdForFilename('...')).toBeNull(); expect(sanitizeSessionIdForFilename('..')).toBeNull(); expect(sanitizeSessionIdForFilename('.')).toBeNull(); }); test('truncates long session ids', () => { const longId = 'a'.repeat(200); const result = sanitizeSessionIdForFilename(longId); expect(result?.length).toBeLessThanOrEqual(128); }); test('handles path traversal attempts', () => { const result = sanitizeSessionIdForFilename('../../etc/passwd'); expect(result).not.toContain('/'); expect(result).not.toContain('..'); }); }); describe('redactSecrets', () => { test('redacts TOKEN=value patterns', () => { const result = redactSecrets('TOKEN=secret123 git reset --hard'); expect(result).toContain(''); expect(result).not.toContain('secret123'); }); test('redacts API_KEY patterns', () => { const result = redactSecrets('API_KEY=mysecretkey'); expect(result).toContain(''); expect(result).not.toContain('mysecretkey'); }); test('redacts GitHub tokens', () => { const result = redactSecrets('ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'); expect(result).toBe(''); }); test('redacts URL credentials', () => { const result = redactSecrets('https://user:password@example.com'); expect(result).not.toContain('password'); expect(result).toContain(''); }); test('preserves non-secret content', () => { const result = redactSecrets('git reset --hard'); expect(result).toBe('git reset --hard'); }); test('redacts Authorization Bearer token', () => { const result = redactSecrets('curl -H "Authorization: Bearer abc123" https://example.com'); expect(result).not.toContain('abc123'); expect(result).toContain(''); }); test('redacts Authorization Basic token', () => { const result = redactSecrets("curl -H 'Authorization: Basic abc123' https://example.com"); expect(result).not.toContain('abc123'); expect(result).toContain(''); }); }); describe('writeAuditLog', () => { let testDir: string; beforeEach(() => { testDir = join( tmpdir(), `safety-net-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); mkdirSync(testDir, { recursive: true }); }); afterEach(() => { if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } }); function getLogFile(sessionId: string): string { return join(testDir, '.cc-safety-net', 'logs', `${sessionId}.jsonl`); } function readLogEntries(sessionId: string): AuditLogEntry[] { const logFile = getLogFile(sessionId); if (!existsSync(logFile)) { return []; } const content = readFileSync(logFile, 'utf-8'); return content .split('\n') .filter((line) => line.trim()) .map((line) => JSON.parse(line) as AuditLogEntry); } test('denied command creates log entry', () => { const sessionId = 'test-session-123'; writeAuditLog( sessionId, 'git reset --hard', 'git reset --hard', 'git reset --hard destroys uncommitted changes', '/home/user/project', { homeDir: testDir }, ); const entries = readLogEntries(sessionId); expect(entries.length).toBe(1); expect(entries[0]?.command).toContain('git reset --hard'); }); test('log format has correct fields', () => { const sessionId = 'test-session-789'; writeAuditLog( sessionId, 'git reset --hard', 'git reset --hard', 'git reset --hard destroys uncommitted changes', '/home/user/project', { homeDir: testDir }, ); const entries = readLogEntries(sessionId); expect(entries.length).toBe(1); expect(entries[0]).toHaveProperty('ts'); expect(entries[0]).toHaveProperty('command'); expect(entries[0]).toHaveProperty('segment'); expect(entries[0]).toHaveProperty('reason'); expect(entries[0]).toHaveProperty('cwd'); expect(entries[0]?.cwd).toBe('/home/user/project'); expect(entries[0]?.reason).toContain('git reset --hard'); }); test('log redacts secrets', () => { const sessionId = 'test-session-redact'; writeAuditLog( sessionId, 'TOKEN=secret123 git reset --hard', 'TOKEN=secret123 git reset --hard', 'git reset --hard destroys uncommitted changes', null, { homeDir: testDir }, ); const entries = readLogEntries(sessionId); expect(entries.length).toBe(1); expect(entries[0]?.command).not.toContain('secret123'); expect(entries[0]?.command).toContain(''); }); test('missing session id creates no log', () => { // Empty session ID writeAuditLog('', 'git reset --hard', 'git reset --hard', 'reason', null, { homeDir: testDir, }); const logsDir = join(testDir, '.cc-safety-net', 'logs'); if (existsSync(logsDir)) { const files = readdirSync(logsDir); expect(files.length).toBe(0); } }); test('multiple denials append to same log', () => { const sessionId = 'test-session-multi'; writeAuditLog(sessionId, 'git reset --hard', 'git reset --hard', 'reason1', null, { homeDir: testDir, }); writeAuditLog(sessionId, 'git clean -f', 'git clean -f', 'reason2', null, { homeDir: testDir, }); writeAuditLog(sessionId, 'rm -rf /', 'rm -rf /', 'reason3', null, { homeDir: testDir, }); const entries = readLogEntries(sessionId); expect(entries.length).toBe(3); expect(entries[0]?.command).toContain('git reset --hard'); expect(entries[1]?.command).toContain('git clean -f'); expect(entries[2]?.command).toContain('rm -rf /'); }); test('session id path traversal does not escape logs dir', () => { const sessionId = '../../outside'; writeAuditLog(sessionId, 'git reset --hard', 'git reset --hard', 'reason', null, { homeDir: testDir, }); // Verify no file was created outside the logs dir expect(existsSync(join(testDir, 'outside.jsonl'))).toBe(false); // Verify log was created in the correct location const logsDir = join(testDir, '.cc-safety-net', 'logs'); if (existsSync(logsDir)) { const files = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl')); expect(files.length).toBe(1); // The file should be inside logs dir for (const file of files) { const fullPath = join(logsDir, file); expect(fullPath.startsWith(logsDir)).toBe(true); } } }); test('session id absolute path does not escape logs dir', () => { const sessionId = join(testDir, 'escaped'); writeAuditLog(sessionId, 'git reset --hard', 'git reset --hard', 'reason', null, { homeDir: testDir, }); // Verify no file was created at the escaped location expect(existsSync(join(testDir, 'escaped.jsonl'))).toBe(false); // Verify log was created in the correct location const logsDir = join(testDir, '.cc-safety-net', 'logs'); if (existsSync(logsDir)) { const files = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl')); expect(files.length).toBe(1); for (const file of files) { const fullPath = join(logsDir, file); expect(fullPath.startsWith(logsDir)).toBe(true); } } }); test('cwd null when not provided', () => { const sessionId = 'test-session-no-cwd'; writeAuditLog(sessionId, 'git reset --hard', 'git reset --hard', 'reason', null, { homeDir: testDir, }); const entries = readLogEntries(sessionId); expect(entries.length).toBe(1); expect(entries[0]?.cwd).toBeNull(); }); test('truncates long commands', () => { const sessionId = 'test-session-long'; const longCommand = `git reset --hard ${'x'.repeat(500)}`; writeAuditLog(sessionId, longCommand, longCommand, 'reason', null, { homeDir: testDir, }); const entries = readLogEntries(sessionId); expect(entries.length).toBe(1); expect(entries[0]?.command.length).toBeLessThanOrEqual(300); }); });