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.
#!/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()