Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:start -->
# GitNexus — Code Intelligence

Expand Down
8 changes: 7 additions & 1 deletion src/PatchHound.Api/Controllers/DashboardController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -1747,6 +1749,10 @@ private async Task<ExecutiveAccountabilitySummaryDto> BuildExecutiveAccountabili
topOwners);
}

private static IQueryable<DeviceVulnerabilityExposure> DeviceIsActiveAndHealthy(
IQueryable<DeviceVulnerabilityExposure> exposures) =>
exposures.Where(e => e.Device.ActiveInTenant && e.Device.HealthStatus == ActiveDeviceHealthStatus);

private async Task<ExecutiveExposureSummaryDto> BuildExecutiveExposureSummaryAsync(
Guid tenantId,
DashboardFilterQuery filter,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace PatchHound.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class FilterOpenExposureSummaryToActiveDevices : Migration
{
/// <inheritdoc />
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);
"""
);
}

/// <inheritdoc />
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);
"""
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -106,13 +110,42 @@ 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<OkObjectResult>().Subject;
var dto = ok.Value.Should().BeOfType<DashboardSummaryDto>().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)]
public async Task GetSummary_ExceptionVulnerability_IsExcludedFromPresentationListsAndCharts(
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);
Expand Down Expand Up @@ -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();
Expand Down
Loading