diff --git a/CLAUDE.md b/CLAUDE.md index c2016fd7..d2e7c64a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,10 @@ See `docs/testing-conventions.md` for the full rules. Key points: Canonical entities must enforce EF max-length caps and FK `Guid` validity at the factory boundary — not in service code. See memory file `feedback_canonical_entity_factory_validation.md` for details. +## Exposure Counting Convention + +Dashboard open-exposure summaries should count only exposures on active, healthy devices: `Devices.ActiveInTenant = true` and `Devices.HealthStatus = 'Active'`. Keep materialized views and base-table fallbacks aligned with this filter. + # GitNexus — Code Intelligence diff --git a/src/PatchHound.Api/Controllers/DashboardController.cs b/src/PatchHound.Api/Controllers/DashboardController.cs index f8c135d2..49e5efcb 100644 --- a/src/PatchHound.Api/Controllers/DashboardController.cs +++ b/src/PatchHound.Api/Controllers/DashboardController.cs @@ -19,6 +19,8 @@ namespace PatchHound.Api.Controllers; [Authorize] public class DashboardController : ControllerBase { + private const string ActiveDeviceHealthStatus = "Active"; + private readonly PatchHoundDbContext _dbContext; private readonly DashboardQueryService _dashboardQueryService; private readonly ITenantContext _tenantContext; @@ -70,7 +72,7 @@ CancellationToken ct // Vulnerability counts from canonical DeviceVulnerabilityExposures var now = DateTimeOffset.UtcNow; - var exposureBaseQuery = _dbContext.DeviceVulnerabilityExposures.AsNoTracking() + var exposureBaseQuery = DeviceIsActiveAndHealthy(_dbContext.DeviceVulnerabilityExposures.AsNoTracking()) .Where(e => e.TenantId == tenantId); if (filteredAssetIds != null) exposureBaseQuery = exposureBaseQuery.Where(e => filteredAssetIds.Contains(e.DeviceId)); @@ -1747,6 +1749,10 @@ private async Task BuildExecutiveAccountabili topOwners); } + private static IQueryable DeviceIsActiveAndHealthy( + IQueryable exposures) => + exposures.Where(e => e.Device.ActiveInTenant && e.Device.HealthStatus == ActiveDeviceHealthStatus); + private async Task BuildExecutiveExposureSummaryAsync( Guid tenantId, DashboardFilterQuery filter, diff --git a/src/PatchHound.Infrastructure/Migrations/20260523120000_FilterOpenExposureSummaryToActiveDevices.cs b/src/PatchHound.Infrastructure/Migrations/20260523120000_FilterOpenExposureSummaryToActiveDevices.cs new file mode 100644 index 00000000..b94b6c63 --- /dev/null +++ b/src/PatchHound.Infrastructure/Migrations/20260523120000_FilterOpenExposureSummaryToActiveDevices.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PatchHound.Infrastructure.Migrations +{ + /// + public partial class FilterOpenExposureSummaryToActiveDevices : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DROP MATERIALIZED VIEW IF EXISTS mv_open_exposure_vuln_summary; + + CREATE MATERIALIZED VIEW mv_open_exposure_vuln_summary AS + SELECT + dve."TenantId", + dve."VulnerabilityId", + v."VendorSeverity", + COUNT(DISTINCT dve."DeviceId")::integer AS "AffectedDeviceCount", + MAX(dve."LastObservedAt") AS "LatestSeenAt", + MAX(v."CvssScore") AS "MaxCvss", + MIN(v."PublishedDate") AS "PublishedDate" + FROM "DeviceVulnerabilityExposures" dve + JOIN "Vulnerabilities" v ON v."Id" = dve."VulnerabilityId" + JOIN "Devices" d ON d."Id" = dve."DeviceId" + WHERE dve."Status" = 'Open' + AND d."ActiveInTenant" = TRUE + AND d."HealthStatus" = 'Active' + GROUP BY dve."TenantId", dve."VulnerabilityId", v."VendorSeverity"; + + CREATE UNIQUE INDEX ix_mv_oevs_tenant_vuln + ON mv_open_exposure_vuln_summary ("TenantId", "VulnerabilityId"); + + CREATE INDEX ix_mv_oevs_tenant_severity_count + ON mv_open_exposure_vuln_summary ("TenantId", "VendorSeverity", "AffectedDeviceCount" DESC); + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DROP MATERIALIZED VIEW IF EXISTS mv_open_exposure_vuln_summary; + + CREATE MATERIALIZED VIEW mv_open_exposure_vuln_summary AS + SELECT + dve."TenantId", + dve."VulnerabilityId", + v."VendorSeverity", + COUNT(DISTINCT dve."DeviceId")::integer AS "AffectedDeviceCount", + MAX(dve."LastObservedAt") AS "LatestSeenAt", + MAX(v."CvssScore") AS "MaxCvss", + MIN(v."PublishedDate") AS "PublishedDate" + FROM "DeviceVulnerabilityExposures" dve + JOIN "Vulnerabilities" v ON v."Id" = dve."VulnerabilityId" + WHERE dve."Status" = 'Open' + GROUP BY dve."TenantId", dve."VulnerabilityId", v."VendorSeverity"; + + CREATE UNIQUE INDEX ix_mv_oevs_tenant_vuln + ON mv_open_exposure_vuln_summary ("TenantId", "VulnerabilityId"); + + CREATE INDEX ix_mv_oevs_tenant_severity_count + ON mv_open_exposure_vuln_summary ("TenantId", "VendorSeverity", "AffectedDeviceCount" DESC); + """ + ); + } + } +} diff --git a/tests/PatchHound.Tests/Api/DashboardControllerSummaryAggregationTests.cs b/tests/PatchHound.Tests/Api/DashboardControllerSummaryAggregationTests.cs index 76d41746..6933aa9f 100644 --- a/tests/PatchHound.Tests/Api/DashboardControllerSummaryAggregationTests.cs +++ b/tests/PatchHound.Tests/Api/DashboardControllerSummaryAggregationTests.cs @@ -49,6 +49,8 @@ public DashboardControllerSummaryAggregationTests() public async Task GetSummary_VulnerabilityAgeBuckets_CountUniqueVulnerabilities() { var seed = await CanonicalSeed.PlantAsync(_dbContext, _tenantId); + MarkActiveAndHealthy(seed.DeviceA); + MarkActiveAndHealthy(seed.DeviceB); _dbContext.DeviceVulnerabilityExposures.Add(DeviceVulnerabilityExposure.Observe( _tenantId, seed.DeviceB.Id, @@ -77,6 +79,8 @@ public async Task GetSummary_VulnerabilityAgeBuckets_CountUniqueVulnerabilities( public async Task GetSummary_TopCriticalVulnerabilities_ExcludesAlternateMitigationVulnerabilities() { var seed = await CanonicalSeed.PlantAsync(_dbContext, _tenantId); + MarkActiveAndHealthy(seed.DeviceA); + MarkActiveAndHealthy(seed.DeviceB); var remediationCase = RemediationCase.Create(_tenantId, seed.ProductA.Id); var decision = RemediationDecision.Create( _tenantId, @@ -106,6 +110,33 @@ public async Task GetSummary_TopCriticalVulnerabilities_ExcludesAlternateMitigat dto.TopCriticalVulnerabilities.Should().NotContain(item => item.Id == seed.ExposureA.VulnerabilityId); } + [Fact] + public async Task GetSummary_OpenExposureCounts_ExcludeInactiveAndUnhealthyDevices() + { + var seed = await CanonicalSeed.PlantAsync(_dbContext, _tenantId); + MarkActiveAndHealthy(seed.DeviceA); + seed.DeviceB.UpdateInventoryDetails( + computerDnsName: null, + healthStatus: "Inactive", + osPlatform: null, + osVersion: null, + externalRiskLabel: null, + lastSeenAt: DateTimeOffset.UtcNow, + lastIpAddress: null, + aadDeviceId: null); + await _dbContext.SaveChangesAsync(); + + var action = await _controller.GetSummary(new DashboardFilterQuery(), CancellationToken.None); + + var ok = action.Result.Should().BeOfType().Subject; + var dto = ok.Value.Should().BeOfType().Subject; + dto.VulnerabilitiesBySeverity[nameof(Severity.Critical)].Should().Be(1); + dto.VulnerabilitiesBySeverity[nameof(Severity.High)].Should().Be(0); + dto.VulnerabilitiesByStatus[nameof(VulnerabilityStatus.Open)].Should().Be(1); + dto.TopCriticalVulnerabilities.Should().ContainSingle(item => item.Id == seed.ExposureA.VulnerabilityId); + dto.TopCriticalVulnerabilities.Should().NotContain(item => item.Id == seed.ExposureB.VulnerabilityId); + } + [Theory] [InlineData(RemediationOutcome.RiskAcceptance)] [InlineData(RemediationOutcome.AlternateMitigation)] @@ -113,6 +144,8 @@ public async Task GetSummary_ExceptionVulnerability_IsExcludedFromPresentationLi RemediationOutcome outcome) { var seed = await CanonicalSeed.PlantAsync(_dbContext, _tenantId); + MarkActiveAndHealthy(seed.DeviceA); + MarkActiveAndHealthy(seed.DeviceB); await AddApprovedRemediationAsync(seed.ProductA.Id, seed.ExposureA.VulnerabilityId, outcome); var action = await _controller.GetSummary(new DashboardFilterQuery(), CancellationToken.None); @@ -154,6 +187,20 @@ private async Task AddApprovedRemediationAsync( await _dbContext.SaveChangesAsync(); } + private static void MarkActiveAndHealthy(Device device) + { + device.SetActiveInTenant(true); + device.UpdateInventoryDetails( + computerDnsName: null, + healthStatus: "Active", + osPlatform: null, + osVersion: null, + externalRiskLabel: null, + lastSeenAt: DateTimeOffset.UtcNow, + lastIpAddress: null, + aadDeviceId: null); + } + public void Dispose() { _dbContext.Dispose();