diff --git a/CLAUDE.md b/CLAUDE.md index e7b5539..939496a 100644 --- a/CLAUDE.md +++ b/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 diff --git a/README.md b/README.md index 3d8c2de..e8da9a5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,76 @@ trace "Observed outbound connection to 192.168.1.55 on port 80. #suspicious #net **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. +## 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 ### Quick Install from Latest Release diff --git a/trace/cli.py b/trace/cli.py index 821d09b..79a6f64 100644 --- a/trace/cli.py +++ b/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}'") @@ -182,25 +383,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()