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 @@ -23,6 +23,7 @@ control plane, or runtime AI filter.
- `src/Bower.Output.*`: bounded delivery adapters.
- `src/Bower.Source.Aws`: AWS security telemetry parsers (CloudTrail, GuardDuty, Security Hub, CloudWatch).
- `src/Bower.Ocsf`: OCSF normalisation engine and source mappers.
- `src/Bower.Detection`: Sigma-compatible detection rules engine.
- `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 @@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Source.Aws", "src\Bow
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Ocsf", "src\Bower.Ocsf\Bower.Ocsf.csproj", "{F95A8CF7-7C6D-49DF-853D-F845DB65C26F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bower.Detection", "src\Bower.Detection\Bower.Detection.csproj", "{C63ADD85-CA8A-49DC-9366-30E9426C7062}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -253,6 +255,18 @@ Global
{F95A8CF7-7C6D-49DF-853D-F845DB65C26F}.Release|x64.Build.0 = Release|Any CPU
{F95A8CF7-7C6D-49DF-853D-F845DB65C26F}.Release|x86.ActiveCfg = Release|Any CPU
{F95A8CF7-7C6D-49DF-853D-F845DB65C26F}.Release|x86.Build.0 = Release|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Debug|x64.ActiveCfg = Debug|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Debug|x64.Build.0 = Debug|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Debug|x86.ActiveCfg = Debug|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Debug|x86.Build.0 = Debug|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Release|Any CPU.Build.0 = Release|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Release|x64.ActiveCfg = Release|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Release|x64.Build.0 = Release|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Release|x86.ActiveCfg = Release|Any CPU
{C63ADD85-CA8A-49DC-9366-30E9426C7062}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -262,5 +276,6 @@ Global
{A9934CA4-88C5-4AD0-99C9-92490167B48E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{EA3A7CAF-51CE-4A81-A808-2239893A5332} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F95A8CF7-7C6D-49DF-853D-F845DB65C26F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C63ADD85-CA8A-49DC-9366-30E9426C7062} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
19 changes: 19 additions & 0 deletions rules/sigma/auth_failure_burst.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
title: Authentication Failure Observed
id: bower-auth-failure-001
status: stable
description: Detects authentication failure security events emitted by applications.
version: 1.0.0
level: medium
logsource:
product: bower
service: authentication
detection:
selection:
EventType: authentication_failure
EventResult: Failure
condition: selection
tags:
- attack.t1110
- attack.credential_access
falsepositives:
- User mistyped password during legitimate login
5 changes: 5 additions & 0 deletions src/Bower.Detection/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Detection engine instructions

Rules are declarative only. Never evaluate untrusted code. Keep matching
deterministic, bounded and fail-closed on malformed rules. Preserve MITRE tags
and rule version identity in every alert.
6 changes: 6 additions & 0 deletions src/Bower.Detection/Bower.Detection.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../Bower.Contracts/Bower.Contracts.csproj" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
</Project>
244 changes: 244 additions & 0 deletions src/Bower.Detection/DetectionEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
using System.Text.Json;
using Bower.Contracts;

namespace Bower.Detection;

public sealed class DetectionEngine
{
private readonly IReadOnlyList<DetectionRule> rules;
private readonly HashSet<string> suppressedRuleIds;
private readonly HashSet<string> seenFingerprints = new(StringComparer.Ordinal);

public DetectionEngine(
IEnumerable<DetectionRule> rules,
IEnumerable<string>? suppressedRuleIds = null)
{
this.rules = rules.ToArray();
this.suppressedRuleIds = new HashSet<string>(
suppressedRuleIds ?? [],
StringComparer.OrdinalIgnoreCase);
}

public static DetectionEngine FromDirectory(
string directory,
IEnumerable<string>? suppressedRuleIds = null)
{
return new DetectionEngine(SigmaRuleLoader.LoadDirectory(directory), suppressedRuleIds);
}

public IReadOnlyList<DetectionRule> Rules => rules;

public DetectionEvaluationResult Evaluate(SecurityEventEnvelope envelope, DateTimeOffset? now = null)
{
ArgumentNullException.ThrowIfNull(envelope);
DateTimeOffset detectedAt = now ?? DateTimeOffset.UtcNow;
List<DetectionAlert> alerts = [];
List<string> suppressed = [];

foreach (DetectionRule rule in rules)
{
if (suppressedRuleIds.Contains(rule.Id))
{
suppressed.Add(rule.Id);
continue;
}

if (!Matches(rule, envelope, out List<string> matchedFields))
{
continue;
}

string fingerprint = $"{rule.Id}:{envelope.EventId}:{rule.RuleHash}";
if (!seenFingerprints.Add(fingerprint))
{
continue;
}

alerts.Add(
new DetectionAlert(
Guid.CreateVersion7().ToString(),
rule.Id,
rule.Title,
rule.Version,
rule.RuleHash,
rule.Level,
RiskScore(rule.Level),
detectedAt,
envelope.EventId,
envelope.EventType,
envelope.Actor?.Username ?? envelope.Actor?.UserId,
envelope.Source?.IpAddress,
rule.MitreTechniques,
matchedFields,
$"{rule.Title} matched event {envelope.EventType} ({envelope.EventAction})"));
}

return new DetectionEvaluationResult(alerts, suppressed.Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
}

public DetectionEvaluationResult EvaluateJson(string eventJson, DateTimeOffset? now = null)
{
SecurityEventEnvelope? envelope = JsonSerializer.Deserialize<SecurityEventEnvelope>(
eventJson,
BowerJson.Options);
if (envelope is null)
{
throw new InvalidDataException("Event JSON did not deserialize to SecurityEventEnvelope.");
}

return Evaluate(envelope, now);
}

private static bool Matches(
DetectionRule rule,
SecurityEventEnvelope envelope,
out List<string> matchedFields)
{
matchedFields = [];
Dictionary<string, string> haystack = BuildHaystack(envelope);

// MVP: condition "selection" or "selection1 or selection2" — all keys under named selection groups.
string[] groups = rule.Condition
.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(token => !token.Equals("or", StringComparison.OrdinalIgnoreCase)
&& !token.Equals("and", StringComparison.OrdinalIgnoreCase)
&& !token.Equals("not", StringComparison.OrdinalIgnoreCase)
&& !token.Equals("1", StringComparison.Ordinal)
&& !token.Equals("of", StringComparison.OrdinalIgnoreCase)
&& !token.Equals("them", StringComparison.OrdinalIgnoreCase))
.ToArray();

if (groups.Length == 0)
{
groups = rule.DetectionFields.Keys.ToArray();
}

bool anyGroup = rule.Condition.Contains(" or ", StringComparison.OrdinalIgnoreCase)
|| rule.Condition.Contains("1 of them", StringComparison.OrdinalIgnoreCase);

List<bool> groupResults = [];
foreach (string group in groups)
{
if (!rule.DetectionFields.TryGetValue(group, out string? selection))
{
groupResults.Add(false);
continue;
}

bool groupMatch = MatchSelection(selection, haystack, matchedFields);
groupResults.Add(groupMatch);
}

return anyGroup ? groupResults.Any(result => result) : groupResults.All(result => result);
}

private static bool MatchSelection(
string selection,
IReadOnlyDictionary<string, string> haystack,
List<string> matchedFields)
{
// selection forms: "field:value|value2;field2:value"
string[] clauses = selection.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (clauses.Length == 0)
{
return ContainsIgnoreCase(string.Join(' ', haystack.Values), selection, matchedFields, "payload");
}

foreach (string clause in clauses)
{
int separator = clause.IndexOf(':');
if (separator <= 0)
{
if (!ContainsIgnoreCase(string.Join(' ', haystack.Values), clause, matchedFields, "payload"))
{
return false;
}

continue;
}

string field = clause[..separator].Trim().TrimEnd('|', '*');
string pattern = clause[(separator + 1)..];
if (!haystack.TryGetValue(field, out string? value))
{
// also try event.* aliases
string? alias = haystack.FirstOrDefault(pair =>
pair.Key.EndsWith(field, StringComparison.OrdinalIgnoreCase)).Value;
if (alias is null)
{
return false;
}

value = alias;
}

string[] alternatives = pattern.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
bool matched = alternatives.Any(option =>
value.Contains(option, StringComparison.OrdinalIgnoreCase));
if (!matched)
{
return false;
}

matchedFields.Add(field);
}

return true;
}

private static bool ContainsIgnoreCase(
string haystack,
string needle,
List<string> matchedFields,
string fieldName)
{
if (haystack.Contains(needle, StringComparison.OrdinalIgnoreCase))
{
matchedFields.Add(fieldName);
return true;
}

return false;
}

private static Dictionary<string, string> BuildHaystack(SecurityEventEnvelope envelope)
{
Dictionary<string, string> values = new(StringComparer.OrdinalIgnoreCase)
{
["EventType"] = envelope.EventType,
["EventAction"] = envelope.EventAction,
["EventCategory"] = envelope.EventCategory,
["EventResult"] = envelope.EventResult.ToString(),
["EventOutcomeReason"] = envelope.EventOutcomeReason ?? string.Empty,
["ActorUsername"] = envelope.Actor?.Username ?? string.Empty,
["ActorUserId"] = envelope.Actor?.UserId ?? string.Empty,
["SourceIp"] = envelope.Source?.IpAddress ?? string.Empty,
["TargetName"] = envelope.Target?.Name ?? string.Empty,
["TargetType"] = envelope.Target?.Type ?? string.Empty,
["Application"] = envelope.Application.Name
};

if (envelope.Labels is not null)
{
foreach ((string key, string value) in envelope.Labels)
{
values[key] = value;
}
}

return values;
}

private static int RiskScore(string level)
{
return level.ToLowerInvariant() switch
{
"informational" => 10,
"low" => 25,
"medium" => 50,
"high" => 75,
"critical" => 95,
_ => 40
};
}
}
36 changes: 36 additions & 0 deletions src/Bower.Detection/DetectionModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Bower.Detection;

public sealed record DetectionRule(
string Id,
string Title,
string Version,
string Level,
string Status,
string Description,
IReadOnlyList<string> LogSources,
IReadOnlyDictionary<string, string> DetectionFields,
string Condition,
IReadOnlyList<string> MitreTechniques,
IReadOnlyList<string> FalsePositiveHints,
string RuleHash);

public sealed record DetectionAlert(
string AlertId,
string RuleId,
string RuleTitle,
string RuleVersion,
string RuleHash,
string Level,
int RiskScore,
DateTimeOffset DetectedAt,
string EventId,
string? EventType,
string? Actor,
string? SourceIp,
IReadOnlyList<string> MitreTechniques,
IReadOnlyList<string> MatchedFields,
string Summary);

public sealed record DetectionEvaluationResult(
IReadOnlyList<DetectionAlert> Alerts,
IReadOnlyList<string> SuppressedRuleIds);
Loading