import unittest from netscaler_triage.sigma_rules import SigmaEvaluationLimit, SigmaMatcher, UnsupportedSigma def rule(detection: str, service: str = "") -> str: return ( "title: Local test\nlogsource:\n product: netscaler\n" + service + "detection:\n" + detection ) class SigmaTests(unittest.TestCase): def test_catalog_not_repeated_in_hit(self) -> None: text = rule(" s: [abc]\n condition: s\n") text += "indicators:\n - type: ipv4\n value: 192.0.2.1\n status: reference_only\n" text += "indicator_status: reference_only\n" matcher = SigmaMatcher.from_yaml(text) self.assertEqual(matcher.indicators[0]["value"], "192.0.2.1") hit = matcher.search_with_details({"raw": "abc"})[0] self.assertEqual(hit["custom_attributes"], {"indicator_status": "reference_only"}) with self.assertRaises(UnsupportedSigma): SigmaMatcher.from_yaml(text.replace("value: 192.0.2.1", "value: 123")) def test_evaluation_bounds(self) -> None: matcher = SigmaMatcher.from_yaml(rule(" s: [abc]\n condition: s\n")) with self.assertRaises(SigmaEvaluationLimit): matcher.search({"raw": "x" * 1048577}) with self.assertRaises(SigmaEvaluationLimit): matcher.search({"raw": "abc"}, ["abc"] * 65) with self.assertRaises(SigmaEvaluationLimit): matcher.search({"raw": "abc"}, ["x" * 1048577]) def test_boolean_and_keywords(self) -> None: matcher = SigmaMatcher.from_yaml( rule( " selection: [alpha, beta]\n exclude: [benign]\n" " condition: selection and not exclude\n" ) ) self.assertTrue(matcher.search({"raw": "prefix ALPHA suffix"})) self.assertFalse(matcher.search({"raw": "alpha benign"})) self.assertFalse(matcher.search({"raw": "other"})) def test_modifiers_fields_and_all(self) -> None: matcher = SigmaMatcher.from_yaml( rule( " s:\n uri|startswith: /vpn/\n user_agent|contains|all: [curl, exploit]\n" " source_ip|endswith: '.1'\n condition: s\n" ) ) event = {"fields": {"uri": "/vpn/a", "user_agent": "CURL exploit", "src": "192.0.2.1"}} self.assertTrue(matcher.search(event)) self.assertFalse( matcher.search({"fields": {"uri": "/vpn/a", "user_agent": "curl", "src": "192.0.2.1"}}) ) def test_selectors_and_wildcard_escaping(self) -> None: matcher = SigmaMatcher.from_yaml( rule( " sel_a: {uri: '/a*'}\n sel_b: {user_agent: 'agent?'}\n" " condition: all of sel_*\n" ) ) self.assertTrue(matcher.search({"uri": "/abcdef", "user_agent": "agentX"})) self.assertFalse(matcher.search({"uri": "/abcdef", "user_agent": "agentXY"})) matcher = SigmaMatcher.from_yaml( rule(" sel_a: {uri: '/literal\\*'}\n sel_b: [other]\n condition: 1 of sel_*\n") ) self.assertTrue(matcher.search({"uri": "/literal*"})) self.assertFalse(matcher.search({"uri": "/literalanything"})) def test_regex_service_and_decoded_basis(self) -> None: matcher = SigmaMatcher.from_yaml( rule( " s:\n raw|re: '(?i)passthru\\s*\\('\n condition: s\n", " service: http_access\n", ), source="test.yml", ) hits = matcher.search_with_details( {"raw": "encoded bytes", "kind": "http_access"}, ["passthru('id')"] ) self.assertEqual(hits[0]["basis"], ["decoded:0"]) self.assertEqual(hits[0]["source"], "test.yml") self.assertFalse(matcher.search({"raw": "passthru('id')", "kind": "syslog"})) def test_regex_case_is_not_string_case(self) -> None: matcher = SigmaMatcher.from_yaml(rule(" s: {raw|re: Alpha}\n condition: s\n")) self.assertTrue(matcher.search({"raw": "Alpha"})) self.assertFalse(matcher.search({"raw": "alpha"})) def test_structured_only_match_is_not_attributed_to_decoding(self) -> None: matcher = SigmaMatcher.from_yaml(rule(" s: {SHA256: [abc, def]}\n condition: s\n")) hits = matcher.search_with_details({"sha256": ["def"], "raw": "original"}, ["decoded"]) self.assertEqual(hits[0]["basis"], ["raw"]) def test_null_numeric_and_missing_fields(self) -> None: matcher = SigmaMatcher.from_yaml(rule(" s: {status: 200, absent: null}\n condition: s\n")) self.assertTrue(matcher.search({"status": 200})) self.assertFalse(matcher.search({"status": "200"})) self.assertFalse(matcher.search({"status": 200, "absent": "value"})) def test_numeric_and_boolean_lists_preserve_types(self) -> None: numeric = SigmaMatcher.from_yaml(rule(" s: {status: 403}\n condition: s\n")) self.assertTrue(numeric.search({"status": [200, 403]})) self.assertFalse(numeric.search({"status": ["403", True]})) boolean = SigmaMatcher.from_yaml(rule(" s: {enabled: true}\n condition: s\n")) self.assertTrue(boolean.search({"enabled": [False, True]})) self.assertFalse(boolean.search({"enabled": [0, 1, "true"]})) def test_limits_cannot_be_skipped_by_service_or_short_circuit(self) -> None: matcher = SigmaMatcher.from_yaml( rule(" s: {status: 200, uri: '*'}\n condition: s\n", " service: http_access\n") ) for event in ( {"kind": "syslog", "raw": "x" * 1048577}, {"kind": "http_access", "status": 404, "uri": ["x" * 1048577]}, ): with self.subTest(kind=event["kind"]), self.assertRaises(SigmaEvaluationLimit): matcher.search(event) with self.assertRaises(SigmaEvaluationLimit): matcher.search({"kind": "syslog"}, ["x" * 1048577]) def test_unused_metadata_is_not_traversed_and_used_lists_are_bounded(self) -> None: matcher = SigmaMatcher.from_yaml(rule(" s: {status: 200}\n condition: s\n")) self.assertTrue(matcher.search({"status": 200, "unrelated": ["x" * 1048577]})) with self.assertRaises(SigmaEvaluationLimit): matcher.search({"status": [200] * 10001}) def test_decoded_views_preserve_positive_and_negative_structured_context(self) -> None: matcher = SigmaMatcher.from_yaml( rule( " s: {raw|contains: suspicious, uri|startswith: /vpn/}\n" " excluded: {source_ip: '192.0.2.5'}\n" " condition: s and not excluded\n" ) ) event = {"raw": "encoded", "uri": "/vpn/check", "source_ip": "192.0.2.6"} hits = matcher.search_with_details(event, ["suspicious"]) self.assertEqual(hits[0]["basis"], ["decoded:0"]) self.assertFalse(matcher.search({**event, "source_ip": "192.0.2.5"}, ["suspicious"])) self.assertFalse(matcher.search({**event, "uri": "/health"}, ["suspicious"])) def test_explicitly_unsupported(self) -> None: for modifier in ("base64", "cidr", "exists", "fieldref", "cased"): with self.subTest(modifier=modifier), self.assertRaises((UnsupportedSigma, ValueError)): SigmaMatcher.from_yaml(rule(f" s:\n raw|{modifier}: abc\n condition: s\n")) with self.assertRaises(UnsupportedSigma): SigmaMatcher.from_yaml(rule(" s: [abc]\n condition: s\n", " service: windows\n")) with self.assertRaises(UnsupportedSigma): SigmaMatcher.from_yaml( rule(" s: [abc]\n condition: s\n").replace("netscaler", "windows") ) with self.assertRaises(UnsupportedSigma): SigmaMatcher.from_yaml("title: Unsupported\ncorrelation: {type: event_count}\n") with self.assertRaises(UnsupportedSigma): SigmaMatcher.from_yaml(rule(" s: &selector [abc]\n condition: s\n")) with self.assertRaises(UnsupportedSigma): SigmaMatcher.from_yaml(rule(" s: [abc]\n s: [def]\n condition: s\n")) def test_timeout(self) -> None: matcher = SigmaMatcher.from_yaml( rule(" s: {raw|re: '(a+)+$'}\n condition: s\n"), timeout=0.00001 ) with self.assertRaises(TimeoutError): matcher.search({"raw": "a" * 50000 + "!"}) if __name__ == "__main__": unittest.main()