diff --git a/Makefile b/Makefile index 3a2428c..917eb3f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL := /bin/bash GO ?= go -VERSION ?= 0.1.4 +VERSION ?= 0.2.0 GOOS ?= $(shell $(GO) env GOOS) GOARCH ?= $(shell $(GO) env GOARCH) HOST_OS ?= $(shell uname -s | tr '[:upper:]' '[:lower:]') diff --git a/cmd/aurora/main.go b/cmd/aurora/main.go index a01781f..eb8a9b3 100644 --- a/cmd/aurora/main.go +++ b/cmd/aurora/main.go @@ -13,7 +13,7 @@ import ( "github.com/spf13/pflag" ) -var version = "0.1.4" +var version = "0.2.0" func main() { params := agent.DefaultParameters() diff --git a/lib/consumer/ioc/c2.go b/lib/consumer/ioc/c2.go index 30e398f..c0aa4a3 100644 --- a/lib/consumer/ioc/c2.go +++ b/lib/consumer/ioc/c2.go @@ -4,15 +4,26 @@ import ( "bufio" "errors" "os" + "strconv" "strings" log "github.com/sirupsen/logrus" ) -func loadC2IOCs(path string, required bool) (map[string]struct{}, map[string]struct{}, error) { +// c2IOCEntry holds a single C2 indicator with its optional score. +type c2IOCEntry struct { + indicator string + score int +} + +// defaultC2Score is the score assigned to C2 IOCs without an explicit score. +// C2 indicators are inherently high-severity, so they default to a high score. +const defaultC2Score = 80 + +func loadC2IOCs(path string, required bool) (map[string]c2IOCEntry, map[string]c2IOCEntry, error) { path = strings.TrimSpace(path) if path == "" { - return map[string]struct{}{}, map[string]struct{}{}, nil + return map[string]c2IOCEntry{}, map[string]c2IOCEntry{}, nil } f, err := os.Open(path) @@ -25,12 +36,12 @@ func loadC2IOCs(path string, required bool) (map[string]struct{}, map[string]str } else { log.WithError(err).WithField("path", path).Warn("Failed to open C2 IOC file; C2 IOC matching disabled") } - return map[string]struct{}{}, map[string]struct{}{}, nil + return map[string]c2IOCEntry{}, map[string]c2IOCEntry{}, nil } defer f.Close() - domains := make(map[string]struct{}) - ips := make(map[string]struct{}) + domains := make(map[string]c2IOCEntry) + ips := make(map[string]c2IOCEntry) scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) @@ -48,17 +59,37 @@ func loadC2IOCs(path string, required bool) (map[string]struct{}, map[string]str continue } - if ip := normalizeIP(raw); ip != "" { - ips[ip] = struct{}{} + // Parse optional score suffix: INDICATOR;SCORE + indicator := raw + score := defaultC2Score + if idx := strings.LastIndex(raw, ";"); idx >= 0 { + candidateScore := strings.TrimSpace(raw[idx+1:]) + if s, err := strconv.Atoi(candidateScore); err == nil { + indicator = strings.TrimSpace(raw[:idx]) + score = s + } + // If the part after ; is not a number, treat the whole + // line as the indicator (preserves backwards compatibility + // for entries that might legitimately contain a semicolon). + } + + if ip := normalizeIP(indicator); ip != "" { + ips[ip] = c2IOCEntry{indicator: ip, score: score} continue } - host := normalizeDomain(raw) + host := normalizeDomain(indicator) if !isLikelyDomain(host) { - warnSkipIOCLine(path, lineNo, "invalid domain or IP") + // Give a specific hint when ':' is present — likely a + // mistyped score separator (should be ';' not ':'). + if strings.Contains(indicator, ":") { + warnSkipIOCLine(path, lineNo, "indicator contains ':' (not valid in FQDN — use ';' as score separator)") + } else { + warnSkipIOCLine(path, lineNo, "invalid domain or IP") + } continue } - domains[host] = struct{}{} + domains[host] = c2IOCEntry{indicator: host, score: score} } if err := scanner.Err(); err != nil { diff --git a/lib/consumer/ioc/iocconsumer.go b/lib/consumer/ioc/iocconsumer.go index 96e1412..57b5735 100644 --- a/lib/consumer/ioc/iocconsumer.go +++ b/lib/consumer/ioc/iocconsumer.go @@ -43,8 +43,8 @@ type Consumer struct { cfg Config filenameEntries []filenameIOCEntry - c2Domains map[string]struct{} - c2IPs map[string]struct{} + c2Domains map[string]c2IOCEntry + c2IPs map[string]c2IOCEntry logger *log.Logger @@ -117,9 +117,9 @@ func (c *Consumer) HandleEvent(event provider.Event) error { switch key { case "DestinationIp": if ip := normalizeIP(value); ip != "" { - if _, ok := c.c2IPs[ip]; ok { + if entry, ok := c.c2IPs[ip]; ok { c.matches.Add(1) - c.emitC2Match(event, key, value, ip) + c.emitC2Match(event, key, value, entry) } } case "DestinationHostname": @@ -127,9 +127,9 @@ func (c *Consumer) HandleEvent(event provider.Event) error { if host == "" { continue } - if _, ok := c.c2Domains[host]; ok { + if entry, ok := c.c2Domains[host]; ok { c.matches.Add(1) - c.emitC2Match(event, key, value, host) + c.emitC2Match(event, key, value, entry) } } } @@ -138,12 +138,15 @@ func (c *Consumer) HandleEvent(event provider.Event) error { } func (c *Consumer) emitFilenameMatch(event provider.Event, field, value string, entry filenameIOCEntry) { + level, levelName := scoreToLevel(entry.score) + fields := log.Fields{ "ioc_type": "filename", "ioc_field": field, "ioc_value": sanitizeFieldForLogging(field, value), "ioc_regex": entry.rawPattern, "ioc_score": entry.score, + "ioc_level": levelName, "ioc_line": entry.line, "ioc_source": filepath.Base(strings.TrimSpace(c.cfg.FilenameIOCPath)), "event_provider": event.ID().ProviderName, @@ -158,15 +161,19 @@ func (c *Consumer) emitFilenameMatch(event provider.Event, field, value string, addEventFields(fields, event) entryLog := log.Entry{Logger: effectiveLogger(c.logger), Data: fields} - entryLog.Log(logLevelForFilenameScore(entry.score), "IOC match") + entryLog.Log(level, "IOC match") } -func (c *Consumer) emitC2Match(event provider.Event, field, value, indicator string) { +func (c *Consumer) emitC2Match(event provider.Event, field, value string, entry c2IOCEntry) { + level, levelName := scoreToLevel(entry.score) + fields := log.Fields{ "ioc_type": "c2", "ioc_field": field, "ioc_value": sanitizeFieldForLogging(field, value), - "ioc_indicator": indicator, + "ioc_indicator": entry.indicator, + "ioc_score": entry.score, + "ioc_level": levelName, "ioc_source": filepath.Base(strings.TrimSpace(c.cfg.C2IOCPath)), "event_provider": event.ID().ProviderName, "event_id": event.ID().EventID, @@ -177,7 +184,7 @@ func (c *Consumer) emitC2Match(event provider.Event, field, value, indicator str addEventFields(fields, event) entryLog := log.Entry{Logger: effectiveLogger(c.logger), Data: fields} - entryLog.Log(log.ErrorLevel, "IOC match") + entryLog.Log(level, "IOC match") } func addEventFields(fields log.Fields, event provider.Event) { @@ -200,17 +207,41 @@ func effectiveLogger(logger *log.Logger) *log.Logger { return log.StandardLogger() } -func logLevelForFilenameScore(score int) log.Level { +// scoreToLevel maps a numeric IOC score (0–100) to a log level and a +// human-readable level name aligned with the Sigma severity scale. +// +// The mapping mirrors how Sigma rule levels translate to log output: +// +// Score 0–39 → info → log.InfoLevel +// Score 40–59 → low → log.InfoLevel +// Score 60–74 → medium → log.WarnLevel +// Score 75–89 → high → log.ErrorLevel +// Score 90–100 → critical → log.ErrorLevel +// +// This keeps IOC-based alerts consistent with Sigma-based alerts so +// that downstream consumers (SIEM, dashboards) can filter uniformly. +func scoreToLevel(score int) (log.Level, string) { switch { - case score >= 80: - return log.ErrorLevel + case score >= 90: + return log.ErrorLevel, "critical" + case score >= 75: + return log.ErrorLevel, "high" case score >= 60: - return log.WarnLevel + return log.WarnLevel, "medium" + case score >= 40: + return log.InfoLevel, "low" default: - return log.InfoLevel + return log.InfoLevel, "info" } } +// logLevelForFilenameScore is kept for backwards compatibility in tests +// that reference the old function name. New code should use scoreToLevel. +func logLevelForFilenameScore(score int) log.Level { + level, _ := scoreToLevel(score) + return level +} + func sanitizeFieldForLogging(key, value string) string { keyLower := strings.ToLower(key) for _, marker := range sensitiveValueFieldNames { diff --git a/lib/consumer/ioc/iocconsumer_test.go b/lib/consumer/ioc/iocconsumer_test.go index 5be0f2f..f4abf0e 100644 --- a/lib/consumer/ioc/iocconsumer_test.go +++ b/lib/consumer/ioc/iocconsumer_test.go @@ -80,6 +80,9 @@ func TestFilenameIOCFalsePositiveExclusionAndMatch(t *testing.T) { if got, _ := lines[0]["ioc_score"].(float64); int(got) != 85 { t.Fatalf("ioc_score = %v, want 85", lines[0]["ioc_score"]) } + if got, _ := lines[0]["ioc_level"].(string); got != "high" { + t.Fatalf("ioc_level = %q, want high", got) + } if got, _ := lines[0]["level"].(string); got != "error" { t.Fatalf("level = %q, want error", got) } @@ -159,6 +162,96 @@ func TestC2IOCMatchesNetworkFieldsOnly(t *testing.T) { if got, _ := line["ioc_field"].(string); got != "DestinationHostname" && got != "DestinationIp" { t.Fatalf("line %d ioc_field = %q, want network field", i, got) } + // Entries without explicit score get defaultC2Score (80) → "high" + if got, _ := line["ioc_level"].(string); got != "high" { + t.Fatalf("line %d ioc_level = %q, want high", i, got) + } + if got, ok := line["ioc_score"].(float64); !ok || int(got) != defaultC2Score { + t.Fatalf("line %d ioc_score = %v, want %d", i, line["ioc_score"], defaultC2Score) + } + } +} + +func TestC2IOCWithScoreParsing(t *testing.T) { + tmpDir := t.TempDir() + c2IOCPath := filepath.Join(tmpDir, "c2-iocs.txt") + if err := os.WriteFile(c2IOCPath, []byte(strings.Join([]string{ + "# C2 IOCs with scores", + "high-severity.evil.com;95", + "medium-severity.evil.com;65", + "low-severity.evil.com;40", + "no-score.evil.com", + "10.0.0.1;90", + "10.0.0.2;50", + "10.0.0.3", + "", + }, "\n")), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + matchLogger, out := testLogger() + consumer := New(Config{ + C2IOCPath: c2IOCPath, + C2IOCRequired: true, + Logger: matchLogger, + }) + if err := consumer.Initialize(); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + + tests := []struct { + name string + field string + value string + wantScore int + wantLevel string + wantLog string + }{ + {"critical_domain", "DestinationHostname", "high-severity.evil.com", 95, "critical", "error"}, + {"medium_domain", "DestinationHostname", "medium-severity.evil.com", 65, "medium", "warning"}, + {"low_domain", "DestinationHostname", "low-severity.evil.com", 40, "low", "info"}, + {"default_domain", "DestinationHostname", "no-score.evil.com", defaultC2Score, "high", "error"}, + {"critical_ip", "DestinationIp", "10.0.0.1", 90, "critical", "error"}, + {"low_ip", "DestinationIp", "10.0.0.2", 50, "low", "info"}, + {"default_ip", "DestinationIp", "10.0.0.3", defaultC2Score, "high", "error"}, + } + + for _, tc := range tests { + event := &testEvent{ + id: provider.EventIdentifier{ProviderName: "LinuxEBPF", EventID: 3}, + source: "LinuxEBPF:NetConnect", + ts: time.Unix(1700000200, 0).UTC(), + fields: enrichment.DataFieldsMap{ + tc.field: enrichment.NewStringValue(tc.value), + }, + } + if err := consumer.HandleEvent(event); err != nil { + t.Fatalf("%s: HandleEvent() error = %v", tc.name, err) + } + } + + if got := consumer.Matches(); got != uint64(len(tests)) { + t.Fatalf("Matches() = %d, want %d", got, len(tests)) + } + + lines := decodeJSONLines(t, out) + if len(lines) != len(tests) { + t.Fatalf("expected %d IOC alert lines, got %d", len(tests), len(lines)) + } + + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + line := lines[i] + if got, _ := line["ioc_score"].(float64); int(got) != tc.wantScore { + t.Fatalf("ioc_score = %v, want %d", line["ioc_score"], tc.wantScore) + } + if got, _ := line["ioc_level"].(string); got != tc.wantLevel { + t.Fatalf("ioc_level = %q, want %q", got, tc.wantLevel) + } + if got, _ := line["level"].(string); got != tc.wantLog { + t.Fatalf("level = %q, want %q", got, tc.wantLog) + } + }) } } @@ -278,36 +371,59 @@ func TestSanitizeFieldForLoggingRedactsCommandLineSecrets(t *testing.T) { } } -func TestLogLevelForFilenameScore(t *testing.T) { +func TestScoreToLevel(t *testing.T) { tests := []struct { - score int - want log.Level + score int + wantLevel log.Level + wantName string }{ - {100, log.ErrorLevel}, - {95, log.ErrorLevel}, - {80, log.ErrorLevel}, - {79, log.WarnLevel}, - {70, log.WarnLevel}, - {60, log.WarnLevel}, - {59, log.InfoLevel}, - {50, log.InfoLevel}, - {0, log.InfoLevel}, - {-1, log.InfoLevel}, + {100, log.ErrorLevel, "critical"}, + {95, log.ErrorLevel, "critical"}, + {90, log.ErrorLevel, "critical"}, + {89, log.ErrorLevel, "high"}, + {80, log.ErrorLevel, "high"}, + {75, log.ErrorLevel, "high"}, + {74, log.WarnLevel, "medium"}, + {70, log.WarnLevel, "medium"}, + {60, log.WarnLevel, "medium"}, + {59, log.InfoLevel, "low"}, + {50, log.InfoLevel, "low"}, + {40, log.InfoLevel, "low"}, + {39, log.InfoLevel, "info"}, + {20, log.InfoLevel, "info"}, + {0, log.InfoLevel, "info"}, + {-1, log.InfoLevel, "info"}, } for _, tc := range tests { - got := logLevelForFilenameScore(tc.score) - if got != tc.want { - t.Fatalf("logLevelForFilenameScore(%d) = %v, want %v", tc.score, got, tc.want) + gotLevel, gotName := scoreToLevel(tc.score) + if gotLevel != tc.wantLevel { + t.Fatalf("scoreToLevel(%d) level = %v, want %v", tc.score, gotLevel, tc.wantLevel) + } + if gotName != tc.wantName { + t.Fatalf("scoreToLevel(%d) name = %q, want %q", tc.score, gotName, tc.wantName) + } + } +} + +func TestLogLevelForFilenameScoreBackwardsCompat(t *testing.T) { + // logLevelForFilenameScore wraps scoreToLevel for backwards compatibility. + // Verify it returns the same log.Level as scoreToLevel. + scores := []int{100, 90, 80, 75, 74, 60, 50, 40, 39, 0, -1} + for _, score := range scores { + got := logLevelForFilenameScore(score) + want, _ := scoreToLevel(score) + if got != want { + t.Fatalf("logLevelForFilenameScore(%d) = %v, want %v", score, got, want) } } } func TestIsLikelyDomainEdgeCases(t *testing.T) { tests := []struct { - name string + name string input string - want bool + want bool }{ {name: "valid", input: "evil.com", want: true}, {name: "subdomain", input: "c2.evil.com", want: true}, @@ -480,6 +596,59 @@ func TestLoadC2IOCsCategorizesCorrectly(t *testing.T) { } } +func TestLoadC2IOCsWithScores(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "c2.txt") + content := strings.Join([]string{ + "# C2 with scores", + "evil.com;95", + "medium.com;65", + "plain.com", + "192.168.1.1;90", + "10.0.0.1", + }, "\n") + "\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + domains, ips, err := loadC2IOCs(path, true) + if err != nil { + t.Fatalf("loadC2IOCs() error = %v", err) + } + + // Check domain scores + if entry, ok := domains["evil.com"]; !ok { + t.Fatal("missing evil.com") + } else if entry.score != 95 { + t.Fatalf("evil.com score = %d, want 95", entry.score) + } + + if entry, ok := domains["medium.com"]; !ok { + t.Fatal("missing medium.com") + } else if entry.score != 65 { + t.Fatalf("medium.com score = %d, want 65", entry.score) + } + + if entry, ok := domains["plain.com"]; !ok { + t.Fatal("missing plain.com") + } else if entry.score != defaultC2Score { + t.Fatalf("plain.com score = %d, want %d (default)", entry.score, defaultC2Score) + } + + // Check IP scores + if entry, ok := ips["192.168.1.1"]; !ok { + t.Fatal("missing 192.168.1.1") + } else if entry.score != 90 { + t.Fatalf("192.168.1.1 score = %d, want 90", entry.score) + } + + if entry, ok := ips["10.0.0.1"]; !ok { + t.Fatal("missing 10.0.0.1") + } else if entry.score != defaultC2Score { + t.Fatalf("10.0.0.1 score = %d, want %d (default)", entry.score, defaultC2Score) + } +} + func TestLoadC2IOCsRejectsWhitespacedLines(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "c2.txt") @@ -540,6 +709,245 @@ func TestMultipleFilenameIOCMatchesOnSingleEvent(t *testing.T) { if len(lines) != 3 { t.Fatalf("expected 3 IOC alert lines, got %d", len(lines)) } + + // Verify ioc_level is present on all lines + expectedLevels := []string{"low", "high", "medium"} // scores 50, 80, 70 + for i, line := range lines { + if got, _ := line["ioc_level"].(string); got != expectedLevels[i] { + t.Fatalf("line %d ioc_level = %q, want %q", i, got, expectedLevels[i]) + } + } +} + +func TestFilenameIOCLevelField(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "filename-iocs.txt") + content := strings.Join([]string{ + `(?i)critical-tool;95`, + `(?i)high-risk;80`, + `(?i)medium-risk;65`, + `(?i)low-risk;45`, + `(?i)info-only;20`, + }, "\n") + "\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + matchLogger, out := testLogger() + consumer := New(Config{ + FilenameIOCPath: path, + FilenameIOCRequired: true, + Logger: matchLogger, + }) + if err := consumer.Initialize(); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + + tests := []struct { + filename string + wantLevel string + wantLog string + }{ + {"/tmp/critical-tool.exe", "critical", "error"}, + {"/tmp/high-risk.exe", "high", "error"}, + {"/tmp/medium-risk.exe", "medium", "warning"}, + {"/tmp/low-risk.exe", "low", "info"}, + {"/tmp/info-only.exe", "info", "info"}, + } + + for _, tc := range tests { + event := &testEvent{ + id: provider.EventIdentifier{ProviderName: "LinuxEBPF", EventID: 11}, + source: "LinuxEBPF:FileCreate", + ts: time.Unix(1700000000, 0).UTC(), + fields: enrichment.DataFieldsMap{ + "TargetFilename": enrichment.NewStringValue(tc.filename), + }, + } + if err := consumer.HandleEvent(event); err != nil { + t.Fatalf("HandleEvent(%s) error = %v", tc.filename, err) + } + } + + lines := decodeJSONLines(t, out) + if len(lines) != len(tests) { + t.Fatalf("expected %d lines, got %d", len(tests), len(lines)) + } + + for i, tc := range tests { + t.Run(tc.filename, func(t *testing.T) { + if got, _ := lines[i]["ioc_level"].(string); got != tc.wantLevel { + t.Fatalf("ioc_level = %q, want %q", got, tc.wantLevel) + } + if got, _ := lines[i]["level"].(string); got != tc.wantLog { + t.Fatalf("level = %q, want %q", got, tc.wantLog) + } + }) + } +} + +// TestLoadC2IOCsRejectsMalformedEntries verifies that common formatting +// mistakes in C2 IOC files are rejected with warnings, not silently loaded. +func TestLoadC2IOCsRejectsMalformedEntries(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "c2.txt") + content := strings.Join([]string{ + "# Malformed entries that must be rejected", + "evildomain.com:65", // colon instead of semicolon + "bad:domain.com", // colon in FQDN + "192.168.1.1:8080", // IP with port (colon) + "evil domain.com", // space in domain + "not_a_domain", // no dot, underscore + ";95", // empty indicator with score + "evil.com;abc", // non-numeric score — treated as full indicator, rejected by isLikelyDomain + "", // empty line (skipped) + "# Valid entries that must be accepted", + "legit.evil.com;75", // valid domain with score + "clean-c2.example.org", // valid domain without score + "10.20.30.40;60", // valid IP with score + "172.16.0.1", // valid IP without score + }, "\n") + "\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + domains, ips, err := loadC2IOCs(path, true) + if err != nil { + t.Fatalf("loadC2IOCs() error = %v", err) + } + + // Exactly 2 valid domains should load + if len(domains) != 2 { + t.Fatalf("expected 2 valid domains, got %d: %v", len(domains), domains) + } + if _, ok := domains["legit.evil.com"]; !ok { + t.Error("missing legit.evil.com") + } + if _, ok := domains["clean-c2.example.org"]; !ok { + t.Error("missing clean-c2.example.org") + } + + // Exactly 2 valid IPs should load + if len(ips) != 2 { + t.Fatalf("expected 2 valid IPs, got %d: %v", len(ips), ips) + } + if entry, ok := ips["10.20.30.40"]; !ok { + t.Error("missing 10.20.30.40") + } else if entry.score != 60 { + t.Errorf("10.20.30.40 score = %d, want 60", entry.score) + } + if entry, ok := ips["172.16.0.1"]; !ok { + t.Error("missing 172.16.0.1") + } else if entry.score != defaultC2Score { + t.Errorf("172.16.0.1 score = %d, want %d (default)", entry.score, defaultC2Score) + } + + // Verify scores on domains + if entry := domains["legit.evil.com"]; entry.score != 75 { + t.Errorf("legit.evil.com score = %d, want 75", entry.score) + } + if entry := domains["clean-c2.example.org"]; entry.score != defaultC2Score { + t.Errorf("clean-c2.example.org score = %d, want %d (default)", entry.score, defaultC2Score) + } +} + +// TestLoadFilenameIOCsThreeFieldFormat verifies the REGEX;SCORE;FP_REGEX format +// including edge cases with false-positive exclusion patterns. +func TestLoadFilenameIOCsThreeFieldFormat(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "filename-iocs.txt") + content := strings.Join([]string{ + // Standard two-field + `\\evil\.exe;80`, + // Three-field with FP exclusion + `\\cmd\.exe;65;\\(System32|Winsxs)\\`, + // Three-field with empty FP field (should work, no FP filter) + `\\danger\.dll;90;`, + // Missing leading backslash (still valid regex, just broader match) + `master\.exe;70`, + // Properly escaped leading backslash + `\\\\master\.exe;70`, + }, "\n") + "\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + entries, err := loadFilenameIOCs(path, true) + if err != nil { + t.Fatalf("loadFilenameIOCs() error = %v", err) + } + + if len(entries) != 5 { + t.Fatalf("expected 5 entries, got %d", len(entries)) + } + + // Verify the three-field entry has a compiled FP exclusion + cmdEntry := entries[1] // \\cmd\.exe;65;\\(System32|Winsxs)\\ + if cmdEntry.score != 65 { + t.Errorf("cmd.exe score = %d, want 65", cmdEntry.score) + } + if cmdEntry.falsePositive == nil { + t.Fatal("cmd.exe should have a false-positive exclusion pattern") + } + // FP pattern should match System32 paths + if !cmdEntry.falsePositive.MatchString(`C:\Windows\System32\cmd.exe`) { + t.Error("FP pattern should match System32 path") + } + // But not match other paths + if cmdEntry.falsePositive.MatchString(`C:\Temp\cmd.exe`) { + t.Error("FP pattern should NOT match Temp path") + } + + // Empty FP field should result in nil falsePositive + dangerEntry := entries[2] + if dangerEntry.falsePositive != nil { + t.Error("empty FP field should result in nil falsePositive") + } + if dangerEntry.score != 90 { + t.Errorf("danger.dll score = %d, want 90", dangerEntry.score) + } +} + +// TestLoadFilenameIOCsRejectsMalformedEntries tests that invalid filename IOC +// lines are rejected — missing score, invalid regex, non-numeric score. +func TestLoadFilenameIOCsRejectsMalformedEntries(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "filename-iocs.txt") + content := strings.Join([]string{ + `no-score-field`, + `(invalid-regex;90`, + `;50`, + `valid-pattern;not-a-num`, + `good\.exe;abc;fp-pattern`, + `ok\.dll;75;(unclosed`, + "# this is a comment line", + ``, + `(?i)\\legit\.exe;80`, // valid entry + `(?i)\\tool\.exe;60;(?i)\\Windows\\`, // valid three-field + }, "\n") + "\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + entries, err := loadFilenameIOCs(path, true) + if err != nil { + t.Fatalf("loadFilenameIOCs() error = %v", err) + } + + // Only the 2 valid entries should load + if len(entries) != 2 { + for i, e := range entries { + t.Logf(" entry[%d]: pattern=%q score=%d", i, e.rawPattern, e.score) + } + t.Fatalf("expected 2 valid entries, got %d", len(entries)) + } + + if entries[0].rawPattern != `(?i)\\legit\.exe` || entries[0].score != 80 { + t.Errorf("entry[0] = %q;%d, want (?i)\\\\legit\\.exe;80", entries[0].rawPattern, entries[0].score) + } + if entries[1].rawPattern != `(?i)\\tool\.exe` || entries[1].score != 60 || entries[1].falsePositive == nil { + t.Errorf("entry[1] unexpected: pattern=%q score=%d hasFP=%v", entries[1].rawPattern, entries[1].score, entries[1].falsePositive != nil) + } } func testLogger() (*log.Logger, *bytes.Buffer) { diff --git a/lib/consumer/ioc/validate_iocs_test.go b/lib/consumer/ioc/validate_iocs_test.go new file mode 100644 index 0000000..fd57a75 --- /dev/null +++ b/lib/consumer/ioc/validate_iocs_test.go @@ -0,0 +1,75 @@ +package ioc + +import ( + "os" + "testing" +) + +// TestValidateRealC2IOCs loads the production C2 IOC file and verifies +// all entries are accepted. This catches regressions where validation +// rejects previously-valid indicators. +func TestValidateRealC2IOCs(t *testing.T) { + const path = "/opt/aurora-linux/resources/iocs/c2-iocs.txt" + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Skipf("production IOC file not found: %s", path) + } + + domains, ips, err := loadC2IOCs(path, true) + if err != nil { + t.Fatalf("loadC2IOCs() error = %v", err) + } + + total := len(domains) + len(ips) + t.Logf("Loaded %d C2 IOCs (%d domains, %d IPs)", total, len(domains), len(ips)) + + if total == 0 { + t.Fatal("expected at least some C2 IOCs to load") + } + + // Verify scored entries parsed correctly + for key, entry := range domains { + if entry.score < 0 || entry.score > 100 { + t.Errorf("domain %q has out-of-range score %d", key, entry.score) + } + } + for key, entry := range ips { + if entry.score < 0 || entry.score > 100 { + t.Errorf("IP %q has out-of-range score %d", key, entry.score) + } + } +} + +// TestValidateRealFilenameIOCs loads the production filename IOC file +// and verifies all entries are accepted. +func TestValidateRealFilenameIOCs(t *testing.T) { + const path = "/opt/aurora-linux/resources/iocs/filename-iocs.txt" + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Skipf("production IOC file not found: %s", path) + } + + entries, err := loadFilenameIOCs(path, true) + if err != nil { + t.Fatalf("loadFilenameIOCs() error = %v", err) + } + + t.Logf("Loaded %d filename IOC entries", len(entries)) + + if len(entries) == 0 { + t.Fatal("expected at least some filename IOCs to load") + } + + // Check all entries have valid scores and compiled patterns + withFP := 0 + for _, entry := range entries { + if entry.score < 0 || entry.score > 100 { + t.Errorf("line %d: pattern %q has out-of-range score %d", entry.line, entry.rawPattern, entry.score) + } + if entry.pattern == nil { + t.Errorf("line %d: pattern %q did not compile", entry.line, entry.rawPattern) + } + if entry.falsePositive != nil { + withFP++ + } + } + t.Logf(" %d entries have false-positive exclusion patterns", withFP) +}