mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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>
This commit is contained in:
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
# Log
|
# 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).
|
* **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).
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ description: >-
|
|||||||
guard rather than trusting rule.Target.
|
guard rather than trusting rule.Target.
|
||||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
|
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
|
||||||
tags: [gather-rules, security, guardrails, agent]
|
tags: [gather-rules, security, guardrails, agent]
|
||||||
timestamp: 2026-07-22
|
timestamp: 2026-08-10
|
||||||
---
|
---
|
||||||
|
|
||||||
# Concept
|
# 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`.
|
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
|
# Schema
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
|
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
|
||||||
| `IsFilePathAllowed` | `filePrefixes` | `Path.GetFullPath` first, then prefix + boundary |
|
| `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 |
|
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
|
||||||
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
|
| `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
|
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.
|
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
|
# Citations
|
||||||
|
|
||||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs`
|
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs`
|
||||||
|
|||||||
+43
@@ -308,9 +308,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
|||||||
[InlineData("SELECT * FROM Win32_UserAccount")]
|
[InlineData("SELECT * FROM Win32_UserAccount")]
|
||||||
[InlineData("SELECT * FROM Win32_ShadowCopy")]
|
[InlineData("SELECT * FROM Win32_ShadowCopy")]
|
||||||
[InlineData("SELECT * FROM Win32_OperatingSystemExtra")] // prefix spoofing
|
[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)
|
public void WmiCollector_OffAllowlistQuery_IsBlockedBeforeTheQueryRuns(string query)
|
||||||
=> AssertBlocked(new WmiCollector().Execute(Rule("wmi", query), Context()), 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]
|
[Fact]
|
||||||
public void JsonCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
public void JsonCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
||||||
{
|
{
|
||||||
|
|||||||
+50
-3
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
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 IReadOnlyCollection<string> AllowedCommands;
|
||||||
public static readonly IReadOnlyList<string> AllowedEventLogChannels;
|
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
|
// Hard blocks: never allowed, even in unrestricted mode
|
||||||
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||||
private static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
|
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);
|
AllowedCommands = new HashSet<string>(commands, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
AllowedEventLogChannels = FlattenCategorized(obj, "eventLogChannels", "channels");
|
AllowedEventLogChannels = FlattenCategorized(obj, "eventLogChannels", "channels");
|
||||||
|
DeriveWmiClassAllowlist(AllowedWmiQueryPrefixes, out AllowedWmiClasses, out ResidualWmiPrefixes);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +135,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
|||||||
AllowedWmiQueryPrefixes = new List<string>();
|
AllowedWmiQueryPrefixes = new List<string>();
|
||||||
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
AllowedEventLogChannels = new List<string>();
|
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()
|
private static string LoadEmbeddedGuardrails()
|
||||||
@@ -250,8 +290,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if the WMI query (trimmed) matches an allowed prefix
|
/// Returns true if the WMI query (trimmed) is "SELECT <* | property-list> FROM <class>"
|
||||||
/// with boundary matching (next char must be whitespace or end of string).
|
/// (optionally followed by a WHERE clause) against a class on the allowlist.
|
||||||
|
/// A property projection exposes a strict subset of the allowed "SELECT *".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsWmiQueryAllowed(string query)
|
public static bool IsWmiQueryAllowed(string query)
|
||||||
=> IsWmiQueryAllowed(query, unrestrictedMode: false);
|
=> IsWmiQueryAllowed(query, unrestrictedMode: false);
|
||||||
@@ -269,7 +310,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
var trimmed = query.Trim();
|
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.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||||
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
|
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
Enabled = true,
|
||||||
IsBuiltIn = false,
|
IsBuiltIn = false,
|
||||||
IsCommunity = false,
|
IsCommunity = false,
|
||||||
|
Author = "Alice Admin",
|
||||||
CreatedAt = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc),
|
CreatedAt = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc),
|
||||||
ActiveFromPhase = "AccountSetup",
|
ActiveFromPhase = "AccountSetup",
|
||||||
EmitMode = "on_change",
|
EmitMode = "on_change",
|
||||||
@@ -77,6 +78,7 @@ public class GatherRuleUpdatePartialMergeTests
|
|||||||
Assert.Equal("AccountSetup", written.ActiveFromPhase);
|
Assert.Equal("AccountSetup", written.ActiveFromPhase);
|
||||||
Assert.Equal("on_change", written.EmitMode);
|
Assert.Equal("on_change", written.EmitMode);
|
||||||
Assert.Equal(existing.CreatedAt, written.CreatedAt);
|
Assert.Equal(existing.CreatedAt, written.CreatedAt);
|
||||||
|
Assert.Equal("Alice Admin", written.Author);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -126,6 +128,33 @@ public class GatherRuleUpdatePartialMergeTests
|
|||||||
Assert.Equal(existing.CreatedAt, written.CreatedAt);
|
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]
|
[Fact]
|
||||||
public async Task BuiltInRule_toggle_still_goes_through_RuleState_path()
|
public async Task BuiltInRule_toggle_still_goes_through_RuleState_path()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
|||||||
return badRequest;
|
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
|
try
|
||||||
{
|
{
|
||||||
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
|
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
|
||||||
|
|||||||
@@ -19,27 +19,33 @@ public static class TenantHelper
|
|||||||
/// <param name="req">The HTTP request</param>
|
/// <param name="req">The HTTP request</param>
|
||||||
/// <returns>The tenant ID from the JWT token</returns>
|
/// <returns>The tenant ID from the JWT token</returns>
|
||||||
/// <exception cref="UnauthorizedAccessException">Thrown when user is not authenticated or tenant ID is missing</exception>
|
/// <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)
|
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
|
||||||
&& principalObj is ClaimsPrincipal principal)
|
&& 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)
|
return httpContext.User;
|
||||||
var httpContext = req.FunctionContext.GetHttpContext();
|
|
||||||
if (httpContext?.User?.Identity?.IsAuthenticated == true)
|
|
||||||
{
|
|
||||||
user = httpContext.User;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetTenantId(HttpRequestData req)
|
||||||
|
{
|
||||||
|
var user = GetAuthenticatedPrincipal(req);
|
||||||
|
|
||||||
if (user?.Identity?.IsAuthenticated != true)
|
if (user?.Identity?.IsAuthenticated != true)
|
||||||
{
|
{
|
||||||
throw new UnauthorizedAccessException("User is not authenticated. JWT token required.");
|
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>
|
/// <returns>User email or name, or "Anonymous" if not authenticated</returns>
|
||||||
public static string GetUserIdentifier(HttpRequestData req)
|
public static string GetUserIdentifier(HttpRequestData req)
|
||||||
{
|
{
|
||||||
// Azure Functions Isolated Worker: Try FunctionContext.Items first
|
var user = GetAuthenticatedPrincipal(req);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user?.Identity?.IsAuthenticated != true)
|
if (user?.Identity?.IsAuthenticated != true)
|
||||||
{
|
{
|
||||||
@@ -100,4 +90,32 @@ public static class TenantHelper
|
|||||||
user.FindFirst("name")?.Value ??
|
user.FindFirst("name")?.Value ??
|
||||||
"Unknown";
|
"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.IsBuiltIn = false;
|
||||||
rule.IsCommunity = false;
|
rule.IsCommunity = false;
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
|
{
|
||||||
rule.CreatedAt = existing.CreatedAt;
|
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;
|
rule.UpdatedAt = DateTime.UtcNow;
|
||||||
return await _ruleRepo.StoreGatherRuleAsync(rule, tenantId);
|
return await _ruleRepo.StoreGatherRuleAsync(rule, tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,37 @@ describe('gather guardrails (agent matching semantics)', () => {
|
|||||||
expect(validateRuleDraft(spoof).valid).toBe(false);
|
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', () => {
|
it('commands match exactly — extra arguments are rejected', () => {
|
||||||
const ok = validGather();
|
const ok = validGather();
|
||||||
ok.collectorType = 'command_allowlisted';
|
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 flatCommands = RULE_GUARDRAILS.allowedCommands.flatMap((g) => [...g.commands]);
|
||||||
const flatEventLogChannels = RULE_GUARDRAILS.eventLogChannels.flatMap((g) => [...g.channels]);
|
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
|
/** 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),
|
* sync with GatherRuleGuards.cs — additions there are fail-safe (agent still blocks),
|
||||||
* missing ones here just cost a pre-flight warning. */
|
* missing ones here just cost a pre-flight warning. */
|
||||||
@@ -121,15 +140,24 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'wmi': {
|
case 'wmi': {
|
||||||
const t = target.toLowerCase();
|
// Trim like the agent does — an untrimmed target must not be a false positive.
|
||||||
const ok = RULE_GUARDRAILS.wmiQueryPrefixes.some((p) => {
|
const t = target.trim().toLowerCase();
|
||||||
const pl = p.toLowerCase();
|
const m = WMI_QUERY_REGEX.exec(t);
|
||||||
// Whitespace-bounded: the query is the prefix or continues with whitespace
|
let ok = m !== null && allowedWmiClasses.has(m[1]);
|
||||||
// (mirrors the agent's boundary check, prevents Win32_BIOSX spoofing).
|
|
||||||
return t === pl || (t.startsWith(pl) && /\s/.test(t.charAt(pl.length)));
|
|
||||||
});
|
|
||||||
if (!ok) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`bg-white rounded-lg shadow border transition-all ${
|
className={`bg-white rounded-lg shadow border transition-all ${
|
||||||
@@ -99,13 +104,15 @@ export function GatherRuleCard({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggle();
|
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 ${
|
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"
|
? "opacity-50 cursor-not-allowed"
|
||||||
: "cursor-pointer"
|
: "cursor-pointer"
|
||||||
} ${rule.enabled ? "bg-emerald-500" : "bg-gray-300"}`}
|
} ${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
|
<span
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
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"}
|
{rule.isBuiltIn ? "Built-in" : rule.isCommunity ? "Community" : "Custom"}
|
||||||
</span>
|
</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 */}
|
{/* Expand/Collapse Arrow */}
|
||||||
<svg
|
<svg
|
||||||
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${
|
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}
|
{rule.target}
|
||||||
</code>
|
</code>
|
||||||
<ValidationIndicator result={targetValidation} className="ml-2" />
|
<ValidationIndicator result={targetValidation} className="ml-2" />
|
||||||
|
{isBlockedTarget && (
|
||||||
|
<p className="mt-1.5 text-xs text-gray-500">
|
||||||
|
This target isn'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>
|
</div>
|
||||||
{rule.parameters && Object.keys(rule.parameters).length > 0 && (
|
{rule.parameters && Object.keys(rule.parameters).length > 0 && (
|
||||||
<div className="text-sm">
|
<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>
|
<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} />
|
<ValidationIndicator result={targetValidation} />
|
||||||
</div>
|
</div>
|
||||||
|
{targetValidation && !targetValidation.allowed && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
This target isn'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>
|
</div>
|
||||||
|
|
||||||
{/* Registry: optional Value Name */}
|
{/* Registry: optional Value Name */}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs
|
|||||||
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
|
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
|
||||||
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
||||||
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
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 { GatherRuleFormFields } from "./components/GatherRuleFormFields";
|
||||||
import { GatherRuleCard } from "./components/GatherRuleCard";
|
import { GatherRuleCard } from "./components/GatherRuleCard";
|
||||||
import { DOCS_URL } from "@/utils/config";
|
import { DOCS_URL } from "@/utils/config";
|
||||||
@@ -125,6 +125,12 @@ export default function GatherRulesPage() {
|
|||||||
}, [effectiveTenantId, getAccessToken]);
|
}, [effectiveTenantId, getAccessToken]);
|
||||||
|
|
||||||
const handleToggleRule = async (rule: GatherRule) => {
|
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);
|
setTogglingRule(rule.ruleId);
|
||||||
const result = await mutate(
|
const result = await mutate(
|
||||||
gatherRuleWriteUrl(rule.ruleId),
|
gatherRuleWriteUrl(rule.ruleId),
|
||||||
@@ -214,6 +220,9 @@ export default function GatherRulesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCreating(true);
|
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 = {
|
const payload = {
|
||||||
ruleId: form.ruleId,
|
ruleId: form.ruleId,
|
||||||
title: form.title,
|
title: form.title,
|
||||||
@@ -229,6 +238,7 @@ export default function GatherRulesPage() {
|
|||||||
...buildScopeFields(form),
|
...buildScopeFields(form),
|
||||||
outputEventType: form.outputEventType,
|
outputEventType: form.outputEventType,
|
||||||
outputSeverity: form.outputSeverity,
|
outputSeverity: form.outputSeverity,
|
||||||
|
enabled: !blocked,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await mutate(
|
const result = await mutate(
|
||||||
@@ -241,7 +251,9 @@ export default function GatherRulesPage() {
|
|||||||
);
|
);
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
trackEvent("rule_created", { ruleType: "gather" });
|
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);
|
setShowCreateForm(false);
|
||||||
setNewRule({ ...EMPTY_FORM });
|
setNewRule({ ...EMPTY_FORM });
|
||||||
setJsonModeCreate(false);
|
setJsonModeCreate(false);
|
||||||
@@ -306,6 +318,9 @@ export default function GatherRulesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
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 = {
|
const payload = {
|
||||||
title: form.title,
|
title: form.title,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
@@ -321,8 +336,7 @@ export default function GatherRulesPage() {
|
|||||||
outputEventType: form.outputEventType,
|
outputEventType: form.outputEventType,
|
||||||
outputSeverity: form.outputSeverity,
|
outputSeverity: form.outputSeverity,
|
||||||
version: bumpVersion(rule.version),
|
version: bumpVersion(rule.version),
|
||||||
enabled: rule.enabled,
|
enabled: rule.enabled && !forcedOff,
|
||||||
author: rule.author,
|
|
||||||
createdAt: rule.createdAt,
|
createdAt: rule.createdAt,
|
||||||
// Same wipe class as the toggle bug: the full-replace PUT must carry tags or they vanish.
|
// Same wipe class as the toggle bug: the full-replace PUT must carry tags or they vanish.
|
||||||
tags: rule.tags ?? [],
|
tags: rule.tags ?? [],
|
||||||
@@ -340,7 +354,9 @@ export default function GatherRulesPage() {
|
|||||||
);
|
);
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
trackEvent("rule_modified", { ruleType: "gather" });
|
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);
|
setEditingRuleId(null);
|
||||||
setJsonModeEdit(false);
|
setJsonModeEdit(false);
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { validateGatherRuleTarget } from "@/utils/guardValidation";
|
||||||
|
|
||||||
export interface GatherRule {
|
export interface GatherRule {
|
||||||
ruleId: string;
|
ruleId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -126,7 +128,7 @@ export const TARGET_PLACEHOLDERS: Record<string, string> = {
|
|||||||
export const TARGET_HINTS: 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.",
|
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.",
|
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.",
|
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.",
|
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.",
|
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);
|
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
|
* 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
|
* silently sends null (= unrestricted) and the rule runs everywhere while the form still
|
||||||
|
|||||||
@@ -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">;
|
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 {
|
export function bumpVersion(v: string): string {
|
||||||
const parts = (v ?? "1.0").split(".");
|
const parts = (v || "1.0").split(".");
|
||||||
const major = parts[0] ?? "1";
|
const major = parts[0] || "1";
|
||||||
const minor = parseInt(parts[1] ?? "0", 10);
|
const parsedMinor = parseInt(parts[1] ?? "0", 10);
|
||||||
return `${major}.${minor + 1}`;
|
const minor = Number.isNaN(parsedMinor) ? 0 : parsedMinor;
|
||||||
|
const trailingZeros = parts.slice(2).map(() => "0");
|
||||||
|
return [major, `${minor + 1}`, ...trailingZeros].join(".");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,32 @@ describe("validateWmiTarget", () => {
|
|||||||
const r = validateWmiTarget("", false);
|
const r = validateWmiTarget("", false);
|
||||||
expect(r.allowed).toBe(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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -39,6 +39,29 @@ const ALLOWED_COMMANDS_SET = new Set(
|
|||||||
ALLOWED_COMMANDS_LIST.map((c) => c.toLowerCase())
|
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";
|
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 };
|
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) {
|
if (trimmed.length >= prefix.length) {
|
||||||
const candidate = trimmed.substring(0, prefix.length);
|
const candidate = trimmed.substring(0, prefix.length);
|
||||||
if (candidate.toLowerCase() === prefix.toLowerCase()) {
|
if (candidate.toLowerCase() === prefix.toLowerCase()) {
|
||||||
@@ -280,7 +311,7 @@ export function validateWmiTarget(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "Not a recognized WMI query prefix",
|
reason: "Not an allowed WMI query — use SELECT <properties or *> FROM <allowed class>",
|
||||||
unrestricted: false,
|
unrestricted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user