From 61892592e490d2bbb985f2ff83f00cdc27c0da71 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 9 Sep 2026 22:53:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(rules):=20community=20rule=20submissions?= =?UTF-8?q?=20=E2=80=94=20submit=20custom=20rules=20from=20the=20portal,?= =?UTF-8?q?=20review=20via=20GA=20page=20or=20MCP,=20publish=20by=20repo?= =?UTF-8?q?=20commit=20+=20reseed=20(D-236)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenant admins submit references to their own gather/analyze rules; the server freezes the rule, records pre-flight findings and fire stats, and stores it under a 12-character id (RuleSubmissions, SessionReports layout). Attribution is the submitter's frozen choice (anonymous by default) and becomes the published author. Review (approve with reserved id / decline with comment) through /admin/reports/rule-submissions or the MCP tools list_rule_submissions, get_rule_submission, review_rule_submission, reseed_rules_from_github; published is derived from the global catalog, never stored. Analyze rules now get the same server-side author stamp as gather rules; ANALYZE-ID-001 carries author Community. Privacy page names the credit; bumps internal and .claude/commands gitlinks. Co-Authored-By: Claude Fable 5.1 --- .claude/commands | 2 +- internal | 2 +- rules/analyze/ANALYZE-ID-001.json | 1 + rules/dist/analyze-rules.json | 1 + ...AnalyzeRulesFunctionAuthorEndpointTests.cs | 206 +++++++ .../RuleSubmissionServiceTests.cs | 492 ++++++++++++++++ .../RuleSubmissionsWireParityTests.cs | 134 +++++ .../SharedManifestParityTests.cs | 6 + .../TableRuleSubmissionRepositoryTests.cs | 159 +++++ .../DataAccess/DataAccessServiceExtensions.cs | 1 + .../TableRuleSubmissionRepository.cs | 242 ++++++++ .../Functions/Rules/AnalyzeRulesFunction.cs | 12 +- .../GetGlobalRuleSubmissionFunction.cs | 41 ++ .../GetGlobalRuleSubmissionsFunction.cs | 77 +++ .../Submissions/GetRuleSubmissionsFunction.cs | 41 ++ .../ReviewRuleSubmissionFunction.cs | 92 +++ .../SubmitRuleSubmissionsFunction.cs | 137 +++++ .../WithdrawRuleSubmissionFunction.cs | 55 ++ .../Pagination/RuleSubmissionsPagination.cs | 92 +++ .../AutopilotMonitor.Functions/Program.cs | 1 + .../Security/EndpointAccessPolicyCatalog.cs | 9 + .../Services/AnalyzeRuleService.cs | 15 +- .../Offboarding/TenantOffboardingHandler.cs | 1 + .../Services/RuleSubmissionService.cs | 532 +++++++++++++++++ .../Services/TelegramNotificationService.cs | 36 ++ .../TenantNotificationAudienceCatalog.cs | 2 + .../src/__tests__/tools-catalog-order.test.ts | 20 +- .../src/generated/wire-types.generated.ts | 113 ++++ .../generated/wire-vocabularies.generated.ts | 32 + .../autopilot-monitor-mcp/src/tools/admin.ts | 160 +++++ .../AutopilotMonitor.Shared/Constants.cs | 7 + .../DataAccess/IRuleSubmissionRepository.cs | 32 + .../Models/RuleSubmissionApiModels.cs | 140 +++++ .../Models/Rules/RuleSubmission.cs | 127 ++++ .../components/RuleSubmissionsSection.tsx | 551 ++++++++++++++++++ .../admin/reports/[section]/SectionClient.tsx | 2 + .../app/admin/reports/reportsNavSections.ts | 1 + .../sections/SectionRuleSubmissions.tsx | 15 + .../app/analyze-rules/page.tsx | 44 +- .../app/gather-rules/page.tsx | 43 +- .../app/privacy/page.tsx | 4 +- .../rules/CommunityContributionBox.tsx | 37 ++ .../components/rules/MySubmissionsList.tsx | 141 +++++ .../components/rules/SubmitRulesModal.tsx | 295 ++++++++++ src/Web/autopilot-monitor-web/lib/api.ts | 21 + .../autopilot-monitor-web/lib/docsPaths.ts | 1 + .../lib/globalNavConfig.tsx | 1 + .../scripts/generate-shared-manifest-types.js | 8 + .../utils/shared-manifests.generated.ts | 20 + .../utils/shared-manifests.json | 544 +++++++++++++++++ .../utils/wire-types.generated.ts | 113 ++++ 51 files changed, 4828 insertions(+), 33 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRulesFunctionAuthorEndpointTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionServiceTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionsWireParityTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TableRuleSubmissionRepositoryTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleSubmissionRepository.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/GetGlobalRuleSubmissionFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/GetGlobalRuleSubmissionsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/GetRuleSubmissionsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/ReviewRuleSubmissionFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/SubmitRuleSubmissionsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Rules/Submissions/WithdrawRuleSubmissionFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Pagination/RuleSubmissionsPagination.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/RuleSubmissionService.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleSubmissionRepository.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/RuleSubmissionApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleSubmission.cs create mode 100644 src/Web/autopilot-monitor-web/app/admin/components/RuleSubmissionsSection.tsx create mode 100644 src/Web/autopilot-monitor-web/app/admin/reports/sections/SectionRuleSubmissions.tsx create mode 100644 src/Web/autopilot-monitor-web/components/rules/CommunityContributionBox.tsx create mode 100644 src/Web/autopilot-monitor-web/components/rules/MySubmissionsList.tsx create mode 100644 src/Web/autopilot-monitor-web/components/rules/SubmitRulesModal.tsx diff --git a/.claude/commands b/.claude/commands index 8dcfe1e2..80ee4efa 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit 8dcfe1e23e5520ca373caf4afca257d3a1a67d9b +Subproject commit 80ee4efaf6066b0b720b8d6a498c3586b61d504c diff --git a/internal b/internal index 33acedeb..8efdea43 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 33acedeb8514afc90d5f1a0cb7bb5359e08e6174 +Subproject commit 8efdea43fab477dce8cce1abde9ccc7bfe5921a8 diff --git a/rules/analyze/ANALYZE-ID-001.json b/rules/analyze/ANALYZE-ID-001.json index 6da3acd5..389b60a1 100644 --- a/rules/analyze/ANALYZE-ID-001.json +++ b/rules/analyze/ANALYZE-ID-001.json @@ -5,6 +5,7 @@ "description": "Detects when a certificate with the configured subject is missing from the local machine personal store (LocalMachine\\My). Requires the gather rule GATHER-ID-002 (machine cert store inventory) to be enabled — the check runs against its gather_machine_cert_store output. A missing certificate may indicate that MDM enrollment certificate provisioning or SCEP certificate deployment failed.", "severity": "warning", "category": "identity", + "author": "Community", "isCommunity": true, "enabled": false, "evaluateOn": [ diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json index 409c4642..ab52e2ee 100644 --- a/rules/dist/analyze-rules.json +++ b/rules/dist/analyze-rules.json @@ -2806,6 +2806,7 @@ "description": "Detects when a certificate with the configured subject is missing from the local machine personal store (LocalMachine\\My). Requires the gather rule GATHER-ID-002 (machine cert store inventory) to be enabled — the check runs against its gather_machine_cert_store output. A missing certificate may indicate that MDM enrollment certificate provisioning or SCEP certificate deployment failed.", "severity": "warning", "category": "identity", + "author": "Community", "isCommunity": true, "enabled": false, "evaluateOn": [ diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRulesFunctionAuthorEndpointTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRulesFunctionAuthorEndpointTests.cs new file mode 100644 index 00000000..1276f3cd --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRulesFunctionAuthorEndpointTests.cs @@ -0,0 +1,206 @@ +using System.Net; +using System.Security.Claims; +using System.Text; +using AutopilotMonitor.Functions.Functions.Rules; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Azure.Core.Serialization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Analyze twin of : the rule Author is +/// stamped from the caller's JWT on create, on a PUT-upsert of an unknown rule and on the +/// template copy, and a true update keeps the original creator over editor and body. +/// Before this pin, custom analyze rules were created as "Autopilot Monitor" and the edit +/// path let the browser choose the author. +/// +public class AnalyzeRulesFunctionAuthorEndpointTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string RuleId = "ANALYZE-CUSTOM-777"; + private const string TemplateRuleId = "ANALYZE-SEC-004"; + + private sealed class FakeHttpResponseData : HttpResponseData + { + public FakeHttpResponseData(FunctionContext context) : base(context) { } + public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK; + public override HttpHeadersCollection Headers { get; set; } = new(); + public override Stream Body { get; set; } = new MemoryStream(); + public override HttpCookies Cookies { get; } = new Mock().Object; + } + + private static FunctionContext BuildContext(ClaimsPrincipal? principal) + { + var services = new ServiceCollection(); + services.AddOptions(); + services.Configure(o => o.Serializer = + new JsonObjectSerializer(ApiJsonOptions.Create())); + var provider = services.BuildServiceProvider(); + + var items = new Dictionary(); + if (principal != null) + { + items["ClaimsPrincipal"] = principal; + } + + var context = new Mock(); + context.SetupGet(c => c.Items).Returns(items); + context.SetupGet(c => c.InstanceServices).Returns(provider); + return context.Object; + } + + private static HttpRequestData BuildRequest(ClaimsPrincipal? principal, object body) + { + var context = BuildContext(principal); + var req = new Mock(context); + req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection()); + req.SetupGet(r => r.Body).Returns( + new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body)))); + req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context)); + return req.Object; + } + + private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims) + => new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth")); + + private static ClaimsPrincipal AlicePrincipal() + => Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")); + + private static (AnalyzeRulesFunction function, List stored) BuildFunction( + AnalyzeRule? existingTenantRule = null, AnalyzeRule? globalRule = null) + { + var repo = new Mock(MockBehavior.Loose); + var stored = new List(); + + repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + repo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync( + globalRule == null ? new List() : new List { globalRule }); + repo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync( + existingTenantRule == null ? new List() : new List { existingTenantRule }); + repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary()); + repo.Setup(r => r.StoreRuleStateAsync(TenantId, It.IsAny(), It.IsAny())).ReturnsAsync(true); + repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny(), TenantId)) + .Callback((rule, _) => stored.Add(rule)) + .ReturnsAsync(true); + + var service = new AnalyzeRuleService(repo.Object, NullLogger.Instance); + return (new AnalyzeRulesFunction(NullLogger.Instance, service), stored); + } + + private static AnalyzeRule SpoofedPayload() => new() + { + RuleId = RuleId, + Title = "Proxy PAC unreachable", + Description = "Custom", + Severity = "warning", + Category = "network", + Explanation = "The PAC file could not be fetched.", + IsBuiltIn = false, + IsCommunity = false, + Enabled = true, + Author = "Spoofed Author", + Conditions = new List + { + new() { Signal = "pac_failed", Source = "event_type", EventType = "network_pac_failed", Required = true } + }, + }; + + [Fact] + public async Task CreateRule_stamps_author_from_jwt_not_from_body() + { + var (function, stored) = BuildFunction(); + var req = BuildRequest(AlicePrincipal(), SpoofedPayload()); + + var response = await function.CreateRule(req); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var written = Assert.Single(stored); + Assert.Equal("Alice Admin", written.Author); + } + + [Fact] + public async Task CreateRule_falls_back_to_product_name_without_identifying_claims() + { + var (function, stored) = BuildFunction(); + var req = BuildRequest(Principal(("tid", TenantId)), SpoofedPayload()); + + var response = await function.CreateRule(req); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var written = Assert.Single(stored); + Assert.Equal("Autopilot Monitor", written.Author); + } + + [Fact] + public async Task UpdateRule_upsert_of_unknown_rule_stamps_author_from_jwt() + { + var (function, stored) = BuildFunction(existingTenantRule: null); + var req = BuildRequest(AlicePrincipal(), SpoofedPayload()); + + var response = await function.UpdateRule(req, RuleId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var written = Assert.Single(stored); + Assert.Equal("Alice Admin", written.Author); + } + + [Fact] + public async Task UpdateRule_true_update_keeps_original_author_over_editor_and_body() + { + var existing = SpoofedPayload(); + existing.Author = "Original Creator"; + existing.CreatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var (function, stored) = BuildFunction(existing); + var req = BuildRequest( + Principal(("tid", TenantId), ("name", "Bob Editor")), SpoofedPayload()); + + var response = await function.UpdateRule(req, RuleId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var written = Assert.Single(stored); + Assert.Equal("Original Creator", written.Author); + Assert.Equal(existing.CreatedAt, written.CreatedAt); + } + + [Fact] + public async Task CreateFromTemplate_stamps_the_copying_admin_not_the_template_author() + { + var template = new AnalyzeRule + { + RuleId = TemplateRuleId, + Title = "AutoLogon enabled for an unexpected user", + Description = "Template", + Severity = "warning", + Category = "security", + Explanation = "Template", + IsBuiltIn = true, + Author = "Autopilot Monitor", + Conditions = new List + { + new() { Signal = "autologon", Source = "event_type", EventType = "autologon_analysis", Operator = "equals", Value = "PLACEHOLDER", Required = true } + }, + TemplateVariables = new List + { + new() { Name = "allowed", Label = "Allowed", ConditionIndex = 0, Field = "value" } + }, + }; + var (function, stored) = BuildFunction(globalRule: template); + var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary { ["allowed"] = "KIOSK-1" } }); + + var response = await function.CreateFromTemplate(req, TemplateRuleId); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var written = Assert.Single(stored); + Assert.Equal("Alice Admin", written.Author); + Assert.Equal(TemplateRuleId, written.DerivedFromTemplateRuleId); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionServiceTests.cs new file mode 100644 index 00000000..a5d05bee --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleSubmissionServiceTests.cs @@ -0,0 +1,492 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The pure pieces of (id suggestion, repo file, snapshot +/// stripping, attribution shape) and the review state machine against mocked repositories. +/// +public class RuleSubmissionServiceTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + + // ── id suggestion ─────────────────────────────────────────────────────── + + [Fact] + public void Suggest_is_max_plus_one_and_never_fills_a_gap() + { + // 001 and 003 exist, 002 is a retired id — the next is 004, not 002. + var taken = new[] { "ANALYZE-NET-001", "ANALYZE-NET-003", "ANALYZE-APP-018", "GATHER-NET-009" }; + Assert.Equal("ANALYZE-NET-004", RuleSubmissionService.SuggestPublishedRuleId("analyze", "network", taken)); + } + + [Fact] + public void Suggest_starts_at_001_for_an_empty_category_and_maps_the_kind_convention() + { + Assert.Equal("ANALYZE-NET-001", RuleSubmissionService.SuggestPublishedRuleId("analyze", "network", Array.Empty())); + Assert.Equal("GATHER-DEVICE-001", RuleSubmissionService.SuggestPublishedRuleId("gather", "device", Array.Empty())); + Assert.Equal("ANALYZE-DEV-001", RuleSubmissionService.SuggestPublishedRuleId("analyze", "device", Array.Empty())); + Assert.Equal("ANALYZE-ENRL-001", RuleSubmissionService.SuggestPublishedRuleId("analyze", "enrollment", Array.Empty())); + Assert.Equal("GATHER-ENROLL-001", RuleSubmissionService.SuggestPublishedRuleId("gather", "enrollment", Array.Empty())); + } + + [Fact] + public void Suggest_is_case_insensitive_over_taken_ids() + { + Assert.Equal("ANALYZE-SEC-008", RuleSubmissionService.SuggestPublishedRuleId("analyze", "security", new[] { "analyze-sec-007" })); + } + + [Fact] + public void Suggest_falls_back_to_the_upper_cased_category_when_unmapped() + { + Assert.Equal("ANALYZE-OFFICE-001", RuleSubmissionService.SuggestPublishedRuleId("analyze", "office", Array.Empty())); + } + + // ── freeze + repo file ────────────────────────────────────────────────── + + private static AnalyzeRule CustomAnalyzeRule() => new() + { + RuleId = "ANALYZE-CUSTOM-003", + Title = "Proxy PAC unreachable", + Description = "PAC download fails", + Severity = "warning", + Category = "network", + Version = "1.2.0", + Author = "Alice Admin", + Enabled = false, + IsBuiltIn = false, + IsCommunity = false, + Provenance = "embedded", + MarkSessionAsFailed = true, + Notify = true, + NotifyChannelIds = new List { "chan-1" }, + DerivedFromTemplateRuleId = "ANALYZE-SEC-004", + Explanation = "The PAC file could not be fetched.", + Conditions = new List + { + new() { Signal = "pac_failed", Source = "event_type", EventType = "network_pac_failed", Required = true }, + }, + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + UpdatedAt = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc), + }; + + [Fact] + public void Freeze_strips_server_and_tenant_fields_and_keeps_the_rule() + { + var json = RuleSubmissionService.FreezeRule(CustomAnalyzeRule()); + var node = JsonNode.Parse(json)!.AsObject(); + + foreach (var stripped in new[] { "isBuiltIn", "provenance", "createdAt", "updatedAt", "markSessionAsFailed", "notifyDefault", "notify", "notifyChannelIds" }) + Assert.False(node.ContainsKey(stripped), $"{stripped} must not be frozen"); + + Assert.Equal("ANALYZE-CUSTOM-003", node["ruleId"]!.GetValue()); + Assert.Equal("Alice Admin", node["author"]!.GetValue()); + Assert.Equal("ANALYZE-SEC-004", node["derivedFromTemplateRuleId"]!.GetValue()); + Assert.Single(node["conditions"]!.AsArray()); + } + + [Fact] + public void RepoFile_carries_schema_pointer_assigned_id_credit_and_community_flag_in_repo_order() + { + var s = new RuleSubmission + { + RuleKind = RuleSubmissionKinds.Analyze, + RuleJson = RuleSubmissionService.FreezeRule(CustomAnalyzeRule()), + AttributionName = RuleAttributionModes.AnonymousAuthor, + }; + + var file = RuleSubmissionService.BuildRepoFile(s, "ANALYZE-NET-002"); + + Assert.Equal("rules/analyze/ANALYZE-NET-002.json", file.Path); + Assert.EndsWith("\n", file.Content); + var node = JsonNode.Parse(file.Content)!.AsObject(); + var keys = node.Select(kv => kv.Key).ToList(); + Assert.Equal(new[] { "$schema", "ruleId", "title", "description", "severity", "category", "version", "author", "enabled", "isCommunity" }, keys.Take(10)); + Assert.Equal("../schema/analyze-rule.schema.json", node["$schema"]!.GetValue()); + Assert.Equal("ANALYZE-NET-002", node["ruleId"]!.GetValue()); + Assert.Equal("Community contribution", node["author"]!.GetValue()); + Assert.True(node["isCommunity"]!.GetValue()); + // Analyze rules ship enabled — the tenant's own toggle state does not travel. + Assert.True(node["enabled"]!.GetValue()); + Assert.False(node.ContainsKey("derivedFromTemplateRuleId")); + Assert.False(node.ContainsKey("isBuiltIn")); + Assert.Contains("\"explanation\": \"The PAC file could not be fetched.\"", file.Content); + } + + [Fact] + public void RepoFile_for_a_gather_rule_ships_disabled_and_without_severity() + { + var gather = new GatherRule + { + RuleId = "GATHER-CUSTOM-001", + Title = "BIOS config", + Description = "Reads the BIOS registry block", + Category = "device", + Enabled = true, + IsBuiltIn = false, + CollectorType = "registry", + Target = "HKLM\\SOFTWARE\\Vendor\\BIOS", + Trigger = "startup", + OutputEventType = "gather_bios_config", + }; + var s = new RuleSubmission + { + RuleKind = RuleSubmissionKinds.Gather, + RuleJson = RuleSubmissionService.FreezeRule(gather), + AttributionName = "Contoso IT", + }; + + var file = RuleSubmissionService.BuildRepoFile(s, "GATHER-DEVICE-009"); + + Assert.Equal("rules/gather/GATHER-DEVICE-009.json", file.Path); + var node = JsonNode.Parse(file.Content)!.AsObject(); + Assert.Equal("../schema/gather-rule.schema.json", node["$schema"]!.GetValue()); + Assert.False(node["enabled"]!.GetValue()); + Assert.Equal("Contoso IT", node["author"]!.GetValue()); + Assert.False(node.ContainsKey("severity")); + // No \u escaping of the backslash-heavy registry path beyond JSON's own rules. + Assert.Contains("HKLM\\\\SOFTWARE\\\\Vendor\\\\BIOS", file.Content); + } + + // ── attribution ───────────────────────────────────────────────────────── + + [Theory] + [InlineData("Alice A.")] + [InlineData("Contoso IT & Ops (EMEA)")] + [InlineData("Zoë Müller-Lüdenscheidt")] + public void AttributionName_accepts_names(string name) + => Assert.Equal(name, RuleSubmissionService.ValidateAttributionName(name)); + + [Theory] + [InlineData("")] + [InlineData("