144 lines
6.1 KiB
Python
144 lines
6.1 KiB
Python
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()
|