Skip to main content
Ethical Hacking (methodology, lab-only) · lesson 4 of 6
Open the Ethical Hacking (methodology, lab-only) page →

Logging Test Activity and Hashing the Evidence

Keep a pentest activity log in UTC tied to the signed scope, hash every evidence file with SHA-256, and catch a one-byte change with a verify step.

By SkillByExample · Published

ethical-hacking penetration-testing evidence sha-256 python

The findings report says what you found. The activity log says what you did: every scan, request and check, with the time, the target and the engagement it belongs to. If a client’s server falls over at 10:40 on Wednesday, the log answers whether you were testing it then. The evidence behind each finding gets the same care, with a SHA-256 hash recorded when it’s collected, so anyone can check later that it hasn’t changed.

The examples continue the ACME-2026-10 engagement from the scope lesson, and read the same scope.yaml. They use Python 3.12 and PyYAML 6.0.3 on Linux, with GNU sha256sum and sed. Nothing here contacts a host. The commands in the log are strings the tester records, not commands this lesson runs.

The same moment, two different times

Two testers on the same engagement each note the time of one action, using their laptop’s clock.

# clock.py: one instant, written down by two testers' laptops
from datetime import datetime, timezone

instant = 1792578300  # a single moment in time, as a Unix timestamp
local = datetime.fromtimestamp(instant)
utc = datetime.fromtimestamp(instant, timezone.utc)
print(f"local: {local.isoformat()}")
print(f"utc:   {utc.isoformat()}")
$ TZ=Europe/London python clock.py
local: 2026-10-21T11:25:00
utc:   2026-10-21T10:25:00+00:00
$ TZ=America/New_York python clock.py
local: 2026-10-21T06:25:00
utc:   2026-10-21T10:25:00+00:00

datetime.fromtimestamp without a timezone returns a naive local time with no offset. London is on summer time in October, so it says 11:25, and New York says 06:25. Neither string records which zone it came from. Put both in one log and the same action looks like two events five hours apart. The UTC line is identical on both machines and carries its +00:00 offset, so anyone can convert it back. Client firewall and SIEM logs are often kept in UTC too, so a UTC log lines up with theirs. Confirm the client’s log timezone at kickoff, so you know which way to convert if they aren’t.

One line per action

The log is a JSON Lines file: one JSON object per line, appended and never rewritten. Each entry carries the engagement ID from scope.yaml, so a log can’t be mistaken for another client’s. LOG_NOW pins the clock for reproducible output, as SCOPE_NOW did in the scope lesson.

# log_action.py: append one tester action to activity.jsonl, timestamped in UTC
import json
import os
import sys
from datetime import datetime, timezone

import yaml

scope = yaml.safe_load(open("scope.yaml"))
now = os.environ.get("LOG_NOW")
now = datetime.fromisoformat(now) if now else datetime.now(timezone.utc)
target, tool, command, result = sys.argv[1:5]
entry = {
    "ts": now.astimezone(timezone.utc).isoformat(),
    "engagement": scope["engagement"],
    "tester": os.environ.get("TESTER", "unknown"),
    "target": target, "tool": tool, "command": command, "result": result,
}
with open("activity.jsonl", "a", encoding="utf-8") as log:
    log.write(json.dumps(entry) + "\n")
print(f"logged {entry['ts']} {entry['engagement']} {target} {tool}")
$ export TESTER=a.rao
$ LOG_NOW=2026-10-21T10:25:00+00:00 python log_action.py 10.0.0.5 nmap "nmap -sV -p 8080 10.0.0.5" "8080/tcp open http"
logged 2026-10-21T10:25:00+00:00 ACME-2026-10 10.0.0.5 nmap
$ LOG_NOW=2026-10-21T11:40:00+01:00 python log_action.py 10.0.0.5 curl "curl -i http://10.0.0.5:8080/admin/users" "200, user list without auth"
logged 2026-10-21T10:40:00+00:00 ACME-2026-10 10.0.0.5 curl
$ cat activity.jsonl
{"ts": "2026-10-21T10:25:00+00:00", "engagement": "ACME-2026-10", "tester": "a.rao", "target": "10.0.0.5", "tool": "nmap", "command": "nmap -sV -p 8080 10.0.0.5", "result": "8080/tcp open http"}
{"ts": "2026-10-21T10:40:00+00:00", "engagement": "ACME-2026-10", "tester": "a.rao", "target": "10.0.0.5", "tool": "curl", "command": "curl -i http://10.0.0.5:8080/admin/users", "result": "200, user list without auth"}

The second action was given in London summer time, 11:40:00+01:00, and the log stored 10:40:00+00:00. astimezone(timezone.utc) converts any offset to UTC before writing, so every line uses the same clock. Opening the file in append mode ("a") adds lines without touching the earlier ones. The exact command goes in the log as well as the result, so a reviewer can repeat it. In a real engagement, put the scope checker in front: python scope_check.py "$t" && nmap ... "$t" && python log_action.py "$t" ... logs only what the scope allowed.

Hash the evidence when you collect it

Evidence is the raw material behind a finding: a saved HTTP response, a header, a file listing. Create two evidence files for F-01 and F-05 from the findings report. HTTP header lines end in \r\n (carriage return, line feed), so the saved response does too.

# hash_evidence.py: record a SHA-256 for every evidence file in manifest.json
import hashlib
import json
from pathlib import Path

manifest = {}
for path in sorted(Path("evidence").rglob("*")):
    if path.is_file():
        digest = hashlib.sha256(path.read_bytes()).hexdigest()
        manifest[path.as_posix()] = digest
        print(f"{digest}  {path.as_posix()}")

Path("manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
print(f"{len(manifest)} files hashed into manifest.json")
$ mkdir -p evidence
$ printf 'HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Type: application/json\r\n\r\n[{"id": 1, "name": "admin"}]\n' > evidence/F-01-admin-users.http
$ printf 'Server: nginx/1.24.0\n' > evidence/F-05-server-header.txt
$ python hash_evidence.py
ec806b226f096ec5bf1e7df414fa7bae70da22ae53bc7a0c187fc4c077f0aed1  evidence/F-01-admin-users.http
1fa87d163c3fe10b25c86adfbba59244ce691839e00b272b25b543393be2f2c1  evidence/F-05-server-header.txt
2 files hashed into manifest.json
$ sha256sum evidence/*
ec806b226f096ec5bf1e7df414fa7bae70da22ae53bc7a0c187fc4c077f0aed1  evidence/F-01-admin-users.http
1fa87d163c3fe10b25c86adfbba59244ce691839e00b272b25b543393be2f2c1  evidence/F-05-server-header.txt

The script prints in the same hash path layout as sha256sum, and the hashes match. That matters: the client can check your manifest with a standard tool, without trusting your script. sorted makes the manifest’s order stable from run to run. as_posix() writes the path with forward slashes even on Windows, so the manifest checks the same way on any machine. The evidence in a real report has its live credentials redacted before you hash it, because the hash fixes the file as it was when you hashed it.

The text-mode trap

hash_evidence.py reads the files with read_bytes(). Opening the file normally looks equivalent, but it isn’t.

# hash_text.py: the same evidence file, hashed two ways
import hashlib

path = "evidence/F-01-admin-users.http"
with open(path) as f:  # text mode
    text_digest = hashlib.sha256(f.read().encode()).hexdigest()
with open(path, "rb") as f:  # binary mode
    raw_digest = hashlib.sha256(f.read()).hexdigest()

print(f"text mode:   {text_digest}")
print(f"binary mode: {raw_digest}")
with open(path) as f:
    print(f"carriage returns seen in text mode: {f.read().count(chr(13))}")
$ python hash_text.py
text mode:   354cf88f2bd14592a33f407964eb49c01da0578397e90effe84e693dee5f4528
binary mode: ec806b226f096ec5bf1e7df414fa7bae70da22ae53bc7a0c187fc4c077f0aed1
carriage returns seen in text mode: 0

Only the binary-mode hash matches sha256sum. Text mode uses universal newlines: on reading, it translates every \r\n into \n, so the four carriage returns in the response vanish before the hash sees them. The text-mode hash is a correct hash of a file that doesn’t exist. Plain .txt evidence with only \n endings would hash the same both ways, which is why this bug passes a quick test and then fails on the first saved HTTP response. Hash bytes, never text.

Verify, and fail on a single byte

Before you write the report, and again before you hand evidence over, recompute every hash and compare it with the manifest. The exit code makes it usable as a gate.

# verify_evidence.py: fail if any evidence file changed since it was hashed
import hashlib
import json
import sys
from pathlib import Path

manifest = json.loads(Path("manifest.json").read_text())
problems = 0
for name, expected in manifest.items():
    path = Path(name)
    if not path.is_file():
        print(f"MISSING  {name}")
        problems += 1
        continue
    actual = hashlib.sha256(path.read_bytes()).hexdigest()
    if actual == expected:
        print(f"OK       {name}")
    else:
        print(f"CHANGED  {name}\n  expected {expected}\n  actual   {actual}")
        problems += 1
sys.exit(1 if problems else 0)
$ python verify_evidence.py; echo "exit=$?"
OK       evidence/F-01-admin-users.http
OK       evidence/F-05-server-header.txt
exit=0
$ sed -i 's/1.24.0/1.24.1/' evidence/F-05-server-header.txt
$ python verify_evidence.py; echo "exit=$?"
OK       evidence/F-01-admin-users.http
CHANGED  evidence/F-05-server-header.txt
  expected 1fa87d163c3fe10b25c86adfbba59244ce691839e00b272b25b543393be2f2c1
  actual   79082989e11455109090219e2df4c63d6b0e67dfe1a736c24e52e0b50a18038e
exit=1

The sed changed one character, 0 to 1, and the whole hash changed. A cryptographic hash gives no hint of how big the change was: one byte or a whole page produces an equally different value. Separately, SHA-256 is designed so nobody can construct a different file with the same hash, so an altered file can’t be adjusted until it matches the manifest again. The verifier says which file changed and exits 1, but it can’t say what the file used to contain. Keep the original evidence read-only, and keep a copy somewhere the working directory can’t touch.

The manifest is only as trustworthy as its own copy. If someone can edit both a file and manifest.json, the check passes. Record the manifest’s own hash where you can’t quietly change it: in the activity log, and in the end-of-day email to the client’s contact.

Practice

  1. Restore the tampered file from your read-only copy (here, sed can undo the one-byte change), then add a new file to evidence/. verify_evidence.py still passes, because it only checks the files the manifest lists. Make it report extra files too.
Solution

Add this loop just before sys.exit:

for path in sorted(Path("evidence").rglob("*")):
    if path.is_file() and path.as_posix() not in manifest:
        print(f"EXTRA    {path.as_posix()}")
        problems += 1
$ sed -i 's/1.24.1/1.24.0/' evidence/F-05-server-header.txt
$ printf 'GET /debug returned 200\n' > evidence/F-07-debug.txt
$ python verify_evidence.py; echo "exit=$?"
OK       evidence/F-01-admin-users.http
OK       evidence/F-05-server-header.txt
EXTRA    evidence/F-07-debug.txt
exit=1

An extra file isn’t necessarily tampering. Often it’s new evidence nobody hashed yet. It still fails the check, because evidence without a recorded hash can’t be shown to be original. Hash it, and log that you did.

  1. A tester in New York sets LOG_NOW=2026-10-21T10:25:00, without an offset, meaning 10:25 UTC. What does the log record? Make log_action.py refuse a time with no offset.
Solution
$ export TESTER=a.rao
$ TZ=America/New_York LOG_NOW=2026-10-21T10:25:00 python log_action.py 10.0.0.5 nmap "nmap -sV -p 8080 10.0.0.5" "8080/tcp open http"
logged 2026-10-21T14:25:00+00:00 ACME-2026-10 10.0.0.5 nmap

fromisoformat returns a naive datetime, and astimezone treats a naive value as the machine’s local time. The action is logged four hours late. Refuse naive times after parsing:

if now.tzinfo is None:
    sys.exit("LOG_NOW needs a UTC offset, for example 2026-10-21T10:25:00+00:00")
$ TZ=America/New_York LOG_NOW=2026-10-21T10:25:00 python log_action.py 10.0.0.5 nmap "nmap -sV -p 8080 10.0.0.5" "8080/tcp open http"; echo "exit=$?"
LOG_NOW needs a UTC offset, for example 2026-10-21T10:25:00+00:00
exit=1

The real clock path is already safe, because datetime.now(timezone.utc) is timezone-aware.

  1. Log two more actions, then write summary.py to print, for each target, how many actions it received and the first and last timestamp.
Solution
# summary.py: how many actions hit each target, and between which times
import json
from collections import defaultdict

stamps = defaultdict(list)
for line in open("activity.jsonl", encoding="utf-8"):
    entry = json.loads(line)
    stamps[entry["target"]].append(entry["ts"])

for target, ts in sorted(stamps.items()):
    print(f"{target}: {len(ts)} logged, first {min(ts)}, last {max(ts)}")
$ LOG_NOW=2026-10-21T13:05:00+00:00 python log_action.py 172.20.0.10 nmap "nmap -sV -p 443 172.20.0.10" "443/tcp open https"
logged 2026-10-21T13:05:00+00:00 ACME-2026-10 172.20.0.10 nmap
$ LOG_NOW=2026-10-22T09:15:00+00:00 python log_action.py 10.0.0.5 curl "curl -I http://10.0.0.5:8080/" "Server: nginx/1.24.0"
logged 2026-10-22T09:15:00+00:00 ACME-2026-10 10.0.0.5 curl
$ python summary.py
10.0.0.5: 4 logged, first 2026-10-21T10:25:00+00:00, last 2026-10-22T09:15:00+00:00
172.20.0.10: 1 logged, first 2026-10-21T13:05:00+00:00, last 2026-10-21T13:05:00+00:00

10.0.0.5 shows 4 entries, not 3: the wrongly timed 14:25 line from task 2 is still in the log. Leave it there. An append-only log is corrected by appending a note that says which entry was wrong and why, never by deleting a line. min and max compare the timestamps as strings, which works only because every entry is UTC in the same ISO 8601 format. Mixed offsets would sort wrongly, which is one more reason to convert to UTC as you write each line. This summary is what goes in the report’s methodology section: which hosts were tested, and when.

  1. At the end of the day, record the manifest’s hash in the activity log, so a later edit to manifest.json would be visible.
Solution
$ export TESTER=a.rao LOG_NOW=2026-10-22T17:00:00+00:00
$ python log_action.py evidence sha256sum "sha256sum manifest.json" "$(sha256sum manifest.json | cut -c1-64)"
logged 2026-10-22T17:00:00+00:00 ACME-2026-10 evidence sha256sum
$ tail -n 1 activity.jsonl
{"ts": "2026-10-22T17:00:00+00:00", "engagement": "ACME-2026-10", "tester": "a.rao", "target": "evidence", "tool": "sha256sum", "command": "sha256sum manifest.json", "result": "5f9c5e8bffa6bc20957d4f9e7485c4a70727a91d7130623b0b5b50af47cd44fe"}

cut -c1-64 keeps just the 64 hex characters of the hash. The log is on your machine, so on its own this only moves the problem. Send the same hash to the client’s technical contact in the end-of-day email. Once it’s in their inbox, neither side can change the manifest without the change showing.

Frequently Asked Questions

Why does a penetration tester keep an activity log?
It's the record of what was done, to which host, and when. If a client system fails during the test, the log shows whether the tester was touching it at that moment. It also proves every action was inside the signed scope and window, and it lets someone else repeat a finding later.
Why should pentest logs use UTC timestamps?
Testers, clients and their monitoring systems are often in different time zones, and local times written without an offset are ambiguous. A UTC timestamp with an explicit offset means the same instant to everyone. That makes it possible to line your log up against the client's firewall and server logs.
How do you prove evidence files haven't been changed?
Record a cryptographic hash, such as SHA-256, of each file when you collect it, and keep the list of hashes separately from the files. Recomputing a hash later and comparing it with the recorded one shows whether the file changed, even by a single byte. Send the hash of the list itself to the client too, so neither side can quietly alter it.
Why does hashing a file in Python text mode give the wrong result?
Text mode decodes the bytes and translates line endings, turning each carriage return and line feed pair into a single newline. The hash is then computed over different bytes from the ones on disk, so it won't match sha256sum or any other tool. Always open the file in binary mode, or use Path.read_bytes, when hashing.