mirror of
https://github.com/overcuriousity/trace.git
synced 2025-12-20 13:02:21 +00:00
Rebrand fnote to trace
- Rename directory structure from fnote to trace - Update package references and imports - Update application branding and storage paths - Update build script and documentation
This commit is contained in:
28
trace/.gitignore
vendored
Normal file
28
trace/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.env
|
||||
.venv
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.spec
|
||||
152
trace/README.md
Normal file
152
trace/README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# trace - Forensic Notes
|
||||
|
||||
`trace` is a minimal, terminal-based forensic note-taking application designed for digital investigators and incident responders. It provides a secure, integrity-focused environment for case management and evidence logging.
|
||||
|
||||
## Features
|
||||
|
||||
* **Integrity Focused:**
|
||||
* **Hashing:** Every note is automatically SHA256 hashed (content + timestamp).
|
||||
* **Signing:** Optional GPG signing of notes for non-repudiation (requires system `gpg`).
|
||||
* **Minimal Dependencies:** Written in Python using only the standard library (`curses`, `json`, `sqlite3` avoided, etc.) + `pyinstaller` for packaging.
|
||||
* **Dual Interface:**
|
||||
* **TUI (Text User Interface):** Interactive browsing of Cases and Evidence hierarchies with multi-line note editor.
|
||||
* **CLI Shorthand:** Quickly append notes to the currently active Case/Evidence from your shell (`trace "Found a USB key"`).
|
||||
* **Multi-Line Notes:** Full-featured text editor supports detailed forensic observations with multiple lines, arrow key navigation, and scrolling.
|
||||
* **Evidence Source Hashing:** Optionally store source hash values (e.g., SHA256) as metadata when creating evidence items for chain of custody tracking.
|
||||
* **Tag System:** Organize notes with hashtags (e.g., `#malware #windows #critical`). View tags sorted by usage, filter notes by tag, and navigate tagged notes with full context.
|
||||
* **IOC Detection:** Automatically extracts Indicators of Compromise (IPs, domains, URLs, hashes, emails) from notes. View, filter, and export IOCs with occurrence counts and context separators.
|
||||
* **Context Awareness:** Set an "Active" context in the TUI, which persists across sessions for CLI note taking. Recent notes displayed inline for reference.
|
||||
* **Filtering:** Quickly filter Cases and Evidence lists (press `/`).
|
||||
* **Export:** Export all data to a formatted Markdown report with verification details, including evidence source hashes.
|
||||
|
||||
## Installation
|
||||
|
||||
### From Source
|
||||
|
||||
Requires Python 3.x.
|
||||
|
||||
```bash
|
||||
git clone <repository_url>
|
||||
cd trace
|
||||
# Run directly
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
### Building Binary
|
||||
|
||||
You can build a single-file executable using PyInstaller.
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
./build_binary.sh
|
||||
# Binary will be in dist/trace
|
||||
./dist/trace
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```powershell
|
||||
# Install dependencies (includes windows-curses)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Build the executable
|
||||
pyinstaller --onefile --name trace --clean --paths . --hidden-import curses main.py
|
||||
|
||||
# Binary will be in dist\trace.exe
|
||||
.\dist\trace.exe
|
||||
```
|
||||
|
||||
### Installing to PATH
|
||||
|
||||
After building the binary, you can install it to your system PATH for easy access:
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
# Option 1: Copy to /usr/local/bin (requires sudo)
|
||||
sudo cp dist/trace /usr/local/bin/
|
||||
|
||||
# Option 2: Copy to ~/.local/bin (user-only, ensure ~/.local/bin is in PATH)
|
||||
mkdir -p ~/.local/bin
|
||||
cp dist/trace ~/.local/bin/
|
||||
|
||||
# Add to PATH if not already (add to ~/.bashrc or ~/.zshrc)
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```powershell
|
||||
# Option 1: Copy to a directory in PATH (e.g., C:\Windows\System32 - requires admin)
|
||||
Copy-Item dist\trace.exe C:\Windows\System32\
|
||||
|
||||
# Option 2: Create a local bin directory and add to PATH
|
||||
# 1. Create directory
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\bin"
|
||||
Copy-Item dist\trace.exe "$env:USERPROFILE\bin\"
|
||||
|
||||
# 2. Add to PATH permanently (run as admin or use GUI: System Properties > Environment Variables)
|
||||
[Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:USERPROFILE\bin", "User")
|
||||
|
||||
# 3. Restart terminal/PowerShell for changes to take effect
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### TUI Mode
|
||||
Run `trace` without arguments to open the interface.
|
||||
|
||||
**Navigation:**
|
||||
* `Arrow Keys`: Navigate lists.
|
||||
* `Enter`: Select Case / View Evidence details.
|
||||
* `b`: Back.
|
||||
* `q`: Quit.
|
||||
|
||||
**Management:**
|
||||
* `n`: Add a Note to the current context (works in any view).
|
||||
* **Multi-line support**: Notes can span multiple lines - press `Enter` for new lines.
|
||||
* **Tagging**: Use hashtags in your notes (e.g., `#malware #critical`) for organization.
|
||||
* Press `Ctrl+G` to submit the note, or `Esc` to cancel.
|
||||
* Recent notes are displayed inline for context (non-blocking).
|
||||
* `N` (Shift+n): New Case (in Case List) or New Evidence (in Case Detail).
|
||||
* `t`: **Tags View**. Browse all tags in the current context (case or evidence), sorted by usage count.
|
||||
* Press `Enter` on a tag to see all notes with that tag.
|
||||
* Press `Enter` on a note to view full details with tag highlighting.
|
||||
* Navigate back with `b`.
|
||||
* `i`: **IOCs View**. View all Indicators of Compromise extracted from notes in the current context.
|
||||
* Shows IOC types (IPv4, domain, URL, hash, email) with occurrence counts.
|
||||
* Press `Enter` on an IOC to see all notes containing it.
|
||||
* Press `e` to export IOCs to `~/.trace/exports/` in plain text format.
|
||||
* IOC counts are displayed in red in case and evidence views.
|
||||
* `a`: **Set Active**. Sets the currently selected Case or Evidence as the global "Active" context.
|
||||
* `d`: Delete the selected Case or Evidence (with confirmation).
|
||||
* `v`: View all notes for the current Case (in Case Detail view).
|
||||
* `/`: Filter list (type to search, `Esc` or `Enter` to exit filter mode).
|
||||
* `s`: Settings menu (in Case List view).
|
||||
* `Esc`: Cancel during input dialogs.
|
||||
|
||||
### CLI Mode
|
||||
Once a Case or Evidence is set as **Active** in the TUI, you can add notes directly from the command line:
|
||||
|
||||
```bash
|
||||
trace "Suspect system is powered on, attempting live memory capture."
|
||||
```
|
||||
|
||||
This note is automatically timestamped, hashed, signed, and appended to the active context.
|
||||
|
||||
### Exporting
|
||||
To generate a report:
|
||||
|
||||
```bash
|
||||
trace --export
|
||||
# Creates trace_export.md
|
||||
```
|
||||
|
||||
## Data Storage
|
||||
Data is stored in JSON format at `~/.trace/data.json`.
|
||||
Application state (active context) is stored at `~/.trace/state`.
|
||||
|
||||
## License
|
||||
MIT
|
||||
16
trace/build_binary.sh
Executable file
16
trace/build_binary.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Clean previous builds
|
||||
rm -rf build dist *.spec
|
||||
|
||||
# Build the single-file executable
|
||||
# --paths .: Add current directory to search path so 'trace' package is found
|
||||
pyinstaller --onefile \
|
||||
--name trace \
|
||||
--clean \
|
||||
--paths . \
|
||||
--hidden-import curses \
|
||||
main.py
|
||||
|
||||
echo "Build complete. Binary is at dist/trace"
|
||||
4
trace/main.py
Normal file
4
trace/main.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from trace.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
trace/requirements.txt
Normal file
2
trace/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pyinstaller
|
||||
windows-curses; sys_platform == 'win32'
|
||||
0
trace/trace/__init__.py
Normal file
0
trace/trace/__init__.py
Normal file
152
trace/trace/cli.py
Normal file
152
trace/trace/cli.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from .models import Note, Case
|
||||
from .storage import Storage, StateManager
|
||||
from .crypto import Crypto
|
||||
|
||||
def quick_add_note(content: str):
|
||||
storage = Storage()
|
||||
state_manager = StateManager()
|
||||
state = state_manager.get_active()
|
||||
settings = state_manager.get_settings()
|
||||
|
||||
case_id = state.get("case_id")
|
||||
evidence_id = state.get("evidence_id")
|
||||
|
||||
if not case_id:
|
||||
print("Error: No active case set. Open the TUI to select a case first.")
|
||||
sys.exit(1)
|
||||
|
||||
case = storage.get_case(case_id)
|
||||
if not case:
|
||||
print("Error: Active case not found in storage. Ensure you have set an active case in the TUI.")
|
||||
sys.exit(1)
|
||||
|
||||
target_evidence = None
|
||||
|
||||
if evidence_id:
|
||||
# Find evidence
|
||||
for ev in case.evidence:
|
||||
if ev.evidence_id == evidence_id:
|
||||
target_evidence = ev
|
||||
break
|
||||
|
||||
# Create note
|
||||
note = Note(content=content)
|
||||
note.calculate_hash()
|
||||
note.extract_tags() # Extract hashtags from content
|
||||
note.extract_iocs() # Extract IOCs from content
|
||||
|
||||
# Try signing if enabled
|
||||
if settings.get("pgp_enabled", True):
|
||||
signature = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}")
|
||||
if signature:
|
||||
note.signature = signature
|
||||
else:
|
||||
print("Warning: GPG signature failed (GPG not found or no key). Note saved without signature.")
|
||||
|
||||
# Attach to evidence or case
|
||||
if target_evidence:
|
||||
target_evidence.notes.append(note)
|
||||
print(f"✓ Note added to evidence '{target_evidence.name}'")
|
||||
elif evidence_id:
|
||||
print("Warning: Active evidence not found. Adding to case instead.")
|
||||
case.notes.append(note)
|
||||
print(f"✓ Note added to case '{case.case_number}'")
|
||||
else:
|
||||
case.notes.append(note)
|
||||
print(f"✓ Note added to case '{case.case_number}'")
|
||||
|
||||
storage.save_data()
|
||||
|
||||
def export_markdown(output_file: str = "export.md"):
|
||||
storage = Storage()
|
||||
|
||||
with open(output_file, "w") as f:
|
||||
f.write("# Forensic Notes Export\n\n")
|
||||
f.write(f"Generated on: {time.ctime()}\n\n")
|
||||
|
||||
for case in storage.cases:
|
||||
f.write(f"## Case: {case.case_number}\n")
|
||||
if case.name:
|
||||
f.write(f"**Name:** {case.name}\n")
|
||||
if case.investigator:
|
||||
f.write(f"**Investigator:** {case.investigator}\n")
|
||||
f.write(f"**Case ID:** {case.case_id}\n\n")
|
||||
|
||||
f.write("### Case Notes\n")
|
||||
if not case.notes:
|
||||
f.write("_No notes._\n")
|
||||
for note in case.notes:
|
||||
write_note(f, note)
|
||||
|
||||
f.write("\n### Evidence\n")
|
||||
if not case.evidence:
|
||||
f.write("_No evidence._\n")
|
||||
|
||||
for ev in case.evidence:
|
||||
f.write(f"#### Evidence: {ev.name}\n")
|
||||
if ev.description:
|
||||
f.write(f"_{ev.description}_\n")
|
||||
f.write(f"**ID:** {ev.evidence_id}\n")
|
||||
|
||||
# Include source hash if available
|
||||
source_hash = ev.metadata.get("source_hash")
|
||||
if source_hash:
|
||||
f.write(f"**Source Hash:** `{source_hash}`\n")
|
||||
f.write("\n")
|
||||
|
||||
f.write("##### Evidence Notes\n")
|
||||
if not ev.notes:
|
||||
f.write("_No notes._\n")
|
||||
for note in ev.notes:
|
||||
write_note(f, note)
|
||||
f.write("\n")
|
||||
f.write("---\n\n")
|
||||
print(f"Exported to {output_file}")
|
||||
|
||||
def write_note(f, note: Note):
|
||||
f.write(f"- **{time.ctime(note.timestamp)}**\n")
|
||||
f.write(f" - Content: {note.content}\n")
|
||||
f.write(f" - Hash: `{note.content_hash}`\n")
|
||||
if note.signature:
|
||||
f.write(" - **Signature Verified:**\n")
|
||||
f.write(" ```\n")
|
||||
# Indent signature for markdown block
|
||||
for line in note.signature.splitlines():
|
||||
f.write(f" {line}\n")
|
||||
f.write(" ```\n")
|
||||
f.write("\n")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="trace: Forensic Note Taking Tool")
|
||||
parser.add_argument("note", nargs="?", help="Quick note content to add to active context")
|
||||
parser.add_argument("--export", help="Export all data to Markdown file", action="store_true")
|
||||
parser.add_argument("--output", help="Output file for export", default="trace_export.md")
|
||||
parser.add_argument("--open", "-o", help="Open TUI directly at active case/evidence", action="store_true")
|
||||
|
||||
# We will import TUI only if needed to keep start time fast
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.export:
|
||||
export_markdown(args.output)
|
||||
return
|
||||
|
||||
if args.note:
|
||||
quick_add_note(args.note)
|
||||
return
|
||||
|
||||
# Launch TUI (with optional direct navigation to active context)
|
||||
try:
|
||||
from .tui import run_tui
|
||||
run_tui(open_active=args.open)
|
||||
except ImportError as e:
|
||||
print(f"Error launching TUI: {e}")
|
||||
# For development debugging, it might be useful to see full traceback
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
35
trace/trace/crypto.py
Normal file
35
trace/trace/crypto.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import subprocess
|
||||
import hashlib
|
||||
|
||||
class Crypto:
|
||||
@staticmethod
|
||||
def sign_content(content: str) -> str:
|
||||
"""
|
||||
Signs the content using GPG.
|
||||
Returns the clearsigned content or None if GPG fails.
|
||||
"""
|
||||
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', '-'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
stdout, stderr = proc.communicate(input=content)
|
||||
|
||||
if proc.returncode != 0:
|
||||
# Fallback: maybe no key is found or gpg error
|
||||
# In a real app we might want to log this 'stderr'
|
||||
return ""
|
||||
|
||||
return stdout
|
||||
except FileNotFoundError:
|
||||
return "" # GPG not installed
|
||||
|
||||
@staticmethod
|
||||
def hash_content(content: str, timestamp: float) -> str:
|
||||
data = f"{timestamp}:{content}".encode('utf-8')
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
180
trace/trace/models.py
Normal file
180
trace/trace/models.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import time
|
||||
import hashlib
|
||||
import uuid
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
content: str
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
note_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
content_hash: str = ""
|
||||
signature: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
iocs: List[str] = field(default_factory=list)
|
||||
|
||||
def extract_tags(self):
|
||||
"""Extract hashtags from content (case-insensitive, stored lowercase)"""
|
||||
# Match hashtags: # followed by word characters
|
||||
tag_pattern = r'#(\w+)'
|
||||
matches = re.findall(tag_pattern, self.content)
|
||||
# Convert to lowercase and remove duplicates while preserving order
|
||||
seen = set()
|
||||
self.tags = []
|
||||
for tag in matches:
|
||||
tag_lower = tag.lower()
|
||||
if tag_lower not in seen:
|
||||
seen.add(tag_lower)
|
||||
self.tags.append(tag_lower)
|
||||
|
||||
def extract_iocs(self):
|
||||
"""Extract Indicators of Compromise from content"""
|
||||
seen = set()
|
||||
self.iocs = []
|
||||
|
||||
# IPv4 addresses
|
||||
ipv4_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
|
||||
for match in re.findall(ipv4_pattern, self.content):
|
||||
if match not in seen:
|
||||
seen.add(match)
|
||||
self.iocs.append(match)
|
||||
|
||||
# IPv6 addresses (simplified)
|
||||
ipv6_pattern = r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b'
|
||||
for match in re.findall(ipv6_pattern, self.content):
|
||||
if match not in seen:
|
||||
seen.add(match)
|
||||
self.iocs.append(match)
|
||||
|
||||
# Domain names (basic pattern)
|
||||
domain_pattern = r'\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b'
|
||||
for match in re.findall(domain_pattern, self.content):
|
||||
# Filter out common false positives
|
||||
if match not in seen and not match.startswith('example.'):
|
||||
seen.add(match)
|
||||
self.iocs.append(match)
|
||||
|
||||
# URLs
|
||||
url_pattern = r'https?://[^\s]+'
|
||||
for match in re.findall(url_pattern, self.content):
|
||||
if match not in seen:
|
||||
seen.add(match)
|
||||
self.iocs.append(match)
|
||||
|
||||
# MD5 hashes (32 hex chars)
|
||||
md5_pattern = r'\b[a-fA-F0-9]{32}\b'
|
||||
for match in re.findall(md5_pattern, self.content):
|
||||
if match not in seen:
|
||||
seen.add(match)
|
||||
self.iocs.append(match)
|
||||
|
||||
# SHA1 hashes (40 hex chars)
|
||||
sha1_pattern = r'\b[a-fA-F0-9]{40}\b'
|
||||
for match in re.findall(sha1_pattern, self.content):
|
||||
if match not in seen:
|
||||
seen.add(match)
|
||||
self.iocs.append(match)
|
||||
|
||||
# SHA256 hashes (64 hex chars)
|
||||
sha256_pattern = r'\b[a-fA-F0-9]{64}\b'
|
||||
for match in re.findall(sha256_pattern, self.content):
|
||||
if match not in seen:
|
||||
seen.add(match)
|
||||
self.iocs.append(match)
|
||||
|
||||
# Email addresses
|
||||
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
|
||||
for match in re.findall(email_pattern, self.content):
|
||||
if match not in seen:
|
||||
seen.add(match)
|
||||
self.iocs.append(match)
|
||||
|
||||
def calculate_hash(self):
|
||||
# We hash the content + timestamp to ensure integrity of 'when' it was said
|
||||
data = f"{self.timestamp}:{self.content}".encode('utf-8')
|
||||
self.content_hash = hashlib.sha256(data).hexdigest()
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"note_id": self.note_id,
|
||||
"content": self.content,
|
||||
"timestamp": self.timestamp,
|
||||
"content_hash": self.content_hash,
|
||||
"signature": self.signature,
|
||||
"tags": self.tags,
|
||||
"iocs": self.iocs
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data):
|
||||
note = Note(
|
||||
content=data["content"],
|
||||
timestamp=data["timestamp"],
|
||||
note_id=data["note_id"],
|
||||
content_hash=data.get("content_hash", ""),
|
||||
signature=data.get("signature"),
|
||||
tags=data.get("tags", []),
|
||||
iocs=data.get("iocs", [])
|
||||
)
|
||||
return note
|
||||
|
||||
@dataclass
|
||||
class Evidence:
|
||||
name: str
|
||||
evidence_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
description: str = ""
|
||||
metadata: Dict[str, str] = field(default_factory=dict)
|
||||
notes: List[Note] = field(default_factory=list)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"evidence_id": self.evidence_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"metadata": self.metadata,
|
||||
"notes": [n.to_dict() for n in self.notes]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data):
|
||||
ev = Evidence(
|
||||
name=data["name"],
|
||||
evidence_id=data["evidence_id"],
|
||||
description=data.get("description", ""),
|
||||
metadata=data.get("metadata", {})
|
||||
)
|
||||
ev.notes = [Note.from_dict(n) for n in data.get("notes", [])]
|
||||
return ev
|
||||
|
||||
@dataclass
|
||||
class Case:
|
||||
case_number: str
|
||||
case_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = ""
|
||||
investigator: str = ""
|
||||
evidence: List[Evidence] = field(default_factory=list)
|
||||
notes: List[Note] = field(default_factory=list)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"case_id": self.case_id,
|
||||
"case_number": self.case_number,
|
||||
"name": self.name,
|
||||
"investigator": self.investigator,
|
||||
"evidence": [e.to_dict() for e in self.evidence],
|
||||
"notes": [n.to_dict() for n in self.notes]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data):
|
||||
case = Case(
|
||||
case_number=data["case_number"],
|
||||
case_id=data["case_id"],
|
||||
name=data.get("name", ""),
|
||||
investigator=data.get("investigator", "")
|
||||
)
|
||||
case.evidence = [Evidence.from_dict(e) for e in data.get("evidence", [])]
|
||||
case.notes = [Note.from_dict(n) for n in data.get("notes", [])]
|
||||
return case
|
||||
110
trace/trace/storage.py
Normal file
110
trace/trace/storage.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from .models import Case, Evidence, Note
|
||||
|
||||
DEFAULT_APP_DIR = Path.home() / ".trace"
|
||||
|
||||
class Storage:
|
||||
def __init__(self, app_dir: Path = DEFAULT_APP_DIR):
|
||||
self.app_dir = app_dir
|
||||
self.data_file = self.app_dir / "data.json"
|
||||
self._ensure_app_dir()
|
||||
self.cases: List[Case] = self._load_data()
|
||||
|
||||
def _ensure_app_dir(self):
|
||||
if not self.app_dir.exists():
|
||||
self.app_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_data(self) -> List[Case]:
|
||||
if not self.data_file.exists():
|
||||
return []
|
||||
try:
|
||||
with open(self.data_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
return [Case.from_dict(c) for c in data]
|
||||
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
|
||||
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)
|
||||
|
||||
def add_case(self, case: Case):
|
||||
self.cases.append(case)
|
||||
self.save_data()
|
||||
|
||||
def get_case(self, case_id: str) -> Optional[Case]:
|
||||
# Case ID lookup
|
||||
for c in self.cases:
|
||||
if c.case_id == case_id:
|
||||
return c
|
||||
return None
|
||||
|
||||
def delete_case(self, case_id: str):
|
||||
self.cases = [c for c in self.cases if c.case_id != case_id]
|
||||
self.save_data()
|
||||
|
||||
def delete_evidence(self, case_id: str, evidence_id: str):
|
||||
case = self.get_case(case_id)
|
||||
if case:
|
||||
case.evidence = [e for e in case.evidence if e.evidence_id != evidence_id]
|
||||
self.save_data()
|
||||
|
||||
def find_evidence(self, evidence_id: str) -> Tuple[Optional[Case], Optional[Evidence]]:
|
||||
for c in self.cases:
|
||||
for e in c.evidence:
|
||||
if e.evidence_id == evidence_id:
|
||||
return c, e
|
||||
return None, None
|
||||
|
||||
class StateManager:
|
||||
def __init__(self, app_dir: Path = DEFAULT_APP_DIR):
|
||||
self.app_dir = app_dir
|
||||
self.state_file = self.app_dir / "state"
|
||||
self.settings_file = self.app_dir / "settings.json"
|
||||
self._ensure_app_dir()
|
||||
|
||||
def _ensure_app_dir(self):
|
||||
if not self.app_dir.exists():
|
||||
self.app_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def set_active(self, case_id: Optional[str] = None, evidence_id: Optional[str] = None):
|
||||
state = self.get_active()
|
||||
state["case_id"] = case_id
|
||||
state["evidence_id"] = evidence_id
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump(state, f)
|
||||
|
||||
def get_active(self) -> dict:
|
||||
if not self.state_file.exists():
|
||||
return {"case_id": None, "evidence_id": None}
|
||||
try:
|
||||
with open(self.state_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {"case_id": None, "evidence_id": None}
|
||||
|
||||
def get_settings(self) -> dict:
|
||||
if not self.settings_file.exists():
|
||||
return {"pgp_enabled": True}
|
||||
try:
|
||||
with open(self.settings_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {"pgp_enabled": True}
|
||||
|
||||
def set_setting(self, key: str, value):
|
||||
settings = self.get_settings()
|
||||
settings[key] = value
|
||||
with open(self.settings_file, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
66
trace/trace/tests/test_models.py
Normal file
66
trace/trace/tests/test_models.py
Normal file
@@ -0,0 +1,66 @@
|
||||
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
|
||||
|
||||
class TestModels(unittest.TestCase):
|
||||
def test_note_hash(self):
|
||||
note = Note(content="Test content")
|
||||
note.calculate_hash()
|
||||
self.assertTrue(note.content_hash)
|
||||
|
||||
def test_case_dict(self):
|
||||
c = Case(case_number="123", name="Test")
|
||||
d = c.to_dict()
|
||||
self.assertEqual(d["case_number"], "123")
|
||||
c2 = Case.from_dict(d)
|
||||
self.assertEqual(c2.name, "Test")
|
||||
|
||||
class TestStorage(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.test_dir = Path(tempfile.mkdtemp())
|
||||
self.storage = Storage(app_dir=self.test_dir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def test_save_and_load_case(self):
|
||||
case = Case(case_number="T-001", name="Test Case")
|
||||
self.storage.add_case(case)
|
||||
|
||||
# Reload storage from same dir
|
||||
new_storage = Storage(app_dir=self.test_dir)
|
||||
loaded_case = new_storage.get_case(case.case_id)
|
||||
|
||||
self.assertIsNotNone(loaded_case)
|
||||
self.assertEqual(loaded_case.name, "Test Case")
|
||||
|
||||
def test_find_evidence(self):
|
||||
case = Case(case_number="T-002")
|
||||
ev = Evidence(name="Gun")
|
||||
case.evidence.append(ev)
|
||||
self.storage.add_case(case)
|
||||
|
||||
c, e = self.storage.find_evidence(ev.evidence_id)
|
||||
self.assertEqual(c.case_id, case.case_id)
|
||||
self.assertEqual(e.name, "Gun")
|
||||
|
||||
class TestStateManager(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.test_dir = Path(tempfile.mkdtemp())
|
||||
self.mgr = StateManager(app_dir=self.test_dir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def test_set_get_active(self):
|
||||
self.mgr.set_active(case_id="123", evidence_id="456")
|
||||
state = self.mgr.get_active()
|
||||
self.assertEqual(state["case_id"], "123")
|
||||
self.assertEqual(state["evidence_id"], "456")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
1738
trace/trace/tui.py
Normal file
1738
trace/trace/tui.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user