diff --git a/AGENTS.md b/AGENTS.md
index 6240d51..3a47627 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/Bower.sln b/Bower.sln
index c322ba5..57f1994 100644
--- a/Bower.sln
+++ b/Bower.sln
@@ -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
@@ -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
@@ -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
diff --git a/rules/sigma/auth_failure_burst.yml b/rules/sigma/auth_failure_burst.yml
new file mode 100644
index 0000000..44ff6a1
--- /dev/null
+++ b/rules/sigma/auth_failure_burst.yml
@@ -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
diff --git a/src/Bower.Detection/AGENTS.md b/src/Bower.Detection/AGENTS.md
new file mode 100644
index 0000000..b545333
--- /dev/null
+++ b/src/Bower.Detection/AGENTS.md
@@ -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.
diff --git a/src/Bower.Detection/Bower.Detection.csproj b/src/Bower.Detection/Bower.Detection.csproj
new file mode 100644
index 0000000..613e357
--- /dev/null
+++ b/src/Bower.Detection/Bower.Detection.csproj
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/Bower.Detection/DetectionEngine.cs b/src/Bower.Detection/DetectionEngine.cs
new file mode 100644
index 0000000..fd3e130
--- /dev/null
+++ b/src/Bower.Detection/DetectionEngine.cs
@@ -0,0 +1,244 @@
+using System.Text.Json;
+using Bower.Contracts;
+
+namespace Bower.Detection;
+
+public sealed class DetectionEngine
+{
+ private readonly IReadOnlyList rules;
+ private readonly HashSet suppressedRuleIds;
+ private readonly HashSet seenFingerprints = new(StringComparer.Ordinal);
+
+ public DetectionEngine(
+ IEnumerable rules,
+ IEnumerable? suppressedRuleIds = null)
+ {
+ this.rules = rules.ToArray();
+ this.suppressedRuleIds = new HashSet(
+ suppressedRuleIds ?? [],
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ public static DetectionEngine FromDirectory(
+ string directory,
+ IEnumerable? suppressedRuleIds = null)
+ {
+ return new DetectionEngine(SigmaRuleLoader.LoadDirectory(directory), suppressedRuleIds);
+ }
+
+ public IReadOnlyList Rules => rules;
+
+ public DetectionEvaluationResult Evaluate(SecurityEventEnvelope envelope, DateTimeOffset? now = null)
+ {
+ ArgumentNullException.ThrowIfNull(envelope);
+ DateTimeOffset detectedAt = now ?? DateTimeOffset.UtcNow;
+ List alerts = [];
+ List suppressed = [];
+
+ foreach (DetectionRule rule in rules)
+ {
+ if (suppressedRuleIds.Contains(rule.Id))
+ {
+ suppressed.Add(rule.Id);
+ continue;
+ }
+
+ if (!Matches(rule, envelope, out List 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(
+ 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 matchedFields)
+ {
+ matchedFields = [];
+ Dictionary 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 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 haystack,
+ List 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 matchedFields,
+ string fieldName)
+ {
+ if (haystack.Contains(needle, StringComparison.OrdinalIgnoreCase))
+ {
+ matchedFields.Add(fieldName);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static Dictionary BuildHaystack(SecurityEventEnvelope envelope)
+ {
+ Dictionary 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
+ };
+ }
+}
diff --git a/src/Bower.Detection/DetectionModels.cs b/src/Bower.Detection/DetectionModels.cs
new file mode 100644
index 0000000..4a313d2
--- /dev/null
+++ b/src/Bower.Detection/DetectionModels.cs
@@ -0,0 +1,36 @@
+namespace Bower.Detection;
+
+public sealed record DetectionRule(
+ string Id,
+ string Title,
+ string Version,
+ string Level,
+ string Status,
+ string Description,
+ IReadOnlyList LogSources,
+ IReadOnlyDictionary DetectionFields,
+ string Condition,
+ IReadOnlyList MitreTechniques,
+ IReadOnlyList 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 MitreTechniques,
+ IReadOnlyList MatchedFields,
+ string Summary);
+
+public sealed record DetectionEvaluationResult(
+ IReadOnlyList Alerts,
+ IReadOnlyList SuppressedRuleIds);
diff --git a/src/Bower.Detection/SigmaRuleLoader.cs b/src/Bower.Detection/SigmaRuleLoader.cs
new file mode 100644
index 0000000..4cb8136
--- /dev/null
+++ b/src/Bower.Detection/SigmaRuleLoader.cs
@@ -0,0 +1,182 @@
+using System.Security.Cryptography;
+using System.Text;
+using YamlDotNet.RepresentationModel;
+
+namespace Bower.Detection;
+
+public static class SigmaRuleLoader
+{
+ public static DetectionRule LoadFile(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ string yaml = File.ReadAllText(path);
+ return LoadYaml(yaml, path);
+ }
+
+ public static IReadOnlyList LoadDirectory(string directory)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(directory);
+ if (!Directory.Exists(directory))
+ {
+ throw new DirectoryNotFoundException($"Rule directory '{directory}' was not found.");
+ }
+
+ return Directory
+ .EnumerateFiles(directory, "*.y*ml", SearchOption.AllDirectories)
+ .OrderBy(path => path, StringComparer.Ordinal)
+ .Select(LoadFile)
+ .ToArray();
+ }
+
+ public static DetectionRule LoadYaml(string yaml, string? sourceName = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(yaml);
+ if (Encoding.UTF8.GetByteCount(yaml) > 256 * 1024)
+ {
+ throw new InvalidDataException("Sigma rule exceeds 256 KiB limit.");
+ }
+
+ using StringReader reader = new(yaml);
+ YamlStream stream = new();
+ stream.Load(reader);
+ if (stream.Documents.Count == 0 || stream.Documents[0].RootNode is not YamlMappingNode root)
+ {
+ throw new InvalidDataException("Sigma rule root must be a mapping.");
+ }
+
+ string id = RequiredScalar(root, "id", sourceName);
+ string title = RequiredScalar(root, "title", sourceName);
+ string level = OptionalScalar(root, "level") ?? "medium";
+ string status = OptionalScalar(root, "status") ?? "experimental";
+ string description = OptionalScalar(root, "description") ?? string.Empty;
+ string version = OptionalScalar(root, "version")
+ ?? OptionalScalar(root, "date")
+ ?? "1.0.0";
+
+ List logSources = [];
+ if (root.Children.TryGetValue(new YamlScalarNode("logsource"), out YamlNode? logSourceNode) &&
+ logSourceNode is YamlMappingNode logSource)
+ {
+ foreach (string key in new[] { "product", "service", "category" })
+ {
+ string? value = OptionalScalar(logSource, key);
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ logSources.Add(value);
+ }
+ }
+ }
+
+ Dictionary fields = new(StringComparer.OrdinalIgnoreCase);
+ string condition = "selection";
+ if (root.Children.TryGetValue(new YamlScalarNode("detection"), out YamlNode? detectionNode) &&
+ detectionNode is YamlMappingNode detection)
+ {
+ condition = OptionalScalar(detection, "condition") ?? "selection";
+ foreach ((YamlNode keyNode, YamlNode valueNode) in detection.Children)
+ {
+ string key = keyNode.ToString();
+ if (key.Equals("condition", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ fields[key] = FlattenSelection(valueNode);
+ }
+ }
+
+ if (fields.Count == 0)
+ {
+ throw new InvalidDataException($"Sigma rule '{id}' has no detection selections.");
+ }
+
+ List techniques = [];
+ if (root.Children.TryGetValue(new YamlScalarNode("tags"), out YamlNode? tagsNode) &&
+ tagsNode is YamlSequenceNode tags)
+ {
+ foreach (YamlNode tag in tags)
+ {
+ string value = tag.ToString();
+ if (value.StartsWith("attack.t", StringComparison.OrdinalIgnoreCase))
+ {
+ techniques.Add(value["attack.".Length..].ToUpperInvariant());
+ }
+ else if (value.StartsWith("t", StringComparison.OrdinalIgnoreCase) &&
+ value.Length >= 5 &&
+ value.Skip(1).All(char.IsDigit))
+ {
+ techniques.Add(value.ToUpperInvariant());
+ }
+ }
+ }
+
+ List falsePositives = [];
+ if (root.Children.TryGetValue(new YamlScalarNode("falsepositives"), out YamlNode? fpNode) &&
+ fpNode is YamlSequenceNode fpSequence)
+ {
+ falsePositives.AddRange(fpSequence.Select(item => item.ToString()));
+ }
+
+ string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(yaml)))
+ .ToLowerInvariant();
+
+ return new DetectionRule(
+ id,
+ title,
+ version,
+ level.ToLowerInvariant(),
+ status.ToLowerInvariant(),
+ description,
+ logSources,
+ fields,
+ condition,
+ techniques,
+ falsePositives,
+ hash);
+ }
+
+ private static string FlattenSelection(YamlNode node)
+ {
+ if (node is YamlScalarNode scalar)
+ {
+ return scalar.Value ?? string.Empty;
+ }
+
+ if (node is YamlSequenceNode sequence)
+ {
+ return string.Join('|', sequence.Select(item => item.ToString()));
+ }
+
+ if (node is YamlMappingNode mapping)
+ {
+ List parts = [];
+ foreach ((YamlNode key, YamlNode value) in mapping.Children)
+ {
+ parts.Add($"{key}:{FlattenSelection(value)}");
+ }
+
+ return string.Join(';', parts);
+ }
+
+ return node.ToString();
+ }
+
+ private static string RequiredScalar(YamlMappingNode root, string name, string? source)
+ {
+ string? value = OptionalScalar(root, name);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new InvalidDataException(
+ $"Sigma rule{(source is null ? string.Empty : $" '{source}'")} missing required field '{name}'.");
+ }
+
+ return value;
+ }
+
+ private static string? OptionalScalar(YamlMappingNode root, string name)
+ {
+ return root.Children.TryGetValue(new YamlScalarNode(name), out YamlNode? node)
+ ? node.ToString()
+ : null;
+ }
+}
diff --git a/src/Bower.Detection/packages.lock.json b/src/Bower.Detection/packages.lock.json
new file mode 100644
index 0000000..6c48cec
--- /dev/null
+++ b/src/Bower.Detection/packages.lock.json
@@ -0,0 +1,16 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "YamlDotNet": {
+ "type": "Direct",
+ "requested": "[18.1.0, )",
+ "resolved": "18.1.0",
+ "contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw=="
+ },
+ "bower.contracts": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Bower.UnitTests/Bower.UnitTests.csproj b/tests/Bower.UnitTests/Bower.UnitTests.csproj
index 4c90f26..e1a5ef7 100644
--- a/tests/Bower.UnitTests/Bower.UnitTests.csproj
+++ b/tests/Bower.UnitTests/Bower.UnitTests.csproj
@@ -17,6 +17,9 @@
+
+
+
@@ -26,9 +29,9 @@
schemas/%(RecursiveDir)%(Filename)%(Extension)
PreserveNewest
-
-
-
-
+
+ rules/%(RecursiveDir)%(Filename)%(Extension)
+ PreserveNewest
+
diff --git a/tests/Bower.UnitTests/DetectionEngineTests.cs b/tests/Bower.UnitTests/DetectionEngineTests.cs
new file mode 100644
index 0000000..e88c90e
--- /dev/null
+++ b/tests/Bower.UnitTests/DetectionEngineTests.cs
@@ -0,0 +1,94 @@
+using Bower.Contracts;
+using Bower.Detection;
+
+namespace Bower.UnitTests;
+
+public sealed class DetectionEngineTests
+{
+ [Fact]
+ public void LoadDirectory_ParsesSampleSigmaRule()
+ {
+ string directory = Path.Combine(AppContext.BaseDirectory, "rules", "sigma");
+
+ IReadOnlyList rules = SigmaRuleLoader.LoadDirectory(directory);
+
+ Assert.Contains(rules, rule => rule.Id == "bower-auth-failure-001");
+ DetectionRule auth = rules.Single(rule => rule.Id == "bower-auth-failure-001");
+ Assert.Contains("T1110", auth.MitreTechniques);
+ Assert.Equal("medium", auth.Level);
+ }
+
+ [Fact]
+ public void Evaluate_RaisesAlertForAuthenticationFailure()
+ {
+ DetectionRule rule = SigmaRuleLoader.LoadYaml(
+ """
+ title: Auth Fail
+ id: test-auth-1
+ level: high
+ version: 1.0.0
+ detection:
+ selection:
+ EventType: authentication_failure
+ condition: selection
+ tags:
+ - attack.t1110
+ """);
+
+ DetectionEngine engine = new([rule]);
+ SecurityEventEnvelope envelope = new()
+ {
+ SchemaVersion = SecurityEventEnvelope.CurrentSchemaVersion,
+ EventId = Guid.CreateVersion7().ToString(),
+ TimeGenerated = DateTimeOffset.UtcNow,
+ EventCategory = SecurityEventCategories.Authentication,
+ EventType = SecurityEventTypes.AuthenticationFailure,
+ EventAction = "authentication.attempt",
+ EventResult = EventResult.Failure,
+ Application = new ApplicationContext { Name = "app", Environment = "test" },
+ Actor = new ActorContext { Username = "alice" },
+ Source = new SourceContext { IpAddress = "203.0.113.9" }
+ };
+
+ DetectionEvaluationResult result = engine.Evaluate(envelope);
+
+ DetectionAlert alert = Assert.Single(result.Alerts);
+ Assert.Equal("test-auth-1", alert.RuleId);
+ Assert.Equal(75, alert.RiskScore);
+ Assert.Contains("T1110", alert.MitreTechniques);
+ Assert.Equal("alice", alert.Actor);
+ }
+
+ [Fact]
+ public void Evaluate_SuppressesConfiguredRuleIds()
+ {
+ DetectionRule rule = SigmaRuleLoader.LoadYaml(
+ """
+ title: Auth Fail
+ id: suppressed-1
+ level: low
+ detection:
+ selection:
+ EventType: authentication_failure
+ condition: selection
+ """);
+
+ DetectionEngine engine = new([rule], ["suppressed-1"]);
+ SecurityEventEnvelope envelope = new()
+ {
+ SchemaVersion = SecurityEventEnvelope.CurrentSchemaVersion,
+ EventId = Guid.CreateVersion7().ToString(),
+ TimeGenerated = DateTimeOffset.UtcNow,
+ EventCategory = SecurityEventCategories.Authentication,
+ EventType = SecurityEventTypes.AuthenticationFailure,
+ EventAction = "authentication.attempt",
+ EventResult = EventResult.Failure,
+ Application = new ApplicationContext { Name = "app", Environment = "test" }
+ };
+
+ DetectionEvaluationResult result = engine.Evaluate(envelope);
+
+ Assert.Empty(result.Alerts);
+ Assert.Contains("suppressed-1", result.SuppressedRuleIds);
+ }
+}
diff --git a/tests/Bower.UnitTests/packages.lock.json b/tests/Bower.UnitTests/packages.lock.json
index 2c5d5b1..2d7c07e 100644
--- a/tests/Bower.UnitTests/packages.lock.json
+++ b/tests/Bower.UnitTests/packages.lock.json
@@ -426,6 +426,13 @@
"Bower.Redaction": "[1.0.0, )"
}
},
+ "bower.detection": {
+ "type": "Project",
+ "dependencies": {
+ "Bower.Contracts": "[1.0.0, )",
+ "YamlDotNet": "[18.1.0, )"
+ }
+ },
"bower.management.api": {
"type": "Project",
"dependencies": {