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

248
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:**
* **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.
## ⚡ Key Feature: Hot Logging (CLI Shorthand)
## 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
git clone <repository_url>
cd trace
# Run directly
python3 main.py
# Log an immediate status update
trace "IR team gained shell access. Initial persistence checks running."
# 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
pip install -r requirements.txt
./build_binary.sh
# Binary will be in dist/trace
./dist/trace
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
**Windows (PowerShell):**
```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
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.*
### Installing to PATH
**Optional: Create Ultra-Fast Alias**
After building the binary, you can install it to your system PATH for easy access:
#### Linux/macOS
For maximum speed when logging, create a single-character alias:
**Linux / macOS (Bash):**
```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"
echo 'alias t="trace"' >> ~/.bashrc && source ~/.bashrc
```
#### Windows
**Linux / macOS (Zsh):**
```bash
echo 'alias t="trace"' >> ~/.zshrc && source ~/.zshrc
```
**Windows (PowerShell):**
```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
New-Item -ItemType File -Force $PROFILE; Add-Content $PROFILE 'function t { trace $args }'; . $PROFILE
```
## Usage
After this, you can log with just: `t "Your note here"`
### 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.
### Platform: Linux / UNIX (including macOS)
**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**. 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.
**Prerequisites:** Python 3.x and the binary build utility (PyInstaller).
### CLI Mode
Once a Case or Evidence is set as **Active** in the TUI, you can add notes directly from the command line:
**Deployment:**
```bash
trace "Suspect system is powered on, attempting live memory capture."
```
1. **Build Binary:** Execute the build script in the source directory.
This note is automatically timestamped, hashed, signed, and appended to the active context.
```bash
./build_binary.sh
```
### Exporting
To generate a report:
*The output executable will land in `dist/trace`.*
2. **Path Integration:** For universal access, the binary must reside in a directory referenced by your `$PATH` environment variable (e.g., `/usr/local/bin`).
```bash
# Place executable in system path
sudo mv dist/trace /usr/local/bin/
# Ensure execute bit is set
sudo chmod +x /usr/local/bin/trace
```
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
pyinstaller --onefile --name trace --clean --paths . --hidden-import curses main.py
```
*The executable is located at `dist\trace.exe`.*
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"
Copy-Item dist\trace.exe "$env:USERPROFILE\bin\"
# Inject the directory into the User PATH variable
[Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:USERPROFILE\bin", "User")
```
**ATTENTION:** You must cycle your command shell (exit and reopen) before the `trace` command will resolve correctly.
## Core Feature Breakdown
| Feature | Description | Operational Impact |
| :--- | :--- | :--- |
| **Integrity Hashing** | SHA256 applied to every log entry (content + timestamp). | **Guaranteed log integrity.** No modification possible post-entry. |
| **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. |
| **Minimal Footprint** | Built solely on Python standard library modules. | **Maximum portability** on restricted forensic environments. |
## TUI Reference (Management Console)
Execute `trace` (no arguments) to enter the Text User Interface. This environment is used for setup, review, and reporting.
| Key | Function | Detail |
| :--- | :--- | :--- |
| `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. |
## Report Generation
To generate the Markdown report package, use the `--export` flag.
```bash
trace --export
# Creates trace_export.md
# Creates trace_export.md in the current directory.
```
## Data Storage
Data is stored in JSON format at `~/.trace/data.json`.
Application state (active context) is stored at `~/.trace/state`.
## Data Persistence
## License
MIT
Trace maintains a simple flat-file structure in the user's home directory.
* `~/.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
@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):
return {
"note_id": self.note_id,

View File

@@ -57,6 +57,10 @@ class TUI:
curses.init_pair(7, curses.COLOR_BLUE, curses.COLOR_BLACK)
# Tags (magenta)
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()
@@ -257,6 +261,102 @@ class TUI:
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):
# Modern header with icon and better styling
title = "◆ trace"
@@ -538,10 +638,17 @@ class TUI:
self._update_scroll(total_items)
# 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 evidence, scroll to show the selected 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:
evidence_scroll_offset = 0
@@ -668,14 +775,10 @@ class TUI:
display_str = f"- {note_content}"
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
if item_idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(y, 4, display_str)
is_selected = (item_idx == self.selected_index)
self._display_line_with_highlights(y, 4, display_str, is_selected)
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
display_str = self._safe_truncate(display_str, self.width - 6)
# Highlight selected note
if idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
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)
# Display with smart highlighting (IOCs take priority over selection)
is_selected = (idx == self.selected_index)
self._display_line_with_highlights(start_y + i, 4, display_str, is_selected)
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
display_line = self._safe_truncate(line, self.width - 6)
x_pos = 4
# Extract IOCs and tags from the line
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:
# Display with highlighting (no selection in detail view)
try:
self.stdscr.addstr(current_y, x_pos, remaining[:pos])
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)
self._display_line_with_highlights(current_y, 4, display_line, is_selected=False)
except curses.error:
pass
@@ -1270,23 +1311,9 @@ class TUI:
case_notes = self.active_case.notes
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
# 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):
# Selected evidence - navigate to evidence detail
self.active_evidence = filtered[self.selected_index]
@@ -2116,14 +2143,10 @@ class TUI:
return
name = self._input_dialog("New Case - Step 2/3", "Enter descriptive name (optional):")
if name is None:
self.show_message("Case creation cancelled.")
return
# For optional fields, treat None as empty string (user pressed Enter on empty field)
investigator = self._input_dialog("New Case - Step 3/3", "Enter investigator name (optional):")
if investigator is None:
self.show_message("Case creation cancelled.")
return
# For optional fields, treat None as empty string (user pressed Enter on empty field)
case = Case(case_number=case_num, name=name or "", investigator=investigator or "")
self.storage.add_case(case)
@@ -2336,6 +2359,7 @@ class TUI:
while True:
win = curses.newwin(h, w, y, x)
win.keypad(True)
win.timeout(25) # 25ms timeout makes ESC responsive
win.box()
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:
y_pos = 3 + i
if is_highlighted:
# Highlight entire line for selected note
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)
# Use unified highlighting function
self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
except curses.error:
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.refresh()
key = win.getch()
if key == -1: # timeout, redraw
del win
continue
del win
# Handle key presses
@@ -2488,6 +2475,7 @@ class TUI:
while True:
win = curses.newwin(h, w, y, x)
win.keypad(True)
win.timeout(25) # 25ms timeout makes ESC responsive
win.box()
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:
y_pos = 3 + i
if is_highlighted:
# Highlight entire line for selected note
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)
# Use unified highlighting function
self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
except curses.error:
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.refresh()
key = win.getch()
if key == -1: # timeout, redraw
del win
continue
del win
# Handle key presses