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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-24 20:47:06 +02:00
parent 801891d57a
commit 6c53dc33e7
12 changed files with 564 additions and 5 deletions
@@ -0,0 +1,194 @@
using AutopilotMonitor.Functions.Functions.Reports;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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).
/// </summary>
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<DistressReportEntry>
{
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<DistressReportEntry>
{
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<DistressReportEntry>
{
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<DistressReportEntry>
{
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<DistressReportEntry>
{
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<DistressReportEntry>
{
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<DistressReportEntry>
{
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<string, object?>;
foreach (var prop in type.GetProperties())
{
dict[prop.Name] = prop.GetValue(obj);
}
return dict;
}
}
@@ -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);
}
}
@@ -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)]
@@ -9,7 +9,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// Table Storage implementation of <see cref="IHardwareRejectionNotificationTracker"/>.
/// 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.
/// </summary>
public class TableHardwareRejectionNotificationTracker : IHardwareRejectionNotificationTracker
@@ -68,6 +71,44 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<bool> 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<int> 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}";
}
}
}
@@ -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}",
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class GetTpmPssUnsupportedFunction
{
private readonly ILogger<GetTpmPssUnsupportedFunction> _logger;
private readonly IDistressReportRepository _repository;
public GetTpmPssUnsupportedFunction(
ILogger<GetTpmPssUnsupportedFunction> logger,
IDistressReportRepository repository)
{
_logger = logger;
_repository = repository;
}
[Function("GetTpmPssUnsupported")]
public async Task<HttpResponseData> 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");
}
}
/// <summary>
/// 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.
/// </summary>
public static (List<object> aggregated, int totalRawReports) BuildAggregatedResult(
List<DistressReportEntry> 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<object>();
return (aggregated, tpmReports.Count);
}
}
}
@@ -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),
@@ -26,6 +26,7 @@ public static class TenantNotificationAudienceCatalog
private static readonly Dictionary<string, NotificationAudience> _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,
@@ -4,20 +4,30 @@ using System.Threading.Tasks;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// 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).
/// </summary>
public interface IHardwareRejectionNotificationTracker
{
/// <summary>
/// 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.
/// </summary>
Task<bool> TryRegisterFirstNotificationAsync(string tenantId, string manufacturer, string model);
/// <summary>
/// Atomically records a first-time TPM-PSS-unsupported notification for the given
/// (tenantId, serialNumber). Same semantics as
/// <see cref="TryRegisterFirstNotificationAsync"/>; the serial number is matched
/// case-insensitively after trim.
/// </summary>
Task<bool> TryRegisterFirstTpmPssNotificationAsync(string tenantId, string serialNumber);
/// <summary>
/// Retention cleanup: deletes tracker rows whose FirstNotifiedAt is older than
/// <paramref name="cutoffUtc"/>. Pruning re-arms the dedup for that model. Returns rows deleted.
@@ -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<string | null>;
}
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<TpmPssUnsupportedResponse | null>(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 (
<div className="bg-white rounded-lg shadow mt-6">
{/* Header */}
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div>
<h3 className="text-lg font-semibold text-gray-900">
Devices with Incompatible TPM
<span className="ml-2 text-xs font-normal text-gray-500">Last 14 days</span>
</h3>
<p className="text-sm text-gray-500 mt-0.5">
These devices have a TPM that cannot perform RSA-PSS signing, so the agent cannot
authenticate and no enrollment data is reported
</p>
</div>
</div>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Unverified
</span>
</div>
</div>
{/* Explanation + remediation */}
<div className="px-6 pt-4 space-y-3">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
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&apos;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.
</p>
</div>
</div>
<div className="p-6">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 uppercase tracking-wider border-b border-gray-200">
<th className="pb-2 pr-4">Serial Number</th>
<th className="pb-2 pr-4">Manufacturer</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">Attempts</th>
<th className="pb-2 pr-4">First Seen</th>
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.aggregated.map((row, idx) => (
<tr key={`${row.serialNumber}|${idx}`}>
<td className="py-3 pr-4 font-mono text-gray-900">{row.serialNumber || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.manufacturer || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.model || "–"}</td>
<td className="py-3 pr-4 text-right font-mono text-gray-900">{row.attemptCount}</td>
<td className="py-3 pr-4 text-gray-500 whitespace-nowrap">{formatDate(row.firstSeen)}</td>
<td className="py-3 text-gray-500 whitespace-nowrap">{formatDate(row.lastSeen)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-400 mt-3">
{data.totalRawReports} total report{data.totalRawReports !== 1 ? "s" : ""} across {data.aggregated.length} device{data.aggregated.length !== 1 ? "s" : ""}
</p>
</div>
</div>
);
}
@@ -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}
/>
<TpmIncompatibleDevicesInsights getAccessToken={getAccessToken} />
</>
);
}
+1
View File
@@ -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 ───────────────────────────────────────────────────────────