Compare commits

..

10 Commits

Author SHA1 Message Date
Oliver Kieselbach 2109133aee bump pointer: internal, .claude/commands (TPM-PSS TLS probe, D-372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:07 +02:00
Oliver Kieselbach 7ac7a5e8ba TPM-PSS distress carries a TLS probe: protocol, whether the certificate went out, who answered (D-372)
tls= was empty on every report: .NET Framework's SecureChannelFailure
carries no inner Win32 code. After a terminal SecureChannelFailure the
agent now opens one direct SslStream connection to the backend host with
the client certificate (system-default protocols, normal server
validation, never a proxy) and sends one GET of the agent config with the
agent's headers - the platform asks for the certificate only once it has
seen the path. The message carries tlsp=<protocol>,<m|->,<answer>:
b<status> only when the probe's own correlation id comes back (shared
ResponseOriginClassifier), cc<status> for the platform's "Client
Certificate Required" page, st/x/io otherwise; or unreachable,
hs(<SSPI code>), hs(srvcert), timeout, error. A server certificate not
issued by Microsoft appends ,i=<issuer CN> (TLS inspection).

The probe runs beside the TPM and Schannel reads, all three within 10 s
(4 s per network step), on the failure path only. The Schannel window now
ends when the failure is detected (ReadBetween), so the probe's own
handshake never shows up in sch=. A connect that fails after its timeout
is observed (an unobserved task exception would write crash artefacts);
an answer that began is kept when the server does not close.

Live, .NET Framework 4.8 against production without a certificate:
tls12,-,cc403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:22:56 +02:00
Oliver Kieselbach 014b3e1e83 bump pointer: .claude/commands (platform-health catalog notes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:18:26 +02:00
Oliver Kieselbach b948d32c9a bump pointer: internal, .claude/commands (TPM-PSS precision, D-372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:26:43 +02:00
Oliver Kieselbach 40d3922fe0 TPM-PSS distress says where the device stood when the failure began: ESP phase, OOBE flag, boot; a later success compares reboot and certificate (D-372)
The first reports with context all carried OobeState=completed: Windows'
OOBE flag flips at the desktop, was read at send time after minutes of
retries, and marks the later successful starts too, so it separated
nothing.

Agent: EspPhaseSnapshot reads the ESP registry once with the tracker's
own parser (device_preparation / device_setup / account_setup while
running, <category>_failed, *_done, esp_done, none, unavailable).
DistressDeviceContext (OOBE flag, ESP phase, boot from the uptime
counter) is captured before a registration's first attempt and travels
with the TPM-PSS report; other reports sample it at send time. The
reporter takes a DistressDetail (TPM version, context). sch= is always
set: the events, none, unreadable or timeout. The failed-run record keeps
the ESP phase and OOBE flag, and beside it - never on the wire - the boot
time and the client-certificate thumbprint; the next successful start's
agent_started reports priorRegistrationLastEspPhase, LastOobeState,
RebootedSince (boots more than 2 min apart) and CertChanged, and every
start reports espPhaseAtAgentStart.

Shared/backend: DistressReport.EspPhase (kept as [a-z_] up to 32) and
DeviceBootUtc (kept within 30 days before and 5 minutes after the report),
stored, logged and on the custom event; the TPM panel item carries the ESP
phase. The GA distress detail names the OOBE flag as such and shows ESP
phase and device boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:26:41 +02:00
Oliver Kieselbach 37bd4c73f5 chore(deps-dev): bump vite to 8.3.4 in web and MCP
Closes Dependabot alerts #123 and #124 (GHSA-698v-4r3x-3vcv, launch-editor
escapeCmdArgs() omits % escaping on Windows). Lockfile-only: vite is a
transitive dev dependency of vitest, whose range already allows 8.3.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:21:07 +02:00
dependabot[bot] 9f4c1efe26 chore(deps): bump hono (#190)
Bumps the mcp-security group with 1 update in the /src/McpServer/autopilot-monitor-mcp directory: [hono](https://github.com/honojs/hono).


Updates `hono` from 4.13.7 to 4.13.13
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.13.7...v4.13.13)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.13
  dependency-type: indirect
  dependency-group: mcp-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 21:11:26 +02:00
Oliver Kieselbach 13a34fb7f1 bump pointer: internal (CI credential isolation released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:44:57 +02:00
Oliver Kieselbach 60a41e9778 CI: signing jobs run in the release environment
The code-signing identity trusts only environment:release now, so build-agent
release, build-msi sign-loader and release, and publish-scripts publish run in it.
Every job that federates to Azure carries an environment; the ref is no longer a
trusted subject. Help texts of the storage and endpoint helpers follow: the local
build publishes nothing since D-354.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:30:02 +02:00
Oliver Kieselbach 9aa5e46e22 bump pointer: internal (TPM-PSS distress released, D-370)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:36:32 +02:00
41 changed files with 1458 additions and 361 deletions
+6 -5
View File
@@ -4,15 +4,16 @@
.DESCRIPTION
CI's member of the URL registry (Shared Constants.cs, web lib/config.ts, MCP src/config.ts):
every own host the workflows, the .github/scripts helpers and the local release build talk to,
plus the Authenticode timestamp server, is defined here once. Assert-HostsFromRegistry.ps1
(Workflow gates) fails on any of these hosts spelled out elsewhere under .github/.
every own host the workflows and the .github/scripts helpers talk to, plus the Authenticode
timestamp server, is defined here once. Assert-HostsFromRegistry.ps1 (Workflow gates) fails
on any of these hosts spelled out elsewhere under .github/.
The storage URLs derive from the account name, so blob and table can never point at two
different accounts.
A file in the repository rather than a GitHub variable: the local release build reads it too,
and bearer tokens are sent to these URLs -- where a token goes stays a reviewed line in git.
A file in the repository rather than a GitHub variable: the helpers read it themselves, so
they behave the same in every job and in a local run, and bearer tokens are sent to these
URLs -- where a token goes stays a reviewed line in git.
.PARAMETER ExportToGitHubEnv
Write every entry to $GITHUB_ENV instead of returning it, so the following steps of the job
+2 -2
View File
@@ -9,8 +9,8 @@
live here so they cannot drift between call sites.
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
operator's own account for the local build. No secret is read, stored or passed around.
in CI (azure/login in a job of the `production` or `release` environment), the operator's
own account when a helper runs locally. No secret is read, stored or passed around.
Under GitHub Actions the token is masked in the run log before it is returned.
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
+4 -2
View File
@@ -258,12 +258,14 @@ jobs:
# run here -- never a restore or build. The build output arrives as data. The runner
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
#
# No `environment:` yet: the code-signing credential is still bound to the ref, and a job
# carries exactly one OIDC subject, so an environment here would break the signing login.
# Runs in the `release` environment: both identities trust its subject, and it admits main
# only. No federated credential trusts the ref, so a job without an environment can neither
# sign nor write storage, whatever its permissions say.
release:
name: Sign and publish
needs: [reserve, build]
runs-on: windows-latest
environment: release
permissions:
contents: write # tag + GitHub Release
id-token: write # code-signing and storage logins, keyless Sigstore signing
+5 -4
View File
@@ -39,11 +39,11 @@ jobs:
# need the embedded loader to carry a signature of its own.
#
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool run
# here. No `environment:` yet: the code-signing credential is still bound to the ref, and a
# job carries exactly one OIDC subject.
# here. The `release` environment is the only subject the code-signing identity trusts.
sign-loader:
name: Sign bootstrap loader
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing login
@@ -198,12 +198,13 @@ jobs:
if-no-files-found: error
retention-days: 1
# Trust zone: signs, verifies and publishes the MSI as data; never builds. No
# `environment:` yet, for the same reason as sign-loader.
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
# `release`, like sign-loader.
release:
name: Sign and publish MSI
needs: build
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing and storage logins, keyless Sigstore signing
+3 -3
View File
@@ -54,6 +54,8 @@ jobs:
name: Publish to the download alias
needs: gates
runs-on: windows-latest
# Both identities trust only the `release` environment, which admits main only.
environment: release
# Job-scoped: only the publish job federates to Azure, the gates job never holds an
# OIDC token it does not need.
permissions:
@@ -74,9 +76,7 @@ jobs:
}
# First, and only to fetch the Key Vault token: azure/login sets the default az
# account and the storage login below has to be the one that stays. See D-223 --
# both federated credentials are bound to the ref, so this job must never carry a
# GitHub `environment:`.
# account and the storage login below has to be the one that stays.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
@@ -18,6 +18,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
public string Message { get; set; } = default!;
public int? HttpStatusCode { get; set; }
public string? TpmVersion { get; set; }
public DistressDeviceContext? Context { get; set; }
}
public List<Call> Calls { get; } = new List<Call>();
@@ -33,9 +34,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
}
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null, string? tpmVersion = null)
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null, DistressDetail? detail = null)
{
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode, TpmVersion = tpmVersion });
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode, TpmVersion = detail?.TpmVersion, Context = detail?.Context });
return Send?.Invoke() ?? Task.CompletedTask;
}
}
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// The ESP phase a report names, derived from the three ESP registry values. Both value shapes
/// Windows writes are covered: flat status texts (older builds) and nested subcategory objects.
/// </summary>
public sealed class EspPhaseSnapshotTests
{
private const string DoneFlag = "{\"categorySucceeded\":true}";
private const string FailedFlag = "{\"categorySucceeded\":false}";
private const string NestedRunning =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\",\"subcategoryStatusText\":\"Complete\"}," +
"\"AppsSubcategory\":{\"subcategoryState\":\"inProgress\",\"subcategoryStatusText\":\"Installing (1 of 3)\"}}";
private const string NestedAllDoneNoFlag =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"notRequired\"}}";
private const string NestedFailedNoFlag =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"failed\"}}";
private const string FlatRunning = "{\"CertificatesSubcategory\":\"Certificates (0 of 1 applied)\",\"AppsSubcategory\":\"Identifying\"}";
private static string Derive(string? preparation, string? deviceSetup, string? accountSetup)
=> EspPhaseSnapshot.Derive(new List<string> { preparation!, deviceSetup!, accountSetup! });
[Fact]
public void No_value_is_none()
{
Assert.Equal("none", Derive(null, null, null));
}
[Fact]
public void Running_category_names_its_phase()
{
Assert.Equal("device_preparation", Derive(NestedRunning, null, null));
Assert.Equal("device_setup", Derive(DoneFlag, NestedRunning, null));
Assert.Equal("account_setup", Derive(DoneFlag, DoneFlag, NestedRunning));
}
[Fact]
public void Flat_status_texts_are_read_too()
{
Assert.Equal("device_setup", Derive(DoneFlag, FlatRunning, null));
}
[Fact]
public void Done_categories_name_the_last_one_reached()
{
Assert.Equal("device_preparation_done", Derive(DoneFlag, null, null));
Assert.Equal("device_setup_done", Derive(DoneFlag, DoneFlag, null));
Assert.Equal("esp_done", Derive(DoneFlag, DoneFlag, DoneFlag));
}
[Fact]
public void All_subcategories_done_counts_as_done_without_the_flag()
{
// Some builds never set categorySucceeded although every subcategory finished.
Assert.Equal("esp_done", Derive(DoneFlag, NestedAllDoneNoFlag, NestedAllDoneNoFlag));
}
[Fact]
public void A_failure_wins_over_everything_after_it()
{
Assert.Equal("device_setup_failed", Derive(DoneFlag, FailedFlag, NestedRunning));
Assert.Equal("account_setup_failed", Derive(DoneFlag, DoneFlag, NestedFailedNoFlag));
}
[Fact]
public void Unparseable_value_counts_as_running()
{
Assert.Equal("device_setup", Derive(DoneFlag, "{not json", null));
}
[Fact]
public void Read_on_this_machine_returns_a_known_token()
{
var phase = EspPhaseSnapshot.Read();
Assert.Matches("^[a-z_]+$", phase);
}
}
}
@@ -164,6 +164,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Null(persistence.LoadRegistrationFailure());
}
[Fact]
public void Success_after_a_failed_start_tells_reboot_certificate_and_where_the_failure_began()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
var failedBoot = at.AddMinutes(-16);
BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.Failed("An error occurred while sending the request."),
at, at.AddSeconds(90), true, true, "Succeeded", "fp-a", NewLogger(tmp.Path),
new AutopilotMonitor.Agent.V2.Core.Monitoring.Transport.DistressDeviceContext("completed", "account_setup", failedBoot),
certThumbprint: "6B98620E8BE952FEA7CD9200B899A9A8CCADD1E5");
// The record carries phase and OOBE flag; boot and thumbprint stay beside it.
var summary = persistence.LoadRegistrationFailure()!;
Assert.Equal("account_setup", summary.EspPhase);
Assert.Equal("completed", summary.OobeState);
var prior = BackendSessionRegistration.ClearAfterSuccess(
persistence, () => "fp-a", NewLogger(tmp.Path),
currentBootUtc: at.AddHours(1), currentCertThumbprint: "6b98620e8be952fea7cd9200b899a9a8ccadd1e5");
Assert.NotNull(prior);
Assert.Equal("account_setup", prior!.LastEspPhase);
Assert.Equal("completed", prior.LastOobeState);
Assert.True(prior.RebootedSince);
Assert.False(prior.CertChanged);
Assert.False(prior.NetworkChanged);
}
[Fact]
public void Same_boot_and_unknown_sides_are_reported_as_such()
{
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
Assert.False(BackendSessionRegistration.RebootedSince(boot, boot.AddSeconds(30)));
Assert.True(BackendSessionRegistration.RebootedSince(boot, boot.AddMinutes(5)));
Assert.Null(BackendSessionRegistration.RebootedSince(null, boot));
Assert.Null(BackendSessionRegistration.RebootedSince(boot, null));
Assert.True(BackendSessionRegistration.CertificateChanged("AA", "BB"));
Assert.Null(BackendSessionRegistration.CertificateChanged(null, "BB"));
Assert.Null(BackendSessionRegistration.CertificateChanged("AA", ""));
}
[Fact]
public void Record_from_an_older_agent_leaves_the_new_comparisons_unknown()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
// Written without context, boot or thumbprint — the shape agents up to 2.0.1487 leave.
BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.Failed("x"), at, at.AddSeconds(5), true, true, null, null, NewLogger(tmp.Path));
var prior = BackendSessionRegistration.ClearAfterSuccess(
persistence, () => null, NewLogger(tmp.Path), at.AddHours(1), "AA");
Assert.NotNull(prior);
Assert.Null(prior!.LastEspPhase);
Assert.Null(prior.RebootedSince);
Assert.Null(prior.CertChanged);
}
[Fact]
public void Recording_without_persistence_is_a_no_op()
{
@@ -5,6 +5,7 @@ using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Runtime;
@@ -35,8 +36,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
new WebException("Could not create SSL/TLS secure channel.",
new Win32Exception(unchecked((int)0x80090326)), WebExceptionStatus.SecureChannelFailure, null);
private static Func<Task<(string TpmVersion, string SchannelEvents)>> Context(string tpm, string schannel) =>
() => Task.FromResult((tpm, schannel));
private static Func<Task<(string TpmVersion, string SchannelEvents, string TlsProbe)>> Context(
string tpm, string schannel, string tlsProbe = null!) =>
() => Task.FromResult((tpm, schannel, tlsProbe));
[Fact]
public async Task Broken_tpm_report_completes_only_after_the_send()
@@ -46,14 +48,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
var reporter = new FakeDistressReporter { Send = () => sendGate.Task };
var report = BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "36888:40/1205"));
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "36888:40/1205", "tls12,-,cc403"));
var call = Assert.Single(reporter.Calls);
Assert.False(report.IsCompleted);
Assert.Equal(DistressErrorType.TpmPssUnsupported, call.ErrorType);
Assert.Equal("IFX 7.63.3353.0", call.TpmVersion);
Assert.Equal(
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 sch=36888:40/1205 prov=PCP key=2048",
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 tlsp=tls12,-,cc403 sch=36888:40/1205 prov=PCP key=2048",
call.Message);
sendGate.SetResult(true);
@@ -72,7 +74,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
var call = Assert.Single(reporter.Calls);
Assert.Null(call.TpmVersion);
Assert.DoesNotContain("sch=", call.Message);
Assert.Contains("sch=unreadable", call.Message);
}
[Fact]
@@ -115,14 +117,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
await Task.Delay(500);
});
var (tpm, schannel) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
var (tpm, schannel, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => { fastReturned.Set(); return "IFX 7.63.3353.0"; },
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "late"; },
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "tls12,-,cc403"; },
bound,
NewLogger(tmp));
Assert.Equal("IFX 7.63.3353.0", tpm);
Assert.Null(schannel);
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, schannel);
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, tlsProbe);
}
finally
{
@@ -135,14 +139,62 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
using var tmp = new TempDirectory();
var (tpm, schannel) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
var (tpm, schannel, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => throw new InvalidOperationException("no WMI"),
() => "36887:116",
() => "tls13,-,cc403",
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Null(tpm);
Assert.Equal("36887:116", schannel);
Assert.Equal("tls13,-,cc403", tlsProbe);
}
[Fact]
public async Task Schannel_read_that_throws_is_reported_as_unreadable_not_as_empty()
{
using var tmp = new TempDirectory();
var (_, schannel, _) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => "IFX 7.63.3353.0",
() => throw new UnauthorizedAccessException("System log"),
() => "tls12,-,cc403",
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Equal(SchannelEventReader.Unreadable, schannel);
}
[Fact]
public async Task Tls_probe_that_throws_is_reported_as_error_not_left_out()
{
using var tmp = new TempDirectory();
var (_, _, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => "IFX 7.63.3353.0",
() => "none",
() => throw new InvalidOperationException("socket"),
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Equal("error", tlsProbe);
}
[Fact]
public async Task Report_carries_the_context_the_run_started_in()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
var startContext = new DistressDeviceContext("in_progress", "device_setup",
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc));
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "none"), startContext);
var call = Assert.Single(reporter.Calls);
Assert.Same(startContext, call.Context);
Assert.Contains("sch=none", call.Message);
}
}
}
@@ -43,10 +43,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
[Fact]
public void Query_is_accepted_by_the_event_log_service()
{
// A malformed XPath would only surface as "no events" through ReadSince (it never
// A malformed XPath would only surface as "unreadable" through ReadBetween (it never
// throws), so the query itself is run here.
using (var reader = new EventLogReader(new EventLogQuery(
"System", PathType.LogName, SchannelEventReader.BuildXPath(DateTime.UtcNow.AddDays(-1))) { ReverseDirection = true }))
"System", PathType.LogName, SchannelEventReader.BuildXPath(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow)) { ReverseDirection = true }))
{
using (reader.ReadEvent()) { }
}
@@ -55,7 +55,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
[Fact]
public void No_events_after_now()
{
Assert.Null(SchannelEventReader.ReadSince(DateTime.UtcNow.AddHours(1)));
Assert.Equal(SchannelEventReader.NoEvents,
SchannelEventReader.ReadBetween(DateTime.UtcNow.AddHours(1), DateTime.UtcNow.AddHours(2)));
}
[Fact]
public void Window_ends_where_the_diagnostics_begin()
{
var xpath = SchannelEventReader.BuildXPath(
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc),
new DateTime(2026, 10, 21, 6, 55, 30, 250, DateTimeKind.Utc));
Assert.Contains(
"TimeCreated[@SystemTime >= '2026-10-21T06:40:00.000Z' and @SystemTime <= '2026-10-21T06:55:30.250Z']",
xpath);
}
}
}
@@ -0,0 +1,151 @@
using System.Security.Authentication;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The TLS probe's request and the reading of its answer: only an echo of the probe's own
/// correlation id counts as the backend, the platform's "Client Certificate Required" page says
/// the certificate did not arrive, and a device value can never split the request.
/// </summary>
public sealed class TlsHandshakeProbeTests
{
private const string SentId = "0123456789abcdef0123456789abcdef";
private static TlsProbeTarget Target(string serial = "SN-1") => new TlsProbeTarget
{
BaseUrl = "https://api.example.test",
TenantId = "00000000-0000-0000-0000-000000000001",
Manufacturer = "Contoso",
Model = "Book 1",
SerialNumber = serial,
AgentVersion = "2.0.1500",
};
private static byte[] Raw(string statusLine, string headers, string body) =>
Encoding.UTF8.GetBytes(statusLine + "\r\n" + headers + "\r\n" + body);
[Fact]
public void Request_asks_for_the_agent_config_with_the_agent_headers()
{
var request = TlsHandshakeProbe.BuildRequest(Target(), "api.example.test", SentId);
Assert.StartsWith(
"GET /api/agent/config?tenantId=00000000-0000-0000-0000-000000000001 HTTP/1.1\r\nHost: api.example.test\r\n",
request);
Assert.Contains("\r\nX-Correlation-ID: " + SentId + "\r\n", request);
Assert.Contains("\r\nX-Device-Manufacturer: Contoso\r\n", request);
Assert.Contains("\r\nX-Device-Model: Book 1\r\n", request);
Assert.Contains("\r\nX-Device-SerialNumber: SN-1\r\n", request);
Assert.Contains("\r\nX-Agent-Version: 2.0.1500\r\n", request);
Assert.EndsWith("\r\nConnection: close\r\n\r\n", request);
}
[Fact]
public void Device_value_with_a_line_break_cannot_add_a_header()
{
var request = TlsHandshakeProbe.BuildRequest(Target("SN\r\nX-Injected: 1"), "api.example.test", SentId);
Assert.DoesNotContain("\r\nX-Injected", request);
Assert.Contains("\r\nX-Device-SerialNumber: SN??X-Injected: 1\r\n", request);
}
[Fact]
public void Echoed_correlation_id_means_the_backend_answered()
{
var raw = Raw("HTTP/1.1 401 Unauthorized", "X-Correlation-ID: " + SentId + "\r\nContent-Type: application/json\r\n", "{}");
Assert.Equal("b401", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Backend_success_is_read_as_backend()
{
var raw = Raw("HTTP/1.1 200 OK", "x-correlation-id: " + SentId + "\r\n", "{\"ok\":true}");
Assert.Equal("b200", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Platform_certificate_required_page_means_the_certificate_did_not_arrive()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.CertificateRequiredPage);
Assert.Equal("cc403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Certificate_required_page_with_an_echo_is_still_the_backend()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: " + SentId + "\r\n", InterceptionFixtures.CertificateRequiredPage);
Assert.Equal("b403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Echo_of_another_id_is_someone_else()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: ffffffffffffffffffffffffffffffff\r\n", "<html>Access denied</html>");
Assert.Equal("x403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Stopped_app_page_is_reported_as_stopped()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.StoppedAppPage);
Assert.Equal("st403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Theory]
[InlineData("")]
[InlineData("garbage")]
[InlineData("HTTP/1.1 abc\r\n\r\n")]
public void No_parsable_answer_is_reported_as_no_response(string text)
{
Assert.Equal("io(noresponse)", TlsHandshakeProbe.ClassifyAnswer(Encoding.ASCII.GetBytes(text), SentId));
}
[Fact]
public void Microsoft_issued_server_certificate_adds_nothing()
{
using var cert = ServerCertificateObserverTests.SelfSigned(
"CN=Microsoft Azure RSA TLS Issuing CA 03, O=Microsoft Corporation, C=US");
Assert.Null(TlsHandshakeProbe.ForeignIssuer(cert));
Assert.Null(TlsHandshakeProbe.ForeignIssuer(null!));
}
[Fact]
public void Inspection_issuer_is_named_in_a_compact_token()
{
using var cert = ServerCertificateObserverTests.SelfSigned("CN=Contoso Inspection CA (2026), O=Contoso Ltd");
Assert.Equal("Contoso_Inspection_CA__2026_", TlsHandshakeProbe.ForeignIssuer(cert));
}
[Fact]
public void Long_issuer_is_capped()
{
using var cert = ServerCertificateObserverTests.SelfSigned("CN=" + new string('A', 80) + ", O=Fabrikam");
Assert.Equal(new string('A', TlsHandshakeProbe.MaxIssuerLength), TlsHandshakeProbe.ForeignIssuer(cert));
}
[Theory]
[InlineData(SslProtocols.Tls12, "tls12")]
[InlineData((SslProtocols)12288, "tls13")]
[InlineData(SslProtocols.Tls11, "tls11")]
[InlineData(SslProtocols.Tls, "tls10")]
[InlineData(SslProtocols.None, "p0")]
public void Protocol_token_names_the_negotiated_version(SslProtocols protocol, string expected)
{
Assert.Equal(expected, TlsHandshakeProbe.ProtocolToken(protocol));
}
}
}
@@ -28,10 +28,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
[Fact]
public void Message_carries_codes_handshake_and_schannel_events()
{
var message = BrokenPcp().ToDistressMessage("0x80090326", "36888:40/1205,36887:116");
var message = BrokenPcp().ToDistressMessage("0x80090326", "36888:40/1205,36887:116", "tls12,-,cc403");
Assert.Equal(
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 sch=36888:40/1205,36887:116 prov=PCP key=2048",
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 tlsp=tls12,-,cc403 sch=36888:40/1205,36887:116 prov=PCP key=2048",
message);
}
@@ -1,5 +1,6 @@
using System;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
@@ -27,28 +28,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
logger: null!);
[Fact]
public void Report_carries_os_build_and_setup_state_at_send_time()
public void Without_a_context_the_report_samples_the_device_when_sent()
{
using (var reporter = NewReporter())
{
var report = reporter.BuildReport(DistressErrorType.AuthCertificateRejected, "m", 401, tpmVersion: null);
var report = reporter.BuildReport(DistressErrorType.AuthCertificateRejected, "m", 401, detail: null);
var osBuild = DeviceInfoProvider.GetOsBuild();
Assert.Equal(string.IsNullOrEmpty(osBuild) ? null : osBuild, report.OsBuild);
Assert.Equal(OobeStateReader.Read(), report.OobeState);
Assert.Equal(EspPhaseSnapshot.Read(), report.EspPhase);
Assert.NotNull(report.DeviceBootUtc);
Assert.True(report.DeviceBootUtc <= DateTime.UtcNow);
Assert.Null(report.TpmVersion);
}
}
[Fact]
public void A_given_context_wins_over_the_send_time_sample()
{
// A failed registration samples the device before its first attempt; the report must
// carry that, not where the device stands minutes later.
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
using (var reporter = NewReporter())
{
var report = reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail
{
Context = new DistressDeviceContext("in_progress", "device_setup", boot),
});
Assert.Equal("in_progress", report.OobeState);
Assert.Equal("device_setup", report.EspPhase);
Assert.Equal(boot, report.DeviceBootUtc);
}
}
[Fact]
public void Tpm_version_passes_through_capped_at_48()
{
using (var reporter = NewReporter())
{
Assert.Equal("IFX 7.63.3353.0",
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, "IFX 7.63.3353.0").TpmVersion);
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail { TpmVersion = "IFX 7.63.3353.0" }).TpmVersion);
Assert.Equal(48,
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new string('v', 60)).TpmVersion!.Length);
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail { TpmVersion = new string('v', 60) }).TpmVersion!.Length);
}
}
@@ -58,7 +81,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
// Every field at the agent's own caps; the agent version as built (not cut to 32).
using (var reporter = NewReporter(new string('A', 64), "2.0.1486+d95640c597cb90849803dd65afda19e2d7a39444"))
{
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('m', 256), 403, new string('t', 48));
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('m', 256), 403,
new DistressDetail { TpmVersion = new string('t', 48) });
report.CertThumbprint = new string('F', 40);
report.CertSubject = new string('S', 96);
report.CertIssuer = new string('I', 96);
@@ -66,6 +90,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
report.CertNotAfter = DateTime.UtcNow;
report.OsBuild = "26300.97111";
report.OobeState = "unknown_2147483647".Substring(0, 16);
report.EspPhase = "device_preparation_failed";
report.DeviceBootUtc = DateTime.UtcNow;
var json = DistressReporter.SerializeWithinCap(report);
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// One-shot read of the Autopilot ESP phase from the values <see cref="ProvisioningStatusTracker"/>
/// watches (<c>HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings</c>), for reports taken
/// without the tracker — a failed registration has no session and no tracker yet.
/// <para>
/// <b>Contract:</b> observational only, never decision-engine input. Unlike the tracker it has
/// no settle or fallback timers: a category whose <c>categorySucceeded</c> Windows never sets
/// counts as done once all its subcategories read succeeded or notRequired.
/// </para>
/// </summary>
internal static class EspPhaseSnapshot
{
internal const string None = "none";
internal const string Unavailable = "unavailable";
internal enum CategoryState { Absent, Running, Done, Failed }
// ESP order: device preparation, device setup, account setup.
private static readonly (string ValueName, string Phase)[] Categories =
{
("DevicePreparationCategory.Status", "device_preparation"),
("DeviceSetupCategory.Status", "device_setup"),
("AccountSetupCategory.Status", "account_setup"),
};
/// <summary>
/// <c>device_preparation</c> / <c>device_setup</c> / <c>account_setup</c> while that
/// category runs, <c>&lt;category&gt;_failed</c>, <c>device_preparation_done</c> /
/// <c>device_setup_done</c> when the next category has not appeared (yet, or skipped),
/// <c>esp_done</c>, <c>none</c> without any ESP value, <c>unavailable</c> when the
/// registry cannot be read. Never throws.
/// </summary>
public static string Read()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusTracker.ProvisioningStatusRegistryPath, writable: false))
{
if (key == null) return None;
return Derive(Categories.Select(c => key.GetValue(c.ValueName) as string).ToList());
}
}
catch
{
return Unavailable;
}
}
/// <param name="statusJson">The three category values in ESP order; null where absent.</param>
internal static string Derive(IReadOnlyList<string> statusJson)
{
var states = Categories.Select((_, i) => Classify(i < statusJson.Count ? statusJson[i] : null)).ToList();
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Failed) return Categories[i].Phase + "_failed";
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Running) return Categories[i].Phase;
var lastDone = states.FindLastIndex(s => s == CategoryState.Done);
if (lastDone < 0) return None;
return lastDone == Categories.Length - 1 ? "esp_done" : Categories[lastDone].Phase + "_done";
}
/// <summary>
/// One category value: done (<c>categorySucceeded=true</c>, or every subcategory succeeded
/// or notRequired), failed (<c>false</c>, or a failed subcategory), running (anything
/// else that is present, also unparseable JSON), absent (no value).
/// </summary>
internal static CategoryState Classify(string json)
{
if (string.IsNullOrWhiteSpace(json)) return CategoryState.Absent;
try
{
using (var doc = JsonDocument.Parse(json))
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return CategoryState.Running;
var succeeded = ProvisioningStatusTracker.SafeGetBool(root, "categorySucceeded");
if (succeeded == true) return CategoryState.Done;
if (succeeded == false) return CategoryState.Failed;
var subcategories = ProvisioningStatusTracker.ParseSubcategories(root);
if (subcategories.Any(s => string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase)))
return CategoryState.Failed;
if (subcategories.Count > 0 && subcategories.All(s =>
string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase)
|| string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)))
return CategoryState.Done;
return CategoryState.Running;
}
}
catch (JsonException)
{
return CategoryState.Running;
}
}
}
}
@@ -0,0 +1,50 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Termination;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Where the device stood when a failure was observed: Windows' OOBE flag, the Autopilot ESP
/// phase and the last boot. Observational only. Sample it when the failing work starts — a
/// failed registration retries for minutes, and the device moves on meanwhile.
/// </summary>
public sealed class DistressDeviceContext
{
public DistressDeviceContext(string oobeState, string espPhase, DateTime? deviceBootUtc)
{
OobeState = oobeState;
EspPhase = espPhase;
DeviceBootUtc = deviceBootUtc;
}
/// <summary><see cref="OobeStateReader"/>: flips to <c>completed</c> at the desktop, not ESP progress.</summary>
public string OobeState { get; }
/// <summary><see cref="EspPhaseSnapshot"/> token.</summary>
public string EspPhase { get; }
/// <summary>Last boot (UTC) from the monotonic uptime counter; null when it could not be read.</summary>
public DateTime? DeviceBootUtc { get; }
/// <summary>Samples all three now. Registry, WinRT and one kernel call; never throws.</summary>
public static DistressDeviceContext Capture()
{
DateTime? boot;
try { boot = ObservationCoverage.DeviceBootUtc(); }
catch { boot = null; }
return new DistressDeviceContext(OobeStateReader.Read(), EspPhaseSnapshot.Read(), boot);
}
}
/// <summary>Optional parts of one distress report.</summary>
public sealed class DistressDetail
{
/// <summary>TPM manufacturer id and firmware (TPM-PSS report only).</summary>
public string TpmVersion { get; set; }
/// <summary>The device context of the failure; null samples it when the report is sent.</summary>
public DistressDeviceContext Context { get; set; }
}
}
@@ -6,7 +6,6 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -127,14 +126,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// is bounded by the 5-second HTTP timeout.
///
/// All anti-flood checks are applied synchronously before the async HTTP call,
/// so repeated calls are cheap when suppressed. Every report carries the OS build and the
/// Windows setup state at send time; <paramref name="tpmVersion"/> only the TPM-PSS one.
/// so repeated calls are cheap when suppressed. Every report carries the OS build and a
/// <see cref="DistressDeviceContext"/> — <paramref name="detail"/>'s, sampled when the
/// failure was observed, or one sampled at send time; the TPM version comes only with the
/// TPM-PSS report.
/// </summary>
public virtual async Task TrySendAsync(
DistressErrorType errorType,
string message,
int? httpStatusCode = null,
string tpmVersion = null)
DistressDetail detail = null)
{
var key = $"{errorType}:{httpStatusCode}";
int currentReportCount;
@@ -169,7 +170,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
_logger?.Warning($"[DistressChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
var report = BuildReport(errorType, message, httpStatusCode, tpmVersion);
var report = BuildReport(errorType, message, httpStatusCode, detail);
try
{
@@ -203,9 +204,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
return value.Length <= maxLength ? value : value.Substring(0, maxLength);
}
/// <summary>The report as sent: construction-time device and cert context plus the send-time OS build and setup state.</summary>
internal DistressReport BuildReport(DistressErrorType errorType, string message, int? httpStatusCode, string tpmVersion)
/// <summary>
/// The report as sent: construction-time device and cert context, the send-time OS build,
/// and the device context of <paramref name="detail"/> (sampled now when it has none).
/// </summary>
internal DistressReport BuildReport(DistressErrorType errorType, string message, int? httpStatusCode, DistressDetail detail)
{
var context = detail?.Context ?? DistressDeviceContext.Capture();
return new DistressReport
{
TenantId = _tenantId,
@@ -225,8 +230,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
CertNotBefore = _capturedCertNotBefore,
CertNotAfter = _capturedCertNotAfter,
OsBuild = NullIfEmpty(DeviceInfoProvider.GetOsBuild()),
OobeState = OobeStateReader.Read(),
TpmVersion = Truncate(tpmVersion, 48),
OobeState = context.OobeState,
EspPhase = context.EspPhase,
DeviceBootUtc = context.DeviceBootUtc,
TpmVersion = Truncate(detail?.TpmVersion, 48),
};
}
@@ -8,7 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// One-shot read of the Schannel errors the System log recorded since a point in time:
/// One-shot read of the Schannel errors the System log recorded in a time window:
/// 36871 (creating the client credential failed), 36887 (fatal TLS alert received) and
/// 36888 (fatal TLS alert sent). Only the terminal registration-failure path calls it.
/// Another process's TLS failure in the same window shows up too, so the result is a hint.
@@ -17,17 +17,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
{
internal const int MaxEvents = 2;
/// <summary>The log was read and holds no matching event — a finding, unlike <see cref="Unreadable"/>.</summary>
internal const string NoEvents = "none";
/// <summary>The System log could not be queried; nothing is known about Schannel.</summary>
internal const string Unreadable = "unreadable";
/// <summary>
/// Newest first, at most <see cref="MaxEvents"/> events as <see cref="FormatToken"/> tokens
/// joined by commas (<c>36888:40/1205,36887:116</c>); null when there is none or the log
/// cannot be read. Never throws.
/// joined by commas (<c>36888:40/1205,36887:116</c>); <see cref="NoEvents"/> when there is
/// none, <see cref="Unreadable"/> when the log cannot be read. <paramref name="untilUtc"/>
/// keeps out what the diagnostics after the failure log themselves (the TLS probe's own
/// handshake). Never throws.
/// </summary>
public static string ReadSince(DateTime sinceUtc, AgentLogger logger = null)
public static string ReadBetween(DateTime sinceUtc, DateTime untilUtc, AgentLogger logger = null)
{
var tokens = new List<string>();
try
{
var query = new EventLogQuery("System", PathType.LogName, BuildXPath(sinceUtc)) { ReverseDirection = true };
var query = new EventLogQuery("System", PathType.LogName, BuildXPath(sinceUtc, untilUtc)) { ReverseDirection = true };
using (var reader = new EventLogReader(query))
{
EventRecord record;
@@ -43,18 +51,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
catch (Exception ex)
{
logger?.Debug($"SchannelEventReader: System log not readable ({ex.Message}).");
return null;
return Unreadable;
}
return tokens.Count == 0 ? null : string.Join(",", tokens);
return tokens.Count == 0 ? NoEvents : string.Join(",", tokens);
}
internal static string BuildXPath(DateTime sinceUtc)
internal static string BuildXPath(DateTime sinceUtc, DateTime untilUtc)
{
var since = sinceUtc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
return "*[System[Provider[@Name='Schannel'] and (EventID=36871 or EventID=36887 or EventID=36888)" +
$" and TimeCreated[@SystemTime >= '{since}']]]";
$" and TimeCreated[@SystemTime >= '{XPathTime(sinceUtc)}' and @SystemTime <= '{XPathTime(untilUtc)}']]]";
}
private static string XPathTime(DateTime utc) =>
utc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
/// <summary>
/// <c>36888:40/1205</c>: the event id and its numeric properties (TLS alert code, Schannel
/// error state). Text properties (target name, certificate subjects) are left out.
@@ -29,8 +29,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
private readonly string _registrationFailurePath;
private readonly object _lockObject = new object();
// Agent-local key in registration-failure.json; RegistrationFailureSummary has no such member.
// Agent-local keys in registration-failure.json; RegistrationFailureSummary has no such members.
private const string LocalNetworkFingerprintKey = "NetworkFingerprint";
private const string LocalDeviceBootKey = "DeviceBootUtc";
private const string LocalCertThumbprintKey = "CertThumbprint";
public SessionIdPersistence(string dataDirectory)
{
@@ -180,13 +182,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <see cref="RegistrationFailureSummary.InterceptedRuns"/>), the first failure time is kept,
/// everything else describes the latest run. A run that never registers exits and leaves
/// nothing else behind — the emergency break reads this file so the backend learns why the
/// session never appeared. <paramref name="networkFingerprint"/> (the latest run's network)
/// is stored beside the summary, never in it, so it cannot reach the wire. Written through to
/// disk (L.12): the process exits right after and the next boot decides on the record.
/// session never appeared. <paramref name="networkFingerprint"/>, <paramref name="deviceBootUtc"/>
/// and <paramref name="certThumbprint"/> (the latest run's network, boot and client
/// certificate) are stored beside the summary, never in it, so they cannot reach the wire —
/// the next successful start reports only whether they changed. Written through to disk
/// (L.12): the process exits right after and the next boot decides on the record.
/// Returns the merged summary, null when it could not be written. Never throws.
/// </summary>
public RegistrationFailureSummary RecordRegistrationFailure(
RegistrationFailureSummary latestRun, AgentLogger logger = null, string networkFingerprint = null)
RegistrationFailureSummary latestRun, AgentLogger logger = null, string networkFingerprint = null,
DateTime? deviceBootUtc = null, string certThumbprint = null)
{
if (latestRun == null) return null;
lock (_lockObject)
@@ -206,8 +211,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
NetworkLinkUpAtEnd = latestRun.NetworkLinkUpAtEnd,
AttemptWindowSeconds = latestRun.AttemptWindowSeconds,
ConfigFetchOutcome = latestRun.ConfigFetchOutcome,
EspPhase = latestRun.EspPhase,
OobeState = latestRun.OobeState,
};
WriteRegistrationFailure(merged, networkFingerprint);
WriteRegistrationFailure(merged, networkFingerprint, deviceBootUtc, certThumbprint);
logger?.Info($"SessionIdPersistence: recorded failed registration run #{merged.FailedRuns} ({merged.InterceptedRuns} intercepted).");
return merged;
}
@@ -286,7 +293,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
var json = JObject.Parse(File.ReadAllText(_registrationFailurePath));
var summary = json.ToObject<RegistrationFailureSummary>();
return summary != null && summary.FailedRuns > 0
? new RegistrationFailureRecord(summary, (string)json[LocalNetworkFingerprintKey])
? new RegistrationFailureRecord(
summary,
(string)json[LocalNetworkFingerprintKey],
json[LocalDeviceBootKey]?.Type == JTokenType.Date ? json[LocalDeviceBootKey].ToObject<DateTime>().ToUniversalTime() : (DateTime?)null,
(string)json[LocalCertThumbprintKey])
: null;
}
catch
@@ -297,14 +308,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
/// <summary>
/// The summary's own properties plus one agent-local key: the fingerprint sits next to the
/// wire type, never inside it (the summary deserializes without it).
/// The summary's own properties plus the agent-local keys: they sit next to the wire type,
/// never inside it (the summary deserializes without them).
/// </summary>
private void WriteRegistrationFailure(RegistrationFailureSummary summary, string networkFingerprint)
private void WriteRegistrationFailure(
RegistrationFailureSummary summary, string networkFingerprint, DateTime? deviceBootUtc, string certThumbprint)
{
var json = JObject.FromObject(summary);
if (!string.IsNullOrEmpty(networkFingerprint))
json[LocalNetworkFingerprintKey] = networkFingerprint;
if (deviceBootUtc.HasValue)
json[LocalDeviceBootKey] = deviceBootUtc.Value.ToUniversalTime();
if (!string.IsNullOrEmpty(certThumbprint))
json[LocalCertThumbprintKey] = certThumbprint;
var bytes = Encoding.UTF8.GetBytes(json.ToString(Formatting.None));
using (var stream = new FileStream(
@@ -357,20 +373,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>
/// <c>registration-failure.json</c> as the agent keeps it: the <see cref="RegistrationFailureSummary"/>
/// the emergency break may send, plus the network fingerprint of the latest failed run, which
/// stays on the device — only the next successful start's comparison (changed or not) is reported.
/// the emergency break may send, plus the network fingerprint, boot time and client-certificate
/// thumbprint of the latest failed run, which stay on the device — only the next successful
/// start's comparisons (changed or not) are reported.
/// </summary>
public sealed class RegistrationFailureRecord
{
public RegistrationFailureRecord(RegistrationFailureSummary summary, string networkFingerprint)
public RegistrationFailureRecord(
RegistrationFailureSummary summary, string networkFingerprint,
DateTime? deviceBootUtc = null, string certThumbprint = null)
{
Summary = summary ?? throw new ArgumentNullException(nameof(summary));
NetworkFingerprint = networkFingerprint;
DeviceBootUtc = deviceBootUtc;
CertThumbprint = certThumbprint;
}
public RegistrationFailureSummary Summary { get; }
/// <summary>Fingerprint of the latest failed run's network; null when it was unknown.</summary>
public string NetworkFingerprint { get; }
/// <summary>Boot time (UTC) of the latest failed run; null when it was unknown or the record predates it.</summary>
public DateTime? DeviceBootUtc { get; }
/// <summary>Thumbprint of the client certificate the latest failed run used; null when none or unknown.</summary>
public string CertThumbprint { get; }
}
}
@@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>Who the TLS probe talks to and with which identity headers.</summary>
internal sealed class TlsProbeTarget
{
public string BaseUrl { get; set; }
public string TenantId { get; set; }
public string Manufacturer { get; set; }
public string Model { get; set; }
public string SerialNumber { get; set; }
public string AgentVersion { get; set; }
}
/// <summary>
/// Diagnostic after a terminal <c>SecureChannelFailure</c>: one direct TLS connection to the
/// backend on a raw <see cref="SslStream"/> with the client certificate (system-default
/// protocols, default certificate selection, normal server validation) and one
/// <c>GET /api/agent/config</c> with the agent's own headers — the platform asks for the
/// certificate only once it has seen a path that needs one. The HTTP stack reports only
/// <c>SecureChannelFailure</c>; this says which protocol was negotiated, whether the certificate
/// went out and who answered, or the SSPI code of the failure. Failure path only, never a proxy.
/// <para>
/// Result token: <c>unreachable</c>, <c>hs(srvcert)</c> / <c>hs(0x…)</c> / <c>hs</c> (handshake
/// failed), or <c>&lt;protocol&gt;,&lt;m|-&gt;,&lt;answer&gt;</c> — protocol <c>tls12</c> /
/// <c>tls13</c> / …, <c>m</c> when the session ended mutually authenticated, answer
/// <c>b&lt;status&gt;</c> (the backend answered: the certificate arrived), <c>cc&lt;status&gt;</c>
/// (the platform's "Client Certificate Required" page: it did not), <c>st&lt;status&gt;</c>
/// (stopped app), <c>x&lt;status&gt;</c> (someone else), <c>io(0x…)</c> / <c>io</c> (the
/// exchange failed, e.g. during renegotiation). A server certificate not issued by Microsoft
/// (TLS inspection on the path) appends <c>,i=&lt;issuer CN&gt;</c>.
/// </para>
/// </summary>
internal static class TlsHandshakeProbe
{
internal const string Unreachable = "unreachable";
internal const int MaxResponseBytes = 16 * 1024;
internal const int MaxIssuerLength = 32;
/// <summary>Runs the probe within <paramref name="timeout"/> per network step. Never throws.</summary>
public static string Run(TlsProbeTarget target, X509Certificate2 clientCertificate, TimeSpan timeout, AgentLogger logger = null)
{
Uri baseUri;
try { baseUri = new Uri(target.BaseUrl); }
catch { return Unreachable; }
var host = baseUri.Host;
var port = baseUri.IsDefaultPort ? 443 : baseUri.Port;
var timeoutMs = (int)timeout.TotalMilliseconds;
try
{
using (var tcp = new TcpClient())
{
var connect = tcp.ConnectAsync(host, port);
// A connect that fails after the timeout must not surface as an unobserved
// task exception: the agent writes crash artefacts for those.
connect.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
try
{
if (!connect.Wait(timeout))
return Unreachable;
}
catch
{
return Unreachable;
}
var serverCertificateRejected = false;
string foreignIssuer = null;
using (var ssl = new SslStream(tcp.GetStream(), leaveInnerStreamOpen: false,
userCertificateValidationCallback: (sender, certificate, chain, errors) =>
{
foreignIssuer = ForeignIssuer(certificate);
serverCertificateRejected = errors != SslPolicyErrors.None;
return !serverCertificateRejected;
}))
{
ssl.ReadTimeout = timeoutMs;
ssl.WriteTimeout = timeoutMs;
var certificates = new X509CertificateCollection();
if (clientCertificate != null) certificates.Add(clientCertificate);
try
{
ssl.AuthenticateAsClient(host, certificates, SslProtocols.None, checkCertificateRevocation: false);
}
catch (Exception ex)
{
logger?.Debug($"TlsHandshakeProbe: handshake failed: {ex.GetBaseException().Message}");
return (serverCertificateRejected ? "hs(srvcert)" : "hs" + CodeSuffix(ex)) + IssuerSuffix(foreignIssuer);
}
var protocol = ProtocolToken(ssl.SslProtocol);
string answer;
try
{
var correlationId = Guid.NewGuid().ToString("N");
var request = Encoding.ASCII.GetBytes(BuildRequest(target, host, correlationId));
ssl.Write(request, 0, request.Length);
ssl.Flush();
answer = ClassifyAnswer(ReadResponse(ssl), correlationId);
}
catch (Exception ex)
{
logger?.Debug($"TlsHandshakeProbe: exchange failed: {ex.GetBaseException().Message}");
answer = "io" + CodeSuffix(ex);
}
bool mutual;
try { mutual = ssl.IsMutuallyAuthenticated; }
catch { mutual = false; }
return $"{protocol},{(mutual ? "m" : "-")},{answer}{IssuerSuffix(foreignIssuer)}";
}
}
}
catch (Exception ex)
{
logger?.Debug($"TlsHandshakeProbe: failed: {ex.Message}");
return Unreachable;
}
}
internal static string BuildRequest(TlsProbeTarget target, string host, string correlationId)
{
var sb = new StringBuilder();
sb.Append("GET ").Append(Constants.ApiEndpoints.GetAgentConfig)
.Append("?tenantId=").Append(Uri.EscapeDataString(target.TenantId ?? string.Empty)).Append(" HTTP/1.1\r\n");
sb.Append("Host: ").Append(host).Append("\r\n");
sb.Append("User-Agent: AutopilotMonitor.Agent/").Append(HeaderValue(target.AgentVersion)).Append(" tls-probe\r\n");
sb.Append(ResponseOriginClassifier.CorrelationIdHeader).Append(": ").Append(correlationId).Append("\r\n");
AppendHeader(sb, "X-Device-Manufacturer", target.Manufacturer);
AppendHeader(sb, "X-Device-Model", target.Model);
AppendHeader(sb, "X-Device-SerialNumber", target.SerialNumber);
AppendHeader(sb, "X-Agent-Version", target.AgentVersion);
sb.Append("Connection: close\r\n\r\n");
return sb.ToString();
}
/// <summary>
/// Reads until the server closes or <see cref="MaxResponseBytes"/> arrived; a read that
/// times out after the answer began keeps what came (a server that ignores
/// <c>Connection: close</c>).
/// </summary>
private static byte[] ReadResponse(Stream stream)
{
var buffer = new byte[4096];
using (var collected = new MemoryStream())
{
while (collected.Length < MaxResponseBytes)
{
int read;
try
{
read = stream.Read(buffer, 0, buffer.Length);
}
catch (IOException) when (collected.Length > 0)
{
break;
}
if (read <= 0) break;
collected.Write(buffer, 0, read);
}
return collected.ToArray();
}
}
/// <summary>
/// The answer token for a raw HTTP response, through the shared <see cref="ResponseOriginClassifier"/>:
/// an echo of <paramref name="correlationId"/> means the backend wrote it.
/// </summary>
internal static string ClassifyAnswer(byte[] raw, string correlationId)
{
if (!TryParse(raw, out var status, out var headers, out var body))
return "io(noresponse)";
using (var response = new HttpResponseMessage((HttpStatusCode)status))
{
foreach (var header in headers)
response.Headers.TryAddWithoutValidation(header.Key, header.Value);
response.Content = new StringContent(body ?? string.Empty);
var classification = ResponseOriginClassifier.Classify(response, correlationId, body);
switch (classification.Origin)
{
case ResponseOrigin.Backend:
return "b" + status;
case ResponseOrigin.PlatformStopped:
return "st" + status;
default:
return classification.ForeignReason == ResponseClassification.ClientCertificateRequired
? "cc" + status
: "x" + status;
}
}
}
internal static bool TryParse(byte[] raw, out int status, out List<KeyValuePair<string, string>> headers, out string body)
{
status = 0;
headers = new List<KeyValuePair<string, string>>();
body = null;
if (raw == null || raw.Length == 0) return false;
var text = Encoding.UTF8.GetString(raw);
var headEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
var head = headEnd >= 0 ? text.Substring(0, headEnd) : text;
body = headEnd >= 0 ? text.Substring(headEnd + 4) : string.Empty;
var lines = head.Split(new[] { "\r\n" }, StringSplitOptions.None);
var statusParts = lines[0].Split(' ');
if (statusParts.Length < 2 || !statusParts[0].StartsWith("HTTP/", StringComparison.Ordinal)
|| !int.TryParse(statusParts[1], out status))
return false;
for (var i = 1; i < lines.Length; i++)
{
var colon = lines[i].IndexOf(':');
if (colon > 0)
headers.Add(new KeyValuePair<string, string>(lines[i].Substring(0, colon).Trim(), lines[i].Substring(colon + 1).Trim()));
}
return true;
}
internal static string ProtocolToken(SslProtocols protocol)
{
switch ((int)protocol)
{
case 12288: return "tls13"; // SslProtocols.Tls13
case 3072: return "tls12";
case 768: return "tls11";
case 192: return "tls10";
default: return "p" + ((int)protocol).ToString(System.Globalization.CultureInfo.InvariantCulture);
}
}
/// <summary>
/// The issuer CN of a server certificate that Microsoft did not issue (the backend's comes
/// from a Microsoft CA), else null: <c>[A-Za-z0-9.-]</c>, others as <c>_</c>, at most
/// <see cref="MaxIssuerLength"/>.
/// </summary>
internal static string ForeignIssuer(X509Certificate certificate)
{
if (certificate == null) return null;
try
{
if (certificate.Issuer.IndexOf("O=Microsoft Corporation", StringComparison.Ordinal) >= 0)
return null;
string cn;
using (var cert2 = new X509Certificate2(certificate))
cn = cert2.GetNameInfo(X509NameType.SimpleName, forIssuer: true);
if (string.IsNullOrEmpty(cn)) return "unknown";
var sb = new StringBuilder(Math.Min(cn.Length, MaxIssuerLength));
foreach (var c in cn)
{
if (sb.Length == MaxIssuerLength) break;
sb.Append((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-' ? c : '_');
}
return sb.ToString();
}
catch
{
return "unknown";
}
}
private static string IssuerSuffix(string foreignIssuer) =>
foreignIssuer == null ? string.Empty : ",i=" + foreignIssuer;
private static string CodeSuffix(Exception ex)
{
var code = TransportErrorCodes.InnermostNativeCode(ex);
return code == null ? string.Empty : "(" + code + ")";
}
private static void AppendHeader(StringBuilder sb, string name, string value)
{
if (!string.IsNullOrEmpty(value))
sb.Append(name).Append(": ").Append(HeaderValue(value)).Append("\r\n");
}
/// <summary>ASCII only, no line breaks — a header value must never split the request.</summary>
private static string HeaderValue(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
var sb = new StringBuilder(value.Length);
foreach (var c in value)
sb.Append(c >= 0x20 && c < 0x7F ? c : '?');
return sb.ToString();
}
}
}
@@ -190,19 +190,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>
/// Language-neutral summary for the distress message, codes instead of the localized
/// error texts: <c>pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029)
/// tls=0x80090326 sch=36888:40/1205 prov=PCP key=2048</c>. <paramref name="tlsCode"/>
/// is the native code of the failed handshake (<see cref="TransportErrorCodes"/>),
/// <paramref name="schannelEvents"/> the System-log tokens (<see cref="SchannelEventReader"/>);
/// both are left out when null. At most <see cref="MaxDistressMessageLength"/> chars.
/// error texts: <c>pkcs1=ok pss256=fail(0x80070423) pss384=fail(0x80090029)
/// tlsp=tls12,-,cc403 sch=none prov=PCP key=2048</c>. <paramref name="tlsCode"/> is the
/// native code of the failed HTTP-stack handshake (<see cref="TransportErrorCodes"/>),
/// <paramref name="tlsProbe"/> the <see cref="TlsHandshakeProbe"/> token,
/// <paramref name="schannelEvents"/> the System-log finding (<see cref="SchannelEventReader"/>);
/// each is left out when null. At most <see cref="MaxDistressMessageLength"/> chars.
/// </summary>
public string ToDistressMessage(string tlsCode = null, string schannelEvents = null)
public string ToDistressMessage(string tlsCode = null, string schannelEvents = null, string tlsProbe = null)
{
var sb = new StringBuilder();
sb.Append("pkcs1=").Append(Pkcs1Sha256Works ? "ok" : "fail");
AppendSignOutcome(sb, " pss256=", PssSha256Works, PssSha256ErrorCode);
AppendSignOutcome(sb, " pss384=", PssSha384Works, PssSha384ErrorCode);
if (!string.IsNullOrEmpty(tlsCode)) sb.Append(" tls=").Append(tlsCode);
if (!string.IsNullOrEmpty(tlsProbe)) sb.Append(" tlsp=").Append(tlsProbe);
if (!string.IsNullOrEmpty(schannelEvents)) sb.Append(" sch=").Append(schannelEvents);
sb.Append(" prov=").Append(ShortProviderName(ProviderName));
if (KeySizeBits > 0) sb.Append(" key=").Append(KeySizeBits);
@@ -149,7 +149,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
distressReporter: distressReporter,
emergencyReporter: emergencyReporter,
authFailureTracker: authFailureTracker,
serverCertificates: serverCertificates);
serverCertificates: serverCertificates,
clientCertificateThumbprint: clientCertificate?.Thumbprint);
}
/// <summary>
@@ -290,6 +291,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// </summary>
public ServerCertificateObserver ServerCertificates { get; }
/// <summary>
/// Thumbprint of the client certificate this run uses; null without one. Kept beside a
/// failed run's record so the next successful start can tell whether the certificate changed.
/// </summary>
public string ClientCertificateThumbprint { get; }
public BackendAuthBundle(
BackendApiClient backendApiClient,
NetworkMetrics networkMetrics,
@@ -300,7 +307,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
DistressReporter distressReporter,
EmergencyReporter emergencyReporter,
AuthFailureTracker authFailureTracker,
ServerCertificateObserver serverCertificates)
ServerCertificateObserver serverCertificates,
string clientCertificateThumbprint = null)
{
BackendApiClient = backendApiClient;
NetworkMetrics = networkMetrics;
@@ -312,6 +320,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
EmergencyReporter = emergencyReporter;
AuthFailureTracker = authFailureTracker;
ServerCertificates = serverCertificates;
ClientCertificateThumbprint = clientCertificateThumbprint;
}
}
@@ -60,6 +60,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// the device slept or was suspended while this run tried to register.
var startedUtc = DateTime.UtcNow;
var linkUpAtStart = TryIsNetworkLinkUp();
// Where the device stands before the first attempt: the retries take minutes, and a
// failure is reported (and recorded) with this, not with where it stands at the end.
var startContext = DistressDeviceContext.Capture();
var registrationResult = SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient: auth.BackendApiClient,
@@ -68,7 +71,18 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger: logger,
authFailureTracker: auth.AuthFailureTracker,
emergencyReporter: auth.EmergencyReporter,
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, startedUtc, logger),
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(
ex, auth, startedUtc, startContext,
new TlsProbeTarget
{
BaseUrl = agentConfig.ApiBaseUrl,
TenantId = agentConfig.TenantId,
Manufacturer = auth.Manufacturer,
Model = auth.Model,
SerialNumber = auth.SerialNumber,
AgentVersion = agentVersion,
},
logger),
// Reuse the single hardened hardware read from the auth bundle so the session row
// matches the security headers the backend validated against (no second WMI query).
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
@@ -78,12 +92,15 @@ namespace AutopilotMonitor.Agent.V2.Runtime
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
{
return SessionRegistrationOutcomeResult.Continue(
registrationResult, ClearAfterSuccess(sessionPersistence, NetworkFingerprint.TryCapture, logger));
registrationResult,
ClearAfterSuccess(sessionPersistence, NetworkFingerprint.TryCapture, logger,
startContext.DeviceBootUtc, auth.ClientCertificateThumbprint));
}
var record = RecordFailedRun(
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger);
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger,
startContext, auth.ClientCertificateThumbprint);
logger.Error(
$"=== SESSION REGISTRATION FAILED ({registrationResult.Outcome}: {registrationResult.ErrorMessage}) — " +
@@ -106,8 +123,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// it — for a session that never registered, the only account of why) and returns the record
/// including this run; null without persistence or when it could not be written. An
/// intercepted run also counts toward <see cref="RegistrationFailureSummary.InterceptedRuns"/>,
/// which only a successful registration resets. <paramref name="networkFingerprint"/> is this
/// run's network; it is kept beside the record and never leaves the device. Never throws.
/// which only a successful registration resets. <paramref name="startContext"/> supplies the
/// ESP phase and OOBE flag the run started in. <paramref name="networkFingerprint"/>, the
/// boot time and <paramref name="certThumbprint"/> are kept beside the record and never leave
/// the device. Never throws.
/// </summary>
internal static RegistrationFailureSummary RecordFailedRun(
SessionIdPersistence sessionPersistence,
@@ -118,7 +137,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
bool? linkUpAtEnd,
string configFetchOutcome,
string networkFingerprint,
AgentLogger logger)
AgentLogger logger,
DistressDeviceContext startContext = null,
string certThumbprint = null)
{
if (sessionPersistence == null || registrationResult == null) return null;
try
@@ -139,7 +160,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
NetworkLinkUpAtEnd = linkUpAtEnd,
AttemptWindowSeconds = Math.Round(Math.Max(0, (endedUtc - startedUtc).TotalSeconds), 1),
ConfigFetchOutcome = configFetchOutcome,
}, logger, networkFingerprint);
EspPhase = startContext?.EspPhase,
OobeState = startContext?.OobeState,
}, logger, networkFingerprint, startContext?.DeviceBootUtc, certThumbprint);
}
catch (Exception ex)
{
@@ -152,14 +175,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// A successful registration clears <c>registration-failure.json</c>. When earlier starts of
/// this session had failed, the cleared record comes back for this run's <c>agent_started</c>
/// — until now the success erased it silently and late successes were uncountable. The
/// network fingerprints are compared here (<paramref name="captureNetworkFingerprint"/> runs
/// only when there is a record) and never leave the device. Null when there was no record.
/// Never throws.
/// network fingerprints, boot times (<paramref name="currentBootUtc"/>) and client-certificate
/// thumbprints (<paramref name="currentCertThumbprint"/>) are compared here and never leave
/// the device (<paramref name="captureNetworkFingerprint"/> runs only when there is a record).
/// Null when there was no record. Never throws.
/// </summary>
internal static PriorRegistrationFailures ClearAfterSuccess(
SessionIdPersistence sessionPersistence,
Func<string> captureNetworkFingerprint,
AgentLogger logger)
AgentLogger logger,
DateTime? currentBootUtc = null,
string currentCertThumbprint = null)
{
if (sessionPersistence == null) return null;
try
@@ -172,10 +198,16 @@ namespace AutopilotMonitor.Agent.V2.Runtime
cleared.Summary.FailedRuns,
cleared.Summary.InterceptedRuns,
cleared.Summary.Outcome,
NetworkFingerprint.Differs(cleared.NetworkFingerprint, captureNetworkFingerprint?.Invoke()));
NetworkFingerprint.Differs(cleared.NetworkFingerprint, captureNetworkFingerprint?.Invoke()),
cleared.Summary.EspPhase,
cleared.Summary.OobeState,
RebootedSince(cleared.DeviceBootUtc, currentBootUtc),
CertificateChanged(cleared.CertThumbprint, currentCertThumbprint));
logger?.Info(
$"Session registered after {prior.FailedRuns} failed start(s) ({prior.InterceptedRuns} intercepted, " +
$"last: {prior.LastOutcome}, network changed: {prior.NetworkChanged?.ToString() ?? "unknown"}).");
$"last: {prior.LastOutcome}, network changed: {prior.NetworkChanged?.ToString() ?? "unknown"}, " +
$"last ESP phase: {prior.LastEspPhase ?? "unknown"}, rebooted since: {prior.RebootedSince?.ToString() ?? "unknown"}, " +
$"certificate changed: {prior.CertChanged?.ToString() ?? "unknown"}).");
return prior;
}
catch (Exception ex)
@@ -232,6 +264,27 @@ namespace AutopilotMonitor.Agent.V2.Runtime
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
}
/// <summary>
/// Boot times derived from the uptime counter jitter by milliseconds to seconds between two
/// reads of the same boot (NTP corrections shift them further), so only a gap above this
/// counts as a reboot.
/// </summary>
internal static readonly TimeSpan RebootDetectionTolerance = TimeSpan.FromMinutes(2);
/// <summary>True when the two boot times are further apart than <see cref="RebootDetectionTolerance"/>; null when either is unknown.</summary>
internal static bool? RebootedSince(DateTime? failedRunBootUtc, DateTime? currentBootUtc)
{
if (!failedRunBootUtc.HasValue || !currentBootUtc.HasValue) return null;
return (currentBootUtc.Value - failedRunBootUtc.Value).Duration() > RebootDetectionTolerance;
}
/// <summary>True when the thumbprints differ (case-insensitive); null when either is unknown.</summary>
internal static bool? CertificateChanged(string failedRunThumbprint, string currentThumbprint)
{
if (string.IsNullOrEmpty(failedRunThumbprint) || string.IsNullOrEmpty(currentThumbprint)) return null;
return !string.Equals(failedRunThumbprint, currentThumbprint, StringComparison.OrdinalIgnoreCase);
}
private static bool? TryIsNetworkLinkUp()
{
try { return NetworkInterface.GetIsNetworkAvailable(); }
@@ -282,8 +335,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
}
}
/// <summary>How long the TPM and System-log reads for the TPM-PSS report may take together.</summary>
internal static readonly TimeSpan DiagnosticContextBound = TimeSpan.FromSeconds(5);
/// <summary>How long the TPM read, the System-log read and the TLS probe for the TPM-PSS report may take together.</summary>
internal static readonly TimeSpan DiagnosticContextBound = TimeSpan.FromSeconds(10);
/// <summary>Per network step of the TLS probe (connect, handshake, request, response).</summary>
internal static readonly TimeSpan TlsProbeStepTimeout = TimeSpan.FromSeconds(4);
/// <summary>
/// Off-hot-path diagnostic invoked once after all session-registration retries fail.
@@ -301,8 +357,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
Exception ex,
BackendAuthBundle auth,
DateTime registrationStartedUtc,
DistressDeviceContext startContext,
TlsProbeTarget probeTarget,
AgentLogger logger)
{
// The Schannel window closes here: the TLS probe below logs its own handshake.
var failureDetectedUtc = DateTime.UtcNow;
try
{
if (!IsSecureChannelFailure(ex))
@@ -324,8 +384,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
probe, ex, auth.DistressReporter, logger,
() => ReadDiagnosticContextAsync(
() => TpmInfoReader.Read()?.Label,
() => SchannelEventReader.ReadSince(registrationStartedUtc, logger),
Task.Delay(DiagnosticContextBound), logger));
() => SchannelEventReader.ReadBetween(registrationStartedUtc, failureDetectedUtc, logger),
() => TlsHandshakeProbe.Run(probeTarget, cert, TlsProbeStepTimeout, logger),
Task.Delay(DiagnosticContextBound), logger),
startContext);
}
catch (Exception probeEx)
{
@@ -337,16 +399,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <summary>
/// Sends <see cref="DistressErrorType.TpmPssUnsupported"/> when the probe shows a TPM-backed
/// key that signs PKCS#1 but not PSS, with the native code of the failed handshake, the
/// Schannel events and the TPM firmware from <paramref name="readContext"/>. The returned
/// task completes only after the send: the run exits right after this callback, and
/// nothing else waits for it. Never throws.
/// Schannel finding, the TLS probe and the TPM firmware from <paramref name="readContext"/>, and the
/// <paramref name="startContext"/> the run began in. The returned task completes only after
/// the send: the run exits right after this callback, and nothing else waits for it. Never throws.
/// </summary>
internal static async Task ReportProbeResultAsync(
TpmPssProbeResult probe,
Exception transportFailure,
DistressReporter reporter,
AgentLogger logger,
Func<Task<(string TpmVersion, string SchannelEvents)>> readContext)
Func<Task<(string TpmVersion, string SchannelEvents, string TlsProbe)>> readContext,
DistressDeviceContext startContext = null)
{
try
{
@@ -363,20 +426,21 @@ namespace AutopilotMonitor.Agent.V2.Runtime
"Schannel can withhold the cert from TLS client-auth, blocking mTLS to the backend. " +
"Likely fix: update TPM firmware (common on older TPM 2.0 firmware).");
string tpmVersion = null, schannelEvents = null;
string tpmVersion = null, schannelEvents = SchannelEventReader.Unreadable, tlsProbe = null;
try
{
(tpmVersion, schannelEvents) = await readContext().ConfigureAwait(false);
(tpmVersion, schannelEvents, tlsProbe) = await readContext().ConfigureAwait(false);
}
catch (Exception ex)
{
// The context only enriches the report; it never holds it back.
logger?.Debug($"TPM-PSS report: context read failed ({ex.Message}) — sending without it.");
}
logger?.Info($"TPM-PSS report: tlsProbe={tlsProbe ?? "n/a"} schannel={schannelEvents ?? "n/a"} tpm={tpmVersion ?? "n/a"}");
await reporter.TrySendAsync(
DistressErrorType.TpmPssUnsupported,
probe.ToDistressMessage(tlsCode, schannelEvents),
tpmVersion: tpmVersion).ConfigureAwait(false);
probe.ToDistressMessage(tlsCode, schannelEvents, tlsProbe),
detail: new DistressDetail { TpmVersion = tpmVersion, Context = startContext }).ConfigureAwait(false);
}
else if (probe.IsTpmPssBroken)
{
@@ -392,24 +456,35 @@ namespace AutopilotMonitor.Agent.V2.Runtime
}
}
/// <summary>Schannel finding or TLS-probe token when that read did not finish within the bound.</summary>
internal const string SchannelReadTimeout = "timeout";
/// <summary>
/// Runs both reads in parallel until both finished or <paramref name="bound"/> completed
/// (production: <see cref="DiagnosticContextBound"/>); a read that threw or had not
/// finished by then counts as missing (null). Never throws.
/// Runs the three reads in parallel until all finished or <paramref name="bound"/> completed
/// (production: <see cref="DiagnosticContextBound"/>). A TPM read that threw or had not
/// finished counts as missing (null); the Schannel finding then says
/// <see cref="SchannelEventReader.Unreadable"/> or <see cref="SchannelReadTimeout"/>, so an
/// empty finding is never mistaken for "no events"; the TLS probe says <c>error</c> or
/// <see cref="SchannelReadTimeout"/>. Never throws.
/// </summary>
internal static async Task<(string TpmVersion, string SchannelEvents)> ReadDiagnosticContextAsync(
internal static async Task<(string TpmVersion, string SchannelEvents, string TlsProbe)> ReadDiagnosticContextAsync(
Func<string> readTpmVersion,
Func<string> readSchannelEvents,
Func<string> runTlsProbe,
Task bound,
AgentLogger logger)
{
var tpm = Task.Run(readTpmVersion);
var schannel = Task.Run(readSchannelEvents);
await Task.WhenAny(Task.WhenAll(tpm, schannel), bound).ConfigureAwait(false);
return (ResultOrNull(tpm, "TPM", logger), ResultOrNull(schannel, "Schannel event", logger));
var tlsProbe = Task.Run(runTlsProbe);
await Task.WhenAny(Task.WhenAll(tpm, schannel, tlsProbe), bound).ConfigureAwait(false);
return (
ResultOr(tpm, "TPM", logger, faulted: null, unfinished: null),
ResultOr(schannel, "Schannel event", logger, faulted: SchannelEventReader.Unreadable, unfinished: SchannelReadTimeout),
ResultOr(tlsProbe, "TLS probe", logger, faulted: "error", unfinished: SchannelReadTimeout));
}
private static string ResultOrNull(Task<string> read, string what, AgentLogger logger)
private static string ResultOr(Task<string> read, string what, AgentLogger logger, string faulted, string unfinished)
{
if (read.Status == TaskStatus.RanToCompletion)
return read.Result;
@@ -419,7 +494,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger?.Debug(read.IsFaulted
? $"TPM-PSS report: {what} read failed ({read.Exception?.GetBaseException().Message})."
: $"TPM-PSS report: {what} read still running — sending without it.");
return null;
return read.IsFaulted ? faulted : unfinished;
}
/// <summary>
@@ -477,19 +552,37 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <summary>
/// What the failed starts before a successful registration left in the record it cleared,
/// reported once on that run's <c>agent_started</c> (<c>priorRegistration*</c> keys): whether a
/// further boot rescues intercepted or failed starts becomes countable. Only the fingerprint
/// comparison is carried, never a fingerprint.
/// further boot rescues intercepted or failed starts becomes countable. Only the comparisons of
/// network, boot and client certificate are carried, never a fingerprint, boot time or thumbprint.
/// </summary>
internal sealed class PriorRegistrationFailures
{
public PriorRegistrationFailures(int failedRuns, int interceptedRuns, string lastOutcome, bool? networkChanged)
public PriorRegistrationFailures(
int failedRuns, int interceptedRuns, string lastOutcome, bool? networkChanged,
string lastEspPhase = null, string lastOobeState = null, bool? rebootedSince = null, bool? certChanged = null)
{
FailedRuns = failedRuns;
InterceptedRuns = interceptedRuns;
LastOutcome = lastOutcome;
NetworkChanged = networkChanged;
LastEspPhase = lastEspPhase;
LastOobeState = lastOobeState;
RebootedSince = rebootedSince;
CertChanged = certChanged;
}
/// <summary>ESP phase the latest failed start began in; null for records that predate it.</summary>
public string LastEspPhase { get; }
/// <summary>Windows' OOBE flag when the latest failed start began; null for records that predate it.</summary>
public string LastOobeState { get; }
/// <summary>The device booted between the latest failed start and this one; null when either boot time is unknown.</summary>
public bool? RebootedSince { get; }
/// <summary>This start uses a different client certificate than the latest failed one; null when either side is unknown.</summary>
public bool? CertChanged { get; }
public int FailedRuns { get; }
public int InterceptedRuns { get; }
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
@@ -87,6 +88,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// this makes late starts provable: "completed" on a first_run means OOBE
// was already over when the agent arrived (e.g. IME queue blocked).
{ "oobeStateAtAgentStart", OobeStateReader.Read() },
// The ESP phase from the ESP registry — unlike the OOBE flag, which flips only
// at the desktop, it tells device setup from account setup from done.
{ "espPhaseAtAgentStart", EspPhaseSnapshot.Read() },
};
if (recovered)
@@ -116,6 +120,16 @@ namespace AutopilotMonitor.Agent.V2.Runtime
data["priorRegistrationLastOutcome"] = priorRegistrationFailures.LastOutcome;
if (priorRegistrationFailures.NetworkChanged.HasValue)
data["priorRegistrationNetworkChanged"] = priorRegistrationFailures.NetworkChanged.Value;
// Where the latest failed start began, and what differs now: a reboot in
// between, another client certificate. Comparisons only, no boot time or thumbprint.
if (!string.IsNullOrEmpty(priorRegistrationFailures.LastEspPhase))
data["priorRegistrationLastEspPhase"] = priorRegistrationFailures.LastEspPhase;
if (!string.IsNullOrEmpty(priorRegistrationFailures.LastOobeState))
data["priorRegistrationLastOobeState"] = priorRegistrationFailures.LastOobeState;
if (priorRegistrationFailures.RebootedSince.HasValue)
data["priorRegistrationRebootedSince"] = priorRegistrationFailures.RebootedSince.Value;
if (priorRegistrationFailures.CertChanged.HasValue)
data["priorRegistrationCertChanged"] = priorRegistrationFailures.CertChanged.Value;
}
post.Emit(new EnrollmentEvent
@@ -558,6 +558,35 @@ public class DistressValidationTests
input, ReportDistressFunction.MaxOobeStateLength, ReportDistressFunction.OobeStatePattern));
}
[Theory]
[InlineData("device_setup", "device_setup")]
[InlineData("account_setup_failed", "account_setup_failed")]
[InlineData("esp_done", "esp_done")]
[InlineData("device_preparation_failed", "device_preparation_failed")]
[InlineData("Device_Setup", null)]
[InlineData("device-setup", null)]
[InlineData("device_setup2", null)]
[InlineData("an_esp_phase_token_far_beyond_the_cap", null)]
[InlineData(null, null)]
public void SanitizeToken_EspPhase(string? input, string? expected)
{
Assert.Equal(expected, ReportDistressFunction.SanitizeToken(
input, ReportDistressFunction.MaxEspPhaseLength, ReportDistressFunction.EspPhasePattern));
}
[Fact]
public void SanitizeDeviceBoot_KeepsBootsWithinThirtyDaysBeforeTheReport()
{
var report = FixedUtcNow;
Assert.Equal(report.AddMinutes(-20), ReportDistressFunction.SanitizeDeviceBoot(report.AddMinutes(-20), report));
Assert.Equal(report.AddDays(-30), ReportDistressFunction.SanitizeDeviceBoot(report.AddDays(-30), report));
Assert.Equal(report.AddMinutes(4), ReportDistressFunction.SanitizeDeviceBoot(report.AddMinutes(4), report));
Assert.Null(ReportDistressFunction.SanitizeDeviceBoot(report.AddDays(-31), report));
Assert.Null(ReportDistressFunction.SanitizeDeviceBoot(report.AddMinutes(6), report));
Assert.Null(ReportDistressFunction.SanitizeDeviceBoot(DateTime.MinValue, report));
Assert.Null(ReportDistressFunction.SanitizeDeviceBoot(null, report));
}
[Fact]
public void SanitizeToken_OsBuildExactlyAtCap_Kept()
{
@@ -175,6 +175,7 @@ public class GetTpmPssUnsupportedFunctionTests
older.TpmVersion = "IFX 7.63.3353.0";
older.OsBuild = "26100.1000";
older.OobeState = "in_progress";
older.EspPhase = "device_setup";
var newer = MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0.AddHours(1));
newer.OsBuild = "26200.2000";
newer.OobeState = "completed";
@@ -187,6 +188,7 @@ public class GetTpmPssUnsupportedFunctionTests
Assert.Equal("IFX 7.63.3353.0", item.TpmVersion);
Assert.Equal("26200.2000", item.OsBuild);
Assert.Equal("completed", item.OobeState);
Assert.Equal("device_setup", item.EspPhase);
}
[Fact]
@@ -199,6 +201,7 @@ public class GetTpmPssUnsupportedFunctionTests
Assert.Null(item.TpmVersion);
Assert.Null(item.OsBuild);
Assert.Null(item.OobeState);
Assert.Null(item.EspPhase);
}
// =========================================================================
@@ -34,6 +34,8 @@ public class TableDistressReportRepositoryMappingTests
OsBuild = "26200.6899",
OobeState = "in_progress",
TpmVersion = "IFX 7.63.3353.0",
EspPhase = "account_setup",
DeviceBootUtc = T0.AddMinutes(-20),
};
[Fact]
@@ -58,6 +58,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["OsBuild"] = Truncate(entry.OsBuild, 32),
["OobeState"] = Truncate(entry.OobeState, 16),
["TpmVersion"] = Truncate(entry.TpmVersion, 48),
["EspPhase"] = Truncate(entry.EspPhase, 32),
["DeviceBootUtc"] = entry.DeviceBootUtc,
};
}
@@ -142,6 +144,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
OsBuild = entity.GetString("OsBuild"),
OobeState = entity.GetString("OobeState"),
TpmVersion = entity.GetString("TpmVersion"),
EspPhase = entity.GetString("EspPhase"),
DeviceBootUtc = entity.GetDateTimeOffset("DeviceBootUtc")?.UtcDateTime,
};
}
@@ -55,6 +55,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
internal const int MaxOsBuildLength = 32;
internal const int MaxOobeStateLength = 16;
internal const int MaxTpmVersionLength = 48;
internal const int MaxEspPhaseLength = 32;
internal static readonly TimeSpan MaxDeviceBootAge = TimeSpan.FromDays(30);
internal static readonly TimeSpan MaxTimestampAge = TimeSpan.FromHours(24);
internal static readonly TimeSpan MaxTimestampFuture = TimeSpan.FromMinutes(5);
@@ -81,6 +83,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
@"^[a-z0-9_]+\z",
RegexOptions.Compiled);
// EspPhaseSnapshot tokens: device_setup, account_setup_failed, esp_done, none, ...
internal static readonly Regex EspPhasePattern = new Regex(
@"^[a-z_]+\z",
RegexOptions.Compiled);
public ReportDistressFunction(
ILogger<ReportDistressFunction> logger,
TenantConfigurationService tenantConfigService,
@@ -208,6 +215,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
var osBuild = SanitizeToken(report.OsBuild, MaxOsBuildLength, OsBuildPattern);
var oobeState = SanitizeToken(report.OobeState, MaxOobeStateLength, OobeStatePattern);
var tpmVersion = Sanitize(report.TpmVersion, MaxTpmVersionLength);
var espPhase = SanitizeToken(report.EspPhase, MaxEspPhaseLength, EspPhasePattern);
var deviceBootUtc = SanitizeDeviceBoot(report.DeviceBootUtc, report.Timestamp);
// Persist to Table Storage
var entry = new DistressReportEntry
@@ -233,6 +242,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
OsBuild = osBuild,
OobeState = oobeState,
TpmVersion = tpmVersion,
EspPhase = espPhase,
DeviceBootUtc = deviceBootUtc,
};
await _repository.SaveDistressReportAsync(tenantId, entry);
@@ -327,9 +338,10 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Structured log (Warning, not Critical — data is unverified)
_logger.LogWarning(
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} oobe={OobeState} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
report.HttpStatusCode, agentVersion, osBuild ?? "n/a", oobeState ?? "n/a", tpmVersion ?? "n/a",
report.HttpStatusCode, agentVersion, osBuild ?? "n/a", espPhase ?? "n/a", oobeState ?? "n/a",
deviceBootUtc?.ToString("O") ?? "n/a", tpmVersion ?? "n/a",
certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
// Custom event for KQL queries:
@@ -356,6 +368,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
["OsBuild"] = osBuild ?? string.Empty,
["OobeState"] = oobeState ?? string.Empty,
["TpmVersion"] = tpmVersion ?? string.Empty,
["EspPhase"] = espPhase ?? string.Empty,
["DeviceBootUtc"] = deviceBootUtc?.ToString("O") ?? string.Empty,
});
return req.CreateResponse(HttpStatusCode.OK);
@@ -398,6 +412,18 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
return pattern.IsMatch(trimmed) ? trimmed : null;
}
/// <summary>
/// The device boot time as UTC when it lies within <see cref="MaxDeviceBootAge"/> before and
/// <see cref="MaxTimestampFuture"/> after the report time, otherwise null.
/// </summary>
internal static DateTime? SanitizeDeviceBoot(DateTime? value, DateTime reportTimestamp)
{
if (!value.HasValue) return null;
var boot = value.Value.Kind == DateTimeKind.Utc ? value.Value : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
var age = reportTimestamp - boot;
return age >= -MaxTimestampFuture && age <= MaxDeviceBootAge ? boot : null;
}
/// <summary>The tenant bell for the first TPM-PSS report of a device.</summary>
internal static string BuildTpmPssBellMessage(string serialNumber, string? manufacturer, string? model)
{
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
Success = true,
Aggregated = aggregated,
TotalRawReports = totalRawReports,
DataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot sign with RSA-PSS, and their agent could not connect when it sent the report; connectedAt marks a device that uploaded with its certificate afterwards. Serial number, manufacturer, model, OS build, setup state and TPM version are self-reported by devices."
DataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot sign with RSA-PSS, and their agent could not connect when it sent the report; connectedAt marks a device that uploaded with its certificate afterwards. Serial number, manufacturer, model, OS build, ESP phase, Windows OOBE flag and TPM version are self-reported by devices."
});
}
catch (Exception ex)
@@ -129,6 +129,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
TpmVersion = newestFirst.Select(r => r.TpmVersion).FirstOrDefault(v => !string.IsNullOrEmpty(v)),
OsBuild = newestFirst.Select(r => r.OsBuild).FirstOrDefault(v => !string.IsNullOrEmpty(v)),
OobeState = newestFirst.Select(r => r.OobeState).FirstOrDefault(v => !string.IsNullOrEmpty(v)),
EspPhase = newestFirst.Select(r => r.EspPhase).FirstOrDefault(v => !string.IsNullOrEmpty(v)),
ConnectedAt = connectedAt,
};
})
+86 -110
View File
@@ -1510,9 +1510,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1530,9 +1527,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1550,9 +1544,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1570,9 +1561,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1590,9 +1578,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1610,9 +1595,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1630,9 +1612,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1650,9 +1629,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1731,13 +1707,13 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.146.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
"integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
"version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
"integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
@@ -2080,9 +2056,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
"integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
"integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
"cpu": [
"arm"
],
@@ -2097,9 +2073,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz",
"integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
"integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
"cpu": [
"arm64"
],
@@ -2114,9 +2090,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz",
"integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
"integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
"cpu": [
"arm64"
],
@@ -2131,9 +2107,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz",
"integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
"integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
"cpu": [
"x64"
],
@@ -2148,9 +2124,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz",
"integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
"integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
"cpu": [
"x64"
],
@@ -2165,9 +2141,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz",
"integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
"integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
"cpu": [
"arm"
],
@@ -2182,9 +2158,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz",
"integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
"integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
"cpu": [
"arm64"
],
@@ -2199,9 +2175,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz",
"integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
"integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
"cpu": [
"arm64"
],
@@ -2216,9 +2192,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz",
"integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
"integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
"cpu": [
"ppc64"
],
@@ -2233,9 +2209,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz",
"integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
"integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
"cpu": [
"s390x"
],
@@ -2250,9 +2226,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz",
"integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
"integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
"cpu": [
"x64"
],
@@ -2267,9 +2243,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz",
"integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
"integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
"cpu": [
"x64"
],
@@ -2284,9 +2260,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz",
"integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
"integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
"cpu": [
"arm64"
],
@@ -2301,9 +2277,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz",
"integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
"integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
"cpu": [
"arm64"
],
@@ -2318,9 +2294,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz",
"integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
"integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
"cpu": [
"x64"
],
@@ -4146,9 +4122,9 @@
}
},
"node_modules/hono": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"version": "4.13.13",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -4826,9 +4802,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [
{
@@ -5196,9 +5172,9 @@
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"dev": true,
"funding": [
{
@@ -5216,9 +5192,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -5354,13 +5330,13 @@
}
},
"node_modules/rolldown": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
"integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
"integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.146.0",
"@oxc-project/types": "=0.154.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -5370,21 +5346,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.5",
"@rolldown/binding-android-arm64": "1.2.5",
"@rolldown/binding-darwin-arm64": "1.2.5",
"@rolldown/binding-darwin-x64": "1.2.5",
"@rolldown/binding-freebsd-x64": "1.2.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.5",
"@rolldown/binding-linux-arm64-gnu": "1.2.5",
"@rolldown/binding-linux-arm64-musl": "1.2.5",
"@rolldown/binding-linux-ppc64-gnu": "1.2.5",
"@rolldown/binding-linux-s390x-gnu": "1.2.5",
"@rolldown/binding-linux-x64-gnu": "1.2.5",
"@rolldown/binding-linux-x64-musl": "1.2.5",
"@rolldown/binding-openharmony-arm64": "1.2.5",
"@rolldown/binding-win32-arm64-msvc": "1.2.5",
"@rolldown/binding-win32-x64-msvc": "1.2.5"
"@rolldown/binding-android-arm-eabi": "1.2.14",
"@rolldown/binding-android-arm64": "1.2.14",
"@rolldown/binding-darwin-arm64": "1.2.14",
"@rolldown/binding-darwin-x64": "1.2.14",
"@rolldown/binding-freebsd-x64": "1.2.14",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
"@rolldown/binding-linux-arm64-gnu": "1.2.14",
"@rolldown/binding-linux-arm64-musl": "1.2.14",
"@rolldown/binding-linux-ppc64-gnu": "1.2.14",
"@rolldown/binding-linux-s390x-gnu": "1.2.14",
"@rolldown/binding-linux-x64-gnu": "1.2.14",
"@rolldown/binding-linux-x64-musl": "1.2.14",
"@rolldown/binding-openharmony-arm64": "1.2.14",
"@rolldown/binding-win32-arm64-msvc": "1.2.14",
"@rolldown/binding-win32-x64-msvc": "1.2.14"
}
},
"node_modules/router": {
@@ -5973,16 +5949,16 @@
}
},
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"picomatch": "^4.0.7",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -5999,7 +5975,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -1825,6 +1825,8 @@ export interface DistressReportEntry {
osBuild?: string;
oobeState?: string;
tpmVersion?: string;
espPhase?: string;
deviceBootUtc?: string;
}
/** Success body of GET global/distress-reports: all pre-auth distress reports (Global Admin). */
@@ -5729,8 +5731,10 @@ export interface TpmPssUnsupportedItem {
tpmVersion?: string;
/** OS build of the most recent report; null from agents that predate it. */
osBuild?: string;
/** Windows setup state of the most recent report (in_progress, completed, …); null from agents that predate it. */
/** Windows' OOBE flag of the most recent report (in_progress, completed, …; not ESP progress); null from agents that predate it. */
oobeState?: string;
/** Autopilot ESP phase of the most recent report that carries one (device_setup, account_setup, esp_done, …); null from agents that predate it. */
espPhase?: string;
/** Last upload of the newest certificate-authenticated session of this serial when that upload came after LastSeen: the device connected after its last report. Null while it has not, and when the lookup failed. */
connectedAt?: string;
}
@@ -50,5 +50,7 @@ namespace AutopilotMonitor.Shared.DataAccess
public string? OsBuild { get; set; }
public string? OobeState { get; set; }
public string? TpmVersion { get; set; }
public string? EspPhase { get; set; }
public DateTime? DeviceBootUtc { get; set; }
}
}
@@ -190,12 +190,29 @@ namespace AutopilotMonitor.Shared.Models
public string? OsBuild { get; set; }
/// <summary>
/// Windows setup (OOBE) state when the report was sent: <c>not_started</c>,
/// <c>in_progress</c>, <c>completed</c>, <c>unknown_&lt;n&gt;</c> or <c>unavailable</c>.
/// Kept server-side only as <c>[a-z0-9_]</c>, at most 16 chars. Null from older agents.
/// Windows' own OOBE flag (<c>SystemSetupInfo.OutOfBoxExperienceState</c>: <c>not_started</c>,
/// <c>in_progress</c>, <c>completed</c>, <c>unknown_&lt;n&gt;</c>, <c>unavailable</c>) — it
/// flips to <c>completed</c> at the desktop and says nothing about ESP progress (see
/// <see cref="EspPhase"/>). Sampled with <see cref="EspPhase"/>. Kept server-side only as
/// <c>[a-z0-9_]</c>, at most 16 chars. Null from older agents.
/// </summary>
public string? OobeState { get; set; }
/// <summary>
/// Autopilot ESP phase from the ESP registry: <c>device_preparation</c>,
/// <c>device_setup</c>, <c>account_setup</c> (running), <c>&lt;phase&gt;_failed</c>,
/// <c>device_setup_done</c>, <c>esp_done</c>, <c>none</c> or <c>unavailable</c>. A failed
/// registration samples it before its first attempt, other reports when sent. Kept
/// server-side only as <c>[a-z_]</c>, at most 32 chars. Null from older agents.
/// </summary>
public string? EspPhase { get; set; }
/// <summary>
/// When the device last booted (UTC), sampled with <see cref="EspPhase"/>. Kept server-side
/// only within 30 days before and 5 minutes after <see cref="Timestamp"/>. Null from older agents.
/// </summary>
public DateTime? DeviceBootUtc { get; set; }
/// <summary>
/// TPM manufacturer id and firmware version (e.g. <c>IFX 7.63.3353.0</c>), sent with
/// <see cref="DistressErrorType.TpmPssUnsupported"/> only. Sanitized, at most 48 chars.
@@ -47,5 +47,9 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Outcome of the remote-config fetch in the latest failed run (e.g. <c>Succeeded</c>, <c>UsedDefaults</c>).</summary>
public string? ConfigFetchOutcome { get; set; }
/// <summary>Autopilot ESP phase when the latest failed run started registering (see <c>DistressReport.EspPhase</c>).</summary>
public string? EspPhase { get; set; }
/// <summary>Windows' OOBE flag when the latest failed run started registering (see <c>DistressReport.OobeState</c>).</summary>
public string? OobeState { get; set; }
}
}
@@ -226,9 +226,12 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>OS build of the most recent report; null from agents that predate it.</summary>
public string? OsBuild { get; set; }
/// <summary>Windows setup state of the most recent report (<c>in_progress</c>, <c>completed</c>, …); null from agents that predate it.</summary>
/// <summary>Windows' OOBE flag of the most recent report (<c>in_progress</c>, <c>completed</c>, …; not ESP progress); null from agents that predate it.</summary>
public string? OobeState { get; set; }
/// <summary>Autopilot ESP phase of the most recent report that carries one (<c>device_setup</c>, <c>account_setup</c>, <c>esp_done</c>, …); null from agents that predate it.</summary>
public string? EspPhase { get; set; }
/// <summary>
/// Last upload of the newest certificate-authenticated session of this serial when that
/// upload came after <see cref="LastSeen"/>: the device connected after its last report.
@@ -346,20 +346,35 @@ export function DistressReportsSection({
<dd className="text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.agentVersion}</dd>
</div>
)}
{(selectedReport.osBuild || selectedReport.oobeState || selectedReport.tpmVersion) && (
<div className="grid grid-cols-3 gap-3">
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">OS Build</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.osBuild ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Setup State</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.oobeState ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">TPM</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.tpmVersion ?? "-"}</dd>
{(selectedReport.osBuild || selectedReport.oobeState || selectedReport.tpmVersion || selectedReport.espPhase) && (
<div>
<div className="grid grid-cols-3 gap-3">
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">OS Build</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.osBuild ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">TPM</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.tpmVersion ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Device Boot</dt>
<dd className="text-gray-900 dark:text-gray-100 mt-0.5 text-xs">
{selectedReport.deviceBootUtc ? new Date(selectedReport.deviceBootUtc).toLocaleString() : "-"}
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">ESP Phase</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.espPhase ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Windows OOBE Flag</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.oobeState ?? "-"}</dd>
</div>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
ESP phase, OOBE flag and boot are taken when the failure began (a failed registration: before its first attempt). The OOBE flag turns &quot;completed&quot; at the desktop; it does not track ESP progress.
</p>
</div>
)}
{selectedReport.isCloudPc && (
@@ -9035,6 +9035,22 @@
"name": "string"
},
"optional": true
},
{
"name": "espPhase",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true
},
{
"name": "deviceBootUtc",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true
}
]
},
@@ -26231,7 +26247,16 @@
"name": "string"
},
"optional": true,
"doc": "Windows setup state of the most recent report (in_progress, completed, …); null from agents that predate it."
"doc": "Windows' OOBE flag of the most recent report (in_progress, completed, …; not ESP progress); null from agents that predate it."
},
{
"name": "espPhase",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Autopilot ESP phase of the most recent report that carries one (device_setup, account_setup, esp_done, …); null from agents that predate it."
},
{
"name": "connectedAt",
@@ -1825,6 +1825,8 @@ export interface DistressReportEntry {
osBuild?: string;
oobeState?: string;
tpmVersion?: string;
espPhase?: string;
deviceBootUtc?: string;
}
/** Success body of GET global/distress-reports: all pre-auth distress reports (Global Admin). */
@@ -5729,8 +5731,10 @@ export interface TpmPssUnsupportedItem {
tpmVersion?: string;
/** OS build of the most recent report; null from agents that predate it. */
osBuild?: string;
/** Windows setup state of the most recent report (in_progress, completed, …); null from agents that predate it. */
/** Windows' OOBE flag of the most recent report (in_progress, completed, …; not ESP progress); null from agents that predate it. */
oobeState?: string;
/** Autopilot ESP phase of the most recent report that carries one (device_setup, account_setup, esp_done, …); null from agents that predate it. */
espPhase?: string;
/** Last upload of the newest certificate-authenticated session of this serial when that upload came after LastSeen: the device connected after its last report. Null while it has not, and when the lookup failed. */
connectedAt?: string;
}
+78 -114
View File
@@ -1428,9 +1428,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1447,9 +1444,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1466,9 +1460,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1485,9 +1476,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1696,9 +1684,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1716,9 +1701,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1736,9 +1718,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1756,9 +1735,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1776,9 +1752,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1796,9 +1769,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1816,9 +1786,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1836,9 +1803,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1917,13 +1881,13 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.146.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
"integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
"version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
"integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
@@ -2249,9 +2213,9 @@
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
"integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
"integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
"cpu": [
"arm"
],
@@ -2266,9 +2230,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz",
"integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
"integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
"cpu": [
"arm64"
],
@@ -2283,9 +2247,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz",
"integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
"integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
"cpu": [
"arm64"
],
@@ -2300,9 +2264,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz",
"integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
"integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
"cpu": [
"x64"
],
@@ -2317,9 +2281,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz",
"integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
"integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
"cpu": [
"x64"
],
@@ -2334,9 +2298,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz",
"integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
"integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
"cpu": [
"arm"
],
@@ -2351,9 +2315,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz",
"integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
"integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
"cpu": [
"arm64"
],
@@ -2368,9 +2332,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz",
"integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
"integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
"cpu": [
"arm64"
],
@@ -2385,9 +2349,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz",
"integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
"integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
"cpu": [
"ppc64"
],
@@ -2402,9 +2366,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz",
"integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
"integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
"cpu": [
"s390x"
],
@@ -2419,9 +2383,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz",
"integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
"integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
"cpu": [
"x64"
],
@@ -2436,9 +2400,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz",
"integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
"integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
"cpu": [
"x64"
],
@@ -2453,9 +2417,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz",
"integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
"integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
"cpu": [
"arm64"
],
@@ -2470,9 +2434,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz",
"integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
"integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
"cpu": [
"arm64"
],
@@ -2487,9 +2451,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz",
"integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
"integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
"cpu": [
"x64"
],
@@ -8148,13 +8112,13 @@
}
},
"node_modules/rolldown": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
"integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
"integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.146.0",
"@oxc-project/types": "=0.154.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -8164,21 +8128,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.5",
"@rolldown/binding-android-arm64": "1.2.5",
"@rolldown/binding-darwin-arm64": "1.2.5",
"@rolldown/binding-darwin-x64": "1.2.5",
"@rolldown/binding-freebsd-x64": "1.2.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.5",
"@rolldown/binding-linux-arm64-gnu": "1.2.5",
"@rolldown/binding-linux-arm64-musl": "1.2.5",
"@rolldown/binding-linux-ppc64-gnu": "1.2.5",
"@rolldown/binding-linux-s390x-gnu": "1.2.5",
"@rolldown/binding-linux-x64-gnu": "1.2.5",
"@rolldown/binding-linux-x64-musl": "1.2.5",
"@rolldown/binding-openharmony-arm64": "1.2.5",
"@rolldown/binding-win32-arm64-msvc": "1.2.5",
"@rolldown/binding-win32-x64-msvc": "1.2.5"
"@rolldown/binding-android-arm-eabi": "1.2.14",
"@rolldown/binding-android-arm64": "1.2.14",
"@rolldown/binding-darwin-arm64": "1.2.14",
"@rolldown/binding-darwin-x64": "1.2.14",
"@rolldown/binding-freebsd-x64": "1.2.14",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
"@rolldown/binding-linux-arm64-gnu": "1.2.14",
"@rolldown/binding-linux-arm64-musl": "1.2.14",
"@rolldown/binding-linux-ppc64-gnu": "1.2.14",
"@rolldown/binding-linux-s390x-gnu": "1.2.14",
"@rolldown/binding-linux-x64-gnu": "1.2.14",
"@rolldown/binding-linux-x64-musl": "1.2.14",
"@rolldown/binding-openharmony-arm64": "1.2.14",
"@rolldown/binding-win32-arm64-msvc": "1.2.14",
"@rolldown/binding-win32-x64-msvc": "1.2.14"
}
},
"node_modules/run-parallel": {
@@ -9393,16 +9357,16 @@
}
},
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"picomatch": "^4.0.7",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -9419,7 +9383,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -9471,9 +9435,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {