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
107 changes: 107 additions & 0 deletions src/lib/sigma/generate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
generateSigmaRules,
ruleToYaml,
type GenerateOptions,
} from "@/lib/sigma/generate";

function opts(overrides: Partial<GenerateOptions> = {}): 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");
});
});
62 changes: 52 additions & 10 deletions src/lib/sigma/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
// LSASS credential dumping
"T1003.001": {
logsource: { category: "process_access", product: "windows" },
build: (actor, tid, tname, confidence) => ({

Check warning on line 197 in src/lib/sigma/generate.ts

View workflow job for this annotation

GitHub Actions / verify

'confidence' is defined but never used
...base(
`${actor} — LSASS Memory Access / Credential Dumping (${tid})`,
`Detects LSASS process access attributed to ${actor} credential harvesting. Technique: ${tname}.`,
Expand Down Expand Up @@ -242,8 +242,10 @@
},
// 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) => ({

Check warning on line 248 in src/lib/sigma/generate.ts

View workflow job for this annotation

GitHub Actions / verify

'confidence' is defined but never used
...base(
`${actor} — Web Shell Activity (${tid})`,
`Detects web shell file creation or execution patterns linked to ${actor}. Technique: ${tname}.`,
Expand Down Expand Up @@ -321,15 +323,27 @@
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<string, unknown> = {};
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 {
Expand Down Expand Up @@ -358,10 +372,27 @@
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<string, unknown> = {};
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(
Expand All @@ -386,10 +417,21 @@

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<string, unknown> = {};
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(
Expand Down
Loading