From e80f891c8acc900f9b16e3637f9abf1fb91a2bd1 Mon Sep 17 00:00:00 2001 From: jeda Date: Sat, 7 Mar 2026 05:01:42 +0000 Subject: [PATCH] feat(bd-iyz): Anomaly Detection for File Activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements anomaly detection for file activity in the heatmap panel: - Add anomaly detection types (AnomalyType, AnomalySeverity, FileAnomaly) - Create fileAnomalyDetection.ts utility with detection algorithms: - Config file modifications (e.g., .env, config/, settings.json) - Sensitive file access (secrets, credentials, keys) - High-frequency modifications (outliers beyond 3x average) - Burst activity (rapid successive modifications) - Unusual multi-worker patterns (3+ workers on same file) - Add getFileAnomalies() and getAnomalyStats() methods to store - Update FileHeatmap component to show anomaly alerts: - New [a] keybinding to toggle anomaly-only view - "Unexpected activity" section at bottom of heatmap - Severity icons (🚨 critical, âš ī¸ warning, â„šī¸ info) - Update app.ts to pass anomaly getter to FileHeatmap - Add comprehensive tests for anomaly detection 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Worker --- src/store.ts | 24 ++ src/tui/app.ts | 11 +- src/tui/components/FileHeatmap.ts | 190 ++++++++++-- src/tui/utils/fileAnomalyDetection.test.ts | 270 ++++++++++++++++ src/tui/utils/fileAnomalyDetection.ts | 338 +++++++++++++++++++++ src/types.ts | 101 ++++++ 6 files changed, 906 insertions(+), 28 deletions(-) create mode 100644 src/tui/utils/fileAnomalyDetection.test.ts create mode 100644 src/tui/utils/fileAnomalyDetection.ts diff --git a/src/store.ts b/src/store.ts index c60551f..7cbcfae 100644 --- a/src/store.ts +++ b/src/store.ts @@ -35,7 +35,11 @@ import { SemanticNarrative, NarrativeOptions, NarrativeUpdate, + FileAnomaly, + AnomalyDetectionOptions, + AnomalyStats, } from './types.js'; +import { detectAnomalies, getAnomalyStats } from './tui/utils/fileAnomalyDetection.js'; import { ErrorGroupManager, getErrorGroupManager } from './errorGrouping.js'; import { RecoveryManager, getRecoveryManager } from './tui/utils/recoveryPlaybook.js'; import { CrossReferenceManager, getCrossReferenceManager } from './crossReferenceManager.js'; @@ -819,6 +823,26 @@ export class InMemoryEventStore implements EventStore { .slice(0, 20); } + // ============================================ + // File Anomaly Detection + // ============================================ + + /** + * Get file anomalies detected from current activity + */ + getFileAnomalies(options: AnomalyDetectionOptions = {}): FileAnomaly[] { + const entries = this.getFileHeatmap({ maxEntries: Infinity }); + return detectAnomalies(entries, options); + } + + /** + * Get statistics about detected anomalies + */ + getAnomalyStats(): AnomalyStats { + const anomalies = this.getFileAnomalies(); + return getAnomalyStats(anomalies); + } + // ============================================ // Bead Collision Detection // ============================================ diff --git a/src/tui/app.ts b/src/tui/app.ts index d775470..d6cb55b 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -728,13 +728,14 @@ export class FabricTuiApp { this.fileHeatmap.getElement().show(); this.fileHeatmap.updateData( (opts) => this.store.getFileHeatmap(opts), - () => this.store.getFileHeatmapStats() + () => this.store.getFileHeatmapStats(), + (opts) => this.store.getFileAnomalies(opts) ); this.fileHeatmap.focus(); // Update header this.headerBox.setContent(' FABRIC - File Heatmap'); - this.footerBox.setContent(' [s] Sort [c] Collisions [Esc] Back [?] Help [q] Quit'); + this.footerBox.setContent(' [s] Sort [c] Collisions [a] Anomalies [Esc] Back [?] Help [q] Quit'); } else if (mode === 'dag') { // Hide other panels this.workerGrid.getElement().hide(); @@ -1258,7 +1259,8 @@ General: if (this.viewMode === 'heatmap') { this.fileHeatmap.updateData( (opts) => this.store.getFileHeatmap(opts), - () => this.store.getFileHeatmapStats() + () => this.store.getFileHeatmapStats(), + (opts) => this.store.getFileAnomalies(opts) ); } @@ -1279,7 +1281,8 @@ General: if (this.viewMode === 'heatmap') { this.fileHeatmap.updateData( (opts) => this.store.getFileHeatmap(opts), - () => this.store.getFileHeatmapStats() + () => this.store.getFileHeatmapStats(), + (opts) => this.store.getFileAnomalies(opts) ); } else if (this.viewMode === 'dag') { // DAG view handles its own refresh diff --git a/src/tui/components/FileHeatmap.ts b/src/tui/components/FileHeatmap.ts index 83c352b..a4f3ba1 100644 --- a/src/tui/components/FileHeatmap.ts +++ b/src/tui/components/FileHeatmap.ts @@ -3,11 +3,13 @@ * * Displays a heatmap of files showing modification frequency and collision risks. * Helps identify hotspots and potential collision areas between workers. + * Includes anomaly detection for unexpected file activity. */ import blessed from 'blessed'; -import { FileHeatmapEntry, FileHeatmapStats, HeatmapOptions, HeatLevel } from '../../types.js'; +import { FileHeatmapEntry, FileHeatmapStats, HeatmapOptions, HeatLevel, FileAnomaly, AnomalyDetectionOptions } from '../../types.js'; import { colors, getHeatColor, getHeatIcon } from '../utils/colors.js'; +import { getAnomalyIcon, getAnomalyColor, getAnomalyTypeLabel } from '../utils/fileAnomalyDetection.js'; export interface FileHeatmapOptions { /** Parent screen */ @@ -35,10 +37,13 @@ export class FileHeatmap { private box: blessed.Widgets.BoxElement; private entries: FileHeatmapEntry[] = []; private stats: FileHeatmapStats | null = null; + private anomalies: FileAnomaly[] = []; private selectedIndex = 0; private sortMode: HeatmapSortMode = 'modifications'; private filter: string = ''; private showCollisionOnly = false; + private showAnomaliesOnly = false; + private anomalyIndex = 0; constructor(options: FileHeatmapOptions) { this.box = blessed.box({ @@ -95,6 +100,15 @@ export class FileHeatmap { // Toggle collision filter this.box.key(['c'], () => { this.showCollisionOnly = !this.showCollisionOnly; + this.showAnomaliesOnly = false; // Reset anomaly mode + this.render(); + }); + + // Toggle anomaly view + this.box.key(['a'], () => { + this.showAnomaliesOnly = !this.showAnomaliesOnly; + this.showCollisionOnly = false; // Reset collision mode + this.anomalyIndex = 0; this.render(); }); } @@ -198,22 +212,71 @@ export class FileHeatmap { const heatDist = stats.heatDistribution; const sortLabel = `Sort: ${this.sortMode}`; const filterLabel = this.showCollisionOnly ? ' | Collisions Only' : ''; + const anomalyLabel = this.showAnomaliesOnly ? ' | Anomalies Only' : ''; + + const anomalyCount = this.anomalies.length; + const anomalyDisplay = anomalyCount > 0 + ? ` | {yellow-fg}⚠ ${anomalyCount} anomalies{/}` + : ''; return `{bold}Files: ${stats.totalFiles}{/} | ` + `Mods: ${stats.totalModifications} | ` + `Active: ${stats.activeFiles} | ` + - `{red-fg}⚠ ${stats.collisionFiles}{/} | ` + - `[s] ${sortLabel}${filterLabel}\n` + + `{red-fg}⚠ ${stats.collisionFiles}{/}${anomalyDisplay} | ` + + `[s] ${sortLabel}${filterLabel}${anomalyLabel}\n` + `{blue-fg}○${heatDist.cold}{/} ` + `{yellow-fg}◐${heatDist.warm}{/} ` + `{magenta-fg}●${heatDist.hot}{/} ` + `{red-fg}đŸ”Ĩ${heatDist.critical}{/}`; } + /** + * Format a single anomaly entry + */ + private formatAnomaly(anomaly: FileAnomaly, isSelected: boolean): string { + const icon = getAnomalyIcon(anomaly.severity); + const color = getAnomalyColor(anomaly.severity); + const typeLabel = getAnomalyTypeLabel(anomaly.type); + const path = this.formatPath(anomaly.path); + + const selectedMarker = isSelected ? '>' : ' '; + + // Format: [icon] [type] [severity] [path] [message] + const severityTag = `{${color}-fg}[${anomaly.severity.toUpperCase()}]{/}`; + const typeTag = `{gray-fg}[${typeLabel}]{/}`; + + // Truncate message if too long + let message = anomaly.message; + if (message.length > 30) { + message = message.substring(0, 27) + '...'; + } + + return `${selectedMarker} {${color}-fg}${icon}{/} ${typeTag} ${severityTag} ${path}\n` + + ` ${message}`; + } + + /** + * Format anomaly section header + */ + private formatAnomalyHeader(): string { + const critical = this.anomalies.filter(a => a.severity === 'critical').length; + const warning = this.anomalies.filter(a => a.severity === 'warning').length; + const info = this.anomalies.filter(a => a.severity === 'info').length; + + return `{bold}Unexpected Activity{/} ` + + `({red-fg}🚨 ${critical}{/} ` + + `{yellow-fg}⚠ ${warning}{/} ` + + `{blue-fg}ℹ ${info}{/})`; + } + /** * Update heatmap data */ - updateData(getHeatmap: (options: HeatmapOptions) => FileHeatmapEntry[], getStats: () => FileHeatmapStats): void { + updateData( + getHeatmap: (options: HeatmapOptions) => FileHeatmapEntry[], + getStats: () => FileHeatmapStats, + getAnomalies?: (options: AnomalyDetectionOptions) => FileAnomaly[] + ): void { this.entries = getHeatmap({ sortBy: this.sortMode, maxEntries: 100, @@ -221,7 +284,14 @@ export class FileHeatmap { directoryFilter: this.filter || undefined, }); this.stats = getStats(); + + // Get anomalies if getter provided + if (getAnomalies) { + this.anomalies = getAnomalies({}); + } + this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.entries.length - 1)); + this.anomalyIndex = Math.min(this.anomalyIndex, Math.max(0, this.anomalies.length - 1)); this.render(); } @@ -239,6 +309,7 @@ export class FileHeatmap { clearFilter(): void { this.filter = ''; this.showCollisionOnly = false; + this.showAnomaliesOnly = false; this.render(); } @@ -246,8 +317,13 @@ export class FileHeatmap { * Select next entry */ selectNext(): void { - if (this.entries.length === 0) return; - this.selectedIndex = (this.selectedIndex + 1) % this.entries.length; + if (this.showAnomaliesOnly) { + if (this.anomalies.length === 0) return; + this.anomalyIndex = (this.anomalyIndex + 1) % this.anomalies.length; + } else { + if (this.entries.length === 0) return; + this.selectedIndex = (this.selectedIndex + 1) % this.entries.length; + } this.render(); } @@ -255,10 +331,17 @@ export class FileHeatmap { * Select previous entry */ selectPrevious(): void { - if (this.entries.length === 0) return; - this.selectedIndex = this.selectedIndex === 0 - ? this.entries.length - 1 - : this.selectedIndex - 1; + if (this.showAnomaliesOnly) { + if (this.anomalies.length === 0) return; + this.anomalyIndex = this.anomalyIndex === 0 + ? this.anomalies.length - 1 + : this.anomalyIndex - 1; + } else { + if (this.entries.length === 0) return; + this.selectedIndex = this.selectedIndex === 0 + ? this.entries.length - 1 + : this.selectedIndex - 1; + } this.render(); } @@ -269,6 +352,13 @@ export class FileHeatmap { return this.entries[this.selectedIndex]; } + /** + * Get currently selected anomaly + */ + getSelectedAnomaly(): FileAnomaly | undefined { + return this.anomalies[this.anomalyIndex]; + } + /** * Get current sort mode */ @@ -283,6 +373,13 @@ export class FileHeatmap { return this.showCollisionOnly; } + /** + * Get anomaly filter state + */ + getAnomalyFilter(): boolean { + return this.showAnomaliesOnly; + } + /** * Render the component */ @@ -295,27 +392,72 @@ export class FileHeatmap { lines.push(''); // Empty line separator } - if (this.entries.length === 0) { - lines.push('{gray-fg}No file modifications detected{/}'); - if (this.showCollisionOnly) { - lines.push('{gray-fg}Press [c] to show all files{/}'); + // Anomaly-only view mode + if (this.showAnomaliesOnly) { + lines.push(this.formatAnomalyHeader()); + lines.push(''); + + if (this.anomalies.length === 0) { + lines.push('{green-fg}✓ No anomalies detected{/}'); + lines.push('{gray-fg}Press [a] to return to file view{/}'); + } else { + for (let i = 0; i < this.anomalies.length; i++) { + const anomaly = this.anomalies[i]; + const isSelected = i === this.anomalyIndex; + lines.push(this.formatAnomaly(anomaly, isSelected)); + } + + // Footer help + lines.push(''); + lines.push('{gray-fg}[a] Back to files [j/k] Scroll{/}'); } } else { - for (let i = 0; i < this.entries.length; i++) { - const entry = this.entries[i]; - const isSelected = i === this.selectedIndex; - lines.push(this.formatEntry(entry, isSelected)); + // Normal file view + if (this.entries.length === 0) { + lines.push('{gray-fg}No file modifications detected{/}'); + if (this.showCollisionOnly) { + lines.push('{gray-fg}Press [c] to show all files{/}'); + } + } else { + for (let i = 0; i < this.entries.length; i++) { + const entry = this.entries[i]; + const isSelected = i === this.selectedIndex; + lines.push(this.formatEntry(entry, isSelected)); + } + + // Footer help + lines.push(''); + lines.push('{gray-fg}[s] Sort [c] Collisions [a] Anomalies [j/k] Scroll{/}'); } - // Footer help - lines.push(''); - lines.push('{gray-fg}[s] Sort [c] Collisions only [j/k] Scroll{/}'); + // Add anomaly summary section if there are anomalies + if (this.anomalies.length > 0 && !this.showCollisionOnly) { + lines.push(''); + lines.push('─'.repeat(40)); + lines.push(this.formatAnomalyHeader()); + + // Show top 3 anomalies + const topAnomalies = this.anomalies.slice(0, 3); + for (const anomaly of topAnomalies) { + const icon = getAnomalyIcon(anomaly.severity); + const color = getAnomalyColor(anomaly.severity); + const path = this.formatPath(anomaly.path, 25); + lines.push(` {${color}-fg}${icon}{/} ${path}`); + } + + if (this.anomalies.length > 3) { + lines.push(` {gray-fg} ... +${this.anomalies.length - 3} more (press [a] to view){/}`); + } + } } // Update label with current mode - const label = this.showCollisionOnly - ? ' File Heatmap [COLLISIONS] ' - : ' File Heatmap '; + let label = ' File Heatmap '; + if (this.showCollisionOnly) { + label = ' File Heatmap [COLLISIONS] '; + } else if (this.showAnomaliesOnly) { + label = ' File Heatmap [ANOMALIES] '; + } this.box.setLabel(label); this.box.setContent(lines.join('\n')); diff --git a/src/tui/utils/fileAnomalyDetection.test.ts b/src/tui/utils/fileAnomalyDetection.test.ts new file mode 100644 index 0000000..5e9e23d --- /dev/null +++ b/src/tui/utils/fileAnomalyDetection.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for File Anomaly Detection + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + detectAnomalies, + getAnomalyStats, + getAnomalyIcon, + getAnomalyColor, + getAnomalyTypeLabel, +} from './fileAnomalyDetection.js'; +import { FileHeatmapEntry, AnomalyType, AnomalySeverity } from '../../types.js'; + +// Helper to create mock heatmap entries +function createMockEntry(overrides: Partial = {}): FileHeatmapEntry { + return { + path: '/src/test.ts', + modifications: 1, + heatLevel: 'cold', + workers: [{ workerId: 'worker-1', modifications: 1, lastModified: Date.now(), percentage: 100 }], + firstModified: Date.now() - 1000, + lastModified: Date.now(), + hasCollision: false, + activeWorkers: 1, + avgModificationInterval: 0, + ...overrides, + }; +} + +describe('detectAnomalies', () => { + it('should return empty array for normal file activity', () => { + const entries: FileHeatmapEntry[] = [ + createMockEntry({ path: '/src/index.ts', modifications: 3 }), + createMockEntry({ path: '/src/utils.ts', modifications: 2 }), + ]; + + const anomalies = detectAnomalies(entries); + + expect(anomalies.length).toBe(0); + }); + + it('should detect config file modifications', () => { + const entries: FileHeatmapEntry[] = [ + createMockEntry({ path: '/src/config/app.config.ts', modifications: 1 }), + ]; + + const anomalies = detectAnomalies(entries); + + expect(anomalies.length).toBeGreaterThan(0); + expect(anomalies[0].type).toBe('config_modification'); + expect(anomalies[0].severity).toBe('warning'); + expect(anomalies[0].message).toContain('Configuration file'); + }); + + it('should detect various config file patterns', () => { + const configPaths = [ + '/src/.env', + '/src/.env.production', + '/src/config/database.yml', + '/src/settings.json', + '/src/app.config.js', + ]; + + for (const path of configPaths) { + const entries = [createMockEntry({ path })]; + const anomalies = detectAnomalies(entries); + expect(anomalies.some(a => a.type === 'config_modification')).toBe(true); + } + }); + + it('should detect sensitive file access', () => { + const entries: FileHeatmapEntry[] = [ + createMockEntry({ path: '/src/secrets/api-key.ts', modifications: 1 }), + ]; + + const anomalies = detectAnomalies(entries); + + expect(anomalies.some(a => a.type === 'sensitive_file')).toBe(true); + const sensitive = anomalies.find(a => a.type === 'sensitive_file'); + expect(sensitive?.severity).toBe('critical'); + }); + + it('should detect various sensitive file patterns', () => { + const sensitivePaths = [ + '/src/auth/password.ts', + '/src/credentials/db.ts', + '/src/.ssh/id_rsa', + '/src/certs/server.pem', + ]; + + for (const path of sensitivePaths) { + const entries = [createMockEntry({ path })]; + const anomalies = detectAnomalies(entries); + expect(anomalies.some(a => a.type === 'sensitive_file')).toBe(true); + } + }); + + it('should detect high-frequency modifications', () => { + // Create entries where one has significantly more modifications + const entries: FileHeatmapEntry[] = [ + createMockEntry({ path: '/src/normal.ts', modifications: 5 }), + createMockEntry({ path: '/src/normal2.ts', modifications: 3 }), + createMockEntry({ path: '/src/normal3.ts', modifications: 4 }), + createMockEntry({ path: '/src/hot.ts', modifications: 50 }), // 10x average + ]; + + const anomalies = detectAnomalies(entries); + + const highFreq = anomalies.find(a => a.type === 'high_frequency'); + expect(highFreq).toBeDefined(); + expect(highFreq?.path).toBe('/src/hot.ts'); + expect(highFreq?.details.actualValue).toBe(50); + expect(highFreq?.details.expectedValue).toBeDefined(); + }); + + it('should detect burst activity', () => { + const entries: FileHeatmapEntry[] = [ + createMockEntry({ + path: '/src/burst.ts', + modifications: 15, + avgModificationInterval: 100, // Very fast - 100ms between mods + firstModified: Date.now() - 1500, + lastModified: Date.now(), + }), + ]; + + const anomalies = detectAnomalies(entries); + + const burst = anomalies.find(a => a.type === 'burst_activity'); + expect(burst).toBeDefined(); + expect(burst?.message).toContain('Burst activity'); + }); + + it('should detect unusual multi-worker patterns', () => { + const entries: FileHeatmapEntry[] = [ + createMockEntry({ + path: '/src/shared.ts', + modifications: 10, + workers: [ + { workerId: 'w1', modifications: 4, lastModified: Date.now(), percentage: 40 }, + { workerId: 'w2', modifications: 3, lastModified: Date.now(), percentage: 30 }, + { workerId: 'w3', modifications: 3, lastModified: Date.now(), percentage: 30 }, + ], + }), + ]; + + const anomalies = detectAnomalies(entries); + + const unusual = anomalies.find(a => a.type === 'unusual_pattern'); + expect(unusual).toBeDefined(); + expect(unusual?.severity).toBe('info'); + }); + + it('should sort anomalies by severity', () => { + const entries: FileHeatmapEntry[] = [ + createMockEntry({ path: '/src/config.ts', modifications: 1 }), // warning + createMockEntry({ path: '/src/secrets.ts', modifications: 1 }), // critical + createMockEntry({ path: '/src/shared.ts', modifications: 10, workers: [ // info + { workerId: 'w1', modifications: 4, lastModified: Date.now(), percentage: 40 }, + { workerId: 'w2', modifications: 3, lastModified: Date.now(), percentage: 30 }, + { workerId: 'w3', modifications: 3, lastModified: Date.now(), percentage: 30 }, + ]}), + ]; + + const anomalies = detectAnomalies(entries); + + // Should be sorted: critical first, then warning, then info + const severities = anomalies.map(a => a.severity); + expect(severities.indexOf('critical')).toBeLessThan(severities.indexOf('warning')); + expect(severities.indexOf('warning')).toBeLessThan(severities.indexOf('info')); + }); + + it('should respect minModifications option', () => { + const entries: FileHeatmapEntry[] = [ + createMockEntry({ path: '/src/config.ts', modifications: 0 }), + ]; + + const anomalies = detectAnomalies(entries, { minModifications: 1 }); + + expect(anomalies.length).toBe(0); + }); + + it('should respect frequencyThreshold option', () => { + const entries: FileHeatmapEntry[] = [ + createMockEntry({ path: '/src/a.ts', modifications: 5 }), + createMockEntry({ path: '/src/b.ts', modifications: 4 }), + createMockEntry({ path: '/src/hot.ts', modifications: 20 }), // 4x average + ]; + + // With high threshold, no anomaly + const anomalies1 = detectAnomalies(entries, { frequencyThreshold: 10 }); + expect(anomalies1.some(a => a.type === 'high_frequency')).toBe(false); + + // With low threshold, should detect + const anomalies2 = detectAnomalies(entries, { frequencyThreshold: 2 }); + expect(anomalies2.some(a => a.type === 'high_frequency')).toBe(true); + }); +}); + +describe('getAnomalyStats', () => { + it('should return correct statistics', () => { + const anomalies = [ + { + path: '/src/config.ts', + type: 'config_modification' as AnomalyType, + severity: 'warning' as AnomalySeverity, + message: 'Test', + detectedAt: Date.now(), + details: {}, + }, + { + path: '/src/config.ts', + type: 'high_frequency' as AnomalyType, + severity: 'critical' as AnomalySeverity, + message: 'Test', + detectedAt: Date.now(), + details: {}, + }, + { + path: '/src/secret.ts', + type: 'sensitive_file' as AnomalyType, + severity: 'critical' as AnomalySeverity, + message: 'Test', + detectedAt: Date.now(), + details: {}, + }, + ]; + + const stats = getAnomalyStats(anomalies); + + expect(stats.totalAnomalies).toBe(3); + expect(stats.byType.config_modification).toBe(1); + expect(stats.byType.high_frequency).toBe(1); + expect(stats.byType.sensitive_file).toBe(1); + expect(stats.bySeverity.critical).toBe(2); + expect(stats.bySeverity.warning).toBe(1); + expect(stats.topAnomalyFiles[0].path).toBe('/src/config.ts'); + expect(stats.topAnomalyFiles[0].count).toBe(2); + }); + + it('should handle empty anomalies', () => { + const stats = getAnomalyStats([]); + + expect(stats.totalAnomalies).toBe(0); + expect(stats.topAnomalyFiles.length).toBe(0); + }); +}); + +describe('anomaly display helpers', () => { + it('should return correct icons for severity', () => { + expect(getAnomalyIcon('critical')).toBe('🚨'); + expect(getAnomalyIcon('warning')).toBe('âš ī¸'); + expect(getAnomalyIcon('info')).toBe('â„šī¸'); + }); + + it('should return correct colors for severity', () => { + expect(getAnomalyColor('critical')).toBe('red'); + expect(getAnomalyColor('warning')).toBe('yellow'); + expect(getAnomalyColor('info')).toBe('blue'); + }); + + it('should return correct labels for types', () => { + expect(getAnomalyTypeLabel('config_modification')).toBe('CONFIG'); + expect(getAnomalyTypeLabel('high_frequency')).toBe('FREQ'); + expect(getAnomalyTypeLabel('burst_activity')).toBe('BURST'); + expect(getAnomalyTypeLabel('unusual_pattern')).toBe('PATTERN'); + expect(getAnomalyTypeLabel('sensitive_file')).toBe('SENSITIVE'); + }); +}); diff --git a/src/tui/utils/fileAnomalyDetection.ts b/src/tui/utils/fileAnomalyDetection.ts new file mode 100644 index 0000000..d9216b4 --- /dev/null +++ b/src/tui/utils/fileAnomalyDetection.ts @@ -0,0 +1,338 @@ +/** + * File Anomaly Detection Utility + * + * Detects unusual file activity patterns that may indicate: + * - Configuration files modified outside expected context + * - High-frequency modifications (possible thrashing) + * - Burst activity (sudden spike in modifications) + * - Sensitive files being accessed + */ + +import { + FileHeatmapEntry, + FileAnomaly, + AnomalyType, + AnomalySeverity, + AnomalyDetectionOptions, + AnomalyStats, +} from '../../types.js'; + +/** Default patterns for configuration files */ +const DEFAULT_CONFIG_PATTERNS = [ + /\.config\.(ts|js|json|yaml|yml)$/i, + /config\.(ts|js|json|yaml|yml)$/i, + /\/\.env$/, + /\.env\./, + /\/config\//, + /\/settings\//, + /\/conf\//, + /\.rc\.(json|js|yaml|yml)$/i, + /\/rc$/, + /settings\.(ts|js|json|yaml|yml)$/i, +]; + +/** Default patterns for sensitive files */ +const DEFAULT_SENSITIVE_PATTERNS = [ + /secret/i, + /credential/i, + /password/i, + /api[-_]?key/i, + /token/i, + /auth/i, + /\.pem$/, + /\.key$/, + /id_rsa/, + /\.ssh\//, +]; + +/** Default options */ +const DEFAULT_OPTIONS: Required = { + minModifications: 1, + frequencyThreshold: 3.0, // 3x average = anomaly + burstWindow: 60000, // 1 minute + burstThreshold: 10, // 10+ mods in 1 minute = burst + sensitivePatterns: [], +}; + +/** + * Check if a path matches any pattern + */ +function matchesPattern(path: string, patterns: RegExp[]): boolean { + return patterns.some(pattern => pattern.test(path)); +} + +/** + * Calculate statistics for modifications + */ +function calculateStats(entries: FileHeatmapEntry[]): { + avgModifications: number; + stdDevModifications: number; + avgInterval: number; +} { + if (entries.length === 0) { + return { avgModifications: 0, stdDevModifications: 0, avgInterval: 0 }; + } + + const modifications = entries.map(e => e.modifications); + const avgModifications = modifications.reduce((a, b) => a + b, 0) / modifications.length; + + const squaredDiffs = modifications.map(m => Math.pow(m - avgModifications, 2)); + const stdDevModifications = Math.sqrt(squaredDiffs.reduce((a, b) => a + b, 0) / modifications.length); + + const intervals = entries + .filter(e => e.avgModificationInterval > 0) + .map(e => e.avgModificationInterval); + const avgInterval = intervals.length > 0 + ? intervals.reduce((a, b) => a + b, 0) / intervals.length + : 0; + + return { avgModifications, stdDevModifications, avgInterval }; +} + +/** + * Detect anomalies in file activity + */ +export function detectAnomalies( + entries: FileHeatmapEntry[], + options: AnomalyDetectionOptions = {} +): FileAnomaly[] { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const anomalies: FileAnomaly[] = []; + const now = Date.now(); + + // Combine sensitive patterns + const sensitivePatterns = [ + ...DEFAULT_SENSITIVE_PATTERNS, + ...opts.sensitivePatterns.map(p => new RegExp(p, 'i')), + ]; + + // Calculate baseline statistics + const stats = calculateStats(entries); + + for (const entry of entries) { + // Skip if below minimum modifications + if (entry.modifications < opts.minModifications) continue; + + // 1. Detect config file modifications + if (matchesPattern(entry.path, DEFAULT_CONFIG_PATTERNS)) { + anomalies.push({ + path: entry.path, + type: 'config_modification', + severity: 'warning', + message: `Configuration file modified outside of config-related task`, + detectedAt: now, + details: { + modifications: entry.modifications, + workers: entry.workers.map(w => w.workerId), + context: { + heatLevel: entry.heatLevel, + }, + }, + }); + } + + // 2. Detect sensitive file access + if (matchesPattern(entry.path, sensitivePatterns)) { + anomalies.push({ + path: entry.path, + type: 'sensitive_file', + severity: 'critical', + message: `Sensitive file accessed - review for security implications`, + detectedAt: now, + details: { + modifications: entry.modifications, + workers: entry.workers.map(w => w.workerId), + context: { + pattern: 'sensitive', + }, + }, + }); + } + + // 3. Detect high-frequency modifications (outliers) + if ( + stats.avgModifications > 0 && + entry.modifications > stats.avgModifications * opts.frequencyThreshold + ) { + const ratio = entry.modifications / stats.avgModifications; + const severity: AnomalySeverity = ratio > opts.frequencyThreshold * 2 ? 'critical' : 'warning'; + + anomalies.push({ + path: entry.path, + type: 'high_frequency', + severity, + message: `High modification frequency (${ratio.toFixed(1)}x average)`, + detectedAt: now, + details: { + modifications: entry.modifications, + workers: entry.workers.map(w => w.workerId), + expectedValue: Math.round(stats.avgModifications), + actualValue: entry.modifications, + context: { + ratio: ratio, + }, + }, + }); + } + + // 4. Detect burst activity (very low modification interval) + if ( + entry.avgModificationInterval > 0 && + entry.avgModificationInterval < 1000 && // Less than 1 second between mods + entry.modifications >= opts.burstThreshold + ) { + anomalies.push({ + path: entry.path, + type: 'burst_activity', + severity: 'warning', + message: `Burst activity detected - ${entry.modifications} modifications in rapid succession`, + detectedAt: now, + details: { + modifications: entry.modifications, + workers: entry.workers.map(w => w.workerId), + timeSpan: entry.lastModified - entry.firstModified, + context: { + avgInterval: entry.avgModificationInterval, + }, + }, + }); + } + + // 5. Detect unusual patterns (multiple workers on typically single-worker files) + if ( + entry.workers.length >= 3 && + entry.modifications > 5 + ) { + anomalies.push({ + path: entry.path, + type: 'unusual_pattern', + severity: 'info', + message: `Unusual multi-worker activity on same file (${entry.workers.length} workers)`, + detectedAt: now, + details: { + modifications: entry.modifications, + workers: entry.workers.map(w => w.workerId), + context: { + workerCount: entry.workers.length, + collision: entry.hasCollision, + }, + }, + }); + } + } + + // Sort by severity (critical first) then by path + const severityOrder: Record = { + critical: 0, + warning: 1, + info: 2, + }; + + anomalies.sort((a, b) => { + const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]; + if (severityDiff !== 0) return severityDiff; + return a.path.localeCompare(b.path); + }); + + return anomalies; +} + +/** + * Get anomaly statistics + */ +export function getAnomalyStats(anomalies: FileAnomaly[]): AnomalyStats { + const byType: Record = { + config_modification: 0, + high_frequency: 0, + burst_activity: 0, + unusual_pattern: 0, + sensitive_file: 0, + }; + + const bySeverity: Record = { + info: 0, + warning: 0, + critical: 0, + }; + + const fileAnomalies = new Map }>(); + + for (const anomaly of anomalies) { + byType[anomaly.type]++; + bySeverity[anomaly.severity]++; + + const existing = fileAnomalies.get(anomaly.path); + if (existing) { + existing.count++; + existing.types.add(anomaly.type); + } else { + fileAnomalies.set(anomaly.path, { + count: 1, + types: new Set([anomaly.type]), + }); + } + } + + // Get top 5 files with most anomalies + const topAnomalyFiles = Array.from(fileAnomalies.entries()) + .map(([path, data]) => ({ + path, + count: data.count, + types: Array.from(data.types), + })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + return { + totalAnomalies: anomalies.length, + byType, + bySeverity, + topAnomalyFiles, + }; +} + +/** + * Get severity icon for display + */ +export function getAnomalyIcon(severity: AnomalySeverity): string { + switch (severity) { + case 'critical': return '🚨'; + case 'warning': return 'âš ī¸'; + case 'info': return 'â„šī¸'; + default: return '?'; + } +} + +/** + * Get color name for severity + */ +export function getAnomalyColor(severity: AnomalySeverity): string { + switch (severity) { + case 'critical': return 'red'; + case 'warning': return 'yellow'; + case 'info': return 'blue'; + default: return 'gray'; + } +} + +/** + * Get short label for anomaly type + */ +export function getAnomalyTypeLabel(type: AnomalyType): string { + switch (type) { + case 'config_modification': return 'CONFIG'; + case 'high_frequency': return 'FREQ'; + case 'burst_activity': return 'BURST'; + case 'unusual_pattern': return 'PATTERN'; + case 'sensitive_file': return 'SENSITIVE'; + default: return 'UNKNOWN'; + } +} + +export default { + detectAnomalies, + getAnomalyStats, + getAnomalyIcon, + getAnomalyColor, + getAnomalyTypeLabel, +}; diff --git a/src/types.ts b/src/types.ts index 730941b..bcbbb00 100644 --- a/src/types.ts +++ b/src/types.ts @@ -698,6 +698,107 @@ export interface FileHeatmapStats { avgModificationsPerFile: number; } +// ============================================ +// File Anomaly Detection Types +// ============================================ + +/** + * Types of file activity anomalies + */ +export type AnomalyType = + | 'config_modification' // Config file modified outside expected context + | 'high_frequency' // Unusually high modification rate + | 'burst_activity' // Sudden burst of modifications + | 'unusual_pattern' // Activity pattern outside normal bounds + | 'sensitive_file'; // Sensitive file (secrets, credentials) touched + +/** + * Severity level for anomalies + */ +export type AnomalySeverity = 'info' | 'warning' | 'critical'; + +/** + * Detected file anomaly + */ +export interface FileAnomaly { + /** File path with anomaly */ + path: string; + + /** Type of anomaly detected */ + type: AnomalyType; + + /** Severity level */ + severity: AnomalySeverity; + + /** Human-readable description */ + message: string; + + /** When the anomaly was detected */ + detectedAt: number; + + /** Supporting data for the anomaly */ + details: { + /** Modification count involved */ + modifications?: number; + + /** Workers involved */ + workers?: string[]; + + /** Time span of anomalous activity (ms) */ + timeSpan?: number; + + /** Expected normal value */ + expectedValue?: number; + + /** Actual observed value */ + actualValue?: number; + + /** Additional context */ + context?: Record; + }; +} + +/** + * Options for anomaly detection + */ +export interface AnomalyDetectionOptions { + /** Minimum modifications to consider for anomaly detection */ + minModifications?: number; + + /** Threshold multiplier for high-frequency detection (e.g., 3 = 3x average) */ + frequencyThreshold?: number; + + /** Time window for burst detection (ms) */ + burstWindow?: number; + + /** Minimum burst count to trigger anomaly */ + burstThreshold?: number; + + /** Custom patterns for sensitive files */ + sensitivePatterns?: string[]; +} + +/** + * Statistics for anomaly detection + */ +export interface AnomalyStats { + /** Total anomalies detected */ + totalAnomalies: number; + + /** Count by type */ + byType: Record; + + /** Count by severity */ + bySeverity: Record; + + /** Files with most anomalies */ + topAnomalyFiles: Array<{ + path: string; + count: number; + types: AnomalyType[]; + }>; +} + // ============================================ // Dependency DAG Types // ============================================