mirror of
https://github.com/overcuriousity/trace.git
synced 2025-12-20 13:02:21 +00:00
Update trace application with improvements and documentation
This commit is contained in:
131
CLAUDE.md
Normal file
131
CLAUDE.md
Normal 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/`.
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
169
trace/storage.py
169
trace/storage.py
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
863
trace/tui.py
863
trace/tui.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user