Implement ML-based anomaly detection for device behavior monitoring#14
Conversation
Co-authored-by: robgrame <12012136+robgrame@users.noreply.github.com>
Co-authored-by: robgrame <12012136+robgrame@users.noreply.github.com>
Co-authored-by: robgrame <12012136+robgrame@users.noreply.github.com>
Co-authored-by: robgrame <12012136+robgrame@users.noreply.github.com>
…s to constants Co-authored-by: robgrame <12012136+robgrame@users.noreply.github.com>
…prevent log forging Co-authored-by: robgrame <12012136+robgrame@users.noreply.github.com>
There was a problem hiding this comment.
Pull Request Overview
This PR introduces an anomaly detection system for the Secure Boot Certificate Watcher that identifies unusual patterns in device behavior. Despite being labeled as "machine learning" in the documentation, the implementation uses statistical analysis (mean and standard deviation calculations) rather than ML.NET.
Key changes:
- New anomaly detection service using statistical thresholds (2.5σ) to identify reporting frequency, deployment state, and inactivity anomalies
- Background service running hourly scans with manual trigger capability
- Web dashboard UI for viewing, investigating, and resolving anomalies with severity-based prioritization
- Database schema additions with new Anomalies table and EF Core migration
Reviewed Changes
Copilot reviewed 22 out of 24 changed files in this pull request and generated 29 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/ANOMALY_DETECTION.md | Comprehensive documentation for the anomaly detection feature (misleadingly titled as ML-based) |
| SecureBootWatcher.Shared/Models/AnomalyDetectionResult.cs | Data model for anomaly results with type, severity, and status enums |
| SecureBootDashboard.Api/Services/AnomalyDetectionService.cs | Core service implementing statistical analysis for three anomaly types |
| SecureBootDashboard.Api/Services/AnomalyDetectionBackgroundService.cs | Hosted service running hourly automatic scans |
| SecureBootDashboard.Api/Controllers/AnomaliesController.cs | REST API endpoints for anomaly CRUD operations |
| SecureBootDashboard.Api/Data/AnomalyEntity.cs | EF Core entity for persisting anomaly data |
| SecureBootDashboard.Api/Data/SecureBootDbContext.cs | DbContext updates to include Anomalies table |
| SecureBootDashboard.Api/Data/Migrations/20251107061541_AddAnomalyDetection.cs | Database migration creating Anomalies table with indexes |
| SecureBootDashboard.Web/Pages/Anomalies/List.cshtml | Razor page displaying anomalies with statistics, filtering, and resolution actions |
| SecureBootDashboard.Web/Pages/Anomalies/List.cshtml.cs | Page model handling anomaly list display, manual scans, and resolution |
| SecureBootDashboard.Web/Services/SecureBootApiClient.cs | Client methods for anomaly API endpoints |
| SecureBootDashboard.Web/Services/ISecureBootApiClient.cs | Interface additions for anomaly methods |
| SecureBootDashboard.Web/Pages/Shared/_Layout.cshtml | Navigation menu update adding Anomalies link |
| SecureBootDashboard.Web/Pages/Index.cshtml | Dashboard banner showing active anomaly count |
| SecureBootDashboard.Web/Pages/Index.cshtml.cs | Logic to fetch and display anomaly count on main dashboard |
| SecureBootDashboard.Api/Program.cs | Service registration for anomaly detection |
| SecureBootDashboard.Api/SecureBootDashboard.Api.csproj | Added Microsoft.ML package (unused in implementation) |
| SecureBootDashboard.Api.Tests/SecureBootDashboard.Api.Tests.csproj | Test dependencies for in-memory database and mocking |
| SecureBootDashboard.Api.Tests/AnomalyDetectionServiceTests.cs | Unit tests covering inactivity, deployment state, and resolution scenarios |
| README.md | Updated features section and roadmap to reflect anomaly detection completion |
Files not reviewed (1)
- SecureBootDashboard.Api/Data/Migrations/20251107061541_AddAnomalyDetection.Designer.cs: Language not supported
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error triggering anomaly scan"); | ||
| ErrorMessage = "Error triggering anomaly scan. Please try again."; | ||
| Anomalies = await _apiClient.GetActiveAnomaliesAsync(HttpContext.RequestAborted); | ||
| } |
There was a problem hiding this comment.
Generic catch clause.
| string description; | ||
| if (device.ReportCount > upperThreshold) | ||
| { | ||
| description = $"Excessive reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; | ||
| } | ||
| else | ||
| { | ||
| description = $"Insufficient reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; | ||
| } |
There was a problem hiding this comment.
Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.
| string description; | |
| if (device.ReportCount > upperThreshold) | |
| { | |
| description = $"Excessive reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; | |
| } | |
| else | |
| { | |
| description = $"Insufficient reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; | |
| } | |
| string description = device.ReportCount > upperThreshold | |
| ? $"Excessive reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})" | |
| : $"Insufficient reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
| var stuckDevices = await _dbContext.Devices | ||
| .Include(d => d.Reports.Where(r => r.CreatedAtUtc > lookbackPeriod).OrderByDescending(r => r.CreatedAtUtc)) | ||
| .Where(d => d.Reports.Any(r => r.CreatedAtUtc > lookbackPeriod)) | ||
| .ToListAsync(cancellationToken); | ||
|
|
||
| foreach (var device in stuckDevices) | ||
| { | ||
| var recentReports = device.Reports.Take(5).ToList(); |
There was a problem hiding this comment.
Inefficient query pattern: The query loads devices with filtered reports using .Include(d => d.Reports.Where(...)) (line 173), but then iterates through devices and takes only 5 reports per device (line 179). For large datasets, this could load many reports into memory unnecessarily. Consider restructuring the query to limit reports per device at the database level or using a more efficient approach with separate queries.
| [HttpPost("scan")] | ||
| [ProducesResponseType(typeof(IReadOnlyList<AnomalyDetectionResult>), 200)] | ||
| public async Task<IActionResult> TriggerScanAsync(CancellationToken cancellationToken) | ||
| { | ||
| try | ||
| { | ||
| _logger.LogInformation("Manual anomaly detection scan triggered"); | ||
| var anomalies = await _anomalyService.DetectAnomaliesAsync(cancellationToken); | ||
| return Ok(anomalies); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error during manual anomaly detection scan"); | ||
| return StatusCode(500, "Error during anomaly detection"); | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing input validation: The manual scan trigger endpoint (line 73-88) accepts a POST request but doesn't validate any parameters or implement any rate limiting. A malicious or buggy client could repeatedly trigger expensive anomaly detection scans. Consider implementing rate limiting, authentication checks, or requiring administrator privileges for this endpoint.
| foreach (var device in deviceReportStats.Where(d => d.ReportCount > upperThreshold || d.ReportCount < lowerThreshold)) | ||
| { | ||
| var severity = Math.Abs(device.ReportCount - mean) / Math.Max(stdDev, 1); | ||
| severity = Math.Min(severity / 3.0, 1.0); // Normalize to 0-1 |
There was a problem hiding this comment.
Hardcoded magic numbers: The severity calculation uses a hardcoded divisor of 3.0 (line 139) to normalize the severity score. This magic number should be extracted as a named constant (e.g., NormalizationFactor or MaxExpectedStandardDeviations) to improve code readability and maintainability.
| catch (HttpRequestException ex) | ||
| { | ||
| _logger.LogError(ex, "Failed to resolve anomaly {AnomalyId}", id); | ||
| // Suppress exception for consistency with other methods | ||
| return; | ||
| } |
There was a problem hiding this comment.
The comment "Suppress exception for consistency with other methods" (line 241) suggests this is intentional behavior, but it creates an inconsistent API contract. The method signature returns Task, not Task<bool>, so callers have no way to know if the operation succeeded or failed. Consider either returning a boolean success indicator or using a result type that can convey success/failure status.
| var inactiveDevices = await _dbContext.Devices | ||
| .Where(d => d.LastSeenUtc < inactivityThreshold && d.Reports.Any()) |
There was a problem hiding this comment.
[nitpick] The DetectInactivityAnomaliesAsync method queries for devices where LastSeenUtc < inactivityThreshold (line 226), which means devices that haven't been seen in the last 14 days. However, it also requires d.Reports.Any() to ensure the device has reports. Consider whether this excludes newly registered devices that have never reported, which might be an anomaly worth detecting.
| { | ||
| _logger.LogError(dbex, "Database update error while detecting anomalies"); | ||
| } | ||
| catch (Exception ex) |
There was a problem hiding this comment.
Generic catch clause.
| catch (Exception ex) | |
| catch (Exception ex) when ( | |
| !(ex is OutOfMemoryException) && | |
| !(ex is StackOverflowException) && | |
| !(ex is ThreadAbortException) | |
| ) |
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error triggering anomaly scan"); | ||
| ErrorMessage = "Error triggering anomaly scan. Please try again."; | ||
| Anomalies = await _apiClient.GetActiveAnomaliesAsync(HttpContext.RequestAborted); | ||
| } |
There was a problem hiding this comment.
Generic catch clause.
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error resolving anomaly {AnomalyId}", anomalyId); | ||
| ErrorMessage = "Error resolving anomaly. Please try again."; | ||
| } |
There was a problem hiding this comment.
Generic catch clause.
| catch (Exception ex) | ||
| { | ||
| _logger.LogWarning(ex, "Failed to load anomaly count"); | ||
| ActiveAnomaliesCount = 0; | ||
| } |
There was a problem hiding this comment.
Generic catch clause.
| catch (Exception ex) | |
| { | |
| _logger.LogWarning(ex, "Failed to load anomaly count"); | |
| ActiveAnomaliesCount = 0; | |
| } | |
| catch (HttpRequestException ex) | |
| { | |
| _logger.LogWarning(ex, "Failed to load anomaly count"); | |
| ActiveAnomaliesCount = 0; | |
| } | |
| catch (TaskCanceledException ex) | |
| { | |
| _logger.LogWarning(ex, "Failed to load anomaly count (request canceled)"); | |
| ActiveAnomaliesCount = 0; | |
| } |
| string description; | ||
| if (device.ReportCount > upperThreshold) | ||
| { | ||
| description = $"Excessive reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; | ||
| } | ||
| else | ||
| { | ||
| description = $"Insufficient reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; | ||
| } |
There was a problem hiding this comment.
Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.
| string description; | |
| if (device.ReportCount > upperThreshold) | |
| { | |
| description = $"Excessive reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; | |
| } | |
| else | |
| { | |
| description = $"Insufficient reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; | |
| } | |
| string description = device.ReportCount > upperThreshold | |
| ? $"Excessive reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})" | |
| : $"Insufficient reporting detected. Device reported {device.ReportCount} times in {ReportingFrequencyLookbackDays} days (expected: ~{mean:F1})"; |
Adds automated anomaly detection to identify unusual device behavior patterns across the fleet. Uses statistical analysis (2.5σ threshold) to detect reporting anomalies, deployment failures, and inactive devices.
Changes
Backend
Anomaliestable with indexed foreign key toDevicesFrontend
/Anomalies/List) with severity-based filtering and one-click resolutionSecurity
ResolvedByprevents empty audit trailsAPI Example
Migration Required
No configuration changes needed—feature enabled by default with sensible thresholds.
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.