Update trace application with improvements and documentation

This commit is contained in:
Overcuriousity
2025-12-11 21:33:19 +01:00
parent be36cbd116
commit cfc71fc68d
6 changed files with 1075 additions and 191 deletions

131
CLAUDE.md Normal file
View File

@@ -0,0 +1,131 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
`trace` is a minimal, terminal-based forensic note-taking application for digital investigators and incident responders. It focuses on data integrity through SHA256 hashing and optional GPG signing of all notes, with zero external dependencies beyond Python's standard library.
## Development Commands
### Running the Application
```bash
# Run directly from source
python3 main.py
# Quick CLI note addition (requires active case/evidence set in TUI)
python3 main.py "Your note content here"
# Export to markdown
python3 main.py --export --output report.md
# Open TUI directly at active case/evidence
python3 main.py --open
```
### Building Binary
```bash
# Install dependencies first
pip install -r requirements.txt
# Build on Linux/macOS
./build_binary.sh
# Build on Windows
pyinstaller --onefile --name trace --clean --paths . --hidden-import curses main.py
```
### Testing
```bash
# Run unit tests
python3 -m unittest trace/tests/test_models.py
# Run all tests with discovery
python3 -m unittest discover -s trace/tests
```
## Architecture
### Data Model Hierarchy
The application uses a three-level hierarchy:
- **Case** → **Evidence****Note** (with Notes also attachable directly to Cases)
Each level has unique IDs (UUIDs) for reliable lookups across the hierarchy.
### Core Modules
**`trace/models.py`**: Data models using dataclasses
- `Note`: Content + timestamp + SHA256 hash + optional GPG signature + auto-extracted tags/IOCs
- `Evidence`: Container for notes about a specific piece of evidence, includes metadata dict for source hashes
- `Case`: Top-level container with case number, investigator, evidence list, and notes
- All models implement `to_dict()`/`from_dict()` for JSON serialization
**`trace/storage.py`**: Persistence layer
- `Storage`: Manages `~/.trace/data.json` with atomic writes (temp file + rename)
- `StateManager`: Manages `~/.trace/state` (active case/evidence) and `~/.trace/settings.json` (PGP enabled/disabled)
- Data is loaded into memory on init, modified, then saved atomically
**`trace/crypto.py`**: Integrity features
- `sign_content()`: GPG clearsign via subprocess (falls back gracefully if GPG unavailable)
- `hash_content()`: SHA256 of timestamp:content to ensure temporal integrity
**`trace/cli.py`**: Entry point and CLI operations
- `quick_add_note()`: Adds note to active context from command line
- `export_markdown()`: Generates full case report with hashes and signatures
- `main()`: Argument parsing, routes to TUI or CLI functions
**`trace/tui.py`**: Curses-based Text User Interface
- View hierarchy: case_list → case_detail → evidence_detail
- Additional views: tags_list, tag_notes_list, ioc_list, ioc_notes_list, note_detail
- Multi-line note editor with Ctrl+G to submit, Esc to cancel
- Filter mode (press `/`), active context management (press `a`)
- All note additions automatically extract tags (#hashtag) and IOCs (IPs, domains, URLs, hashes, emails)
### Key Features Implementation
**Integrity System**: Every note automatically gets:
1. SHA256 hash of `timestamp:content` (via `Note.calculate_hash()`)
2. Optional GPG clearsign signature (if `pgp_enabled` in settings and GPG available)
**Tag System**: Regex-based hashtag extraction (`#word`)
- Extracted on note creation and stored in `Note.tags` list
- Case-insensitive matching, stored lowercase
- TUI provides tag browser with usage counts
**IOC Detection**: Automatic extraction of forensic indicators
- Patterns: IPv4, IPv6, domains, URLs, MD5/SHA1/SHA256 hashes, emails
- Extracted on note creation and stored in `Note.iocs` list
- TUI provides IOC browser with type categorization and export capability
**Active Context**: Persistent state across TUI/CLI sessions
- Set via 'a' key in TUI on any Case or Evidence
- Enables `trace "note"` CLI shorthand to append to active context
- State persists in `~/.trace/state` JSON file
### Data Storage
All data lives in `~/.trace/`:
- `data.json`: All cases, evidence, and notes
- `state`: Active context (case_id, evidence_id)
- `settings.json`: User preferences (pgp_enabled)
- `exports/`: IOC exports directory
JSON structure mirrors the data model hierarchy exactly (Case → Evidence → Note).
### Important Patterns
**Atomic Writes**: All saves use temp file + rename pattern to prevent corruption
```python
temp_file = self.data_file.with_suffix(".tmp")
with open(temp_file, 'w') as f:
json.dump(data, f, indent=2)
temp_file.replace(self.data_file)
```
**Graceful Degradation**: GPG signing is optional and fails silently if GPG unavailable
**Zero External Dependencies**: Only stdlib (except PyInstaller for builds and windows-curses for Windows)
## Testing Notes
Tests use temporary directories created with `tempfile.mkdtemp()` and cleaned up in `tearDown()` to avoid polluting `~/.trace/`.

View File

@@ -40,7 +40,8 @@ def quick_add_note(content: str):
# Try signing if enabled
if settings.get("pgp_enabled", True):
signature = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}")
gpg_key_id = settings.get("gpg_key_id", None)
signature = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id)
if signature:
note.signature = signature
else:

View File

@@ -3,16 +3,69 @@ import hashlib
class Crypto:
@staticmethod
def sign_content(content: str) -> str:
def list_gpg_keys():
"""
Signs the content using GPG.
Returns the clearsigned content or None if GPG fails.
List available GPG secret keys.
Returns a list of tuples: (key_id, user_id)
"""
try:
# We use --clearsign so the signature is attached to the text in a readable format
# We assume a default key is available or configured.
proc = subprocess.Popen(
['gpg', '--clearsign', '--output', '-'],
['gpg', '--list-secret-keys', '--with-colons'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
stdout, stderr = proc.communicate()
if proc.returncode != 0:
return []
keys = []
current_key_id = None
for line in stdout.split('\n'):
fields = line.split(':')
if len(fields) < 2:
continue
# sec = secret key
if fields[0] == 'sec':
# Key ID is in field 4 (short) or we can extract from field 5 (fingerprint)
current_key_id = fields[4] if len(fields) > 4 else None
# uid = user ID
elif fields[0] == 'uid' and current_key_id:
user_id = fields[9] if len(fields) > 9 else "Unknown"
keys.append((current_key_id, user_id))
current_key_id = None # Reset after matching
return keys
except FileNotFoundError:
return [] # GPG not installed
@staticmethod
def sign_content(content: str, key_id: str = None) -> str:
"""
Signs the content using GPG.
Args:
content: The content to sign
key_id: Optional GPG key ID to use. If None, uses default key.
Returns:
The clearsigned content or empty string if GPG fails.
"""
try:
# Build command
cmd = ['gpg', '--clearsign', '--output', '-']
# Add specific key if provided
if key_id:
cmd.extend(['--local-user', key_id])
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,

View File

@@ -1,5 +1,5 @@
import json
import os
import time
from pathlib import Path
from typing import List, Optional, Tuple
from .models import Case, Evidence, Note
@@ -13,10 +13,173 @@ class Storage:
self._ensure_app_dir()
self.cases: List[Case] = self._load_data()
# Create demo case on first launch
if not self.cases:
self._create_demo_case()
def _ensure_app_dir(self):
if not self.app_dir.exists():
self.app_dir.mkdir(parents=True, exist_ok=True)
def _create_demo_case(self):
"""Create a demo case with evidence showcasing all features"""
# Create demo case
demo_case = Case(
case_number="DEMO-2024-001",
name="Sample Investigation",
investigator="Demo User"
)
# Add case-level notes to demonstrate case notes feature
case_note1 = Note(content="""Initial case briefing: Suspected data exfiltration incident.
Key objectives:
- Identify compromised systems
- Determine scope of data loss
- Document timeline of events
#incident-response #data-breach #investigation""")
case_note1.calculate_hash()
case_note1.extract_tags()
case_note1.extract_iocs()
demo_case.notes.append(case_note1)
# Wait a moment for different timestamp
time.sleep(0.1)
case_note2 = Note(content="""Investigation lead: Employee reported suspicious email from sender@phishing-domain.com
Initial analysis shows potential credential harvesting attempt.
Review email headers and attachments for IOCs. #phishing #email-analysis""")
case_note2.calculate_hash()
case_note2.extract_tags()
case_note2.extract_iocs()
demo_case.notes.append(case_note2)
time.sleep(0.1)
# Create evidence 1: Compromised laptop
evidence1 = Evidence(
name="Employee Laptop HDD",
description="Primary workstation hard drive - user reported suspicious activity"
)
# Add source hash for chain of custody demonstration
evidence1.metadata["source_hash"] = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
# Add notes to evidence 1 with various features
note1 = Note(content="""Forensic imaging completed. Drive imaged using FTK Imager.
Image hash verified: SHA256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Chain of custody maintained throughout process. #forensics #imaging #chain-of-custody""")
note1.calculate_hash()
note1.extract_tags()
note1.extract_iocs()
evidence1.notes.append(note1)
time.sleep(0.1)
note2 = Note(content="""Discovered suspicious connections to external IP addresses:
- 192.168.1.100 (local gateway)
- 203.0.113.45 (external, geolocation: Unknown)
- 198.51.100.78 (command and control server suspected)
Browser history shows visits to malicious-site.com and data-exfil.net.
#network-analysis #ioc #c2-server""")
note2.calculate_hash()
note2.extract_tags()
note2.extract_iocs()
evidence1.notes.append(note2)
time.sleep(0.1)
note3 = Note(content="""Malware identified in temp directory:
File: evil.exe
MD5: d41d8cd98f00b204e9800998ecf8427e
SHA1: da39a3ee5e6b4b0d3255bfef95601890afd80709
SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Submitting to VirusTotal for analysis. #malware #hash-analysis #virustotal""")
note3.calculate_hash()
note3.extract_tags()
note3.extract_iocs()
evidence1.notes.append(note3)
time.sleep(0.1)
note4 = Note(content="""Timeline analysis reveals:
- 2024-01-15 09:23:45 - Suspicious email received
- 2024-01-15 09:24:12 - User clicked phishing link https://evil-domain.com/login
- 2024-01-15 09:25:03 - Credentials submitted to attacker-controlled site
- 2024-01-15 09:30:15 - Lateral movement detected
User credentials compromised. Recommend immediate password reset. #timeline #lateral-movement""")
note4.calculate_hash()
note4.extract_tags()
note4.extract_iocs()
evidence1.notes.append(note4)
demo_case.evidence.append(evidence1)
time.sleep(0.1)
# Create evidence 2: Network logs
evidence2 = Evidence(
name="Firewall Logs",
description="Corporate firewall logs from incident timeframe"
)
evidence2.metadata["source_hash"] = "a3f5c8b912e4d67f89b0c1a2e3d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2"
note5 = Note(content="""Log analysis shows outbound connections to suspicious domains:
- attacker-c2.com on port 443 (encrypted channel)
- data-upload.net on port 8080 (unencrypted)
- exfil-server.org on port 22 (SSH tunnel)
Total data transferred: approximately 2.3 GB over 4 hours.
#log-analysis #data-exfiltration #network-traffic""")
note5.calculate_hash()
note5.extract_tags()
note5.extract_iocs()
evidence2.notes.append(note5)
time.sleep(0.1)
note6 = Note(content="""Contact information found in malware configuration:
Email: attacker@malicious-domain.com
Backup C2: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 (IPv6)
Cross-referencing with threat intelligence databases. #threat-intel #attribution""")
note6.calculate_hash()
note6.extract_tags()
note6.extract_iocs()
evidence2.notes.append(note6)
demo_case.evidence.append(evidence2)
time.sleep(0.1)
# Create evidence 3: Email forensics
evidence3 = Evidence(
name="Phishing Email",
description="Original phishing email preserved in .eml format"
)
note7 = Note(content="""Email headers analysis:
From: sender@phishing-domain.com (spoofed)
Reply-To: attacker@evil-mail-server.net
X-Originating-IP: 198.51.100.99
Email contains embedded tracking pixel at http://tracking.malicious-site.com/pixel.gif
Attachment: invoice.pdf.exe (double extension trick) #email-forensics #phishing-analysis""")
note7.calculate_hash()
note7.extract_tags()
note7.extract_iocs()
evidence3.notes.append(note7)
demo_case.evidence.append(evidence3)
# Add the demo case to storage
self.cases.append(demo_case)
self.save_data()
def _load_data(self) -> List[Case]:
if not self.data_file.exists():
return []
@@ -27,10 +190,6 @@ class Storage:
except (json.JSONDecodeError, IOError):
return []
def reload(self):
"""Reloads data from disk to refresh state."""
self.cases = self._load_data()
def save_data(self):
data = [c.to_dict() for c in self.cases]
# Write to temp file then rename for atomic-ish write

View File

@@ -1,7 +1,6 @@
import unittest
import shutil
import tempfile
import json
from pathlib import Path
from trace.models import Note, Case, Evidence
from trace.storage import Storage, StateManager

File diff suppressed because it is too large Load Diff