From 1a3da08faf1c33306ed341ab9f381670cc21f5b7 Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 09:07:22 +0330 Subject: [PATCH 1/3] feat(safety): argument rules for dc3dd, ewfacquire and exiftool Phase 10, step 5 plan, PR 3b (decision D14i). Imaging and metadata tools need rules a list of options cannot express; codebud/forensic.py holds them, and the safety engine applies them wherever a profile allow-lists the tool, behind wrappers too (complete mediation). - dc3dd: input an image file inside the project, never a device; every output and log inside the project; hash= and log=/hlog= required (NIST SP 800-86 section 4.2.2, ACPO principle 3); wipe=, hwipe=, pat=, tpat= and unknown keys refused. - ewfacquire: -u required, and with it the case metadata on the command line (-C -e -E -D), -d with sha256, -l, the target (and a second one) inside the project, the source an image file inside the project; unknown options refused. The plan said to refuse -u, but without it ewfacquire prompts on stdin, which a step cannot answer; requiring the metadata flags meets the plan's concern (metadata skipped) instead. - exiftool: reading only. Its format strings (${...}), -if and -config run Perl, and =, < and > write, copy or rename tags: those, and the options that write, run code or read arguments from a file, are refused; read options and tag names (-Artist) are allowed. Paths are judged with symlinks followed. The rules come from the man pages; none of the tools is installed on the dev machine, so they are tested as rules, not against the binaries. Manual (Safety and Sandbox), architecture layout, build list and test list updated. --- docs/chapters/architecture.texi | 1 + docs/chapters/development.texi | 2 + docs/chapters/safety-and-sandbox.texi | 43 ++++++ src/Makefile.am | 1 + src/codebud/forensic.py | 188 ++++++++++++++++++++++++++ src/codebud/safety.py | 7 +- tests/test_forensic_rules.py | 178 ++++++++++++++++++++++++ 7 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 src/codebud/forensic.py create mode 100644 tests/test_forensic_rules.py diff --git a/docs/chapters/architecture.texi b/docs/chapters/architecture.texi index 68564ee..5b477e7 100644 --- a/docs/chapters/architecture.texi +++ b/docs/chapters/architecture.texi @@ -573,6 +573,7 @@ src/codebud/ the package ├── confine.py where file tools may read and write ├── redact.py mask secrets before sessions/history are saved ├── safety.py SafetyEngine (policy validation) +├── forensic.py argument rules for dc3dd, ewfacquire, exiftool ├── modes.py agent modes (ask, auto, full-auto, plan, edit) ├── sandbox.py Sandbox (subprocess + timeout) ├── memory.py MemoryStore (timeline persistence) diff --git a/docs/chapters/development.texi b/docs/chapters/development.texi index 9e923d0..1b8a9d8 100644 --- a/docs/chapters/development.texi +++ b/docs/chapters/development.texi @@ -72,6 +72,8 @@ tests/ ├── test_evidence.py evidence log: hash chain, tamper │ detection, artefact hashes, encrypted │ entries (append while locked) +├── test_forensic_rules.py dc3dd/ewfacquire on images in the project, +│ hashed and logged; exiftool only reads ├── test_fsutil.py atomic_write_text() / _bytes() ├── test_gitsafe.py git runs none of the repository's configured │ programs (fsmonitor, filters, textconv) diff --git a/docs/chapters/safety-and-sandbox.texi b/docs/chapters/safety-and-sandbox.texi index b414ac4..e0863f8 100644 --- a/docs/chapters/safety-and-sandbox.texi +++ b/docs/chapters/safety-and-sandbox.texi @@ -142,6 +142,49 @@ allowed. A program with such rules run by @command{xargs} or file or from input, which cannot be checked (@code{xargs -a files.txt wc -l} is fine). +@cindex imaging tools, rules +@cindex dc3dd +@cindex ewfacquire +@cindex exiftool +Three tools get rules a list of options cannot express. They apply +wherever a profile allow-lists the tool (@code{security-ir} does) and +keep it to working on evidence as NIST SP 800-86 (section 4.2.2) and the +ACPO principles ask: on copies, hashed, logged, never writing to a +device or outside the project. + +@table @command +@item dc3dd +Its input (@option{if=}, @option{ifs=}) must be an image file inside the +project, never a device; every output and log (@option{of=}, +@option{hof=}, @option{ofs=}, @option{hofs=}, @option{log=}, +@option{hlog=}, @option{mlog=}) inside the project; @option{hash=} and +@option{log=} or @option{hlog=} required. @option{wipe=}, +@option{hwipe=}, @option{pat=} and @option{tpat=} are refused, and so is +any key not known to be safe. +@item ewfacquire +Unattended (@option{-u}), since nobody can answer its prompts in a step, +and so with the case metadata on the command line: @option{-C} (case), +@option{-e} (examiner), @option{-E} (evidence number), @option{-D} +(description); @option{-d} including @code{sha256}; a log +(@option{-l}); the target (@option{-t}) and any second target +(@option{-2}) inside the project; the source one image file inside the +project. Options not known to be safe are refused. +@item exiftool +Only reading. Refused: an argument with @samp{=}, @samp{<} or @samp{>} +(writing, copying or renaming tags), one with @samp{$@{} (Perl code), +and the options that write, run code or read arguments from a file +(@option{-o}, @option{-overwrite_original}, @option{-tagsFromFile}, +@option{-geotag}, @option{-p}, @option{-if}, @option{-config}, +@option{-api}, @option{-@@}, @option{-w}, @option{-execute}, @dots{}). +Read options and tag names (@code{exiftool -a -G1 -s}, +@code{exiftool -Artist photo.jpg}) are fine. +@end table + +@noindent +The paths are judged with symbolic links followed, so a link inside the +project that points out of it does not count as inside. These rules +were written from the tools' manual pages. + @cindex refused_arguments.json You can refuse more with @file{refused_arguments.json}, which maps a program to the options it may never be given: diff --git a/src/Makefile.am b/src/Makefile.am index 118f6da..5d0a044 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -26,6 +26,7 @@ nobase_python_PYTHON = \ codebud/crypto.py \ codebud/encrypt_existing.py \ codebud/executor.py \ + codebud/forensic.py \ codebud/fsutil.py \ codebud/gitsafe.py \ codebud/integrity.py \ diff --git a/src/codebud/forensic.py b/src/codebud/forensic.py new file mode 100644 index 0000000..c9fa227 --- /dev/null +++ b/src/codebud/forensic.py @@ -0,0 +1,188 @@ +""" +codebud/forensic.py + +Argument rules for imaging and metadata tools that a word list cannot +express (phase 10, step 5 plan, PR 3b). They apply wherever a profile +allow-lists the tool -- security-ir does -- and keep it to working on +evidence the way NIST SP 800-86 (section 4.2.2) and ACPO principle 3 +ask: on copies, hashed, logged, never writing to a device or outside the +project. + +- dc3dd: input an image file inside the project, never a device; every + output and log inside the project; hash= and a log required; wipe and + pattern writes refused; unknown keys refused. +- ewfacquire: unattended (-u: in a step nobody can answer its prompts) + and so the case metadata given on the command line (-C -e -E -D), + -d with sha256, a log (-l), the target (-t) and any second target + inside the project, the source an image file inside the project; + options not known to be safe refused. +- exiftool: only reading. Its format strings (${...}), -if conditions and + -config files run Perl code, and tag values are written with =, < and + >: those, and the options that write, run or read arguments from a + file, are refused. Read options and tag names (-Artist) are fine. + +Rules were written from the tools' manual pages (dc3dd(1), +ewfacquire(1), exiftool(1)). +""" + +import os +import re + +# --- paths ------------------------------------------------------------------- + + +def _inside_project(path: str) -> bool: + """Whether ``path`` (symlinks followed) is inside the current project + directory and is not a device.""" + real = os.path.realpath(path) + if real.startswith("/dev/") or real == "/dev": + return False + project = os.path.realpath(os.getcwd()) + return os.path.commonpath([real, project]) == project + + +def _image_file(path: str) -> bool: + """An existing regular file inside the project (an image to work on).""" + return _inside_project(path) and os.path.isfile(path) + + +# --- dc3dd ------------------------------------------------------------------- + +_DC3DD_INPUT = {"if", "ifs"} +_DC3DD_OUTPUT = {"of", "hof", "ofs", "hofs", "log", "hlog", "mlog"} +_DC3DD_REFUSED = { + "wipe": "wipes", + "hwipe": "wipes", + "pat": "writes a pattern", + "tpat": "writes a pattern", +} +_DC3DD_VALUES = { + "hash": re.compile(r"^(md5|sha1|sha256|sha512)(,(md5|sha1|sha256|sha512))*$"), + "cnt": re.compile(r"^\d+$"), + "iskip": re.compile(r"^\d+$"), + "oskip": re.compile(r"^\d+$"), + "bufsz": re.compile(r"^\d+[KMG]?$"), + "ssz": re.compile(r"^\d+$"), + "verb": re.compile(r"^on$"), + "nwspc": re.compile(r"^on$"), + "rec": re.compile(r"^off$"), +} + + +def _dc3dd(args: list[str]) -> str | None: + keys = {} + for arg in args: + key, sep, value = arg.partition("=") + if not sep: + return f"{arg}: dc3dd takes key=value arguments only" + if key in _DC3DD_REFUSED: + return f"{key}= {_DC3DD_REFUSED[key]}" + if key in _DC3DD_INPUT: + if not _image_file(value): + return ( + f"{key}={value}: input must be an image file inside the project, not a device" + ) + elif key in _DC3DD_OUTPUT: + if not _inside_project(value): + return f"{key}={value}: output must be inside the project, not a device" + elif key in _DC3DD_VALUES: + if not _DC3DD_VALUES[key].match(value): + return f"{key}={value}: not an accepted value" + else: + return f"{key}=: not a known safe dc3dd option" + keys[key] = value + if not keys.keys() & _DC3DD_INPUT: + return "dc3dd needs an input image (if=)" + if "hash" not in keys: + return "dc3dd must hash what it copies (hash=sha256)" + if not keys.keys() & {"log", "hlog"}: + return "dc3dd must log what it does (log= or hlog=)" + return None + + +# --- ewfacquire -------------------------------------------------------------- + +# Options that take a value; paths among them must stay inside the project. +_EWF_VALUE = {"-A", "-b", "-B", "-c", "-C", "-d", "-D", "-e", "-E", "-f", "-l", "-m", + "-M", "-N", "-o", "-p", "-P", "-r", "-S", "-t", "-T", "-2"} # fmt: skip +_EWF_PATHS = {"-l", "-t", "-2"} +_EWF_FLAGS = {"-u", "-q", "-v", "-R", "-s", "-x", "-h", "-V"} +_EWF_REQUIRED = { + "-u": "unattended (-u): in a step nobody can answer its prompts", + "-C": "the case number (-C)", + "-e": "the examiner (-e)", + "-E": "the evidence number (-E)", + "-D": "a description (-D)", + "-l": "a log file (-l)", + "-t": "the target (-t)", +} + + +def _ewfacquire(args: list[str]) -> str | None: + given, sources = {}, [] + i = 0 + while i < len(args): + arg = args[i] + if arg in _EWF_VALUE: + if i + 1 >= len(args): + return f"{arg} needs a value" + value = args[i + 1] + if arg in _EWF_PATHS and not _inside_project(value): + return f"{arg} {value}: must be inside the project, not a device" + given[arg] = value + i += 2 + continue + if arg in _EWF_FLAGS: + given[arg] = "" + elif arg.startswith("-"): + return f"{arg}: not a known safe ewfacquire option" + else: + sources.append(arg) + i += 1 + for option, what in _EWF_REQUIRED.items(): + if option not in given: + return f"ewfacquire needs {what}" + if "sha256" not in given.get("-d", "").split(","): + return "ewfacquire must compute a SHA-256 (-d sha256)" + if len(sources) != 1 or not _image_file(sources[0]): + return "ewfacquire's source must be one image file inside the project, not a device" + return None + + +# --- exiftool ---------------------------------------------------------------- + +# Options that write, run code, or take arguments from somewhere unseen +# (compared lower-case, before any "="). +_EXIF_REFUSED = { + "-o", "-out", "-overwrite_original", "-overwrite_original_in_place", + "-tagsfromfile", "-geotag", "-geosync", "-geotime", "-config", "-p", + "-printformat", "-api", "-@", "-execute", "-srcfile", "-delete_original", + "-delete_original!", "-restore_original", "-textout", "-tagout", "-k", + "-pause", "-stay_open", "-common_args", "-userparam", "-wm", "-writemode", +} # fmt: skip +_EXIF_REFUSED_FORMS = re.compile(r"^(-if\d*|-w[!+]*|-execute\d*)$") + + +def _exiftool(args: list[str]) -> str | None: + for arg in args: + if "${" in arg: + return f"{arg}: exiftool runs Perl code in ${{...}}" + if not arg.startswith("-"): + continue # a file or a value + if any(c in arg for c in "=<>"): + return f"{arg}: writes, copies or renames tags" + name = arg.lower() + if name in _EXIF_REFUSED or _EXIF_REFUSED_FORMS.match(name): + return f"{arg}: writes, runs code or reads arguments from a file" + return None + + +_RULES = {"dc3dd": _dc3dd, "ewfacquire": _ewfacquire, "exiftool": _exiftool} +PROGRAMS = frozenset(_RULES) + + +def argument_refusal(program: str, args: list[str]) -> str | None: + """Why ``program args`` is refused by these rules, or None (also for + programs without rules).""" + rule = _RULES.get(program) + return rule(args) if rule else None diff --git a/src/codebud/safety.py b/src/codebud/safety.py index 45a4fc1..f097460 100644 --- a/src/codebud/safety.py +++ b/src/codebud/safety.py @@ -30,7 +30,7 @@ import shlex from typing import Any -from . import privacy +from . import forensic, privacy from .modes import DEFAULT_MODE, step_allowed # The policy in effect -- yours, adjusted by the project's -- comes from @@ -583,7 +583,7 @@ def _ruled_programs(self) -> set[str]: """Program names some refusal rule applies to (family names).""" refused = getattr(self, "refused", {}) return { - "find", "python", "git", *_AWKS, *_SEDS, *_REFUSED_FLOOR, + "find", "python", "git", *_AWKS, *_SEDS, *_REFUSED_FLOOR, *forensic.PROGRAMS, *(_program_name(name) for name in refused), } # fmt: skip @@ -597,6 +597,9 @@ def _argument_refusal(self, words: list[str]) -> str | None: forbidden = _forbidden_argument([program, *args]) if forbidden: return f"{shown} {forbidden} is never allowed (it can run or delete anything)" + evidence = forensic.argument_refusal(program, args) + if evidence: + return f"{shown} refused: {evidence}" if program == "tar" and args and not args[0].startswith("-"): # tar's old style: a dash-less first argument is a cluster of # options (tar cIf a.tar . is tar -c -I -f). diff --git a/tests/test_forensic_rules.py b/tests/test_forensic_rules.py new file mode 100644 index 0000000..2d2516d --- /dev/null +++ b/tests/test_forensic_rules.py @@ -0,0 +1,178 @@ +""" +tests/test_forensic_rules.py + +Argument rules for the imaging and metadata tools (phase 10, step 5 +plan, PR 3b): dc3dd and ewfacquire work only on image files inside the +project, hashing and logging what they do (NIST SP 800-86 section 4.2.2, +ACPO principle 3); exiftool may only read -- an allow-list of read-only +options, since its format strings, -if and -config run Perl code. +""" + +import pytest + +from codebud.forensic import argument_refusal + + +@pytest.fixture +def project(tmp_path, monkeypatch): + root = tmp_path / "case" + (root / "evidence").mkdir(parents=True) + (root / "evidence" / "disk.raw").write_bytes(b"\0" * 512) + monkeypatch.chdir(root) + return root + + +def _refused(cmd): + import shlex + + words = shlex.split(cmd) + return argument_refusal(words[0], words[1:]) + + +# --- dc3dd --------------------------------------------------------------- + + +GOOD_DC3DD = "dc3dd if=evidence/disk.raw of=evidence/copy.raw hash=sha256 log=evidence/copy.log" + + +def test_dc3dd_on_an_image_in_the_project_with_hash_and_log(project): + assert _refused(GOOD_DC3DD) is None + assert _refused(GOOD_DC3DD.replace("log=", "hlog=")) is None + assert _refused(GOOD_DC3DD + " hof=evidence/copy2.raw verb=on") is None + + +@pytest.mark.parametrize( + ("cmd", "why"), + [ + ("dc3dd if=/dev/sda of=evidence/x.raw hash=sha256 log=evidence/x.log", "a device"), + ("dc3dd if=evidence/disk.raw of=/dev/sdb hash=sha256 log=evidence/x.log", "a device"), + ("dc3dd if=evidence/disk.raw of=/tmp/x.raw hash=sha256 log=evidence/x.log", "outside"), + ("dc3dd if=evidence/disk.raw of=../x.raw hash=sha256 log=evidence/x.log", "outside"), + ("dc3dd if=evidence/disk.raw of=evidence/x.raw hash=sha256 log=/var/log/x", "outside"), + ("dc3dd if=/etc/shadow of=evidence/x.raw hash=sha256 log=evidence/x.log", "outside"), + ("dc3dd if=evidence/disk.raw of=evidence/x.raw log=evidence/x.log", "no hash"), + ("dc3dd if=evidence/disk.raw of=evidence/x.raw hash=sha256", "no log"), + ("dc3dd wipe=/dev/sda", "wipes"), + ("dc3dd hwipe=evidence/disk.raw hash=sha256 log=evidence/x.log", "wipes"), + ("dc3dd pat=00 of=evidence/x.raw hash=sha256 log=evidence/x.log", "pattern write"), + ( + "dc3dd if=evidence/disk.raw of=evidence/x.raw hash=sha256 log=evidence/x.log rec=on", + "unknown", + ), + ("dc3dd --help-me", "unknown"), + ], +) +def test_dc3dd_refusals(project, cmd, why): + assert _refused(cmd) is not None, f"{cmd} ({why})" + + +def test_dc3dd_rejects_a_symlink_out_of_the_project(project, tmp_path): + (tmp_path / "outside").mkdir() + (project / "evidence" / "link").symlink_to(tmp_path / "outside") + cmd = "dc3dd if=evidence/disk.raw of=evidence/link/x.raw hash=sha256 log=evidence/x.log" + assert _refused(cmd) is not None + + +# --- ewfacquire ---------------------------------------------------------- + + +GOOD_EWF = ( + "ewfacquire -u -C CASE-1 -e analyst -E EV-7 -D 'web server disk' " + "-d sha256 -l evidence/acq.log -t evidence/disk evidence/disk.raw" +) + + +def test_ewfacquire_unattended_with_metadata_hash_and_log(project): + assert _refused(GOOD_EWF) is None + assert _refused(GOOD_EWF + " -N 'imaged from a copy' -f encase6 -c fast") is None + + +@pytest.mark.parametrize( + ("change", "why"), + [ + (lambda c: c.replace(" -u", ""), "interactive: cannot answer in a step"), + (lambda c: c.replace("-C CASE-1 ", ""), "no case number"), + (lambda c: c.replace("-e analyst ", ""), "no examiner"), + (lambda c: c.replace("-E EV-7 ", ""), "no evidence number"), + (lambda c: c.replace("-D 'web server disk' ", ""), "no description"), + (lambda c: c.replace("-d sha256 ", ""), "no sha256"), + (lambda c: c.replace("-l evidence/acq.log ", ""), "no log"), + (lambda c: c.replace("evidence/disk.raw", "/dev/sda"), "a device"), + (lambda c: c.replace("-t evidence/disk", "-t /tmp/disk"), "outside"), + (lambda c: c.replace("-l evidence/acq.log", "-l /tmp/acq.log"), "outside"), + (lambda c: c + " -2 /mnt/usb/copy", "secondary target outside"), + (lambda c: c + " -w", "zeroes on read error: writes"), + (lambda c: c + " -Z", "unknown"), + ], +) +def test_ewfacquire_refusals(project, change, why): + assert _refused(change(GOOD_EWF)) is not None, why + + +# --- exiftool ------------------------------------------------------------ + + +@pytest.mark.parametrize( + "cmd", + [ + "exiftool evidence/disk.raw", + "exiftool -a -u -G1 -s evidence/", + "exiftool -j -n -r -ext jpg evidence/", + "exiftool -csv -r evidence/", + ], +) +def test_exiftool_reading_is_allowed(project, cmd): + assert _refused(cmd) is None, cmd + + +@pytest.mark.parametrize( + ("cmd", "why"), + [ + ("exiftool -Artist=x evidence/a.jpg", "writes a tag"), + ("exiftool -all= evidence/a.jpg", "deletes tags"), + ("exiftool -overwrite_original -Artist=x evidence/a.jpg", "overwrites"), + ("exiftool -tagsFromFile a.jpg b.jpg", "copies tags"), + ("exiftool -o out.jpg a.jpg", "writes a file"), + ("exiftool -p '${filename;system(\"id\")}' a.jpg", "runs Perl"), + ("exiftool -if '$Make eq 1' a.jpg", "runs Perl"), + ("exiftool -config evil.pl a.jpg", "loads Perl"), + ("exiftool -@ args.txt", "arguments from a file"), + ("exiftool '-filename Date: Thu, 12 Nov 2026 09:09:00 +0330 Subject: [PATCH 2/3] feat(security-ir): image files and metadata, within the forensic rules Phase 10, step 5 plan, PR 3b. security-ir gains the imaging and metadata tools, now that forensic.py keeps them to evidence handling: - powerful (asked every time): dc3dd and ewfacquire, on image files inside the project only -- re-imaging and converting (raw to E01), hashed and logged; - harmless (they only read): ewfverify, ewfinfo, exiftool. Acquisition from devices stays with the operator, outside codebud (NIST SP 800-86 section 4.2.1): dc3dd from /dev, dd, and exiftool writes stay refused; tests pin that. Manual (Configuration > Profiles) and NEWS updated. --- NEWS | 2 + docs/chapters/configuration.texi | 12 ++++-- .../security-ir/harmless_commands.json | 3 +- .../security-ir/powerful_commands.json | 2 +- tests/test_security_ir.py | 43 +++++++++++++++++++ 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/NEWS b/NEWS index 75e0e7c..eea6a52 100644 --- a/NEWS +++ b/NEWS @@ -72,6 +72,8 @@ a safety policy you can read and change. security-ir, the first security profile (in codebud_sec): incident response on hosts you own -- locked, observer isolation, encrypted, integrity required, a read-only triage and forensic allow-list. + Imaging tools work on image files in the project only, hashed and + logged (dc3dd, ewfacquire), and exiftool may only read. A profile can ask for observer isolation (isolation = "observer"): command steps see the host's processes through a read-only /proc, diff --git a/docs/chapters/configuration.texi b/docs/chapters/configuration.texi index 6ad258f..0a853d5 100644 --- a/docs/chapters/configuration.texi +++ b/docs/chapters/configuration.texi @@ -457,13 +457,16 @@ text and binary viewers (@command{cat}, @command{grep}, @command{rg}, (@command{ps}, @command{pstree}, @command{lsof}, @command{who}, @command{w}, @command{last}, @command{lastb}), @command{journalctl}, @command{ausearch}, @command{aureport}, reading a capture -(@command{tcpdump -r}, @command{tshark -r}), @command{find}, -@command{lsblk}, @command{date}. Collecting copies and the forensic +(@command{tcpdump -r}, @command{tshark -r}), reading images and +metadata (@command{ewfverify}, @command{ewfinfo}, @command{exiftool}), +@command{find}, @command{lsblk}, @command{date}. Collecting copies and the forensic tools are powerful and asked for every time: @command{cp}, @command{tar}, @command{yara}, Volatility (@command{vol}, @command{vol3}), the Sleuth Kit (@command{fls}, @command{icat}, @command{mmls}, @command{istat}, @command{fsstat}) and plaso -(@command{log2timeline.py}, @command{psort.py}, @command{pinfo.py}). +(@command{log2timeline.py}, @command{psort.py}, @command{pinfo.py}), +and imaging image files that are in the project (@command{dc3dd}, +@command{ewfacquire}), hashed and logged (@pxref{The Safety Engine}). Nothing that deletes, moves, kills, changes permissions or installs is on it, and no trusted sequence. @@ -483,7 +486,8 @@ isolation they would show the sandbox --- a falsely clean answer; use system bus, which steps cannot reach. Disk and memory acquisition is done by you, outside Codebud, with a write-blocker where it applies (NIST SP 800-86 advises against imaging a whole device from a live -system); Codebud analyses the images. +system); Codebud analyses the images, and re-images or converts them +(raw to E01) only as files inside the project. @end table @subheading Your own profiles diff --git a/src/codebud_sec/data/profiles/security-ir/harmless_commands.json b/src/codebud_sec/data/profiles/security-ir/harmless_commands.json index 5100600..0a666bb 100644 --- a/src/codebud_sec/data/profiles/security-ir/harmless_commands.json +++ b/src/codebud_sec/data/profiles/security-ir/harmless_commands.json @@ -4,5 +4,6 @@ "readelf", "objdump", "nm", "sha256sum", "sha1sum", "md5sum", "b3sum", "ps", "pstree", "lsof", "who", "w", "last", "lastb", "lastlog", "id", "getent", "uname", "uptime", "date", "hostname", "lsblk", "journalctl", - "ausearch", "aureport", "tcpdump -r", "tshark -r", "find", "pwd", "echo" + "ausearch", "aureport", "tcpdump -r", "tshark -r", "find", "pwd", "echo", + "ewfverify", "ewfinfo", "exiftool" ] diff --git a/src/codebud_sec/data/profiles/security-ir/powerful_commands.json b/src/codebud_sec/data/profiles/security-ir/powerful_commands.json index 5db7a65..bbeb655 100644 --- a/src/codebud_sec/data/profiles/security-ir/powerful_commands.json +++ b/src/codebud_sec/data/profiles/security-ir/powerful_commands.json @@ -1,4 +1,4 @@ [ "cp", "tar", "yara", "vol", "vol3", "fls", "icat", "mmls", "istat", - "fsstat", "log2timeline.py", "psort.py", "pinfo.py" + "fsstat", "log2timeline.py", "psort.py", "pinfo.py", "dc3dd", "ewfacquire" ] diff --git a/tests/test_security_ir.py b/tests/test_security_ir.py index cff144d..a289989 100644 --- a/tests/test_security_ir.py +++ b/tests/test_security_ir.py @@ -129,3 +129,46 @@ def test_iso_timestamps_have_an_allowed_spelling(ir): """-Iseconds holds an "s" in a short-option cluster and is refused by the conservative rule; the long spelling does the same and runs.""" assert _engine().refusal_reason(_step("date --iso-8601=seconds")) is None + + +@pytest.fixture +def case(tmp_path, monkeypatch): + root = tmp_path / "case" + (root / "evidence").mkdir(parents=True) + (root / "evidence" / "disk.raw").write_bytes(b"\0" * 512) + monkeypatch.chdir(root) + return root + + +def test_imaging_an_image_file_is_allowed_and_asked(ir, case): + engine = _engine() + for cmd in ( + "dc3dd if=evidence/disk.raw of=evidence/copy.raw hash=sha256 log=evidence/copy.log", + "ewfacquire -u -C C1 -e me -E E1 -D disk -d sha256 -l evidence/a.log -t evidence/disk evidence/disk.raw", + ): + assert engine.refusal_reason(_step(cmd)) is None, cmd + assert engine.is_powerful(_step(cmd)), cmd + + +def test_reading_images_and_metadata_is_harmless(ir, case): + engine = _engine() + for cmd in ( + "ewfverify -d sha256 evidence/disk.E01", + "ewfinfo evidence/disk.E01", + "exiftool -a -G1 evidence/", + ): + assert engine.refusal_reason(_step(cmd)) is None, cmd + assert not engine.is_powerful(_step(cmd)), cmd + + +@pytest.mark.parametrize( + "cmd", + [ + "dc3dd if=/dev/sda of=evidence/x.raw hash=sha256 log=evidence/x.log", + "dc3dd wipe=/dev/sda", + "exiftool -overwrite_original -Artist=x evidence/a.jpg", + "dd if=/dev/sda of=evidence/x.raw", + ], +) +def test_acquiring_from_devices_and_writing_metadata_are_refused(ir, case, cmd): + assert _engine().refusal_reason(_step(cmd)) is not None, cmd From e9e155b8a2dbe110122bc21ff8aa49cf6f592d6b Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 09:11:47 +0330 Subject: [PATCH 3/3] fix(forensic): outputs, targets and logs must be new files Review of PR #17. The rules only required outputs to be inside the project and not a device, so an imaging step could overwrite evidence: `dc3dd if=evidence/disk.raw of=evidence/disk.raw ...` truncated the source image, of= or hof= could replace another evidence file, log= could be the image itself, and ewfacquire's -l, -t or -2 likewise. Every dc3dd output and log, and ewfacquire's log, target and second target, must now be a path that does not exist yet -- which also rules out the input, since it exists. For an ewfacquire target, a base name, its segment files (.E01, .Ex01, ...) must not exist either. Nine new cases, all allowed before. Manual updated. --- docs/chapters/safety-and-sandbox.texi | 7 +++-- src/codebud/forensic.py | 28 ++++++++++++++++--- tests/test_forensic_rules.py | 39 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/docs/chapters/safety-and-sandbox.texi b/docs/chapters/safety-and-sandbox.texi index e0863f8..c393fe3 100644 --- a/docs/chapters/safety-and-sandbox.texi +++ b/docs/chapters/safety-and-sandbox.texi @@ -181,8 +181,11 @@ Read options and tag names (@code{exiftool -a -G1 -s}, @end table @noindent -The paths are judged with symbolic links followed, so a link inside the -project that points out of it does not count as inside. These rules +Every output, target and log must be a new file: one that already +exists --- the input image, another piece of evidence, an earlier +@file{.E01} --- is refused, so an imaging step can never overwrite +evidence. The paths are judged with symbolic links followed, so a link +inside the project that points out of it does not count as inside. These rules were written from the tools' manual pages. @cindex refused_arguments.json diff --git a/src/codebud/forensic.py b/src/codebud/forensic.py index c9fa227..db78226 100644 --- a/src/codebud/forensic.py +++ b/src/codebud/forensic.py @@ -41,6 +41,17 @@ def _inside_project(path: str) -> bool: return os.path.commonpath([real, project]) == project +def _new_output(path: str) -> str | None: + """Why ``path`` may not be written, or None: it must be inside the + project, not a device, and not exist yet -- so an output can never + overwrite the input or another piece of evidence.""" + if not _inside_project(path): + return "must be inside the project, not a device" + if os.path.lexists(path): + return "exists already: outputs and logs must be new files" + return None + + def _image_file(path: str) -> bool: """An existing regular file inside the project (an image to work on).""" return _inside_project(path) and os.path.isfile(path) @@ -83,8 +94,9 @@ def _dc3dd(args: list[str]) -> str | None: f"{key}={value}: input must be an image file inside the project, not a device" ) elif key in _DC3DD_OUTPUT: - if not _inside_project(value): - return f"{key}={value}: output must be inside the project, not a device" + problem = _new_output(value) + if problem: + return f"{key}={value}: {problem}" elif key in _DC3DD_VALUES: if not _DC3DD_VALUES[key].match(value): return f"{key}={value}: not an accepted value" @@ -106,6 +118,7 @@ def _dc3dd(args: list[str]) -> str | None: _EWF_VALUE = {"-A", "-b", "-B", "-c", "-C", "-d", "-D", "-e", "-E", "-f", "-l", "-m", "-M", "-N", "-o", "-p", "-P", "-r", "-S", "-t", "-T", "-2"} # fmt: skip _EWF_PATHS = {"-l", "-t", "-2"} +_EWF_SEGMENTS = (".E01", ".Ex01", ".e01", ".s01", ".L01", ".Lx01") _EWF_FLAGS = {"-u", "-q", "-v", "-R", "-s", "-x", "-h", "-V"} _EWF_REQUIRED = { "-u": "unattended (-u): in a step nobody can answer its prompts", @@ -127,8 +140,15 @@ def _ewfacquire(args: list[str]) -> str | None: if i + 1 >= len(args): return f"{arg} needs a value" value = args[i + 1] - if arg in _EWF_PATHS and not _inside_project(value): - return f"{arg} {value}: must be inside the project, not a device" + if arg in _EWF_PATHS: + problem = _new_output(value) + if problem: + return f"{arg} {value}: {problem}" + if arg != "-l": + # A target is a base name; its segment files must be new too. + existing = [value + e for e in _EWF_SEGMENTS if os.path.lexists(value + e)] + if existing: + return f"{arg} {value}: {existing[0]} exists already: images are never overwritten" given[arg] = value i += 2 continue diff --git a/tests/test_forensic_rules.py b/tests/test_forensic_rules.py index 2d2516d..a6a969e 100644 --- a/tests/test_forensic_rules.py +++ b/tests/test_forensic_rules.py @@ -176,3 +176,42 @@ def test_the_engine_applies_the_rules(project): def test_the_rules_hold_behind_a_wrapper(project): cmd = "timeout 600 dc3dd if=/dev/sda of=evidence/x hash=sha256 log=evidence/l" assert _engine().refusal_reason({"tool": "command", "args": {"cmd": cmd}}) is not None + + +# --- outputs must be new files (review of #17) ------------------------------ + + +@pytest.mark.parametrize( + "cmd", + [ + "dc3dd if=evidence/disk.raw of=evidence/disk.raw hash=sha256 log=evidence/x.log", + "dc3dd if=evidence/disk.raw of=evidence/report.txt hash=sha256 log=evidence/x.log", + "dc3dd if=evidence/disk.raw hash=sha256 log=evidence/disk.raw", + "dc3dd if=evidence/disk.raw hof=evidence/report.txt hash=sha256 hlog=evidence/x.log", + ], +) +def test_dc3dd_never_overwrites_a_file(project, cmd): + (project / "evidence" / "report.txt").write_text("findings\n") + reason = _refused(cmd) + assert reason is not None and "exists" in reason, cmd + + +@pytest.mark.parametrize( + "change", + [ + lambda c: c.replace("-l evidence/acq.log", "-l evidence/disk.raw"), + lambda c: c.replace("-l evidence/acq.log", "-l evidence/report.txt"), + lambda c: c + " -2 evidence/report.txt", + ], +) +def test_ewfacquire_never_overwrites_a_file(project, change): + (project / "evidence" / "report.txt").write_text("findings\n") + reason = _refused(change(GOOD_EWF)) + assert reason is not None and "exists" in reason + + +@pytest.mark.parametrize("segment", ["disk.E01", "disk.Ex01"]) +def test_ewfacquire_never_overwrites_an_existing_image(project, segment): + (project / "evidence" / segment).write_bytes(b"EVF") + reason = _refused(GOOD_EWF) + assert reason is not None and "exists" in reason