feat(bd-iyz): Anomaly Detection for File Activity
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 <noreply@anthropic.com>
This commit is contained in:
parent
6c66c71972
commit
e80f891c8a
6 changed files with 906 additions and 28 deletions
24
src/store.ts
24
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
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
270
src/tui/utils/fileAnomalyDetection.test.ts
Normal file
270
src/tui/utils/fileAnomalyDetection.test.ts
Normal file
|
|
@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
338
src/tui/utils/fileAnomalyDetection.ts
Normal file
338
src/tui/utils/fileAnomalyDetection.ts
Normal file
|
|
@ -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<AnomalyDetectionOptions> = {
|
||||
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<AnomalySeverity, number> = {
|
||||
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<AnomalyType, number> = {
|
||||
config_modification: 0,
|
||||
high_frequency: 0,
|
||||
burst_activity: 0,
|
||||
unusual_pattern: 0,
|
||||
sensitive_file: 0,
|
||||
};
|
||||
|
||||
const bySeverity: Record<AnomalySeverity, number> = {
|
||||
info: 0,
|
||||
warning: 0,
|
||||
critical: 0,
|
||||
};
|
||||
|
||||
const fileAnomalies = new Map<string, { count: number; types: Set<AnomalyType> }>();
|
||||
|
||||
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,
|
||||
};
|
||||
101
src/types.ts
101
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<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AnomalyType, number>;
|
||||
|
||||
/** Count by severity */
|
||||
bySeverity: Record<AnomalySeverity, number>;
|
||||
|
||||
/** Files with most anomalies */
|
||||
topAnomalyFiles: Array<{
|
||||
path: string;
|
||||
count: number;
|
||||
types: AnomalyType[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Dependency DAG Types
|
||||
// ============================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue