mirror of
https://github.com/overcuriousity/trace.git
synced 2025-12-20 13:02:21 +00:00
Compare commits
34 Commits
v0.2.0-alp
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b6503b29 | ||
|
|
f54e2560f3 | ||
|
|
a2e7798a2d | ||
|
|
15bc00a195 | ||
|
|
053369df78 | ||
|
|
eca56c0d54 | ||
|
|
06b7680982 | ||
|
|
bfefb42761 | ||
|
|
070e76467c | ||
|
|
b80dd10901 | ||
|
|
fe3c0710c6 | ||
|
|
809a4a498f | ||
|
|
931e5debc8 | ||
|
|
f91f434f7f | ||
|
|
85ca483a1d | ||
|
|
f50fd1800d | ||
|
|
b830d15d85 | ||
|
|
4a4e1e7c06 | ||
|
|
2a7d00d221 | ||
|
|
c68fc66de6 | ||
|
|
f68c8389da | ||
|
|
50ffeb1b6e | ||
|
|
d6b8231bae | ||
|
|
8b13cfc37b | ||
|
|
62fa781350 | ||
|
|
f4f276160a | ||
|
|
33cad5bd5f | ||
|
|
4fad8a3561 | ||
|
|
48525fe505 | ||
|
|
085c9e9aa8 | ||
|
|
06548df373 | ||
|
|
dff27ac7e4 | ||
|
|
a1f95548fd | ||
|
|
425a169217 |
77
CLAUDE.md
77
CLAUDE.md
@@ -9,20 +9,75 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Development Commands
|
||||
|
||||
### Running the Application
|
||||
|
||||
#### Launching TUI
|
||||
```bash
|
||||
# Run directly from source
|
||||
# Launch TUI (default behavior)
|
||||
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
|
||||
```
|
||||
|
||||
#### Context Management
|
||||
```bash
|
||||
# Show current active case and evidence
|
||||
python3 main.py --show-context
|
||||
|
||||
# List all cases and evidence in hierarchy
|
||||
python3 main.py --list
|
||||
|
||||
# Switch active case (by case number or UUID)
|
||||
python3 main.py --switch-case 2024-001
|
||||
|
||||
# Switch active evidence (by name or UUID, within active case)
|
||||
python3 main.py --switch-evidence "disk-image-1"
|
||||
```
|
||||
|
||||
#### Creating Cases and Evidence
|
||||
```bash
|
||||
# Create new case (automatically sets as active)
|
||||
python3 main.py --new-case 2024-001
|
||||
|
||||
# Create case with metadata
|
||||
python3 main.py --new-case 2024-001 --name "Ransomware Investigation" --investigator "John Doe"
|
||||
|
||||
# Create evidence in active case (automatically sets as active)
|
||||
python3 main.py --new-evidence "Laptop HDD"
|
||||
|
||||
# Create evidence with description
|
||||
python3 main.py --new-evidence "Server Logs" --description "Apache access logs from compromised server"
|
||||
```
|
||||
|
||||
#### Adding Notes
|
||||
```bash
|
||||
# Quick note to active context
|
||||
python3 main.py "Observed suspicious process at 14:32"
|
||||
|
||||
# Read note from stdin (for piping command output)
|
||||
echo "Network spike detected" | python3 main.py --stdin
|
||||
ps aux | grep malware | python3 main.py --stdin
|
||||
tail -f logfile.txt | grep error | python3 main.py --stdin
|
||||
|
||||
# Add note to specific case without changing active context
|
||||
python3 main.py --case 2024-002 "Found malware in temp folder"
|
||||
|
||||
# Add note to specific evidence without changing active context
|
||||
python3 main.py --evidence "disk-image-2" "Bad sectors detected"
|
||||
|
||||
# Add note to specific case and evidence (both overrides)
|
||||
python3 main.py --case 2024-001 --evidence "Laptop HDD" "Recovered deleted files"
|
||||
```
|
||||
|
||||
#### Export
|
||||
```bash
|
||||
# Export all data to markdown
|
||||
python3 main.py --export --output report.md
|
||||
|
||||
# Export with default filename (trace_export.md)
|
||||
python3 main.py --export
|
||||
```
|
||||
|
||||
### Building Binary
|
||||
```bash
|
||||
# Install dependencies first
|
||||
@@ -90,6 +145,14 @@ The codebase is organized into focused, single-responsibility modules to make it
|
||||
|
||||
**Integrity System**: Every note automatically gets:
|
||||
1. SHA256 hash of `timestamp:content` (via `Note.calculate_hash()`)
|
||||
- **Timestamp Format**: Unix epoch timestamp as float (seconds since 1970-01-01 00:00:00 UTC)
|
||||
- **Hash Input Format**: `"{timestamp}:{content}"` where timestamp is converted to string using Python's default str() conversion
|
||||
- **Example**: For content "Suspicious process detected" with timestamp 1702345678.123456, the hash input is:
|
||||
```
|
||||
1702345678.123456:Suspicious process detected
|
||||
```
|
||||
- This ensures integrity of both WHAT was said (content) and WHEN it was said (timestamp)
|
||||
- The exact float precision is preserved in the hash, making timestamps forensically tamper-evident
|
||||
2. Optional GPG clearsign signature (if `pgp_enabled` in settings and GPG available)
|
||||
|
||||
**Tag System**: Regex-based hashtag extraction (`#word`)
|
||||
|
||||
113
README.md
113
README.md
@@ -20,7 +20,77 @@ trace "IR team gained shell access. Initial persistence checks running."
|
||||
trace "Observed outbound connection to 192.168.1.55 on port 80. #suspicious #network"
|
||||
```
|
||||
|
||||
**System Integrity Chain:** Each command-line note is immediately stamped, concatenated with its content, and hashed using SHA256 before storage. This ensures a non-repudiable log entry.
|
||||
**System Integrity Chain:** Each command-line note is immediately stamped with a Unix epoch timestamp (seconds since 1970-01-01 00:00:00 UTC as float, e.g., `1702345678.123456`), concatenated with its content in the format `"{timestamp}:{content}"`, and hashed using SHA256 before storage. This ensures a non-repudiable log entry with forensically tamper-evident timestamps.
|
||||
|
||||
## CLI Command Reference
|
||||
|
||||
### Context Management
|
||||
|
||||
View and switch between cases and evidence without opening the TUI:
|
||||
|
||||
```bash
|
||||
# Show current active case and evidence
|
||||
trace --show-context
|
||||
|
||||
# List all cases and evidence in hierarchy
|
||||
trace --list
|
||||
|
||||
# Switch active case (by case number or UUID)
|
||||
trace --switch-case 2024-001
|
||||
|
||||
# Switch active evidence (by name or UUID, within active case)
|
||||
trace --switch-evidence "disk-image-1"
|
||||
```
|
||||
|
||||
### Case and Evidence Creation
|
||||
|
||||
Create new cases and evidence directly from the command line:
|
||||
|
||||
```bash
|
||||
# Create new case (automatically becomes active)
|
||||
trace --new-case 2024-001
|
||||
|
||||
# Create case with full metadata
|
||||
trace --new-case 2024-001 --name "Ransomware Investigation" --investigator "Jane Doe"
|
||||
|
||||
# Create evidence in active case (automatically becomes active)
|
||||
trace --new-evidence "Laptop HDD"
|
||||
|
||||
# Create evidence with description
|
||||
trace --new-evidence "Server Logs" --description "Apache logs from compromised server"
|
||||
```
|
||||
|
||||
### Advanced Note-Taking
|
||||
|
||||
Beyond basic hot logging, trace supports stdin piping and context overrides:
|
||||
|
||||
```bash
|
||||
# Pipe command output directly into notes
|
||||
ps aux | grep malware | trace --stdin
|
||||
tail -f /var/log/auth.log | grep "Failed password" | trace --stdin
|
||||
netstat -an | trace --stdin
|
||||
|
||||
# Add note to specific case without changing active context
|
||||
trace --case 2024-002 "Found malware in temp folder"
|
||||
|
||||
# Add note to specific evidence without changing active context
|
||||
trace --evidence "Memory Dump" "Suspicious process identified"
|
||||
|
||||
# Override both case and evidence for a single note
|
||||
trace --case 2024-001 --evidence "Disk Image" "Recovered deleted files"
|
||||
```
|
||||
|
||||
**Identifiers:** All commands accept both human-friendly identifiers (case numbers like `2024-001`, evidence names like `Laptop HDD`) and UUIDs. Use `--list` to see available identifiers.
|
||||
|
||||
### Export
|
||||
|
||||
```bash
|
||||
# Export all data to markdown (GPG-signed if enabled)
|
||||
trace --export --output investigation-report.md
|
||||
|
||||
# Export with default filename (trace_export.md)
|
||||
trace --export
|
||||
```
|
||||
|
||||
## Installation & Deployment
|
||||
|
||||
@@ -125,7 +195,7 @@ After this, you can log with just: `t "Your note here"`
|
||||
|
||||
| Feature | Description | Operational Impact |
|
||||
| :--- | :--- | :--- |
|
||||
| **Integrity Hashing** | SHA256 applied to every log entry (content + timestamp). | **Guaranteed log integrity.** No modification possible post-entry. |
|
||||
| **Integrity Hashing** | SHA256 applied to every log entry using format `"{unix_timestamp}:{content}"`. Timestamp is Unix epoch as float (e.g., `1702345678.123456`). | **Guaranteed log integrity.** No modification possible post-entry. Timestamps are forensically tamper-evident with full float precision. |
|
||||
| **GPG Signing** | Optional PGP/GPG signature applied to notes. | **Non-repudiation** for formal evidence handling. |
|
||||
| **IOC Extraction** | Automatic parsing of IPv4, FQDNs, URLs, hashes, and email addresses. | **Immediate intelligence gathering** from raw text. |
|
||||
| **Tag System** | Supports `#hashtags` for classification and filtering. | **Efficient triage** of large log sets. |
|
||||
@@ -138,20 +208,33 @@ After this, you can log with just: `t "Your note here"`
|
||||
### Layer 1: Note-Level Integrity (Always Active)
|
||||
|
||||
**Process:**
|
||||
1. **Timestamp Generation** - Precise Unix timestamp captured at note creation
|
||||
2. **Content Hashing** - SHA256 hash computed from `timestamp:content`
|
||||
1. **Timestamp Generation** - Precise Unix epoch timestamp (float) captured at note creation
|
||||
- Format: Seconds since 1970-01-01 00:00:00 UTC (e.g., `1702345678.123456`)
|
||||
- Full float precision preserved for forensic tamper-evidence
|
||||
2. **Content Hashing** - SHA256 hash computed from `"{timestamp}:{content}"`
|
||||
3. **Optional Signature** - Hash is signed with investigator's GPG private key
|
||||
|
||||
**Mathematical Representation:**
|
||||
```
|
||||
hash = SHA256(timestamp + ":" + content)
|
||||
timestamp = Unix epoch time as float (e.g., 1702345678.123456)
|
||||
hash_input = "{timestamp}:{content}"
|
||||
hash = SHA256(hash_input)
|
||||
signature = GPG_Sign(hash, private_key)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Content: "Suspicious process detected"
|
||||
Timestamp: 1702345678.123456
|
||||
Hash input: "1702345678.123456:Suspicious process detected"
|
||||
Hash: SHA256 of above = a3f5b2c8d9e1f4a7b6c3d8e2f5a9b4c7d1e6f3a8b5c2d9e4f7a1b8c6d3e0f5a2
|
||||
```
|
||||
|
||||
**Security Properties:**
|
||||
- **Temporal Integrity**: Timestamp is cryptographically bound to content (cannot backdate notes)
|
||||
- **Tamper Detection**: Any modification to content or timestamp invalidates the hash
|
||||
- **Non-Repudiation**: GPG signature proves who created the note (if signing enabled)
|
||||
- **Hash Reproducibility**: Exported markdown includes Unix timestamp for independent verification
|
||||
- **Efficient Storage**: Signing only the hash (64 hex chars) instead of full content
|
||||
|
||||
### Layer 2: Export-Level Integrity (On Demand)
|
||||
@@ -258,6 +341,26 @@ Individual note signatures are embedded in the markdown export. To verify a spec
|
||||
- The GPG signature proves who created that hash
|
||||
- Together: Proves this specific content was created by this investigator at this time
|
||||
|
||||
**Hash Verification (Manual):**
|
||||
|
||||
To independently verify a note's hash from the markdown export:
|
||||
|
||||
1. Locate the note in the export file and extract:
|
||||
- Unix Timestamp (e.g., `1702345678.123456`)
|
||||
- Content (e.g., `"Suspicious process detected"`)
|
||||
- Claimed Hash (e.g., `a3f5b2c8...`)
|
||||
|
||||
2. Recompute the hash:
|
||||
```bash
|
||||
# Using Python
|
||||
python3 -c "import hashlib; print(hashlib.sha256(b'1702345678.123456:Suspicious process detected').hexdigest())"
|
||||
|
||||
# Using command-line tools
|
||||
echo -n "1702345678.123456:Suspicious process detected" | sha256sum
|
||||
```
|
||||
|
||||
3. Compare the computed hash with the claimed hash - they must match exactly
|
||||
|
||||
### Cryptographic Trust Model
|
||||
|
||||
```
|
||||
|
||||
346
trace/cli.py
346
trace/cli.py
@@ -1,11 +1,204 @@
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from .models import Note, Case
|
||||
from typing import Optional, Tuple
|
||||
from .models import Note, Case, Evidence
|
||||
from .storage import Storage, StateManager
|
||||
from .crypto import Crypto
|
||||
|
||||
def quick_add_note(content: str):
|
||||
def find_case(storage: Storage, identifier: str) -> Optional[Case]:
|
||||
"""Find a case by case_id (UUID) or case_number."""
|
||||
for case in storage.cases:
|
||||
if case.case_id == identifier or case.case_number == identifier:
|
||||
return case
|
||||
return None
|
||||
|
||||
def find_evidence(case: Case, identifier: str) -> Optional[Evidence]:
|
||||
"""Find evidence by evidence_id (UUID) or name within a case."""
|
||||
for evidence in case.evidence:
|
||||
if evidence.evidence_id == identifier or evidence.name == identifier:
|
||||
return evidence
|
||||
return None
|
||||
|
||||
def show_context():
|
||||
"""Display the current active context."""
|
||||
state_manager = StateManager()
|
||||
storage = Storage()
|
||||
|
||||
state = state_manager.get_active()
|
||||
case_id = state.get("case_id")
|
||||
evidence_id = state.get("evidence_id")
|
||||
|
||||
if not case_id:
|
||||
print("No active context set.")
|
||||
print("Use --switch-case to set an active case, or open the TUI to select one.")
|
||||
return
|
||||
|
||||
case = storage.get_case(case_id)
|
||||
if not case:
|
||||
print("Error: Active case not found in storage.")
|
||||
return
|
||||
|
||||
print(f"Active context:")
|
||||
print(f" Case: {case.case_number}", end="")
|
||||
if case.name:
|
||||
print(f" - {case.name}", end="")
|
||||
print(f" [{case.case_id[:8]}...]")
|
||||
|
||||
if evidence_id:
|
||||
evidence = find_evidence(case, evidence_id)
|
||||
if evidence:
|
||||
print(f" Evidence: {evidence.name}", end="")
|
||||
if evidence.description:
|
||||
print(f" - {evidence.description}", end="")
|
||||
print(f" [{evidence.evidence_id[:8]}...]")
|
||||
else:
|
||||
print(f" Evidence: [not found - stale reference]")
|
||||
else:
|
||||
print(f" Evidence: [none - notes will attach to case]")
|
||||
|
||||
def list_contexts():
|
||||
"""List all cases and their evidence in a hierarchical format."""
|
||||
storage = Storage()
|
||||
|
||||
if not storage.cases:
|
||||
print("No cases found.")
|
||||
print("Use --new-case to create one, or open the TUI.")
|
||||
return
|
||||
|
||||
print("Cases and Evidence:")
|
||||
for case in storage.cases:
|
||||
# Show case
|
||||
print(f" [{case.case_id[:8]}...] {case.case_number}", end="")
|
||||
if case.name:
|
||||
print(f" - {case.name}", end="")
|
||||
if case.investigator:
|
||||
print(f" (Investigator: {case.investigator})", end="")
|
||||
print()
|
||||
|
||||
# Show evidence under this case
|
||||
for evidence in case.evidence:
|
||||
print(f" [{evidence.evidence_id[:8]}...] {evidence.name}", end="")
|
||||
if evidence.description:
|
||||
print(f" - {evidence.description}", end="")
|
||||
print()
|
||||
|
||||
# Add blank line between cases for readability
|
||||
if storage.cases[-1] != case:
|
||||
print()
|
||||
|
||||
def create_case(case_number: str, name: Optional[str] = None, investigator: Optional[str] = None):
|
||||
"""Create a new case and set it as active."""
|
||||
storage = Storage()
|
||||
state_manager = StateManager()
|
||||
|
||||
# Check if case number already exists
|
||||
existing = find_case(storage, case_number)
|
||||
if existing:
|
||||
print(f"Error: Case with number '{case_number}' already exists.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Create new case
|
||||
case = Case(case_number=case_number, name=name, investigator=investigator)
|
||||
storage.cases.append(case)
|
||||
storage.save_data()
|
||||
|
||||
# Set as active case
|
||||
state_manager.set_active(case.case_id, None)
|
||||
|
||||
print(f"✓ Created case '{case_number}' [{case.case_id[:8]}...]")
|
||||
if name:
|
||||
print(f" Name: {name}")
|
||||
if investigator:
|
||||
print(f" Investigator: {investigator}")
|
||||
print(f"✓ Set as active case")
|
||||
|
||||
def create_evidence(name: str, description: Optional[str] = None):
|
||||
"""Create new evidence and attach to active case."""
|
||||
storage = Storage()
|
||||
state_manager = StateManager()
|
||||
|
||||
state = state_manager.get_active()
|
||||
case_id = state.get("case_id")
|
||||
|
||||
if not case_id:
|
||||
print("Error: No active case set. Use --switch-case or --new-case first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
case = storage.get_case(case_id)
|
||||
if not case:
|
||||
print("Error: Active case not found in storage.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Check if evidence with this name already exists in the case
|
||||
existing = find_evidence(case, name)
|
||||
if existing:
|
||||
print(f"Error: Evidence named '{name}' already exists in case '{case.case_number}'.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Create new evidence
|
||||
evidence = Evidence(name=name, description=description)
|
||||
case.evidence.append(evidence)
|
||||
storage.save_data()
|
||||
|
||||
# Set as active evidence
|
||||
state_manager.set_active(case.case_id, evidence.evidence_id)
|
||||
|
||||
print(f"✓ Created evidence '{name}' [{evidence.evidence_id[:8]}...]")
|
||||
if description:
|
||||
print(f" Description: {description}")
|
||||
print(f"✓ Added to case '{case.case_number}'")
|
||||
print(f"✓ Set as active evidence")
|
||||
|
||||
def switch_case(identifier: str):
|
||||
"""Switch active case context."""
|
||||
storage = Storage()
|
||||
state_manager = StateManager()
|
||||
|
||||
case = find_case(storage, identifier)
|
||||
if not case:
|
||||
print(f"Error: Case '{identifier}' not found.", file=sys.stderr)
|
||||
print("Use --list to see available cases.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Set as active case, clear evidence
|
||||
state_manager.set_active(case.case_id, None)
|
||||
|
||||
print(f"✓ Switched to case '{case.case_number}' [{case.case_id[:8]}...]")
|
||||
if case.name:
|
||||
print(f" {case.name}")
|
||||
|
||||
def switch_evidence(identifier: str):
|
||||
"""Switch active evidence context within the active case."""
|
||||
storage = Storage()
|
||||
state_manager = StateManager()
|
||||
|
||||
state = state_manager.get_active()
|
||||
case_id = state.get("case_id")
|
||||
|
||||
if not case_id:
|
||||
print("Error: No active case set. Use --switch-case first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
case = storage.get_case(case_id)
|
||||
if not case:
|
||||
print("Error: Active case not found in storage.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
evidence = find_evidence(case, identifier)
|
||||
if not evidence:
|
||||
print(f"Error: Evidence '{identifier}' not found in case '{case.case_number}'.", file=sys.stderr)
|
||||
print("Use --list to see available evidence.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Set as active evidence
|
||||
state_manager.set_active(case.case_id, evidence.evidence_id)
|
||||
|
||||
print(f"✓ Switched to evidence '{evidence.name}' [{evidence.evidence_id[:8]}...]")
|
||||
if evidence.description:
|
||||
print(f" {evidence.description}")
|
||||
|
||||
def quick_add_note(content: str, case_override: Optional[str] = None, evidence_override: Optional[str] = None):
|
||||
storage = Storage()
|
||||
state_manager = StateManager()
|
||||
|
||||
@@ -17,31 +210,43 @@ def quick_add_note(content: str):
|
||||
state = state_manager.get_active()
|
||||
settings = state_manager.get_settings()
|
||||
|
||||
case_id = state.get("case_id")
|
||||
evidence_id = state.get("evidence_id")
|
||||
# Handle case override or use active case
|
||||
if case_override:
|
||||
case = find_case(storage, case_override)
|
||||
if not case:
|
||||
print(f"Error: Case '{case_override}' not found.", file=sys.stderr)
|
||||
print("Use --list to see available cases.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
case_id = state.get("case_id")
|
||||
if not case_id:
|
||||
print("Error: No active case set. Use --switch-case, --new-case, or open the TUI to select a case.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not case_id:
|
||||
print("Error: No active case set. Open the TUI to select a case first.", file=sys.stderr)
|
||||
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.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
case = storage.get_case(case_id)
|
||||
if not case:
|
||||
print("Error: Active case not found in storage.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Handle evidence override or use active evidence
|
||||
target_evidence = None
|
||||
|
||||
if evidence_id:
|
||||
# Find and validate evidence belongs to active case
|
||||
for ev in case.evidence:
|
||||
if ev.evidence_id == evidence_id:
|
||||
target_evidence = ev
|
||||
break
|
||||
|
||||
if evidence_override:
|
||||
target_evidence = find_evidence(case, evidence_override)
|
||||
if not target_evidence:
|
||||
# Evidence ID is set but doesn't exist in case - clear it
|
||||
print(f"Warning: Active evidence not found in case. Clearing to case level.", file=sys.stderr)
|
||||
state_manager.set_active(case_id, None)
|
||||
print(f"Error: Evidence '{evidence_override}' not found in case '{case.case_number}'.", file=sys.stderr)
|
||||
print("Use --list to see available evidence.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not case_override: # Only use active evidence if not overriding case
|
||||
evidence_id = state.get("evidence_id")
|
||||
if evidence_id:
|
||||
# Find and validate evidence belongs to active case
|
||||
target_evidence = find_evidence(case, evidence_id)
|
||||
|
||||
if not target_evidence:
|
||||
# Evidence ID is set but doesn't exist in case - clear it
|
||||
print(f"Warning: Active evidence not found in case. Clearing to case level.", file=sys.stderr)
|
||||
state_manager.set_active(case.case_id, None)
|
||||
|
||||
# Create note
|
||||
note = Note(content=content)
|
||||
@@ -67,10 +272,6 @@ def quick_add_note(content: str):
|
||||
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}'")
|
||||
@@ -163,9 +364,14 @@ def export_markdown(output_file: str = "export.md"):
|
||||
sys.exit(1)
|
||||
|
||||
def format_note_for_export(note: Note) -> str:
|
||||
"""Format a single note for export (returns string instead of writing to file)"""
|
||||
"""Format a single note for export (returns string instead of writing to file)
|
||||
|
||||
Includes Unix timestamp for hash reproducibility - anyone can recompute the hash
|
||||
using the formula: SHA256("{unix_timestamp}:{content}")
|
||||
"""
|
||||
lines = []
|
||||
lines.append(f"- **{time.ctime(note.timestamp)}**\n")
|
||||
lines.append(f" - Unix Timestamp: `{note.timestamp}` (for hash verification)\n")
|
||||
lines.append(f" - Content:\n")
|
||||
# Properly indent multi-line content
|
||||
for line in note.content.splitlines():
|
||||
@@ -182,25 +388,93 @@ def format_note_for_export(note: Note) -> str:
|
||||
return "".join(lines)
|
||||
|
||||
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")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="trace: Forensic Note Taking Tool",
|
||||
epilog="Examples:\n"
|
||||
" trace 'Found suspicious process' Add note to active context\n"
|
||||
" trace --stdin < output.txt Add file contents as note\n"
|
||||
" trace --list List all cases and evidence\n"
|
||||
" trace --new-case 2024-001 Create new case\n"
|
||||
" trace --switch-case 2024-001 Switch active case\n",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
|
||||
# We will import TUI only if needed to keep start time fast
|
||||
# Note content (positional or stdin)
|
||||
parser.add_argument("note", nargs="?", help="Quick note content to add to active context")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read note content from stdin")
|
||||
|
||||
# Context management
|
||||
parser.add_argument("--show-context", action="store_true", help="Show active case and evidence")
|
||||
parser.add_argument("--list", action="store_true", help="List all cases and evidence")
|
||||
parser.add_argument("--switch-case", metavar="IDENTIFIER", help="Switch active case (by ID or case number)")
|
||||
parser.add_argument("--switch-evidence", metavar="IDENTIFIER", help="Switch active evidence (by ID or name)")
|
||||
|
||||
# Temporary overrides for note addition
|
||||
parser.add_argument("--case", metavar="IDENTIFIER", help="Use specific case for this note (doesn't change active)")
|
||||
parser.add_argument("--evidence", metavar="IDENTIFIER", help="Use specific evidence for this note (doesn't change active)")
|
||||
|
||||
# Case and evidence creation
|
||||
parser.add_argument("--new-case", metavar="CASE_NUMBER", help="Create new case")
|
||||
parser.add_argument("--name", metavar="NAME", help="Name for new case")
|
||||
parser.add_argument("--investigator", metavar="INVESTIGATOR", help="Investigator name for new case")
|
||||
parser.add_argument("--new-evidence", metavar="EVIDENCE_NAME", help="Create new evidence in active case")
|
||||
parser.add_argument("--description", metavar="DESC", help="Description for new evidence")
|
||||
|
||||
# Export
|
||||
parser.add_argument("--export", action="store_true", help="Export all data to Markdown file")
|
||||
parser.add_argument("--output", metavar="FILE", default="trace_export.md", help="Output file for export")
|
||||
|
||||
# TUI
|
||||
parser.add_argument("--open", "-o", action="store_true", help="Open TUI directly at active case/evidence")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle context management commands
|
||||
if args.show_context:
|
||||
show_context()
|
||||
return
|
||||
|
||||
if args.list:
|
||||
list_contexts()
|
||||
return
|
||||
|
||||
if args.switch_case:
|
||||
switch_case(args.switch_case)
|
||||
return
|
||||
|
||||
if args.switch_evidence:
|
||||
switch_evidence(args.switch_evidence)
|
||||
return
|
||||
|
||||
# Handle case/evidence creation
|
||||
if args.new_case:
|
||||
create_case(args.new_case, name=args.name, investigator=args.investigator)
|
||||
return
|
||||
|
||||
if args.new_evidence:
|
||||
create_evidence(args.new_evidence, description=args.description)
|
||||
return
|
||||
|
||||
# Handle export
|
||||
if args.export:
|
||||
export_markdown(args.output)
|
||||
return
|
||||
|
||||
if args.note:
|
||||
quick_add_note(args.note)
|
||||
# Handle note addition
|
||||
if args.stdin:
|
||||
# Read from stdin
|
||||
content = sys.stdin.read().strip()
|
||||
if not content:
|
||||
print("Error: No content provided from stdin.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
quick_add_note(content, case_override=args.case, evidence_override=args.evidence)
|
||||
return
|
||||
|
||||
# Check for first run and run GPG wizard if needed
|
||||
if args.note:
|
||||
quick_add_note(args.note, case_override=args.case, evidence_override=args.evidence)
|
||||
return
|
||||
|
||||
# No arguments - check for first run and launch TUI
|
||||
from .gpg_wizard import check_and_run_wizard
|
||||
check_and_run_wizard()
|
||||
|
||||
|
||||
@@ -43,12 +43,25 @@ class Crypto:
|
||||
return False, "Not a GPG signed message"
|
||||
|
||||
try:
|
||||
# Force English output for consistent parsing across locales
|
||||
# Linux/macOS: LC_ALL/LANG variables control GPG's output language
|
||||
# Windows: GPG may ignore these, but encoding='utf-8' + errors='replace' provides robustness
|
||||
import os
|
||||
env = os.environ.copy()
|
||||
# Use C.UTF-8 for English messages with UTF-8 encoding support
|
||||
# Falls back gracefully via errors='replace' if locale not available
|
||||
env['LC_ALL'] = 'C.UTF-8'
|
||||
env['LANG'] = 'C.UTF-8'
|
||||
|
||||
proc = subprocess.Popen(
|
||||
['gpg', '--verify'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace', # Handle encoding issues on any platform
|
||||
env=env
|
||||
)
|
||||
stdout, stderr = proc.communicate(input=signed_content, timeout=10)
|
||||
|
||||
@@ -61,9 +74,9 @@ class Crypto:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 2:
|
||||
signer_info = parts[1]
|
||||
break
|
||||
break # Only break after successfully extracting signer info
|
||||
elif "using" in line:
|
||||
# Try to get key ID
|
||||
# Try to get key ID as fallback
|
||||
if "key" in line.lower():
|
||||
signer_info = line.strip()
|
||||
|
||||
@@ -171,5 +184,25 @@ class Crypto:
|
||||
|
||||
@staticmethod
|
||||
def hash_content(content: str, timestamp: float) -> str:
|
||||
"""Calculate SHA256 hash of timestamp:content.
|
||||
|
||||
Hash input format: "{timestamp}:{content}"
|
||||
- timestamp: Unix epoch timestamp as float (seconds since 1970-01-01 00:00:00 UTC)
|
||||
Example: 1702345678.123456
|
||||
- The float is converted to string using Python's default str() conversion
|
||||
- Colon (':') separator between timestamp and content
|
||||
- Ensures integrity of both WHAT was said and WHEN it was said
|
||||
|
||||
Args:
|
||||
content: The note content to hash
|
||||
timestamp: Unix epoch timestamp as float
|
||||
|
||||
Returns:
|
||||
SHA256 hash as hexadecimal string (64 characters)
|
||||
|
||||
Example:
|
||||
>>> hash_content("Suspicious process detected", 1702345678.123456)
|
||||
Computes SHA256 of: "1702345678.123456:Suspicious process detected"
|
||||
"""
|
||||
data = f"{timestamp}:{content}".encode('utf-8')
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
@@ -76,27 +76,23 @@ def run_gpg_wizard():
|
||||
# Let user select a key
|
||||
selected_key = None
|
||||
|
||||
if len(keys) == 1:
|
||||
print(f"Only one key found. Using: {keys[0][1]}")
|
||||
selected_key = keys[0][0]
|
||||
else:
|
||||
while True:
|
||||
try:
|
||||
choice = input(f"Select a key (1-{len(keys)}, or 0 to use default key): ").strip()
|
||||
choice_num = int(choice)
|
||||
while True:
|
||||
try:
|
||||
choice = input(f"Select a key (1-{len(keys)}, or 0 to use default key): ").strip()
|
||||
choice_num = int(choice)
|
||||
|
||||
if choice_num == 0:
|
||||
print("Using GPG default key (no specific key ID)")
|
||||
selected_key = None
|
||||
break
|
||||
elif 1 <= choice_num <= len(keys):
|
||||
selected_key = keys[choice_num - 1][0]
|
||||
print(f"Selected: {keys[choice_num - 1][1]}")
|
||||
break
|
||||
else:
|
||||
print(f"Please enter a number between 0 and {len(keys)}")
|
||||
except ValueError:
|
||||
print("Please enter a valid number")
|
||||
if choice_num == 0:
|
||||
print("Using GPG default key (no specific key ID)")
|
||||
selected_key = None
|
||||
break
|
||||
elif 1 <= choice_num <= len(keys):
|
||||
selected_key = keys[choice_num - 1][0]
|
||||
print(f"Selected: {keys[choice_num - 1][1]}")
|
||||
break
|
||||
else:
|
||||
print(f"Please enter a number between 0 and {len(keys)}")
|
||||
except ValueError:
|
||||
print("Please enter a valid number")
|
||||
|
||||
print("\n✓ GPG signing enabled!")
|
||||
if selected_key:
|
||||
@@ -115,10 +111,9 @@ def check_and_run_wizard():
|
||||
Returns True if wizard was run, False otherwise.
|
||||
"""
|
||||
state_manager = StateManager()
|
||||
settings = state_manager.get_settings()
|
||||
|
||||
# Check if wizard has already been run (presence of any GPG setting indicates setup was done)
|
||||
if "pgp_enabled" in settings:
|
||||
# Check if settings file exists - if it does, wizard has already been run
|
||||
if state_manager.settings_file.exists():
|
||||
return False
|
||||
|
||||
# First run - run wizard
|
||||
|
||||
@@ -12,6 +12,9 @@ from .extractors import TagExtractor, IOCExtractor
|
||||
@dataclass
|
||||
class Note:
|
||||
content: str
|
||||
# Unix timestamp: seconds since 1970-01-01 00:00:00 UTC as float
|
||||
# Example: 1702345678.123456
|
||||
# This exact float value (with full precision) is used in hash calculation
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
note_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
content_hash: str = ""
|
||||
@@ -28,7 +31,16 @@ class Note:
|
||||
self.iocs = IOCExtractor.extract_iocs(self.content)
|
||||
|
||||
def calculate_hash(self):
|
||||
# We hash the content + timestamp to ensure integrity of 'when' it was said
|
||||
"""Calculate SHA256 hash of timestamp:content.
|
||||
|
||||
Hash input format: "{timestamp}:{content}"
|
||||
- timestamp: Unix epoch timestamp as float (e.g., "1702345678.123456")
|
||||
- The float is converted to string using Python's default str() conversion
|
||||
- Colon separator between timestamp and content
|
||||
- Ensures integrity of both WHAT was said and WHEN it was said
|
||||
|
||||
Example hash input: "1702345678.123456:Suspicious process detected"
|
||||
"""
|
||||
data = f"{self.timestamp}:{self.content}".encode('utf-8')
|
||||
self.content_hash = hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
@@ -222,15 +222,23 @@ class ExportHandler:
|
||||
|
||||
@staticmethod
|
||||
def _write_note_markdown(f, note: Note):
|
||||
"""Helper to write a note in markdown format"""
|
||||
"""Helper to write a note in markdown format
|
||||
|
||||
Includes Unix timestamp for hash reproducibility - anyone can recompute the hash
|
||||
using the formula: SHA256("{unix_timestamp}:{content}")
|
||||
"""
|
||||
f.write(f"- **{time.ctime(note.timestamp)}**\n")
|
||||
f.write(f" - Content: {note.content}\n")
|
||||
f.write(f" - Unix Timestamp: `{note.timestamp}` (for hash verification)\n")
|
||||
f.write(f" - Content:\n")
|
||||
# Properly indent multi-line content
|
||||
for line in note.content.splitlines():
|
||||
f.write(f" {line}\n")
|
||||
if note.tags:
|
||||
tags_str = " ".join([f"#{tag}" for tag in note.tags])
|
||||
f.write(f" - Tags: {tags_str}\n")
|
||||
f.write(f" - Hash: `{note.content_hash}`\n")
|
||||
f.write(f" - SHA256 Hash (timestamp:content): `{note.content_hash}`\n")
|
||||
if note.signature:
|
||||
f.write(" - **Signature Verified:**\n")
|
||||
f.write(" - **GPG Signature of Hash:**\n")
|
||||
f.write(" ```\n")
|
||||
for line in note.signature.splitlines():
|
||||
f.write(f" {line}\n")
|
||||
|
||||
@@ -39,5 +39,5 @@ def init_colors():
|
||||
curses.init_pair(ColorPairs.TAG, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
|
||||
# IOCs on selected background (red on cyan)
|
||||
curses.init_pair(ColorPairs.IOC_SELECTED, curses.COLOR_RED, curses.COLOR_CYAN)
|
||||
# Tags on selected background (yellow on cyan)
|
||||
curses.init_pair(ColorPairs.TAG_SELECTED, curses.COLOR_YELLOW, curses.COLOR_CYAN)
|
||||
# Tags on selected background (magenta on cyan)
|
||||
curses.init_pair(ColorPairs.TAG_SELECTED, curses.COLOR_MAGENTA, curses.COLOR_CYAN)
|
||||
|
||||
@@ -113,15 +113,15 @@ class TextRenderer:
|
||||
screen.addstr(y, x_pos, text)
|
||||
screen.attroff(curses.color_pair(ColorPairs.ERROR) | curses.A_BOLD)
|
||||
else: # tag
|
||||
# Tag highlighting: yellow on cyan if selected, yellow on black otherwise
|
||||
# Tag highlighting: magenta on cyan if selected, magenta on black otherwise
|
||||
if is_selected:
|
||||
screen.attron(curses.color_pair(ColorPairs.TAG_SELECTED))
|
||||
screen.addstr(y, x_pos, text)
|
||||
screen.attroff(curses.color_pair(ColorPairs.TAG_SELECTED))
|
||||
else:
|
||||
screen.attron(curses.color_pair(ColorPairs.WARNING))
|
||||
screen.attron(curses.color_pair(ColorPairs.TAG))
|
||||
screen.addstr(y, x_pos, text)
|
||||
screen.attroff(curses.color_pair(ColorPairs.WARNING))
|
||||
screen.attroff(curses.color_pair(ColorPairs.TAG))
|
||||
|
||||
x_pos += len(text)
|
||||
last_pos = end
|
||||
|
||||
88
trace/tui/visual_constants.py
Normal file
88
trace/tui/visual_constants.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Visual constants for consistent TUI layout and styling"""
|
||||
|
||||
|
||||
class Layout:
|
||||
"""Screen layout constants"""
|
||||
HEADER_Y = 0
|
||||
HEADER_X = 2
|
||||
CONTENT_START_Y = 2
|
||||
CONTENT_INDENT = 4
|
||||
FOOTER_OFFSET_FROM_BOTTOM = 3
|
||||
BORDER_OFFSET_FROM_BOTTOM = 2
|
||||
STATUS_LINE_OFFSET_FROM_BOTTOM = 1 # height - 1 for status bar
|
||||
NOTE_DETAIL_BOTTOM_RESERVE = 6 # height - 6 for note detail view
|
||||
|
||||
|
||||
class Spacing:
|
||||
"""Spacing and padding constants"""
|
||||
SECTION_VERTICAL_GAP = 2
|
||||
ITEM_VERTICAL_GAP = 1
|
||||
DIALOG_MARGIN = 4
|
||||
HORIZONTAL_PADDING = 6 # width - 6 for truncation
|
||||
HASH_DISPLAY_PADDING = 20 # width - 20
|
||||
HASH_SHORT_PADDING = 12 # width - 12 for shorter hash displays
|
||||
EMPTY_STATE_PADDING = 8 # width - 8 for empty state boxes
|
||||
STATUS_BAR_PADDING = 2 # width - 2 for status bar
|
||||
|
||||
|
||||
class ColumnWidths:
|
||||
"""Column widths for list displays - can be percentage-based"""
|
||||
TAG_COLUMN_MIN = 30
|
||||
IOC_COLUMN_MIN = 50
|
||||
CONTENT_PREVIEW_MIN = 50
|
||||
NOTE_PREVIEW_MIN = 60
|
||||
|
||||
@staticmethod
|
||||
def get_tag_width(terminal_width):
|
||||
"""Get responsive tag column width (40% of terminal or min 30)"""
|
||||
return max(ColumnWidths.TAG_COLUMN_MIN, int(terminal_width * 0.4))
|
||||
|
||||
@staticmethod
|
||||
def get_ioc_width(terminal_width):
|
||||
"""Get responsive IOC column width (50% of terminal or min 50)"""
|
||||
return max(ColumnWidths.IOC_COLUMN_MIN, int(terminal_width * 0.5))
|
||||
|
||||
@staticmethod
|
||||
def get_content_preview_width(terminal_width):
|
||||
"""Get responsive content preview width (50% of terminal or min 50)"""
|
||||
return max(ColumnWidths.CONTENT_PREVIEW_MIN, int(terminal_width * 0.5))
|
||||
|
||||
|
||||
class DialogSize:
|
||||
"""Standard dialog dimensions (width, height)"""
|
||||
SMALL = (40, 8) # Confirm dialogs
|
||||
MEDIUM = (60, 15) # Settings, single input
|
||||
LARGE = (70, 20) # Multiline, help
|
||||
|
||||
|
||||
class Icons:
|
||||
"""Unicode symbols used throughout UI"""
|
||||
ACTIVE = "●"
|
||||
INACTIVE = "○"
|
||||
DIAMOND = "◆"
|
||||
SQUARE = "■"
|
||||
SMALL_SQUARE = "▪"
|
||||
ARROW_RIGHT = "▸"
|
||||
WARNING = "⚠"
|
||||
HASH = "⌗"
|
||||
FILTER = "◈"
|
||||
VERIFIED = "✓"
|
||||
FAILED = "✗"
|
||||
UNSIGNED = "?"
|
||||
SEPARATOR_H = "─"
|
||||
SEPARATOR_V = "│"
|
||||
SEPARATOR_GROUP = "│" # For grouping footer commands
|
||||
BOX_TL = "┌"
|
||||
BOX_BL = "└"
|
||||
# Box drawing for improved empty states
|
||||
BOX_DOUBLE_TL = "╔"
|
||||
BOX_DOUBLE_TR = "╗"
|
||||
BOX_DOUBLE_BL = "╚"
|
||||
BOX_DOUBLE_BR = "╝"
|
||||
BOX_DOUBLE_H = "═"
|
||||
BOX_DOUBLE_V = "║"
|
||||
|
||||
|
||||
class Timing:
|
||||
"""Timing constants"""
|
||||
FLASH_MESSAGE_DURATION = 3 # seconds
|
||||
600
trace/tui_app.py
600
trace/tui_app.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user