diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-08 13:41:59 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-08 13:41:59 +0200 |
| commit | c2b0d5c8ebe980f59e2d8ad21e4f69f3d1d28990 (patch) | |
| tree | 524f4a8df8e3b4286e63a58bbb88fa73b38f71d2 | |
| parent | 498282f9d89cdc391781b9aadfe329452122fa98 (diff) | |
| download | abusectl-c2b0d5c8ebe980f59e2d8ad21e4f69f3d1d28990.tar.gz abusectl-c2b0d5c8ebe980f59e2d8ad21e4f69f3d1d28990.zip | |
feat: case directory with an atomically written manifest
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
| -rw-r--r-- | abusectl/case.py | 132 | ||||
| -rw-r--r-- | tests/test_case.py | 102 |
2 files changed, 234 insertions, 0 deletions
diff --git a/abusectl/case.py b/abusectl/case.py new file mode 100644 index 0000000..4c8c2db --- /dev/null +++ b/abusectl/case.py @@ -0,0 +1,132 @@ +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""The case directory: the state every abusectl subcommand reads and writes. + +A case lives on disk, not in memory, because a review can take a week and +must survive a reboot: parse, contacts, report and submit run as separate +invocations, possibly days apart, and the manifest is what carries state +between them. This module is the ONLY writer of a case directory; every +other module that touches case state goes through create/load/save here. + +The manifest is written atomically, temp file plus rename in the same +directory, because a half-written manifest during a review is a corrupted +evidence record and the user is a security consultant relying on these +directories as evidence. + +Nothing in this module deletes a case, and nothing should be added that +does. A case, once created, is permanent. + +source.eml holds the original message UNREDACTED. The redaction rules +elsewhere in this tool govern what may be PUBLISHED, not what is kept +locally, so a case directory is sensitive at rest and must be treated +accordingly by anything that handles the path. +""" + +import json +import os +import secrets +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +FORMAT_VERSION = 1 + +MANIFEST = "manifest.json" +SOURCE = "source.eml" +BODIES = "bodies" + + +@dataclass(frozen=True) +class Case: + path: Path + case_id: str + + +def create(root: Path, raw: bytes) -> Case: + """Create a new case directory under root and return it. + + The case id is <YYYY-MM-DD>-<4 hex chars>: the date for browsing, the + random suffix for collision resistance. Deliberately not a hash of the + message, since two reports of the same campaign are separate cases. + """ + root = Path(root) + root.mkdir(parents=True, exist_ok=True) + + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + while True: + case_id = f"{today}-{secrets.token_hex(2)}" + path = root / case_id + try: + path.mkdir() + break + except FileExistsError: + continue + + (path / BODIES).mkdir() + (path / SOURCE).write_bytes(raw) + + manifest = { + "format": FORMAT_VERSION, + "case_id": case_id, + "created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "source": SOURCE, + "iocs": [], + "auth": [], + "contacts": [], + "destinations": [], + } + save(path, manifest) + + return Case(path=path, case_id=case_id) + + +def load(path: Path) -> dict: + """Read and parse the manifest, refusing one from a newer format. + + A newer writer may mean fields this build would silently drop on the + next save, so refusing here is correct rather than cautious. + """ + manifest = json.loads((Path(path) / MANIFEST).read_text()) + if manifest.get("format") != FORMAT_VERSION: + raise ValueError( + f"unknown manifest format {manifest.get('format')!r} in {path}, " + f"expected {FORMAT_VERSION}" + ) + return manifest + + +def save(path: Path, manifest: dict) -> None: + """Write the manifest atomically: temp file, fsync, rename. + + The temp file is created in the same directory as the target so the + rename is on one filesystem and therefore atomic. Any failure during + the write, including one raised by json.dump on unserialisable data, + removes the temp file and leaves the existing manifest untouched. + """ + path = Path(path) + fd, tmp_name = tempfile.mkstemp(dir=path, suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + json.dump(manifest, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_name, path / MANIFEST) + except BaseException: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass + raise diff --git a/tests/test_case.py b/tests/test_case.py new file mode 100644 index 0000000..c6964a9 --- /dev/null +++ b/tests/test_case.py @@ -0,0 +1,102 @@ +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Tests for the case directory: creation, manifest round trip, atomic saves.""" + +import json +import pathlib +import tempfile +import unittest + +from abusectl import case + + +class TestCaseCreation(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def test_a_case_holds_the_source_and_a_manifest(self): + created = case.create(self.root, b"From: <a@b.example.invalid>\r\n\r\nhi") + self.assertTrue((created.path / "source.eml").is_file()) + self.assertTrue((created.path / "manifest.json").is_file()) + + def test_the_source_is_stored_byte_for_byte(self): + raw = b"From: <a@b.example.invalid>\r\n\r\nhi\r\n" + created = case.create(self.root, raw) + self.assertEqual((created.path / "source.eml").read_bytes(), raw) + + def test_the_manifest_carries_a_format_version(self): + created = case.create(self.root, b"x") + manifest = json.loads((created.path / "manifest.json").read_text()) + self.assertEqual(manifest["format"], case.FORMAT_VERSION) + + def test_two_cases_do_not_collide(self): + a = case.create(self.root, b"one") + b = case.create(self.root, b"two") + self.assertNotEqual(a.path, b.path) + + def test_a_case_id_is_filesystem_safe(self): + created = case.create(self.root, b"x") + self.assertRegex(created.path.name, r"^\d{4}-\d{2}-\d{2}-[0-9a-f]{4}$") + + def test_a_manifest_round_trips(self): + created = case.create(self.root, b"x") + manifest = case.load(created.path) + manifest["iocs"] = [{"id": "ioc-1", "type": "ipv4", "value": "203.0.113.1"}] + case.save(created.path, manifest) + self.assertEqual(case.load(created.path)["iocs"][0]["value"], "203.0.113.1") + + def test_saving_leaves_no_temporary_file_behind(self): + # The manifest is written atomically, temp file plus rename, because a + # half-written manifest during a review is a corrupted evidence record. + created = case.create(self.root, b"x") + case.save(created.path, case.load(created.path)) + leftovers = [p.name for p in created.path.iterdir() if p.suffix == ".tmp"] + self.assertEqual(leftovers, []) + + def test_an_unknown_manifest_format_is_refused(self): + # A newer writer may mean fields this build would silently drop on the + # next save, so refusing is correct rather than cautious. + created = case.create(self.root, b"x") + manifest = case.load(created.path) + manifest["format"] = case.FORMAT_VERSION + 1 + (created.path / "manifest.json").write_text(json.dumps(manifest)) + with self.assertRaises(ValueError): + case.load(created.path) + + def test_a_failed_save_leaves_the_existing_manifest_intact(self): + # An evidence record must not be destroyed by a write that fails. + created = case.create(self.root, b"x") + good = case.load(created.path) + good["iocs"] = [{"id": "ioc-1"}] + case.save(created.path, good) + + class Unserialisable: + pass + + with self.assertRaises(TypeError): + case.save(created.path, {"format": case.FORMAT_VERSION, + "bad": Unserialisable()}) + + self.assertEqual(case.load(created.path)["iocs"], [{"id": "ioc-1"}]) + leftovers = [p.name for p in created.path.iterdir() if p.suffix == ".tmp"] + self.assertEqual(leftovers, []) + + +if __name__ == "__main__": + unittest.main() |
