95 lines
4.9 KiB
Python
95 lines
4.9 KiB
Python
"""Boundary semantics for the bundled public reference Sigma groups."""
|
|||
|
|
|
||
|
|
import unittest
|
||
|
|
from importlib.resources import files
|
||
|
|
from typing import ClassVar
|
||
|
|
from urllib.parse import urlsplit
|
||
|
|
|
||
|
|
from netscaler_triage.sigma_rules import SigmaMatcher
|
||
|
|
|
||
|
|
|
||
|
|
class PublicBoundaryTests(unittest.TestCase):
|
||
|
|
matcher: ClassVar[SigmaMatcher]
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def setUpClass(cls) -> None:
|
||
|
|
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
|
||
|
|
cls.matcher = SigmaMatcher.from_yaml(content)
|
||
|
|
|
||
|
|
def matched(self, kind: str, value: str, decoded: bool = False) -> bool:
|
||
|
|
hits = self.matcher.search_with_details(
|
||
|
|
{"raw": "opaque" if decoded else value}, [value] if decoded else []
|
||
|
|
)
|
||
|
|
return any(hit["name"] == "public_reference_" + kind for hit in hits)
|
||
|
|
|
||
|
|
def test_catalog_positive_raw_and_decoded_per_group(self) -> None:
|
||
|
|
for row in self.matcher.indicators:
|
||
|
|
kind, value = str(row["type"]), str(row["value"])
|
||
|
|
if kind not in {"domain", "path", "url", "sha256"}:
|
||
|
|
continue
|
||
|
|
if value == "/var/python/bin/customsnmpd":
|
||
|
|
continue # Legitimate binary intentionally omitted from standalone selectors.
|
||
|
|
with self.subTest(kind=kind, value=value):
|
||
|
|
self.assertTrue(self.matched(kind, value))
|
||
|
|
self.assertTrue(self.matched(kind, value, decoded=True))
|
||
|
|
|
||
|
|
def test_domains_accept_subdomains_not_lookalike_suffixes(self) -> None:
|
||
|
|
domain = next(str(r["value"]) for r in self.matcher.indicators if r["type"] == "domain")
|
||
|
|
self.assertTrue(self.matched("domain", "sub." + domain.upper()))
|
||
|
|
for value in ("evil" + domain, domain + ".invalid", domain + "-suffix"):
|
||
|
|
self.assertFalse(self.matched("domain", value))
|
||
|
|
|
||
|
|
def test_path_boundaries_case_and_url_embedding(self) -> None:
|
||
|
|
value = "/tmp/lula"
|
||
|
|
self.assertTrue(self.matched("path", value))
|
||
|
|
self.assertTrue(self.matched("path", "https://example.invalid" + value))
|
||
|
|
for other in (value + ".backup", value + "-longer", "/other" + value, value.upper()):
|
||
|
|
self.assertFalse(self.matched("path", other))
|
||
|
|
self.assertTrue(self.matched("path", "/directory/LoginIcon.sig"))
|
||
|
|
self.assertFalse(self.matched("path", "/directory/LoginIcon.sig.backup"))
|
||
|
|
self.assertFalse(self.matched("path", "/directory/loginicon.sig"))
|
||
|
|
|
||
|
|
def test_urls_preserve_path_case_and_reject_extension(self) -> None:
|
||
|
|
value = next(str(r["value"]) for r in self.matcher.indicators if r["type"] == "url")
|
||
|
|
parsed = urlsplit(value)
|
||
|
|
authority = parsed.scheme + "://" + parsed.netloc
|
||
|
|
self.assertTrue(self.matched("url", authority.upper() + value[len(authority) :]))
|
||
|
|
for suffix in ("-longer", ".backup", "/extra"):
|
||
|
|
self.assertFalse(self.matched("url", value + suffix))
|
||
|
|
if any(char.isalpha() for char in parsed.path):
|
||
|
|
self.assertFalse(self.matched("url", authority + value[len(authority) :].upper()))
|
||
|
|
|
||
|
|
def test_hashes_require_token_boundaries(self) -> None:
|
||
|
|
value = next(str(r["value"]) for r in self.matcher.indicators if r["type"] == "sha256")
|
||
|
|
self.assertTrue(self.matched("sha256", value.upper()))
|
||
|
|
self.assertFalse(self.matched("sha256", "f" + value))
|
||
|
|
self.assertFalse(self.matched("sha256", value + "z"))
|
||
|
|
|
||
|
|
|
||
|
|
class PublicLongLineTests(unittest.TestCase):
|
||
|
|
def test_encoded_loader_bounds_long_negative_and_positive_lines(self) -> None:
|
||
|
|
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
|
||
|
|
matcher = SigmaMatcher.from_yaml(content)
|
||
|
|
loader = next(
|
||
|
|
rule for rule in matcher.rules if rule.metadata["name"] == "sygnia-encoded-loader"
|
||
|
|
)
|
||
|
|
long_encoded = "QUJD" * 200000
|
||
|
|
self.assertFalse(loader.predicate({"raw": long_encoded}))
|
||
|
|
self.assertTrue(loader.predicate({"raw": long_encoded + " NSPPE base64 httpaccess"}))
|
||
|
|
self.assertTrue(loader.predicate({"raw": "first line\nNSPPE base64 httpaccess"}))
|
||
|
|
self.assertFalse(loader.predicate({"raw": "NSPPE\nbase64 httpaccess"}))
|
||
|
|
|
||
|
|
def test_crash_lookahead_long_negatives_and_line_semantics(self) -> None:
|
||
|
|
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
|
||
|
|
matcher = SigmaMatcher.from_yaml(content)
|
||
|
|
crash = next(
|
||
|
|
rule
|
||
|
|
for rule in matcher.rules
|
||
|
|
if rule.metadata["name"] == "elastic-inspired-crash-shell"
|
||
|
|
)
|
||
|
|
for text in ("QUJD" * 200000, "pitboss; " + "packet " * 100000):
|
||
|
|
self.assertFalse(crash.predicate({"raw": text}))
|
||
|
|
self.assertTrue(crash.predicate({"raw": "QUJD" * 200000 + " pitboss NSPPE;"}))
|
||
|
|
self.assertTrue(crash.predicate({"raw": "first\npitboss packet engine;"}))
|
||
|
|
self.assertFalse(crash.predicate({"raw": "pitboss packet\nengine;"}))
|
||
|
|
self.assertFalse(crash.predicate({"raw": "pitboss engine packet;"}))
|