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

+173
View File
@@ -0,0 +1,173 @@
import gzip
import io
import tempfile
import unittest
import zipfile
from pathlib import Path
from typing import IO, Any, cast
from unittest.mock import patch
from netscaler_triage.ingest import InputError, iter_records
from netscaler_triage.preparation import prepare_inputs
class PreparationTests(unittest.TestCase):
def test_corrupt_archive_does_not_abort_other_inputs(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
(root / "bad.zip").write_bytes(b"not a zip")
(root / "good.log").write_text("evidence\n")
groups, manifest = prepare_inputs([root / "bad.zip", root / "good.log"], root / "out")
self.assertEqual(len(groups), 1)
errors = [row for row in manifest if row["action"] == "error"]
self.assertEqual(errors[0]["error"], "ArchiveError")
self.assertIn("sha256", errors[0])
def test_unreadable_file_is_manifest_error(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
source = root / "source"
source.mkdir()
(source / "bad").write_text("private")
(source / "good").write_text("evidence")
original = Path.open
def guarded(path: Path, *args: Any, **kwargs: Any) -> IO[Any]:
if path == source / "bad":
raise PermissionError(13, "denied", str(path))
return cast(IO[Any], original(path, *args, **kwargs))
with patch.object(Path, "open", guarded):
groups, manifest = prepare_inputs([source], root / "out")
self.assertEqual(len(groups), 1)
self.assertEqual([r["source"] for r in manifest if r["action"] == "error"], ["bad"])
self.assertEqual(
[p.name for _, folder in groups for p in folder.rglob("*") if p.is_file()], ["good"]
)
def test_password_and_combined_archive_budget(self) -> None:
import py7zr
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
encrypted = root / "private.7z"
with py7zr.SevenZipFile(
encrypted, "w", password="test-secret", header_encryption=True
) as z:
z.writestr(b"log evidence\n", "log/messages")
groups, manifest = prepare_inputs([encrypted], root / "good", password="test-secret")
self.assertEqual(len(groups), 1)
self.assertNotIn("test-secret", str(manifest))
archives = []
for name in ("a.zip", "b.zip"):
path = root / name
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as z:
z.writestr("messages", "A" * 1000)
archives.append(path)
limit = sum(p.stat().st_size for p in archives) + 1999
with self.assertRaises(InputError):
prepare_inputs(archives, root / "limited", max_bytes=limit)
self.assertFalse((root / "limited").exists())
def test_multiple_appliances_and_gzip(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
source = root / "export.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("wrapper/QS/var/log/messages", b"first\n")
archive.writestr("wrapper/PROD/var/log/messages.gz", gzip.compress(b"second\n"))
before = source.read_bytes()
groups, manifest = prepare_inputs([source], root / "prepared")
self.assertEqual(len(groups), 2)
records = [r for name, folder in groups for r in iter_records(folder, name)]
self.assertEqual({r["raw"] for r in records}, {"first", "second"})
self.assertFalse(any(r.get("error") for r in records))
self.assertEqual(before, source.read_bytes())
self.assertEqual(sum(r["action"] == "scan_root" for r in manifest), 2)
def test_toolkit_exclusions_preserve_script_evidence(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
source = root / "source"
source.mkdir()
(source / "pyproject.toml").write_text('[project]\nname = "netscaler-triage"\n')
(source / "src").mkdir()
(source / "src/tool.py").write_text("tool")
(source / "results").mkdir()
(source / "results/old.log").write_text("old")
(source / "messages").write_text("evidence\n")
(source / "malware.py").write_text('print("evidence")\n')
groups, manifest = prepare_inputs([source], source / "new-output")
files = {p.name for _, folder in groups for p in folder.rglob("*") if p.is_file()}
self.assertEqual(files, {"messages", "malware.py"})
self.assertEqual(
{r["source"] for r in manifest if r["action"] == "excluded"},
{"src", "results", "pyproject.toml", "new-output"},
)
def test_plain_evidence_src_is_not_toolkit(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
source = root / "source"
(source / "src").mkdir(parents=True)
(source / "src/malware.py").write_text("code evidence")
groups, _ = prepare_inputs([source], root / "out")
self.assertEqual(sum(len(list(p.rglob("malware.py"))) for _, p in groups), 1)
def test_gzip_wrapped_zip_and_nested_named_archive(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
source = root / "source.zip"
zipped = io.BytesIO()
with zipfile.ZipFile(zipped, "w") as archive:
archive.writestr("log/messages", "hello\n")
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("inner.gz", gzip.compress(zipped.getvalue()))
groups, _ = prepare_inputs([source], root / "out")
records = [r for name, folder in groups for r in iter_records(folder, name)]
self.assertEqual([r["raw"] for r in records], ["hello"])
def test_combined_quota_and_nooverwrite(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
a = root / "a.log"
b = root / "b.log"
a.write_bytes(b"a" * 6)
b.write_bytes(b"b" * 6)
with self.assertRaises(InputError):
prepare_inputs([a, b], root / "out", max_bytes=10)
self.assertFalse((root / "out").exists())
(root / "out").mkdir()
(root / "out/keep").write_text("safe")
with self.assertRaises(FileExistsError):
prepare_inputs([a], root / "out")
self.assertEqual((root / "out/keep").read_text(), "safe")
def test_symlink_rejected_and_source_preserved(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
source = root / "source"
source.mkdir()
(source / "real").write_text("evidence")
(source / "link").symlink_to("real")
with self.assertRaises(InputError):
prepare_inputs([source], root / "out")
self.assertFalse((root / "out").exists())
self.assertEqual((source / "real").read_text(), "evidence")
def test_two_same_named_exports_stay_distinct(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
sources = []
for n in ("one", "two"):
source = root / n / "log"
source.mkdir(parents=True)
(source / "messages").write_text(n)
sources.append(source)
groups, _ = prepare_inputs(sources, root / "out")
self.assertEqual(len(groups), 2)
self.assertEqual(len({name for name, _ in groups}), 2)
if __name__ == "__main__":
unittest.main()