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