2026-10-01 23:08:50 +02:00
|
|
|
import hashlib
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
|
|
|
|
from contextlib import redirect_stderr, redirect_stdout
|
|
|
|
|
from importlib.resources import files
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
from netscaler_triage.cli import main
|
|
|
|
|
from netscaler_triage.engine import scan
|
|
|
|
|
from netscaler_triage.matching import Matcher, load_config, public_config
|
|
|
|
|
from netscaler_triage.sigma_rules import SigmaEvaluationLimit, SigmaMatcher
|
|
|
|
|
|
|
|
|
|
RULE = """title: Decoded command with request context
|
|
|
|
|
id: 283224da-7d4b-4436-a564-bf16e14b2536
|
|
|
|
|
logsource:
|
|
|
|
|
product: netscaler
|
|
|
|
|
service: http_access
|
|
|
|
|
detection:
|
|
|
|
|
selection:
|
|
|
|
|
uri|startswith: /vpn/
|
|
|
|
|
raw|re: '^id$'
|
|
|
|
|
excluded:
|
|
|
|
|
uri|endswith: /health?x=aWQ%3D
|
|
|
|
|
condition: selection and not excluded
|
|
|
|
|
falsepositives:
|
|
|
|
|
- Authorized diagnostic requests require analyst review.
|
|
|
|
|
level: medium
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_requests(logs: Path) -> None:
|
|
|
|
|
logs.mkdir()
|
|
|
|
|
lines = [
|
|
|
|
|
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
|
|
|
|
|
f'"GET {uri} HTTP/1.1" 403 0 "-" "synthetic-agent"\n'
|
|
|
|
|
for uri in ("/vpn/test?x=aWQ%3D", "/vpn/health?x=aWQ%3D", "/other?x=aWQ%3D")
|
|
|
|
|
]
|
|
|
|
|
(logs / "httpaccess.log").write_text("".join(lines), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def findings(output: Path) -> list[dict[str, object]]:
|
|
|
|
|
return [json.loads(line) for line in (output / "findings.jsonl").read_text().splitlines()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SigmaIntegrationTests(unittest.TestCase):
|
|
|
|
|
def test_cli_yaml_structured_context_decoded_and_negation(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
|
|
|
root = Path(temporary).resolve()
|
|
|
|
|
logs, output, rules = root / "logs", root / "results", root / "rules.yml"
|
|
|
|
|
write_requests(logs)
|
|
|
|
|
rules.write_text(RULE, encoding="utf-8")
|
|
|
|
|
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
|
|
|
|
code = main(
|
|
|
|
|
[
|
|
|
|
|
"scan",
|
|
|
|
|
"--input",
|
|
|
|
|
f"ADC={logs}",
|
|
|
|
|
"--output",
|
|
|
|
|
str(output),
|
|
|
|
|
"--config",
|
|
|
|
|
str(rules),
|
|
|
|
|
"--no-default-patterns",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(code, 0)
|
2026-10-05 06:54:18 +02:00
|
|
|
rows = findings(output / "details")
|
2026-10-01 23:08:50 +02:00
|
|
|
matches = [row for row in rows if row.get("rules")]
|
|
|
|
|
self.assertEqual(len(matches), 1)
|
|
|
|
|
self.assertEqual(matches[0]["line"], 1)
|
|
|
|
|
details = matches[0]["rule_details"]
|
|
|
|
|
self.assertIsInstance(details, list)
|
|
|
|
|
assert isinstance(details, list)
|
|
|
|
|
self.assertTrue(any("decoded:" in str(detail["basis"]) for detail in details))
|
|
|
|
|
self.assertIn("sigma_decoded_views", matches[0])
|
|
|
|
|
self.assertTrue(all(detail["source"] == "rules.yml" for detail in details))
|
|
|
|
|
views, layers = matches[0]["sigma_decoded_views"], matches[0]["decoding"]
|
|
|
|
|
assert isinstance(views, list) and isinstance(layers, list)
|
|
|
|
|
for detail in details:
|
|
|
|
|
for basis in detail["basis"]:
|
|
|
|
|
if basis.startswith("decoded:"):
|
|
|
|
|
index = int(basis.split(":", 1)[1])
|
|
|
|
|
self.assertEqual(layers[views[index]]["output"], "id")
|
|
|
|
|
|
|
|
|
|
def test_decoded_sha256_is_a_structured_sigma_field(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
|
|
|
root = Path(temporary).resolve()
|
|
|
|
|
logs, output, rules = root / "logs", root / "results", root / "hash.yml"
|
|
|
|
|
write_requests(logs)
|
|
|
|
|
digest = hashlib.sha256(b"id").hexdigest()
|
|
|
|
|
rules.write_text(
|
|
|
|
|
"title: Synthetic decoded hash\nlogsource: {product: netscaler}\n"
|
|
|
|
|
f"detection:\n selection:\n SHA256: '{digest}'\n condition: selection\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
scan([("ADC", logs)], output, [load_config(rules)], defaults=False)
|
|
|
|
|
matched = [row for row in findings(output) if row.get("rules")]
|
|
|
|
|
self.assertEqual(len(matched), 3)
|
|
|
|
|
self.assertTrue(all("Synthetic decoded hash" in str(row["rules"]) for row in matched))
|
|
|
|
|
|
|
|
|
|
def test_public_pack_parses_and_loads_by_default(self) -> None:
|
|
|
|
|
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
|
|
|
|
|
pack = SigmaMatcher.from_yaml(content, source="public_sigma.yml")
|
|
|
|
|
self.assertGreaterEqual(len(pack.rules), 22)
|
|
|
|
|
self.assertEqual(pack.indicators, public_config()["indicators"])
|
|
|
|
|
matcher = Matcher([])
|
|
|
|
|
hits = matcher.search_sigma({"raw": "<?php passthru('synthetic');", "kind": "http_access"})
|
|
|
|
|
self.assertTrue(hits)
|
|
|
|
|
self.assertTrue(all(hit["source"] == "public_sigma.yml" for hit in hits))
|
|
|
|
|
|
|
|
|
|
def test_sigma_limit_marks_partial_and_continues(self) -> None:
|
|
|
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
|
|
|
root = Path(temporary).resolve()
|
|
|
|
|
logs, output = root / "logs", root / "results"
|
|
|
|
|
logs.mkdir()
|
|
|
|
|
(logs / "messages").write_text("first\nsecond\n", encoding="utf-8")
|
|
|
|
|
with patch.object(
|
|
|
|
|
Matcher, "search_sigma", side_effect=[SigmaEvaluationLimit("limit"), []]
|
|
|
|
|
):
|
|
|
|
|
result = scan([("ADC", logs)], output, [], defaults=False, extract_all=True)
|
|
|
|
|
self.assertEqual(result["status"], "partial")
|
|
|
|
|
self.assertEqual(result["counts"]["records"], 2)
|
|
|
|
|
self.assertEqual(result["counts"]["matching_errors"], 1)
|
|
|
|
|
rows = findings(output)
|
|
|
|
|
self.assertTrue(rows[0]["analysis_errors"])
|
|
|
|
|
self.assertEqual(rows[1]["raw"], "second")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|