Build an Investigative Log Analyzer
Build and test a Python tool that correlates synthetic security logs.
Introduction
30 Second Summary
A failed sign-in can look harmless on its own. Its meaning changes when later activity forms a pattern around it.
In this project, you will build CaseTrace with Python to turn a fully synthetic incident log into a tested HIGH-severity finding with supporting evidence. This is Part 1 of the CaseTrace cybersecurity series.
What You'll Build
You'll run CaseTrace against a synthetic log to reveal a HIGH-severity incident in the terminal plus a readable report for an investigative teammate.
By the end of this project, you'll have:
- A validated timeline that loads nine good events while clearly reporting the malformed CSV row it skipped.
- A correlation engine that reconstructs one HIGH-severity sequence across sign-ins, file access, plus outbound network activity.
- A defensible result backed by an investigator-readable Markdown report plus a separate SHA-256 integrity record plus five passing tests.
- Secret Mission: Escalate the incident when CaseTrace connects the suspicious sequence to a later device-health warning.
Are there any prerequisites?
You'll need a Windows 10 or newer computer with Visual Studio Code plus basic Python syntax. Step 1 guides you through installing Python 3.15.2.
Before We Start
Before any hands-on work, lock in CaseTrace's synthetic-only mission. CaseTrace will analyze a fictional CSV incident log with repeatable, tested results so an investigative team can review defensible findings without collecting or examining real personal data.
Set Up the Windows Workspace
Your synthetic-only mission is clear: CaseTrace must produce repeatable findings without touching real personal data. That promise depends on a controlled local environment.
CaseTrace needs a verified Python runtime before it can validate the fictional log. A project-specific virtual environment keeps that runtime isolated inside the Visual Studio Code workspace.
In this step, get ready to:
- Install and verify Python 3.15.2.
- Create the CaseTrace workspace with separate evidence and output folders.
- Create and activate an isolated .venv for CaseTrace.
Install and verify Python
The Python install manager provides the runtime that executes CaseTrace. You will verify the exact version from the same terminal used throughout the project.
- Visit the Python downloads page in your browser.
- Download the Python install manager.
- Double-click the downloaded installer file.
- Select Install.
The install manager is now available on your computer. The first runtime download can pause briefly while Python is added.
- Press the Windows key to open search.
- Type Visual Studio Code into search.
- Press Enter to open Visual Studio Code.
- Select Terminal from the top menu.
- Select New Terminal.
- Check the runtime version by running this command. The Python install manager may prompt you to install the current latest stable runtime:
python --version
What does this command do?
The --version option prints the active Python version. This confirms which runtime Visual Studio Code can use.
- Approve the runtime installation if the Python install manager prompts you.
✔️ I see the required version
You should see Python 3.15.2 in the terminal. Your runtime is ready for CaseTrace.
ⓧ I see an older version
An older runtime can behave differently from the version used for this project. Install the latest release in the 3.15 series before continuing.
- Install the latest 3.15 runtime by running these commands:
py install 3.15
python --version
What do these commands do?
- The first command asks the Python install manager for the latest runtime in the 3.15 series.
- The second command prints the runtime selected by the global Python command.
You should now see Python 3.15.2 in the terminal.
Still seeing the older version?
Close the current terminal after the installation completes. Create a new integrated terminal so Visual Studio Code can detect the updated runtime.
Still stuck? Help me make Python 3.15.2 the active runtime on Windows.
ⓧ Command not found
The terminal cannot reach the Python install manager yet. Reinstalling the manager registers the global Python command.
- Return to the Python downloads page.
- Download the Python install manager.
- Double-click the downloaded installer file.
- Select Install.
- Close the current terminal in Visual Studio Code.
- Select Terminal from the top menu.
- Select New Terminal.
- Check the registered runtime by running this command:
python --version
What does this command confirm?
The command confirms that the new terminal can reach Python. It also prints the exact runtime version selected by Windows.
You should now see Python 3.15.2 in the terminal.
Python still unavailable?
Restart Visual Studio Code after the installer finishes. A fresh application session can detect the newly registered command.
Need a hand? Help me troubleshoot why Windows cannot find Python.
That is the runtime hurdle cleared. Visual Studio Code can now run the same Python version used throughout this project.
Create the CaseTrace workspace
A workspace gives every project file one predictable home. Separate folders keep the synthetic source log away from reports generated later.
- Select File from the Visual Studio Code menu.
- Select Open Folder....
You will see a folder selection window. This is where you create the workspace on your Desktop.
- Choose Desktop as the location.
- Select New Folder.
A name field appears for the new folder.
- Enter CaseTrace as the folder name.
- Select Select Folder.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
The Explorer sidebar now shows CaseTrace as the open workspace. The folder is empty and ready for its project structure.
- Select Terminal from the top menu.
- Select New Terminal.
- Create the evidence and generated-artifact folders by running these commands:
New-Item -Path "data" -ItemType "Directory"
New-Item -Path "output" -ItemType "Directory"
What do these commands create?
- The first command creates data for the synthetic evidence file.
- The second command creates output for reports and integrity records generated later.
- Both paths are relative to the open CaseTrace workspace.
- Confirm the Explorer sidebar shows data and output directly under CaseTrace.
You should see both folders in the Explorer. Each folder should be empty.
Folders missing from Explorer?
Check that the terminal prompt points to the CaseTrace folder. A different location places the new folders outside this workspace.
If the commands report that the folders already exist, confirm that both folders are empty before continuing.
Need help? Help me find the data and output folders created by PowerShell.
The workspace now has clear places for source evidence and generated results. The final layer gives CaseTrace its own Python interpreter and package directory.
- Create .venv inside the open CaseTrace workspace by running this command:
python -m venv .venv
Why use a virtual environment?
- python -m venv .venv uses the verified Python runtime to create a project-specific interpreter.
- .venv stores its interpreter and package directory inside CaseTrace.
- CaseTrace uses only the Python standard library today. The isolated environment also prevents conflicts if you add packages later.
- Activate .venv in the current PowerShell terminal by running this command:
.\.venv\Scripts\Activate.ps1
You should see (.venv) at the start of the terminal prompt. That prefix confirms that python now uses the interpreter inside .venv.
PowerShell blocked activation?
Changing an execution policy can feel risky. This command applies RemoteSigned only to your Windows account:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Approve the PowerShell confirmation if it appears. Run the activation command again, then look for (.venv) at the start of the prompt.
Still blocked? Help me activate the CaseTrace virtual environment.
✔️ Awesome, I've got everything!
Your CaseTrace workspace contains .venv, data, and output. The integrated terminal prompt begins with (.venv).
ⓧ I'd like to double check the workspace
- Confirm .venv appears directly under CaseTrace.
- Confirm data appears directly under CaseTrace.
- Confirm output appears directly under CaseTrace.
- Confirm the integrated terminal prompt begins with (.venv).
Verify the complete workspace
Before you run the final check, which Python version should the active .venv report?
- Check the Python version inside the active virtual environment by running this command:
python --version
What does this final check prove?
The (.venv) prompt confirms that the terminal points to the project-specific interpreter. The version output confirms that the environment was created from Python 3.15.2.
Together, these signals prove that later python commands run inside the isolated CaseTrace environment.
You should see (.venv) at the start of the prompt and Python 3.15.2 in the terminal output.
- Confirm the Explorer sidebar shows .venv, data, and output directly under CaseTrace.
That setup is complete. CaseTrace now has an isolated Python 3.15.2 interpreter plus separate folders for synthetic evidence and generated results.
Final check does not match?
If the prompt does not begin with (.venv), activate the environment again:
.\.venv\Scripts\Activate.ps1
If the version differs, return to the outcome tab that matches your result. If a workspace folder is missing, recreate only that folder from the CaseTrace terminal.
Still blocked? Help me verify my isolated CaseTrace setup.
Your isolated workspace is ready. Next, you will add synthetic evidence and run the first CaseTrace analysis inside .venv.
Make the Evidence Visible
Your CaseTrace workspace now runs Python 3.15.2 inside an isolated virtual environment. The empty data folder is ready for synthetic evidence.
Raw CSV logs are difficult to reason about without input validation. This step also tests whether isolated event matching can explain a coordinated incident.
In this step, get ready to:
- Store a fixed synthetic log containing nine valid events plus one malformed row.
- Validate required fields before sorting accepted events by timestamp.
- Run a baseline detector to reveal the limitation of isolated event matching.
Create the synthetic evidence file
A known dataset gives every run the same starting evidence. One deliberately malformed row lets you confirm that the analyzer rejects incomplete input.
- Select the data folder in the Visual Studio Code Explorer sidebar.
- Click the New File button at the top of the Explorer sidebar.
- Enter case_log.csv as the file name.
- Press Enter to create the file inside data.
- Add the fixed evidence dataset by pasting the content below into data/case_log.csv:
timestamp,device,user,source_ip,event_type,detail
2026-10-27T08:45:00Z,workstation-02,asmith,10.0.0.12,AUTH_SUCCESS,routine sign-in
2026-10-27T08:50:00Z,sensor-gateway-02,system,10.0.0.50,DEVICE_HEALTH,status normal
2026-10-27T09:00:00Z,workstation-07,jrivera,10.0.0.24,AUTH_FAILURE,invalid password
2026-10-27T09:01:00Z,workstation-07,jrivera,10.0.0.24,AUTH_FAILURE,invalid password
2026-10-27T09:02:00Z,workstation-07,jrivera,10.0.0.24,AUTH_FAILURE,invalid password
2026-10-27T09:03:00Z,workstation-07,jrivera,10.0.0.24,AUTH_SUCCESS,successful sign-in after failures
2026-10-27T09:04:00Z,workstation-07,jrivera,10.0.0.24,FILE_ACCESS,sensitive_plans.zip opened
2026-10-27T09:05:00Z,workstation-07,jrivera,10.0.0.24,OUTBOUND_CONNECTION,destination 203.0.113.77
2026-10-27T09:06:00Z,workstation-07,system,10.0.0.24,DEVICE_WARNING,network adapter reset unexpectedly
2026-10-27T09:07:00Z,workstation-07,,10.0.0.24,AUTH_FAILURE,missing user field
What is in this dataset?
- The rows cover authentication activity, file access, an outbound connection, and device health.
- The events use fixed timestamps so the analyzer can reconstruct the same order on every run.
- The final row has an empty user field so load_events() can count it as malformed.
- Save data/case_log.csv with Cmd+S on macOS or Ctrl+S on Windows.
- Confirm the Explorer sidebar lists case_log.csv inside the data folder.
- Count the rows below the header to confirm that the file contains ten event rows.
Good progress. Your synthetic evidence file now gives CaseTrace a repeatable incident timeline without using real credentials or production logs.
Does the evidence file look incomplete?
Confirm that the first line begins with timestamp and the final line contains an empty value between workstation-07 and 10.0.0.24.
If the rows appear on one long line, paste the dataset again while case_log.csv is open in the editor.
Still stuck? Help me check the structure of my CaseTrace CSV file.
Build the validator and baseline detector
The analyzer needs a clear schema before it can trust a row. REQUIRED_FIELDS defines that schema while SUSPICIOUS_TYPES defines the three event types used by the baseline detector.
- Select the CaseTrace workspace at the top of the Explorer sidebar.
- Click the New File button at the top of the Explorer sidebar.
- Enter case_trace.py as the file name.
- Press Enter to create the file at the top level of CaseTrace.
- Define the analyzer's inputs by pasting the code below into case_trace.py:
import csv
from pathlib import Path
REQUIRED_FIELDS = (
"timestamp",
"device",
"user",
"source_ip",
"event_type",
"detail",
)
SUSPICIOUS_TYPES = {
"AUTH_FAILURE",
"FILE_ACCESS",
"OUTBOUND_CONNECTION",
}
What do these definitions control?
- The csv module reads each evidence row as named fields.
- Path gives the analyzer a platform-aware reference to the evidence file.
- REQUIRED_FIELDS lists every field that a valid event must contain.
- SUSPICIOUS_TYPES identifies the event categories that the first detector flags individually.
- Save case_trace.py with Cmd+S on macOS or Ctrl+S on Windows.
- Confirm the Explorer sidebar now lists case_trace.py beside the data folder.
Seeing an editor warning?
Check that every field in REQUIRED_FIELDS ends with a comma. Confirm that the tuple closes with a parenthesis.
Check that SUSPICIOUS_TYPES closes with a curly brace.
Need another pair of eyes? Help me check my CaseTrace constants for a Python syntax problem.
Validation protects the analysis from incomplete evidence rows. The loader keeps a separate count of rejected rows so malformed input remains visible to the investigator.
- Add the evidence loader by pasting the function below after SUSPICIOUS_TYPES in case_trace.py:
def load_events(path):
events = []
skipped = 0
with path.open(mode="r", encoding="utf-8", newline="") as source:
reader = csv.DictReader(source)
for row in reader:
is_valid = row and all(
(row.get(field) or "").strip() for field in REQUIRED_FIELDS
)
if not is_valid:
skipped += 1
continue
event = {
field: row[field].strip()
for field in REQUIRED_FIELDS
}
events.append(event)
return sorted(events, key=lambda event: event["timestamp"]), skipped
What does this validator do?
- csv.DictReader maps each row to the field names from the header.
- is_valid requires every named field to contain non-whitespace text.
- skipped records how many rows fail validation.
- sorted() returns the accepted events in timestamp order.
- Save case_trace.py.
- Check the Visual Studio Code Problems panel to confirm that the loader has no syntax diagnostics.
Does the loader show a syntax problem?
Confirm that the body of load_events() uses four spaces for each indentation level. Python uses indentation to define each block.
Compare the closing parenthesis after the all() expression with the code above.
Still seeing a problem? Help me debug the indentation in load_events().
The baseline detector asks one narrow question about each accepted event. It collects suspicious records without connecting them into a sequence.
- Add isolated event matching by pasting the function below after load_events():
def baseline_findings(events):
return [
event
for event in events
if event["event_type"] in SUSPICIOUS_TYPES
]
How does the baseline work?
baseline_findings() keeps any event whose event_type appears in SUSPICIOUS_TYPES.
This produces a list of individual matches. The result does not yet explain whether those records belong to one incident.
- Save case_trace.py.
- Confirm the function contains one membership check against SUSPICIOUS_TYPES.
Is the list comprehension underlined?
Confirm that event appears before the for line. Check that the closing square bracket aligns with return.
Need help comparing the function? Help me fix the baseline_findings() list comprehension.
The execution path connects the evidence file to both functions. It also prints the validation count plus the baseline conclusion for the terminal check.
- Add the execution path by pasting the code below after baseline_findings():
def main():
source_path = Path("data/case_log.csv")
if not source_path.exists():
print("ERROR: data/case_log.csv was not found.")
return 1
events, skipped = load_events(source_path)
findings = baseline_findings(events)
print(f"Loaded {len(events)} valid events; skipped {skipped} malformed row(s).")
print(f"Baseline findings: {len(findings)} individual suspicious event(s).")
print(
"Baseline result: individual suspicious events found, "
"but no coordinated incident was identified."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
What does the execution path do?
- source_path points to the fixed evidence file inside data.
- The existence check stops the analyzer with a clear diagnostic when the source file is unavailable.
- main() loads the accepted events before applying the baseline detector.
- The final print statements expose the valid count, skipped count, number of findings, and baseline limitation.
- Save the completed case_trace.py file.
✔️ Awesome, I've got everything!
Your complete baseline analyzer is saved in case_trace.py.
ⓧ I'd like to double check the full code
import csv
from pathlib import Path
REQUIRED_FIELDS = (
"timestamp",
"device",
"user",
"source_ip",
"event_type",
"detail",
)
SUSPICIOUS_TYPES = {
"AUTH_FAILURE",
"FILE_ACCESS",
"OUTBOUND_CONNECTION",
}
def load_events(path):
events = []
skipped = 0
with path.open(mode="r", encoding="utf-8", newline="") as source:
reader = csv.DictReader(source)
for row in reader:
is_valid = row and all(
(row.get(field) or "").strip() for field in REQUIRED_FIELDS
)
if not is_valid:
skipped += 1
continue
event = {
field: row[field].strip()
for field in REQUIRED_FIELDS
}
events.append(event)
return sorted(events, key=lambda event: event["timestamp"]), skipped
def baseline_findings(events):
return [
event
for event in events
if event["event_type"] in SUSPICIOUS_TYPES
]
def main():
source_path = Path("data/case_log.csv")
if not source_path.exists():
print("ERROR: data/case_log.csv was not found.")
return 1
events, skipped = load_events(source_path)
findings = baseline_findings(events)
print(f"Loaded {len(events)} valid events; skipped {skipped} malformed row(s).")
print(f"Baseline findings: {len(findings)} individual suspicious event(s).")
print(
"Baseline result: individual suspicious events found, "
"but no coordinated incident was identified."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run the baseline analysis
- Activate the CaseTrace virtual environment for this terminal session by running this command:
.\.venv\Scripts\Activate.ps1
You should see (.venv) at the start of the prompt. Every Python command in this step now uses the isolated interpreter.
Virtual environment not active?
If PowerShell cannot find the activation script, confirm that the terminal prompt points to the CaseTrace folder and that Explorer shows .venv.
If PowerShell blocks the script, apply the current-user execution policy from Step 1 and run the activation command again.
Still stuck? Help me activate .venv before running CaseTrace.
Before you run the analyzer, do you think five suspicious records are enough to explain one coordinated incident?
- Run the baseline analyzer from the integrated terminal with this command:
python case_trace.py
You should see the validation summary followed by the baseline result shown below.
Loaded 9 valid events; skipped 1 malformed row(s).
Baseline findings: 5 individual suspicious event(s).
Baseline result: individual suspicious events found, but no coordinated incident was identified.
What does the baseline prove?
The validator accepts nine complete rows. It rejects the row with the missing user value.
The baseline finds three AUTH_FAILURE records, one FILE_ACCESS record, and one OUTBOUND_CONNECTION record. Those five matches remain isolated from one another.
This is the intended shortfall. A useful investigative conclusion needs event correlation across the user, device, event order, and timestamps.
That first investigative result is working. CaseTrace now validates the synthetic evidence while making the baseline detector's limitation visible.
Did the analyzer fail to run?
Confirm that the integrated terminal is still working inside the CaseTrace workspace. The Explorer sidebar should show case_trace.py beside the data folder.
If the analyzer reports that the source file is unavailable, confirm that case_log.csv is inside data.
Need help with another result? Help me troubleshoot why the CaseTrace baseline analyzer does not show the expected counts.
Your evidence is validated and the detector's blind spot is clear. Next, you will connect the isolated records into one investigative sequence.
Correlate the Incident
Your baseline parser now validates the synthetic evidence. It also exposes the limitation of treating five suspicious records as isolated findings.
This step adds event correlation to reconstruct the sequence for one user on one device. A Python function will test the event order before returning a HIGH-severity incident.
In this step, get ready to:
- Group the validated events by user.
- Require the complete authentication, file-access, and network sequence.
- Print the correlated incident with its severity, user, and device.
Group events by user
Correlation starts by giving each user a timestamp-ordered slice of the validated events. Separate lists make the authentication failures and successes available for sequence checks.
- Switch back to case_trace.py in the Visual Studio Code editor from earlier.
- Find the end of baseline_findings(events).
- Add the first version of correlate_incident(events) below it by pasting this code:
def correlate_incident(events):
users = sorted({event["user"] for event in events})
for user in users:
user_events = [event for event in events if event["user"] == user]
failures = [
event
for event in user_events
if event["event_type"] == "AUTH_FAILURE"
]
successes = [
event
for event in user_events
if event["event_type"] == "AUTH_SUCCESS"
]
return None
How does the first pass work?
- The users set identifies every validated user in the evidence.
- The user_events list keeps each user's records together.
- The failures and successes lists separate the two authentication outcomes needed for the sequence.
- The temporary return None keeps this first version runnable while the rule is still being assembled.
- Find the baseline result print statement in the main execution path.
- Add the incident check below that statement by pasting this code:
incident = correlate_incident(events)
if incident:
print(
f"Correlated incident: {incident['severity']} severity for "
f"{incident['user']} on {incident['device']}."
)
else:
print("Correlated incident: none.")
What does this code do?
The main execution path passes the validated events into correlate_incident(events). It prints incident details when the function returns a dictionary.
The fallback message makes an empty result visible. This gives you a direct test point for the next correlation rule.
- Save case_trace.py.
Before you run the analyzer, predict the result from the current grouping logic.
- Run the current analyzer in the integrated terminal by entering this command:
python case_trace.py
What should you see?
The existing validation and baseline output still appears. You will also see Correlated incident: none. because the function groups authentication events without testing their sequence yet.
🙋♀️ Analyzer stopped before the result?
Check that correlate_incident(events) starts at the left edge of the file. Its internal lines must remain indented beneath the function.
Check that the incident-printing block uses the same indentation as the existing baseline print statements.
Still stuck? Help me check the indentation and placement of my CaseTrace correlation function.
Require the full event sequence
A meaningful correlation rule needs ordered evidence from the same user and device. The rule first finds three failures before a successful sign-in. It then looks for sensitive file access after that success.
- Insert the first sequence check before the final return None in correlate_incident(events) by pasting this code:
for success in successes:
device = success["device"]
earlier_failures = [
event
for event in failures
if event["device"] == device
and event["timestamp"] < success["timestamp"]
]
sensitive_accesses = [
event
for event in user_events
if event["device"] == device
and event["event_type"] == "FILE_ACCESS"
and event["timestamp"] > success["timestamp"]
]
if len(earlier_failures) < 3 or not sensitive_accesses:
continue
How does the sequence gate work?
- Each successful sign-in provides the device and timestamp that anchor the search.
- The earlier_failures list accepts failures from the same device only when they occurred before that success.
- The sensitive_accesses list accepts file access on the same device only when it occurred after the success.
- The continue statement rejects any candidate that lacks three earlier failures or later file access.
- Insert the outbound-connection check below the continue line by pasting this code:
sensitive_access = sensitive_accesses[0]
outbound_connections = [
event
for event in user_events
if event["device"] == device
and event["event_type"] == "OUTBOUND_CONNECTION"
and event["timestamp"] > sensitive_access["timestamp"]
]
if outbound_connections:
return {
"severity": "HIGH",
"user": user,
"device": device,
"reason": (
"Three authentication failures were followed by a success, "
"sensitive file access, and an outbound connection."
),
"evidence": (
earlier_failures
+ [success, sensitive_access, outbound_connections[0]]
),
}
How is the incident built?
- The first matching file-access event becomes the next timestamp anchor.
- The outbound_connections list requires a later network event for the same user and device.
- A complete match returns HIGH severity with the user and device that connect the records.
- The evidence tuple preserves the matching events in investigative order.
- Save case_trace.py.
Before you run the completed rule, predict whether all four event stages belong to the same user and device.
- Run the completed analyzer in the integrated terminal by entering this command:
python case_trace.py
What should you see?
Look near the end of the terminal output for Correlated incident: HIGH severity for jrivera on workstation-07. This confirms that the ordered records satisfy every stage of the rule.
🙋♀️ Still seeing no correlated incident?
Check that the outbound-connection block sits inside for success in successes:. It must align with sensitive_accesses.
Check that each timestamp comparison uses the same square brackets and quotation marks shown in the code.
Need another pair of eyes? Help me find why CaseTrace returns no incident for the known dataset.
You have closed the baseline gap. CaseTrace can now explain how the authentication, file-access, and network events form one HIGH-severity sequence.
✔️ Awesome, I've got everything!
Great work. Confirm that case_trace.py is saved before moving on.
ⓧ I'd like to double check the full code
import csv
from pathlib import Path
REQUIRED_FIELDS = (
"timestamp",
"device",
"user",
"source_ip",
"event_type",
"detail",
)
SUSPICIOUS_TYPES = {
"AUTH_FAILURE",
"FILE_ACCESS",
"OUTBOUND_CONNECTION",
}
def load_events(path):
events = []
skipped = 0
with path.open(mode="r", encoding="utf-8", newline="") as source:
reader = csv.DictReader(source)
for row in reader:
is_valid = row and all(
(row.get(field) or "").strip() for field in REQUIRED_FIELDS
)
if not is_valid:
skipped += 1
continue
event = {
field: row[field].strip()
for field in REQUIRED_FIELDS
}
events.append(event)
return sorted(events, key=lambda event: event["timestamp"]), skipped
def baseline_findings(events):
return [
event
for event in events
if event["event_type"] in SUSPICIOUS_TYPES
]
def correlate_incident(events):
users = sorted({event["user"] for event in events})
for user in users:
user_events = [event for event in events if event["user"] == user]
failures = [
event
for event in user_events
if event["event_type"] == "AUTH_FAILURE"
]
successes = [
event
for event in user_events
if event["event_type"] == "AUTH_SUCCESS"
]
for success in successes:
device = success["device"]
earlier_failures = [
event
for event in failures
if event["device"] == device
and event["timestamp"] < success["timestamp"]
]
sensitive_accesses = [
event
for event in user_events
if event["device"] == device
and event["event_type"] == "FILE_ACCESS"
and event["timestamp"] > success["timestamp"]
]
if len(earlier_failures) < 3 or not sensitive_accesses:
continue
sensitive_access = sensitive_accesses[0]
outbound_connections = [
event
for event in user_events
if event["device"] == device
and event["event_type"] == "OUTBOUND_CONNECTION"
and event["timestamp"] > sensitive_access["timestamp"]
]
if outbound_connections:
return {
"severity": "HIGH",
"user": user,
"device": device,
"reason": (
"Three authentication failures were followed by a success, "
"sensitive file access, and an outbound connection."
),
"evidence": (
earlier_failures
+ [success, sensitive_access, outbound_connections[0]]
),
}
return None
def main():
source_path = Path("data/case_log.csv")
if not source_path.exists():
print("ERROR: data/case_log.csv was not found.")
return 1
events, skipped = load_events(source_path)
findings = baseline_findings(events)
print(f"Loaded {len(events)} valid events; skipped {skipped} malformed row(s).")
print(f"Baseline findings: {len(findings)} individual suspicious event(s).")
print(
"Baseline result: individual suspicious events found, "
"but no coordinated incident was identified."
)
incident = correlate_incident(events)
if incident:
print(
f"Correlated incident: {incident['severity']} severity for "
f"{incident['user']} on {incident['device']}."
)
else:
print("Correlated incident: none.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Your correlation engine now reconstructs the suspicious sequence. Next, you will preserve the source file's integrity and turn the result into an investigator-friendly report.
Preserve Integrity and Report Findings
Your correlation engine now reconstructs the suspicious sequence. CaseTrace can identify the affected user and device.
The result still lives only in the terminal. CaseTrace cannot yet demonstrate which source file it analyzed. It also needs a report that an investigative teammate can review.
In this step, get ready to:
- Create a SHA-256 integrity record for the synthetic evidence.
- Generate an investigator-friendly Markdown report.
- Keep every generated artifact inside the output folder.
Create the integrity record
An integrity digest acts as a fingerprint for the source evidence. CaseTrace calculates that fingerprint from the bytes in data/case_log.csv.
- In case_trace.py, replace the import group at the top with the code below:
import csv
import hashlib
from pathlib import Path
Why add hashlib?
The hashlib module supplies the hashing function used by CaseTrace. The existing imports continue to support CSV parsing and file paths.
- Find def main(): in case_trace.py.
- Add the following function immediately above main():
def integrity_hash(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
What does this function do?
The function reads the source file as bytes. It returns the resulting digest as text that CaseTrace can store in an integrity record.
- Inside main(), replace the single source_path assignment with these three path assignments:
source_path = Path("data/case_log.csv")
report_path = Path("output/investigation_report.md")
hash_path = Path("output/evidence_hash.sha256")
Why use separate paths?
The source_path points to the fixed synthetic evidence. The other paths keep generated artifacts under output.
- Near the bottom of main(), locate return 0.
- Insert this integrity block immediately above return 0:
digest = integrity_hash(source_path)
report_path.parent.mkdir(parents=True, exist_ok=True)
hash_path.write_text(
f"{digest} {source_path.as_posix()}\n",
encoding="utf-8",
)
print(f"Integrity record: {hash_path.as_posix()}")
What writes the integrity record?
The block calculates the digest from the source file. It writes that digest beside the source path in output/evidence_hash.sha256.
The directory creation call keeps the output location available. It does not write to the source evidence.
- Save case_trace.py.
Before you run CaseTrace, do you expect the existing HIGH-severity result to remain intact?
- Create the integrity record by running this command in the integrated terminal:
python case_trace.py
What does this run check?
This run repeats the existing parsing and correlation flow. It also hashes the same source file before writing the integrity artifact.
You'll still see the HIGH-severity incident. You'll also see Integrity record: output/evidence_hash.sha256.
- Expand the output folder in the Explorer sidebar.
You'll see evidence_hash.sha256 inside the folder. Your source file remains under data.
Integrity record missing?
Check that integrity_hash() appears above main(). Confirm that hash_path.write_text() remains indented inside main().
Still stuck? Help me troubleshoot why CaseTrace is not writing its integrity record.
Build the Markdown report
A digest identifies the analyzed source. The report connects that digest to the validated counts and correlated conclusion.
- Add the following build_report() function below integrity_hash():
def build_report(source_path, events, skipped, findings, incident, digest):
lines = [
"# CaseTrace Investigation Report",
"",
"Scope: Synthetic training data only.",
f"Evidence source: `{source_path.as_posix()}`",
f"SHA-256: `{digest}`",
f"Valid events: {len(events)}",
f"Malformed rows skipped: {skipped}",
f"Baseline findings: {len(findings)}",
"",
"## Correlated conclusion",
"",
]
if incident:
lines.extend(
[
f"Severity: {incident['severity']}",
f"User: {incident['user']}",
f"Device: {incident['device']}",
f"Reason: {incident['reason']}",
]
)
else:
lines.append("No coordinated incident identified.")
return "\n".join(lines) + "\n"
What does this report include?
The first section records the synthetic scope and evidence source. It also records the digest and validation counts.
The conclusion turns the incident dictionary into readable severity, user, device, and reason fields. A missing incident receives an explicit negative conclusion.
- Inside main(), find the block that starts with digest = integrity_hash(source_path).
- Use this current block as your reference:
digest = integrity_hash(source_path)
report_path.parent.mkdir(parents=True, exist_ok=True)
hash_path.write_text(
f"{digest} {source_path.as_posix()}\n",
encoding="utf-8",
)
print(f"Integrity record: {hash_path.as_posix()}")
What are you replacing?
This is the integrity-only version of the output block. The next version keeps that behavior while adding report generation.
- Replace that block with the integrated output block below:
digest = integrity_hash(source_path)
report = build_report(
source_path,
events,
skipped,
findings,
incident,
digest,
)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(report, encoding="utf-8")
hash_path.write_text(
f"{digest} {source_path.as_posix()}\n",
encoding="utf-8",
)
print(f"Integrity record: {hash_path.as_posix()}")
print(f"Report written: {report_path.as_posix()}")
How does main write the report?
The report variable holds the text returned by build_report(). CaseTrace writes that text to output/investigation_report.md.
The integrity record remains a separate artifact. This separation makes the digest easy to inspect without opening the full report.
- Save case_trace.py.
- Generate the first report by running this command:
python case_trace.py
What does this run add?
This run creates investigation_report.md beside the integrity record. The report now captures the source details and correlated conclusion.
You'll see Report written: output/investigation_report.md beneath the integrity-record path.
- Select investigation_report.md in the Explorer sidebar.
You'll see the CaseTrace heading followed by the synthetic scope, source path, digest, event counts, and HIGH-severity conclusion.
Report not created?
Confirm that build_report() appears before main(). Check that report_path.write_text() is indented inside main().
Need another pair of eyes? Help me find why CaseTrace is not writing its Markdown report.
Verify the evidence trail
The conclusion needs the full validated timeline for review. A limitations section also keeps the educational rule set within its intended scope.
- Inside build_report(), locate the final return statement.
- Insert the timeline and limitations block immediately above that statement:
lines.extend(["", "## Timeline", ""])
for event in events:
lines.append(
f"- {event['timestamp']} | {event['device']} | "
f"{event['user']} | {event['event_type']} | {event['detail']}"
)
lines.extend(
[
"",
"## Limitations",
"",
"This educational rule set analyzes one known CSV schema and does not "
"establish identity, intent, or legal conclusions.",
]
)
How does this complete the report?
The loop adds every validated event in timestamp order. Each line preserves the event's device, user, type, and detail.
The limitations section states what the rule set can support. It prevents the correlated signal from being presented as an identity or legal conclusion.
- Save case_trace.py.
Before you run the final check, which two generated file paths do you expect CaseTrace to print?
- Regenerate the complete evidence trail by running this command:
python case_trace.py
What does the final run prove?
The analyzer reaches the same HIGH-severity conclusion. It then creates both output artifacts from the unchanged synthetic source.
You'll see Integrity record: output/evidence_hash.sha256 followed by Report written: output/investigation_report.md.
- Select investigation_report.md in the Explorer sidebar.
- Confirm the report contains the HIGH conclusion for jrivera on workstation-07.
- Confirm the report contains the complete validated timeline.
- Select evidence_hash.sha256 in the Explorer sidebar.
- Confirm the integrity record ends with data/case_log.csv.
- Confirm the generated files appear only under output.
You now have a reviewable report plus a separate integrity record. CaseTrace reads the evidence from data/case_log.csv while directing every generated artifact to output.
Timeline or conclusion missing?
Check that the timeline block appears before the final return inside build_report(). Confirm that the for event in events: line is indented inside the function.
Still seeing an incomplete report? Help me compare my build_report function with the complete CaseTrace version.
Use the tabs below to confirm that your completed file matches the working version.
✔️ Awesome, I've got everything!
Your analyzer now preserves a digest and writes a complete investigative report. Make sure case_trace.py is saved.
ⓧ I'd like to double check the full code
import csv
import hashlib
from pathlib import Path
REQUIRED_FIELDS = (
"timestamp",
"device",
"user",
"source_ip",
"event_type",
"detail",
)
SUSPICIOUS_TYPES = {
"AUTH_FAILURE",
"FILE_ACCESS",
"OUTBOUND_CONNECTION",
}
def load_events(path):
events = []
skipped = 0
with path.open(mode="r", encoding="utf-8", newline="") as source:
reader = csv.DictReader(source)
for row in reader:
is_valid = row and all(
(row.get(field) or "").strip() for field in REQUIRED_FIELDS
)
if not is_valid:
skipped += 1
continue
event = {
field: row[field].strip()
for field in REQUIRED_FIELDS
}
events.append(event)
return sorted(events, key=lambda event: event["timestamp"]), skipped
def baseline_findings(events):
return [
event
for event in events
if event["event_type"] in SUSPICIOUS_TYPES
]
def correlate_incident(events):
users = sorted({event["user"] for event in events})
for user in users:
user_events = [event for event in events if event["user"] == user]
failures = [
event
for event in user_events
if event["event_type"] == "AUTH_FAILURE"
]
successes = [
event
for event in user_events
if event["event_type"] == "AUTH_SUCCESS"
]
for success in successes:
device = success["device"]
earlier_failures = [
event
for event in failures
if event["device"] == device
and event["timestamp"] < success["timestamp"]
]
sensitive_accesses = [
event
for event in user_events
if event["device"] == device
and event["event_type"] == "FILE_ACCESS"
and event["timestamp"] > success["timestamp"]
]
if len(earlier_failures) < 3 or not sensitive_accesses:
continue
sensitive_access = sensitive_accesses[0]
outbound_connections = [
event
for event in user_events
if event["device"] == device
and event["event_type"] == "OUTBOUND_CONNECTION"
and event["timestamp"] > sensitive_access["timestamp"]
]
if outbound_connections:
return {
"severity": "HIGH",
"user": user,
"device": device,
"reason": (
"Three authentication failures were followed by a success, "
"sensitive file access, and an outbound connection."
),
"evidence": (
earlier_failures
+ [success, sensitive_access, outbound_connections[0]]
),
}
return None
def integrity_hash(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def build_report(source_path, events, skipped, findings, incident, digest):
lines = [
"# CaseTrace Investigation Report",
"",
"Scope: Synthetic training data only.",
f"Evidence source: `{source_path.as_posix()}`",
f"SHA-256: `{digest}`",
f"Valid events: {len(events)}",
f"Malformed rows skipped: {skipped}",
f"Baseline findings: {len(findings)}",
"",
"## Correlated conclusion",
"",
]
if incident:
lines.extend(
[
f"Severity: {incident['severity']}",
f"User: {incident['user']}",
f"Device: {incident['device']}",
f"Reason: {incident['reason']}",
]
)
else:
lines.append("No coordinated incident identified.")
lines.extend(["", "## Timeline", ""])
for event in events:
lines.append(
f"- {event['timestamp']} | {event['device']} | "
f"{event['user']} | {event['event_type']} | {event['detail']}"
)
lines.extend(
[
"",
"## Limitations",
"",
"This educational rule set analyzes one known CSV schema and does not "
"establish identity, intent, or legal conclusions.",
]
)
return "\n".join(lines) + "\n"
def main():
source_path = Path("data/case_log.csv")
report_path = Path("output/investigation_report.md")
hash_path = Path("output/evidence_hash.sha256")
if not source_path.exists():
print("ERROR: data/case_log.csv was not found.")
return 1
events, skipped = load_events(source_path)
findings = baseline_findings(events)
print(f"Loaded {len(events)} valid events; skipped {skipped} malformed row(s).")
print(f"Baseline findings: {len(findings)} individual suspicious event(s).")
print(
"Baseline result: individual suspicious events found, "
"but no coordinated incident was identified."
)
incident = correlate_incident(events)
if incident:
print(
f"Correlated incident: {incident['severity']} severity for "
f"{incident['user']} on {incident['device']}."
)
else:
print("Correlated incident: none.")
digest = integrity_hash(source_path)
report = build_report(
source_path,
events,
skipped,
findings,
incident,
digest,
)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(report, encoding="utf-8")
hash_path.write_text(
f"{digest} {source_path.as_posix()}\n",
encoding="utf-8",
)
print(f"Integrity record: {hash_path.as_posix()}")
print(f"Report written: {report_path.as_posix()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
This reference shows the complete cumulative case_trace.py file for this step. Compare its function order and indentation with your saved file.
Your evidence trail is now reviewable beyond the terminal. Next, you'll prove that CaseTrace produces repeatable results and rejects bad input.
Validate the Tool
Your analyzer now preserves a SHA-256 integrity record alongside an investigator-friendly report. The next goal is to prove that the underlying results stay reliable.
One successful run only demonstrates one moment. Automated unit testing gives your Python tool repeatable evidence that it accepts the known data while rejecting bad input.
In this step, get ready to:
- Create a reusable test suite around the known synthetic dataset.
- Check the incident result against positive and negative cases.
- Confirm unchanged evidence produces a repeatable SHA-256 digest.
Test the known dataset
A known-dataset test compares the analyzer against fixed acceptance criteria. The synthetic CSV gives you exact counts for valid events, rejected rows, and baseline findings.
- Create test_case_trace.py inside the CaseTrace folder from the Explorer sidebar's New File control.
- Add the shared test setup plus the first known-dataset test by pasting this code:
import unittest
from pathlib import Path
from case_trace import baseline_findings
from case_trace import correlate_incident
from case_trace import integrity_hash
from case_trace import load_events
CASE_LOG = Path("data/case_log.csv")
class CaseTraceTests(unittest.TestCase):
def setUp(self):
self.events, self.skipped = load_events(CASE_LOG)
def test_known_dataset_counts(self):
self.assertEqual(len(self.events), 9)
self.assertEqual(self.skipped, 1)
self.assertEqual(len(baseline_findings(self.events)), 5)
What Does This Test Do?
- The imports bring the four CaseTrace functions into the test suite.
- The CASE_LOG path points every test at the unchanged synthetic evidence.
- The setUp() method reloads the validated events before each test.
- The first test checks three known outcomes from the fixed dataset.
- Save test_case_trace.py.
Before you run this first check, consider whether all three known counts will pass.
- Run the growing test suite from the integrated terminal by using this command:
python -m unittest -v
What Does This Command Do?
The unittest test runner discovers test files in the current folder. The verbose option prints each test name with its result.
You should see test_known_dataset_counts pass. The run ends with OK.
Good progress. Your parser counts now have a repeatable check tied to the fixed evidence.
Test Not Discovered?
Confirm the file is named test_case_trace.py. Test discovery looks for files whose names begin with test.
Confirm the file is saved inside the same CaseTrace folder as case_trace.py.
Still stuck? Help me diagnose why Python is not discovering test_case_trace.py.
Test correlation and false positives
A useful detector needs a positive case that recognizes the suspicious sequence. It also needs a negative case that leaves routine activity alone.
- In test_case_trace.py, place your cursor below test_known_dataset_counts.
- Add the correlation tests by pasting this code:
def test_correlates_high_severity_incident(self):
incident = correlate_incident(self.events)
self.assertIsNotNone(incident)
self.assertEqual(incident["severity"], "HIGH")
self.assertEqual(incident["user"], "jrivera")
self.assertEqual(incident["device"], "workstation-07")
def test_routine_events_do_not_create_incident(self):
routine_events = [
event
for event in self.events
if event["user"] == "asmith"
or event["event_type"] == "DEVICE_HEALTH"
]
self.assertIsNone(correlate_incident(routine_events))
How Do These Tests Protect the Result?
- The positive test requires a correlated incident with HIGH severity.
- The same test checks that the conclusion identifies jrivera on workstation-07.
- The negative test filters the dataset down to routine activity.
- The final assertion requires that routine activity produces no incident.
- Save test_case_trace.py.
Before you rerun the suite, consider whether the suspicious sequence and routine activity will produce different results.
- Run the expanded test suite from the integrated terminal by using this command:
python -m unittest -v
What Does This Run Prove?
The runner now checks the dataset counts plus both correlation paths. Passing results show that the detector recognizes the target sequence without flagging the routine subset.
You should see three tests pass. The run ends with OK.
That closes an important gap. CaseTrace now has repeatable checks for both detection and restraint.
Seeing a Correlation Test Failure?
Check that both new methods remain indented inside CaseTraceTests.
Confirm data/case_log.csv still contains the unchanged synthetic dataset from earlier.
Need help? Help me troubleshoot my positive and negative CaseTrace correlation tests.
Test rejection and repeatable hashing
The final checks cover two defensive properties. Malformed input stays outside the validated event list while unchanged evidence produces the same digest every time.
- In test_case_trace.py, place your cursor below test_routine_events_do_not_create_incident.
- Complete the suite by pasting this code:
def test_malformed_row_is_rejected(self):
users = {event["user"] for event in self.events}
self.assertNotIn("", users)
self.assertEqual(self.skipped, 1)
def test_hash_is_repeatable_for_unchanged_source(self):
first_digest = integrity_hash(CASE_LOG)
second_digest = integrity_hash(CASE_LOG)
self.assertEqual(first_digest, second_digest)
if __name__ == "__main__":
unittest.main()
What Do the Final Checks Cover?
- The malformed-row test confirms that no validated event has a blank user.
- The same test confirms that exactly one source row was skipped.
- The hash test calculates the source digest twice.
- Matching digests demonstrate repeatability while the evidence remains unchanged.
- Save test_case_trace.py.
✔️ Awesome, I've got everything!
Your saved file now contains all five tests for the core CaseTrace tool.
ⓧ I'd like to double check the full code
import unittest
from pathlib import Path
from case_trace import baseline_findings
from case_trace import correlate_incident
from case_trace import integrity_hash
from case_trace import load_events
CASE_LOG = Path("data/case_log.csv")
class CaseTraceTests(unittest.TestCase):
def setUp(self):
self.events, self.skipped = load_events(CASE_LOG)
def test_known_dataset_counts(self):
self.assertEqual(len(self.events), 9)
self.assertEqual(self.skipped, 1)
self.assertEqual(len(baseline_findings(self.events)), 5)
def test_correlates_high_severity_incident(self):
incident = correlate_incident(self.events)
self.assertIsNotNone(incident)
self.assertEqual(incident["severity"], "HIGH")
self.assertEqual(incident["user"], "jrivera")
self.assertEqual(incident["device"], "workstation-07")
def test_routine_events_do_not_create_incident(self):
routine_events = [
event
for event in self.events
if event["user"] == "asmith"
or event["event_type"] == "DEVICE_HEALTH"
]
self.assertIsNone(correlate_incident(routine_events))
def test_malformed_row_is_rejected(self):
users = {event["user"] for event in self.events}
self.assertNotIn("", users)
self.assertEqual(self.skipped, 1)
def test_hash_is_repeatable_for_unchanged_source(self):
first_digest = integrity_hash(CASE_LOG)
second_digest = integrity_hash(CASE_LOG)
self.assertEqual(first_digest, second_digest)
if __name__ == "__main__":
unittest.main()
Before the final run, consider whether every parser, correlation, rejection, and integrity check will pass together.
- Run the complete suite from the integrated terminal by using this command:
python -m unittest -v
What Does the Full Suite Demonstrate?
The five tests exercise expected counts, incident correlation, routine behavior, malformed input, and repeatable hashing. One command now verifies the core behaviors that support the investigative conclusion.
You should see five tests listed. The run ends with OK.
That is the proof in place. CaseTrace now produces a HIGH-severity conclusion backed by a repeatable automated test suite.
One of the Five Tests Failed?
Use the failing test name to locate the matching method in test_case_trace.py.
Compare that method with the full-file reference above. Keep data/case_log.csv unchanged while you correct the test file.
Still blocked? Help me diagnose the failing CaseTrace test from my verbose unittest output.
Secret mission
Add Device-Health Escalation
Extend CaseTrace so a later device-health warning raises the correlated incident from HIGH to CRITICAL. Update the known-dataset test while keeping the account activity, file access, and network connection as the primary investigative sequence.
Clean Up Your Resources
Clean Up Your Resources
CaseTrace runs entirely on your computer with Python inside Visual Studio Code at no ongoing cost. Decide whether to keep the workspace, pause your work, or delete the local files.
Resources you used:
- Local CaseTrace workspace containing the complete project and its .venv virtual environment.
- Synthetic CSV evidence stored in data/case_log.csv.
- Analyzer and automated test suite stored in case_trace.py and test_case_trace.py.
- Markdown report stored in output/investigation_report.md.
- SHA-256 integrity record stored in output/evidence_hash.sha256.
Keep everything running
No action is needed. Choose this if you want to keep testing CaseTrace or extending its correlation rules.
- Keep the CaseTrace workspace in its current location.
- Leave data/case_log.csv unchanged so the integrity record remains repeatable.
- Retain output/investigation_report.md with output/evidence_hash.sha256.
- Reactivate .venv whenever you open a new integrated terminal by running this command:
.\.venv\Scripts\Activate.ps1
Pause - I'll come back to this later
CaseTrace has no server or background service to stop. Closing the integrated terminal ends the active .venv session while keeping the environment available for your return.
- Save any open files in the CaseTrace workspace.
- Close the integrated terminal after its current command has finished.
- Close Visual Studio Code.
- Keep the CaseTrace folder in its current location for your next session.
Delete - I don't want to use this again
Deleting the workspace permanently removes the source files, generated reports, and .venv after the recovery copy is cleared. The project uses only synthetic data, so no cloud resource or live evidence is affected.
- Copy any report or source file you want to keep to a location outside CaseTrace.
- Close the integrated terminal.
- Close Visual Studio Code.
- Locate the CaseTrace folder you opened earlier in File Explorer.
- Remove the entire CaseTrace folder through File Explorer.
- Permanently clear the deleted CaseTrace folder from Windows' local recovery area.
- Confirm that CaseTrace no longer appears in its previous location.
Nice Work!
Nice Work!
You brought CaseTrace from a simple baseline detector to a tested investigative utility. Your local Python tool now validates synthetic evidence, reconstructs a suspicious sequence, preserves integrity, and communicates its conclusion through a readable report.
You've learned how to:
- Build a validated timeline from a synthetic CSV log while rejecting malformed rows.
- Demonstrate the limits of isolated event matching. Then connect authentication activity, sensitive file access, and an outbound connection through multi-event correlation.
- Verify unchanged evidence with a SHA-256 integrity record. Present the conclusion through a Markdown report. Prove expected behavior with five automated tests.
- Secret Mission: Escalated the incident to CRITICAL when a later same-device warning added supporting operational context.
Ready to quiz yourself?