ToolCONSTRUCTED

hash_manifest.py: write and verify a SHA-256 manifest for an evidence directory

Hashes every file in a collection into a sha256sum-compatible manifest, refuses to overwrite one that exists, and on verification reports changed, missing and new files as the three different findings they are.

version 1.0checked 2026-09-21any1 min read

What it is for

Evidence that cannot be shown to be unchanged is an anecdote. The fix is dull and takes one command: hash everything at collection time, record the hash of the manifest itself in your case notes, and verify before you rely on a copy.

python3 hash_manifest.py create /cases/0142/collection
python3 hash_manifest.py verify /cases/0142/collection

Three design decisions

It will not overwrite a manifest. The first manifest is the record of what you collected. A tool that silently replaces it turns "verify" into "agree with whatever is there now".

Verification separates three outcomes. A file whose hash changed, a file that is missing, and a file that is new since the manifest was written are different findings with different explanations, and a single "FAILED" hides which one you have. Any of the three makes the exit status non-zero, so it can gate a script.

The format is sha256sum's. The manifest can be checked with sha256sum -c MANIFEST.sha256 on Linux or shasum -a 256 -c on macOS, without this tool. Evidence handling should not depend on a script from a website still existing in five years.

Symbolic links are skipped rather than followed, so a link inside a collection cannot make the tool hash something outside it.

How it was tested

Run on macOS with Python 3.9: a manifest was created, verified clean, then a file was altered, one deleted and one added, and all three were reported with a non-zero exit. A second create was refused. The manifest was independently checked with shasum -a 256 -c. It has not been tested on Windows paths.

The files

hash_manifest.py

Standard library only, Python 3.8 or later, no network access.

Download hash_manifest.py · 119 lines · 4.4 KB

#!/usr/bin/env python3
"""
hash_manifest.py: write or verify a SHA-256 manifest for an evidence directory.
Security Artifacts, version 1.0, 2026-09-21. Released under CC0.

Standard library only, Python 3.8 or later, no network.

    python3 hash_manifest.py create  /cases/0142/collection
    python3 hash_manifest.py verify  /cases/0142/collection

`create` walks the directory, hashes every file and writes MANIFEST.sha256 in
the same format `sha256sum` produces, so the two tools can check each other.
It refuses to overwrite an existing manifest: the first manifest is the record
of what you collected, and replacing it silently is how a chain of custody
stops meaning anything.

`verify` re-hashes and reports three different things, because they are three
different findings: a file whose hash CHANGED, a file that is MISSING, and a
file that is NEW since the manifest was written. It exits non-zero if any of
the three occurred, so it can sit in a script.
"""
import argparse
import hashlib
import os
import sys

MANIFEST = "MANIFEST.sha256"
CHUNK = 1024 * 1024


def sha256_of(path):
    digest = hashlib.sha256()
    with open(path, "rb") as handle:
        for block in iter(lambda: handle.read(CHUNK), b""):
            digest.update(block)
    return digest.hexdigest()


def walk(root):
    """Relative paths of every regular file under root, sorted, manifest excluded."""
    found = []
    for directory, _subdirs, names in os.walk(root):
        for name in names:
            full = os.path.join(directory, name)
            rel = os.path.relpath(full, root)
            if rel == MANIFEST or os.path.islink(full) or not os.path.isfile(full):
                continue
            found.append(rel)
    return sorted(found)


def create(root):
    target = os.path.join(root, MANIFEST)
    if os.path.exists(target):
        sys.exit(
            f"{target} already exists. Verify against it, or move it aside deliberately; "
            "this tool will not replace a manifest."
        )
    files = walk(root)
    with open(target, "w", encoding="utf-8", newline="\n") as out:
        for rel in files:
            out.write(f"{sha256_of(os.path.join(root, rel))}  ./{rel.replace(os.sep, '/')}\n")
    print(f"{len(files)} files hashed")
    print(f"manifest: {target}")
    print(f"manifest sha256: {sha256_of(target)}")
    print("Record that last line in your case notes. It is what proves the manifest itself is unchanged.")


def read_manifest(path):
    recorded = {}
    with open(path, encoding="utf-8") as handle:
        for number, line in enumerate(handle, 1):
            line = line.rstrip("\n")
            if not line.strip():
                continue
            # sha256sum separates with two spaces, or " *" for binary mode.
            digest, sep, name = line.partition("  ")
            if not sep:
                digest, sep, name = line.partition(" *")
            if not sep or len(digest) != 64:
                sys.exit(f"{path}:{number}: not a sha256sum line")
            recorded[name[2:] if name.startswith("./") else name] = digest.lower()
    return recorded


def verify(root):
    target = os.path.join(root, MANIFEST)
    if not os.path.exists(target):
        sys.exit(f"no {MANIFEST} in {root}")
    recorded = read_manifest(target)
    present = {rel.replace(os.sep, "/") for rel in walk(root)}

    changed = [n for n in sorted(recorded) if n in present and sha256_of(os.path.join(root, n)) != recorded[n]]
    missing = [n for n in sorted(recorded) if n not in present]
    new = sorted(present - set(recorded))

    for label, names in (("CHANGED", changed), ("MISSING", missing), ("NEW", new)):
        for name in names:
            print(f"{label:8} {name}")

    intact = len(recorded) - len(changed) - len(missing)
    print(f"{intact} of {len(recorded)} recorded files intact; {len(changed)} changed, {len(missing)} missing, {len(new)} new")
    print(f"manifest sha256: {sha256_of(target)}")
    return 1 if (changed or missing or new) else 0


def main():
    parser = argparse.ArgumentParser(description="Write or verify a SHA-256 manifest for an evidence directory.")
    parser.add_argument("mode", choices=["create", "verify"])
    parser.add_argument("directory")
    args = parser.parse_args()
    if not os.path.isdir(args.directory):
        sys.exit(f"{args.directory} is not a directory")
    sys.exit(create(args.directory) if args.mode == "create" else verify(args.directory))


if __name__ == "__main__":
    main()

sources

  1. RFC 3227: Guidelines for Evidence Collection and Archiving (chain of custody and cryptographic checksums) · primary
  2. NIST SP 800-86: Guide to Integrating Forensic Techniques into Incident Response

Tags: evidence · chain-of-custody · hashing · python · integrity · collection