From c9a5220192320475f7f5e54182410530fc3d1733 Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Tue, 28 Jul 2026 22:30:43 +1000 Subject: [PATCH] feat(detection): add Sigma-compatible detection engine Declarative YAML rule loader, real-time evaluation against SecurityEventEnvelope, MITRE tags, risk scoring and false-positive suppression list support. --- AGENTS.md | 1 + Bower.sln | 15 ++ rules/sigma/auth_failure_burst.yml | 19 ++ src/Bower.Detection/AGENTS.md | 5 + src/Bower.Detection/Bower.Detection.csproj | 6 + src/Bower.Detection/DetectionEngine.cs | 244 ++++++++++++++++++ src/Bower.Detection/DetectionModels.cs | 36 +++ src/Bower.Detection/SigmaRuleLoader.cs | 182 +++++++++++++ src/Bower.Detection/packages.lock.json | 16 ++ tests/Bower.UnitTests/Bower.UnitTests.csproj | 11 +- tests/Bower.UnitTests/DetectionEngineTests.cs | 94 +++++++ tests/Bower.UnitTests/packages.lock.json | 7 + 12 files changed, 632 insertions(+), 4 deletions(-) create mode 100644 rules/sigma/auth_failure_burst.yml create mode 100644 src/Bower.Detection/AGENTS.md create mode 100644 src/Bower.Detection/Bower.Detection.csproj create mode 100644 src/Bower.Detection/DetectionEngine.cs create mode 100644 src/Bower.Detection/DetectionModels.cs create mode 100644 src/Bower.Detection/SigmaRuleLoader.cs create mode 100644 src/Bower.Detection/packages.lock.json create mode 100644 tests/Bower.UnitTests/DetectionEngineTests.cs 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": {