Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ Interactive Chart.js visualizations showing compliance trends and deployment sta
- **Dual Storage**: EF Core with SQL Server or file-based JSON storage
- **Queue Processing**: Background service for Azure Queue consumption

### 📈 Statistical Anomaly Detection
- **Automatic Detection**: Background service runs hourly to identify unusual patterns
- **Statistical Analysis**: Uses 2.5σ thresholds to detect anomalies in device behavior
- **Multiple Detection Types**: Reporting frequency, deployment state, and inactivity anomalies
- **Interactive Dashboard**: View, investigate, and resolve detected anomalies
- **Severity Scoring**: Prioritized alerts (High/Medium/Low) for efficient triage

### 🔒 Enterprise Security
- **Managed Identity**: Azure AD authentication for database and storage access
- **Certificate-based Auth**: Client certificate authentication for Azure Queue
Expand Down Expand Up @@ -294,6 +301,7 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory:
- **[Device List Separation](docs/DEVICE_LIST_SEPARATION.md)** - UI reorganization details
- **[Certificate Enumeration](docs/CERTIFICATE_ENUMERATION.md)** - UEFI certificate tracking
- **[Logo & Banner Implementation](docs/LOGO_BANNER_IMPLEMENTATION.md)** - Branding assets
- **[Anomaly Detection](docs/ANOMALY_DETECTION.md)** - ML-powered anomaly detection system

Copilot AI Nov 8, 2025

Copy link

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.

Suggested change
- **[Anomaly Detection](docs/ANOMALY_DETECTION.md)** - ML-powered anomaly detection system
- **[Anomaly Detection](docs/ANOMALY_DETECTION.md)** - Statistical anomaly detection system (mean and standard deviation)

Copilot uses AI. Check for mistakes.

### Operations & Troubleshooting
- **[Logging Guide](docs/LOGGING_GUIDE.md)** - Serilog configuration and best practices
Expand Down Expand Up @@ -493,7 +501,8 @@ For questions, issues, or support:
### v2.0 (Q3 2025)
- [ ] Linux client support (.NET 8)
- [ ] API v2 with GraphQL
- [ ] Machine learning anomaly detection
- [x] **Statistical anomaly detection** ✨ *Completed in Nov 2025*
- [ ] **Machine learning anomaly detection (ML.NET)** *Planned for future release*
- [ ] Integration with ServiceNow/Jira

See [GitHub Projects](https://github.com/robgrame/Nimbus.BootCertWatcher/projects) for detailed roadmap.
Expand Down
221 changes: 221 additions & 0 deletions SecureBootDashboard.Api.Tests/AnomalyDetectionServiceTests.cs
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
Expand Up @@ -11,7 +11,9 @@

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
Expand Down
130 changes: 130 additions & 0 deletions SecureBootDashboard.Api/Controllers/AnomaliesController.cs
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

Copilot AI Nov 8, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing authentication/authorization: The documentation mentions at line 299 that "API endpoints should be protected with authentication", but the AnomaliesController class doesn't have an [Authorize] attribute. This means the anomaly endpoints are accessible without authentication, potentially exposing sensitive device information. Add [Authorize] attribute to the controller or individual endpoints as appropriate.

Copilot uses AI. Check for mistakes.
{
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

Copilot AI Nov 8, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic catch clause.

Suggested change
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 uses AI. Check for mistakes.
}
Comment thread
robgrame marked this conversation as resolved.
}

/// <summary>
/// Get a specific anomaly by ID
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(AnomalyDetectionResult), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetAnomalyAsync(Guid id, CancellationToken cancellationToken)
{
try
{
var anomaly = await _anomalyService.GetAnomalyAsync(id, cancellationToken);
if (anomaly == null)
{
return NotFound();
}
return Ok(anomaly);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving anomaly {AnomalyId}", id);
return StatusCode(500, "Error retrieving anomaly");
Comment on lines +63 to +66

Copilot AI Nov 8, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic catch clause.

Suggested change
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 uses AI. Check for mistakes.
}
Comment thread
robgrame marked this conversation as resolved.
}

/// <summary>
/// Trigger manual anomaly detection scan
/// </summary>
[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");
}
Comment thread
robgrame marked this conversation as resolved.
}
Comment on lines +73 to +88

Copilot AI Nov 8, 2025

Copy link

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 uses AI. Check for mistakes.
Comment on lines +83 to +88

Copilot AI Nov 8, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic catch clause.

Suggested change
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 uses AI. Check for mistakes.

/// <summary>
/// Resolve an anomaly
/// </summary>
[HttpPost("{id:guid}/resolve")]
[ProducesResponseType(204)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<IActionResult> ResolveAnomalyAsync(
Guid id,
[FromBody] ResolveAnomalyRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ResolvedBy))
{
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" });
Comment on lines +104 to +108

Copilot AI Nov 8, 2025

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.
}
try
{
var anomaly = await _anomalyService.GetAnomalyAsync(id, cancellationToken);
if (anomaly == null)
{
return NotFound();
}

await _anomalyService.ResolveAnomalyAsync(id, request.ResolvedBy, cancellationToken);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resolving anomaly {AnomalyId}", id);
return StatusCode(500, "Error resolving anomaly");
}
Comment thread
robgrame marked this conversation as resolved.
Comment on lines +121 to +125

Copilot AI Nov 8, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic catch clause.

Copilot uses AI. Check for mistakes.
}

public sealed record ResolveAnomalyRequest(string? ResolvedBy);

Copilot AI Nov 8, 2025

Copy link

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.

Suggested change
public sealed record ResolveAnomalyRequest(string? ResolvedBy);
public sealed record ResolveAnomalyRequest(string ResolvedBy);

Copilot uses AI. Check for mistakes.
}
}
Loading
Loading