-
Notifications
You must be signed in to change notification settings - Fork 0
Implement ML-based anomaly detection for device behavior monitoring #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
abf6028
ab43347
bb239f8
1609a67
25b05e8
1a1c21a
3e37f6b
8c9d51a
31665a6
cf57120
963f80f
491f611
60b9c2d
0109e28
2c162bd
bc91483
9981c5e
1cc493c
f66ce84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Microsoft.Extensions.Logging; | ||
| using Moq; | ||
| using SecureBootDashboard.Api.Data; | ||
| using SecureBootDashboard.Api.Services; | ||
| using SecureBootWatcher.Shared.Models; | ||
| using Xunit; | ||
|
|
||
| namespace SecureBootDashboard.Api.Tests; | ||
|
|
||
| public class AnomalyDetectionServiceTests | ||
| { | ||
| private SecureBootDbContext CreateInMemoryContext() | ||
| { | ||
| var options = new DbContextOptionsBuilder<SecureBootDbContext>() | ||
| .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) | ||
| .Options; | ||
|
|
||
| return new SecureBootDbContext(options); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task DetectAnomaliesAsync_WithNoData_ReturnsEmptyList() | ||
| { | ||
| // Arrange | ||
| using var context = CreateInMemoryContext(); | ||
| var logger = new Mock<ILogger<AnomalyDetectionService>>(); | ||
| var service = new AnomalyDetectionService(context, logger.Object); | ||
|
|
||
| // Act | ||
| var result = await service.DetectAnomaliesAsync(); | ||
|
|
||
| // Assert | ||
| Assert.NotNull(result); | ||
| Assert.Empty(result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task DetectAnomaliesAsync_WithInactiveDevice_DetectsInactivityAnomaly() | ||
| { | ||
| // Arrange | ||
| using var context = CreateInMemoryContext(); | ||
| var deviceId = Guid.NewGuid(); | ||
| var device = new DeviceEntity | ||
| { | ||
| Id = deviceId, | ||
| MachineName = "TestDevice", | ||
| LastSeenUtc = DateTimeOffset.UtcNow.AddDays(-20), | ||
| CreatedAtUtc = DateTimeOffset.UtcNow.AddDays(-30) | ||
| }; | ||
|
|
||
| var report = new SecureBootReportEntity | ||
| { | ||
| Id = Guid.NewGuid(), | ||
| DeviceId = deviceId, | ||
| CreatedAtUtc = DateTimeOffset.UtcNow.AddDays(-20), | ||
| RegistryStateJson = "{}" | ||
| }; | ||
|
|
||
| context.Devices.Add(device); | ||
| context.Reports.Add(report); | ||
| await context.SaveChangesAsync(); | ||
|
|
||
| var logger = new Mock<ILogger<AnomalyDetectionService>>(); | ||
| var service = new AnomalyDetectionService(context, logger.Object); | ||
|
|
||
| // Act | ||
| var result = await service.DetectAnomaliesAsync(); | ||
|
|
||
| // Assert | ||
| Assert.NotEmpty(result); | ||
| Assert.Contains(result, a => a.Type == AnomalyType.DeviceBehavior); | ||
| Assert.Contains(result, a => a.DeviceId == deviceId); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task DetectAnomaliesAsync_WithDeviceStuckInPending_DetectsDeploymentAnomaly() | ||
| { | ||
| // Arrange | ||
| using var context = CreateInMemoryContext(); | ||
| var deviceId = Guid.NewGuid(); | ||
| var device = new DeviceEntity | ||
| { | ||
| Id = deviceId, | ||
| MachineName = "TestDevice", | ||
| LastSeenUtc = DateTimeOffset.UtcNow, | ||
| CreatedAtUtc = DateTimeOffset.UtcNow.AddDays(-30) | ||
| }; | ||
|
|
||
| // Add 4 reports with Pending state | ||
| for (int i = 0; i < 4; i++) | ||
| { | ||
| context.Reports.Add(new SecureBootReportEntity | ||
| { | ||
| Id = Guid.NewGuid(), | ||
| DeviceId = deviceId, | ||
| CreatedAtUtc = DateTimeOffset.UtcNow.AddDays(-i), | ||
| RegistryStateJson = "{}", | ||
| DeploymentState = "Pending" | ||
| }); | ||
| } | ||
|
|
||
| context.Devices.Add(device); | ||
| await context.SaveChangesAsync(); | ||
|
|
||
| var logger = new Mock<ILogger<AnomalyDetectionService>>(); | ||
| var service = new AnomalyDetectionService(context, logger.Object); | ||
|
|
||
| // Act | ||
| var result = await service.DetectAnomaliesAsync(); | ||
|
|
||
| // Assert | ||
| Assert.NotEmpty(result); | ||
| Assert.Contains(result, a => a.Type == AnomalyType.DeploymentState); | ||
| Assert.Contains(result, a => a.Description.Contains("Pending")); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task GetActiveAnomaliesAsync_ReturnsOnlyActiveAnomalies() | ||
| { | ||
| // Arrange | ||
| using var context = CreateInMemoryContext(); | ||
| var deviceId = Guid.NewGuid(); | ||
| var device = new DeviceEntity | ||
| { | ||
| Id = deviceId, | ||
| MachineName = "TestDevice", | ||
| LastSeenUtc = DateTimeOffset.UtcNow, | ||
| CreatedAtUtc = DateTimeOffset.UtcNow | ||
| }; | ||
|
|
||
| context.Devices.Add(device); | ||
|
|
||
| var activeAnomaly = new AnomalyEntity | ||
| { | ||
| Id = Guid.NewGuid(), | ||
| DeviceId = deviceId, | ||
| AnomalyType = "DeviceBehavior", | ||
| Description = "Test anomaly", | ||
| Severity = 0.5, | ||
| DetectedAtUtc = DateTimeOffset.UtcNow, | ||
| Status = "Active" | ||
| }; | ||
|
|
||
| var resolvedAnomaly = new AnomalyEntity | ||
| { | ||
| Id = Guid.NewGuid(), | ||
| DeviceId = deviceId, | ||
| AnomalyType = "DeviceBehavior", | ||
| Description = "Resolved anomaly", | ||
| Severity = 0.5, | ||
| DetectedAtUtc = DateTimeOffset.UtcNow.AddDays(-1), | ||
| Status = "Resolved", | ||
| ResolvedBy = "Admin", | ||
| ResolvedAtUtc = DateTimeOffset.UtcNow | ||
| }; | ||
|
|
||
| context.Anomalies.Add(activeAnomaly); | ||
| context.Anomalies.Add(resolvedAnomaly); | ||
| await context.SaveChangesAsync(); | ||
|
|
||
| var logger = new Mock<ILogger<AnomalyDetectionService>>(); | ||
| var service = new AnomalyDetectionService(context, logger.Object); | ||
|
|
||
| // Act | ||
| var result = await service.GetActiveAnomaliesAsync(); | ||
|
|
||
| // Assert | ||
| Assert.Single(result); | ||
| Assert.Equal(activeAnomaly.Id, result[0].Id); | ||
| Assert.Equal(AnomalyStatus.Active, result[0].Status); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task ResolveAnomalyAsync_UpdatesAnomalyStatus() | ||
| { | ||
| // Arrange | ||
| using var context = CreateInMemoryContext(); | ||
| var deviceId = Guid.NewGuid(); | ||
| var device = new DeviceEntity | ||
| { | ||
| Id = deviceId, | ||
| MachineName = "TestDevice", | ||
| LastSeenUtc = DateTimeOffset.UtcNow, | ||
| CreatedAtUtc = DateTimeOffset.UtcNow | ||
| }; | ||
|
|
||
| context.Devices.Add(device); | ||
|
|
||
| var anomaly = new AnomalyEntity | ||
| { | ||
| Id = Guid.NewGuid(), | ||
| DeviceId = deviceId, | ||
| AnomalyType = "DeviceBehavior", | ||
| Description = "Test anomaly", | ||
| Severity = 0.5, | ||
| DetectedAtUtc = DateTimeOffset.UtcNow, | ||
| Status = "Active" | ||
| }; | ||
|
|
||
| context.Anomalies.Add(anomaly); | ||
| await context.SaveChangesAsync(); | ||
|
|
||
| var logger = new Mock<ILogger<AnomalyDetectionService>>(); | ||
| var service = new AnomalyDetectionService(context, logger.Object); | ||
|
|
||
| // Act | ||
| await service.ResolveAnomalyAsync(anomaly.Id, "TestUser"); | ||
|
|
||
| // Assert | ||
| var updatedAnomaly = await context.Anomalies.FindAsync(anomaly.Id); | ||
| Assert.NotNull(updatedAnomaly); | ||
| Assert.Equal("Resolved", updatedAnomaly.Status); | ||
| Assert.Equal("TestUser", updatedAnomaly.ResolvedBy); | ||
| Assert.NotNull(updatedAnomaly.ResolvedAtUtc); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,130 @@ | ||||||||||||||||||||||||||||||||||
| using System; | ||||||||||||||||||||||||||||||||||
| using System.Collections.Generic; | ||||||||||||||||||||||||||||||||||
| using System.Threading; | ||||||||||||||||||||||||||||||||||
| using System.Threading.Tasks; | ||||||||||||||||||||||||||||||||||
| using Microsoft.AspNetCore.Mvc; | ||||||||||||||||||||||||||||||||||
| using Microsoft.Extensions.Logging; | ||||||||||||||||||||||||||||||||||
| using SecureBootDashboard.Api.Services; | ||||||||||||||||||||||||||||||||||
| using SecureBootWatcher.Shared.Models; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| namespace SecureBootDashboard.Api.Controllers | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| [ApiController] | ||||||||||||||||||||||||||||||||||
| [Route("api/[controller]")] | ||||||||||||||||||||||||||||||||||
| public sealed class AnomaliesController : ControllerBase | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+12
to
+14
|
||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| private readonly IAnomalyDetectionService _anomalyService; | ||||||||||||||||||||||||||||||||||
| private readonly ILogger<AnomaliesController> _logger; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| public AnomaliesController( | ||||||||||||||||||||||||||||||||||
| IAnomalyDetectionService anomalyService, | ||||||||||||||||||||||||||||||||||
| ILogger<AnomaliesController> logger) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| _anomalyService = anomalyService; | ||||||||||||||||||||||||||||||||||
| _logger = logger; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||
| /// Get all active anomalies | ||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||
| [HttpGet] | ||||||||||||||||||||||||||||||||||
| [ProducesResponseType(typeof(IReadOnlyList<AnomalyDetectionResult>), 200)] | ||||||||||||||||||||||||||||||||||
| public async Task<IActionResult> GetActiveAnomaliesAsync(CancellationToken cancellationToken) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| var anomalies = await _anomalyService.GetActiveAnomaliesAsync(cancellationToken); | ||||||||||||||||||||||||||||||||||
| return Ok(anomalies); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| catch (Exception ex) | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| _logger.LogError(ex, "Error retrieving active anomalies"); | ||||||||||||||||||||||||||||||||||
| return StatusCode(500, "Error retrieving anomalies"); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+39
to
+42
|
||||||||||||||||||||||||||||||||||
| catch (Exception ex) | |
| { | |
| _logger.LogError(ex, "Error retrieving active anomalies"); | |
| return StatusCode(500, "Error retrieving anomalies"); | |
| catch (OperationCanceledException) | |
| { | |
| _logger.LogWarning("Request was cancelled while retrieving active anomalies"); | |
| return StatusCode(499, "Request was cancelled"); |
Copilot
AI
Nov 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generic catch clause.
| catch (Exception ex) | |
| { | |
| _logger.LogError(ex, "Error retrieving anomaly {AnomalyId}", id); | |
| return StatusCode(500, "Error retrieving anomaly"); | |
| catch (OperationCanceledException) | |
| { | |
| return BadRequest("Request was cancelled."); |
Copilot
AI
Nov 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Nov 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generic catch clause.
| catch (Exception ex) | |
| { | |
| _logger.LogError(ex, "Error during manual anomaly detection scan"); | |
| return StatusCode(500, "Error during anomaly detection"); | |
| } | |
| } | |
| catch (OperationCanceledException ocex) | |
| { | |
| _logger.LogWarning(ocex, "Manual anomaly detection scan was cancelled"); | |
| return BadRequest("Anomaly detection scan was cancelled."); | |
| } | |
| catch (Exception ex) | |
| { | |
| _logger.LogError(ex, "Error during manual anomaly detection scan"); | |
| return StatusCode(500, "Error during anomaly detection"); | |
| } |
Copilot
AI
Nov 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The validation error messages use inconsistent casing. Line 104 uses lowercase "error" while line 108 also uses lowercase "error". However, standard REST API conventions typically use PascalCase or camelCase for property names. Consider using consistent casing like "Error" or following a defined API error response standard.
| return BadRequest(new { error = "ResolvedBy is required" }); | |
| } | |
| if (request.ResolvedBy != null && request.ResolvedBy.Length > 256) | |
| { | |
| return BadRequest(new { error = "ResolvedBy must not exceed 256 characters" }); | |
| return BadRequest(new { Error = "ResolvedBy is required" }); | |
| } | |
| if (request.ResolvedBy != null && request.ResolvedBy.Length > 256) | |
| { | |
| return BadRequest(new { Error = "ResolvedBy must not exceed 256 characters" }); |
Copilot
AI
Nov 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generic catch clause.
Copilot
AI
Nov 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security concern: The ResolveAnomalyRequest record uses a nullable string (string?) for ResolvedBy (line 128), which allows null values to be passed. However, the validation at line 102 requires it to be non-null/whitespace. The nullability should match the validation requirement - either make it non-nullable (string) or adjust validation logic accordingly.
| public sealed record ResolveAnomalyRequest(string? ResolvedBy); | |
| public sealed record ResolveAnomalyRequest(string ResolvedBy); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation references "ML-powered anomaly detection system" in the README.md link text, but this implementation uses statistical analysis (mean and standard deviation), not machine learning. Consider updating to "Statistical Anomaly Detection system" to accurately reflect the implementation.