Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions Bower.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
4 changes: 4 additions & 0 deletions src/Bower.Source.Ama/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
239 changes: 239 additions & 0 deletions src/Bower.Source.Ama/AmaCompanion.cs
Original file line number Diff line number Diff line change
@@ -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<string> DataCollectionRuleIds,
IReadOnlyList<string> WorkspaceIds,
IReadOnlyList<string> 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<AmaCustomLogTarget> 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<string, bool> pathExists;
private readonly Func<string, string> readAllText;

public AmaCompanionService(
AmaCompanionOptions options,
Func<string, bool>? pathExists = null,
Func<string, string>? readAllText = null)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
this.options = options;
this.pathExists = pathExists ?? File.Exists;
this.readAllText = readAllText ?? File.ReadAllText;
}

public AmaDiscoveryResult Discover()
{
List<string> findings = [];
List<string> dcrs = [];
List<string> 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<SecurityEventEnvelope> MapCustomLogLines(
string targetId,
IEnumerable<string> 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<SecurityEventEnvelope> 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<string, string>
{
["ama.targetId"] = target.Id,
["ama.format"] = target.Format,
["ama.linePreview"] = line.Length <= 256 ? line : line[..256]
}
});
}

return events;
}
}
6 changes: 6 additions & 0 deletions src/Bower.Source.Ama/Bower.Source.Ama.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../Bower.Abstractions/Bower.Abstractions.csproj" />
<ProjectReference Include="../Bower.Contracts/Bower.Contracts.csproj" />
</ItemGroup>
</Project>
16 changes: 16 additions & 0 deletions src/Bower.Source.Ama/packages.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"bower.abstractions": {
"type": "Project",
"dependencies": {
"Bower.Contracts": "[1.0.0, )"
}
},
"bower.contracts": {
"type": "Project"
}
}
}
}
73 changes: 73 additions & 0 deletions tests/Bower.UnitTests/AmaCompanionServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Bower.Contracts;
using Bower.Source.Ama;

namespace Bower.UnitTests;

public sealed class AmaCompanionServiceTests
{
[Fact]
public void Discover_ReadsInventoryWhenPresent()
{
Dictionary<string, string> 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<SecurityEventEnvelope> 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<ArgumentException>(options.Validate);
}
}
1 change: 1 addition & 0 deletions tests/Bower.UnitTests/Bower.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Bower.Pipeline\Bower.Pipeline.csproj" />
<ProjectReference Include="..\..\src\Bower.Analytics\Bower.Analytics.csproj" />
<ProjectReference Include="..\..\src\Bower.Source.Ama\Bower.Source.Ama.csproj" />
</ItemGroup>
</Project>
Loading