173 lines
8.3 KiB
Python
173 lines
8.3 KiB
Python
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()
|