Initial release: offline NetScaler triage with Sigma rules and MIT license

This commit is contained in:
StasonJatham committed 2026-10-01 23:08:50 +02:00
commit 711c42b270
52 files changed
+61222

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
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)
rows = findings(output)
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()