Compare commits

...

4 Commits

Author SHA1 Message Date
Oliver Kieselbach 249e39bc68 feat(security): cert tenant binding, stage 1 shadow mode
A chain-valid Intune MDM certificate only proves "issued to some tenant" —
the pinned roots are shared by every Intune tenant. Tenant scoping rested
entirely on the device serial in the downstream validators, which is not a
secret. Bind the tenant stamped into the auth cert to the requested tenant.

The Entra tenant id sits in OID 1.2.840.113556.5.14 of the cert the agent
already presents for mTLS, so this needs no Graph call, no consent and no
agent release. Not 5.6 (Intune Account id, a different GUID) and not 5.4
(device id, stored as 16 bare bytes) — the decoder handles both encodings
and now lives once in Shared, used by agent and backend.

Stage 1 observes only: ObserveCertTenantBinding returns void and swallows
its own exceptions, so it cannot fail an otherwise valid request. Match is
logged only on cert-cache miss, keeping the denominator without per-request
volume. Grep marker for stage 2: CERT-TENANT-BINDING-SHADOW.

Backend 4604/4604, Agent V2.Core 2519/2519, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:03:05 +02:00
Oliver Kieselbach 6bd9139869 fix(agent): cert GUID parser accepts bare 16-byte extension values
Intune's MDM device cert stores the DeviceId in OID 1.2.840.113556.5.4 as a
bare 16-byte GUID with no ASN.1 wrapper, so the strict OCTET-STRING parser
bailed on the tag check and returned null. Verified against a live Intune
device cert: 5.4 is 16 raw bytes, while 5.6 / 5.11 / 5.14 carry the usual
04 10 wrapper.

- ParseGuidFromAsn1OctetString -> ParseGuidFromExtension (the old name no
  longer describes what it parses); both encodings now handled, with the
  shared LE-GUID construction pulled into ToGuid(). The forms cannot
  collide: a wrapped 16-byte GUID is always >= 18 bytes.
- Document the Intune MDM cert OID arc on the class: 5.4 DeviceId,
  5.6 Intune AccountId (NOT the Entra tenant), 5.14 Entra TenantId,
  5.11 undocumented GUID, 5.15/.16/.17/.18/.19/.23 INTEGER enums.
- 13 tests pinning both encodings plus the malformed-input rejections,
  including a bare GUID whose first byte is the 0x04 OCTET STRING tag.

No behaviour change for the MS-Organization-Access probe the agent uses;
TryGetTenantIdFromCert is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:41:00 +02:00
Oliver Kieselbach 73373a2c9e feat(agent): compiled-in fallback rule catalogs for the no-config default
The built-in default config shipped empty ImeLogPatterns/GatherRules lists, so a
session that got neither a live fetch nor a cached config ran with the entire IME
tracking pipeline dead — no app installs, no script results, no health-script
compliance, no IME ESP-phase signals — although the agent binary carries all 37
action handlers. The scalar knobs never drift (shared AgentConfigResponse property
initializers are also the backend's fallbacks), but the catalogs cannot come from
initializers.

BuiltInConfigCatalog embeds the SAME CI-combined rules/dist artifacts the backend
embeds and serves (ime-log-patterns.json, gather-rules.json) and feeds the enabled
entries into CreateDefaultConfig. Single source by construction: each agent release
snapshots the catalog state of its own build, so the offline fallback can never
drift further than the running agent version itself. A live fetch (startup,
post-registration recovery, rotate_config) always wins. Fail-soft: a load error
degrades to the previous empty-list behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:14:30 +02:00
Oliver Kieselbach 6d607c9045 fix(agent): network-link gate before initial config fetch + post-registration recovery
Root cause (tenant aebdce78 audit 2026-08-24, session ba828038): on boot-time
relaunches the agent starts before the Wi-Fi link is up. The Phase 4 config fetch
had no network gate (98f8114d only guarded Phase 6 registration), burned its
10/30/60s retry budget into a dead link and stranded the whole session on built-in
defaults (ConfigVersion=0) — up to ~50% of that tenant's sessions, with no
self-heal short of a manual rotate_config action. Not a version regression: rates
varied wildly per day within the same agent build; the failed requests never
reached the backend.

Two complementary layers:
- RemoteConfigService gates the initial fetch on the shared NetworkLinkWait poll
  (60s cap — longer than registration's 15s because the fetch runs first), so the
  retry budget starts on a live link. rotate_config stays ungated (latency-bound).
- Phase 6.5: after registration proves the backend reachable, TryRecoverConfigAsync
  refetches once and AgentRuntimeConfig.TryRecoverAfterRegistration re-applies the
  live config (same merge/integrity pipeline as Phase 4). LastFetch* keeps the
  original failure record; remote_config_fetch_failed reports it with a recovered
  flag at Info severity, agent_started flags remoteConfigRecovered.

Deliberately not re-run in recovery: kill-signal check (telemetry-channel control
signals cover post-registration kills) and endpoint migration (next start).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:53:04 +02:00
21 changed files with 1593 additions and 77 deletions
+127
View File
@@ -0,0 +1,127 @@
---
type: Concept
title: Client-Certificate Tenant Binding — Closing the Cross-Tenant Gap
description: Why a chain-valid Intune MDM certificate does not prove the caller belongs to the tenant it claims, which certificate extension carries the Entra tenant id (and which two look like it but do not), and the shadow-then-enforce rollout that measures field coverage before it can lock devices out.
resource: src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs
tags:
- backend
- security
- authentication
- certificates
- intune
- multi-tenant
timestamp: 2026-08-24T00:00:00+02:00
---
# Problem
Agent requests authenticate with mTLS. `CertificateValidator` pins the chain to the embedded
Intune roots (`X509ChainTrustMode.CustomRootTrust`), checks the validity window and requires the
Client-Authentication EKU. All of that together proves exactly one thing: **the certificate was
issued by the Microsoft Intune MDM Device CA to some tenant**.
Those roots are shared by every Intune tenant on the planet. Nothing in the certificate stage ties
the caller to the tenant in the request. The tenant scoping came entirely from the downstream
device validators — the serial number must appear in that tenant's Autopilot / imported-device /
Cloud PC inventory. Device serial numbers are not secrets; they are printed on the chassis.
So the pre-existing attack was: enroll a device in your own Intune tenant, obtain a legitimate
device certificate, learn one serial number of the victim tenant, and submit telemetry as that
tenant. Certificate validation passes, device validation passes.
# Mechanism
The Intune MDM Device CA stamps the customer's identifiers into the leaf certificate. Since the
agent presents this certificate in the TLS handshake, its contents are proof of possession and the
backend can compare them against the requested tenant — no Graph call, no additional consent, and
nothing the agent has to send.
## Which extension carries what
Verified by decoding a real field certificate (issued 2026-05-08, `Functions.Tests/device-cert-sample.pem`):
| OID | Encoding | Content |
| --- | --- | --- |
| `1.2.840.113556.5.14` | nested OCTET STRING (`04 10` + 16 bytes) | **Entra tenant id** — the one to compare |
| `1.2.840.113556.5.6` | nested OCTET STRING (`04 10` + 16 bytes) | Intune **Account** id — a *different* GUID, never equal to the tenant id |
| `1.2.840.113556.5.4` | **16 raw bytes, no wrapper** | Intune device id, identical to the certificate's Subject CN |
Two traps are worth stating explicitly, because both cost real debugging time:
1. `…5.6` is widely described as "the tenant id (AccountID)". It is not the Entra tenant id.
Comparing it against the requested tenant would reject every request in production.
2. The encodings are not uniform. `…5.4` holds the GUID bare while the other two nest it in a
second OCTET STRING, so a decoder written against one shape silently fails on the other.
`MsDeviceCertificateOids.TryParseGuid` accepts both, and all GUIDs are Microsoft
little-endian (what `new Guid(byte[])` expects).
A 16-byte input is inherently ambiguous — it is indistinguishable from a truncated wrapper and is
always read as a bare GUID. That is harmless here: the value is only ever compared for equality
against a known tenant id, never trusted as a standalone claim.
## Where it is NOT
`EntraDeviceCertHelper` reads tenant and device ids from the **MS-Organization-Access** certificate
(`1.2.840.113556.1.5.284.*`). That certificate is not part of the authentication path at all — the
agent selects the Intune MDM certificate by exact issuer match (`CN=Microsoft Intune MDM Device CA`,
see `CertificateHelper.SelectMdmCertificate`) and the backend pins only the MDM chain. The Entra
certificate is used purely as a local fallback for `TenantIdResolver`; it never leaves the device.
Sending it in a header instead would prove nothing: only the certificate used in the TLS handshake
demonstrates possession of a private key. A certificate copied into a custom header can be replayed
by anyone who has seen it once.
# Rollout
Enforcing immediately would be reckless: any device population whose certificates predate the
extension, or whose CA behaves differently (sovereign clouds), would be locked out at once. The
rollout therefore has staged consequences.
**Stage 1 — shadow (current).** `SecurityValidator.ObserveCertTenantBinding` evaluates the binding
after certificate validation and logs the outcome. It returns `void`, changes no state, and swallows
its own exceptions, so it cannot fail a request that would otherwise be accepted. The pure comparison
lives in `CertTenantBinding.Evaluate`; the outcome codes (`Match`, `Mismatch`, `ExtensionMissing`,
`Unparseable`, `RequestTenantNotAGuid`) are stable and queried by exact match in KQL.
`WouldRejectUnderEnforcement` records what stage 2 *would* have done, so the telemetry can be read
without re-deriving the rule at query time.
Volume control: the outcomes that inform the decision (mismatch, missing, undecodable) are always
logged, because they are expected to be rare and each is individually interesting. The bulk `Match`
case is logged only when the certificate was freshly validated rather than served from the 5-minute
thumbprint cache (`CertificateValidationResult.FromCache`). Both numerator and denominator are
therefore counted per certificate-window rather than per request, which keeps the ratio meaningful
while cutting the volume by roughly the request-per-device factor.
**Stage 2 — enforce.** Once telemetry shows the `ExtensionMissing` rate is negligible, `Mismatch`
becomes a rejection. The decision that still has to be made is the policy for certificates without
the extension: skip with a warning, or reject. Grep marker for every stage-2 site:
`CERT-TENANT-BINDING-SHADOW`.
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
unaffected by either stage.
# Examples
Read the shadow telemetry:
```kusto
traces
| where message has "AgentCertTenantBinding"
| extend Outcome = tostring(customDimensions.Outcome),
Tenant = tostring(customDimensions.TenantId)
| summarize count() by Outcome, Tenant
```
Anything in `Mismatch` is a cross-tenant certificate; anything in `ExtensionMissing` is a device
that stage-2 enforcement would have to make a policy decision about.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Security/MsDeviceCertificateOids.cs` — OID catalog and the
shared ASN.1 GUID decoder (one implementation for agent and backend).
* `src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs` — extension extraction
alongside chain validation, cached per thumbprint.
* `src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs` — the pure comparison.
* `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — shadow observation.
* [W365 Cloud PC Device Validation](cloudpc-device-validation.md) — the other consumer of this
certificate's identity fields (Subject CN = Intune device id).
+1
View File
@@ -76,6 +76,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Security & Identity
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry.
# Conventions for this bundle
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-08-24
* **New**: `backend/client-cert-tenant-binding.md` — the agent's mTLS certificate chains to Microsoft's shared Intune roots, so certificate validation alone never proved tenant membership; tenant scoping rested entirely on the (non-secret) device serial in the downstream validators. The Intune MDM leaf carries the Entra tenant id in OID `1.2.840.113556.5.14` (nested OCTET STRING), which is NOT `…5.6` (Intune Account id, a different GUID) and NOT `…5.4` (device id, stored as 16 bare bytes) — all decoded by one shared `MsDeviceCertificateOids.TryParseGuid` now used by agent and backend. Stage 1 ships as shadow only: `CertTenantBinding.Evaluate` (pure) plus `SecurityValidator.ObserveCertTenantBinding` (returns void, swallows its own exceptions, cannot change an authorization outcome), with `Match` sampled via `CertificateValidationResult.FromCache` so the denominator survives without per-request volume. Grep marker for the stage-2 enforcement change: `CERT-TENANT-BINDING-SHADOW`.
## 2026-08-23
* **New**: `backend/telemetry-ingest-shaping.md` — `RequestTelemetryMiddleware` sets `SamplingPercentage=100` on its own item (worker request rows were adaptively sampled, ItemCount 2–5 on weekdays, despite host.json `excludedTypes`), the redundant host HTTP request copy is dropped by a workspace transformation DCR (`infra/appinsights-workspace-transforms.bicep`, timer/queue rows kept), and `StorageDependencyFilterProcessor` now also drops expected storage outcomes (404/412/409, both InProc and HTTP shapes) and successful SignalR REST calls; the host-emitted SignalR binding calls (bulk of the volume, SDK `rdddsc`) are dropped by a second `AppDependencies` flow in the same DCR. Measured ≈ −1.3 GB/week.
@@ -0,0 +1,62 @@
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
{
/// <summary>
/// Pins the compiled-in fallback rule catalogs (<see cref="BuiltInConfigCatalog"/>):
/// the agent embeds the SAME CI-combined rules/dist artifacts the backend serves, so a
/// no-config/no-cache session still runs full IME tracking. These tests guard the
/// embedding + deserialization end-to-end — a broken LogicalName, a moved resource or a
/// shape change in the combined JSON must fail HERE, not silently strip the fallback
/// back down to zero patterns in the field.
/// </summary>
public class BuiltInConfigCatalogTests
{
[Fact]
public void ImeLogPatterns_LoadFromEmbeddedCatalog_EnabledOnly()
{
var patterns = BuiltInConfigCatalog.GetEnabledImeLogPatterns(logger: null);
Assert.NotEmpty(patterns);
Assert.All(patterns, p =>
{
Assert.True(p.Enabled);
Assert.False(string.IsNullOrWhiteSpace(p.PatternId));
Assert.False(string.IsNullOrWhiteSpace(p.Pattern));
Assert.False(string.IsNullOrWhiteSpace(p.Action));
Assert.False(string.IsNullOrWhiteSpace(p.Category));
});
Assert.Equal(patterns.Count, patterns.Select(p => p.PatternId).Distinct().Count());
}
[Fact]
public void ImeLogPatterns_CarryCoreTrackingActions()
{
// The app-tracking backbone: without these two actions the ImeLogTracker cannot
// attribute installs to apps or follow ESP phases. Their presence proves the
// deserialization actually populated the action field (a shape drift in the
// combined JSON would otherwise still pass the structural checks above).
var actions = BuiltInConfigCatalog.GetEnabledImeLogPatterns(logger: null)
.Select(p => p.Action).ToHashSet();
Assert.Contains("setCurrentApp", actions);
Assert.Contains("espPhaseDetected", actions);
}
[Fact]
public void GatherRules_LoadFromEmbeddedCatalog_EnabledOnly()
{
// The built-in gather-rule catalog ships mostly disabled rules — only the
// default-enabled ones may reach the fallback config.
var rules = BuiltInConfigCatalog.GetEnabledGatherRules(logger: null);
Assert.All(rules, r =>
{
Assert.True(r.Enabled);
Assert.False(string.IsNullOrWhiteSpace(r.RuleId));
});
}
}
}
@@ -114,6 +114,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(1, svc.LastFetchAttempts);
}
[Fact]
public async Task FetchConfig_DefaultsFallback_CarriesBuiltInCatalogs()
{
// A no-config/no-cache session must still get the compiled-in rule catalogs —
// an empty ImeLogPatterns list kills the entire IME tracking pipeline (the
// pre-catalog behaviour this pins against regressing to).
var svc = NewService(BackendBehaviour.AlwaysTransientFailure);
var result = await svc.FetchConfigAsync(retryOnTransientErrors: true);
Assert.Equal(0, result.ConfigVersion);
Assert.NotEmpty(result.ImeLogPatterns);
Assert.All(result.ImeLogPatterns, p => Assert.True(p.Enabled));
Assert.All(result.GatherRules, r => Assert.True(r.Enabled));
}
[Fact]
public async Task FetchConfig_EmptyResponse_TreatedAsTransient()
{
@@ -128,6 +144,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(4, svc.LastFetchAttempts);
}
// ── Network-link gate (tenant aebdce78 audit 2026-08-24) ────────────
[Fact]
public async Task FetchConfig_InitialFetch_WaitsForNetworkLink()
{
// The initial startup fetch must gate on the link-level signal so the retry
// budget isn't burned into a NIC that is provably still down (agent relaunched
// at boot, Wi-Fi still associating).
var svc = NewService(BackendBehaviour.SuccessImmediately);
await svc.FetchConfigAsync(retryOnTransientErrors: true);
Assert.Equal(1, svc.NetworkLinkWaits);
}
[Fact]
public async Task FetchConfig_RotatePath_SkipsNetworkLinkGate()
{
// rotate_config runs mid-session on a proven link and must stay latency-bounded.
var svc = NewService(BackendBehaviour.SuccessImmediately);
await svc.FetchConfigAsync(retryOnTransientErrors: false);
Assert.Equal(0, svc.NetworkLinkWaits);
}
// ── Post-registration recovery ──────────────────────────────────────
[Fact]
public async Task TryRecover_AfterFailedFetch_AppliesLiveConfig_KeepsFailureRecord()
{
// The core self-heal contract: registration proved the backend reachable, the
// recovery swaps in the live config — but the LastFetch* observability keeps
// describing the failed startup fetch so the wire event can report both.
var svc = NewService(BackendBehaviour.FailAllThenSucceed);
await svc.FetchConfigAsync(retryOnTransientErrors: true);
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
var recovered = await svc.TryRecoverConfigAsync();
Assert.NotNull(recovered);
Assert.Equal(42, recovered.ConfigVersion);
Assert.Equal(42, svc.CurrentConfig.ConfigVersion);
Assert.True(svc.RecoveredAfterFetchFailure);
// Startup-failure record intact:
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
Assert.Equal(1 + RemoteConfigService.InitialFetchRetryBackoff.Length, svc.LastFetchAttempts);
Assert.Equal(nameof(HttpRequestException), svc.LastFetchFailureType);
}
[Fact]
public async Task TryRecover_AfterSuccessfulFetch_IsNoOp()
{
var svc = NewService(BackendBehaviour.SuccessImmediately);
await svc.FetchConfigAsync(retryOnTransientErrors: true);
var recovered = await svc.TryRecoverConfigAsync();
Assert.Null(recovered);
Assert.False(svc.RecoveredAfterFetchFailure);
}
[Fact]
public async Task TryRecover_BeforeAnyFetch_IsNoOp()
{
var svc = NewService(BackendBehaviour.SuccessImmediately);
var recovered = await svc.TryRecoverConfigAsync();
Assert.Null(recovered);
Assert.False(svc.RecoveredAfterFetchFailure);
}
[Fact]
public async Task TryRecover_WhenBackendStillFailing_KeepsFallback_NeverThrows()
{
var svc = NewService(BackendBehaviour.AlwaysTransientFailure);
var fallback = await svc.FetchConfigAsync(retryOnTransientErrors: true);
Assert.Equal(0, fallback.ConfigVersion);
var recovered = await svc.TryRecoverConfigAsync();
Assert.Null(recovered);
Assert.False(svc.RecoveredAfterFetchFailure);
Assert.Equal(0, svc.CurrentConfig.ConfigVersion); // fallback config untouched
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
}
// ── Outcome state defaults ──────────────────────────────────────────
[Fact]
@@ -145,6 +249,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
SuccessImmediately,
AlwaysTransientFailure,
FailTwiceThenSucceed,
/// <summary>All 4 initial attempts fail; the NEXT call (the recovery fetch) succeeds.</summary>
FailAllThenSucceed,
AlwaysAuthFailure401,
AlwaysReturnNull,
}
@@ -177,6 +283,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
_behaviour = behaviour;
}
/// <summary>Number of times the network-link gate seam was awaited.</summary>
public int NetworkLinkWaits { get; private set; }
protected override Task<AgentConfigResponse> CallBackendAsync()
{
_calls++;
@@ -188,6 +297,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
if (_calls < 3)
throw new HttpRequestException("simulated cold-start timeout");
return Task.FromResult(new AgentConfigResponse { ConfigVersion = 42 });
case BackendBehaviour.FailAllThenSucceed:
if (_calls <= 1 + RemoteConfigService.InitialFetchRetryBackoff.Length)
throw new HttpRequestException("simulated dead network link");
return Task.FromResult(new AgentConfigResponse { ConfigVersion = 42 });
case BackendBehaviour.AlwaysTransientFailure:
throw new HttpRequestException("simulated network failure");
case BackendBehaviour.AlwaysAuthFailure401:
@@ -202,6 +315,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// Zero-delay so the suite finishes in ms instead of the live 10/30/60 s schedule.
protected override Task DelayBetweenAttemptsAsync(TimeSpan delay) => Task.CompletedTask;
// Record + skip the real link poll so the suite is deterministic on offline CI.
protected override Task WaitForNetworkLinkAsync()
{
NetworkLinkWaits++;
return Task.CompletedTask;
}
// In-memory + per-instance cache so a prior test's successful fetch can't
// bleed into a later test that expects UsedDefaults.
private AgentConfigResponse? _cached;
@@ -182,6 +182,73 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Equal("FromCache", data["outcome"]);
}
// ── Post-registration recovery (Phase 6.5, tenant aebdce78 audit 2026-08-24) ─
[Fact]
public void EmitFetchFailedIfAny_AfterRecovery_MarksRecoveredAndDemotesToInfo()
{
using var rig = new EmitterRig();
// Startup fetch failed (network link not up yet), then the post-registration
// recovery fetched ConfigVersion 39. The event must keep the original failure
// details AND mark the heal.
rig.RemoteConfig.SetFakeOutcome(
RemoteConfigFetchOutcome.UsedDefaults,
configVersion: 39, // CurrentConfig now holds the recovered config
attempts: 4,
failureType: "HttpRequestException",
failureMessage: "simulated dead network link",
recovered: true);
LifecycleEmitters.EmitRemoteConfigFetchFailedIfAny(
rig.Post, rig.AgentConfig, rig.RemoteConfig, rig.Logger);
var data = AssertSingleEmit(rig.Sink, expectedEventType: "remote_config_fetch_failed");
Assert.Equal("UsedDefaults", data["outcome"]); // startup failure record intact
Assert.Equal(4, data["attempts"]);
Assert.Equal(true, data["recovered"]);
Assert.Equal(39, data["recoveredConfigVersion"]);
Assert.Equal(nameof(EventSeverity.Info), rig.Sink.Posted[0].Payload!["severity"]);
}
[Fact]
public void EmitFetchFailedIfAny_WithoutRecovery_StaysWarning()
{
using var rig = new EmitterRig();
rig.RemoteConfig.SetFakeOutcome(
RemoteConfigFetchOutcome.UsedDefaults,
configVersion: 0,
attempts: 4,
failureType: "HttpRequestException",
failureMessage: "still down");
LifecycleEmitters.EmitRemoteConfigFetchFailedIfAny(
rig.Post, rig.AgentConfig, rig.RemoteConfig, rig.Logger);
var data = AssertSingleEmit(rig.Sink, expectedEventType: "remote_config_fetch_failed");
Assert.False(data.ContainsKey("recovered"));
Assert.False(data.ContainsKey("recoveredConfigVersion"));
Assert.Equal(nameof(EventSeverity.Warning), rig.Sink.Posted[0].Payload!["severity"]);
}
[Fact]
public void EmitAgentStarted_AfterRecovery_FlagsLiveConfig()
{
using var rig = new EmitterRig();
rig.RemoteConfig.SetFakeOutcome(
RemoteConfigFetchOutcome.UsedDefaults,
configVersion: 39,
recovered: true);
LifecycleEmitters.EmitAgentStarted(
rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger);
var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started");
Assert.Equal(39, data["configVersion"]);
Assert.Equal(true, data["remoteConfigFetched"]); // running on a live-fetched config
Assert.Equal("UsedDefaults", data["remoteConfigOutcome"]); // startup outcome stays honest
Assert.Equal(true, data["remoteConfigRecovered"]);
}
// ── Helpers ─────────────────────────────────────────────────────────
private static Dictionary<string, object> AssertSingleEmit(
@@ -258,8 +325,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
int attempts = 1,
string? failureType = null,
string? failureMessage = null,
int? authStatusCode = null)
int? authStatusCode = null,
bool recovered = false)
{
typeof(RemoteConfigService).GetProperty(nameof(RecoveredAfterFetchFailure))?.SetValue(this, recovered);
// CurrentConfig is set via the protected SetConfig path — easiest:
// a real FetchConfigAsync replacement that just stashes a canned config.
typeof(RemoteConfigService).GetMethod("SetConfig",
@@ -0,0 +1,146 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// Pins <see cref="EntraDeviceCertHelper.ParseGuidFromExtension"/>, the pure byte-level part of the
/// cert probe. Both encodings Microsoft actually ships are covered: the ASN.1 OCTET STRING wrapper
/// used by the MS-Organization-Access TenantId/DeviceId extensions (and Intune's
/// <c>1.2.840.113556.5.6</c> / <c>5.14</c>), and the BARE 16-byte payload used by Intune's DeviceId
/// extension <c>1.2.840.113556.5.4</c> — the latter was silently returning <c>null</c> before.
/// The store-iteration shell around it needs a real machine cert store and is not unit-tested.
/// </summary>
public sealed class EntraDeviceCertHelperParseTests
{
// Synthetic — never a real tenant/device id. ToByteArray() gives the MS little-endian layout
// that both cert encodings carry, so every expectation below is derived, not hand-transcribed.
private static readonly Guid Sample = new Guid("11223344-5566-7788-99aa-bbccddeeff00");
private static byte[] Wrap(byte[] payload, params byte[] header)
{
var buf = new byte[header.Length + payload.Length];
Buffer.BlockCopy(header, 0, buf, 0, header.Length);
Buffer.BlockCopy(payload, 0, buf, header.Length, payload.Length);
return buf;
}
[Fact]
public void OctetString_ShortForm_Parses()
{
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x10);
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_LongForm_Parses()
{
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x81, 0x10);
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
/// <summary>
/// The regression this class exists for: Intune's <c>1.2.840.113556.5.4</c> extension value is
/// exactly 16 bytes with no <c>04 10</c> prefix, so the strict OCTET-STRING parser bailed on the
/// tag check and the DeviceId was unreadable.
/// </summary>
[Fact]
public void BareSixteenBytes_Parses()
{
var raw = Sample.ToByteArray();
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
/// <summary>
/// A bare GUID may legitimately start with 0x04 (the OCTET STRING tag). That must not be
/// mistaken for a wrapper: a wrapped 16-byte GUID is always >= 18 bytes, so the two forms are
/// disjoint by length and the bare path has to win here.
/// </summary>
[Fact]
public void BareSixteenBytes_StartingWithOctetStringTag_StillParses()
{
var guid = new Guid("aabbcc04-0110-4020-8030-405060708090");
var raw = guid.ToByteArray();
Assert.Equal(0x04, raw[0]); // guard: the byte layout really does start with the ASN.1 tag
Assert.Equal(16, raw.Length);
Assert.Equal(guid, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_IsLittleEndianMsLayout()
{
// 04 10 + the canonical MS binary layout of {11223344-...} => first group byte-reversed.
var raw = new byte[]
{
0x04, 0x10,
0x44, 0x33, 0x22, 0x11,
0x66, 0x55,
0x88, 0x77,
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
};
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void Null_ReturnsNull()
{
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(null));
}
[Fact]
public void Empty_ReturnsNull()
{
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[0]));
}
[Theory]
[InlineData(15)]
[InlineData(17)]
public void BarePayload_OfWrongLength_ReturnsNull(int length)
{
var raw = new byte[length];
for (var i = 0; i < length; i++) raw[i] = (byte)(0x40 + i); // no 0x04 tag, no valid wrapper
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void NonOctetStringTag_ReturnsNull()
{
// e.g. Intune's 1.2.840.113556.5.15 => ASN.1 INTEGER 2.
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[] { 0x02, 0x01, 0x02 }));
}
[Fact]
public void OctetString_WithNonGuidContentLength_ReturnsNull()
{
var raw = Wrap(new byte[8], 0x04, 0x08);
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_TruncatedContent_ReturnsNull()
{
// Declares 16 content bytes but only carries 10.
var raw = Wrap(new byte[10], 0x04, 0x10);
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_LongFormWithOversizedLengthHeader_ReturnsNull()
{
// 0x85 => 5 length bytes, above the 4 the parser accepts.
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x85, 0x00, 0x00, 0x00, 0x00, 0x10);
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
}
}
@@ -52,6 +52,11 @@
carries the V2 assembly namespace so GatherRuleGuards locates it under
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json". -->
<EmbeddedResource Include="..\..\..\rules\guardrails.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json" />
<!-- Built-in fallback rule catalogs (BuiltInConfigCatalog): the SAME CI-combined
rules/dist artifacts the backend embeds and serves. Snapshotted per agent build so
the no-config/no-cache fallback still runs full IME tracking instead of nothing. -->
<EmbeddedResource Include="..\..\..\rules\dist\ime-log-patterns.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.ime-log-patterns.json" />
<EmbeddedResource Include="..\..\..\rules\dist\gather-rules.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.gather-rules.json" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
/// <summary>
/// Compiled-in fallback copies of the global rule catalogs, embedded from the same
/// CI-generated <c>rules/dist</c> artifacts the backend seeds and serves — single
/// source by construction: each agent release snapshots the catalog state of its own
/// build, so the offline fallback can never drift further than the running agent
/// version itself (no manually maintained default to forget).
/// <para>
/// Consumed only by <see cref="RemoteConfigService"/>'s built-in default config,
/// i.e. when a session has NO live config and NO cache. Before this existed, that
/// path shipped an empty <c>ImeLogPatterns</c> list, which left the entire IME
/// tracking pipeline (app installs, platform/remediation scripts, health-script
/// compliance, IME ESP-phase signals) dead for the whole session — the core of
/// enrollment monitoring. A live fetch (startup, post-registration recovery or
/// rotate_config) always wins over this catalog.
/// </para>
/// Fail-soft: any load/parse error returns an empty list (the pre-catalog behaviour).
/// </summary>
internal static class BuiltInConfigCatalog
{
/// <summary>Enabled built-in IME log patterns from the embedded catalog.</summary>
public static List<ImeLogPattern> GetEnabledImeLogPatterns(AgentLogger logger)
=> LoadEnabled<ImeLogPattern>("ime-log-patterns.json", p => p.Enabled, logger);
/// <summary>Enabled built-in gather rules from the embedded catalog.</summary>
public static List<GatherRule> GetEnabledGatherRules(AgentLogger logger)
=> LoadEnabled<GatherRule>("gather-rules.json", r => r.Enabled, logger);
private static List<T> LoadEnabled<T>(string name, Func<T, bool> isEnabled, AgentLogger logger)
{
try
{
var assembly = typeof(BuiltInConfigCatalog).Assembly;
using (var stream = assembly.GetManifestResourceStream(
"AutopilotMonitor.Agent.V2.Core.Resources." + name))
{
if (stream == null)
{
logger?.Warning($"Built-in config catalog resource '{name}' not found — fallback runs without it.");
return new List<T>();
}
using (var reader = new StreamReader(stream))
{
var wrapper = JsonConvert.DeserializeObject<RulesFile<T>>(reader.ReadToEnd());
var all = wrapper?.Rules ?? new List<T>();
return all.Where(isEnabled).ToList();
}
}
}
catch (Exception ex)
{
logger?.Warning($"Built-in config catalog '{name}' failed to load ({ex.Message}) — fallback runs without it.");
return new List<T>();
}
}
/// <summary>Shape of the CI-combined catalog files: <c>{ "rules": [...] }</c>.</summary>
private sealed class RulesFile<T>
{
[JsonProperty("rules")]
public List<T> Rules { get; set; }
}
}
}
@@ -42,6 +42,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
TimeSpan.FromSeconds(60),
};
/// <summary>
/// Upper bound for the pre-fetch network-link wait on the initial startup fetch.
/// Deliberately longer than the registration gate's 15s: the fetch runs FIRST after a
/// boot-time relaunch, when Wi-Fi may still be associating (802.1X, OOBE), and a fetch
/// burned into a dead link strands the whole session on built-in defaults — tenant aebdce78
/// audit 2026-08-24: ~50% of sessions lost their tenant config exactly this way.
/// </summary>
internal static readonly TimeSpan InitialFetchNetworkLinkWaitMax = TimeSpan.FromSeconds(60);
/// <summary>
/// Gets the current configuration (thread-safe)
/// </summary>
@@ -76,6 +85,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// <summary>HTTP status for auth failures (401/403), null otherwise.</summary>
public int? LastFetchAuthStatusCode { get; private set; }
/// <summary>
/// True once <see cref="TryRecoverConfigAsync"/> replaced a failed startup fetch with a
/// live config. The <c>LastFetch*</c> properties intentionally keep describing the failed
/// startup fetch so the wire event can report both the original failure and the recovery.
/// </summary>
public bool RecoveredAfterFetchFailure { get; private set; }
public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null, AuthFailureTracker authFailureTracker = null)
{
_apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
@@ -116,6 +132,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
LastFetchFailureMessage = null;
LastFetchAuthStatusCode = null;
// Initial startup fetch only: give a still-associating NIC a chance to come up
// before the first attempt. Without this the retry budget (10/30/60s) is burned
// into a provably dead link — the agent starts at boot, Wi-Fi associates minutes
// later, and the session runs on defaults although the backend was never the
// problem. The rotate_config path (retryOnTransientErrors:false) runs mid-session
// on a proven link and must stay latency-bounded, so it skips the gate.
if (retryOnTransientErrors)
{
try { await WaitForNetworkLinkAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.Debug($"Pre-fetch network wait failed ({ex.Message}) — fetching anyway."); }
}
var maxAttempts = retryOnTransientErrors ? InitialFetchRetryBackoff.Length + 1 : 1;
Exception lastException = null;
@@ -203,14 +231,63 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
return cached;
}
// Fall back to defaults
_logger.Info("No cached config available, using defaults (all optional collectors disabled)");
// Fall back to built-in defaults (collector/analyzer defaults from the shared
// model initializers + the compiled-in rule catalogs).
var defaults = CreateDefaultConfig();
_logger.Info($"No cached config available, using built-in defaults (ConfigVersion=0, " +
$"{defaults.ImeLogPatterns?.Count ?? 0} built-in IME patterns, " +
$"{defaults.GatherRules?.Count ?? 0} built-in gather rules)");
SetConfig(defaults);
LastFetchOutcome = RemoteConfigFetchOutcome.UsedDefaults;
return defaults;
}
/// <summary>
/// Single-attempt recovery fetch for a session whose startup fetch failed. Called right
/// after session registration succeeded — registration proves the backend is reachable,
/// so the config that Phase 4 could not get is one cheap call away instead of the whole
/// session being stranded on cache/defaults (only a manual <c>rotate_config</c> action
/// could rescue it before this existed).
/// <para>
/// Unlike <see cref="FetchConfigAsync"/> this does NOT reset the <c>LastFetch*</c>
/// observability state — those keep describing the failed startup fetch so
/// <c>remote_config_fetch_failed</c> can report the original failure AND the recovery.
/// Returns the recovered config, or null when there is nothing to recover (startup fetch
/// succeeded / never ran) or the recovery attempt itself failed. Never throws.
/// </para>
/// </summary>
public async Task<AgentConfigResponse> TryRecoverConfigAsync()
{
if (LastFetchOutcome != RemoteConfigFetchOutcome.FromCache &&
LastFetchOutcome != RemoteConfigFetchOutcome.UsedDefaults)
return null;
try
{
_logger.Info("Retrying remote config fetch after successful session registration...");
var config = await CallBackendAsync().ConfigureAwait(false);
if (config == null)
{
_logger.Warning("Config recovery fetch returned empty response — keeping fallback config.");
return null;
}
_logger.Info($"Remote config recovered: ConfigVersion={config.ConfigVersion}, " +
$"GatherRules={config.GatherRules?.Count ?? 0}");
LogCollectorSettings(config.Collectors);
SetConfig(config);
CacheConfig(config);
_authFailureTracker?.RecordSuccess();
RecoveredAfterFetchFailure = true;
return config;
}
catch (Exception ex)
{
_logger.Warning($"Config recovery fetch failed ({ex.Message}) — keeping fallback config.");
return null;
}
}
private void SetConfig(AgentConfigResponse config)
{
lock (_configLock)
@@ -319,6 +396,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
private AgentConfigResponse CreateDefaultConfig()
{
// Everything not set here falls to the shared AgentConfigResponse property
// initializers — the same values the backend uses as its `??` fallbacks, so the
// scalar knobs cannot drift. The rule catalogs are the one thing initializers
// cannot supply: without them the whole IME tracking pipeline is dead, so they
// come from the compiled-in snapshot of the served catalogs (BuiltInConfigCatalog).
return new AgentConfigResponse
{
ConfigVersion = 0,
@@ -330,8 +412,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
EnableGatherRuleDebugLog = false,
EnableEspContinueAnywayObservation = false,
Collectors = CollectorConfiguration.CreateDefault(),
GatherRules = new System.Collections.Generic.List<GatherRule>(),
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
GatherRules = BuiltInConfigCatalog.GetEnabledGatherRules(_logger),
ImeLogPatterns = BuiltInConfigCatalog.GetEnabledImeLogPatterns(_logger)
};
}
@@ -358,6 +440,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
protected virtual Task DelayBetweenAttemptsAsync(TimeSpan delay)
=> Task.Delay(delay);
/// <summary>
/// Awaits the pre-fetch network-link gate (initial startup fetch only). Production
/// polls the link-level signal via <see cref="NetworkLinkWait"/> bounded by
/// <see cref="InitialFetchNetworkLinkWaitMax"/>; tests subclass to record the call
/// and return immediately.
/// </summary>
protected virtual Task WaitForNetworkLinkAsync()
=> NetworkLinkWait.WaitAsync(_logger, InitialFetchNetworkLinkWaitMax, "Remote config fetch");
public void Dispose()
{
// Nothing to dispose
@@ -0,0 +1,38 @@
using System;
using System.Net.NetworkInformation;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Boot-time NIC grace shared by every first-contact backend call (remote-config fetch,
/// session registration, emergency-break reporter pattern): polls the cheap link-level
/// signal once per second for at most <paramref name="maxWait"/>. It cannot prove backend
/// reachability — the callers' retry loops handle that — it only keeps retry budgets from
/// being burned into a link that is provably still down (BootTrigger relaunch after a
/// mid-enrollment reboot, Wi-Fi still associating; tenant aebdce78 audits 2026-08-23/24).
/// Free on the normal path — a live link returns immediately. Probe errors end the wait,
/// never the caller's operation.
/// </summary>
internal static class NetworkLinkWait
{
internal static async Task WaitAsync(AgentLogger logger, TimeSpan maxWait, string context)
{
if (NetworkInterface.GetIsNetworkAvailable()) return;
logger?.Info($"{context}: no network link yet — waiting up to {maxWait.TotalSeconds:F0}s.");
var deadline = DateTime.UtcNow + maxWait;
while (DateTime.UtcNow < deadline)
{
await Task.Delay(1000).ConfigureAwait(false);
if (NetworkInterface.GetIsNetworkAvailable())
{
logger?.Info($"{context}: network link is up.");
return;
}
}
logger?.Warning($"{context}: still no network link after {maxWait.TotalSeconds:F0}s — attempting anyway.");
}
}
}
@@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Security;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
@@ -23,6 +24,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <item><description><c>1.2.840.113556.1.5.284.2</c> → DeviceId (same encoding)</description></item>
/// </list>
/// <para>
/// The sibling <c>Microsoft Intune MDM Device CA</c> client cert (the one the agent authenticates
/// with, see <see cref="CertificateHelper"/>) carries a DIFFERENT OID arc that identifies the same
/// device from Intune's side. Observed on a live Intune-enrolled Windows 11 device:
/// </para>
/// <list type="table">
/// <listheader><term>OID</term><description>Meaning / encoding</description></listheader>
/// <item><term><c>1.2.840.113556.5.4</c></term><description>Intune DeviceId — BARE 16-byte GUID, no ASN.1 wrapper. Equals the cert's Subject CN.</description></item>
/// <item><term><c>1.2.840.113556.5.6</c></term><description>Intune AccountId (the Intune tenant account, NOT the Entra TenantId) — OCTET STRING GUID.</description></item>
/// <item><term><c>1.2.840.113556.5.14</c></term><description>Entra TenantId — OCTET STRING GUID. This is the one to compare against an Entra tenant id.</description></item>
/// <item><term><c>1.2.840.113556.5.11</c></term><description>Further GUID, OCTET STRING; semantics not documented by Microsoft and not verified here.</description></item>
/// <item><term><c>1.2.840.113556.5.15/.16/.17/.18/.19/.23</c></term><description>ASN.1 INTEGER enums (platform / enrollment flavour); semantics undocumented.</description></item>
/// </list>
/// <para>
/// The bare-GUID encoding of <c>1.2.840.113556.5.4</c> is why
/// <see cref="ParseGuidFromExtension"/> accepts an unwrapped 16-byte payload as well —
/// a strict OCTET-STRING parser silently returns <c>null</c> for that extension.
/// </para>
/// <para>
/// All probes are best-effort: never throw, return <c>null</c> on miss, log a Warning
/// with the specific failure reason (no matching cert / extension missing / ASN.1
/// parse failed) so the agent log explains why the fallback didn't help.
@@ -31,8 +50,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public static class EntraDeviceCertHelper
{
public const string MsOrganizationAccessIssuer = "MS-Organization-Access";
public const string TenantIdOid = "1.2.840.113556.1.5.284.5";
public const string DeviceIdOid = "1.2.840.113556.1.5.284.2";
public const string TenantIdOid = MsDeviceCertificateOids.EntraJoinCertTenantIdOid;
public const string DeviceIdOid = MsDeviceCertificateOids.EntraJoinCertDeviceIdOid;
/// <summary>
/// Reads the Entra TenantId from the MS-Organization-Access device cert.
@@ -88,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
continue;
}
var guid = ParseGuidFromAsn1OctetString(ext.RawData);
var guid = ParseGuidFromExtension(ext.RawData);
if (guid != null)
{
logger?.Info(
@@ -98,7 +117,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
logger?.Warning(
$"EntraDeviceCertHelper: failed to parse {label} ASN.1 OCTET STRING in cert thumbprint={cert.Thumbprint} " +
$"EntraDeviceCertHelper: failed to parse {label} GUID extension in cert thumbprint={cert.Thumbprint} " +
$"(raw {ext.RawData.Length} bytes); trying next candidate.");
}
@@ -116,46 +135,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>
/// Parses a 16-byte MS-format binary GUID (little-endian for the first three groups,
/// per <see cref="Guid(byte[])"/>) wrapped in an ASN.1 OCTET STRING. Handles both
/// short-form (<c>04 10 ...</c>) and long-form (<c>04 81 10 ...</c>) length encoding.
/// per <see cref="Guid(byte[])"/>) out of a certificate extension's raw value.
/// Two encodings are accepted, because Microsoft uses both:
/// <list type="number">
/// <item><description>Wrapped in an ASN.1 OCTET STRING — short-form (<c>04 10 ...</c>) or
/// long-form (<c>04 81 10 ...</c>) length. Used by the MS-Organization-Access TenantId/DeviceId
/// extensions and by the Intune <c>…5.6</c> / <c>…5.14</c> extensions.</description></item>
/// <item><description>A bare, unwrapped 16-byte payload. Used by the Intune DeviceId extension
/// <c>1.2.840.113556.5.4</c> — verified against a live Intune MDM device cert, whose raw value is
/// exactly 16 bytes with no <c>04 10</c> prefix.</description></item>
/// </list>
/// A valid OCTET-STRING wrapper around 16 bytes is at least 18 bytes long, so the two forms
/// never collide for well-formed input. A 16-byte value is always read as the bare form —
/// a truncated wrapper of exactly that length is indistinguishable from it.
/// Returns <c>null</c> on any structural mismatch — never throws.
/// <para>
/// The decoding itself lives in <see cref="MsDeviceCertificateOids.TryParseGuid"/> so the agent
/// and the backend's client-cert tenant binding share one implementation; this stays as the
/// agent-side nullable-returning façade.
/// </para>
/// </summary>
public static Guid? ParseGuidFromAsn1OctetString(byte[] raw)
{
if (raw == null || raw.Length < 2) return null;
// ASN.1 OCTET STRING tag.
if (raw[0] != 0x04) return null;
int contentStart;
int contentLen;
if (raw[1] < 0x80)
{
// short-form length
contentLen = raw[1];
contentStart = 2;
}
else
{
// long-form length: lower 7 bits = number of following length bytes.
int numLenBytes = raw[1] & 0x7F;
if (numLenBytes < 1 || numLenBytes > 4 || raw.Length < 2 + numLenBytes) return null;
contentLen = 0;
for (int i = 0; i < numLenBytes; i++)
contentLen = (contentLen << 8) | raw[2 + i];
contentStart = 2 + numLenBytes;
}
if (contentLen != 16 || contentStart + 16 > raw.Length) return null;
var guidBytes = new byte[16];
Buffer.BlockCopy(raw, contentStart, guidBytes, 0, 16);
try { return new Guid(guidBytes); }
catch { return null; }
}
public static Guid? ParseGuidFromExtension(byte[] raw) =>
MsDeviceCertificateOids.TryParseGuid(raw, out var value) ? value : (Guid?)null;
}
}
@@ -1,5 +1,4 @@
using System;
using System.Net.NetworkInformation;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
@@ -165,29 +164,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
=> Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
/// <summary>
/// Boot-time NIC grace (same pattern as the emergency-break reporter): polls the cheap
/// link-level signal once per second for at most <see cref="NetworkLinkWaitMax"/>. It
/// cannot prove backend reachability — the retry loop handles that — it only keeps the
/// first attempts from being burned into a link that is provably still down. Probe
/// Boot-time NIC grace before the first registration attempt — shared polling loop in
/// <see cref="NetworkLinkWait"/>, bounded by <see cref="NetworkLinkWaitMax"/>. Probe
/// errors end the wait, never the registration.
/// </summary>
internal static async Task WaitForNetworkLinkAsync(AgentLogger logger)
{
if (NetworkInterface.GetIsNetworkAvailable()) return;
logger?.Info($"Session registration: no network link yet — waiting up to {NetworkLinkWaitMax.TotalSeconds:F0}s.");
var deadline = DateTime.UtcNow + NetworkLinkWaitMax;
while (DateTime.UtcNow < deadline)
{
await Task.Delay(1000).ConfigureAwait(false);
if (NetworkInterface.GetIsNetworkAvailable())
{
logger?.Info("Session registration: network link is up.");
return;
}
}
logger?.Warning($"Session registration: still no network link after {NetworkLinkWaitMax.TotalSeconds:F0}s — attempting anyway.");
}
internal static Task WaitForNetworkLinkAsync(AgentLogger logger)
=> NetworkLinkWait.WaitAsync(logger, NetworkLinkWaitMax, "Session registration");
private static SessionRegistration BuildRegistration(
AgentConfiguration agentConfig,
@@ -274,6 +274,15 @@ namespace AutopilotMonitor.Agent.V2
{
return registration.ExitCode;
}
// Phase 6.5 (config self-heal): when the Phase 4 fetch ran before the network link
// was up, registration has just proven the backend reachable — retry the fetch once
// so the session runs on the live tenant config instead of cache/defaults.
// See AgentRuntimeConfig.TryRecoverAfterRegistration for what is deliberately
// NOT re-run here (kill-signal check, endpoint migration).
Runtime.AgentRuntimeConfig.TryRecoverAfterRegistration(
bootstrap.AgentConfig, auth, runtimeConfig, GetAgentVersion(), consoleMode, logger);
// Phase 7+8 (orchestrator + shutdown lifecycle + onIngressReady wiring +
// initial signal posts + analyzer/probes startup + shutdown.Wait + finally)
// is encapsulated in AgentRuntimeHost. See Runtime/AgentRuntimeHost.cs.
@@ -69,6 +69,63 @@ namespace AutopilotMonitor.Agent.V2.Runtime
}
}
var configMergeResult = ApplyFetchedConfig(agentConfig, remoteConfig, auth, agentVersion, consoleMode, logger);
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
}
/// <summary>
/// Phase 6.5 — config self-heal. When the Phase 4 fetch ran before the network link was
/// up (agent relaunched at boot, Wi-Fi still associating — tenant aebdce78 audit 2026-08-24:
/// ~50% of sessions ran on ConfigVersion=0 exactly this way), a successful session
/// registration has just proven the backend reachable. Retry the fetch once and apply
/// the live tenant config instead of stranding the whole session on cache/defaults.
/// <para>
/// Deliberately NOT re-run here: the kill-signal check (post-registration kills arrive
/// via the telemetry-channel control signals on every upload response) and endpoint
/// migration (registration already succeeded against the current base URL; a migration
/// signal is honoured at the next agent start). Never throws — recovery is strictly
/// best-effort on top of an already-degraded-but-working fallback.
/// </para>
/// </summary>
public static void TryRecoverAfterRegistration(
AgentConfiguration agentConfig,
BackendAuthBundle auth,
RuntimeConfigBundle bundle,
string agentVersion,
bool consoleMode,
AgentLogger logger)
{
try
{
var service = bundle?.RemoteConfigService;
if (service == null) return;
var recovered = service.TryRecoverConfigAsync().GetAwaiter().GetResult();
if (recovered == null) return;
var mergeResult = ApplyFetchedConfig(agentConfig, recovered, auth, agentVersion, consoleMode, logger);
bundle.ApplyRecovered(recovered, mergeResult);
}
catch (Exception ex)
{
logger.Warning($"Post-registration config recovery failed: {ex.Message}");
}
}
/// <summary>
/// Applies a live-fetched config to the runtime: merge, tracker ceilings, log level,
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path
/// and the post-registration recovery path so both apply identical semantics.
/// </summary>
private static RemoteConfigMergeResult ApplyFetchedConfig(
AgentConfiguration agentConfig,
AgentConfigResponse remoteConfig,
BackendAuthBundle auth,
string agentVersion,
bool consoleMode,
AgentLogger logger)
{
// Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
// downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
// DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
@@ -91,7 +148,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
TryCleanupBootstrapConfig(agentConfig, agentVersion, logger);
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
return configMergeResult;
}
/// <summary>
@@ -175,10 +232,21 @@ namespace AutopilotMonitor.Agent.V2.Runtime
internal sealed class RuntimeConfigBundle
{
public RemoteConfigService RemoteConfigService { get; }
public AgentConfigResponse RemoteConfig { get; }
public RemoteConfigMergeResult MergeResult { get; }
public AgentConfigResponse RemoteConfig { get; private set; }
public RemoteConfigMergeResult MergeResult { get; private set; }
public string PendingMigrationTarget { get; }
/// <summary>
/// Swaps in the config recovered by <see cref="AgentRuntimeConfig.TryRecoverAfterRegistration"/>
/// so Phase 7+8 (orchestrator, collectors, gather rules, IME patterns, the
/// unrestricted-mode audit) run on the live tenant config instead of the fallback.
/// </summary>
public void ApplyRecovered(AgentConfigResponse recoveredConfig, RemoteConfigMergeResult mergeResult)
{
RemoteConfig = recoveredConfig;
MergeResult = mergeResult;
}
public RuntimeConfigBundle(
RemoteConfigService remoteConfigService,
AgentConfigResponse remoteConfig,
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// prior to this, ConfigVersion=0 was only deducible by inferring from
// the absence of tenant-controlled knobs in the DataJson.
var outcome = remoteConfigService?.LastFetchOutcome ?? RemoteConfigFetchOutcome.NotAttempted;
var recovered = remoteConfigService?.RecoveredAfterFetchFailure == true;
var configVersion = remoteConfigService?.CurrentConfig?.ConfigVersion ?? 0;
var data = new Dictionary<string, object>
@@ -66,8 +67,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
// remoteConfigFetched answers "is the agent running on a live-fetched
// config" — true also when the post-registration recovery replaced a
// failed startup fetch (remoteConfigOutcome then still names the startup
// failure; remoteConfigRecovered marks the heal).
{ "configVersion", configVersion },
{ "remoteConfigFetched", outcome == RemoteConfigFetchOutcome.Succeeded },
{ "remoteConfigFetched", outcome == RemoteConfigFetchOutcome.Succeeded || recovered },
{ "remoteConfigOutcome", outcome.ToString() },
// WinRT OOBE state sampled at this start. Together with previousExitType
// this makes late starts provable: "completed" on a first_run means OOBE
@@ -75,6 +80,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{ "oobeStateAtAgentStart", OobeStateReader.Read() },
};
if (recovered)
data["remoteConfigRecovered"] = true;
if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType))
data["previousCrashException"] = previousExit.CrashExceptionType;
@@ -135,12 +143,25 @@ namespace AutopilotMonitor.Agent.V2.Runtime
? $"Remote config fetch failed after {remoteConfigService.LastFetchAttempts} attempt(s); using cached config."
: $"Remote config fetch failed after {remoteConfigService.LastFetchAttempts} attempt(s); running with built-in defaults (ConfigVersion=0).";
// Post-registration recovery (Phase 6.5) replaced the fallback with a live
// fetch before this emitter ran: keep the failure details (the network-late
// condition is worth surfacing) but mark the heal and demote to Info — the
// session is NOT degraded.
var recovered = remoteConfigService.RecoveredAfterFetchFailure;
if (recovered)
{
var recoveredVersion = remoteConfigService.CurrentConfig?.ConfigVersion ?? 0;
data["recovered"] = true;
data["recoveredConfigVersion"] = recoveredVersion;
msg += $" Recovered after session registration (ConfigVersion={recoveredVersion}).";
}
post.Emit(new EnrollmentEvent
{
SessionId = agentConfig.SessionId,
TenantId = agentConfig.TenantId,
EventType = SharedConstants.EventTypes.RemoteConfigFetchFailed,
Severity = EventSeverity.Warning,
Severity = recovered ? EventSeverity.Info : EventSeverity.Warning,
Source = "Agent",
Phase = EnrollmentPhase.Unknown,
Message = msg,
@@ -0,0 +1,330 @@
using System.Net;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Security;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// CERT-TENANT-BINDING-SHADOW — stage 1 coverage.
///
/// The agent authenticates with the Intune MDM device certificate, whose chain is pinned to
/// Microsoft's Intune roots. Those roots are shared by every Intune tenant, so a valid chain alone
/// does not prove the caller belongs to the tenant it claims. The CA stamps the Entra tenant id
/// into OID 1.2.840.113556.5.14, which lets the backend bind the two.
///
/// These tests pin three things:
/// - the tenant id is actually extracted from a REAL field certificate (not a synthetic fixture),
/// - the pure comparison covers every outcome the telemetry can report,
/// - shadow mode observes without deciding: a cross-tenant certificate must still pass the
/// certificate stage in stage 1, or we would be enforcing before we measured.
/// </summary>
public class CertTenantBindingTests
{
private const string DeviceSamplePem = "device-cert-sample.pem";
/// <summary>
/// Tenant stamped into <c>device-cert-sample.pem</c> by the Intune MDM Device CA. The sample is
/// a public-key-only cert from an internal test device; the GUID is the test tenant's own id.
/// </summary>
private static readonly Guid SampleCertTenantId = Guid.Parse("b54dc1af-5320-4f60-b5d4-821e0cf2a359");
private static string SampleCertBase64()
{
var assemblyDir = Path.GetDirectoryName(typeof(CertTenantBindingTests).Assembly.Location)!;
var pem = File.ReadAllText(Path.Combine(assemblyDir, DeviceSamplePem));
using var cert = X509Certificate2.CreateFromPem(pem);
return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
}
// ---------------------------------------------------------------- extraction
[Fact]
public void ValidateCertificate_RealIntuneCert_ExtractsEntraTenantId()
{
// The load-bearing test for the whole feature: if Microsoft ever stops stamping OID
// 1.2.840.113556.5.14, or changes its encoding, enforcement would lock every device out.
var result = CertificateValidator.ValidateCertificate(SampleCertBase64());
Assert.True(result.IsValid, $"Real device cert rejected: {result.ErrorMessage}");
Assert.Equal(CertTenantIdStatus.Present, result.CertTenantIdStatus);
Assert.Equal(SampleCertTenantId, result.CertTenantId);
}
[Fact]
public void ValidateCertificate_CachedResult_KeepsTenantIdAndFlagsCacheHit()
{
// FromCache is what samples the high-volume "Match" telemetry down to ~one line per cert
// per cache window, so it has to survive the cache and not corrupt the cached entry.
var b64 = SampleCertBase64();
_ = CertificateValidator.ValidateCertificate(b64);
var second = CertificateValidator.ValidateCertificate(b64);
var third = CertificateValidator.ValidateCertificate(b64);
Assert.True(second.FromCache);
Assert.Equal(SampleCertTenantId, second.CertTenantId);
// The cache-hit copy must not have mutated the shared cached instance.
Assert.True(third.FromCache);
Assert.Equal(SampleCertTenantId, third.CertTenantId);
Assert.True(third.IsValid);
}
// ---------------------------------------------------------------- encoding
[Fact]
public void TryParseGuid_PinsRealFieldEncodings()
{
// Exact bytes lifted from the sample cert. OID 5.14 nests the GUID in an OCTET STRING,
// OID 5.4 stores the 16 bytes bare — both shapes must decode, and both are little-endian.
var wrapped = Convert.FromHexString("0410afc14db52053604fb5d4821e0cf2a359");
var bare = Convert.FromHexString("6701bc076190b743b2430bb6aeda736e");
Assert.True(MsDeviceCertificateOids.TryParseGuid(wrapped, out var tenantId));
Assert.Equal(SampleCertTenantId, tenantId);
Assert.True(MsDeviceCertificateOids.TryParseGuid(bare, out var deviceId));
// The Intune device id is also the certificate's Subject CN.
Assert.Equal(Guid.Parse("07bc0167-9061-43b7-b243-0bb6aeda736e"), deviceId);
}
[Fact]
public void TryParseGuid_AccountIdOidIsNotTheTenantId()
{
// Guards the mistake this feature was nearly built on: OID 5.6 carries the Intune ACCOUNT
// id, a different GUID. Comparing it against the tenant id would reject every request.
var accountIdExt = Convert.FromHexString("04103d85dede74e26b409d0d46b6fe65b440");
Assert.True(MsDeviceCertificateOids.TryParseGuid(accountIdExt, out var accountId));
Assert.NotEqual(SampleCertTenantId, accountId);
}
[Theory]
[InlineData("")] // empty
[InlineData("0400")] // OCTET STRING, zero length
[InlineData("020102")] // not an OCTET STRING
[InlineData("0411afc14db52053604fb5d4821e0cf2a35900")] // wrapper claims 17 bytes
[InlineData("0410afc14db52053604fb5d4821e0cf2a3")] // wrapper claims 16, only 15 present
public void TryParseGuid_RejectsMalformed(string hex)
{
Assert.False(MsDeviceCertificateOids.TryParseGuid(Convert.FromHexString(hex), out _));
}
[Fact]
public void TryParseGuid_AnySixteenBytesDecodeAsBareGuid()
{
// Documents a deliberate limit rather than a gap: the bare form (OID 5.4) is exactly 16
// bytes with no tag, so a 16-byte input is indistinguishable from a truncated wrapper and
// is always read as a GUID. Harmless here — the value is only ever compared for equality
// against a known tenant id, never trusted as a claim in its own right.
Assert.True(MsDeviceCertificateOids.TryParseGuid(
Convert.FromHexString("0410afc14db52053604fb5d4821e0cf2"), out _));
}
[Fact]
public void TryParseGuid_RejectsNull()
{
Assert.False(MsDeviceCertificateOids.TryParseGuid(null!, out _));
}
// ---------------------------------------------------------------- comparison
[Fact]
public void Evaluate_MatchingTenant_ReturnsMatch()
{
var outcome = CertTenantBinding.Evaluate(
SampleCertTenantId, CertTenantIdStatus.Present, SampleCertTenantId.ToString());
Assert.Equal(CertTenantBinding.Outcome.Match, outcome);
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
[Fact]
public void Evaluate_MatchingTenant_IsCaseInsensitive()
{
// Tenant ids travel as strings in headers/routes and casing must not decide access.
var outcome = CertTenantBinding.Evaluate(
SampleCertTenantId, CertTenantIdStatus.Present, SampleCertTenantId.ToString().ToUpperInvariant());
Assert.Equal(CertTenantBinding.Outcome.Match, outcome);
}
[Fact]
public void Evaluate_ForeignTenantCert_ReturnsMismatch()
{
// The attack this feature exists for: a valid Intune cert from the attacker's own tenant,
// replayed against a victim tenant whose device serial the attacker knows.
var outcome = CertTenantBinding.Evaluate(
SampleCertTenantId, CertTenantIdStatus.Present, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.Equal(CertTenantBinding.Outcome.Mismatch, outcome);
Assert.True(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
[Theory]
[InlineData(CertTenantIdStatus.ExtensionMissing, CertTenantBinding.Outcome.ExtensionMissing)]
[InlineData(CertTenantIdStatus.Unparseable, CertTenantBinding.Outcome.Unparseable)]
public void Evaluate_UndecodableCert_ReportsWhyWithoutClaimingMismatch(
CertTenantIdStatus status, string expected)
{
// These must stay distinguishable from Mismatch: they measure rollout readiness
// (how many field certs can be enforced against), not an attack.
var outcome = CertTenantBinding.Evaluate(null, status, SampleCertTenantId.ToString());
Assert.Equal(expected, outcome);
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
[Fact]
public void Evaluate_PresentButNullGuid_FallsBackToExtensionMissing()
{
var outcome = CertTenantBinding.Evaluate(null, CertTenantIdStatus.Present, SampleCertTenantId.ToString());
Assert.Equal(CertTenantBinding.Outcome.ExtensionMissing, outcome);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-a-guid")]
public void Evaluate_NonGuidRequestTenant_IsReportedSeparately(string? requestedTenantId)
{
var outcome = CertTenantBinding.Evaluate(
SampleCertTenantId, CertTenantIdStatus.Present, requestedTenantId);
Assert.Equal(CertTenantBinding.Outcome.RequestTenantNotAGuid, outcome);
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
}
// ---------------------------------------------------------------- shadow mode
[Fact]
public async Task ValidateRequestAsync_ForeignTenantCert_IsObservedButNotRejected()
{
// Stage 1 contract: a cross-tenant certificate is logged and still passes the certificate
// stage. If this test ever fails, enforcement was switched on without the telemetry review.
var logger = new CapturingLogger();
var validator = BuildValidator(logger);
var result = await validator.ValidateRequestAsync(
BuildRequestWithCert(SampleCertBase64()),
tenantId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
// Deliberately accepted: stage 1 measures, it does not enforce. This assertion is the one
// that must be inverted (to a 403) when stage 2 turns enforcement on.
Assert.True(result.IsValid, $"Shadow mode changed the outcome: {result.ErrorMessage} / {result.Details}");
Assert.NotEqual(HttpStatusCode.Unauthorized, result.StatusCode);
var observed = Assert.Single(logger.Entries, e => e.Message.Contains("AgentCertTenantBinding"));
Assert.Equal(LogLevel.Warning, observed.Level);
Assert.Contains(CertTenantBinding.Outcome.Mismatch, observed.Message);
Assert.Contains("enforced=False", observed.Message);
Assert.Contains("wouldReject=True", observed.Message);
}
[Fact]
public async Task ValidateRequestAsync_MatchingTenantCert_IsAccepted()
{
var logger = new CapturingLogger();
var validator = BuildValidator(logger);
var result = await validator.ValidateRequestAsync(
BuildRequestWithCert(SampleCertBase64()),
tenantId: SampleCertTenantId.ToString());
Assert.True(result.IsValid, $"Matching-tenant request rejected: {result.ErrorMessage}");
// Match is only logged on a fresh validation; the cache is process-wide and shared with the
// other tests here, so assert the outcome only when a line was actually emitted.
var observed = logger.Entries.FirstOrDefault(e => e.Message.Contains("AgentCertTenantBinding"));
if (observed != null)
{
Assert.Equal(LogLevel.Information, observed.Level);
Assert.Contains(CertTenantBinding.Outcome.Match, observed.Message);
}
}
// ---------------------------------------------------------------- harness
private static SecurityValidator BuildValidator(ILogger logger)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
// AllowInsecureAgentRequests clears the "at least one device validator" gate so the request
// reaches — and passes — the certificate stage without needing Graph mocks.
var config = new TenantConfiguration
{
AllowInsecureAgentRequests = true,
ManufacturerWhitelist = "*",
ModelWhitelist = "*",
};
var configServiceMock = new Mock<TenantConfigurationService>(
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
{ CallBase = false };
configServiceMock
.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((config, true));
var adminConfigServiceMock = new Mock<AdminConfigurationService>(
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
{ CallBase = false };
adminConfigServiceMock
.Setup(x => x.GetConfigurationAsync())
.ReturnsAsync(new AdminConfiguration());
return new SecurityValidator(
configService: configServiceMock.Object,
adminConfigService: adminConfigServiceMock.Object,
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!,
corporateIdentifierValidator: null!,
logger: logger,
bootstrapSessionService: null,
deviceAssociationValidator: null);
}
private static HttpRequestData BuildRequestWithCert(string certBase64)
{
var contextMock = new Mock<Microsoft.Azure.Functions.Worker.FunctionContext>();
var reqMock = new Mock<HttpRequestData>(contextMock.Object);
var headers = new HttpHeadersCollection
{
{ "X-ARR-ClientCert", certBase64 },
// The hardware whitelist stage runs after the certificate stage and rejects requests
// with no hardware headers, which would mask what these tests are actually asserting.
{ "X-Device-Manufacturer", "Contoso" },
{ "X-Device-Model", "TestBook" },
{ "X-Device-SerialNumber", "TESTSERIAL01" },
};
reqMock.SetupGet(r => r.Headers).Returns(headers);
reqMock.SetupGet(r => r.Url).Returns(new Uri("https://example.invalid/api/telemetry"));
return reqMock.Object;
}
private sealed record LogEntry(LogLevel Level, string Message);
private sealed class CapturingLogger : ILogger
{
public List<LogEntry> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add(new LogEntry(logLevel, formatter(state, exception)));
}
}
@@ -0,0 +1,81 @@
using System;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Compares the Entra TenantId stamped into the agent's mTLS client certificate against the
/// tenant the request claims to belong to.
/// <para>
/// Without this, a validated certificate only proves "issued by the Intune MDM Device CA to
/// some tenant" — the chain is pinned to Microsoft's roots, which every Intune tenant on the
/// planet shares. The tenant scoping comes solely from the downstream device validators, so an
/// attacker with their own Intune tenant and a known serial number of the victim tenant would
/// pass certificate validation. Binding the cert's tenant to the requested tenant closes that.
/// </para>
/// </summary>
/// <remarks>
/// SHADOW MODE (stage 1): the outcome is observed and logged only — it never changes an
/// authorization decision. Enforcement is stage 2, once telemetry shows how many field
/// certificates actually carry the extension. Grep marker for the stage-2 change:
/// <c>CERT-TENANT-BINDING-SHADOW</c>.
/// </remarks>
public static class CertTenantBinding
{
/// <summary>
/// Stable outcome codes emitted in the <c>AgentCertTenantBinding</c> structured log.
/// Keep these strings stable — they are queried by exact match in KQL
/// (<c>customDimensions.Outcome == "Mismatch"</c>).
/// </summary>
public static class Outcome
{
/// <summary>Certificate tenant equals the requested tenant — the expected case.</summary>
public const string Match = "Match";
/// <summary>Certificate belongs to a different tenant than the request claims.</summary>
public const string Mismatch = "Mismatch";
/// <summary>Certificate carries no tenant extension (older enrollment / unexpected CA).</summary>
public const string ExtensionMissing = "ExtensionMissing";
/// <summary>Tenant extension present but undecodable.</summary>
public const string Unparseable = "Unparseable";
/// <summary>Requested tenant id was not a GUID, so no meaningful comparison is possible.</summary>
public const string RequestTenantNotAGuid = "RequestTenantNotAGuid";
}
/// <summary>
/// Evaluates the binding. Pure — no logging, no I/O, no side effects.
/// </summary>
/// <param name="certTenantId">Tenant GUID decoded from the client certificate, if any.</param>
/// <param name="status">Whether the certificate carried a decodable tenant extension.</param>
/// <param name="requestedTenantId">The tenant id the request is scoped to.</param>
/// <returns>One of the <see cref="Outcome"/> codes.</returns>
public static string Evaluate(Guid? certTenantId, CertTenantIdStatus status, string? requestedTenantId)
{
switch (status)
{
case CertTenantIdStatus.ExtensionMissing:
return Outcome.ExtensionMissing;
case CertTenantIdStatus.Unparseable:
return Outcome.Unparseable;
}
if (certTenantId == null)
return Outcome.ExtensionMissing;
if (!Guid.TryParse(requestedTenantId, out var requested))
return Outcome.RequestTenantNotAGuid;
return certTenantId.Value == requested ? Outcome.Match : Outcome.Mismatch;
}
/// <summary>
/// Whether an outcome would block the request once stage-2 enforcement is switched on.
/// Nothing consumes this for authorization yet — it exists so the shadow telemetry can be
/// read as "would this have been rejected?" without re-deriving the rule at query time.
/// </summary>
public static bool WouldRejectUnderEnforcement(string outcome) =>
outcome == Outcome.Mismatch;
}
}
@@ -6,6 +6,7 @@ using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Shared.Security;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Security
@@ -138,7 +139,8 @@ namespace AutopilotMonitor.Functions.Security
if (cached.ExpiresAt > DateTime.UtcNow)
{
logger?.LogDebug($"Certificate validation cache HIT for thumbprint {thumbprint}");
return cached.Result;
// Copy so FromCache is per-call state and never mutates the shared cached instance.
return cached.Result.AsCacheHit();
}
else
{
@@ -253,6 +255,12 @@ namespace AutopilotMonitor.Functions.Security
};
}
// 4. Extract the Entra TenantId the CA stamped into the cert (OID 1.2.840.113556.5.14).
// Pure function of the cert, so it is cached alongside the validation result.
// Read only - the tenant binding it enables is evaluated (and currently only observed)
// by SecurityValidator; see CertTenantBinding.
var (certTenantId, certTenantIdStatus) = ExtractEntraTenantId(certificate);
// All checks passed - create and cache success result
logger?.LogInformation($"Certificate validated successfully: Thumbprint={thumbprint}");
var successResult = new CertificateValidationResult
@@ -260,7 +268,9 @@ namespace AutopilotMonitor.Functions.Security
IsValid = true,
Thumbprint = thumbprint,
Subject = certificate.Subject,
Issuer = certificate.Issuer
Issuer = certificate.Issuer,
CertTenantId = certTenantId,
CertTenantIdStatus = certTenantIdStatus
};
// Cache successful validation
@@ -288,6 +298,30 @@ namespace AutopilotMonitor.Functions.Security
}
}
/// <summary>
/// Reads the Entra TenantId the Intune MDM Device CA stamps into the client certificate
/// (OID <c>1.2.840.113556.5.14</c>). Never throws — a cert without the extension, or with an
/// undecodable one, yields the corresponding status instead of an error, because this value
/// is currently observed only and must never turn a valid cert into a rejected one.
/// </summary>
/// <remarks>
/// Deliberately NOT OID <c>1.2.840.113556.5.6</c>: that one carries the Intune <b>Account</b>
/// ID, which is a different GUID from the Entra tenant ID.
/// </remarks>
private static (Guid? TenantId, CertTenantIdStatus Status) ExtractEntraTenantId(X509Certificate2 certificate)
{
var ext = certificate.Extensions
.OfType<X509Extension>()
.FirstOrDefault(e => e.Oid?.Value == MsDeviceCertificateOids.IntuneCertEntraTenantIdOid);
if (ext?.RawData == null)
return (null, CertTenantIdStatus.ExtensionMissing);
return MsDeviceCertificateOids.TryParseGuid(ext.RawData, out var tenantId)
? (tenantId, CertTenantIdStatus.Present)
: (null, CertTenantIdStatus.Unparseable);
}
/// <summary>
/// Loads all certs whose embedded resource filename starts with the given prefix.
/// Multiple PEM blocks per file are supported (cross-signed bundles). Duplicates
@@ -378,5 +412,58 @@ namespace AutopilotMonitor.Functions.Security
/// Error message if validation failed
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Entra TenantId stamped into the certificate by the Intune MDM Device CA
/// (OID <c>1.2.840.113556.5.14</c>), or <c>null</c> when
/// <see cref="CertTenantIdStatus"/> is not <see cref="Security.CertTenantIdStatus.Present"/>.
/// </summary>
public Guid? CertTenantId { get; set; }
/// <summary>
/// Whether the certificate carried a decodable Entra TenantId extension.
/// </summary>
public CertTenantIdStatus CertTenantIdStatus { get; set; }
/// <summary>
/// True when this result was served from the 5-minute thumbprint cache rather than freshly
/// validated. Used to sample per-request observability down to roughly one line per
/// certificate per cache window.
/// </summary>
public bool FromCache { get; set; }
/// <summary>
/// Returns a copy of this result marked as a cache hit, so the shared cached instance is
/// never mutated by a caller.
/// </summary>
internal CertificateValidationResult AsCacheHit() => new()
{
IsValid = IsValid,
Thumbprint = Thumbprint,
Subject = Subject,
Issuer = Issuer,
ErrorMessage = ErrorMessage,
CertTenantId = CertTenantId,
CertTenantIdStatus = CertTenantIdStatus,
FromCache = true
};
}
/// <summary>
/// Whether a validated client certificate carried a decodable Entra TenantId extension.
/// </summary>
public enum CertTenantIdStatus
{
/// <summary>Certificate was not validated far enough to look, or validation failed.</summary>
NotEvaluated = 0,
/// <summary>Extension present and decoded into a GUID.</summary>
Present = 1,
/// <summary>Certificate carries no such extension (expected on older enrollments).</summary>
ExtensionMissing = 2,
/// <summary>Extension present but its value could not be decoded as a GUID.</summary>
Unparseable = 3
}
}
@@ -275,6 +275,12 @@ namespace AutopilotMonitor.Functions.Security
};
}
// 1b. CERT-TENANT-BINDING-SHADOW — observe only, never decide.
// The cert chain is pinned to Microsoft's Intune roots, which every Intune tenant shares,
// so a valid cert alone does not prove the caller belongs to THIS tenant. Stage 1 measures
// how reliably the certificate's own tenant stamp matches before stage 2 enforces it.
ObserveCertTenantBinding(certValidation, tenantId, req, sessionId);
// 2. Check rate limit (DoS protection)
// Effective limit = per-tenant override if set, otherwise the global default.
var rateLimitValue = await ResolveDeviceRateLimitAsync(config);
@@ -514,6 +520,71 @@ namespace AutopilotMonitor.Functions.Security
return false;
}
/// <summary>
/// CERT-TENANT-BINDING-SHADOW — stage 1 observability for the client-certificate tenant
/// binding. Evaluates whether the tenant stamped into the agent's certificate matches the
/// tenant the request claims, and logs the outcome. It deliberately returns nothing and
/// changes no state: enforcement is stage 2, after this telemetry shows the miss rate.
/// </summary>
/// <remarks>
/// Volume control: outcomes that need enforcement decisions (mismatch, missing or
/// undecodable extension) are always logged — they are expected to be rare and each one is
/// individually interesting. The bulk "Match" case is logged only when the certificate was
/// freshly validated rather than served from the 5-minute thumbprint cache, which samples it
/// down to roughly one line per certificate per cache window while keeping the ratio between
/// numerator and denominator meaningful (both are counted per cert-window, not per request).
/// </remarks>
private void ObserveCertTenantBinding(
CertificateValidationResult certValidation,
string tenantId,
HttpRequestData req,
string? sessionId)
{
try
{
var outcome = CertTenantBinding.Evaluate(
certValidation.CertTenantId,
certValidation.CertTenantIdStatus,
tenantId);
if (outcome == CertTenantBinding.Outcome.Match && certValidation.FromCache)
return;
var agentVersion = req.Headers.Contains("X-Agent-Version")
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() ?? "n/a"
: "n/a";
// Only the cert's tenant is logged, never the raw certificate. On a mismatch this is
// the attacker-controlled side of the comparison and the single most useful field.
const string template =
"AgentCertTenantBinding outcome={Outcome} enforced={Enforced} wouldReject={WouldReject} "
+ "tenant={TenantId} certTenant={CertTenantId} status={Status} thumbprint={Thumbprint} "
+ "session={SessionId} ver={AgentVersion}";
var certTenant = certValidation.CertTenantId?.ToString() ?? "n/a";
var wouldReject = CertTenantBinding.WouldRejectUnderEnforcement(outcome);
if (outcome == CertTenantBinding.Outcome.Match)
{
_logger.LogInformation(template,
outcome, false, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
certValidation.Thumbprint ?? "n/a", sessionId ?? "n/a", agentVersion);
}
else
{
_logger.LogWarning(template,
outcome, false, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
certValidation.Thumbprint ?? "n/a", sessionId ?? "n/a", agentVersion);
}
}
catch (Exception ex)
{
// Shadow-mode observability must never be able to fail a request that would
// otherwise have been accepted.
_logger.LogWarning(ex, "AgentCertTenantBinding observation failed for tenant {TenantId}", tenantId);
}
}
/// <summary>
/// Emits a structured warning with request-side context (path, hardware headers, agent
/// version) at every fail-fast rejection point. Pairs with the cert-side warnings emitted
@@ -0,0 +1,130 @@
using System;
namespace AutopilotMonitor.Shared.Security
{
/// <summary>
/// Microsoft device-certificate extension OIDs and the shared ASN.1 decoding for the
/// GUID-bearing ones. Canonical for both the agent (local identity probing) and the
/// backend (client-cert tenant binding) so the two cannot drift apart.
/// </summary>
/// <remarks>
/// Two certificate families carry these, and they are NOT interchangeable:
/// <list type="bullet">
/// <item><description>
/// <b>MS-Organization-Access</b> (Entra/AAD-Join, <c>1.2.840.113556.1.5.284.*</c>) — installed
/// on AAD-join. The agent reads it locally to learn its own TenantId; it is never used for
/// backend authentication.
/// </description></item>
/// <item><description>
/// <b>Microsoft Intune MDM Device CA</b> (<c>1.2.840.113556.5.*</c>) — this is the cert the
/// agent presents as the mTLS client certificate, so it is the only one whose contents the
/// backend can trust as proof of possession.
/// </description></item>
/// </list>
/// Values verified against a real field certificate (issued 2026-05-08) — see
/// <c>Functions.Tests/device-cert-sample.pem</c>. Note that the Intune <b>Account</b> ID
/// (<c>5.6</c>) is a DIFFERENT GUID from the Entra tenant ID (<c>5.14</c>); they must never
/// be compared against each other.
/// </remarks>
public static class MsDeviceCertificateOids
{
/// <summary>Issuer CN of the Intune MDM device certificate used for mTLS client auth.</summary>
public const string IntuneMdmIssuer = "CN=Microsoft Intune MDM Device CA";
/// <summary>
/// Intune MDM device certificate → Entra (Azure AD) TenantId. Encoded as a nested
/// ASN.1 OCTET STRING wrapping a 16-byte little-endian GUID.
/// </summary>
public const string IntuneCertEntraTenantIdOid = "1.2.840.113556.5.14";
/// <summary>
/// Intune MDM device certificate → Intune <b>Account</b> ID. Distinct from the Entra
/// tenant ID — do not use it for tenant comparison.
/// </summary>
public const string IntuneCertAccountIdOid = "1.2.840.113556.5.6";
/// <summary>
/// Intune MDM device certificate → Intune device ID (matches the certificate's Subject CN).
/// Unlike the other two this extension holds the 16 GUID bytes <b>raw</b>, without the
/// nested OCTET STRING wrapper — decode it with <see cref="TryParseGuid"/>, which accepts
/// both shapes.
/// </summary>
public const string IntuneCertDeviceIdOid = "1.2.840.113556.5.4";
/// <summary>MS-Organization-Access (Entra join) certificate → TenantId.</summary>
public const string EntraJoinCertTenantIdOid = "1.2.840.113556.1.5.284.5";
/// <summary>MS-Organization-Access (Entra join) certificate → DeviceId.</summary>
public const string EntraJoinCertDeviceIdOid = "1.2.840.113556.1.5.284.2";
/// <summary>
/// Decodes a 16-byte Microsoft binary GUID out of an X.509 extension value
/// (<c>X509Extension.RawData</c>).
/// <para>
/// Accepts both encodings seen in the wild: the GUID wrapped in a nested ASN.1 OCTET
/// STRING (short form <c>04 10 …</c> and long form <c>04 81 10 …</c>), and the 16 raw
/// bytes with no wrapper at all (Intune OID <c>5.4</c>).
/// </para>
/// The byte order is Microsoft's — little-endian for the first three groups, i.e. exactly
/// what <see cref="Guid(byte[])"/> expects.
/// </summary>
/// <returns><c>true</c> and the decoded GUID, or <c>false</c> on any structural mismatch. Never throws.</returns>
public static bool TryParseGuid(byte[] raw, out Guid value)
{
value = Guid.Empty;
if (raw == null) return false;
// Unwrapped form: exactly the 16 GUID bytes (Intune OID 5.4).
if (raw.Length == 16)
return TryFromBytes(raw, 0, out value);
if (raw.Length < 2) return false;
// ASN.1 OCTET STRING tag.
if (raw[0] != 0x04) return false;
int contentStart;
int contentLen;
if (raw[1] < 0x80)
{
// short-form length
contentLen = raw[1];
contentStart = 2;
}
else
{
// long-form length: lower 7 bits = number of following length bytes.
int numLenBytes = raw[1] & 0x7F;
if (numLenBytes < 1 || numLenBytes > 4 || raw.Length < 2 + numLenBytes) return false;
contentLen = 0;
for (int i = 0; i < numLenBytes; i++)
contentLen = (contentLen << 8) | raw[2 + i];
contentStart = 2 + numLenBytes;
}
if (contentLen != 16 || contentStart + 16 > raw.Length) return false;
return TryFromBytes(raw, contentStart, out value);
}
private static bool TryFromBytes(byte[] source, int offset, out Guid value)
{
value = Guid.Empty;
var guidBytes = new byte[16];
Buffer.BlockCopy(source, offset, guidBytes, 0, 16);
try
{
value = new Guid(guidBytes);
return true;
}
catch
{
return false;
}
}
}
}