From 7e0ad83e539bd6926014efced1e0316e0853e90d Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Tue, 28 Jul 2026 22:41:49 +1000 Subject: [PATCH] feat(ama): Azure Monitor Agent companion discovery and custom log mapping --- AGENTS.md | 1 + Bower.sln | 15 ++ src/Bower.Source.Ama/AGENTS.md | 4 + src/Bower.Source.Ama/AmaCompanion.cs | 239 ++++++++++++++++++ src/Bower.Source.Ama/Bower.Source.Ama.csproj | 6 + src/Bower.Source.Ama/packages.lock.json | 16 ++ .../AmaCompanionServiceTests.cs | 73 ++++++ tests/Bower.UnitTests/Bower.UnitTests.csproj | 1 + tests/Bower.UnitTests/packages.lock.json | 7 + 9 files changed, 362 insertions(+) create mode 100644 src/Bower.Source.Ama/AGENTS.md create mode 100644 src/Bower.Source.Ama/AmaCompanion.cs create mode 100644 src/Bower.Source.Ama/Bower.Source.Ama.csproj create mode 100644 src/Bower.Source.Ama/packages.lock.json create mode 100644 tests/Bower.UnitTests/AmaCompanionServiceTests.cs diff --git a/AGENTS.md b/AGENTS.md index 5e3185b..c28c9da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ control plane, or runtime AI filter. - `src/Bower.Detection`: Sigma-compatible detection rules engine. - `src/Bower.Pipeline`: declarative telemetry pipeline model, templates and validation. - `src/Bower.Analytics`: telemetry quality and coverage scoring. +- `src/Bower.Source.Ama`: Azure Monitor Agent companion discovery and custom log mapping. - `schemas`, `policies`, `deploy`, `docs`, `tests`: versioned product assets. Inspect nearest `AGENTS.md` before editing. diff --git a/Bower.sln b/Bower.sln index feb8ae4..eba0f57 100644 --- a/Bower.sln +++ b/Bower.sln @@ -45,6 +45,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Pipeline", "src\Bower EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Analytics", "src\Bower.Analytics\Bower.Analytics.csproj", "{F1BFA9A3-4762-47C4-B97D-578A105A8D6C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Source.Ama", "src\Bower.Source.Ama\Bower.Source.Ama.csproj", "{A876B3F7-3209-4E9E-8993-2D9FF55637F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -295,6 +297,18 @@ Global {F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Release|x64.Build.0 = Release|Any CPU {F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Release|x86.ActiveCfg = Release|Any CPU {F1BFA9A3-4762-47C4-B97D-578A105A8D6C}.Release|x86.Build.0 = Release|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Debug|x64.ActiveCfg = Debug|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Debug|x64.Build.0 = Debug|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Debug|x86.ActiveCfg = Debug|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Debug|x86.Build.0 = Debug|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Release|Any CPU.Build.0 = Release|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Release|x64.ActiveCfg = Release|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Release|x64.Build.0 = Release|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Release|x86.ActiveCfg = Release|Any CPU + {A876B3F7-3209-4E9E-8993-2D9FF55637F2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -307,5 +321,6 @@ Global {C63ADD85-CA8A-49DC-9366-30E9426C7062} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E125F241-5F0D-43FD-8781-092995E1D309} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {F1BFA9A3-4762-47C4-B97D-578A105A8D6C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A876B3F7-3209-4E9E-8993-2D9FF55637F2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/Bower.Source.Ama/AGENTS.md b/src/Bower.Source.Ama/AGENTS.md new file mode 100644 index 0000000..918f257 --- /dev/null +++ b/src/Bower.Source.Ama/AGENTS.md @@ -0,0 +1,4 @@ +# AMA companion instructions + +Bower complements AMA; it does not replace it. Discovery is read-only. Custom log +tails must be path-bounded, size-bounded and never log raw payloads. diff --git a/src/Bower.Source.Ama/AmaCompanion.cs b/src/Bower.Source.Ama/AmaCompanion.cs new file mode 100644 index 0000000..fdabb89 --- /dev/null +++ b/src/Bower.Source.Ama/AmaCompanion.cs @@ -0,0 +1,239 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Bower.Contracts; + +namespace Bower.Source.Ama; + +public sealed record AmaDiscoveryResult( + bool Installed, + string? AgentVersion, + string? Status, + IReadOnlyList DataCollectionRuleIds, + IReadOnlyList WorkspaceIds, + IReadOnlyList Findings); + +public sealed record AmaCustomLogTarget( + string Id, + string Path, + string Format, + bool Enabled); + +public sealed record AmaCompanionOptions +{ + public required string SourceId { get; init; } + + public string Environment { get; init; } = "production"; + + public string ApplicationName { get; init; } = "ama-companion"; + + public string? InstallMarkerPath { get; init; } + + public string? ConfigDirectory { get; init; } + + public IReadOnlyList CustomLogs { get; init; } = []; + + public int MaximumLineBytes { get; init; } = 65_536; + + public void Validate() + { + ArgumentException.ThrowIfNullOrWhiteSpace(SourceId); + if (SourceId.Length > 128) + { + throw new ArgumentException("Source identifier cannot exceed 128 characters."); + } + + if (MaximumLineBytes is < 256 or > 1_048_576) + { + throw new ArgumentOutOfRangeException(nameof(MaximumLineBytes)); + } + + foreach (AmaCustomLogTarget target in CustomLogs) + { + ArgumentException.ThrowIfNullOrWhiteSpace(target.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(target.Path); + if (target.Path.Contains("..", StringComparison.Ordinal)) + { + throw new ArgumentException($"Custom log path for '{target.Id}' must not contain '..'."); + } + } + } +} + +public sealed class AmaCompanionService +{ + private readonly AmaCompanionOptions options; + private readonly Func pathExists; + private readonly Func readAllText; + + public AmaCompanionService( + AmaCompanionOptions options, + Func? pathExists = null, + Func? readAllText = null) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + this.options = options; + this.pathExists = pathExists ?? File.Exists; + this.readAllText = readAllText ?? File.ReadAllText; + } + + public AmaDiscoveryResult Discover() + { + List findings = []; + List dcrs = []; + List workspaces = []; + string? version = null; + string status = "not-found"; + bool installed = false; + + string marker = options.InstallMarkerPath + ?? (OperatingSystem.IsWindows() + ? @"C:\Program Files\Azure Monitor Agent\Agent\agentversion" + : "/opt/microsoft/azuremonitoragent/bin/azuremonitoragent"); + + if (pathExists(marker)) + { + installed = true; + status = "installed"; + findings.Add($"AMA marker present at configured path."); + try + { + string content = readAllText(marker).Trim(); + if (!string.IsNullOrWhiteSpace(content) && content.Length < 128) + { + version = content; + } + } + catch (IOException) + { + findings.Add("AMA marker exists but could not be read."); + } + } + else + { + findings.Add("AMA installation marker not found; companion mode can still tail custom logs."); + } + + string configDir = options.ConfigDirectory + ?? (OperatingSystem.IsWindows() + ? @"C:\Program Files\Azure Monitor Agent\Agent\config" + : "/etc/opt/microsoft/azuremonitoragent/config"); + + string inventoryPath = Path.Combine(configDir, "bower-ama-inventory.json"); + if (pathExists(inventoryPath)) + { + try + { + using JsonDocument document = JsonDocument.Parse(readAllText(inventoryPath)); + if (document.RootElement.TryGetProperty("dcrs", out JsonElement dcrNode) && + dcrNode.ValueKind == JsonValueKind.Array) + { + dcrs.AddRange(dcrNode.EnumerateArray().Select(item => item.GetString() ?? string.Empty) + .Where(item => !string.IsNullOrWhiteSpace(item))); + } + + if (document.RootElement.TryGetProperty("workspaces", out JsonElement wsNode) && + wsNode.ValueKind == JsonValueKind.Array) + { + workspaces.AddRange(wsNode.EnumerateArray().Select(item => item.GetString() ?? string.Empty) + .Where(item => !string.IsNullOrWhiteSpace(item))); + } + + if (document.RootElement.TryGetProperty("status", out JsonElement statusNode)) + { + status = statusNode.GetString() ?? status; + } + } + catch (JsonException) + { + findings.Add("AMA inventory file present but invalid JSON."); + } + } + + if (installed && dcrs.Count == 0) + { + findings.Add("No DCR identifiers discovered; verify AMA association."); + } + + return new AmaDiscoveryResult(installed, version, status, dcrs, workspaces, findings); + } + + public IReadOnlyList MapCustomLogLines( + string targetId, + IEnumerable lines, + DateTimeOffset? observedAt = null) + { + AmaCustomLogTarget target = options.CustomLogs.FirstOrDefault(item => + string.Equals(item.Id, targetId, StringComparison.Ordinal)) + ?? throw new ArgumentException($"Unknown custom log target '{targetId}'."); + + if (!target.Enabled) + { + return []; + } + + DateTimeOffset observed = observedAt ?? DateTimeOffset.UtcNow; + List events = []; + int index = 0; + foreach (string line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + int size = Encoding.UTF8.GetByteCount(line); + if (size > options.MaximumLineBytes) + { + throw new InvalidOperationException( + $"Custom log line for '{targetId}' is {size} bytes; maximum is {options.MaximumLineBytes}."); + } + + string originalId = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes($"{targetId}\u001f{index}\u001f{line}"))) + .ToLowerInvariant()[..24]; + index++; + + events.Add( + new SecurityEventEnvelope + { + SchemaVersion = SecurityEventEnvelope.CurrentSchemaVersion, + EventId = Guid.CreateVersion7().ToString(), + EventOriginalId = originalId, + TimeGenerated = observed, + TimeObserved = observed, + EventCategory = SecurityEventCategories.ApplicationSecurity, + EventType = "ama_custom_log", + EventAction = "log.append", + EventResult = EventResult.Unknown, + Application = new ApplicationContext + { + Name = options.ApplicationName, + Environment = options.Environment + }, + Target = new TargetContext + { + Type = "file", + Name = target.Path + }, + Collector = new CollectorContext + { + Id = options.SourceId, + Version = "0.1.0", + SourceAdapter = "ama.companion", + ConfigurationHash = originalId[..16], + ReceivedAt = observed + }, + Labels = new Dictionary + { + ["ama.targetId"] = target.Id, + ["ama.format"] = target.Format, + ["ama.linePreview"] = line.Length <= 256 ? line : line[..256] + } + }); + } + + return events; + } +} diff --git a/src/Bower.Source.Ama/Bower.Source.Ama.csproj b/src/Bower.Source.Ama/Bower.Source.Ama.csproj new file mode 100644 index 0000000..3b4156c --- /dev/null +++ b/src/Bower.Source.Ama/Bower.Source.Ama.csproj @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Bower.Source.Ama/packages.lock.json b/src/Bower.Source.Ama/packages.lock.json new file mode 100644 index 0000000..e220cbe --- /dev/null +++ b/src/Bower.Source.Ama/packages.lock.json @@ -0,0 +1,16 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "bower.abstractions": { + "type": "Project", + "dependencies": { + "Bower.Contracts": "[1.0.0, )" + } + }, + "bower.contracts": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/Bower.UnitTests/AmaCompanionServiceTests.cs b/tests/Bower.UnitTests/AmaCompanionServiceTests.cs new file mode 100644 index 0000000..bda2307 --- /dev/null +++ b/tests/Bower.UnitTests/AmaCompanionServiceTests.cs @@ -0,0 +1,73 @@ +using Bower.Contracts; +using Bower.Source.Ama; + +namespace Bower.UnitTests; + +public sealed class AmaCompanionServiceTests +{ + [Fact] + public void Discover_ReadsInventoryWhenPresent() + { + Dictionary files = new(StringComparer.Ordinal) + { + ["/tmp/ama/agentversion"] = "1.31.0", + ["/tmp/ama/config/bower-ama-inventory.json"] = + """{"status":"healthy","dcrs":["dcr-1"],"workspaces":["law-1"]}""" + }; + + AmaCompanionService service = new( + new AmaCompanionOptions + { + SourceId = "ama-1", + InstallMarkerPath = "/tmp/ama/agentversion", + ConfigDirectory = "/tmp/ama/config" + }, + pathExists: files.ContainsKey, + readAllText: path => files[path]); + + AmaDiscoveryResult result = service.Discover(); + + Assert.True(result.Installed); + Assert.Equal("1.31.0", result.AgentVersion); + Assert.Equal("healthy", result.Status); + Assert.Equal(["dcr-1"], result.DataCollectionRuleIds); + Assert.Equal(["law-1"], result.WorkspaceIds); + } + + [Fact] + public void MapCustomLogLines_CreatesEnvelopes() + { + AmaCompanionService service = new( + new AmaCompanionOptions + { + SourceId = "ama-1", + CustomLogs = + [ + new AmaCustomLogTarget("app", "/var/log/app/security.json", "json", true) + ] + }, + pathExists: _ => false, + readAllText: _ => string.Empty); + + IReadOnlyList events = service.MapCustomLogLines( + "app", + ["{\"action\":\"login-failed\"}", ""]); + + SecurityEventEnvelope envelope = Assert.Single(events); + Assert.Equal("ama_custom_log", envelope.EventType); + Assert.Equal("ama.companion", envelope.Collector?.SourceAdapter); + Assert.Equal("app", envelope.Labels?["ama.targetId"]); + } + + [Fact] + public void Options_RejectPathTraversal() + { + AmaCompanionOptions options = new() + { + SourceId = "ama-1", + CustomLogs = [new AmaCustomLogTarget("x", "../etc/passwd", "text", true)] + }; + + Assert.Throws(options.Validate); + } +} diff --git a/tests/Bower.UnitTests/Bower.UnitTests.csproj b/tests/Bower.UnitTests/Bower.UnitTests.csproj index 0ed4a57..1ea2f2a 100644 --- a/tests/Bower.UnitTests/Bower.UnitTests.csproj +++ b/tests/Bower.UnitTests/Bower.UnitTests.csproj @@ -37,5 +37,6 @@ + diff --git a/tests/Bower.UnitTests/packages.lock.json b/tests/Bower.UnitTests/packages.lock.json index 4af3f1e..4abe549 100644 --- a/tests/Bower.UnitTests/packages.lock.json +++ b/tests/Bower.UnitTests/packages.lock.json @@ -495,6 +495,13 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.10, )" } }, + "bower.source.ama": { + "type": "Project", + "dependencies": { + "Bower.Abstractions": "[1.0.0, )", + "Bower.Contracts": "[1.0.0, )" + } + }, "bower.source.aws": { "type": "Project", "dependencies": {