129 lines
5.5 KiB
Python
129 lines
5.5 KiB
Python
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()
|