diff --git a/FILESYSTEM_COVERAGE.txt b/FILESYSTEM_COVERAGE.txt index defa969..07a556b 100644 --- a/FILESYSTEM_COVERAGE.txt +++ b/FILESYSTEM_COVERAGE.txt @@ -32,5 +32,21 @@ Prüfkontext, nicht automatisch eine veröffentlichte kampagnenspezifische IOC. Die neuen Regeln ersetzen keine vollständige Datenflussanalyse einer Webshell. 404 und fehlende temporäre Dateien sind keine sicheren Misserfolgsbeweise. -149 automatisierte Tests bestanden. Dies bestätigt die dokumentierte Funktion, +152 automatisierte Tests bestanden. Dies bestätigt die dokumentierte Funktion, keine universelle Erkennungsrate oder vollständige Unversehrtheit. + + +Ergänzung: Zehn weitere Sigma-Prüfungen und elf Inhaltsprüfungen im +Dateisystemmodus ergänzen die bisherige Abdeckung. Neu geprüft werden unter +anderem kommentierte Apache-Schutzdirektiven, Startup-Python, Core-Löschbefehle, +ungewöhnliche ACS-/WS-Federation-Felder sowie erfasste Prozess-/Open-File-Listen. +PHP/XHTML in Webverzeichnissen und SIG/DEB-Kandidaten werden als Namenshinweis +markiert. Bekannte admin_ui-Verzeichnisse und PHP unter gui/websocketd sind von +der pauschalen Namensprüfung ausgenommen, nicht von Inhalts- oder Hashprüfungen. +BLX-Konfiguration /etc/blx/nshttpd.conf wird ebenfalls erfasst. + +Byte-Anomalien werden in Originalbytes geprüft; UTF-8-Ersatzzeichen in Logs +sind nur ein zusätzlicher Suchhinweis. HTTP-Fehlermuster gelten im Dateisystemmodus +nur für httperror-Dateien, nicht für Binärpakete mit ähnlichen Textfragmenten. +Private Suchindikatoren bleiben separat. Die Existenz eines Indikators in einer +zugelieferten Liste ist kein unabhängig bestätigter Malware- oder C2-Nachweis. diff --git a/README.md b/README.md index 1caca59..3da4455 100644 --- a/README.md +++ b/README.md @@ -302,3 +302,36 @@ der oben beschriebenen CLI-Berichtsstruktur. Der Programmcode steht unter der [MIT-Lizenz](LICENSE.txt). Für Abhängigkeiten und zitierte Materialien gelten deren eigene Lizenzen. Quellenangaben bedeuten keine Zugehörigkeit zu Citrix oder den genannten Forschenden. + + +## Ergänzende Prüfungen und ihre Grenzen + +Die zusätzlichen Sigma-Regeln prüfen ungewöhnliche ACS-/WS-Federation-Felder, +Core-Datei-Löschbefehle, PHP-Handler, kommentierte Schutzdirektiven und auffällige +Webartefakt-Antworten. Sie gelten auch für sicher dekodierte Textansichten. +Erfasste Prozess- und Open-File-Listen können nach Prozessen unter Webkonten und +NSPPE-Dateireferenzen durchsucht werden. Das Werkzeug erhebt diese Listen nicht +live: Ohne entsprechende Sicherung ist dieser Zustand nicht prüfbar. + +Im Modus `filesystem` kommen Pfadprüfungen für PHP-/XHTML-Dateien in Webverzeichnissen, +SIG-/DEB-Kandidaten, Startup-Python und BLX-Konfiguration hinzu. Die bestehenden +Hash- und SUID/SGID-Prüfungen bleiben aktiv. Ursprüngliche Rechte und Eigentümer +sind nur mit verlässlichen Archivmetadaten bewertbar; lokale Kopien können diese +verändern. Logregeln werden im Dateisystemmodus auf passende Logpfade begrenzt, +damit Zeichenketten in Herstellerpaketen nicht als Logereignisse erscheinen. + +Alle ergänzenden Verhaltensprüfungen liefern Prüfkandidaten. Ein HTTP-404, ein +PHP-Dateiname, eine auskommentierte Direktive oder nicht-ASCII-Text ist allein kein +Angriffsnachweis. Mit einem vertrauenswürdigen Stand desselben Builds vergleichen. +Fehlende Logs, Prozesslisten oder Dateien bedeuten fehlende Prüfbarkeit. + +Zusätzliche private IOCs als CSV mit `type,value,status,description` laden: + +```sh +netscaler-triage ./logs --ioc-file ./private/indicators.csv --output ./new-log-review +netscaler-triage filesystem ./export-root --ioc-file ./private/indicators.csv --output ./new-file-review +``` + +Nicht unabhängig bestätigte Indikatoren als `reference_only` kennzeichnen. +Sie werden gesucht, aber nicht automatisch als bösartig bewertet oder als +öffentliche Threat Intelligence weitergegeben. diff --git a/VALIDATION.txt b/VALIDATION.txt index 70f49e3..6e10354 100644 --- a/VALIDATION.txt +++ b/VALIDATION.txt @@ -188,3 +188,44 @@ Die Erweiterung verarbeitet Archive ohne Ausführung enthaltener Programme. Unvollständige gzip-Streams werden als Verarbeitungsfehler ausgewiesen. Zusätzliche strukturierte Logkorrelation ist optional; der Batch-Helfer aktiviert sie nicht. Erkennungsgrenzen: FILESYSTEM_COVERAGE.txt. Validierung ist keine Clean-Garantie. + + +SUPPLEMENTAL REVIEW CHECKS — 2026-10-05 + +Version 1.0.2 adds ten low-severity Sigma review rules and eleven filesystem +content checks, plus candidate web-tree file names and BLX configuration paths. +The default Sigma catalog validates with 57 rules; public indicators remain +246. Unverified additional indicators are kept in an optional private pack. + +All 151 tests passed with warnings treated as errors. Ruff lint, formatting and +strict mypy passed. Positive and negative controls exercise each new Sigma rule +on raw and decoded views. Filesystem tests cover original anomalous bytes, +configuration path restrictions and suppression of an error-message lookalike +inside a binary package. Eleven filesystem tests passed after increasing the +stream overlap to 8 KiB for bounded patterns spanning a block boundary. + +An isolated installed package also validated all 57 rules. All 25 supplemental +private indicator values matched raw and Base64-decoded text in a separate +private check. This demonstrates matching behavior, not malicious attribution. + +Two existing configuration/program exports were rescanned: 5,361 and 5,319 +regular files, with no read errors and no hits from the added checks. Existing +heuristic candidates remain separate from newly added checks. A retained larger +snapshot inventory was compared against all 12 supplemental SHA-256 values: +53,696 file hashes, no match. This inventory comparison did not reread the +original larger archive and does not prove current system integrity. + + +RELATIVE OUTPUT REGRESSION + +A real-data run completed analysis but failed while finalizing a relative output +path. Preparation resolves paths internally; the CLI now resolves its preparation +root before constructing relative provenance references. A regression verifies +SUMMARY.txt and each manifest input root when --output is relative. All 152 tests +then passed with warnings treated as errors; Ruff and strict mypy passed. + +The completed run's output was recovered only after every one of its 851 input +inventory entries matched a freshly prepared copy. It processed 1,117,532 records: +two missing-file review events, no supplemental IOC or file-hash hit. Status +remains partial: ten binary-file read stops and 106,842 decoding failures, with +no matching or inventory errors. These counts are not compromise indicators. diff --git a/pyproject.toml b/pyproject.toml index 0b0a56a..9c74adf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netscaler-triage" -version = "1.0.1" +version = "1.0.2" description = "Offline NetScaler evidence analysis with inspectable decoding and provenance" readme = "README.md" license = "MIT" diff --git a/src/netscaler_triage/__init__.py b/src/netscaler_triage/__init__.py index f1f3945..136c207 100644 --- a/src/netscaler_triage/__init__.py +++ b/src/netscaler_triage/__init__.py @@ -1,3 +1,3 @@ """Offline analysis; no discovered payload is ever executed.""" -__version__ = "1.0.1" +__version__ = "1.0.2" diff --git a/src/netscaler_triage/cli.py b/src/netscaler_triage/cli.py index 66a6217..7120254 100644 --- a/src/netscaler_triage/cli.py +++ b/src/netscaler_triage/cli.py @@ -369,7 +369,7 @@ def main(argv: list[str] | None = None) -> int: if args.output.exists(): raise FileExistsError("Output already exists") args.output.parent.mkdir(parents=True, exist_ok=True) - prepared = fresh_destination(args.output.parent, ".triage-preparation") + prepared = fresh_destination(args.output.parent, ".triage-preparation").resolve() inputs, preparation_manifest = prepare_inputs( [path for _, path in inputs], prepared, diff --git a/src/netscaler_triage/data/filesystem_rules.json b/src/netscaler_triage/data/filesystem_rules.json index 4da6357..d39b0c9 100644 --- a/src/netscaler_triage/data/filesystem_rules.json +++ b/src/netscaler_triage/data/filesystem_rules.json @@ -97,6 +97,118 @@ "required_any_literal": [ "nsconfig" ] + }, + { + "id": "httpd_commented_security_directive", + "regex": "^[ \\t]*#[ \\t]*(?:Require[ \\t]+all[ \\t]+denied|php_flag[ \\t]+engine[ \\t]+off)\\b", + "description": "An Apache restriction is commented out. Compare with a trusted configuration of the same build; comments can be vendor defaults.", + "source": "", + "required_any_literal": [ + "#" + ], + "path_regex": "(?:^|/)(?:httpd|nshttpd)\\.conf$" + }, + { + "id": "httpd_php_handler_review", + "regex": "^[ \\t]*AddHandler[ \\t]+application/x-httpd-php\\b", + "description": "PHP handler declaration. Check the mapped extensions and vendor baseline; a handler alone is not malicious.", + "source": "", + "required_any_literal": [ + "addhandler" + ], + "path_regex": "(?:^|/)(?:httpd|nshttpd)\\.conf$" + }, + { + "id": "startup_python_reference", + "regex": "(?:/flash)?/nsconfig/rc\\.netscaler[^\\r\\n]{0,300}\\bpython(?:[23](?:\\.\\d+)?)?\\b", + "description": "Python is referenced alongside the startup configuration path. Verify actual file contents and administrative intent.", + "source": "", + "required_any_literal": [ + "rc.netscaler" + ] + }, + { + "id": "core_file_removal", + "regex": "\\brm[ \\t]+(?:-[a-z]+[ \\t]+){0,3}[\\x22\\x27]?/var/core/\\*", + "description": "A command references removal of core files. This can be maintenance or evidence deletion; a logged command alone does not prove execution.", + "source": "", + "required_any_literal": [ + "/var/core/" + ], + "path_regex": "(?:^|/)(?:notice|sh|bash)\\.log(?:\\.|$)" + }, + { + "id": "wsfed_request_id_anomaly", + "regex": "WSFed:[ \\t]*request_id[^\\r\\n]{0,2048}(?:[\\x80-\\xff\\xfe]|\\^[^\\r\\n]{0,256}\\^)", + "description": "WS-Federation request ID contains unusual characters. Encoding, corruption and malformed input are alternatives; this does not prove memory disclosure.", + "source": "", + "required_any_literal": [ + "wsfed:" + ], + "path_regex": "(?:^|/)ns\\.log(?:\\.|$)" + }, + { + "id": "acs_control_marker", + "regex": "acs=<[^\\r\\n]{0,2048}\\xf0\\^M[^\\r\\n]{0,2048}>,[ \\t]*for", + "description": "ACS field contains an unusual byte/control representation. Inspect original bytes and authentication context; not proof of memory disclosure.", + "source": "", + "required_any_literal": [ + "acs=<" + ], + "path_regex": "(?:^|/)ns\\.log(?:\\.|$)" + }, + { + "id": "web_artifact_response_review", + "regex": "(?:nsginstaller\\.deb|nsgclient[^\\s\\x22?]*|[0-9a-f]{6}\\.ico)[^\\r\\n\\x22]{0,512}HTTP/1\\.[01]\\x22[ \\t]+(?:404|202)[ \\t]+[0-9]{4}\\b", + "description": "A candidate artifact returned 202 or 404 with a four-digit response size. Status and size do not establish a web shell; inspect response content and filesystem evidence.", + "source": "", + "required_any_literal": [ + "nsginstaller", + "nsgclient", + ".ico" + ], + "path_regex": "(?:^|/)httpaccess[^/]*$" + }, + { + "id": "web_artifact_error_review", + "regex": "(?:File does not exist|not found|\\[error\\])[^\\r\\n]{0,1024}(?:nsginstaller\\.deb|nsgclient|[0-9a-f]{6}/\\.sig)", + "description": "Web server error mentions a candidate artifact. A missing-file error usually supports absence at that moment, not successful installation.", + "source": "", + "required_any_literal": [ + "nsginstaller", + "nsgclient", + "/.sig" + ], + "path_regex": "(?:^|/)httperror[^/]*$" + }, + { + "id": "packet_engine_open_file_review", + "regex": "^NSPPE\\S*[ \\t]+\\d+[^\\r\\n]{0,1024}(?:/var/netscaler/|callhome)", + "description": "A captured open-file listing associates NSPPE with a candidate path. Inspect file type and process context; legitimate open files can match.", + "source": "", + "required_any_literal": [ + "nsppe" + ] + }, + { + "id": "web_user_process_review", + "regex": "^(?:nobody|daemon)[ \\t]+\\d+[ \\t]+(?!(?:[^\\r\\n]*)(?:/bin/httpd|/usr/sbin/nshttpd)\\b)[^\\r\\n]{1,2048}$", + "description": "A captured process listing contains a non-httpd process under a web-service account. Validate the executable and parent; legitimate helper processes can match.", + "source": "", + "required_any_literal": [ + "nobody", + "daemon" + ] + }, + { + "id": "startup_python_content", + "regex": "^[ \\t]*(?!#)[^\\r\\n]{0,512}\\bpython(?:[23](?:\\.\\d+)?)?\\b", + "path_regex": "(?:^|/)(?:flash/)?nsconfig/rc\\.netscaler$", + "description": "Python in startup configuration; compare with authorized configuration.", + "source": "", + "required_any_literal": [ + "python" + ] } ] } diff --git a/src/netscaler_triage/data/public_sigma.yml b/src/netscaler_triage/data/public_sigma.yml index 1f838c2..37faf69 100644 --- a/src/netscaler_triage/data/public_sigma.yml +++ b/src/netscaler_triage/data/public_sigma.yml @@ -13162,3 +13162,206 @@ indicators: A match does not establish compromise. matching: exact context_only: false + +--- +title: NetScaler Httpd Commented Security Directive +id: 0da9523e-6cf7-560d-b6ba-11169fbb53bc +name: httpd_commented_security_directive +status: experimental +description: An Apache restriction is commented out. Compare with a trusted configuration + of the same build; comments can be vendor defaults. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?im)^[ \t]*#[ \t]*(?:Require[ \t]+all[ \t]+denied|php_flag[ \t]+engine[ + \t]+off)\b + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] + +--- +title: NetScaler Httpd Php Handler Review +id: 060a060d-71cf-5632-aaab-61cf04128ab2 +name: httpd_php_handler_review +status: experimental +description: PHP handler declaration. Check the mapped extensions and vendor baseline; + a handler alone is not malicious. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?im)^[ \t]*AddHandler[ \t]+application/x-httpd-php\b + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] + +--- +title: NetScaler Startup Python Reference +id: 1b9bc1d2-914e-5ca0-8989-911f0860e0ec +name: startup_python_reference +status: experimental +description: Python is referenced alongside the startup configuration path. Verify + actual file contents and administrative intent. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?i)(?:/flash)?/nsconfig/rc\.netscaler[^\r\n]{0,300}\bpython(?:[23](?:\.\d+)?)?\b + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] + +--- +title: NetScaler Core File Removal +id: 6affc1b7-cc98-5d60-b822-5ca306a0f5ac +name: core_file_removal +status: experimental +description: A command references removal of core files. This can be maintenance or + evidence deletion; a logged command alone does not prove execution. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?i)\brm[ \t]+(?:-[a-z]+[ \t]+){0,3}[\x22\x27]?/var/core/\* + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] + +--- +title: NetScaler Wsfed Request Id Anomaly +id: 04997de6-7489-5045-9d89-ca58c6a6cda2 +name: wsfed_request_id_anomaly +status: experimental +description: WS-Federation request ID contains unusual characters. Encoding, corruption + and malformed input are alternatives; this does not prove memory disclosure. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?i)WSFed:[ \t]*request_id[^\r\n]{0,2048}(?:[\x80-\xff\ufffd]|\^[^\r\n]{0,256}\^) + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] + +--- +title: NetScaler Acs Control Marker +id: 1fd8ae59-7fa2-5996-8fb7-c22a175e4b59 +name: acs_control_marker +status: experimental +description: ACS field contains an unusual byte/control representation. Inspect original + bytes and authentication context; not proof of memory disclosure. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?i)acs=<[^\r\n]{0,2048}(?:\xf0|\ufffd)\^M[^\r\n]{0,2048}>,[ \t]*for + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] + +--- +title: NetScaler Web Artifact Response Review +id: 7e7dbc21-2824-5f7d-a230-849fc84eeede +name: web_artifact_response_review +status: experimental +description: A candidate artifact returned 202 or 404 with a four-digit response size. + Status and size do not establish a web shell; inspect response content and filesystem + evidence. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?i)(?:nsginstaller\.deb|nsgclient[^\s\x22?]*|[0-9a-f]{6}\.ico)[^\r\n\x22]{0,512}HTTP/1\.[01]\x22[ + \t]+(?:404|202)[ \t]+[0-9]{4}\b + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] + +--- +title: NetScaler Web Artifact Error Review +id: 2a24ad72-0b49-5717-98cf-400d0b0e164c +name: web_artifact_error_review +status: experimental +description: Web server error mentions a candidate artifact. A missing-file error + usually supports absence at that moment, not successful installation. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?i)(?:File does not exist|not found|\[error\])[^\r\n]{0,1024}(?:nsginstaller\.deb|nsgclient|[0-9a-f]{6}/\.sig) + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] + +--- +title: NetScaler Packet Engine Open File Review +id: 89dd38c0-6b27-5931-ac71-fa8be5da8e61 +name: packet_engine_open_file_review +status: experimental +description: A captured open-file listing associates NSPPE with a candidate path. + Inspect file type and process context; legitimate open files can match. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?im)^NSPPE\S*[ \t]+\d+[^\r\n]{0,1024}(?:/var/netscaler/|callhome) + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] + +--- +title: NetScaler Web User Process Review +id: 2843041d-c85b-59e1-a8c5-b60fd87233d0 +name: web_user_process_review +status: experimental +description: A captured process listing contains a non-httpd process under a web-service + account. Validate the executable and parent; legitimate helper processes can match. +author: NetScaler Triage contributors +logsource: + product: netscaler +detection: + selection: + raw|re: (?im)^(?:nobody|daemon)[ \t]+\d+[ \t]+(?!(?:[^\r\n]*)(?:/bin/httpd|/usr/sbin/nshttpd)\b)[^\r\n]{1,2048}$ + condition: selection +level: low +falsepositives: +- Legitimate administration, vendor defaults, malformed input or collection artifacts. +indicator_status: behavior_pattern +references: [] diff --git a/src/netscaler_triage/filesystem.py b/src/netscaler_triage/filesystem.py index 888ce81..8503214 100644 --- a/src/netscaler_triage/filesystem.py +++ b/src/netscaler_triage/filesystem.py @@ -22,7 +22,7 @@ from pathlib import Path, PurePosixPath from typing import Any, BinaryIO BLOCK = 1024 * 1024 -OVERLAP = 4096 +OVERLAP = 8192 RARE = re.compile( rb"\.ctxs\.receiver|\.local_journal|\.uxd(?:port|lock)|nsmon\.pl|sec_monitor|http_nsc_ldap|http_nsc_clienttype|http_x_ux|uxd_idle_exit|nsc_tass|rhfajaf1h992|7489a0f93c67fa5cdaeb4b921d90594d|update_c08937\.pl|update_result_3567cs\.tgz|id009\.txt|ww009\.php|wbnsisqmfj\.png" ) @@ -69,6 +69,8 @@ EXACT = { } CAPTURE = { "etc/httpd.conf", + "etc/blx/nshttpd.conf", + "usr/local/bin/blx/nshttpd/conf/httpd.conf", "nsconfig/httpd.conf", "flash/nsconfig/httpd.conf", "nsconfig/crontab", @@ -264,6 +266,11 @@ def scan_filesystem( r["id"]: tuple(t.encode() for t in r.get("required_any_literal", [])) for r in rules_doc["rules"] } + rule_paths = { + r["id"]: re.compile(r["path_regex"], re.I) + for r in rules_doc["rules"] + if r.get("path_regex") + } known = {x["value"].lower(): x for x in intel["indicators"] if x["type"] == "sha256"} known_paths = {x["value"].lstrip("/") for x in intel["indicators"] if x["type"] == "path"} network_values = sorted( @@ -345,6 +352,8 @@ def scan_filesystem( if context(self.path) == "log": patterns.append(("script_or_crash_log", LOG)) for rule, pattern in patterns: + if rule in rule_paths and not rule_paths[rule].search(self.path): + continue gates = rule_gates.get(rule, ()) if gates and not any(token in lower for token in gates): continue @@ -482,6 +491,39 @@ def scan_filesystem( note="Vendor binaries can legitimately have these bits", ) if member.isfile(): + web_path = path.lower() + web_roots = ( + "var/netscaler/logon/", + "var/vpn/", + "netscaler/ns_gui/", + "var/netscaler/gui/", + "netscaler/portal/", + ) + excluded = any( + web_path.startswith(prefix + "admin_ui/") + for prefix in ("netscaler/ns_gui/", "var/netscaler/gui/") + ) or ( + web_path.endswith(".php") + and web_path.startswith("var/netscaler/gui/websocketd/") + ) + if ( + web_path.startswith(web_roots) + and web_path.endswith((".php", ".xhtml")) + and not excluded + ) or ( + web_path.startswith("var/netscaler/gui/") + and ( + web_path.endswith(".sig") + or PurePosixPath(web_path).name + in {"nsgclient18.deb", "nsginstaller.deb"} + ) + ): + finding( + path, + "web_tree_file_review", + metadata=row, + note="Candidate name/location only; compare with a vendor baseline", + ) counts["regular_files"] += 1 view = View(path, "raw") unpacked: View | None = None diff --git a/tests/test_easy_cli.py b/tests/test_easy_cli.py index cf427d8..6ac84ec 100644 --- a/tests/test_easy_cli.py +++ b/tests/test_easy_cli.py @@ -95,6 +95,18 @@ class EasyCliTests(unittest.TestCase): self.invoke(["scan", str(folder)]) self.assertEqual(len(self.events()), 1) + def test_relative_output_preserves_preparation_manifest(self) -> None: + folder = self.case / "logs" + folder.mkdir() + (folder / "httpaccess.log").write_text(LOG, encoding="utf-8") + self.invoke(["scan", "logs", "--output", "results/new"]) + output = self.case / "results/new" + manifest = json.loads((output / "details/preparation_manifest.json").read_text()) + self.assertTrue((output / "SUMMARY.txt").is_file()) + for root in manifest["roots"]: + self.assertTrue((output / root["path"]).is_dir()) + self.assertEqual(len(self.events()), 1) + def test_missing_common_drive_uses_current_output_parent(self) -> None: folder = self.case / "logs" folder.mkdir() diff --git a/tests/test_supplemental_checks.py b/tests/test_supplemental_checks.py new file mode 100644 index 0000000..56772df --- /dev/null +++ b/tests/test_supplemental_checks.py @@ -0,0 +1,106 @@ +"""Review heuristics must retain context and never turn a candidate into proof.""" + +import json +import tempfile +import unittest +from pathlib import Path +from typing import ClassVar + +from netscaler_triage.filesystem import scan_filesystem +from netscaler_triage.matching import Matcher + + +class SupplementalChecksTests(unittest.TestCase): + matcher: ClassVar[Matcher] + + @classmethod + def setUpClass(cls) -> None: + cls.matcher = Matcher([]) + + def test_review_rules_raw_decoded_and_negative_controls(self) -> None: + cases = { + "httpd_commented_security_directive": ("# Require all denied", "Require all denied"), + "httpd_php_handler_review": ( + "AddHandler application/x-httpd-php .php", + "# AddHandler application/x-httpd-php .php", + ), + "startup_python_reference": ( + "/flash/nsconfig/rc.netscaler: python3 script.py", + "python3 script.py", + ), + "core_file_removal": ("rm -f /var/core/*", "ls /var/core/*"), + "wsfed_request_id_anomaly": ("WSFed: request_id bad^M^M", "WSFed: request_id abc-123"), + "acs_control_marker": ( + "acs=, for login", + "acs=, for login", + ), + "web_artifact_response_review": ( + 'GET /vpn/abcdef.ico HTTP/1.1" 404 1234', + 'GET /vpn/abcdef.ico HTTP/1.1" 404 123', + ), + "web_artifact_error_review": ( + "File does not exist: /vpn/abcdef/.sig", + "File does not exist: /favicon.ico", + ), + "packet_engine_open_file_review": ( + "NSPPE 42 root 3r /var/netscaler/sample", + "other 42 root 3r /var/netscaler/sample", + ), + "web_user_process_review": ( + "nobody 42 0.0 0.0 /bin/helper", + "nobody 42 0.0 0.0 /bin/httpd -k start", + ), + } + for name, (positive, negative) in cases.items(): + with self.subTest(name=name): + hits = self.matcher.search_sigma({"raw": positive}) + self.assertTrue(any(h["name"] == name and h["level"] == "low" for h in hits)) + decoded = self.matcher.search_sigma({"raw": "opaque"}, [positive]) + self.assertTrue( + any(h["name"] == name and "decoded:0" in h["basis"] for h in decoded) + ) + self.assertFalse( + any(h["name"] == name for h in self.matcher.search_sigma({"raw": negative})) + ) + + def test_snapshot_paths_original_bytes_and_config_scope(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "export" + entries = { + "etc/blx/nshttpd.conf": ( + b"# Require all denied\nAddHandler application/x-httpd-php .php\n" + ), + "flash/nsconfig/rc.netscaler": b"python3 /opt/example.py\n", + "var/log/ns.log": b"WSFed: request_id \x81\nacs=, for login\n", + "var/vpn/test.PHP": b"inert", + "var/netscaler/gui/package.bin": b"not found\x00/opt/Citrix/NSGClient/a", + "var/netscaler/gui/test.sig": b"inert", + "var/netscaler/gui/admin_ui/vendor.php": b"inert", + "var/log/notes.log": b"# Require all denied\n", + } + for name, content in entries.items(): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + result = scan_filesystem(source, root / "results") + self.assertTrue(result["complete_scan"]) + rows = [ + json.loads(line) + for line in (root / "results/findings.jsonl").read_text().splitlines() + ] + found = {(r["path"], r["rule"]) for r in rows} + for source_path, rule in [ + ("etc/blx/nshttpd.conf", "httpd_commented_security_directive"), + ("flash/nsconfig/rc.netscaler", "startup_python_content"), + ("var/log/ns.log", "wsfed_request_id_anomaly"), + ("var/log/ns.log", "acs_control_marker"), + ("var/vpn/test.PHP", "web_tree_file_review"), + ("var/netscaler/gui/test.sig", "web_tree_file_review"), + ]: + self.assertIn((source_path, rule), found) + self.assertNotIn( + ("var/netscaler/gui/admin_ui/vendor.php", "web_tree_file_review"), found + ) + self.assertNotIn(("var/log/notes.log", "httpd_commented_security_directive"), found) + self.assertNotIn(("var/netscaler/gui/package.bin", "web_artifact_error_review"), found) diff --git a/uv.lock b/uv.lock index 92286e7..928a911 100644 --- a/uv.lock +++ b/uv.lock @@ -546,7 +546,7 @@ wheels = [ [[package]] name = "netscaler-triage" -version = "1.0.1" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "py7zr" },