From 6c53dc33e7df28c0933cd13ed0950cf0e204b0ff Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 24 Jul 2026 20:47:06 +0200 Subject: [PATCH] feat(distress): tenant-visible TpmPssUnsupported devices + one-shot bell TpmPssUnsupported distress reports (TPM cannot RSA-PSS-sign, Schannel drops the client cert on Win11 25H2+, agent can never authenticate) were operator- only, yet the remediation is tenant-side: TPM firmware update or device replacement. - New GET audit/tpm-pss-unsupported (MemberRead), aggregated per serial, mirroring audit/device-not-registered - Bell notification "tpm_pss_unsupported" (Admin audience) once per (tenant, serial); dedup via the existing rejection tracker table with a disjoint "tpmpss|{serial}" row-key space; no webhook by design - Web: TpmIncompatibleDevicesInsights panel in the Hardware Whitelist section; renders only when a device is affected (no permanent card) No agent change, no ConfigVersion bump. Backend tests 3287/3287, tsc clean. Co-Authored-By: Claude Fable 5 --- .../GetTpmPssUnsupportedFunctionTests.cs | 194 ++++++++++++++++++ ...areRejectionNotificationTrackerKeyTests.cs | 31 +++ .../TenantNotificationAudienceCatalogTests.cs | 1 + ...bleHardwareRejectionNotificationTracker.cs | 49 ++++- .../Ingest/ReportDistressFunction.cs | 30 +++ .../Reports/GetTpmPssUnsupportedFunction.cs | 95 +++++++++ .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../TenantNotificationAudienceCatalog.cs | 1 + .../IHardwareRejectionNotificationTracker.cs | 18 +- .../TpmIncompatibleDevicesInsights.tsx | 146 +++++++++++++ .../sections/SectionHardwareWhitelist.tsx | 2 + src/Web/autopilot-monitor-web/lib/api.ts | 1 + 12 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/GetTpmPssUnsupportedFunctionTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetTpmPssUnsupportedFunction.cs create mode 100644 src/Web/autopilot-monitor-web/app/settings/components/TpmIncompatibleDevicesInsights.tsx diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTpmPssUnsupportedFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTpmPssUnsupportedFunctionTests.cs new file mode 100644 index 00000000..a2d73884 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTpmPssUnsupportedFunctionTests.cs @@ -0,0 +1,194 @@ +using AutopilotMonitor.Functions.Functions.Reports; +using AutopilotMonitor.Shared.DataAccess; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the TPM-PSS-unsupported aggregation logic extracted from +/// GetTpmPssUnsupportedFunction. All tests target the pure static BuildAggregatedResult +/// method — no HTTP or DI needed. Aggregation is by serial number (the TPM incompatibility +/// is a per-device condition, not a per-model one — firmware levels differ within a model). +/// +public class GetTpmPssUnsupportedFunctionTests +{ + private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); + + // ========================================================================= + // Empty / no matching reports + // ========================================================================= + + [Fact] + public void BuildAggregatedResult_EmptyList_ReturnsEmpty() + { + var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult([]); + + Assert.Empty(aggregated); + Assert.Equal(0, totalRawReports); + } + + [Fact] + public void BuildAggregatedResult_FiltersTpmPssUnsupportedOnly() + { + var reports = new List + { + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN2", T0.AddMinutes(1)), + MakeEntry("AuthCertificateRejected", "HP", "EliteBook", "SN3", T0.AddMinutes(2)), + }; + + var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports); + + Assert.Single(aggregated); + Assert.Equal(1, totalRawReports); + } + + // ========================================================================= + // Aggregation by serial number + // ========================================================================= + + [Fact] + public void BuildAggregatedResult_AggregatesBySerialCaseInsensitive() + { + var reports = new List + { + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "S4SQ8685", T0), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "s4sq8685", T0.AddMinutes(1)), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", " S4SQ8685 ", T0.AddMinutes(2)), + }; + + var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports); + + Assert.Single(aggregated); + Assert.Equal(3, totalRawReports); + var item = ToDynamic(aggregated[0]); + Assert.Equal(3, (int)item.attemptCount); + } + + [Fact] + public void BuildAggregatedResult_DistinctSerials_ProduceDistinctRows() + { + var reports = new List + { + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN2", T0.AddMinutes(1)), + MakeEntry("TpmPssUnsupported", "Microsoft", "Surface Book", "SN3", T0.AddMinutes(2)), + }; + + var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports); + + Assert.Equal(3, aggregated.Count); + Assert.Equal(3, totalRawReports); + } + + [Fact] + public void BuildAggregatedResult_UsesMostRecentManufacturerModel() + { + var reports = new List + { + MakeEntry("TpmPssUnsupported", "OldMfr", "OldModel", "SN1", T0), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0.AddMinutes(5)), + }; + + var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports); + + var item = ToDynamic(aggregated[0]); + Assert.Equal("Lenovo", (string)item.manufacturer); + Assert.Equal("ThinkCentre M720q", (string)item.model); + Assert.Equal("SN1", (string)item.serialNumber); + } + + // ========================================================================= + // Timestamps + // ========================================================================= + + [Fact] + public void BuildAggregatedResult_FirstSeenLastSeen_Correct() + { + var t1 = T0; + var t2 = T0.AddHours(1); + var t3 = T0.AddHours(2); + + var reports = new List + { + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", t2), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", t1), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", t3), + }; + + var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports); + + var item = ToDynamic(aggregated[0]); + Assert.Equal(t1, (DateTime)item.firstSeen); + Assert.Equal(t3, (DateTime)item.lastSeen); + } + + [Fact] + public void BuildAggregatedResult_OrderedByLastSeenDescending() + { + var reports = new List + { + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkPad X13", "SN2", T0.AddHours(2)), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720s", "SN3", T0.AddHours(1)), + }; + + var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports); + + Assert.Equal(3, aggregated.Count); + var first = ToDynamic(aggregated[0]); + var second = ToDynamic(aggregated[1]); + var third = ToDynamic(aggregated[2]); + + Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen); + Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen); + } + + // ========================================================================= + // Null / empty serial number + // ========================================================================= + + [Fact] + public void BuildAggregatedResult_NullOrEmptySerials_GroupedIntoSingleBucket() + { + var reports = new List + { + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", null, T0), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "", T0.AddMinutes(1)), + MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", " ", T0.AddMinutes(2)), + }; + + var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports); + + Assert.Single(aggregated); + var item = ToDynamic(aggregated[0]); + Assert.Equal(3, (int)item.attemptCount); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private static DistressReportEntry MakeEntry( + string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) => + new() + { + TenantId = "test-tenant", + ErrorType = errorType, + Manufacturer = manufacturer, + Model = model, + SerialNumber = serial, + IngestedAt = ingestedAt, + AgentTimestamp = ingestedAt, + }; + + private static dynamic ToDynamic(object obj) + { + var type = obj.GetType(); + var dict = new System.Dynamic.ExpandoObject() as IDictionary; + foreach (var prop in type.GetProperties()) + { + dict[prop.Name] = prop.GetValue(obj); + } + return dict; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/HardwareRejectionNotificationTrackerKeyTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/HardwareRejectionNotificationTrackerKeyTests.cs index 944b7b5d..6976915b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/HardwareRejectionNotificationTrackerKeyTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/HardwareRejectionNotificationTrackerKeyTests.cs @@ -59,4 +59,35 @@ public class HardwareRejectionNotificationTrackerKeyTests var key = TableHardwareRejectionNotificationTracker.BuildRowKey("", ""); Assert.Equal("|", key); } + + // ========================================================================= + // TPM PSS row keys (same table, "tpmpss|" prefix — dedup per serial) + // ========================================================================= + + [Fact] + public void BuildTpmPssRowKey_LowercasesTrimsAndPrefixes() + { + var key = TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey(" S4SQ8685 "); + Assert.Equal("tpmpss|s4sq8685", key); + } + + [Theory] + [InlineData("S4SQ8685", "s4sq8685")] + [InlineData("s4Sq8685", "S4SQ8685")] + public void BuildTpmPssRowKey_IsCaseInsensitive(string serialA, string serialB) + { + Assert.Equal( + TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey(serialA), + TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey(serialB)); + } + + [Fact] + public void BuildTpmPssRowKey_DoesNotCollideWithHardwareKeyOfSameText() + { + // A hardware key is "{mfr}|{model}"; the TPM key's fixed "tpmpss|" prefix keeps the + // two key spaces disjoint within the shared table. + var hardwareKey = TableHardwareRejectionNotificationTracker.BuildRowKey("Lenovo", "S4SQ8685"); + var tpmKey = TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey("S4SQ8685"); + Assert.NotEqual(hardwareKey, tpmKey); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationAudienceCatalogTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationAudienceCatalogTests.cs index d0495270..25bdbeb6 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationAudienceCatalogTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantNotificationAudienceCatalogTests.cs @@ -13,6 +13,7 @@ public class TenantNotificationAudienceCatalogTests { [Theory] [InlineData("hardware_rejection", NotificationAudience.Admin)] + [InlineData("tpm_pss_unsupported", NotificationAudience.Admin)] [InlineData("sla_breach", NotificationAudience.Member)] [InlineData("sla_consecutive_failures", NotificationAudience.Member)] [InlineData("sla_resolved", NotificationAudience.Member)] diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs index ce535d7a..643e9d2a 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs @@ -9,7 +9,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { /// /// Table Storage implementation of . - /// PartitionKey = tenantId (lowercased), RowKey = "{manufacturer-lower}|{model-lower}" (trimmed). + /// PartitionKey = tenantId (lowercased). RowKey encodes the dedup subject: + /// - hardware rejection: "{manufacturer-lower}|{model-lower}" (trimmed) + /// - TPM PSS unsupported: "tpmpss|{serial-lower}" (trimmed; the literal "tpmpss" prefix + /// cannot collide with a hardware key because no real manufacturer is named "tpmpss") /// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists. /// public class TableHardwareRejectionNotificationTracker : IHardwareRejectionNotificationTracker @@ -68,6 +71,44 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage } } + public async Task TryRegisterFirstTpmPssNotificationAsync(string tenantId, string serialNumber) + { + if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(serialNumber)) + return false; + + var partitionKey = tenantId.ToLowerInvariant(); + var rowKey = BuildTpmPssRowKey(serialNumber); + + var entity = new TableEntity(partitionKey, rowKey) + { + ["TenantId"] = tenantId, + ["SerialNumber"] = serialNumber, + ["FirstNotifiedAt"] = DateTime.UtcNow + }; + + try + { + await _table.AddEntityAsync(entity); + _logger.LogInformation( + "TpmPssUnsupported tracker registered: tenant={TenantId} sn={SerialNumber}", + tenantId, serialNumber); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 409) + { + // Already notified for this (tenant, serial) — no second bell. + return false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "TpmPssUnsupported tracker failed: tenant={TenantId} sn={SerialNumber}", + tenantId, serialNumber); + // On unexpected failure, return false so we do not double-fire if the row was actually written. + return false; + } + } + public async Task DeleteOlderThanAsync(DateTime cutoffUtc) { try @@ -111,5 +152,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage var mdl = (model ?? string.Empty).Trim().ToLowerInvariant(); return $"{mfr}|{mdl}"; } + + internal static string BuildTpmPssRowKey(string serialNumber) + { + var sn = (serialNumber ?? string.Empty).Trim().ToLowerInvariant(); + return $"tpmpss|{sn}"; + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs index cb560417..f77c64d8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs @@ -269,6 +269,36 @@ namespace AutopilotMonitor.Functions.Functions.Ingest } } + // In-app bell notification on first-ever TPM-PSS incompatibility per (tenant, serial). + // Same lifetime-dedup pattern as the hardware-rejection bell above. No webhook — + // volume is tiny and the remediation (TPM firmware update / device replacement) + // is not time-critical. Reports without a serial number cannot be deduped and + // fire no bell; they still appear in the tenant insights panel. + if (report.ErrorType == DistressErrorType.TpmPssUnsupported + && !string.IsNullOrEmpty(serialNumber)) + { + try + { + var isFirst = await _hardwareBellTracker + .TryRegisterFirstTpmPssNotificationAsync(tenantId, serialNumber); + if (isFirst) + { + var deviceLabel = string.Join(" ", + new[] { manufacturer, model }.Where(s => !string.IsNullOrEmpty(s))); + await _tenantNotificationService.CreateNotificationAsync( + tenantId, + type: "tpm_pss_unsupported", + title: "Device with incompatible TPM detected", + message: $"Device {serialNumber}{(deviceLabel.Length > 0 ? $" ({deviceLabel})" : "")} reported a TPM that cannot perform RSA-PSS signing — the agent on this device cannot authenticate. A TPM firmware update or device replacement is required.", + href: "/settings/tenant/hardware-whitelist"); + } + } + catch (Exception bellEx) + { + _logger.LogWarning(bellEx, "TpmPssUnsupported bell notification failed for tenant {TenantId}", tenantId); + } + } + // Structured log (Warning, not Critical — data is unverified) _logger.LogWarning( "AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetTpmPssUnsupportedFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetTpmPssUnsupportedFunction.cs new file mode 100644 index 00000000..11c0254a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetTpmPssUnsupportedFunction.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Reports +{ + /// + /// Tenant-scoped endpoint: returns devices whose TPM-backed client certificate cannot + /// perform RSA-PSS signing (Schannel silently drops the cert from TLS client-auth on + /// Windows 11 25H2+, so the agent can never authenticate), aggregated by serial number. + /// Data comes from distress reports with ErrorType == "TpmPssUnsupported". + /// + /// Informational only — the remediation is a TPM firmware update or device replacement, + /// not a portal action. The 14-day horizon is enforced by the distress-report retention + /// cleanup, not by this function. + /// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware. + /// + public class GetTpmPssUnsupportedFunction + { + private readonly ILogger _logger; + private readonly IDistressReportRepository _repository; + + public GetTpmPssUnsupportedFunction( + ILogger logger, + IDistressReportRepository repository) + { + _logger = logger; + _repository = repository; + } + + [Function("GetTpmPssUnsupported")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "audit/tpm-pss-unsupported")] HttpRequestData req) + { + try + { + string tenantId = TenantHelper.GetTenantId(req); + + var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200); + var (aggregated, totalRawReports) = BuildAggregatedResult(reports); + + return await req.OkAsync(new + { + success = true, + aggregated, + totalRawReports, + dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot perform RSA-PSS signing, so their agent cannot authenticate to the backend. Serial number, manufacturer, and model values are self-reported by devices." + }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "Get TPM PSS unsupported devices"); + } + } + + /// + /// Filters distress reports to TpmPssUnsupported and aggregates by serial number. + /// Reports with no serial number are grouped into a single "unknown serial" bucket. + /// Extracted as public static for testability. + /// + public static (List aggregated, int totalRawReports) BuildAggregatedResult( + List reports) + { + var tpmReports = reports + .Where(r => string.Equals(r.ErrorType, "TpmPssUnsupported", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var aggregated = tpmReports + .GroupBy(r => (r.SerialNumber ?? "").Trim().ToLowerInvariant()) + .Select(g => + { + var mostRecent = g.OrderByDescending(r => r.IngestedAt).First(); + + return new + { + serialNumber = mostRecent.SerialNumber ?? "", + manufacturer = mostRecent.Manufacturer ?? "", + model = mostRecent.Model ?? "", + attemptCount = g.Count(), + firstSeen = g.Min(r => r.IngestedAt), + lastSeen = g.Max(r => r.IngestedAt) + }; + }) + .OrderByDescending(a => a.lastSeen) + .ToList(); + + return (aggregated, tpmReports.Count); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 90df5f3c..f5cf97fd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -244,6 +244,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "audit/logs", EndpointPolicy.MemberRead), new("GET", "audit/hardware-rejected", EndpointPolicy.MemberRead), new("GET", "audit/device-not-registered", EndpointPolicy.MemberRead), + new("GET", "audit/tpm-pss-unsupported", EndpointPolicy.MemberRead), new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "rules/gather", EndpointPolicy.MemberRead), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationAudienceCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationAudienceCatalog.cs index e2c262ab..fcb3d45c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationAudienceCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationAudienceCatalog.cs @@ -26,6 +26,7 @@ public static class TenantNotificationAudienceCatalog private static readonly Dictionary _map = new(System.StringComparer.OrdinalIgnoreCase) { ["hardware_rejection"] = NotificationAudience.Admin, + ["tpm_pss_unsupported"] = NotificationAudience.Admin, ["sla_breach"] = NotificationAudience.Member, ["sla_consecutive_failures"] = NotificationAudience.Member, ["sla_resolved"] = NotificationAudience.Member, diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs index d7f3bfc0..104b6a13 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs @@ -4,20 +4,30 @@ using System.Threading.Tasks; namespace AutopilotMonitor.Shared.DataAccess { /// - /// Tracks which (tenant, manufacturer, model) combinations have already triggered a bell - /// notification. Used by ReportDistressFunction to fire a tenant-admin notification at most - /// once per model per tenant (lifetime dedup). + /// First-notification dedup store for distress-driven bell notifications. Used by + /// ReportDistressFunction to fire each tenant-admin notification at most once per subject + /// (lifetime dedup): hardware rejections per (tenant, manufacturer, model) and TPM PSS + /// incompatibilities per (tenant, serial number). /// public interface IHardwareRejectionNotificationTracker { /// - /// Atomically records a first-time notification for the given (tenantId, manufacturer, model). + /// Atomically records a first-time hardware-rejection notification for the given + /// (tenantId, manufacturer, model). /// Returns true if this is the first time (caller should fire the notification), /// false if an entry already exists (caller should skip). /// Manufacturer and model are matched case-insensitively after trim. /// Task TryRegisterFirstNotificationAsync(string tenantId, string manufacturer, string model); + /// + /// Atomically records a first-time TPM-PSS-unsupported notification for the given + /// (tenantId, serialNumber). Same semantics as + /// ; the serial number is matched + /// case-insensitively after trim. + /// + Task TryRegisterFirstTpmPssNotificationAsync(string tenantId, string serialNumber); + /// /// Retention cleanup: deletes tracker rows whose FirstNotifiedAt is older than /// . Pruning re-arms the dedup for that model. Returns rows deleted. diff --git a/src/Web/autopilot-monitor-web/app/settings/components/TpmIncompatibleDevicesInsights.tsx b/src/Web/autopilot-monitor-web/app/settings/components/TpmIncompatibleDevicesInsights.tsx new file mode 100644 index 00000000..408417a3 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/settings/components/TpmIncompatibleDevicesInsights.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; +import { authenticatedFetch } from "@/lib/authenticatedFetch"; + +interface AggregatedTpmPssUnsupported { + serialNumber: string; + manufacturer: string; + model: string; + attemptCount: number; + firstSeen: string; + lastSeen: string; +} + +interface TpmPssUnsupportedResponse { + success: boolean; + aggregated: AggregatedTpmPssUnsupported[]; + totalRawReports: number; + dataQualityNotice: string; +} + +interface TpmIncompatibleDevicesInsightsProps { + getAccessToken: () => Promise; +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +/** + * Devices whose TPM cannot perform RSA-PSS signing — Schannel silently drops their client + * certificate from TLS client-auth (Windows 11 25H2+ prefers PSS), so the agent can never + * authenticate. Remediation is a TPM firmware update or device replacement. + * + * Unlike the sibling insights panels this one renders nothing while loading, on error, and + * when no device is affected: it is a rare condition and a third permanent card in the + * section would be noise for the common all-clear case. + */ +export default function TpmIncompatibleDevicesInsights({ + getAccessToken, +}: TpmIncompatibleDevicesInsightsProps) { + const [data, setData] = useState(null); + + const fetchData = useCallback(async () => { + try { + const res = await authenticatedFetch(api.distress.tpmPssUnsupported(), getAccessToken); + if (!res.ok) return; + const json: TpmPssUnsupportedResponse = await res.json(); + setData(json); + } catch { + // Silent degrade — the panel is a secondary insight; the section stays usable without it. + } + }, [getAccessToken]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + if (!data || data.aggregated.length === 0) { + return null; + } + + return ( +
+ {/* Header */} +
+
+
+ + + +
+

+ Devices with Incompatible TPM + Last 14 days +

+

+ These devices have a TPM that cannot perform RSA-PSS signing, so the agent cannot + authenticate and no enrollment data is reported +

+
+
+ + Unverified + +
+
+ + {/* Explanation + remediation */} +
+
+

+ Windows 11 25H2 and later prefer RSA-PSS for TLS client authentication. On these devices + the TPM-backed Intune certificate cannot sign with RSA-PSS (common on older TPM firmware), + so Windows silently withholds the certificate and every connection attempt is rejected. + Remediation: update the TPM firmware via the vendor's tooling, or replace the device. + This data comes from pre-authentication distress signals; serial number, manufacturer, + and model values are self-reported and unverified. +

+
+
+ +
+
+ + + + + + + + + + + + + {data.aggregated.map((row, idx) => ( + + + + + + + + + ))} + +
Serial NumberManufacturerModelAttemptsFirst SeenLast Seen
{row.serialNumber || "–"}{row.manufacturer || "–"}{row.model || "–"}{row.attemptCount}{formatDate(row.firstSeen)}{formatDate(row.lastSeen)}
+
+

+ {data.totalRawReports} total report{data.totalRawReports !== 1 ? "s" : ""} across {data.aggregated.length} device{data.aggregated.length !== 1 ? "s" : ""} +

+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionHardwareWhitelist.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionHardwareWhitelist.tsx index 1af260e3..08bf5d69 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionHardwareWhitelist.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionHardwareWhitelist.tsx @@ -6,6 +6,7 @@ import { useTenantConfig } from "../../TenantConfigContext"; import { TenantNotifications } from "../../TenantNotifications"; import HardwareWhitelistSection from "../../components/HardwareWhitelistSection"; import HardwareRejectionInsights from "../../components/HardwareRejectionInsights"; +import TpmIncompatibleDevicesInsights from "../../components/TpmIncompatibleDevicesInsights"; function parseList(csv: string): string[] { return csv.split(",").map((s) => s.trim()).filter(Boolean); @@ -65,6 +66,7 @@ export function SectionHardwareWhitelist() { hasWebhook={hasWebhook} readOnly={!canEditConfig} /> + ); } diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index d24acbdd..ee4d4692 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -409,6 +409,7 @@ export const api = { distress: { hardwareRejected: () => `${API_BASE_URL}/api/audit/hardware-rejected`, deviceNotRegistered: () => `${API_BASE_URL}/api/audit/device-not-registered`, + tpmPssUnsupported: () => `${API_BASE_URL}/api/audit/tpm-pss-unsupported`, }, // ── Ops Events ───────────────────────────────────────────────────────────