From 75d57de3bf2095feb2ae3e4e19b2544e5001e235 Mon Sep 17 00:00:00 2001 From: Naveenkumar Date: Tue, 11 Aug 2026 19:56:07 +0530 Subject: [PATCH 1/2] fix(sigma): emit named sub-selections so IOC rules OR across types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated rules put every observable type into one flat `selection` map with a `1 of selection*` condition. In Sigma a single selection map ANDs its fields, so a network blocklist rule only fired when one event contained a matching domain AND IP AND URL simultaneously — which almost never happens, silently neutering the rules. The same applied to hash and host-artifact rules. Split each generator into `selection_*` sub-selections (DNS/IP/URL, SHA256/MD5/SHA1, mutex/filename/regkey) so `1 of selection*` means "any type matches", and switch MD5/SHA1 to Sysmon's `Hashes` field (plain `md5`/`sha1` fields are not standard Sigma fields and match nothing in most backends). --- src/lib/sigma/generate.test.ts | 107 +++++++++++++++++++++++++++++++++ src/lib/sigma/generate.ts | 58 +++++++++++++++--- 2 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 src/lib/sigma/generate.test.ts diff --git a/src/lib/sigma/generate.test.ts b/src/lib/sigma/generate.test.ts new file mode 100644 index 0000000..f2058ae --- /dev/null +++ b/src/lib/sigma/generate.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + generateSigmaRules, + ruleToYaml, + type GenerateOptions, +} from "@/lib/sigma/generate"; + +function opts(overrides: Partial = {}): GenerateOptions { + return { + actorName: "Fancy Bear", + techniques: [], + networkIndicators: [], + hashIndicators: [], + hostIndicators: [], + ...overrides, + }; +} + +describe("generateSigmaRules", () => { + it("does not generate a network rule with no network indicators", () => { + const rules = generateSigmaRules(opts()); + expect(rules).toHaveLength(0); + }); + + it("splits network types into named sub-selections so `1 of selection*` ORs them", () => { + const rules = generateSigmaRules( + opts({ + networkIndicators: [ + { type: "DOMAIN", normalizedValue: "evil.example.com", confidence: 90 }, + { type: "IPV4", normalizedValue: "203.0.113.7", confidence: 90 }, + ], + }), + ); + + const net = rules.find((r) => r.title.includes("Network IOC")); + expect(net).toBeDefined(); + expect(net!.detection.condition).toBe("1 of selection*"); + // A flat `selection` map ANDs its fields; named sub-selections are what + // makes the condition a true OR across DNS/IP/URL. + expect(Object.keys(net!.detection.selection).sort()).toEqual([ + "selection_dns", + "selection_ip", + ]); + expect(net!.detection.selection.selection_dns).toEqual({ + "dns.question.name|contains": ["evil.example.com"], + }); + }); + + it("uses the Sysmon Hashes field for all three hash types", () => { + const rules = generateSigmaRules( + opts({ + hashIndicators: [ + { type: "SHA256", normalizedValue: "a".repeat(64), confidence: 90 }, + { type: "MD5", normalizedValue: "b".repeat(32), confidence: 90 }, + ], + }), + ); + + const hash = rules.find((r) => r.title.includes("File Hash")); + expect(hash).toBeDefined(); + expect(Object.keys(hash!.detection.selection).sort()).toEqual([ + "selection_md5", + "selection_sha256", + ]); + expect(hash!.detection.selection.selection_sha256).toEqual({ + "Hashes|contains": [`SHA256=${"A".repeat(64)}`], + }); + expect(hash!.detection.selection.selection_md5).toEqual({ + "Hashes|contains": [`MD5=${"B".repeat(32)}`], + }); + }); + + it("emits per-type sub-selections for host artifacts", () => { + const rules = generateSigmaRules( + opts({ + hostIndicators: [ + { type: "MUTEX", normalizedValue: "\\BaseNamedObjects\\evil", confidence: 90 }, + { type: "REGISTRY_KEY", normalizedValue: "HKCU\\Software\\Evil", confidence: 90 }, + ], + }), + ); + + const host = rules.find((r) => r.title.includes("Host Artifact")); + expect(host).toBeDefined(); + expect(Object.keys(host!.detection.selection).sort()).toEqual([ + "selection_mutex", + "selection_regkey", + ]); + }); + + it("serializes sub-selections into valid YAML", () => { + const rules = generateSigmaRules( + opts({ + networkIndicators: [ + { type: "DOMAIN", normalizedValue: "evil.example.com", confidence: 90 }, + { type: "IPV4", normalizedValue: "203.0.113.7", confidence: 90 }, + ], + }), + ); + + const yaml = ruleToYaml(rules[0]); + expect(yaml).toContain("selection_dns:"); + expect(yaml).toContain("selection_ip:"); + expect(yaml).toContain("1 of selection*"); + expect(yaml).toContain("- evil.example.com"); + }); +}); diff --git a/src/lib/sigma/generate.ts b/src/lib/sigma/generate.ts index a569b3f..c15d155 100644 --- a/src/lib/sigma/generate.ts +++ b/src/lib/sigma/generate.ts @@ -321,15 +321,27 @@ function generateNetworkRule(opts: GenerateOptions): SigmaRule | null { const tags = [`attack.command_and_control`]; if (opts.attackGroupId) tags.push(`attack.group.${opts.attackGroupId.toLowerCase()}`); + // Named sub-selections, one per observable type: within a single Sigma + // selection map every field must match (AND), so a flat `selection` would + // make a blocklist rule fire only when a single event contains a matching + // domain AND IP AND URL at once. Splitting into `selection_*` keys makes + // `1 of selection*` behave as an OR across observable types, which is what + // an IOC blocklist means. const selection: Record = {}; if (domains.length > 0) { - selection["dns.question.name|contains"] = domains.map((d) => d.normalizedValue); + selection.selection_dns = { + "dns.question.name|contains": domains.map((d) => d.normalizedValue), + }; } if (ips.length > 0) { - selection["DestinationIp"] = ips.map((i) => i.normalizedValue); + selection.selection_ip = { + DestinationIp: ips.map((i) => i.normalizedValue), + }; } if (urls.length > 0) { - selection["http.request.uri|contains"] = urls.map((u) => u.normalizedValue); + selection.selection_url = { + "http.request.uri|contains": urls.map((u) => u.normalizedValue), + }; } return { @@ -358,10 +370,27 @@ function generateHashRule(opts: GenerateOptions): SigmaRule | null { const tags = [`attack.execution`]; if (opts.attackGroupId) tags.push(`attack.group.${opts.attackGroupId.toLowerCase()}`); + // Same pattern as the network rule: a flat selection would AND the hash + // types together, so each type gets its own `selection_*` key and the + // condition ORs them. All three match against Sysmon's `Hashes` field + // (`MD5=..,SHA1=..,SHA256=..`) — `md5`/`sha1` are not standard Sigma fields, + // so rules using them would silently never match in most backends. const selection: Record = {}; - if (sha256.length > 0) selection["Hashes|contains"] = sha256.map((h) => `SHA256=${h.normalizedValue.toUpperCase()}`); - if (md5.length > 0) selection["md5|contains"] = md5.map((h) => h.normalizedValue.toUpperCase()); - if (sha1.length > 0) selection["sha1|contains"] = sha1.map((h) => h.normalizedValue.toUpperCase()); + if (sha256.length > 0) { + selection.selection_sha256 = { + "Hashes|contains": sha256.map((h) => `SHA256=${h.normalizedValue.toUpperCase()}`), + }; + } + if (md5.length > 0) { + selection.selection_md5 = { + "Hashes|contains": md5.map((h) => `MD5=${h.normalizedValue.toUpperCase()}`), + }; + } + if (sha1.length > 0) { + selection.selection_sha1 = { + "Hashes|contains": sha1.map((h) => `SHA1=${h.normalizedValue.toUpperCase()}`), + }; + } return { ...base( @@ -386,10 +415,21 @@ function generateHostRule(opts: GenerateOptions): SigmaRule | null { if (mutexes.length === 0 && filenames.length === 0 && regkeys.length === 0) return null; + // Named sub-selections, same OR-across-types pattern as the network rule. const selection: Record = {}; - if (mutexes.length > 0) selection["ObjectName"] = mutexes.map((m) => m.normalizedValue); - if (filenames.length > 0) selection["TargetFilename|endswith"] = filenames.map((f) => f.normalizedValue); - if (regkeys.length > 0) selection["TargetObject|contains"] = regkeys.map((r) => r.normalizedValue); + if (mutexes.length > 0) { + selection.selection_mutex = { ObjectName: mutexes.map((m) => m.normalizedValue) }; + } + if (filenames.length > 0) { + selection.selection_filename = { + "TargetFilename|endswith": filenames.map((f) => f.normalizedValue), + }; + } + if (regkeys.length > 0) { + selection.selection_regkey = { + "TargetObject|contains": regkeys.map((r) => r.normalizedValue), + }; + } return { ...base( From 2ccd86ecb79346f49f364f19559f3a7c673325db Mon Sep 17 00:00:00 2001 From: Naveenkumar Date: Tue, 11 Aug 2026 19:56:34 +0530 Subject: [PATCH 2/2] fix(sigma): align T1505.003 rule logsource with its emitted detection The web-shell entry declared a `webserver` logsource at the map level but its build() emits `file_event`, so the outer value was dead config that disagreed with the generated rule. Align it to what the rule actually uses. --- src/lib/sigma/generate.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/sigma/generate.ts b/src/lib/sigma/generate.ts index c15d155..8bdcd5e 100644 --- a/src/lib/sigma/generate.ts +++ b/src/lib/sigma/generate.ts @@ -242,7 +242,9 @@ const TECHNIQUE_DETECTIONS: Record = { }, // Web shell "T1505.003": { - logsource: { category: "webserver", product: "windows" }, + // Matches what build() emits below — a webserver-category logsource here + // would be dead config that silently disagrees with the generated rule. + logsource: { category: "file_event", product: "windows" }, build: (actor, tid, tname, confidence) => ({ ...base( `${actor} — Web Shell Activity (${tid})`,