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,143 @@
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from netscaler_triage.engine import scan
|
||||
from netscaler_triage.matching import Matcher
|
||||
|
||||
|
||||
class EngineTests(unittest.TestCase):
|
||||
def test_hash_iocs_match_file_and_decoded_bytes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp).resolve()
|
||||
logs = root / "logs"
|
||||
logs.mkdir()
|
||||
content = b"original file\n"
|
||||
(logs / "sample.log").write_bytes(content)
|
||||
(logs / "http.log").write_text(
|
||||
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
|
||||
'"GET /?x=aWQ%3D HTTP/1.1" 200 0 "-" "ua"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = {
|
||||
"indicators": [
|
||||
{"type": "sha256", "value": hashlib.sha256(value).hexdigest()}
|
||||
for value in (content, b"id")
|
||||
]
|
||||
}
|
||||
result = scan([("ADC", logs)], root / "out", [config], defaults=False)
|
||||
self.assertEqual(result["counts"]["file_hash_hits"], 1)
|
||||
file_hits = json.loads((root / "out/file_hash_hits.json").read_text())
|
||||
self.assertEqual(file_hits[0]["source"], "sample.log")
|
||||
events = [
|
||||
json.loads(line) for line in (root / "out/findings.jsonl").read_text().splitlines()
|
||||
]
|
||||
self.assertTrue(
|
||||
any(
|
||||
hit.get("basis") == "decoded_bytes_sha256"
|
||||
for event in events
|
||||
for hit in event["indicator_matches"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_interrupt_records_failure_and_keeps_database(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp).resolve()
|
||||
logs = root / "logs"
|
||||
logs.mkdir()
|
||||
(logs / "test.log").write_text("one\ntwo\n", encoding="utf-8")
|
||||
with (
|
||||
patch.object(
|
||||
Matcher,
|
||||
"search_with_details",
|
||||
side_effect=[(["hit"], [], []), KeyboardInterrupt()],
|
||||
),
|
||||
self.assertRaises(KeyboardInterrupt),
|
||||
):
|
||||
scan([("ADC", logs)], root / "out", [], defaults=False)
|
||||
summary = json.loads((root / "out/summary.json").read_text())
|
||||
self.assertEqual(summary["error_type"], "KeyboardInterrupt")
|
||||
with sqlite3.connect(root / "out/evidence.sqlite") as db:
|
||||
self.assertEqual(db.execute("SELECT count(*) FROM events").fetchone()[0], 1)
|
||||
db.close()
|
||||
|
||||
def test_match_timeout_preserves_record_and_continues(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp).resolve()
|
||||
logs = root / "logs"
|
||||
logs.mkdir()
|
||||
(logs / "test.log").write_text("problem\nfollowing\n", encoding="utf-8")
|
||||
with patch.object(
|
||||
Matcher, "search_with_details", side_effect=[TimeoutError(), (["hit"], [], [])]
|
||||
):
|
||||
result = scan([("ADC", logs)], root / "out", [], defaults=False)
|
||||
self.assertEqual(result["status"], "partial")
|
||||
self.assertEqual(result["counts"]["records"], 2)
|
||||
self.assertEqual(result["counts"]["selected"], 2)
|
||||
self.assertEqual(result["counts"]["matching_errors"], 1)
|
||||
rows = [
|
||||
json.loads(line) for line in (root / "out/findings.jsonl").read_text().splitlines()
|
||||
]
|
||||
self.assertEqual(rows[0]["raw"], "problem")
|
||||
self.assertTrue(rows[0]["analysis_errors"])
|
||||
self.assertEqual(rows[1]["rules"], ["hit"])
|
||||
|
||||
def test_large_decoding_candidates_are_not_cached(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp).resolve()
|
||||
logs = root / "logs"
|
||||
logs.mkdir()
|
||||
(logs / "test.log").write_text(("%41" * 2000 + "\n") * 2, encoding="utf-8")
|
||||
with patch("netscaler_triage.engine.decode_layers", return_value=[]) as decode:
|
||||
scan([("ADC", logs)], root / "out", [], defaults=False)
|
||||
self.assertEqual(decode.call_count, 2)
|
||||
|
||||
def test_multi_appliance_and_decoded_match(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp).resolve()
|
||||
inputs = []
|
||||
for name in ("one", "two"):
|
||||
directory = root / name
|
||||
directory.mkdir()
|
||||
(directory / "http.log").write_text(
|
||||
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] [42] "
|
||||
'"GET /test?x=aWQ%3D HTTP/1.1" 403 0 "-" "ua"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
inputs.append((name, directory))
|
||||
result = scan(
|
||||
inputs,
|
||||
root / "out",
|
||||
[{"patterns": [{"id": "id", "pattern": "^id$"}]}],
|
||||
defaults=False,
|
||||
)
|
||||
self.assertEqual(result["counts"]["findings"], 2)
|
||||
db = sqlite3.connect(root / "out/evidence.sqlite")
|
||||
self.assertEqual(db.execute("SELECT count(*) FROM events").fetchone()[0], 2)
|
||||
event = json.loads(db.execute("SELECT data FROM events LIMIT 1").fetchone()[0])
|
||||
self.assertTrue(any(step.get("output") == "id" for step in event["decoding"]))
|
||||
db.close()
|
||||
with self.assertRaises(ValueError):
|
||||
scan(inputs, root / "one/out", [])
|
||||
with self.assertRaises(ValueError):
|
||||
scan(inputs, root / "out", [])
|
||||
|
||||
def test_literal_ip_boundaries_and_timeout(self) -> None:
|
||||
matcher = Matcher(
|
||||
[{"indicators": [{"type": "ipv4", "value": "192.0.2.1"}]}], defaults=False
|
||||
)
|
||||
self.assertFalse(matcher.search("192.0.2.11")[1])
|
||||
self.assertTrue(matcher.search("source 192.0.2.1 end")[1])
|
||||
pathological = Matcher(
|
||||
[{"patterns": [{"pattern": "(a+)+$"}]}], timeout=0.001, defaults=False
|
||||
)
|
||||
with self.assertRaises(TimeoutError):
|
||||
pathological.search("a" * 10000 + "!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user