Compare commits

...

9 Commits

Author SHA1 Message Date
Oliver Kieselbach 4b74995e79 shared: drop Private Preview wording from ContactEmail seeding comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:34:16 +02:00
Oliver Kieselbach 5d8d9e8d40 web: show tenant contact email in config report tenant status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 17:58:47 +02:00
Oliver Kieselbach 25bd2c175e web: bump nanoid to 3.3.18 and js-yaml to 4.3.1 (Dependabot high alerts)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:00:59 +02:00
Oliver Kieselbach 830edb98e7 agent: suppress distress dispatch when the auth failure came from a dead endpoint
Follow-up to 13dd6723: a platform-403 (stopped/retired app, HTML error
page) now skips the DeviceNotRegistered/AuthCertificateRejected distress
report - it would be sent to the very endpoint that just answered with a
platform page, and its classification would be wrong. Counting and the
shutdown thresholds are unchanged, so a dead endpoint still drives the
agent to its soft shutdown instead of retrying forever.

The one-report-per-streak gate moves from "count == 1" to a streak-scoped
flag: an endpoint-unavailable failure no longer consumes the streak's
report slot, so a later genuine 403 from a live backend still dispatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:42:43 +02:00
Oliver Kieselbach 1ebc2675b1 gather-rules: WQL property projections, portal enable-gating, author from JWT
- WMI guard accepts SELECT <property-list> FROM <class> for any class already
  allowed via SELECT * (strict data subset); same algorithm in agent, portal
  validator, and MCP validate_rule (incl. trim fix); guardrails.json unchanged
- Portal refuses to enable custom rules with not-allowed targets: create lands
  disabled, invalid edit force-disables, "Blocked on devices" badge, GitHub-issue
  hint for allowlist requests; agent stays the enforcement boundary
- Rule Author is stamped from the creator's token (display name > UPN) at create
  and preserved through every update, incl. cross-tenant GA edits
- bumpVersion keeps the segment count ("1.0.0" -> "1.1.0")

Customer feedback: SELECT BatteryStatus FROM Win32_Battery + on_change was
impossible because SELECT * re-emits on every charge-percent fluctuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:41:14 +02:00
Oliver Kieselbach 283c2ed214 web,ci: make Grant-AutopilotMonitorAddOn.ps1 actually downloadable
The docs page and the admin UI both told customers to run
Grant-AutopilotMonitorAddOn.ps1, but the script was never published
anywhere -- it only exists in this private repo. Publish it on the
download alias next to the agent artifacts and reference it:

- build-agent.yml: upload the script on publish_as_stable runs
- admin UI: copied grant command now starts with an irm -OutFile
  download line; prose links the script directly
- utils/config.ts: new AGENT_DOWNLOAD_URL registry constant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:33:59 +02:00
Oliver Kieselbach 13dd6723da agent: classify platform-403 as endpoint-unavailable + self-update retry after await-enrollment
Field case sits-d.cloud 2026-08-09: a pinned pre-cutover agent (2.0.1282)
hit the stopped legacy Function App. Azure answers with a platform-level
403 HTML page, which the agent reported as "device is not authorized" -
support chases a non-existent authorization problem, and the stranded
build never rescues itself because the startup self-update check runs
pre-enrollment where OOBE has no network.

Two fixes:
- BackendApiClient reads the 401/403 body: an HTML response cannot come
  from our backend, so it is classified as endpoint stopped/retired/
  proxied (BackendAuthException.EndpointUnavailable, page title in the
  message) instead of a device-authorization verdict.
- AgentBootstrap retries the self-update check once after the MDM
  certificate appears (proof of connectivity) when the startup check
  never reached the manifest, with relaxed timeouts (10s check / 60s
  download). An applied update restarts into a current binary BEFORE
  session registration hits a retired endpoint. Marker files are also
  written on this trigger so agent_version_check reflects the latest
  verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:32:10 +02:00
Oliver Kieselbach cb6d80a76e agent: transliterate typographic punctuation to ASCII in log output
Agent logs are written UTF-8 without BOM; cp1252-guessing viewers
(Outlook web attachment preview) render em dashes as mojibake. Map
em/en dash, arrow, ellipsis, curly quotes and NBSP to ASCII at the
central sanitize step so every message is covered without touching
call sites. Genuine non-ASCII payload (paths, names) stays verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:41:44 +02:00
Oliver Kieselbach 024c90d68f ci: cache the MiniLM embedding model so MCP tests stop 429ing on huggingface
The vector-search tests tokenize with the real Xenova/all-MiniLM-L6-v2
model. transformers.js caches it under node_modules by default, which npm ci
recreates on every run — so each CI run re-downloaded the tokenizer/weights
from huggingface.co, and a rate-limited response (429) failed the whole MCP
job (run 31252869254; unrelated to the commit under test). Persist the model
via actions/cache and point HF_CACHE_DIR (already honored by
vector-search-provider.ts) at it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:37:13 +02:00
37 changed files with 992 additions and 112 deletions
+6
View File
@@ -270,6 +270,12 @@ jobs:
if (Test-Path "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1") {
Send-Blob -Local "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1" -BlobName "Test-ShouldBootstrapAgent.ps1" -ContentType "text/plain; charset=utf-8"
}
# Customer-side Graph add-on grant script -- docs and admin UI link
# https://download.autopilotmonitor.com/agent/Grant-AutopilotMonitorAddOn.ps1
if (Test-Path "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1") {
Send-Blob -Local "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1" -BlobName "Grant-AutopilotMonitorAddOn.ps1" -ContentType "text/plain; charset=utf-8"
}
}
# Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle
+14
View File
@@ -107,8 +107,22 @@ jobs:
- name: Typecheck
run: npx tsc --noEmit
# The vector-search tests tokenize with the real all-MiniLM-L6-v2 model
# (~23 MB tokenizer + ONNX weights). transformers.js caches under
# node_modules by default, which npm ci recreates — so every CI run
# re-downloaded from huggingface.co and rate-limits (429) failed the job
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
# is honored by vector-search-provider.ts.
- name: Cache embedding model
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.hf-cache
key: hf-model-Xenova-all-MiniLM-L6-v2
- name: Tests
run: npm test
env:
HF_CACHE_DIR: ${{ github.workspace }}/.hf-cache
guardrails-in-sync:
name: Generated guardrails match rules/guardrails.json
+3 -1
View File
@@ -1,6 +1,8 @@
# Log
## 2026-08-08
## 2026-08-10
* **Update**: `rules/gather-rule-guardrails.md` — WMI matcher generalized from literal prefix matching to shape parsing: `SELECT <* | property-list> FROM <class>` is allowed when the class is derived from the existing `SELECT * FROM <class>` entries (a projection is a strict subset; motivated by `SELECT BatteryStatus FROM Win32_Battery` + `on_change` battery polling that `SELECT *` defeats via fluctuating charge %). Same algorithm mirrored agent/portal/MCP; guardrails.json unchanged. Portal now also gates enabling on the client-side validation (invalid-target custom rules create disabled, cannot be toggled on, force-disable on invalid edit, "Blocked on devices" badge) — UX only, the agent guard stays the boundary. Gather-rule Author is now stamped from the creator's JWT (display name → UPN) at create and immutable on update.
* **New**: `agent/continue-anyway-observation.md` — Continue-Anyway observation mode (`EnableEspContinueAnywayObservation`, ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles are defanged into a 60-min observation window (Classic/Unknown, non-pre-prov only); real-user desktop + Hello gate complete the session as Succeeded + `EspSoftFailure`/`CompletionSource` (amber "with issues" badge + detail banner), category recovery resolves clean, expiry un-defangs to the original `esp_terminal_failure`. Registered in the Agent index section. Related backend fix (no doc): the auto-analyze enqueue now carries a 30 s `visibilityTimeout` so rule evaluation no longer races the agent's terminal flush (ANALYZE-ESP-004's `{{appName}}` placeholder).
+12 -3
View File
@@ -7,14 +7,14 @@ description: >-
guard rather than trusting rule.Target.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
tags: [gather-rules, security, guardrails, agent]
timestamp: 2026-07-22
timestamp: 2026-08-10
---
# Concept
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, and that the rule lands in the caller's own tenant partition. Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
This is the security boundary. The portal's `ValidationIndicator` is a convenience: it does not block submit, and the API is reachable directly.
This is the security boundary. The portal's `ValidationIndicator` is a convenience — the API stays reachable directly and the backend stays validation-free — but since 2026-08 the portal additionally **gates enabling** on the same client-side validation: a custom rule whose target fails validation cannot be toggled on, is created `enabled=false`, and an edit that makes the target invalid force-disables the rule (`targetBlocked` in `app/gather-rules/types.ts`). An enabled rule with an invalid target shows a "Blocked on devices" badge. All of this is UX, not security — the agent guard remains the enforcement point.
# Schema
@@ -27,7 +27,7 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
| --- | --- | --- |
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
| `IsFilePathAllowed` | `filePrefixes` | `Path.GetFullPath` first, then prefix + boundary |
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | prefix, boundary whitespace or end |
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | `SELECT <* \| property-list> FROM <class>`; class = case-insensitive equality against classes derived from the `SELECT * FROM <class>` entries, boundary whitespace or end (trailing `WHERE` allowed). Non-canonical entries (none today) fall back to literal prefix + boundary |
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
@@ -71,6 +71,15 @@ Two collectors were added to this contract on 2026-07-22 after shipping without
remote config. The operator using it is already a local admin, so it relaxes the
allowlist exactly the way unrestricted mode does — hard blocks still apply.
**WMI property projections (2026-08):** `SELECT BatteryStatus FROM Win32_Battery` is
allowed because `SELECT * FROM Win32_Battery` is — a projection exposes a strict subset
of the allowed class. The matcher parses the query shape instead of literal-prefix
matching; the allowed-class set is derived at load time from the existing
`wmiQueryPrefixes` entries, so `rules/guardrails.json` needs no new key. The same
algorithm is mirrored in the portal (`utils/guardValidation.ts`) and the MCP server
(`rule-validation.ts`); agents older than the change still block projections with a
`security_warning` until they self-update.
# Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs`
@@ -94,6 +94,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
Assert.Equal("older-002\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log")));
}
[Fact]
public void Log_transliterates_typographic_punctuation_to_ascii()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
// The log file is UTF-8 without BOM; cp1252-guessing viewers (Outlook web
// preview) render em dashes as mojibake. Our own punctuation must come out
// as plain ASCII, while genuine non-ASCII payload stays untouched.
logger.Info("check failed \u2014 range 1\u20132 \u2192 done\u2026 \u2018a\u2019 \u201Cb\u201D nb\u00A0sp f\u00FCr M\u00FCller");
var text = File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + ".log"));
Assert.Contains("check failed - range 1-2 -> done... 'a' \"b\" nb sp f\u00FCr M\u00FCller", text);
}
[Fact]
public void Log_swallows_io_errors_silently()
{
@@ -308,9 +308,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
[InlineData("SELECT * FROM Win32_UserAccount")]
[InlineData("SELECT * FROM Win32_ShadowCopy")]
[InlineData("SELECT * FROM Win32_OperatingSystemExtra")] // prefix spoofing
[InlineData("SELECT Name FROM Win32_Process")] // projection of a disallowed class
[InlineData("SELECT BatteryStatus FROM Win32_BatteryX")] // class spoofing via projection
public void WmiCollector_OffAllowlistQuery_IsBlockedBeforeTheQueryRuns(string query)
=> AssertBlocked(new WmiCollector().Execute(Rule("wmi", query), Context()), query);
// -------------------------------------------------------------------
// WMI query matcher: "SELECT <* | property-list> FROM <allowed class>"
//
// Guard-level (no WMI touched): a property projection of an allowed class
// exposes a strict subset of the already-allowed SELECT *, so it is admitted.
// -------------------------------------------------------------------
[Theory]
[InlineData("SELECT * FROM Win32_Battery")] // legacy form unchanged
[InlineData("SELECT * FROM Win32_BIOS WHERE PrimaryBIOS = TRUE")] // legacy + WHERE
[InlineData("SELECT BatteryStatus FROM Win32_Battery")]
[InlineData("SELECT BatteryStatus, EstimatedChargeRemaining FROM Win32_Battery")]
[InlineData("select batterystatus from win32_battery")] // case-insensitive
[InlineData("SELECT\tBatteryStatus\tFROM\tWin32_Battery")] // tabs as separators
[InlineData(" SELECT BatteryStatus FROM Win32_Battery ")] // surrounding whitespace
[InlineData("SELECT BatteryStatus,EstimatedChargeRemaining FROM Win32_Battery")] // no space after comma
[InlineData("SELECT Status FROM Win32_Battery WHERE BatteryStatus = 1")] // projection + WHERE
public void WmiGuard_ProjectionOfAllowedClass_IsAllowed(string query)
=> Assert.True(GatherRuleGuards.IsWmiQueryAllowed(query));
[Theory]
[InlineData("SELECT Name FROM Win32_Process")] // class not on the allowlist
[InlineData("SELECT BatteryStatus FROM Win32_BatteryX")] // class spoofing
[InlineData("SELECT * FROM Win32_BatteryX")]
[InlineData("SELECT *, Name FROM Win32_BIOS")] // star mixed into a property list
[InlineData("SELECT FROM Win32_BIOS")] // empty property list
[InlineData("SELECT BatteryStatus FROM")] // missing class
[InlineData("SELECT BatteryStatus FROM Win32_Battery;DROP")] // non-whitespace boundary after class
[InlineData("SELECT BatteryStatus, FROM Win32_Battery")] // trailing comma
[InlineData("")]
public void WmiGuard_MalformedOrDisallowedQuery_IsBlocked(string query)
=> Assert.False(GatherRuleGuards.IsWmiQueryAllowed(query));
[Fact]
public void WmiGuard_NullQuery_IsBlocked()
=> Assert.False(GatherRuleGuards.IsWmiQueryAllowed(null));
[Fact]
public void WmiGuard_UnrestrictedMode_AllowsAnyQuery()
=> Assert.True(GatherRuleGuards.IsWmiQueryAllowed("SELECT Name FROM Win32_Process", unrestrictedMode: true));
[Fact]
public void JsonCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
{
@@ -260,6 +260,60 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Contains("agent/config", fakeDistress.Calls[0].Message);
}
[Fact]
public void RecordFailure_endpoint_unavailable_suppresses_distress_but_still_counts()
{
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
var fakeDistress = new FakeDistressReporter();
var tracker = new AuthFailureTracker(maxFailures: 2, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
int events = 0;
tracker.ThresholdExceeded += (_, _) => events++;
// Platform-403s from a stopped endpoint: a distress report cannot reach it and the
// DeviceNotRegistered classification would be wrong — nothing must be dispatched.
tracker.RecordFailure(403, "agent/config", endpointUnavailable: true);
tracker.RecordFailure(403, "agent/register-session", endpointUnavailable: true);
Assert.Empty(fakeDistress.Calls);
// The shutdown thresholds still apply — a dead endpoint must not retry forever.
Assert.Equal(2, tracker.ConsecutiveFailures);
Assert.Equal(1, events);
}
[Fact]
public void RecordFailure_endpoint_unavailable_does_not_consume_streak_report_slot()
{
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
var fakeDistress = new FakeDistressReporter();
var tracker = new AuthFailureTracker(maxFailures: 50, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
// The streak opens with a platform-403 (no dispatch), then a live backend answers
// with a genuine 403 — that one must still produce the streak's single report.
tracker.RecordFailure(403, "agent/config", endpointUnavailable: true);
tracker.RecordFailure(403, "agent/config");
tracker.RecordFailure(403, "agent/config");
Assert.Single(fakeDistress.Calls);
Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType);
}
[Fact]
public void RecordSuccess_rearms_distress_dispatch_for_the_next_streak()
{
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
var fakeDistress = new FakeDistressReporter();
var tracker = new AuthFailureTracker(maxFailures: 50, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
tracker.RecordFailure(401, "agent/config");
tracker.RecordSuccess();
tracker.RecordFailure(403, "agent/config");
Assert.Equal(2, fakeDistress.Calls.Count);
Assert.Equal(DistressErrorType.AuthCertificateRejected, fakeDistress.Calls[0].ErrorType);
Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[1].ErrorType);
}
[Fact]
public void RecordFailure_without_distress_reporter_is_silent_but_still_tracks()
{
@@ -0,0 +1,110 @@
#nullable enable
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
/// <summary>
/// 401/403 classification in <see cref="BackendApiClient"/>: a backend JSON 403 is a real
/// device-authorization verdict, while an HTML body means the response came from the Azure
/// platform / an edge proxy (stopped or retired app) and must NOT be reported as
/// "device is not authorized". Field case sits-d.cloud 2026-08-09: a pre-cutover agent hit
/// the stopped legacy Function App and support chased a non-existent authorization problem.
/// </summary>
public sealed class BackendApiClientAuthClassificationTests
{
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_responder(request));
}
private static BackendApiClient BuildClient(HttpStatusCode status, string body, string mediaType)
{
var handler = new StubHandler(_ => new HttpResponseMessage(status)
{
Content = new StringContent(body, Encoding.UTF8, mediaType)
});
return new BackendApiClient(
new HttpClient(handler),
baseUrl: "https://backend.example",
manufacturer: "Contoso",
model: "TestBox",
serialNumber: "SN123",
useBootstrapTokenAuth: false,
bootstrapToken: null,
agentVersion: "2.0.0",
logger: null);
}
[Fact]
public async Task Backend_json_403_is_reported_as_device_not_authorized()
{
using var client = BuildClient(
HttpStatusCode.Forbidden,
"{\"success\":false,\"message\":\"Device validation failed\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.Equal(403, ex.StatusCode);
Assert.False(ex.EndpointUnavailable);
Assert.Contains("The device is not authorized", ex.Message);
}
[Fact]
public async Task Platform_html_403_is_reported_as_endpoint_unavailable_with_page_title()
{
using var client = BuildClient(
HttpStatusCode.Forbidden,
"<!DOCTYPE html><html><head><title>Web App - Unavailable</title></head><body>stopped</body></html>",
"text/html");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.Equal(403, ex.StatusCode);
Assert.True(ex.EndpointUnavailable);
Assert.Contains("Web App - Unavailable", ex.Message);
Assert.Contains("NOT a device-authorization failure", ex.Message);
Assert.DoesNotContain("Check client certificate", ex.Message);
}
[Fact]
public async Task Html_body_with_generic_content_type_is_still_sniffed_as_platform_page()
{
using var client = BuildClient(
HttpStatusCode.Forbidden,
" <html><body>Error 403 - This web app is stopped.</body></html>",
"text/plain");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.True(ex.EndpointUnavailable);
Assert.Contains("HTML platform error page", ex.Message);
}
[Fact]
public async Task Backend_401_keeps_authorization_message_and_status()
{
using var client = BuildClient(
HttpStatusCode.Unauthorized,
"{\"success\":false,\"message\":\"certificate rejected\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.Equal(401, ex.StatusCode);
Assert.False(ex.EndpointUnavailable);
Assert.Contains("The device is not authorized", ex.Message);
}
}
}
@@ -155,7 +155,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
// see the AuthCertificateRejected/DeviceNotRegistered distress signal
// without 100 s of needless retry delay.
_logger.Warning($"Config fetch authentication failed: {ex.Message}");
_authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config");
_authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config", ex.EndpointUnavailable);
lastException = ex;
LastFetchAuthStatusCode = ex.StatusCode;
break;
@@ -197,7 +197,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
/// <summary>
/// Sanitizes log messages to prevent log injection attacks.
/// Escapes control characters that could forge log entries.
/// Redaction patterns can be added here later as needed.
/// Transliterates typographic punctuation to ASCII: the log file is written as
/// UTF-8 without BOM, and customer-side viewers (e.g. Outlook web attachment
/// preview) guess cp1252 and render an em dash as mojibake ("—"). Only our own
/// punctuation is mapped — genuine non-ASCII payload (device names, paths) is
/// kept verbatim. Redaction patterns can be added here later as needed.
/// </summary>
private static string SanitizeLogMessage(string message)
{
@@ -206,6 +210,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
// Escape newlines and carriage returns to prevent log injection
message = message.Replace("\r", "\\r").Replace("\n", "\\n");
message = message
.Replace('\u2014', '-') // — em dash
.Replace('\u2013', '-') // – en dash
.Replace("\u2192", "->") // → arrow
.Replace("\u2026", "...") // … ellipsis
.Replace('\u2018', '\'') // ' left single quote
.Replace('\u2019', '\'') // ' right single quote
.Replace('\u201C', '"') // " left double quote
.Replace('\u201D', '"') // " right double quote
.Replace('\u00A0', ' '); // non-breaking space
return message;
}
}
@@ -88,6 +88,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
public static string BackendExpectedSha256 { get; set; }
/// <summary>
/// True once a <see cref="CheckAndApplyUpdateAsync"/> run actually obtained a version
/// from the manifest (regardless of whether an update was then needed/applied).
/// <para>
/// Consumed by the <c>--await-enrollment</c> path: the startup check runs pre-enrollment
/// where OOBE frequently has no network yet, so a failed check there says nothing about
/// the latest version. Once the MDM certificate appears the device provably has
/// connectivity — the bootstrap retries the check exactly when this is still false.
/// </para>
/// </summary>
public static bool LastVersionCheckSucceeded { get; private set; }
/// <summary>
/// Optional: graceful-shutdown request hook, set by the runtime host once its
/// shutdown machinery is armed (before the orchestrator starts) and cleared in its
@@ -130,14 +142,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// to cover hotfixes that reuse the same version number.
/// </param>
/// <param name="triggerReason">
/// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the
/// agent_version_check event (outcome=updated) can distinguish the two paths.
/// "startup" (default), "await_enrollment" (post-cert retry in the bootstrap), or
/// "runtime_hash_mismatch". Recorded in the marker file so the agent_version_check
/// event (outcome=updated) can distinguish the paths.
/// </param>
/// <param name="downloadTimeoutMsOverride">
/// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve
/// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
/// and we know an update is required.
/// </param>
/// <param name="versionCheckTimeoutMsOverride">
/// Overrides the version-v2.json fetch timeout (default 2500ms, tuned for boot speed).
/// The await-enrollment retry passes a relaxed value: enrollment just completed, the
/// device provably has network, and a stranded old build's last self-rescue chance is
/// worth a few extra seconds before session registration.
/// </param>
/// <param name="allowDowngrade">
/// When false (default), a latest version strictly lower than <paramref name="currentVersion"/>
/// is rejected even when <paramref name="forceUpdate"/> is true — prevents the production
@@ -151,7 +170,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
bool forceUpdate = false,
string triggerReason = "startup",
int? downloadTimeoutMsOverride = null,
bool allowDowngrade = false)
bool allowDowngrade = false,
int? versionCheckTimeoutMsOverride = null)
{
Action<string> log = msg =>
{
@@ -160,7 +180,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
};
var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal);
// The await-enrollment retry replaces the startup verdict — its markers must win so
// the agent_version_check event reflects the LATEST check, not the pre-network one.
var writeMarkers = isStartupTrigger || string.Equals(triggerReason, "await_enrollment", StringComparison.Ordinal);
var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs;
var versionCheckTimeoutMs = versionCheckTimeoutMsOverride ?? VersionCheckTimeoutMs;
var totalSw = Stopwatch.StartNew();
long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0;
long zipSizeBytes = 0;
@@ -174,16 +198,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
try
{
// Step 1: Fetch version-v2.json (2.5s timeout)
// Step 1: Fetch version-v2.json (default 2.5s timeout, relaxed on retry triggers)
var versionSw = Stopwatch.StartNew();
string manifestSha256;
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log, versionCheckTimeoutMs);
versionSw.Stop();
versionCheckMs = versionSw.ElapsedMilliseconds;
LastVersionCheckSucceeded = latestVersion != null;
if (latestVersion == null)
{
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("version_check_failed", currentVersion, null, "version-v2.json fetch failed or timed out", log);
return; // Could not determine latest version — continue with current
}
@@ -213,7 +238,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!forceUpdate)
{
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
if (isStartupTrigger)
if (writeMarkers)
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
return;
}
@@ -230,7 +255,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!forceUpdate)
{
log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping");
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log);
return;
}
@@ -249,7 +274,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
downloadMs = downloadTimerSw.ElapsedMilliseconds;
if (!downloadOk)
{
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log);
return;
}
@@ -282,7 +307,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!integrityOk)
{
CleanupStaging(null, zipPath);
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
return;
}
@@ -297,7 +322,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
extractMs = extractSw.ElapsedMilliseconds;
if (!extractOk)
{
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log);
return;
}
@@ -310,7 +335,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
CleanupStaging(stagingDir, zipPath);
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log);
return;
}
@@ -323,7 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!swapOk)
{
CleanupStaging(stagingDir, zipPath);
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log);
return;
}
@@ -355,7 +380,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
catch (Exception ex)
{
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
if (isStartupTrigger)
if (writeMarkers)
{
try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { }
}
@@ -366,7 +391,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// Fetches version-v2.json from blob storage and returns the version string and optional SHA-256 hash.
/// Returns (null, null) if the check fails or times out.
/// </summary>
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log)
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log, int timeoutMs)
{
try
{
@@ -375,7 +400,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
var versionUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentVersionFileNameForLine(2)}";
using (var handler = new HttpClientHandler())
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) })
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) })
{
var json = await client.GetStringAsync(versionUrl);
var obj = JObject.Parse(json);
@@ -396,7 +421,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
catch (TaskCanceledException)
{
log($"Self-update: version check timed out ({VersionCheckTimeoutMs}ms) — skipping update");
log($"Self-update: version check timed out ({timeoutMs}ms) — skipping update");
return (null, null);
}
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
@@ -35,6 +36,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public static readonly IReadOnlyCollection<string> AllowedCommands;
public static readonly IReadOnlyList<string> AllowedEventLogChannels;
// Derived from AllowedWmiQueryPrefixes: entries of the canonical form
// "SELECT * FROM <class>" contribute their class name; any other entry
// (none today) stays on the legacy literal-prefix path.
private static readonly HashSet<string> AllowedWmiClasses;
private static readonly List<string> ResidualWmiPrefixes;
// Canonical allowlist entry: exactly "SELECT * FROM <class>".
private static readonly Regex CanonicalWmiEntryRegex = new Regex(
@"^SELECT\s+\*\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// WQL shape "SELECT <* | property-list> FROM <class>", class token bounded
// by whitespace or end of string (a trailing WHERE clause is permitted,
// "Win32_BIOSX" cannot pass as "Win32_BIOS").
private static readonly Regex WmiQueryRegex = new Regex(
@"^SELECT\s+(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Hard blocks: never allowed, even in unrestricted mode
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
private static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
@@ -101,6 +120,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
AllowedCommands = new HashSet<string>(commands, StringComparer.OrdinalIgnoreCase);
AllowedEventLogChannels = FlattenCategorized(obj, "eventLogChannels", "channels");
DeriveWmiClassAllowlist(AllowedWmiQueryPrefixes, out AllowedWmiClasses, out ResidualWmiPrefixes);
return;
}
}
@@ -115,6 +135,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
AllowedWmiQueryPrefixes = new List<string>();
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AllowedEventLogChannels = new List<string>();
AllowedWmiClasses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
ResidualWmiPrefixes = new List<string>();
}
private static void DeriveWmiClassAllowlist(
IReadOnlyList<string> entries,
out HashSet<string> classes,
out List<string> residual)
{
classes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
residual = new List<string>();
foreach (var entry in entries)
{
var match = CanonicalWmiEntryRegex.Match(entry.Trim());
if (match.Success)
classes.Add(match.Groups[1].Value);
else
residual.Add(entry);
}
}
private static string LoadEmbeddedGuardrails()
@@ -250,8 +290,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
/// <summary>
/// Returns true if the WMI query (trimmed) matches an allowed prefix
/// with boundary matching (next char must be whitespace or end of string).
/// Returns true if the WMI query (trimmed) is "SELECT &lt;* | property-list&gt; FROM &lt;class&gt;"
/// (optionally followed by a WHERE clause) against a class on the allowlist.
/// A property projection exposes a strict subset of the allowed "SELECT *".
/// </summary>
public static bool IsWmiQueryAllowed(string query)
=> IsWmiQueryAllowed(query, unrestrictedMode: false);
@@ -269,7 +310,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
return true;
var trimmed = query.Trim();
return AllowedWmiQueryPrefixes.Any(prefix =>
var match = WmiQueryRegex.Match(trimmed);
if (match.Success && AllowedWmiClasses.Contains(match.Groups[1].Value))
return true;
// Non-canonical allowlist entries keep the legacy literal-prefix match.
return ResidualWmiPrefixes.Any(prefix =>
trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
}
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
continue;
}
ThrowOnAuthFailure(response);
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -236,7 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
using (response)
{
ThrowOnAuthFailure(response);
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -282,16 +282,97 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// <summary>
/// Throws <see cref="BackendAuthException"/> for 401/403 responses so callers can
/// distinguish authentication failures from transient server errors.
/// <para>
/// Field case (sits-d.cloud, 2026-08-09): a pre-cutover agent build pointed at a
/// retired Function App; Azure answers for a stopped app with a platform-level 403
/// carrying an HTML error page ("Web App - Unavailable"). Our backend never returns
/// HTML on 401/403 — an HTML body therefore means the response did not come from the
/// backend's auth layer at all (stopped/retired app, edge proxy, WAF). Reporting that
/// as "device is not authorized" sends troubleshooting down the wrong path, so the
/// two cases get distinct messages and the exception carries a flag.
/// </para>
/// </summary>
private static void ThrowOnAuthFailure(HttpResponseMessage response)
private static async Task ThrowOnAuthFailureAsync(HttpResponseMessage response)
{
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
response.StatusCode == System.Net.HttpStatusCode.Forbidden)
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized &&
response.StatusCode != System.Net.HttpStatusCode.Forbidden)
{
return;
}
var statusText = $"{(int)response.StatusCode} {response.StatusCode}";
string body = null;
try
{
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
catch
{
// Body is diagnostic-only; classification below treats null as non-HTML.
}
if (LooksLikeHtmlErrorPage(response, body))
{
var title = TryExtractHtmlTitle(body);
var titleText = title != null ? $" (\"{title}\")" : string.Empty;
var host = response.RequestMessage?.RequestUri?.Host ?? "(unknown host)";
throw new BackendAuthException(
$"Backend returned {(int)response.StatusCode} {response.StatusCode}. " +
"The device is not authorized. Check client certificate and Autopilot device validation.",
(int)response.StatusCode);
$"Backend returned {statusText} with an HTML platform error page{titleText} from '{host}'. " +
"This response did not come from the Autopilot Monitor backend — the endpoint appears stopped, " +
"retired, or intercepted by a proxy. This is NOT a device-authorization failure; an outdated " +
"agent build pointing at a stale ApiBaseUrl is the most likely cause.",
(int)response.StatusCode,
endpointUnavailable: true);
}
throw new BackendAuthException(
$"Backend returned {statusText}. " +
"The device is not authorized. Check client certificate and Autopilot device validation.",
(int)response.StatusCode);
}
/// <summary>
/// True when a 401/403 response is an HTML page rather than a backend JSON/plain-text
/// reply. Checks the declared Content-Type first, then falls back to sniffing the body
/// prefix so a platform page served with a generic Content-Type is still recognized.
/// </summary>
private static bool LooksLikeHtmlErrorPage(HttpResponseMessage response, string body)
{
var mediaType = response.Content?.Headers?.ContentType?.MediaType;
if (string.Equals(mediaType, "text/html", StringComparison.OrdinalIgnoreCase))
return true;
if (string.IsNullOrWhiteSpace(body))
return false;
var head = body.TrimStart();
return head.StartsWith("<!DOCTYPE", StringComparison.OrdinalIgnoreCase) ||
head.StartsWith("<html", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Best-effort extraction of the HTML &lt;title&gt; (e.g. "Web App - Unavailable") so the
/// log line names the platform page without dumping markup. Null when absent/oversized.
/// </summary>
private static string TryExtractHtmlTitle(string body)
{
if (string.IsNullOrEmpty(body)) return null;
try
{
var match = System.Text.RegularExpressions.Regex.Match(
body, @"<title[^>]*>\s*(.*?)\s*</title>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
System.Text.RegularExpressions.RegexOptions.Singleline,
TimeSpan.FromMilliseconds(250));
if (!match.Success) return null;
var title = match.Groups[1].Value;
return title.Length == 0 ? null : (title.Length > 80 ? title.Substring(0, 80) : title);
}
catch
{
return null;
}
}
@@ -330,7 +411,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
}
/// <summary>
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized.
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized —
/// or, when <see cref="EndpointUnavailable"/> is set, that the 401/403 was a platform-level
/// HTML response (stopped/retired app, edge proxy) rather than a backend auth verdict.
/// </summary>
public class BackendAuthException : Exception
{
@@ -340,9 +423,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// </summary>
public int StatusCode { get; }
public BackendAuthException(string message, int statusCode = 0) : base(message)
/// <summary>
/// True when the 401/403 carried an HTML platform error page — the endpoint itself is
/// stopped/retired/proxied and the status says nothing about this device's authorization.
/// </summary>
public bool EndpointUnavailable { get; }
public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false) : base(message)
{
StatusCode = statusCode;
EndpointUnavailable = endpointUnavailable;
}
}
}
@@ -46,6 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
private int _consecutiveFailures;
private DateTime? _firstFailureUtc;
private int _thresholdFired; // 0/1 — Interlocked exchange makes the event single-shot
private int _distressDispatched; // 0/1 per failure streak — reset by RecordSuccess
public AuthFailureTracker(
int maxFailures,
@@ -77,18 +78,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public void RecordSuccess()
{
if (Interlocked.Exchange(ref _consecutiveFailures, 0) == 0) return; // no-op if already zero
Interlocked.Exchange(ref _distressDispatched, 0);
lock (_windowLock) { _firstFailureUtc = null; }
}
/// <summary>
/// Record a 401/403 from the given <paramref name="operation"/>. V1 parity — on the FIRST
/// failure this fires a single distress report via the constructor-injected
/// <see cref="DistressReporter"/> (401 → AuthCertificateRejected, 403 → DeviceNotRegistered).
/// Subsequent failures are logged + counted but do not dispatch further distress signals.
/// Emits <see cref="ThresholdExceeded"/> the first time either ceiling is crossed;
/// subsequent calls after termination is armed are no-ops.
/// Record a 401/403 from the given <paramref name="operation"/>. V1 parity — the first
/// qualifying failure of a streak fires a single distress report via the
/// constructor-injected <see cref="DistressReporter"/> (401 → AuthCertificateRejected,
/// 403 → DeviceNotRegistered). Subsequent failures are logged + counted but do not
/// dispatch further distress signals. Emits <see cref="ThresholdExceeded"/> the first
/// time either ceiling is crossed; subsequent calls after termination is armed are no-ops.
/// </summary>
public void RecordFailure(int statusCode, string operation)
/// <param name="endpointUnavailable">
/// True when the 401/403 was a platform-level HTML response (stopped/retired app, edge
/// proxy — see <see cref="BackendAuthException.EndpointUnavailable"/>). Suppresses the
/// distress dispatch: the report would go to the very endpoint that just answered with a
/// platform error page, and its DeviceNotRegistered classification would be wrong anyway.
/// Counting and the shutdown thresholds are unaffected. Should a later failure in the
/// same streak come from a live backend, that one still dispatches the streak's report.
/// </param>
public void RecordFailure(int statusCode, string operation, bool endpointUnavailable = false)
{
if (Volatile.Read(ref _thresholdFired) == 1) return;
@@ -102,10 +112,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
firstFailureAt = _firstFailureUtc.Value;
}
// V1 parity — only the first consecutive failure produces a distress report. Repeat
// hits at 2..N use the local log only; the backend already knows from the first
// report that the device is unauthorized.
if (count == 1 && _distressReporter != null)
// V1 parity — only one distress report per failure streak. Repeat hits use the local
// log only; the backend already knows from the first report that the device is
// unauthorized. Endpoint-unavailable failures never dispatch (nothing to send to) but
// also never consume the streak's report slot.
if (endpointUnavailable)
{
if (_distressReporter != null && Volatile.Read(ref _distressDispatched) == 0)
{
_logger.Info(
$"AuthFailureTracker: distress report suppressed for {operation} (http {statusCode}) — " +
"endpoint unavailable (platform error page), a report cannot reach a stopped endpoint.");
}
}
else if (_distressReporter != null &&
Interlocked.CompareExchange(ref _distressDispatched, 1, 0) == 0)
{
var distressType = statusCode == 403
? DistressErrorType.DeviceNotRegistered
@@ -89,7 +89,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
// Feed the central auth-failure tracker so the first-failure distress dispatches
// and the shutdown threshold advances. V1 parity: no retry on auth-failure —
// backend has definitively rejected the cert/token.
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session");
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session", ex.EndpointUnavailable);
return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
}
catch (Exception ex)
@@ -121,6 +121,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// Await-enrollment is one-shot — remove the persisted config so subsequent restarts proceed normally.
Program.DeleteAwaitEnrollmentConfig(dataDirectory, logger);
RetrySelfUpdateAfterEnrollment(logger, consoleMode);
}
// Event-driven TenantId wait — kicks in only when the user opted-in via
@@ -152,6 +154,46 @@ namespace AutopilotMonitor.Agent.V2.Runtime
isWhiteGloveResume: isWhiteGloveResume,
cleanupServiceFactory: cleanupServiceFactory);
}
/// <summary>
/// One-shot self-update retry after the MDM certificate appeared. The startup check in
/// <c>Main</c> runs pre-enrollment, where an --await-enrollment install typically has no
/// network yet — its failure says nothing about the latest version. The certificate is
/// proof of connectivity, and this is the last chance to swap a stranded old build
/// (stale baked-in ApiBaseUrl) for a current one BEFORE session registration hits a
/// retired endpoint (field case sits-d.cloud, 2026-08-09). Timeouts are relaxed vs. the
/// boot-speed-tuned startup check; enrollment is already past OOBE's critical path here.
/// On update success the process restarts and this method never returns; the
/// await-enrollment config was already removed, so the new binary proceeds normally.
/// </summary>
private static void RetrySelfUpdateAfterEnrollment(AgentLogger logger, bool consoleMode)
{
if (SelfUpdater.LastVersionCheckSucceeded)
return; // Startup check reached the manifest — its verdict stands.
try
{
logger.Info("Await-enrollment: startup self-update check had failed (no network in early OOBE) — retrying now that enrollment proves connectivity.");
var agentDir = System.IO.Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
SelfUpdater.CheckAndApplyUpdateAsync(
currentVersion: Program.GetAgentVersion(),
agentDir: agentDir,
consoleMode: consoleMode,
triggerReason: "await_enrollment",
downloadTimeoutMsOverride: 60000,
// Fresh --await-enrollment installs have no cached remote config yet, so
// there is no admin downgrade override to honour on this path.
allowDowngrade: false,
versionCheckTimeoutMsOverride: 10000).GetAwaiter().GetResult();
}
catch (Exception ex)
{
// Same contract as the startup call site: the updater swallows its own errors;
// anything escaping must not break bootstrap — the current binary continues.
logger.Warning($"Await-enrollment: self-update retry failed unexpectedly: {ex.Message}. Continuing with current version.");
}
}
}
/// <summary>
@@ -0,0 +1,51 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the claim precedence behind gather-rule Author stamping
/// (<see cref="TenantHelper.GetUserDisplayName(ClaimsPrincipal)"/>): display name
/// first, then UPN/email/preferred_username; null for anonymous/app-only callers
/// so CreateGatherRule falls back to the model default "Autopilot Monitor".
/// </summary>
public class GatherRuleAuthorStampingTests
{
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
[Fact]
public void DisplayName_wins_over_upn()
{
var user = Principal(("name", "Alice Admin"), ("upn", "alice@contoso.com"));
Assert.Equal("Alice Admin", TenantHelper.GetUserDisplayName(user));
}
[Fact]
public void Upn_used_when_no_name_claim()
{
var user = Principal(("upn", "alice@contoso.com"), ("preferred_username", "alice@contoso.onmicrosoft.com"));
Assert.Equal("alice@contoso.com", TenantHelper.GetUserDisplayName(user));
}
[Fact]
public void PreferredUsername_is_the_last_fallback()
{
var user = Principal(("preferred_username", "alice@contoso.com"));
Assert.Equal("alice@contoso.com", TenantHelper.GetUserDisplayName(user));
}
[Fact]
public void Null_for_principal_without_identifying_claims()
{
Assert.Null(TenantHelper.GetUserDisplayName(Principal(("tid", "a1b2c3d4-e5f6-7890-abcd-ef1234567890"))));
}
[Fact]
public void Null_for_unauthenticated_or_missing_principal()
{
Assert.Null(TenantHelper.GetUserDisplayName((ClaimsPrincipal?)null));
// ClaimsIdentity without an authentication type => IsAuthenticated == false.
Assert.Null(TenantHelper.GetUserDisplayName(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("name", "Ghost") }))));
}
}
@@ -33,6 +33,7 @@ public class GatherRuleUpdatePartialMergeTests
Enabled = true,
IsBuiltIn = false,
IsCommunity = false,
Author = "Alice Admin",
CreatedAt = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc),
ActiveFromPhase = "AccountSetup",
EmitMode = "on_change",
@@ -77,6 +78,7 @@ public class GatherRuleUpdatePartialMergeTests
Assert.Equal("AccountSetup", written.ActiveFromPhase);
Assert.Equal("on_change", written.EmitMode);
Assert.Equal(existing.CreatedAt, written.CreatedAt);
Assert.Equal("Alice Admin", written.Author);
}
[Fact]
@@ -126,6 +128,33 @@ public class GatherRuleUpdatePartialMergeTests
Assert.Equal(existing.CreatedAt, written.CreatedAt);
}
[Fact]
public async Task FullPayload_preserves_original_Author_even_when_payload_carries_one()
{
var existing = ExistingCustomRule();
var (service, _, stored) = BuildService(existing);
var fullPayload = new GatherRule
{
RuleId = RuleId,
Title = "Collect BIOS Config v2",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS2",
Trigger = "interval",
OutputEventType = "gather_bios_config",
Enabled = true,
// A hand-crafted PUT could try to rewrite attribution — Author is
// stamped at create time and immutable afterwards.
Author = "Spoofed Author",
};
var ok = await service.UpdateRuleAsync(TenantId, fullPayload);
Assert.True(ok);
var written = Assert.Single(stored);
Assert.Equal("Alice Admin", written.Author);
}
[Fact]
public async Task BuiltInRule_toggle_still_goes_through_RuleState_path()
{
@@ -72,6 +72,10 @@ namespace AutopilotMonitor.Functions.Functions.Rules
return badRequest;
}
// Author is stamped from the creator's token, never from the payload
// (anti-spoof), and stays immutable through every later update.
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
try
{
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
@@ -19,27 +19,33 @@ public static class TenantHelper
/// <param name="req">The HTTP request</param>
/// <returns>The tenant ID from the JWT token</returns>
/// <exception cref="UnauthorizedAccessException">Thrown when user is not authenticated or tenant ID is missing</exception>
public static string GetTenantId(HttpRequestData req)
/// <summary>
/// Resolves the authenticated ClaimsPrincipal for the request, or null.
/// Azure Functions Isolated Worker: FunctionContext.Items first (set by
/// AuthenticationMiddleware, more reliable than httpContext.User), then
/// the HTTP context as fallback.
/// </summary>
private static ClaimsPrincipal? GetAuthenticatedPrincipal(HttpRequestData req)
{
// Azure Functions Isolated Worker: Try FunctionContext.Items first
// This is set by AuthenticationMiddleware and is more reliable than httpContext.User
ClaimsPrincipal? user = null;
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal principal)
{
user = principal;
return principal;
}
else
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
// Fallback to HTTP context (may not work reliably in isolated worker)
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
user = httpContext.User;
}
return httpContext.User;
}
return null;
}
public static string GetTenantId(HttpRequestData req)
{
var user = GetAuthenticatedPrincipal(req);
if (user?.Identity?.IsAuthenticated != true)
{
throw new UnauthorizedAccessException("User is not authenticated. JWT token required.");
@@ -67,23 +73,7 @@ public static class TenantHelper
/// <returns>User email or name, or "Anonymous" if not authenticated</returns>
public static string GetUserIdentifier(HttpRequestData req)
{
// Azure Functions Isolated Worker: Try FunctionContext.Items first
ClaimsPrincipal? user = null;
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal principal)
{
user = principal;
}
else
{
// Fallback to HTTP context
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
user = httpContext.User;
}
}
var user = GetAuthenticatedPrincipal(req);
if (user?.Identity?.IsAuthenticated != true)
{
@@ -100,4 +90,32 @@ public static class TenantHelper
user.FindFirst("name")?.Value ??
"Unknown";
}
/// <summary>
/// Gets the caller's human-readable name for attribution (e.g. rule Author):
/// display name first, then UPN/email/preferred_username. Returns null when
/// the request carries no user identity (anonymous or app-only token) so the
/// caller can choose its own fallback.
/// </summary>
public static string? GetUserDisplayName(HttpRequestData req)
=> GetUserDisplayName(GetAuthenticatedPrincipal(req));
/// <summary>
/// Claim-precedence core of <see cref="GetUserDisplayName(HttpRequestData)"/>,
/// separated so the precedence is unit-testable without an HttpRequestData.
/// </summary>
public static string? GetUserDisplayName(ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true)
{
return null;
}
return user.FindFirst("name")?.Value ??
user.FindFirst(ClaimTypes.Name)?.Value ??
user.FindFirst("upn")?.Value ??
user.FindFirst(ClaimTypes.Upn)?.Value ??
user.FindFirst(ClaimTypes.Email)?.Value ??
user.FindFirst("preferred_username")?.Value;
}
}
@@ -214,7 +214,12 @@ namespace AutopilotMonitor.Functions.Services
rule.IsBuiltIn = false;
rule.IsCommunity = false;
if (existing != null)
{
rule.CreatedAt = existing.CreatedAt;
// Author is stamped from the creator's token at create time and is
// immutable afterwards — updates (incl. cross-tenant GA edits) keep it.
rule.Author = existing.Author;
}
rule.UpdatedAt = DateTime.UtcNow;
return await _ruleRepo.StoreGatherRuleAsync(rule, tenantId);
}
@@ -137,6 +137,37 @@ describe('gather guardrails (agent matching semantics)', () => {
expect(validateRuleDraft(spoof).valid).toBe(false);
});
it('WMI: property projection of an allowed class is admitted', () => {
const single = validGather();
single.collectorType = 'wmi';
single.target = 'SELECT BatteryStatus FROM Win32_Battery';
expect(validateRuleDraft(single).valid).toBe(true);
const multi = validGather();
multi.collectorType = 'wmi';
multi.target = 'SELECT BatteryStatus, EstimatedChargeRemaining FROM Win32_Battery WHERE BatteryStatus = 1';
expect(validateRuleDraft(multi).valid).toBe(true);
});
it('WMI: projection of a disallowed class and star-in-list are rejected', () => {
const badClass = validGather();
badClass.collectorType = 'wmi';
badClass.target = 'SELECT Name FROM Win32_Process';
expect(validateRuleDraft(badClass).valid).toBe(false);
const starMix = validGather();
starMix.collectorType = 'wmi';
starMix.target = 'SELECT *, Name FROM Win32_BIOS';
expect(validateRuleDraft(starMix).valid).toBe(false);
});
it('WMI: leading whitespace is trimmed like the agent does (no false positive)', () => {
const padded = validGather();
padded.collectorType = 'wmi';
padded.target = ' SELECT * FROM Win32_BIOS';
expect(validateRuleDraft(padded).valid).toBe(true);
});
it('commands match exactly — extra arguments are rejected', () => {
const ok = validGather();
ok.collectorType = 'command_allowlisted';
@@ -67,6 +67,25 @@ const flatRegistryPrefixes = RULE_GUARDRAILS.registryPrefixes.flatMap((g) => [..
const flatCommands = RULE_GUARDRAILS.allowedCommands.flatMap((g) => [...g.commands]);
const flatEventLogChannels = RULE_GUARDRAILS.eventLogChannels.flatMap((g) => [...g.channels]);
// Canonical WMI allowlist entry: exactly "SELECT * FROM <class>". Such entries
// contribute their class name; any other entry (none today) stays on the legacy
// literal-prefix path. Mirrors GatherRuleGuards.cs.
const CANONICAL_WMI_ENTRY_REGEX = /^SELECT\s+\*\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)$/i;
// WQL shape "SELECT <* | property-list> FROM <class>", class token bounded by
// whitespace or end of string (trailing WHERE permitted, Win32_BIOSX cannot
// pass as Win32_BIOS).
const WMI_QUERY_REGEX =
/^SELECT\s+(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)/i;
const allowedWmiClasses = new Set<string>();
const residualWmiPrefixes: string[] = [];
for (const entry of RULE_GUARDRAILS.wmiQueryPrefixes) {
const m = CANONICAL_WMI_ENTRY_REGEX.exec(entry.trim());
if (m) allowedWmiClasses.add(m[1].toLowerCase());
else residualWmiPrefixes.push(entry);
}
/** Hard blocks enforced in agent CODE (not liftable via guardrails.json). Keep in
* sync with GatherRuleGuards.cs — additions there are fail-safe (agent still blocks),
* missing ones here just cost a pre-flight warning. */
@@ -121,15 +140,24 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
break;
}
case 'wmi': {
const t = target.toLowerCase();
const ok = RULE_GUARDRAILS.wmiQueryPrefixes.some((p) => {
const pl = p.toLowerCase();
// Whitespace-bounded: the query is the prefix or continues with whitespace
// (mirrors the agent's boundary check, prevents Win32_BIOSX spoofing).
return t === pl || (t.startsWith(pl) && /\s/.test(t.charAt(pl.length)));
});
// Trim like the agent does — an untrimmed target must not be a false positive.
const t = target.trim().toLowerCase();
const m = WMI_QUERY_REGEX.exec(t);
let ok = m !== null && allowedWmiClasses.has(m[1]);
if (!ok) {
findings.push({ level: 'error', message: `guardrails: WMI query "${target}" does not start with any allowed query prefix (rule_guardrails → wmiQueryPrefixes).` });
// Non-canonical allowlist entries keep the legacy literal-prefix match.
ok = residualWmiPrefixes.some((p) => {
const pl = p.toLowerCase();
return t === pl || (t.startsWith(pl) && /\s/.test(t.charAt(pl.length)));
});
}
if (!ok) {
findings.push({
level: 'error',
message:
`guardrails: WMI query "${target}" is not allowed — use "SELECT <properties or *> FROM <class>" against a class from rule_guardrails → wmiQueryPrefixes. ` +
'Allowing additional classes requires a contribution to rules/guardrails.json in the product repository.',
});
}
break;
}
@@ -49,7 +49,7 @@ namespace AutopilotMonitor.Shared.Models
/// a security matter, or a change that needs an administrator's attention. Editable by
/// the tenant's own admins under Settings → Tenant → Contact.
/// <para>
/// Seeded once at onboarding from the Private Preview notification address if one was
/// Seeded once at onboarding from the tenant's notification address if one was
/// given, and never re-synced afterwards: from that point the value belongs to the
/// tenant, and a later edit must not be overwritten by the onboarding source.
/// </para>
@@ -19,6 +19,7 @@ interface TenantConfig {
lastUpdated?: string;
updatedBy?: string;
onboardedAt?: string;
contactEmail?: string | null;
// Tenant status
disabled?: boolean;
@@ -536,6 +537,7 @@ export function SectionTenantConfigReport() {
<ConfigRow label="Disabled Reason" value={config.disabledReason} />
<ConfigRow label="Disabled Until" value={config.disabledUntil} isDate />
<ConfigRow label="Onboarded At" value={config.onboardedAt} isDate />
<ConfigRow label="Contact Email" value={config.contactEmail} />
<ConfigRow
label="Plan Tier"
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { targetBlocked } from "../types";
// The enable gate for gather rules: a CUSTOM rule whose target fails guardrail
// validation must not be enableable — the agent would block it on every device.
const custom = { isBuiltIn: false, isCommunity: false };
describe("targetBlocked", () => {
it("blocks a custom rule with a disallowed WMI query", () => {
expect(targetBlocked({ ...custom, collectorType: "wmi", target: "SELECT * FROM Win32_Process" }, false)).toBe(true);
});
it("does not block an allowed projection query", () => {
expect(targetBlocked({ ...custom, collectorType: "wmi", target: "SELECT BatteryStatus FROM Win32_Battery" }, false)).toBe(false);
});
it("never blocks built-in or community rules", () => {
expect(targetBlocked({ isBuiltIn: true, isCommunity: false, collectorType: "wmi", target: "SELECT * FROM Win32_Process" }, false)).toBe(false);
expect(targetBlocked({ isBuiltIn: false, isCommunity: true, collectorType: "wmi", target: "SELECT * FROM Win32_Process" }, false)).toBe(false);
});
it("respects unrestricted mode", () => {
expect(targetBlocked({ ...custom, collectorType: "wmi", target: "SELECT * FROM Win32_Process" }, true)).toBe(false);
});
it("does not block an empty target (required-field validation owns that)", () => {
expect(targetBlocked({ ...custom, collectorType: "wmi", target: "" }, false)).toBe(false);
});
it("blocks disallowed targets of other collector types too", () => {
expect(targetBlocked({ ...custom, collectorType: "file", target: "C:\\Users\\alice\\secret.txt" }, false)).toBe(true);
expect(targetBlocked({ ...custom, collectorType: "eventlog", target: "Security" }, false)).toBe(true);
});
});
@@ -68,6 +68,11 @@ export function GatherRuleCard({
[rule.collectorType, rule.target, unrestrictedMode]
);
// Custom rule whose target the agent would block on every device (security_warning,
// no data): the portal refuses to enable it; disabling always stays possible.
const isBlockedTarget = canEdit && targetValidation !== null && !targetValidation.allowed;
const enableBlocked = !rule.enabled && isBlockedTarget;
return (
<div
className={`bg-white rounded-lg shadow border transition-all ${
@@ -99,13 +104,15 @@ export function GatherRuleCard({
e.stopPropagation();
onToggle();
}}
disabled={togglingRule === rule.ruleId}
disabled={togglingRule === rule.ruleId || enableBlocked}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
togglingRule === rule.ruleId
togglingRule === rule.ruleId || enableBlocked
? "opacity-50 cursor-not-allowed"
: "cursor-pointer"
} ${rule.enabled ? "bg-emerald-500" : "bg-gray-300"}`}
title={rule.enabled ? "Disable rule" : "Enable rule"}
title={enableBlocked
? "Target is not allowed by the collection guardrails — fix the target before enabling"
: rule.enabled ? "Disable rule" : "Enable rule"}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
@@ -150,6 +157,16 @@ export function GatherRuleCard({
{rule.isBuiltIn ? "Built-in" : rule.isCommunity ? "Community" : "Custom"}
</span>
{/* Enabled rule the agent blocks on every device — visible without expanding */}
{rule.enabled && isBlockedTarget && (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-50 text-red-600 border border-red-200 flex-shrink-0"
title={targetValidation?.reason}
>
Blocked on devices
</span>
)}
{/* Expand/Collapse Arrow */}
<svg
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${
@@ -263,6 +280,20 @@ export function GatherRuleCard({
{rule.target}
</code>
<ValidationIndicator result={targetValidation} className="ml-2" />
{isBlockedTarget && (
<p className="mt-1.5 text-xs text-gray-500">
This target isn&apos;t on the collection allowlist. Think it should be?{" "}
<a
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800 underline"
>
Request an addition via a GitHub issue
</a>
.
</p>
)}
</div>
{rule.parameters && Object.keys(rule.parameters).length > 0 && (
<div className="text-sm">
@@ -145,6 +145,21 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<p className="text-xs text-gray-400">{TARGET_HINTS[form.collectorType] || "Registry path, WMI class, event log name, file path, or command depending on collector type"}</p>
<ValidationIndicator result={targetValidation} />
</div>
{targetValidation && !targetValidation.allowed && (
<p className="mt-1 text-xs text-gray-500">
This target isn&apos;t on the collection allowlist — the rule can be saved but not enabled.
Think it should be allowed?{" "}
<a
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800 underline"
>
Request an addition via a GitHub issue
</a>
.
</p>
)}
</div>
{/* Registry: optional Value Name */}
@@ -14,7 +14,7 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { GatherRule, NewRuleForm, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, validateScopeSelection, withDerivedScopeMode } from "./types";
import { GatherRule, NewRuleForm, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, targetBlocked, validateScopeSelection, withDerivedScopeMode } from "./types";
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
import { GatherRuleCard } from "./components/GatherRuleCard";
import { DOCS_URL } from "@/utils/config";
@@ -125,6 +125,12 @@ export default function GatherRulesPage() {
}, [effectiveTenantId, getAccessToken]);
const handleToggleRule = async (rule: GatherRule) => {
// Defense in depth next to the disabled toggle button: never enable a rule the
// agent would block on every device anyway. Disabling always stays possible.
if (!rule.enabled && targetBlocked(rule, unrestrictedMode)) {
showError(`Rule "${rule.title}" cannot be enabled — its target is not allowed by the collection guardrails.`);
return;
}
setTogglingRule(rule.ruleId);
const result = await mutate(
gatherRuleWriteUrl(rule.ruleId),
@@ -214,6 +220,9 @@ export default function GatherRulesPage() {
}
setCreating(true);
// Without an explicit enabled the backend model defaults to true — a rule the agent
// would block on every device must land disabled instead.
const blocked = targetBlocked(form, unrestrictedMode);
const payload = {
ruleId: form.ruleId,
title: form.title,
@@ -229,6 +238,7 @@ export default function GatherRulesPage() {
...buildScopeFields(form),
outputEventType: form.outputEventType,
outputSeverity: form.outputSeverity,
enabled: !blocked,
};
const result = await mutate(
@@ -241,7 +251,9 @@ export default function GatherRulesPage() {
);
if (result !== null) {
trackEvent("rule_created", { ruleType: "gather" });
showSuccess(`Rule "${form.title}" created`);
showSuccess(blocked
? `Rule "${form.title}" created (disabled — its target is not allowed by the collection guardrails)`
: `Rule "${form.title}" created`);
setShowCreateForm(false);
setNewRule({ ...EMPTY_FORM });
setJsonModeCreate(false);
@@ -306,6 +318,9 @@ export default function GatherRulesPage() {
}
setSaving(true);
// An edit that makes the target invalid force-disables the rule — the agent would
// block it on every device, so leaving it enabled only produces security warnings.
const forcedOff = rule.enabled && targetBlocked(form, unrestrictedMode);
const payload = {
title: form.title,
description: form.description,
@@ -321,8 +336,7 @@ export default function GatherRulesPage() {
outputEventType: form.outputEventType,
outputSeverity: form.outputSeverity,
version: bumpVersion(rule.version),
enabled: rule.enabled,
author: rule.author,
enabled: rule.enabled && !forcedOff,
createdAt: rule.createdAt,
// Same wipe class as the toggle bug: the full-replace PUT must carry tags or they vanish.
tags: rule.tags ?? [],
@@ -340,7 +354,9 @@ export default function GatherRulesPage() {
);
if (result !== null) {
trackEvent("rule_modified", { ruleType: "gather" });
showSuccess(`Rule "${form.title}" saved`);
showSuccess(forcedOff
? `Rule "${form.title}" saved and disabled — its target is not allowed by the collection guardrails`
: `Rule "${form.title}" saved`);
setEditingRuleId(null);
setJsonModeEdit(false);
setJsonError(null);
@@ -1,3 +1,5 @@
import { validateGatherRuleTarget } from "@/utils/guardValidation";
export interface GatherRule {
ruleId: string;
title: string;
@@ -126,7 +128,7 @@ export const TARGET_PLACEHOLDERS: Record<string, string> = {
export const TARGET_HINTS: Record<string, string> = {
registry: "Full registry path including hive (HKLM, HKCU). The agent reads values from this key.",
eventlog: "Event log name — supports operational/analytic logs like Microsoft-Windows-Shell-Core/Operational.",
wmi: "Full WQL query (SELECT * FROM ...). Must use an allowed WMI class.",
wmi: "Full WQL query — SELECT * or a comma-separated property list from an allowed WMI class, e.g. SELECT BatteryStatus FROM Win32_Battery. Pairs well with Emit Mode \"On change\": polling a single property only emits when that property changes.",
file: "File path. Environment variables like %ProgramData% are supported. Must be within allowed directories.",
command_allowlisted: "Exact command string from the agent's allowlist. Custom commands are not permitted.",
logparser: "Path to a log file. Supports wildcards (* and ?) in the filename, e.g. AppWorkload-*.log. Environment variables are expanded.",
@@ -203,6 +205,23 @@ export function supportsEmitMode(form: Pick<NewRuleForm, "trigger" | "triggerPha
return !firesExactlyOnce(form);
}
/**
* True when a CUSTOM rule's target fails guardrail validation — the agent would block
* it on every device (security_warning, no data), so the portal refuses to enable it.
* Built-in/community rules are catalog-validated and never gated; unrestrictedMode is
* respected because the validator returns allowed=true for it.
*/
export function targetBlocked(
rule: Pick<GatherRule, "collectorType" | "target"> & Partial<Pick<GatherRule, "isBuiltIn" | "isCommunity">>,
unrestrictedMode: boolean
): boolean {
if (rule.isBuiltIn || rule.isCommunity) return false;
if (!rule.target?.trim()) return false;
// null = collector type the validator doesn't know — no verdict, don't gate.
const result = validateGatherRuleTarget(rule.collectorType, rule.target, unrestrictedMode);
return result !== null && !result.allowed;
}
/**
* Rejects a scope mode that was selected but left unfilled. Without this the payload
* silently sends null (= unrestricted) and the rule runs everywhere while the form still
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../../../../contexts/AuthContext";
import { useTenant } from "../../../../contexts/TenantContext";
import { api } from "@/lib/api";
import { AGENT_DOWNLOAD_URL } from "@/utils/config";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
@@ -111,8 +112,9 @@ export function SectionOptionalGraphCapabilities() {
const allPermissions = Array.from(new Set(featureRows.flatMap(f => f.requiredPermissions)));
const allPermissionsLine = allPermissions.map(p => `"${p}"`).join(",");
const scriptDownloadUrl = `${AGENT_DOWNLOAD_URL}/Grant-AutopilotMonitorAddOn.ps1`;
const psCommand = status?.clientId
? `.\\Grant-AutopilotMonitorAddOn.ps1 \`\n -ClientId "${status.clientId}" \`\n -Permissions ${allPermissionsLine} \`\n -TenantId "${tenantId ?? "<your-tenant-id>"}"`
? `irm '${scriptDownloadUrl}' -OutFile .\\Grant-AutopilotMonitorAddOn.ps1\n.\\Grant-AutopilotMonitorAddOn.ps1 \`\n -ClientId "${status.clientId}" \`\n -Permissions ${allPermissionsLine} \`\n -TenantId "${tenantId ?? "<your-tenant-id>"}"`
: "";
return (
@@ -205,8 +207,16 @@ export function SectionOptionalGraphCapabilities() {
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-md font-semibold text-gray-900 mb-2">PowerShell grant command</h3>
<p className="text-sm text-gray-600 mb-3">
Open a Windows PowerShell or PowerShell 7 prompt as a tenant administrator and run the script
below. It is idempotent — re-running it skips permissions that are already granted.
Open a Windows PowerShell or PowerShell 7 prompt as a tenant administrator and run the commands
below — the first line downloads the script (
<a
href={scriptDownloadUrl}
className="text-green-700 underline hover:text-green-800"
download
>
direct download
</a>
), the second runs it. It is idempotent — re-running it skips permissions that are already granted.
</p>
<pre className="bg-gray-900 text-gray-100 text-xs font-mono p-3 rounded overflow-x-auto">
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { bumpVersion } from "../rulePageHelpers";
describe("bumpVersion", () => {
it.each([
["1.0", "1.1"],
["1.9", "1.10"],
["1.0.0", "1.1.0"], // three segments stay three segments
["2.3.7", "2.4.0"], // patch resets on a minor bump
["1.0.0.5", "1.1.0.0"],
])("bumps %s to %s preserving the segment count", (input, expected) => {
expect(bumpVersion(input)).toBe(expected);
});
it("falls back to 1.1 for empty or missing input", () => {
expect(bumpVersion("")).toBe("1.1");
expect(bumpVersion(undefined as unknown as string)).toBe("1.1");
});
it("treats a non-numeric minor as 0", () => {
expect(bumpVersion("1.x")).toBe("1.1");
});
});
@@ -25,10 +25,16 @@ export function stripInternalFields<T extends { isBuiltIn?: any; isCommunity?: a
return rest as Omit<T, "isBuiltIn" | "isCommunity" | "createdAt" | "updatedAt">;
}
/** Bump a semver-style version string: "1.0" -> "1.1", "1.9" -> "1.10", etc. */
/**
* Bump a semver-style version string's minor segment, preserving the segment
* count and zeroing anything after the minor: "1.0" -> "1.1", "1.9" -> "1.10",
* "1.0.0" -> "1.1.0", "2.3.7" -> "2.4.0".
*/
export function bumpVersion(v: string): string {
const parts = (v ?? "1.0").split(".");
const major = parts[0] ?? "1";
const minor = parseInt(parts[1] ?? "0", 10);
return `${major}.${minor + 1}`;
const parts = (v || "1.0").split(".");
const major = parts[0] || "1";
const parsedMinor = parseInt(parts[1] ?? "0", 10);
const minor = Number.isNaN(parsedMinor) ? 0 : parsedMinor;
const trailingZeros = parts.slice(2).map(() => "0");
return [major, `${minor + 1}`, ...trailingZeros].join(".");
}
+6 -6
View File
@@ -5690,9 +5690,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -6254,9 +6254,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"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==",
"funding": [
{
"type": "github",
@@ -196,6 +196,32 @@ describe("validateWmiTarget", () => {
const r = validateWmiTarget("", false);
expect(r.allowed).toBe(false);
});
// Property projections of an allowed class expose a strict subset of the
// already-allowed SELECT * and are therefore admitted.
it.each([
"SELECT BatteryStatus FROM Win32_Battery",
"SELECT BatteryStatus, EstimatedChargeRemaining FROM Win32_Battery",
"SELECT BatteryStatus,EstimatedChargeRemaining FROM Win32_Battery",
"select batterystatus from win32_battery",
"SELECT\tBatteryStatus\tFROM\tWin32_Battery",
" SELECT BatteryStatus FROM Win32_Battery ",
"SELECT Status FROM Win32_Battery WHERE BatteryStatus = 1",
])("allows projection of an allowed class: %s", (query) => {
expect(validateWmiTarget(query, false).allowed).toBe(true);
});
it.each([
"SELECT Name FROM Win32_Process", // class not on the allowlist
"SELECT BatteryStatus FROM Win32_BatteryX", // class spoofing
"SELECT *, Name FROM Win32_BIOS", // star mixed into a property list
"SELECT FROM Win32_BIOS", // empty property list
"SELECT BatteryStatus FROM", // missing class
"SELECT BatteryStatus FROM Win32_Battery;DROP", // non-whitespace boundary
"SELECT BatteryStatus, FROM Win32_Battery", // trailing comma
])("rejects malformed or disallowed projection: %s", (query) => {
expect(validateWmiTarget(query, false).allowed).toBe(false);
});
});
// ---------------------------------------------------------------------------
@@ -49,6 +49,13 @@ export const API_URL_PROD = "https://autopilotmonitor-api-eu.azurewebsites.net";
/** Production blob origin (CSP connect-src: diagnostics SAS uploads). */
export const BLOB_URL_PROD = "https://autopilotmonitoreu.blob.core.windows.net";
/**
* Customer-facing download alias for agent artifacts and helper scripts
* (Front Door in front of the blob origin). Counterpart of
* Constants.AgentDownloadBaseUrl on the C# side.
*/
export const AGENT_DOWNLOAD_URL = "https://download.autopilotmonitor.com/agent";
/**
* Hostnames the portal accepts in a bootstrap response's agentDownloadUrl.
* Keep in sync with ValidateBootstrapCodeFunction.cs, which builds that URL
@@ -39,6 +39,29 @@ const ALLOWED_COMMANDS_SET = new Set(
ALLOWED_COMMANDS_LIST.map((c) => c.toLowerCase())
);
// Canonical WMI allowlist entry: exactly "SELECT * FROM <class>".
// Such entries contribute their class name; any other entry (none today)
// stays on the legacy literal-prefix path. Mirrors the agent's
// GatherRuleGuards derivation.
const CANONICAL_WMI_ENTRY_REGEX = /^SELECT\s+\*\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)$/i;
// WQL shape "SELECT <* | property-list> FROM <class>", class token bounded by
// whitespace or end of string (a trailing WHERE clause is permitted,
// "Win32_BIOSX" cannot pass as "Win32_BIOS").
const WMI_QUERY_REGEX =
/^SELECT\s+(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)/i;
const ALLOWED_WMI_CLASSES = new Set<string>();
const RESIDUAL_WMI_PREFIXES: string[] = [];
for (const entry of ALLOWED_WMI_QUERY_PREFIXES) {
const match = CANONICAL_WMI_ENTRY_REGEX.exec(entry.trim());
if (match) {
ALLOWED_WMI_CLASSES.add(match[1].toLowerCase());
} else {
RESIDUAL_WMI_PREFIXES.push(entry);
}
}
const BLOCKED_USERS_PREFIX = BLOCKED_FILE_PREFIXES[0] || "C:\\Users";
// ---------------------------------------------------------------------------
@@ -267,7 +290,15 @@ export function validateWmiTarget(
return { allowed: true, reason: "All WMI queries allowed in unrestricted mode", unrestricted: true };
}
for (const prefix of ALLOWED_WMI_QUERY_PREFIXES) {
// "SELECT <* | property-list> FROM <class>" against an allowed class — a
// property projection exposes a strict subset of the already-allowed SELECT *.
const match = WMI_QUERY_REGEX.exec(trimmed);
if (match && ALLOWED_WMI_CLASSES.has(match[1].toLowerCase())) {
return { allowed: true, reason: `Class ${match[1]} is on the WMI allowlist`, unrestricted: false };
}
// Non-canonical allowlist entries keep the legacy literal-prefix match.
for (const prefix of RESIDUAL_WMI_PREFIXES) {
if (trimmed.length >= prefix.length) {
const candidate = trimmed.substring(0, prefix.length);
if (candidate.toLowerCase() === prefix.toLowerCase()) {
@@ -280,7 +311,7 @@ export function validateWmiTarget(
return {
allowed: false,
reason: "Not a recognized WMI query prefix",
reason: "Not an allowed WMI query — use SELECT <properties or *> FROM <allowed class>",
unrestricted: false,
};
}