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:
google-labs-jules[bot]
2025-12-11 06:50:36 +00:00
commit 27f4d65d59
12 changed files with 2483 additions and 0 deletions

28
trace/.gitignore vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
from trace.cli import main
if __name__ == "__main__":
main()

2
trace/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
pyinstaller
windows-curses; sys_platform == 'win32'

0
trace/trace/__init__.py Normal file
View File

152
trace/trace/cli.py Normal file
View 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
View 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
View 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
View 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)

View 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

File diff suppressed because it is too large Load Diff