6 Commits

Author SHA1 Message Date
overcuriousity
dc16a16d49 Merge pull request #1 from overcuriousity/claude/add-install-instructions-015g7n4vPZWeuAUYrHmM74hU
Add binary installation instructions to README
2025-12-12 12:30:51 +01:00
Claude
e4976c81f9 Add optional ultra-fast alias setup for quick logging 2025-12-12 11:28:08 +00:00
Claude
b627f92172 Add installation instructions for latest release binaries 2025-12-12 11:24:52 +00:00
overcuriousity
4c99013426 disclaimer
Added a disclaimer about the coding process and agents used.
2025-12-12 10:21:12 +00:00
overcuriousity
f80a343610 clearer readme
Updated README to reflect new project name and features.
2025-12-12 10:13:41 +00:00
overcuriousity
e1886edee1 bug fixes 2025-12-12 10:26:27 +01:00
3 changed files with 308 additions and 302 deletions

240
README.md
View File

@@ -1,158 +1,172 @@
# trace - Forensic Notes # trace - Digital Evidence Log Utility
`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. `trace` is a bare-bones, terminal-centric note-taking utility for digital forensics and incident response. It is designed for maximum operational efficiency, ensuring that the integrity of your log data is never compromised by the need to slow down.
## Features This tool mandates minimal system overhead, relying solely on standard libraries where possible.
* **Integrity Focused:** ## ⚡ Key Feature: Hot Logging (CLI Shorthand)
* **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 The primary operational benefit of `trace` is its ability to accept input directly from the command line, bypassing the full interface. Once your active target context is set, you can drop notes instantly.
### From Source **Configuration:** Use the TUI to set a Case or Evidence ID as "Active" (`a`). This state persists across sessions.
Requires Python 3.x. **Syntax for Data Injection:**
```bash ```bash
git clone <repository_url> # Log an immediate status update
cd trace trace "IR team gained shell access. Initial persistence checks running."
# Run directly
python3 main.py # Log data and tag it for later triage
trace "Observed outbound connection to 192.168.1.55 on port 80. #suspicious #network"
``` ```
### Building Binary **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.
You can build a single-file executable using PyInstaller. ## Installation & Deployment
#### Linux/macOS ### Quick Install from Latest Release
**Linux / macOS:**
```bash
curl -L https://github.com/overcuriousity/trace/releases/latest/download/trace -o trace && sudo mv trace /usr/local/bin/ && sudo chmod +x /usr/local/bin/trace
```
**Windows (PowerShell):**
```powershell
Invoke-WebRequest -Uri "https://github.com/overcuriousity/trace/releases/latest/download/trace.exe" -OutFile "$env:USERPROFILE\bin\trace.exe"; [Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:USERPROFILE\bin", "User")
```
*Note: Create `$env:USERPROFILE\bin` directory first if it doesn't exist, then restart your shell.*
**Optional: Create Ultra-Fast Alias**
For maximum speed when logging, create a single-character alias:
**Linux / macOS (Bash):**
```bash
echo 'alias t="trace"' >> ~/.bashrc && source ~/.bashrc
```
**Linux / macOS (Zsh):**
```bash
echo 'alias t="trace"' >> ~/.zshrc && source ~/.zshrc
```
**Windows (PowerShell):**
```powershell
New-Item -ItemType File -Force $PROFILE; Add-Content $PROFILE 'function t { trace $args }'; . $PROFILE
```
After this, you can log with just: `t "Your note here"`
---
### Platform: Linux / UNIX (including macOS)
**Prerequisites:** Python 3.x and the binary build utility (PyInstaller).
**Deployment:**
1. **Build Binary:** Execute the build script in the source directory.
```bash ```bash
pip install -r requirements.txt
./build_binary.sh ./build_binary.sh
# Binary will be in dist/trace
./dist/trace
``` ```
#### Windows *The output executable will land in `dist/trace`.*
```powershell 2. **Path Integration:** For universal access, the binary must reside in a directory referenced by your `$PATH` environment variable (e.g., `/usr/local/bin`).
# 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 ```bash
# Option 1: Copy to /usr/local/bin (requires sudo) # Place executable in system path
sudo cp dist/trace /usr/local/bin/ sudo mv dist/trace /usr/local/bin/
# Option 2: Copy to ~/.local/bin (user-only, ensure ~/.local/bin is in PATH) # Ensure execute bit is set
mkdir -p ~/.local/bin sudo chmod +x /usr/local/bin/trace
cp dist/trace ~/.local/bin/
# Add to PATH if not already (add to ~/.bashrc or ~/.zshrc)
export PATH="$HOME/.local/bin:$PATH"
``` ```
#### Windows You are now cleared to run `trace` from any shell prompt.
### Platform: Windows
**Prerequisites:** Python 3.x, `pyinstaller`, and the `windows-curses` library.
**Deployment:**
1. **Build Binary:** Run the build command in a PowerShell or CMD environment.
```powershell ```powershell
# Option 1: Copy to a directory in PATH (e.g., C:\Windows\System32 - requires admin) pyinstaller --onefile --name trace --clean --paths . --hidden-import curses main.py
Copy-Item dist\trace.exe C:\Windows\System32\ ```
# Option 2: Create a local bin directory and add to PATH *The executable is located at `dist\trace.exe`.*
# 1. Create directory
2. **Path Integration:** The executable must be accessible via your user or system `%PATH%` variable for the hot-logging feature to function correctly.
*Option A: System Directory (Requires Administrator Privilege)*
```powershell
Copy-Item dist\trace.exe C:\Windows\System32\
```
*Option B: User-Defined Bin Directory (Recommended)*
```powershell
# Create the user bin location
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\bin" New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\bin"
Copy-Item dist\trace.exe "$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) # Inject the directory into the User PATH variable
[Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:USERPROFILE\bin", "User") [Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:USERPROFILE\bin", "User")
# 3. Restart terminal/PowerShell for changes to take effect
``` ```
## Usage **ATTENTION:** You must cycle your command shell (exit and reopen) before the `trace` command will resolve correctly.
### TUI Mode ## Core Feature Breakdown
Run `trace` without arguments to open the interface.
**Navigation:** | Feature | Description | Operational Impact |
* `Arrow Keys`: Navigate lists. | :--- | :--- | :--- |
* `Enter`: Select Case / View Evidence details. | **Integrity Hashing** | SHA256 applied to every log entry (content + timestamp). | **Guaranteed log integrity.** No modification possible post-entry. |
* `b`: Back. | **GPG Signing** | Optional PGP/GPG signature applied to notes. | **Non-repudiation** for formal evidence handling. |
* `q`: Quit. | **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. |
| **Minimal Footprint** | Built solely on Python standard library modules. | **Maximum portability** on restricted forensic environments. |
**Management:** ## TUI Reference (Management Console)
* `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**. View all notes for the current Case or Evidence in a scrollable full-screen view.
* **IOC Highlighting**: All IOCs in notes are automatically highlighted in red for immediate visibility.
* **Tag Highlighting**: Hashtags are highlighted in cyan.
* Press `Enter` on any note in case/evidence detail view to jump directly to that note in the full view.
* The selected note will be centered and highlighted.
* Navigate with arrow keys, Page Up/Down, Home/End.
* Press `n` to add a new note without leaving the 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 Execute `trace` (no arguments) to enter the Text User Interface. This environment is used for setup, review, and reporting.
Once a Case or Evidence is set as **Active** in the TUI, you can add notes directly from the command line:
```bash | Key | Function | Detail |
trace "Suspect system is powered on, attempting live memory capture." | :--- | :--- | :--- |
``` | `a` | **Set Active** | Designate the current item as the target for CLI injection (hot-logging). |
| `n` | **New Note** | Enter the multi-line log editor. Use $\text{Ctrl+G}$ to save block. |
| `i` | **IOC Index** | View extracted indicators. Option to export IOC list (`e`). |
| `t` | **Tag Index** | View classification tags and filter notes by frequency. |
| `v` | **Full View** | Scrollable screen showing all log entries with automatic IOC/Tag highlighting. |
| `/` | **Filter** | Initiate text-based search/filter on lists. |
| $\text{Enter}$ | **Drill Down** | Access details for Case or Evidence. |
| `q` | **Exit** | Close the application. |
This note is automatically timestamped, hashed, signed, and appended to the active context. ## Report Generation
### Exporting To generate the Markdown report package, use the `--export` flag.
To generate a report:
```bash ```bash
trace --export trace --export
# Creates trace_export.md # Creates trace_export.md in the current directory.
``` ```
## Data Storage ## Data Persistence
Data is stored in JSON format at `~/.trace/data.json`.
Application state (active context) is stored at `~/.trace/state`.
## License Trace maintains a simple flat-file structure in the user's home directory.
MIT
* `~/.trace/data.json`: Case log repository.
* `~/.trace/state`: Active context pointer.
-----
*License: MIT*
**DISCLAIMER**
This program was mostly vibe-coded. This was a deliberate decision as I wanted to focus on producing a usable result with okay user experience rather than implementation details and educating myself by lengthy coding sessions.
I reviewed sections of the code manually and found no issues. The application should be safe to use from a integrity, security and admissability standpoint, while I wont ever make any warranties on this.
The coding agents I mostly used were in this order: Claude Sonnett 45 (CLI), Claude Haiku 4.5 (VSCode Copilot), Google Jules (version unknown).

View File

@@ -161,6 +161,47 @@ class Note:
return iocs return iocs
@staticmethod
def extract_iocs_with_positions(text):
"""Extract IOCs with their positions for highlighting. Returns list of (text, start, end, type) tuples"""
import re
highlights = []
# IPv4 addresses
for match in re.finditer(r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'ipv4'))
# IPv6 addresses
for match in re.finditer(r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'ipv6'))
# URLs (check before domains)
for match in re.finditer(r'https?://[^\s]+', text):
highlights.append((match.group(), match.start(), match.end(), 'url'))
# Domain names
for match in re.finditer(r'\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b', text):
if not match.group().startswith('example.'):
highlights.append((match.group(), match.start(), match.end(), 'domain'))
# SHA256 hashes
for match in re.finditer(r'\b[a-fA-F0-9]{64}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'sha256'))
# SHA1 hashes
for match in re.finditer(r'\b[a-fA-F0-9]{40}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'sha1'))
# MD5 hashes
for match in re.finditer(r'\b[a-fA-F0-9]{32}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'md5'))
# Email addresses
for match in re.finditer(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'email'))
return highlights
def to_dict(self): def to_dict(self):
return { return {
"note_id": self.note_id, "note_id": self.note_id,

View File

@@ -57,6 +57,10 @@ class TUI:
curses.init_pair(7, curses.COLOR_BLUE, curses.COLOR_BLACK) curses.init_pair(7, curses.COLOR_BLUE, curses.COLOR_BLACK)
# Tags (magenta) # Tags (magenta)
curses.init_pair(8, curses.COLOR_MAGENTA, curses.COLOR_BLACK) curses.init_pair(8, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
# IOCs on selected background (red on cyan)
curses.init_pair(9, curses.COLOR_RED, curses.COLOR_CYAN)
# Tags on selected background (yellow on cyan)
curses.init_pair(10, curses.COLOR_YELLOW, curses.COLOR_CYAN)
self.height, self.width = stdscr.getmaxyx() self.height, self.width = stdscr.getmaxyx()
@@ -257,6 +261,102 @@ class TUI:
return ellipsis[:max_width] return ellipsis[:max_width]
def _display_line_with_highlights(self, y, x_start, line, is_selected=False, win=None):
"""
Display a line with intelligent highlighting.
- IOCs are highlighted with color_pair(4) (red)
- Tags are highlighted with color_pair(3) (yellow)
- Selection background is color_pair(1) (cyan) for non-IOC text
- IOC highlighting takes priority over selection
"""
import re
from .models import Note
# Use provided window or default to main screen
screen = win if win is not None else self.stdscr
# Extract IOCs and tags
highlights = []
# Get IOCs with positions
for text, start, end, ioc_type in Note.extract_iocs_with_positions(line):
highlights.append((text, start, end, 'ioc'))
# Get tags
for match in re.finditer(r'#\w+', line):
highlights.append((match.group(), match.start(), match.end(), 'tag'))
# Sort by position and remove overlaps (IOCs take priority over tags)
highlights.sort(key=lambda x: x[1])
deduplicated = []
last_end = -1
for text, start, end, htype in highlights:
if start >= last_end:
deduplicated.append((text, start, end, htype))
last_end = end
highlights = deduplicated
if not highlights:
# No highlights - use selection color if selected
if is_selected:
screen.attron(curses.color_pair(1))
screen.addstr(y, x_start, line)
screen.attroff(curses.color_pair(1))
else:
screen.addstr(y, x_start, line)
return
# Display with intelligent highlighting
x_pos = x_start
last_pos = 0
for text, start, end, htype in highlights:
# Add text before this highlight
if start > last_pos:
text_before = line[last_pos:start]
if is_selected:
screen.attron(curses.color_pair(1))
screen.addstr(y, x_pos, text_before)
screen.attroff(curses.color_pair(1))
else:
screen.addstr(y, x_pos, text_before)
x_pos += len(text_before)
# Add highlighted text
if htype == 'ioc':
# IOC highlighting: red on cyan if selected, red on black otherwise
if is_selected:
screen.attron(curses.color_pair(9) | curses.A_BOLD)
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(9) | curses.A_BOLD)
else:
screen.attron(curses.color_pair(4) | curses.A_BOLD)
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(4) | curses.A_BOLD)
else: # tag
# Tag highlighting: yellow on cyan if selected, yellow on black otherwise
if is_selected:
screen.attron(curses.color_pair(10))
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(10))
else:
screen.attron(curses.color_pair(3))
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(3))
x_pos += len(text)
last_pos = end
# Add remaining text
if last_pos < len(line):
text_after = line[last_pos:]
if is_selected:
screen.attron(curses.color_pair(1))
screen.addstr(y, x_pos, text_after)
screen.attroff(curses.color_pair(1))
else:
screen.addstr(y, x_pos, text_after)
def draw_header(self): def draw_header(self):
# Modern header with icon and better styling # Modern header with icon and better styling
title = "◆ trace" title = "◆ trace"
@@ -538,10 +638,17 @@ class TUI:
self._update_scroll(total_items) self._update_scroll(total_items)
# Calculate which evidence items to display # Calculate which evidence items to display
# If selecting evidence, scroll just enough to keep it visible
# If selecting a case note, show evidence from the beginning # If selecting a case note, show evidence from the beginning
# If selecting evidence, scroll to show the selected evidence
if selecting_evidence: if selecting_evidence:
evidence_scroll_offset = max(0, self.selected_index - evidence_space // 2) # Keep selection visible: scroll up if needed, scroll down if needed
if self.selected_index < 0:
evidence_scroll_offset = 0
elif self.selected_index >= evidence_space:
# Scroll down only as much as needed to show the selected item at the bottom
evidence_scroll_offset = self.selected_index - evidence_space + 1
else:
evidence_scroll_offset = 0
else: else:
evidence_scroll_offset = 0 evidence_scroll_offset = 0
@@ -668,14 +775,10 @@ class TUI:
display_str = f"- {note_content}" display_str = f"- {note_content}"
display_str = self._safe_truncate(display_str, self.width - 6) display_str = self._safe_truncate(display_str, self.width - 6)
# Highlight if selected # Display with smart highlighting (IOCs take priority over selection)
item_idx = len(evidence_list) + note_idx item_idx = len(evidence_list) + note_idx
if item_idx == self.selected_index: is_selected = (item_idx == self.selected_index)
self.stdscr.attron(curses.color_pair(1)) self._display_line_with_highlights(y, 4, display_str, is_selected)
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(y, 4, display_str)
self.stdscr.addstr(self.height - 3, 2, "[N] New Evidence [n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete [?] Help", curses.color_pair(3)) self.stdscr.addstr(self.height - 3, 2, "[N] New Evidence [n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete [?] Help", curses.color_pair(3))
@@ -733,13 +836,9 @@ class TUI:
# Truncate safely for Unicode # Truncate safely for Unicode
display_str = self._safe_truncate(display_str, self.width - 6) display_str = self._safe_truncate(display_str, self.width - 6)
# Highlight selected note # Display with smart highlighting (IOCs take priority over selection)
if idx == self.selected_index: is_selected = (idx == self.selected_index)
self.stdscr.attron(curses.color_pair(1)) self._display_line_with_highlights(start_y + i, 4, display_str, is_selected)
self.stdscr.addstr(start_y + i, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(start_y + i, 4, display_str)
self.stdscr.addstr(self.height - 3, 2, "[n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete Note [?] Help", curses.color_pair(3)) self.stdscr.addstr(self.height - 3, 2, "[n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete Note [?] Help", curses.color_pair(3))
@@ -928,68 +1027,10 @@ class TUI:
# Highlight both tags and IOCs in the content # Highlight both tags and IOCs in the content
display_line = self._safe_truncate(line, self.width - 6) display_line = self._safe_truncate(line, self.width - 6)
x_pos = 4
# Extract IOCs and tags from the line # Display with highlighting (no selection in detail view)
from .models import Note
import re
iocs_found = Note.extract_iocs_from_text(display_line)
tags_pattern = r'#\w+'
tags_found = [(match.group(), match.start()) for match in re.finditer(tags_pattern, display_line)]
# Combine IOCs and tags into a list of (text, start_pos, type)
highlights = []
for ioc, _ in iocs_found:
pos = display_line.find(ioc)
if pos != -1:
highlights.append((ioc, pos, 'ioc'))
for tag, pos in tags_found:
highlights.append((tag, pos, 'tag'))
# Sort by position
highlights.sort(key=lambda x: x[1])
if highlights:
# Display with highlighting
remaining = display_line
for i, (text, orig_pos, htype) in enumerate(highlights):
# Find position in remaining text
pos = remaining.find(text)
if pos == -1:
continue
# Print text before highlight
if pos > 0:
try: try:
self.stdscr.addstr(current_y, x_pos, remaining[:pos]) self._display_line_with_highlights(current_y, 4, display_line, is_selected=False)
x_pos += pos
except curses.error:
break
# Print highlighted text
try:
if htype == 'ioc':
self.stdscr.addstr(current_y, x_pos, text, curses.color_pair(4))
else: # tag
self.stdscr.addstr(current_y, x_pos, text, curses.color_pair(3))
x_pos += len(text)
except curses.error:
break
# Update remaining text
remaining = remaining[pos + len(text):]
# Print any remaining text
if remaining and x_pos < self.width - 2:
try:
self.stdscr.addstr(current_y, x_pos, remaining[:self.width - x_pos - 2])
except curses.error:
pass
else:
# No highlights, display normally
try:
self.stdscr.addstr(current_y, x_pos, display_line)
except curses.error: except curses.error:
pass pass
@@ -1270,23 +1311,9 @@ class TUI:
case_notes = self.active_case.notes case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description") filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Check if a note is selected
if self.selected_index < len(case_notes):
# Open notes view and jump to selected note
self._highlight_note_idx = self.selected_index
self.view_case_notes(highlight_note_index=self.selected_index)
delattr(self, '_highlight_note_idx')
elif self.selected_index - len(case_notes) < len(filtered):
# Evidence selected - open it
evidence_idx = self.selected_index - len(case_notes)
self.active_evidence = filtered[evidence_idx]
self.current_view = "evidence_detail"
self.selected_index = 0
self.scroll_offset = 0
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Check if selecting evidence or note # Check if selecting evidence or note
# Evidence items come first (indices 0 to len(filtered)-1)
# Case notes come second (indices len(filtered) to len(filtered)+len(case_notes)-1)
if self.selected_index < len(filtered): if self.selected_index < len(filtered):
# Selected evidence - navigate to evidence detail # Selected evidence - navigate to evidence detail
self.active_evidence = filtered[self.selected_index] self.active_evidence = filtered[self.selected_index]
@@ -2116,14 +2143,10 @@ class TUI:
return return
name = self._input_dialog("New Case - Step 2/3", "Enter descriptive name (optional):") name = self._input_dialog("New Case - Step 2/3", "Enter descriptive name (optional):")
if name is None: # For optional fields, treat None as empty string (user pressed Enter on empty field)
self.show_message("Case creation cancelled.")
return
investigator = self._input_dialog("New Case - Step 3/3", "Enter investigator name (optional):") investigator = self._input_dialog("New Case - Step 3/3", "Enter investigator name (optional):")
if investigator is None: # For optional fields, treat None as empty string (user pressed Enter on empty field)
self.show_message("Case creation cancelled.")
return
case = Case(case_number=case_num, name=name or "", investigator=investigator or "") case = Case(case_number=case_num, name=name or "", investigator=investigator or "")
self.storage.add_case(case) self.storage.add_case(case)
@@ -2336,6 +2359,7 @@ class TUI:
while True: while True:
win = curses.newwin(h, w, y, x) win = curses.newwin(h, w, y, x)
win.keypad(True) win.keypad(True)
win.timeout(25) # 25ms timeout makes ESC responsive
win.box() win.box()
win.addstr(1, 2, f"Notes: {self.active_case.case_number} ({len(self.active_case.notes)} total)", curses.A_BOLD) win.addstr(1, 2, f"Notes: {self.active_case.case_number} ({len(self.active_case.notes)} total)", curses.A_BOLD)
@@ -2393,48 +2417,8 @@ class TUI:
try: try:
y_pos = 3 + i y_pos = 3 + i
if is_highlighted: # Use unified highlighting function
# Highlight entire line for selected note self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
win.addstr(y_pos, 2, display_line, curses.color_pair(1))
else:
# Check for IOCs in the line and highlight them
from .models import Note
iocs_found = Note.extract_iocs_from_text(display_line)
if iocs_found:
# Display with IOC highlighting
x_pos = 2
remaining = display_line
while iocs_found and remaining:
# Find the earliest IOC in the remaining text
earliest_ioc = None
earliest_pos = len(remaining)
for ioc, _ in iocs_found:
pos = remaining.find(ioc)
if pos != -1 and pos < earliest_pos:
earliest_pos = pos
earliest_ioc = ioc
if earliest_ioc:
# Print text before IOC
if earliest_pos > 0:
win.addstr(y_pos, x_pos, remaining[:earliest_pos])
x_pos += earliest_pos
# Print IOC in color
win.addstr(y_pos, x_pos, earliest_ioc, curses.color_pair(4))
x_pos += len(earliest_ioc)
# Update remaining text
remaining = remaining[earliest_pos + len(earliest_ioc):]
# Remove found IOC from list
iocs_found = [(ioc, t) for ioc, t in iocs_found if ioc != earliest_ioc]
else:
break
# Print any remaining text
if remaining:
win.addstr(y_pos, x_pos, remaining)
else:
# No IOCs, display normally
win.addstr(y_pos, 2, display_line)
except curses.error: except curses.error:
pass pass
@@ -2449,6 +2433,9 @@ class TUI:
win.addstr(h-2, 2, "[↑↓] Scroll [n] Add Note [b/q/Esc] Close", curses.color_pair(3)) win.addstr(h-2, 2, "[↑↓] Scroll [n] Add Note [b/q/Esc] Close", curses.color_pair(3))
win.refresh() win.refresh()
key = win.getch() key = win.getch()
if key == -1: # timeout, redraw
del win
continue
del win del win
# Handle key presses # Handle key presses
@@ -2488,6 +2475,7 @@ class TUI:
while True: while True:
win = curses.newwin(h, w, y, x) win = curses.newwin(h, w, y, x)
win.keypad(True) win.keypad(True)
win.timeout(25) # 25ms timeout makes ESC responsive
win.box() win.box()
win.addstr(1, 2, f"Notes: {self.active_evidence.name} ({len(self.active_evidence.notes)} total)", curses.A_BOLD) win.addstr(1, 2, f"Notes: {self.active_evidence.name} ({len(self.active_evidence.notes)} total)", curses.A_BOLD)
@@ -2545,48 +2533,8 @@ class TUI:
try: try:
y_pos = 3 + i y_pos = 3 + i
if is_highlighted: # Use unified highlighting function
# Highlight entire line for selected note self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
win.addstr(y_pos, 2, display_line, curses.color_pair(1))
else:
# Check for IOCs in the line and highlight them
from .models import Note
iocs_found = Note.extract_iocs_from_text(display_line)
if iocs_found:
# Display with IOC highlighting
x_pos = 2
remaining = display_line
while iocs_found and remaining:
# Find the earliest IOC in the remaining text
earliest_ioc = None
earliest_pos = len(remaining)
for ioc, _ in iocs_found:
pos = remaining.find(ioc)
if pos != -1 and pos < earliest_pos:
earliest_pos = pos
earliest_ioc = ioc
if earliest_ioc:
# Print text before IOC
if earliest_pos > 0:
win.addstr(y_pos, x_pos, remaining[:earliest_pos])
x_pos += earliest_pos
# Print IOC in color
win.addstr(y_pos, x_pos, earliest_ioc, curses.color_pair(4))
x_pos += len(earliest_ioc)
# Update remaining text
remaining = remaining[earliest_pos + len(earliest_ioc):]
# Remove found IOC from list
iocs_found = [(ioc, t) for ioc, t in iocs_found if ioc != earliest_ioc]
else:
break
# Print any remaining text
if remaining:
win.addstr(y_pos, x_pos, remaining)
else:
# No IOCs, display normally
win.addstr(y_pos, 2, display_line)
except curses.error: except curses.error:
pass pass
@@ -2601,6 +2549,9 @@ class TUI:
win.addstr(h-2, 2, "[↑↓] Scroll [n] Add Note [b/q/Esc] Close", curses.color_pair(3)) win.addstr(h-2, 2, "[↑↓] Scroll [n] Add Note [b/q/Esc] Close", curses.color_pair(3))
win.refresh() win.refresh()
key = win.getch() key = win.getch()
if key == -1: # timeout, redraw
del win
continue
del win del win
# Handle key presses # Handle key presses