From a40b27d46a9d2aa8b3a0c6278a2fc1919f4702db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 05:59:46 +0000 Subject: [PATCH 1/8] Initial plan From 359fc4375ca43731bba48bc1a9f537456ae2a741 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 06:04:09 +0000 Subject: [PATCH 2/8] Initial analysis: Certificate compliance policies feature Co-authored-by: robgrame <12012136+robgrame@users.noreply.github.com> --- ...cher.Client.csproj.AssemblyReference.cache | Bin 57350 -> 57350 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/SecureBootWatcher.Client/obj/Debug/net48/SecureBootWatcher.Client.csproj.AssemblyReference.cache b/SecureBootWatcher.Client/obj/Debug/net48/SecureBootWatcher.Client.csproj.AssemblyReference.cache index 8ae0dc833efcf4d028f38401a3bad48310400406..172d9cb0636ea84b6b4c406261cdbf41df70ec21 100644 GIT binary patch delta 18 acmZoWz}$9#dBPmlF2>a!hc_-vy$=9O^9Y*& delta 18 acmZoWz}$9#dBPl4jefC}eH$01-Uk3m8wg$i From 2575889315ac18078bb5d089d185a96770981db4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 06:09:20 +0000 Subject: [PATCH 3/8] Add certificate compliance policy models and API endpoints Co-authored-by: robgrame <12012136+robgrame@users.noreply.github.com> --- .../Controllers/ComplianceController.cs | 276 ++++++++++++++++++ .../Controllers/PoliciesController.cs | 220 ++++++++++++++ ...dCertificateCompliancePolicies.Designer.cs | 239 +++++++++++++++ ...060842_AddCertificateCompliancePolicies.cs | 51 ++++ .../SecureBootDbContextModelSnapshot.cs | 44 +++ SecureBootDashboard.Api/Data/PolicyEntity.cs | 31 ++ .../Data/SecureBootDbContext.cs | 14 + .../Services/PolicyEvaluationService.cs | 241 +++++++++++++++ .../Models/CertificateCompliancePolicy.cs | 56 ++++ .../Models/ComplianceResult.cs | 98 +++++++ SecureBootWatcher.Shared/Models/PolicyRule.cs | 97 ++++++ 11 files changed, 1367 insertions(+) create mode 100644 SecureBootDashboard.Api/Controllers/ComplianceController.cs create mode 100644 SecureBootDashboard.Api/Controllers/PoliciesController.cs create mode 100644 SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.Designer.cs create mode 100644 SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.cs create mode 100644 SecureBootDashboard.Api/Data/PolicyEntity.cs create mode 100644 SecureBootDashboard.Api/Services/PolicyEvaluationService.cs create mode 100644 SecureBootWatcher.Shared/Models/CertificateCompliancePolicy.cs create mode 100644 SecureBootWatcher.Shared/Models/ComplianceResult.cs create mode 100644 SecureBootWatcher.Shared/Models/PolicyRule.cs diff --git a/SecureBootDashboard.Api/Controllers/ComplianceController.cs b/SecureBootDashboard.Api/Controllers/ComplianceController.cs new file mode 100644 index 0000000..880ea74 --- /dev/null +++ b/SecureBootDashboard.Api/Controllers/ComplianceController.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SecureBootDashboard.Api.Data; +using SecureBootDashboard.Api.Services; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Api.Controllers +{ + /// + /// API endpoints for device compliance evaluation. + /// + [ApiController] + [Route("api/[controller]")] + public class ComplianceController : ControllerBase + { + private readonly SecureBootDbContext _dbContext; + private readonly ILogger _logger; + + public ComplianceController( + SecureBootDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Evaluate compliance for a specific device using its latest report. + /// + [HttpGet("devices/{deviceId}")] + public async Task> EvaluateDevice(Guid deviceId) + { + try + { + var device = await _dbContext.Devices + .Include(d => d.Reports.OrderByDescending(r => r.CreatedAtUtc).Take(1)) + .FirstOrDefaultAsync(d => d.Id == deviceId); + + if (device == null) + { + return NotFound($"Device {deviceId} not found"); + } + + var latestReport = device.Reports.FirstOrDefault(); + if (latestReport == null) + { + return NotFound($"No reports found for device {deviceId}"); + } + + // Deserialize certificates + SecureBootCertificateCollection? certificates = null; + if (!string.IsNullOrEmpty(latestReport.CertificatesJson)) + { + try + { + certificates = JsonSerializer.Deserialize(latestReport.CertificatesJson); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize certificates for device {DeviceId}", deviceId); + } + } + + // Load policies + var policies = await LoadPoliciesAsync(); + + // Evaluate compliance + var evaluationService = new PolicyEvaluationService(policies); + var result = evaluationService.EvaluateCompliance( + deviceId, + latestReport.Id, + certificates, + device.FleetId); + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error evaluating compliance for device {DeviceId}", deviceId); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Evaluate compliance for all devices. + /// + [HttpGet("devices")] + public async Task>> EvaluateAllDevices() + { + try + { + var devices = await _dbContext.Devices + .Include(d => d.Reports.OrderByDescending(r => r.CreatedAtUtc).Take(1)) + .ToListAsync(); + + // Load policies once + var policies = await LoadPoliciesAsync(); + var evaluationService = new PolicyEvaluationService(policies); + + var results = new List(); + + foreach (var device in devices) + { + var latestReport = device.Reports.FirstOrDefault(); + if (latestReport == null) + { + continue; + } + + // Deserialize certificates + SecureBootCertificateCollection? certificates = null; + if (!string.IsNullOrEmpty(latestReport.CertificatesJson)) + { + try + { + certificates = JsonSerializer.Deserialize(latestReport.CertificatesJson); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize certificates for device {DeviceId}", device.Id); + } + } + + var result = evaluationService.EvaluateCompliance( + device.Id, + latestReport.Id, + certificates, + device.FleetId); + + results.Add(result); + } + + return Ok(results); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error evaluating compliance for all devices"); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Get compliance summary statistics. + /// + [HttpGet("summary")] + public async Task> GetComplianceSummary() + { + try + { + var devices = await _dbContext.Devices + .Include(d => d.Reports.OrderByDescending(r => r.CreatedAtUtc).Take(1)) + .ToListAsync(); + + var policies = await LoadPoliciesAsync(); + var evaluationService = new PolicyEvaluationService(policies); + + var summary = new ComplianceSummary(); + + foreach (var device in devices) + { + var latestReport = device.Reports.FirstOrDefault(); + if (latestReport == null) + { + summary.UnknownCount++; + continue; + } + + SecureBootCertificateCollection? certificates = null; + if (!string.IsNullOrEmpty(latestReport.CertificatesJson)) + { + try + { + certificates = JsonSerializer.Deserialize(latestReport.CertificatesJson); + } + catch + { + // Ignore deserialization errors + } + } + + var result = evaluationService.EvaluateCompliance( + device.Id, + latestReport.Id, + certificates, + device.FleetId); + + switch (result.Status) + { + case ComplianceStatus.Compliant: + summary.CompliantCount++; + break; + case ComplianceStatus.Warning: + summary.WarningCount++; + break; + case ComplianceStatus.NonCompliant: + summary.NonCompliantCount++; + break; + default: + summary.UnknownCount++; + break; + } + } + + summary.TotalDevices = devices.Count; + summary.EvaluatedAtUtc = DateTimeOffset.UtcNow; + + return Ok(summary); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating compliance summary"); + return StatusCode(500, "Internal server error"); + } + } + + private async Task> LoadPoliciesAsync() + { + var policyEntities = await _dbContext.Policies + .Where(p => p.IsEnabled) + .OrderBy(p => p.Priority) + .ToListAsync(); + + var policies = new List(); + + foreach (var entity in policyEntities) + { + var rules = new List(); + if (!string.IsNullOrEmpty(entity.RulesJson)) + { + try + { + rules = JsonSerializer.Deserialize>(entity.RulesJson) ?? new List(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize rules for policy {PolicyId}", entity.Id); + } + } + + policies.Add(new CertificateCompliancePolicy + { + Id = entity.Id, + Name = entity.Name, + Description = entity.Description, + IsEnabled = entity.IsEnabled, + Priority = entity.Priority, + FleetId = entity.FleetId, + Rules = rules, + CreatedAtUtc = entity.CreatedAtUtc, + ModifiedAtUtc = entity.ModifiedAtUtc + }); + } + + return policies; + } + } + + /// + /// Summary of compliance status across all devices. + /// + public sealed class ComplianceSummary + { + public int TotalDevices { get; set; } + public int CompliantCount { get; set; } + public int WarningCount { get; set; } + public int NonCompliantCount { get; set; } + public int UnknownCount { get; set; } + public DateTimeOffset EvaluatedAtUtc { get; set; } + } +} diff --git a/SecureBootDashboard.Api/Controllers/PoliciesController.cs b/SecureBootDashboard.Api/Controllers/PoliciesController.cs new file mode 100644 index 0000000..104388e --- /dev/null +++ b/SecureBootDashboard.Api/Controllers/PoliciesController.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SecureBootDashboard.Api.Data; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Api.Controllers +{ + /// + /// API endpoints for managing certificate compliance policies. + /// + [ApiController] + [Route("api/[controller]")] + public class PoliciesController : ControllerBase + { + private readonly SecureBootDbContext _dbContext; + private readonly ILogger _logger; + + public PoliciesController( + SecureBootDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Get all compliance policies. + /// + [HttpGet] + public async Task>> GetAllPolicies() + { + try + { + var entities = await _dbContext.Policies + .OrderBy(p => p.Priority) + .ToListAsync(); + + var policies = entities.Select(MapToModel).ToList(); + return Ok(policies); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving policies"); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Get a specific policy by ID. + /// + [HttpGet("{id}")] + public async Task> GetPolicy(Guid id) + { + try + { + var entity = await _dbContext.Policies.FindAsync(id); + if (entity == null) + { + return NotFound(); + } + + var policy = MapToModel(entity); + return Ok(policy); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving policy {PolicyId}", id); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Create a new compliance policy. + /// + [HttpPost] + public async Task> CreatePolicy( + [FromBody] CertificateCompliancePolicy policy) + { + try + { + if (string.IsNullOrWhiteSpace(policy.Name)) + { + return BadRequest("Policy name is required"); + } + + policy.Id = Guid.NewGuid(); + policy.CreatedAtUtc = DateTimeOffset.UtcNow; + policy.ModifiedAtUtc = null; + + var entity = MapToEntity(policy); + _dbContext.Policies.Add(entity); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Created policy {PolicyId}: {PolicyName}", policy.Id, policy.Name); + return CreatedAtAction(nameof(GetPolicy), new { id = policy.Id }, policy); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating policy"); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Update an existing compliance policy. + /// + [HttpPut("{id}")] + public async Task> UpdatePolicy( + Guid id, + [FromBody] CertificateCompliancePolicy policy) + { + try + { + if (id != policy.Id) + { + return BadRequest("Policy ID mismatch"); + } + + var entity = await _dbContext.Policies.FindAsync(id); + if (entity == null) + { + return NotFound(); + } + + policy.ModifiedAtUtc = DateTimeOffset.UtcNow; + policy.CreatedAtUtc = entity.CreatedAtUtc; // Preserve original creation time + + var updatedEntity = MapToEntity(policy); + _dbContext.Entry(entity).CurrentValues.SetValues(updatedEntity); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Updated policy {PolicyId}: {PolicyName}", policy.Id, policy.Name); + return Ok(policy); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating policy {PolicyId}", id); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Delete a compliance policy. + /// + [HttpDelete("{id}")] + public async Task DeletePolicy(Guid id) + { + try + { + var entity = await _dbContext.Policies.FindAsync(id); + if (entity == null) + { + return NotFound(); + } + + _dbContext.Policies.Remove(entity); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Deleted policy {PolicyId}", id); + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting policy {PolicyId}", id); + return StatusCode(500, "Internal server error"); + } + } + + private static CertificateCompliancePolicy MapToModel(PolicyEntity entity) + { + var rules = new List(); + if (!string.IsNullOrEmpty(entity.RulesJson)) + { + try + { + rules = JsonSerializer.Deserialize>(entity.RulesJson) ?? new List(); + } + catch + { + // If deserialization fails, return empty list + } + } + + return new CertificateCompliancePolicy + { + Id = entity.Id, + Name = entity.Name, + Description = entity.Description, + IsEnabled = entity.IsEnabled, + Priority = entity.Priority, + FleetId = entity.FleetId, + Rules = rules, + CreatedAtUtc = entity.CreatedAtUtc, + ModifiedAtUtc = entity.ModifiedAtUtc + }; + } + + private static PolicyEntity MapToEntity(CertificateCompliancePolicy policy) + { + return new PolicyEntity + { + Id = policy.Id, + Name = policy.Name, + Description = policy.Description, + IsEnabled = policy.IsEnabled, + Priority = policy.Priority, + FleetId = policy.FleetId, + RulesJson = JsonSerializer.Serialize(policy.Rules), + CreatedAtUtc = policy.CreatedAtUtc, + ModifiedAtUtc = policy.ModifiedAtUtc + }; + } + } +} diff --git a/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.Designer.cs b/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.Designer.cs new file mode 100644 index 0000000..90de072 --- /dev/null +++ b/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.Designer.cs @@ -0,0 +1,239 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SecureBootDashboard.Api.Data; + +#nullable disable + +namespace SecureBootDashboard.Api.Data.Migrations +{ + [DbContext(typeof(SecureBootDbContext))] + [Migration("20251107060842_AddCertificateCompliancePolicies")] + partial class AddCertificateCompliancePolicies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.DeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DomainName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("FirmwareVersion") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("FleetId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetimeoffset"); + + b.Property("MachineName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Manufacturer") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Model") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TagsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserPrincipalName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("MachineName"); + + b.ToTable("Devices", (string)null); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.PolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FleetId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("ModifiedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FleetId"); + + b.HasIndex("IsEnabled"); + + b.ToTable("Policies", (string)null); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EventId") + .HasColumnType("int"); + + b.Property("Level") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RawXml") + .HasColumnType("nvarchar(max)"); + + b.Property("ReportId") + .HasColumnType("uniqueidentifier"); + + b.Property("TimestampUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ReportId"); + + b.HasIndex("TimestampUtc"); + + b.ToTable("SecureBootEvents", (string)null); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AlertsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("CertificatesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientVersion") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentState") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceId") + .HasColumnType("uniqueidentifier"); + + b.Property("RegistryStateJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("DeviceId"); + + b.ToTable("SecureBootReports", (string)null); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootEventEntity", b => + { + b.HasOne("SecureBootDashboard.Api.Data.SecureBootReportEntity", "Report") + .WithMany("Events") + .HasForeignKey("ReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Report"); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootReportEntity", b => + { + b.HasOne("SecureBootDashboard.Api.Data.DeviceEntity", "Device") + .WithMany("Reports") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.DeviceEntity", b => + { + b.Navigation("Reports"); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootReportEntity", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.cs b/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.cs new file mode 100644 index 0000000..a27892f --- /dev/null +++ b/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecureBootDashboard.Api.Data.Migrations +{ + /// + public partial class AddCertificateCompliancePolicies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Policies", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + IsEnabled = table.Column(type: "bit", nullable: false), + Priority = table.Column(type: "int", nullable: false), + FleetId = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + RulesJson = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAtUtc = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Policies", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Policies_FleetId", + table: "Policies", + column: "FleetId"); + + migrationBuilder.CreateIndex( + name: "IX_Policies_IsEnabled", + table: "Policies", + column: "IsEnabled"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Policies"); + } + } +} diff --git a/SecureBootDashboard.Api/Data/Migrations/SecureBootDbContextModelSnapshot.cs b/SecureBootDashboard.Api/Data/Migrations/SecureBootDbContextModelSnapshot.cs index eceb1e0..cd3d226 100644 --- a/SecureBootDashboard.Api/Data/Migrations/SecureBootDbContextModelSnapshot.cs +++ b/SecureBootDashboard.Api/Data/Migrations/SecureBootDbContextModelSnapshot.cs @@ -73,6 +73,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Devices", (string)null); }); + modelBuilder.Entity("SecureBootDashboard.Api.Data.PolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FleetId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("ModifiedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FleetId"); + + b.HasIndex("IsEnabled"); + + b.ToTable("Policies", (string)null); + }); + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootEventEntity", b => { b.Property("Id") diff --git a/SecureBootDashboard.Api/Data/PolicyEntity.cs b/SecureBootDashboard.Api/Data/PolicyEntity.cs new file mode 100644 index 0000000..22d9659 --- /dev/null +++ b/SecureBootDashboard.Api/Data/PolicyEntity.cs @@ -0,0 +1,31 @@ +using System; + +namespace SecureBootDashboard.Api.Data +{ + /// + /// Entity for storing certificate compliance policies in the database. + /// + public sealed class PolicyEntity + { + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + public bool IsEnabled { get; set; } + + public int Priority { get; set; } + + public string? FleetId { get; set; } + + /// + /// JSON-serialized policy rules. + /// + public string RulesJson { get; set; } = string.Empty; + + public DateTimeOffset CreatedAtUtc { get; set; } + + public DateTimeOffset? ModifiedAtUtc { get; set; } + } +} diff --git a/SecureBootDashboard.Api/Data/SecureBootDbContext.cs b/SecureBootDashboard.Api/Data/SecureBootDbContext.cs index 5348994..bdad4d4 100644 --- a/SecureBootDashboard.Api/Data/SecureBootDbContext.cs +++ b/SecureBootDashboard.Api/Data/SecureBootDbContext.cs @@ -15,6 +15,8 @@ public SecureBootDbContext(DbContextOptions options) public DbSet Events => Set(); + public DbSet Policies => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -65,6 +67,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(e => e.ReportId) .OnDelete(DeleteBehavior.Cascade); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Policies"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).HasMaxLength(256).IsRequired(); + entity.Property(e => e.Description).HasMaxLength(2000); + entity.Property(e => e.FleetId).HasMaxLength(128); + entity.Property(e => e.RulesJson).HasColumnType("nvarchar(max)").IsRequired(); + entity.HasIndex(e => e.IsEnabled); + entity.HasIndex(e => e.FleetId); + }); } } } diff --git a/SecureBootDashboard.Api/Services/PolicyEvaluationService.cs b/SecureBootDashboard.Api/Services/PolicyEvaluationService.cs new file mode 100644 index 0000000..cb84f93 --- /dev/null +++ b/SecureBootDashboard.Api/Services/PolicyEvaluationService.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Api.Services +{ + /// + /// Service for evaluating device compliance against certificate policies. + /// + public interface IPolicyEvaluationService + { + /// + /// Evaluates a device's certificates against active policies. + /// + ComplianceResult EvaluateCompliance( + Guid deviceId, + Guid? reportId, + SecureBootCertificateCollection? certificates, + string? fleetId); + } + + public sealed class PolicyEvaluationService : IPolicyEvaluationService + { + private readonly IEnumerable _policies; + + public PolicyEvaluationService(IEnumerable policies) + { + _policies = policies ?? throw new ArgumentNullException(nameof(policies)); + } + + public ComplianceResult EvaluateCompliance( + Guid deviceId, + Guid? reportId, + SecureBootCertificateCollection? certificates, + string? fleetId) + { + var result = new ComplianceResult + { + DeviceId = deviceId, + ReportId = reportId, + EvaluatedAtUtc = DateTimeOffset.UtcNow, + Status = ComplianceStatus.Compliant + }; + + if (certificates == null || certificates.TotalCertificateCount == 0) + { + result.Status = ComplianceStatus.Unknown; + return result; + } + + // Get applicable policies + var applicablePolicies = _policies + .Where(p => p.IsEnabled) + .Where(p => string.IsNullOrEmpty(p.FleetId) || p.FleetId == fleetId) + .OrderBy(p => p.Priority) + .ToList(); + + foreach (var policy in applicablePolicies) + { + EvaluatePolicy(policy, certificates, result); + } + + // Determine overall status based on violations + if (result.Violations.Any(v => v.Rule.Severity == PolicySeverity.Critical)) + { + result.Status = ComplianceStatus.NonCompliant; + } + else if (result.Violations.Any(v => v.Rule.Severity == PolicySeverity.Warning)) + { + result.Status = ComplianceStatus.Warning; + } + + return result; + } + + private void EvaluatePolicy( + CertificateCompliancePolicy policy, + SecureBootCertificateCollection certificates, + ComplianceResult result) + { + foreach (var rule in policy.Rules) + { + EvaluateRule(policy, rule, certificates, result); + } + } + + private void EvaluateRule( + CertificateCompliancePolicy policy, + PolicyRule rule, + SecureBootCertificateCollection certificates, + ComplianceResult result) + { + // Gather all certificates from all databases + var allCertificates = new List(); + allCertificates.AddRange(certificates.SignatureDatabase ?? new List()); + allCertificates.AddRange(certificates.ForbiddenDatabase ?? new List()); + allCertificates.AddRange(certificates.KeyExchangeKeys ?? new List()); + allCertificates.AddRange(certificates.PlatformKeys ?? new List()); + + // Apply database filter if specified + var certsToCheck = allCertificates; + if (!string.IsNullOrEmpty(rule.DatabaseFilter)) + { + certsToCheck = certsToCheck + .Where(c => c.Database?.Equals(rule.DatabaseFilter, StringComparison.OrdinalIgnoreCase) == true) + .ToList(); + } + + foreach (var cert in certsToCheck) + { + var violation = EvaluateCertificateAgainstRule(policy, rule, cert); + if (violation != null) + { + result.Violations.Add(violation); + } + } + } + + private PolicyViolation? EvaluateCertificateAgainstRule( + CertificateCompliancePolicy policy, + PolicyRule rule, + SecureBootCertificate cert) + { + switch (rule.RuleType) + { + case PolicyRuleType.MinimumKeySize: + if (int.TryParse(rule.Value, out int minKeySize)) + { + if (cert.KeySize.HasValue && cert.KeySize.Value < minKeySize) + { + return CreateViolation(policy, rule, cert, + $"Certificate key size ({cert.KeySize} bits) is below minimum ({minKeySize} bits)"); + } + } + break; + + case PolicyRuleType.AllowedSignatureAlgorithms: + if (!string.IsNullOrEmpty(rule.Value) && !string.IsNullOrEmpty(cert.SignatureAlgorithm)) + { + var allowed = rule.Value.Split(',').Select(a => a.Trim()).ToList(); + if (!allowed.Any(a => cert.SignatureAlgorithm.Contains(a, StringComparison.OrdinalIgnoreCase))) + { + return CreateViolation(policy, rule, cert, + $"Certificate signature algorithm '{cert.SignatureAlgorithm}' is not in allowed list: {rule.Value}"); + } + } + break; + + case PolicyRuleType.DisallowedSignatureAlgorithms: + if (!string.IsNullOrEmpty(rule.Value) && !string.IsNullOrEmpty(cert.SignatureAlgorithm)) + { + var disallowed = rule.Value.Split(',').Select(a => a.Trim()).ToList(); + if (disallowed.Any(a => cert.SignatureAlgorithm.Contains(a, StringComparison.OrdinalIgnoreCase))) + { + return CreateViolation(policy, rule, cert, + $"Certificate signature algorithm '{cert.SignatureAlgorithm}' is disallowed"); + } + } + break; + + case PolicyRuleType.MaximumCertificateAge: + if (int.TryParse(rule.Value, out int maxAgeDays) && cert.NotBefore.HasValue) + { + var age = (DateTimeOffset.UtcNow - cert.NotBefore.Value).Days; + if (age > maxAgeDays) + { + return CreateViolation(policy, rule, cert, + $"Certificate age ({age} days) exceeds maximum ({maxAgeDays} days)"); + } + } + break; + + case PolicyRuleType.MinimumDaysUntilExpiration: + if (int.TryParse(rule.Value, out int minDays) && cert.DaysUntilExpiration.HasValue) + { + if (cert.DaysUntilExpiration.Value < minDays) + { + return CreateViolation(policy, rule, cert, + $"Certificate expires in {cert.DaysUntilExpiration} days, which is below threshold ({minDays} days)"); + } + } + break; + + case PolicyRuleType.RequireMicrosoftCertificate: + if (!cert.IsMicrosoftCertificate) + { + return CreateViolation(policy, rule, cert, + "Certificate is not issued by Microsoft"); + } + break; + + case PolicyRuleType.DisallowExpiredCertificates: + if (cert.IsExpired) + { + return CreateViolation(policy, rule, cert, + $"Certificate is expired (expired on {cert.NotAfter?.ToString("yyyy-MM-dd")})"); + } + break; + + case PolicyRuleType.RequireSubjectPattern: + if (!string.IsNullOrEmpty(rule.Value) && !string.IsNullOrEmpty(cert.Subject)) + { + try + { + if (!Regex.IsMatch(cert.Subject, rule.Value)) + { + return CreateViolation(policy, rule, cert, + $"Certificate subject '{cert.Subject}' does not match required pattern: {rule.Value}"); + } + } + catch (Exception) + { + // Invalid regex pattern, skip + } + } + break; + } + + return null; + } + + private PolicyViolation CreateViolation( + CertificateCompliancePolicy policy, + PolicyRule rule, + SecureBootCertificate cert, + string message) + { + return new PolicyViolation + { + PolicyId = policy.Id, + PolicyName = policy.Name, + Rule = rule, + Message = message, + CertificateThumbprint = cert.Thumbprint, + Database = cert.Database + }; + } + } +} diff --git a/SecureBootWatcher.Shared/Models/CertificateCompliancePolicy.cs b/SecureBootWatcher.Shared/Models/CertificateCompliancePolicy.cs new file mode 100644 index 0000000..deee792 --- /dev/null +++ b/SecureBootWatcher.Shared/Models/CertificateCompliancePolicy.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; + +namespace SecureBootWatcher.Shared.Models +{ + /// + /// Represents a certificate compliance policy that defines rules for certificate validation. + /// + public sealed class CertificateCompliancePolicy + { + /// + /// Unique identifier for the policy. + /// + public Guid Id { get; set; } + + /// + /// Human-readable name for the policy. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Optional description of the policy. + /// + public string? Description { get; set; } + + /// + /// Indicates if this policy is enabled and should be evaluated. + /// + public bool IsEnabled { get; set; } + + /// + /// Priority/order in which policies are evaluated (lower numbers = higher priority). + /// + public int Priority { get; set; } + + /// + /// Optional fleet ID to scope policy to specific fleets. If null, applies to all fleets. + /// + public string? FleetId { get; set; } + + /// + /// Policy rules that define compliance requirements. + /// + public List Rules { get; set; } = new List(); + + /// + /// Date when policy was created. + /// + public DateTimeOffset CreatedAtUtc { get; set; } + + /// + /// Date when policy was last modified. + /// + public DateTimeOffset? ModifiedAtUtc { get; set; } + } +} diff --git a/SecureBootWatcher.Shared/Models/ComplianceResult.cs b/SecureBootWatcher.Shared/Models/ComplianceResult.cs new file mode 100644 index 0000000..c92a7dc --- /dev/null +++ b/SecureBootWatcher.Shared/Models/ComplianceResult.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; + +namespace SecureBootWatcher.Shared.Models +{ + /// + /// Represents the result of evaluating a device against compliance policies. + /// + public sealed class ComplianceResult + { + /// + /// Device ID being evaluated. + /// + public Guid DeviceId { get; set; } + + /// + /// Overall compliance status. + /// + public ComplianceStatus Status { get; set; } + + /// + /// Individual policy violations detected. + /// + public List Violations { get; set; } = new List(); + + /// + /// Timestamp when evaluation was performed. + /// + public DateTimeOffset EvaluatedAtUtc { get; set; } + + /// + /// Report ID used for evaluation. + /// + public Guid? ReportId { get; set; } + } + + /// + /// Overall compliance status for a device. + /// + public enum ComplianceStatus + { + /// + /// Compliance status could not be determined. + /// + Unknown = 0, + + /// + /// Device is fully compliant with all policies. + /// + Compliant = 1, + + /// + /// Device has warnings but no critical violations. + /// + Warning = 2, + + /// + /// Device has critical policy violations. + /// + NonCompliant = 3 + } + + /// + /// Represents a single policy violation. + /// + public sealed class PolicyViolation + { + /// + /// Policy that was violated. + /// + public Guid PolicyId { get; set; } + + /// + /// Name of the policy. + /// + public string PolicyName { get; set; } = string.Empty; + + /// + /// Rule that was violated. + /// + public PolicyRule Rule { get; set; } = new PolicyRule(); + + /// + /// Description of the violation. + /// + public string Message { get; set; } = string.Empty; + + /// + /// Certificate thumbprint related to the violation (if applicable). + /// + public string? CertificateThumbprint { get; set; } + + /// + /// Certificate database where violation occurred (if applicable). + /// + public string? Database { get; set; } + } +} diff --git a/SecureBootWatcher.Shared/Models/PolicyRule.cs b/SecureBootWatcher.Shared/Models/PolicyRule.cs new file mode 100644 index 0000000..5f1b9f7 --- /dev/null +++ b/SecureBootWatcher.Shared/Models/PolicyRule.cs @@ -0,0 +1,97 @@ +using System; + +namespace SecureBootWatcher.Shared.Models +{ + /// + /// Represents a single rule within a compliance policy. + /// + public sealed class PolicyRule + { + /// + /// Type of rule being evaluated. + /// + public PolicyRuleType RuleType { get; set; } + + /// + /// Severity level if rule fails. + /// + public PolicySeverity Severity { get; set; } + + /// + /// Value or threshold for the rule (interpretation depends on RuleType). + /// + public string? Value { get; set; } + + /// + /// Optional database filter (db, dbx, KEK, PK). If null, applies to all databases. + /// + public string? DatabaseFilter { get; set; } + } + + /// + /// Types of policy rules that can be evaluated. + /// + public enum PolicyRuleType + { + /// + /// Minimum key size in bits (e.g., 2048). + /// + MinimumKeySize = 1, + + /// + /// Allowed signature algorithms (comma-separated list). + /// + AllowedSignatureAlgorithms = 2, + + /// + /// Disallowed signature algorithms (comma-separated list). + /// + DisallowedSignatureAlgorithms = 3, + + /// + /// Maximum certificate age in days from NotBefore date. + /// + MaximumCertificateAge = 4, + + /// + /// Minimum days until expiration (warn if certificate expires sooner). + /// + MinimumDaysUntilExpiration = 5, + + /// + /// Require certificate to be from Microsoft. + /// + RequireMicrosoftCertificate = 6, + + /// + /// Disallow expired certificates. + /// + DisallowExpiredCertificates = 7, + + /// + /// Require specific subject pattern (regular expression). + /// + RequireSubjectPattern = 8 + } + + /// + /// Severity levels for policy violations. + /// + public enum PolicySeverity + { + /// + /// Informational notice. + /// + Info = 1, + + /// + /// Warning that should be reviewed. + /// + Warning = 2, + + /// + /// Critical compliance violation. + /// + Critical = 3 + } +} From 0969b6a173d602ef64591334c754cec1c1f2c9b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 06:13:17 +0000 Subject: [PATCH 4/8] Add policy management UI and comprehensive tests Co-authored-by: robgrame <12012136+robgrame@users.noreply.github.com> --- .../PolicyEvaluationServiceTests.cs | 352 ++++++++++++++++++ .../Pages/Policies/Create.cshtml | 233 ++++++++++++ .../Pages/Policies/Create.cshtml.cs | 115 ++++++ .../Pages/Policies/List.cshtml | 148 ++++++++ .../Pages/Policies/List.cshtml.cs | 79 ++++ .../Pages/Shared/_Layout.cshtml | 5 + ....GeneratedMSBuildEditorConfig.editorconfig | 8 + 7 files changed, 940 insertions(+) create mode 100644 SecureBootDashboard.Api.Tests/PolicyEvaluationServiceTests.cs create mode 100644 SecureBootDashboard.Web/Pages/Policies/Create.cshtml create mode 100644 SecureBootDashboard.Web/Pages/Policies/Create.cshtml.cs create mode 100644 SecureBootDashboard.Web/Pages/Policies/List.cshtml create mode 100644 SecureBootDashboard.Web/Pages/Policies/List.cshtml.cs diff --git a/SecureBootDashboard.Api.Tests/PolicyEvaluationServiceTests.cs b/SecureBootDashboard.Api.Tests/PolicyEvaluationServiceTests.cs new file mode 100644 index 0000000..ee436bb --- /dev/null +++ b/SecureBootDashboard.Api.Tests/PolicyEvaluationServiceTests.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using SecureBootDashboard.Api.Services; +using SecureBootWatcher.Shared.Models; +using Xunit; + +namespace SecureBootDashboard.Api.Tests +{ + public class PolicyEvaluationServiceTests + { + [Fact] + public void EvaluateCompliance_NoCertificates_ReturnsUnknown() + { + // Arrange + var policies = new List(); + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + // Act + var result = service.EvaluateCompliance(deviceId, null, null, null); + + // Assert + Assert.Equal(ComplianceStatus.Unknown, result.Status); + Assert.Equal(deviceId, result.DeviceId); + Assert.Empty(result.Violations); + } + + [Fact] + public void EvaluateCompliance_NoPolicies_ReturnsCompliant() + { + // Arrange + var policies = new List(); + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + var certificates = CreateSampleCertificates(); + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.Compliant, result.Status); + Assert.Empty(result.Violations); + } + + [Fact] + public void EvaluateCompliance_MinimumKeySize_DetectsViolation() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Minimum Key Size Policy", + IsEnabled = true, + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.MinimumKeySize, + Severity = PolicySeverity.Critical, + Value = "2048" + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "ABC123", + Subject = "CN=Test Cert", + KeySize = 1024, // Below minimum + IsExpired = false + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.NonCompliant, result.Status); + Assert.Single(result.Violations); + Assert.Equal(policy.Id, result.Violations[0].PolicyId); + Assert.Contains("1024", result.Violations[0].Message); + Assert.Contains("2048", result.Violations[0].Message); + } + + [Fact] + public void EvaluateCompliance_ExpiredCertificate_DetectsViolation() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "No Expired Certs Policy", + IsEnabled = true, + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.DisallowExpiredCertificates, + Severity = PolicySeverity.Critical + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "DEF456", + Subject = "CN=Expired Cert", + IsExpired = true, + NotAfter = DateTimeOffset.UtcNow.AddDays(-10) + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.NonCompliant, result.Status); + Assert.Single(result.Violations); + Assert.Contains("expired", result.Violations[0].Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EvaluateCompliance_RequireMicrosoftCert_DetectsViolation() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Microsoft Only Policy", + IsEnabled = true, + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.RequireMicrosoftCertificate, + Severity = PolicySeverity.Warning + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "GHI789", + Subject = "CN=Third Party Cert", + IsMicrosoftCertificate = false + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.Warning, result.Status); + Assert.Single(result.Violations); + Assert.Contains("Microsoft", result.Violations[0].Message); + } + + [Fact] + public void EvaluateCompliance_MinimumDaysUntilExpiration_DetectsViolation() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Expiration Warning Policy", + IsEnabled = true, + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.MinimumDaysUntilExpiration, + Severity = PolicySeverity.Warning, + Value = "90" + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "JKL012", + Subject = "CN=Expiring Soon", + DaysUntilExpiration = 30, // Below 90 day threshold + IsExpired = false + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.Warning, result.Status); + Assert.Single(result.Violations); + Assert.Contains("30", result.Violations[0].Message); + Assert.Contains("90", result.Violations[0].Message); + } + + [Fact] + public void EvaluateCompliance_DisabledPolicy_IsIgnored() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Disabled Policy", + IsEnabled = false, // Disabled + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.DisallowExpiredCertificates, + Severity = PolicySeverity.Critical + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "MNO345", + IsExpired = true // Would violate if policy was enabled + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.Compliant, result.Status); + Assert.Empty(result.Violations); + } + + [Fact] + public void EvaluateCompliance_FleetScoped_OnlyAppliesToMatchingFleet() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Fleet-Specific Policy", + IsEnabled = true, + Priority = 1, + FleetId = "fleet-01", + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.DisallowExpiredCertificates, + Severity = PolicySeverity.Critical + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "PQR678", + IsExpired = true + } + } + }; + + // Act - device in different fleet + var result = service.EvaluateCompliance(deviceId, null, certificates, "fleet-02"); + + // Assert + Assert.Equal(ComplianceStatus.Compliant, result.Status); + Assert.Empty(result.Violations); + } + + private SecureBootCertificateCollection CreateSampleCertificates() + { + return new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "123ABC", + Subject = "CN=Microsoft Corporation", + KeySize = 2048, + IsExpired = false, + IsMicrosoftCertificate = true, + DaysUntilExpiration = 365 + } + } + }; + } + } +} diff --git a/SecureBootDashboard.Web/Pages/Policies/Create.cshtml b/SecureBootDashboard.Web/Pages/Policies/Create.cshtml new file mode 100644 index 0000000..bf4c738 --- /dev/null +++ b/SecureBootDashboard.Web/Pages/Policies/Create.cshtml @@ -0,0 +1,233 @@ +@page +@model SecureBootDashboard.Web.Pages.Policies.CreateModel +@{ + ViewData["Title"] = "Create Policy"; +} + +
+
+

Create Compliance Policy

+ +
+
+ +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+
+
Policy Details
+
+
+
+
+ + + +
+
+ + + Lower values = higher priority + +
+
+ +
+ + + +
+ +
+
+ + + Policy will apply only to this fleet if specified + +
+
+
+ + +
+
+
+
+
+ +
+
+
Policy Rules
+ +
+
+
+
+ No rules added yet. Click "Add Rule" to define compliance requirements. +
+
+
+
+ + + +
+ + + Cancel + +
+
+ +@section Scripts { + + +} diff --git a/SecureBootDashboard.Web/Pages/Policies/Create.cshtml.cs b/SecureBootDashboard.Web/Pages/Policies/Create.cshtml.cs new file mode 100644 index 0000000..93cc229 --- /dev/null +++ b/SecureBootDashboard.Web/Pages/Policies/Create.cshtml.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Web.Pages.Policies +{ + public class CreateModel : PageModel + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public CreateModel(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + [BindProperty] + [Required] + public string Name { get; set; } = string.Empty; + + [BindProperty] + public string? Description { get; set; } + + [BindProperty] + public bool IsEnabled { get; set; } = true; + + [BindProperty] + [Required] + public int Priority { get; set; } = 100; + + [BindProperty] + public string? FleetId { get; set; } + + [BindProperty] + public string? RulesJson { get; set; } + + public string? ErrorMessage { get; set; } + + public IActionResult OnGet() + { + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + try + { + var rules = new List(); + if (!string.IsNullOrEmpty(RulesJson)) + { + try + { + rules = JsonSerializer.Deserialize>(RulesJson, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? new List(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize rules JSON"); + ErrorMessage = "Invalid rules data. Please check your rule configuration."; + return Page(); + } + } + + var policy = new CertificateCompliancePolicy + { + Name = Name, + Description = Description, + IsEnabled = IsEnabled, + Priority = Priority, + FleetId = FleetId, + Rules = rules + }; + + var client = _httpClientFactory.CreateClient("SecureBootApi"); + var json = JsonSerializer.Serialize(policy); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync("/api/Policies", content); + + if (response.IsSuccessStatusCode) + { + return RedirectToPage("./List"); + } + else + { + ErrorMessage = $"Failed to create policy: {response.StatusCode}"; + _logger.LogWarning("Failed to create policy. Status: {StatusCode}", response.StatusCode); + return Page(); + } + } + catch (Exception ex) + { + ErrorMessage = "An error occurred while creating the policy."; + _logger.LogError(ex, "Error creating policy"); + return Page(); + } + } + } +} diff --git a/SecureBootDashboard.Web/Pages/Policies/List.cshtml b/SecureBootDashboard.Web/Pages/Policies/List.cshtml new file mode 100644 index 0000000..a4babcb --- /dev/null +++ b/SecureBootDashboard.Web/Pages/Policies/List.cshtml @@ -0,0 +1,148 @@ +@page +@model SecureBootDashboard.Web.Pages.Policies.ListModel +@{ + ViewData["Title"] = "Compliance Policies"; +} + +
+
+

Certificate Compliance Policies

+

Define and manage policies to enforce certificate compliance across your device fleet.

+
+ +
+ +@if (Model.ErrorMessage != null) +{ + +} + +@if (Model.Policies.Count == 0) +{ +
+ No compliance policies have been created yet. + Create your first policy to start enforcing certificate compliance. +
+} +else +{ +
+
+
+ + + + + + + + + + + + + + @foreach (var policy in Model.Policies) + { + + + + + + + + + + } + +
NameStatusPriorityFleetRulesCreatedActions
+ @policy.Name + @if (!string.IsNullOrEmpty(policy.Description)) + { +
+ @policy.Description + } +
+ @if (policy.IsEnabled) + { + + Enabled + + } + else + { + + Disabled + + } + @policy.Priority + @if (string.IsNullOrEmpty(policy.FleetId)) + { + All Fleets + } + else + { + @policy.FleetId + } + + @policy.Rules.Count rule(s) + + @policy.CreatedAtUtc.ToString("yyyy-MM-dd HH:mm") + +
+ + + + +
+
+
+
+
+} + +@* Delete confirmation modal *@ + + +@section Scripts { + +} diff --git a/SecureBootDashboard.Web/Pages/Policies/List.cshtml.cs b/SecureBootDashboard.Web/Pages/Policies/List.cshtml.cs new file mode 100644 index 0000000..4f7d6c3 --- /dev/null +++ b/SecureBootDashboard.Web/Pages/Policies/List.cshtml.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Web.Pages.Policies +{ + public class ListModel : PageModel + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public ListModel(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public List Policies { get; set; } = new List(); + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + try + { + var client = _httpClientFactory.CreateClient("SecureBootApi"); + var response = await client.GetAsync("/api/Policies"); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + Policies = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? new List(); + } + else + { + ErrorMessage = $"Failed to load policies: {response.StatusCode}"; + _logger.LogWarning("Failed to load policies. Status: {StatusCode}", response.StatusCode); + } + } + catch (Exception ex) + { + ErrorMessage = "An error occurred while loading policies."; + _logger.LogError(ex, "Error loading policies"); + } + + return Page(); + } + + public async Task OnPostDeleteAsync(Guid id) + { + try + { + var client = _httpClientFactory.CreateClient("SecureBootApi"); + var response = await client.DeleteAsync($"/api/Policies/{id}"); + + if (!response.IsSuccessStatusCode) + { + ErrorMessage = $"Failed to delete policy: {response.StatusCode}"; + _logger.LogWarning("Failed to delete policy {PolicyId}. Status: {StatusCode}", id, response.StatusCode); + } + } + catch (Exception ex) + { + ErrorMessage = "An error occurred while deleting the policy."; + _logger.LogError(ex, "Error deleting policy {PolicyId}", id); + } + + return RedirectToPage(); + } + } +} diff --git a/SecureBootDashboard.Web/Pages/Shared/_Layout.cshtml b/SecureBootDashboard.Web/Pages/Shared/_Layout.cshtml index c9675bc..ba59ea0 100644 --- a/SecureBootDashboard.Web/Pages/Shared/_Layout.cshtml +++ b/SecureBootDashboard.Web/Pages/Shared/_Layout.cshtml @@ -76,6 +76,11 @@ Dispositivi +