Initial release: offline NetScaler triage with Sigma rules and MIT license
This commit is contained in:
commit
711c42b270
52 files changed
+61222
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user