mirror of
https://github.com/overcuriousity/trace.git
synced 2025-12-21 13:32:20 +00:00
Compare commits
11 Commits
v0.0.0-alp
...
v0.1.1-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc16a16d49 | ||
|
|
e4976c81f9 | ||
|
|
b627f92172 | ||
|
|
4c99013426 | ||
|
|
f80a343610 | ||
|
|
e1886edee1 | ||
|
|
aa0f67f1fc | ||
|
|
dc8bd777ef | ||
|
|
89e7b20694 | ||
|
|
ba1fff36f2 | ||
|
|
e37597c315 |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -27,14 +27,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Linux binary
|
- name: Build Linux binary
|
||||||
run: |
|
run: |
|
||||||
pyinstaller --onefile --name trace-linux main.py
|
pyinstaller --onefile --name trace main.py
|
||||||
|
|
||||||
- name: Upload Linux binary to release
|
- name: Upload Linux binary to release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: ./dist/trace-linux
|
files: ./dist/trace
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
@@ -55,11 +55,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Windows executable
|
- name: Build Windows executable
|
||||||
run: |
|
run: |
|
||||||
pyinstaller --onefile --name trace-windows main.py
|
pyinstaller --onefile --name trace main.py
|
||||||
|
|
||||||
- name: Upload Windows executable to release
|
- name: Upload Windows executable to release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: ./dist/trace-windows.exe
|
files: ./dist/trace.exe
|
||||||
|
|||||||
234
README.md
234
README.md
@@ -1,152 +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 for the current Case (in Case Detail view).
|
|
||||||
* `/`: Filter list (type to search, `Esc` or `Enter` to exit filter mode).
|
|
||||||
* `s`: Settings menu (in Case List view).
|
|
||||||
* `Esc`: Cancel during input dialogs.
|
|
||||||
|
|
||||||
### CLI Mode
|
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).
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ def quick_add_note(content: str):
|
|||||||
note.extract_iocs() # Extract IOCs from content
|
note.extract_iocs() # Extract IOCs from content
|
||||||
|
|
||||||
# Try signing if enabled
|
# Try signing if enabled
|
||||||
|
signature = None
|
||||||
if settings.get("pgp_enabled", True):
|
if settings.get("pgp_enabled", True):
|
||||||
gpg_key_id = settings.get("gpg_key_id", None)
|
gpg_key_id = settings.get("gpg_key_id", None)
|
||||||
if gpg_key_id:
|
if gpg_key_id:
|
||||||
@@ -47,6 +48,8 @@ def quick_add_note(content: str):
|
|||||||
note.signature = signature
|
note.signature = signature
|
||||||
else:
|
else:
|
||||||
print("Warning: GPG signature failed (GPG not found or no key). Note saved without signature.")
|
print("Warning: GPG signature failed (GPG not found or no key). Note saved without signature.")
|
||||||
|
else:
|
||||||
|
print("Warning: No GPG key ID configured. Note saved without signature.")
|
||||||
|
|
||||||
# Attach to evidence or case
|
# Attach to evidence or case
|
||||||
if target_evidence:
|
if target_evidence:
|
||||||
|
|||||||
106
trace/models.py
106
trace/models.py
@@ -96,6 +96,112 @@ class Note:
|
|||||||
data = f"{self.timestamp}:{self.content}".encode('utf-8')
|
data = f"{self.timestamp}:{self.content}".encode('utf-8')
|
||||||
self.content_hash = hashlib.sha256(data).hexdigest()
|
self.content_hash = hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_iocs_from_text(text):
|
||||||
|
"""Extract IOCs from text and return as list of (ioc, type) tuples"""
|
||||||
|
iocs = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
# IPv4 addresses
|
||||||
|
ipv4_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
|
||||||
|
for match in re.findall(ipv4_pattern, text):
|
||||||
|
if match not in seen:
|
||||||
|
seen.add(match)
|
||||||
|
iocs.append((match, 'ipv4'))
|
||||||
|
|
||||||
|
# IPv6 addresses (simplified)
|
||||||
|
ipv6_pattern = r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b'
|
||||||
|
for match in re.findall(ipv6_pattern, text):
|
||||||
|
if match not in seen:
|
||||||
|
seen.add(match)
|
||||||
|
iocs.append((match, 'ipv6'))
|
||||||
|
|
||||||
|
# URLs (check before domains to avoid double-matching)
|
||||||
|
url_pattern = r'https?://[^\s]+'
|
||||||
|
for match in re.findall(url_pattern, text):
|
||||||
|
if match not in seen:
|
||||||
|
seen.add(match)
|
||||||
|
iocs.append((match, 'url'))
|
||||||
|
|
||||||
|
# Domain names (basic pattern)
|
||||||
|
domain_pattern = r'\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b'
|
||||||
|
for match in re.findall(domain_pattern, text):
|
||||||
|
# Filter out common false positives and already seen URLs
|
||||||
|
if match not in seen and not match.startswith('example.'):
|
||||||
|
seen.add(match)
|
||||||
|
iocs.append((match, 'domain'))
|
||||||
|
|
||||||
|
# SHA256 hashes (64 hex chars) - check before SHA1 and MD5
|
||||||
|
sha256_pattern = r'\b[a-fA-F0-9]{64}\b'
|
||||||
|
for match in re.findall(sha256_pattern, text):
|
||||||
|
if match not in seen:
|
||||||
|
seen.add(match)
|
||||||
|
iocs.append((match, 'sha256'))
|
||||||
|
|
||||||
|
# SHA1 hashes (40 hex chars) - check before MD5
|
||||||
|
sha1_pattern = r'\b[a-fA-F0-9]{40}\b'
|
||||||
|
for match in re.findall(sha1_pattern, text):
|
||||||
|
if match not in seen:
|
||||||
|
seen.add(match)
|
||||||
|
iocs.append((match, 'sha1'))
|
||||||
|
|
||||||
|
# MD5 hashes (32 hex chars)
|
||||||
|
md5_pattern = r'\b[a-fA-F0-9]{32}\b'
|
||||||
|
for match in re.findall(md5_pattern, text):
|
||||||
|
if match not in seen:
|
||||||
|
seen.add(match)
|
||||||
|
iocs.append((match, 'md5'))
|
||||||
|
|
||||||
|
# Email addresses
|
||||||
|
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
|
||||||
|
for match in re.findall(email_pattern, text):
|
||||||
|
if match not in seen:
|
||||||
|
seen.add(match)
|
||||||
|
iocs.append((match, 'email'))
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class Storage:
|
|||||||
|
|
||||||
def _create_demo_case(self):
|
def _create_demo_case(self):
|
||||||
"""Create a demo case with evidence showcasing all features"""
|
"""Create a demo case with evidence showcasing all features"""
|
||||||
# Create demo case
|
|
||||||
demo_case = Case(
|
demo_case = Case(
|
||||||
case_number="DEMO-2024-001",
|
case_number="DEMO-2024-001",
|
||||||
name="Sample Investigation",
|
name="Sample Investigation",
|
||||||
|
|||||||
590
trace/tui.py
590
trace/tui.py
@@ -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"
|
||||||
@@ -490,14 +590,26 @@ class TUI:
|
|||||||
self.stdscr.attroff(note_color)
|
self.stdscr.attroff(note_color)
|
||||||
y_pos += 1
|
y_pos += 1
|
||||||
|
|
||||||
|
# Split screen between evidence and case notes
|
||||||
|
# Allocate space: half for evidence, half for case notes (if both exist)
|
||||||
|
available_space = self.content_h - 5
|
||||||
|
case_notes = self.active_case.notes
|
||||||
|
evidence_list = self._get_filtered_list(self.active_case.evidence, "name", "description")
|
||||||
|
|
||||||
|
# Determine context: are we selecting evidence or notes?
|
||||||
|
# Evidence items are indices 0 to len(evidence)-1
|
||||||
|
# Case notes are indices len(evidence) to len(evidence)+len(notes)-1
|
||||||
|
total_items = len(evidence_list) + len(case_notes)
|
||||||
|
|
||||||
|
# Determine what's selected
|
||||||
|
selecting_evidence = self.selected_index < len(evidence_list)
|
||||||
|
|
||||||
# Evidence section header
|
# Evidence section header
|
||||||
y_pos += 1
|
if y_pos < self.height - 3:
|
||||||
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
|
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
|
||||||
self.stdscr.addstr(y_pos, 2, "▪ Evidence")
|
self.stdscr.addstr(y_pos, 2, "▪ Evidence")
|
||||||
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
|
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
|
||||||
|
|
||||||
evidence_list = self._get_filtered_list(self.active_case.evidence, "name", "description")
|
|
||||||
|
|
||||||
# Show count
|
# Show count
|
||||||
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
|
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
|
||||||
self.stdscr.addstr(y_pos, 14, f"({len(evidence_list)} items)")
|
self.stdscr.addstr(y_pos, 14, f"({len(evidence_list)} items)")
|
||||||
@@ -506,24 +618,49 @@ class TUI:
|
|||||||
y_pos += 1
|
y_pos += 1
|
||||||
|
|
||||||
if not evidence_list:
|
if not evidence_list:
|
||||||
|
# Check if we have space to display the message
|
||||||
|
if y_pos + 2 < self.height - 2:
|
||||||
self.stdscr.attron(curses.color_pair(3))
|
self.stdscr.attron(curses.color_pair(3))
|
||||||
self.stdscr.addstr(y_pos + 1, 4, "┌─ No evidence items")
|
self.stdscr.addstr(y_pos + 1, 4, "┌─ No evidence items")
|
||||||
self.stdscr.addstr(y_pos + 2, 4, "└─ Press 'N' to add evidence")
|
self.stdscr.addstr(y_pos + 2, 4, "└─ Press 'N' to add evidence")
|
||||||
self.stdscr.attroff(curses.color_pair(3))
|
self.stdscr.attroff(curses.color_pair(3))
|
||||||
else:
|
else:
|
||||||
# Scrolling for evidence list
|
# Scrolling for evidence list
|
||||||
# List starts at y=7
|
# Calculate remaining space
|
||||||
list_h = self.content_h - 5 # 7 is header offset
|
remaining_space = self.content_h - (y_pos - 2)
|
||||||
if list_h < 1: list_h = 1
|
list_h = max(1, remaining_space)
|
||||||
|
|
||||||
self._update_scroll(len(evidence_list))
|
self._update_scroll(total_items)
|
||||||
|
|
||||||
for i in range(list_h):
|
# Calculate space for evidence
|
||||||
idx = self.scroll_offset + i
|
evidence_space = min(len(evidence_list), available_space // 2) if case_notes else available_space
|
||||||
if idx >= len(evidence_list): break
|
|
||||||
|
|
||||||
ev = evidence_list[idx]
|
self._update_scroll(total_items)
|
||||||
y = y_pos + 2 + i
|
|
||||||
|
# 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:
|
||||||
|
# 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
|
||||||
|
|
||||||
|
for i in range(evidence_space):
|
||||||
|
evidence_idx = evidence_scroll_offset + i
|
||||||
|
if evidence_idx < 0 or evidence_idx >= len(evidence_list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
ev = evidence_list[evidence_idx]
|
||||||
|
y = y_pos + i
|
||||||
|
if y >= self.height - 3: # Don't overflow into status bar
|
||||||
|
break
|
||||||
|
|
||||||
note_count = len(ev.notes)
|
note_count = len(ev.notes)
|
||||||
|
|
||||||
@@ -563,7 +700,8 @@ class TUI:
|
|||||||
# Truncate safely
|
# Truncate safely
|
||||||
base_display = self._safe_truncate(display_str, self.width - 6)
|
base_display = self._safe_truncate(display_str, self.width - 6)
|
||||||
|
|
||||||
if idx == self.selected_index:
|
# Check if this evidence item is selected
|
||||||
|
if evidence_idx == self.selected_index:
|
||||||
# Highlighted selection
|
# Highlighted selection
|
||||||
self.stdscr.attron(curses.color_pair(1))
|
self.stdscr.attron(curses.color_pair(1))
|
||||||
self.stdscr.addstr(y, 4, base_display)
|
self.stdscr.addstr(y, 4, base_display)
|
||||||
@@ -596,6 +734,52 @@ class TUI:
|
|||||||
else:
|
else:
|
||||||
self.stdscr.addstr(y, 4, base_display)
|
self.stdscr.addstr(y, 4, base_display)
|
||||||
|
|
||||||
|
y_pos += evidence_space
|
||||||
|
|
||||||
|
# Case Notes section
|
||||||
|
if case_notes:
|
||||||
|
y_pos += 2
|
||||||
|
if y_pos < self.height - 3:
|
||||||
|
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
|
||||||
|
self.stdscr.addstr(y_pos, 2, "▪ Case Notes")
|
||||||
|
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
|
||||||
|
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
|
||||||
|
self.stdscr.addstr(y_pos, 16, f"({len(case_notes)} notes)")
|
||||||
|
self.stdscr.attroff(curses.color_pair(6) | curses.A_DIM)
|
||||||
|
y_pos += 1
|
||||||
|
|
||||||
|
# Calculate remaining space for case notes
|
||||||
|
remaining_space = self.content_h - (y_pos - 2)
|
||||||
|
notes_space = max(1, remaining_space)
|
||||||
|
|
||||||
|
# Calculate which notes to display
|
||||||
|
if selecting_evidence:
|
||||||
|
notes_scroll_offset = 0
|
||||||
|
else:
|
||||||
|
notes_scroll_offset = max(0, (self.selected_index - len(evidence_list)) - notes_space // 2)
|
||||||
|
|
||||||
|
for i in range(notes_space):
|
||||||
|
note_idx = notes_scroll_offset + i
|
||||||
|
if note_idx >= len(case_notes):
|
||||||
|
break
|
||||||
|
|
||||||
|
note = case_notes[note_idx]
|
||||||
|
y = y_pos + i
|
||||||
|
|
||||||
|
# Check if we're out of bounds
|
||||||
|
if y >= self.height - 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Format note content
|
||||||
|
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
|
||||||
|
display_str = f"- {note_content}"
|
||||||
|
display_str = self._safe_truncate(display_str, self.width - 6)
|
||||||
|
|
||||||
|
# Display with smart highlighting (IOCs take priority over selection)
|
||||||
|
item_idx = len(evidence_list) + note_idx
|
||||||
|
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))
|
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))
|
||||||
|
|
||||||
def draw_evidence_detail(self):
|
def draw_evidence_detail(self):
|
||||||
@@ -652,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))
|
||||||
|
|
||||||
@@ -833,11 +1013,11 @@ class TUI:
|
|||||||
|
|
||||||
current_y += 1
|
current_y += 1
|
||||||
|
|
||||||
# Content with tag highlighting
|
# Content with tag and IOC highlighting
|
||||||
self.stdscr.addstr(current_y, 2, "Content:", curses.A_BOLD)
|
self.stdscr.addstr(current_y, 2, "Content:", curses.A_BOLD)
|
||||||
current_y += 1
|
current_y += 1
|
||||||
|
|
||||||
# Display content with highlighted tags
|
# Display content with highlighted tags and IOCs
|
||||||
content_lines = self.current_note.content.split('\n')
|
content_lines = self.current_note.content.split('\n')
|
||||||
max_content_lines = self.content_h - (current_y - 2) - 6 # Reserve space for hash/sig
|
max_content_lines = self.content_h - (current_y - 2) - 6 # Reserve space for hash/sig
|
||||||
|
|
||||||
@@ -845,27 +1025,14 @@ class TUI:
|
|||||||
if current_y >= self.height - 6:
|
if current_y >= self.height - 6:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Highlight tags 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
|
|
||||||
|
|
||||||
# Simple tag highlighting - split by words and color tags
|
# Display with highlighting (no selection in detail view)
|
||||||
import re
|
|
||||||
parts = re.split(r'(#\w+)', display_line)
|
|
||||||
for part in parts:
|
|
||||||
if part.startswith('#'):
|
|
||||||
try:
|
try:
|
||||||
self.stdscr.addstr(current_y, x_pos, part, curses.color_pair(3))
|
self._display_line_with_highlights(current_y, 4, display_line, is_selected=False)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
x_pos += len(part)
|
|
||||||
else:
|
|
||||||
if x_pos < self.width - 2:
|
|
||||||
try:
|
|
||||||
self.stdscr.addstr(current_y, x_pos, part[:self.width - x_pos - 2])
|
|
||||||
except curses.error:
|
|
||||||
pass
|
|
||||||
x_pos += len(part)
|
|
||||||
|
|
||||||
current_y += 1
|
current_y += 1
|
||||||
|
|
||||||
@@ -918,11 +1085,11 @@ class TUI:
|
|||||||
help_lines.append((" n Add note to case", curses.A_NORMAL))
|
help_lines.append((" n Add note to case", curses.A_NORMAL))
|
||||||
help_lines.append((" t View tags across case and all evidence", curses.A_NORMAL))
|
help_lines.append((" t View tags across case and all evidence", curses.A_NORMAL))
|
||||||
help_lines.append((" i View IOCs across case and all evidence", curses.A_NORMAL))
|
help_lines.append((" i View IOCs across case and all evidence", curses.A_NORMAL))
|
||||||
help_lines.append((" v View all case notes", curses.A_NORMAL))
|
help_lines.append((" v View all case notes with IOC highlighting", curses.A_NORMAL))
|
||||||
help_lines.append((" a Set case (or selected evidence) as active", curses.A_NORMAL))
|
help_lines.append((" a Set case (or selected evidence) as active", curses.A_NORMAL))
|
||||||
help_lines.append((" d Delete selected evidence item", curses.A_NORMAL))
|
help_lines.append((" d Delete selected evidence item or note", curses.A_NORMAL))
|
||||||
help_lines.append((" / Filter evidence by name or description", curses.A_NORMAL))
|
help_lines.append((" / Filter evidence by name or description", curses.A_NORMAL))
|
||||||
help_lines.append((" Enter Open evidence details", curses.A_NORMAL))
|
help_lines.append((" Enter Open evidence details or jump to note", curses.A_NORMAL))
|
||||||
help_lines.append(("", curses.A_NORMAL))
|
help_lines.append(("", curses.A_NORMAL))
|
||||||
|
|
||||||
# Evidence Detail View
|
# Evidence Detail View
|
||||||
@@ -930,9 +1097,10 @@ class TUI:
|
|||||||
help_lines.append((" n Add note to evidence", curses.A_NORMAL))
|
help_lines.append((" n Add note to evidence", curses.A_NORMAL))
|
||||||
help_lines.append((" t View tags for this evidence", curses.A_NORMAL))
|
help_lines.append((" t View tags for this evidence", curses.A_NORMAL))
|
||||||
help_lines.append((" i View IOCs for this evidence", curses.A_NORMAL))
|
help_lines.append((" i View IOCs for this evidence", curses.A_NORMAL))
|
||||||
help_lines.append((" v View all evidence notes", curses.A_NORMAL))
|
help_lines.append((" v View all evidence notes with IOC highlighting", curses.A_NORMAL))
|
||||||
help_lines.append((" a Set evidence as active context", curses.A_NORMAL))
|
help_lines.append((" a Set evidence as active context", curses.A_NORMAL))
|
||||||
help_lines.append((" d Delete selected note", curses.A_NORMAL))
|
help_lines.append((" d Delete selected note", curses.A_NORMAL))
|
||||||
|
help_lines.append((" Enter Jump to selected note in full view", curses.A_NORMAL))
|
||||||
help_lines.append(("", curses.A_NORMAL))
|
help_lines.append(("", curses.A_NORMAL))
|
||||||
|
|
||||||
# Tags View
|
# Tags View
|
||||||
@@ -962,7 +1130,11 @@ class TUI:
|
|||||||
help_lines.append((" Active Context Set with 'a' key - enables CLI quick notes", curses.A_NORMAL))
|
help_lines.append((" Active Context Set with 'a' key - enables CLI quick notes", curses.A_NORMAL))
|
||||||
help_lines.append((" Run: trace \"your note text\"", curses.A_DIM))
|
help_lines.append((" Run: trace \"your note text\"", curses.A_DIM))
|
||||||
help_lines.append((" Tags Use #hashtag in notes for auto-tagging", curses.A_NORMAL))
|
help_lines.append((" Tags Use #hashtag in notes for auto-tagging", curses.A_NORMAL))
|
||||||
|
help_lines.append((" Highlighted in cyan throughout the interface", curses.A_DIM))
|
||||||
help_lines.append((" IOCs Auto-extracts IPs, domains, URLs, hashes, emails", curses.A_NORMAL))
|
help_lines.append((" IOCs Auto-extracts IPs, domains, URLs, hashes, emails", curses.A_NORMAL))
|
||||||
|
help_lines.append((" Highlighted in red in full note views", curses.A_DIM))
|
||||||
|
help_lines.append((" Note Navigation Press Enter on any note to view with highlighting", curses.A_NORMAL))
|
||||||
|
help_lines.append((" Selected note auto-centered and highlighted", curses.A_DIM))
|
||||||
help_lines.append((" Integrity All notes SHA256 hashed + optional GPG signing", curses.A_NORMAL))
|
help_lines.append((" Integrity All notes SHA256 hashed + optional GPG signing", curses.A_NORMAL))
|
||||||
help_lines.append((" GPG Settings Press 's' to toggle signing & select GPG key", curses.A_NORMAL))
|
help_lines.append((" GPG Settings Press 's' to toggle signing & select GPG key", curses.A_NORMAL))
|
||||||
help_lines.append((" Source Hash Store evidence file hashes for chain of custody", curses.A_NORMAL))
|
help_lines.append((" Source Hash Store evidence file hashes for chain of custody", curses.A_NORMAL))
|
||||||
@@ -1082,8 +1254,10 @@ class TUI:
|
|||||||
filtered = self._get_filtered_list(self.cases, "case_number", "name")
|
filtered = self._get_filtered_list(self.cases, "case_number", "name")
|
||||||
max_idx = len(filtered) - 1
|
max_idx = len(filtered) - 1
|
||||||
elif self.current_view == "case_detail" and self.active_case:
|
elif self.current_view == "case_detail" and self.active_case:
|
||||||
|
# Total items = evidence + 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")
|
||||||
max_idx = len(filtered) - 1
|
max_idx = len(filtered) + len(case_notes) - 1
|
||||||
elif self.current_view == "evidence_detail" and self.active_evidence:
|
elif self.current_view == "evidence_detail" and self.active_evidence:
|
||||||
# Navigate through notes in evidence detail view
|
# Navigate through notes in evidence detail view
|
||||||
max_idx = len(self.active_evidence.notes) - 1
|
max_idx = len(self.active_evidence.notes) - 1
|
||||||
@@ -1117,15 +1291,42 @@ class TUI:
|
|||||||
self.current_view = "case_detail"
|
self.current_view = "case_detail"
|
||||||
self.selected_index = 0
|
self.selected_index = 0
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
self.filter_query = "" # Reset filter on view change
|
self.filter_query = ""
|
||||||
|
elif self.current_view == "evidence_detail" and self.active_evidence:
|
||||||
|
# Check if a note is selected
|
||||||
|
notes = self.active_evidence.notes
|
||||||
|
list_h = self.content_h - 5
|
||||||
|
display_notes = notes[-list_h:] if len(notes) > list_h else notes
|
||||||
|
|
||||||
|
if display_notes and self.selected_index < len(display_notes):
|
||||||
|
# Calculate the actual note index in the full list
|
||||||
|
note_offset = len(notes) - len(display_notes)
|
||||||
|
actual_note_index = note_offset + self.selected_index
|
||||||
|
# Open notes view and jump to selected note
|
||||||
|
self._highlight_note_idx = actual_note_index
|
||||||
|
self.view_evidence_notes(highlight_note_index=actual_note_index)
|
||||||
|
delattr(self, '_highlight_note_idx') # Reset filter on view change
|
||||||
elif self.current_view == "case_detail":
|
elif self.current_view == "case_detail":
|
||||||
if self.active_case:
|
if self.active_case:
|
||||||
|
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")
|
||||||
if filtered:
|
|
||||||
|
# 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]
|
self.active_evidence = filtered[self.selected_index]
|
||||||
self.current_view = "evidence_detail"
|
self.current_view = "evidence_detail"
|
||||||
self.selected_index = 0
|
self.selected_index = 0
|
||||||
self.filter_query = "" # Reset filter
|
self.filter_query = ""
|
||||||
|
elif case_notes and self.selected_index - len(filtered) < len(case_notes):
|
||||||
|
# Selected a note - show note detail view
|
||||||
|
note_idx = self.selected_index - len(filtered)
|
||||||
|
self.current_note = case_notes[note_idx]
|
||||||
|
self.previous_view = "case_detail"
|
||||||
|
self.current_view = "note_detail"
|
||||||
|
self.filter_query = ""
|
||||||
elif self.current_view == "tags_list":
|
elif self.current_view == "tags_list":
|
||||||
# Enter tag -> show notes with that tag
|
# Enter tag -> show notes with that tag
|
||||||
if self.current_tags and self.selected_index < len(self.current_tags):
|
if self.current_tags and self.selected_index < len(self.current_tags):
|
||||||
@@ -1143,6 +1344,7 @@ class TUI:
|
|||||||
# Enter note -> show expanded view
|
# Enter note -> show expanded view
|
||||||
if self.tag_notes and self.selected_index < len(self.tag_notes):
|
if self.tag_notes and self.selected_index < len(self.tag_notes):
|
||||||
self.current_note = self.tag_notes[self.selected_index]
|
self.current_note = self.tag_notes[self.selected_index]
|
||||||
|
self.previous_view = "tag_notes_list"
|
||||||
self.current_view = "note_detail"
|
self.current_view = "note_detail"
|
||||||
self.selected_index = 0
|
self.selected_index = 0
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
@@ -1163,6 +1365,7 @@ class TUI:
|
|||||||
# Enter note -> show expanded view
|
# Enter note -> show expanded view
|
||||||
if self.ioc_notes and self.selected_index < len(self.ioc_notes):
|
if self.ioc_notes and self.selected_index < len(self.ioc_notes):
|
||||||
self.current_note = self.ioc_notes[self.selected_index]
|
self.current_note = self.ioc_notes[self.selected_index]
|
||||||
|
self.previous_view = "ioc_notes_list"
|
||||||
self.current_view = "note_detail"
|
self.current_view = "note_detail"
|
||||||
self.selected_index = 0
|
self.selected_index = 0
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
@@ -1175,7 +1378,8 @@ class TUI:
|
|||||||
self.selected_index = 0
|
self.selected_index = 0
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
elif self.current_view == "note_detail":
|
elif self.current_view == "note_detail":
|
||||||
self.current_view = "tag_notes_list"
|
# Return to the view we came from
|
||||||
|
self.current_view = getattr(self, 'previous_view', 'case_detail')
|
||||||
self.current_note = None
|
self.current_note = None
|
||||||
self.selected_index = 0
|
self.selected_index = 0
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
@@ -1291,14 +1495,26 @@ class TUI:
|
|||||||
self.show_message(f"Active Case: {case.case_number}")
|
self.show_message(f"Active Case: {case.case_number}")
|
||||||
|
|
||||||
elif self.current_view == "case_detail" and self.active_case:
|
elif self.current_view == "case_detail" and self.active_case:
|
||||||
|
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")
|
||||||
if filtered:
|
|
||||||
|
# Evidence is displayed first (indices 0 to len(evidence)-1)
|
||||||
|
# Case notes are displayed second (indices len(evidence) to len(evidence)+len(notes)-1)
|
||||||
|
if self.selected_index < len(filtered):
|
||||||
|
# Selected evidence - set it as active
|
||||||
ev = filtered[self.selected_index]
|
ev = filtered[self.selected_index]
|
||||||
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=ev.evidence_id)
|
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=ev.evidence_id)
|
||||||
self.global_active_case_id = self.active_case.case_id
|
self.global_active_case_id = self.active_case.case_id
|
||||||
self.global_active_evidence_id = ev.evidence_id
|
self.global_active_evidence_id = ev.evidence_id
|
||||||
self.show_message(f"Active: {ev.name}")
|
self.show_message(f"Active: {ev.name}")
|
||||||
|
elif case_notes and self.selected_index - len(filtered) < len(case_notes):
|
||||||
|
# Selected a note - set case as active (not evidence)
|
||||||
|
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=None)
|
||||||
|
self.global_active_case_id = self.active_case.case_id
|
||||||
|
self.global_active_evidence_id = None
|
||||||
|
self.show_message(f"Active: Case {self.active_case.case_number}")
|
||||||
else:
|
else:
|
||||||
|
# Nothing selected - set case as active
|
||||||
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=None)
|
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=None)
|
||||||
self.global_active_case_id = self.active_case.case_id
|
self.global_active_case_id = self.active_case.case_id
|
||||||
self.global_active_evidence_id = None
|
self.global_active_evidence_id = None
|
||||||
@@ -1559,7 +1775,7 @@ class TUI:
|
|||||||
win.addstr(y, 2, " " * input_width)
|
win.addstr(y, 2, " " * input_width)
|
||||||
|
|
||||||
if line_idx < len(lines):
|
if line_idx < len(lines):
|
||||||
# Show line content
|
# Show line content (truncated if too long)
|
||||||
display_text = lines[line_idx][:input_width]
|
display_text = lines[line_idx][:input_width]
|
||||||
win.addstr(y, 2, display_text)
|
win.addstr(y, 2, display_text)
|
||||||
|
|
||||||
@@ -1682,6 +1898,18 @@ class TUI:
|
|||||||
lines[cursor_line] = line[:cursor_col] + chr(ch) + line[cursor_col:]
|
lines[cursor_line] = line[:cursor_col] + chr(ch) + line[cursor_col:]
|
||||||
cursor_col += 1
|
cursor_col += 1
|
||||||
|
|
||||||
|
# Auto-wrap to next line if cursor exceeds visible width
|
||||||
|
if cursor_col >= input_width:
|
||||||
|
# Always ensure there's a next line to move to
|
||||||
|
if cursor_line >= len(lines) - 1:
|
||||||
|
# We're on the last line, add a new line
|
||||||
|
lines.append("")
|
||||||
|
cursor_line += 1
|
||||||
|
cursor_col = 0
|
||||||
|
# Adjust scroll if needed
|
||||||
|
if cursor_line >= scroll_offset + input_height:
|
||||||
|
scroll_offset = cursor_line - input_height + 1
|
||||||
|
|
||||||
def dialog_confirm(self, message):
|
def dialog_confirm(self, message):
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
h = 5
|
h = 5
|
||||||
@@ -1724,6 +1952,7 @@ class TUI:
|
|||||||
x = (self.width - w) // 2
|
x = (self.width - w) // 2
|
||||||
|
|
||||||
win = curses.newwin(h, w, y, x)
|
win = curses.newwin(h, w, y, x)
|
||||||
|
win.keypad(True) # Enable keypad mode for arrow keys
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
win.clear()
|
win.clear()
|
||||||
@@ -1820,6 +2049,7 @@ class TUI:
|
|||||||
x = (self.width - w) // 2
|
x = (self.width - w) // 2
|
||||||
|
|
||||||
win = curses.newwin(h, w, y, x)
|
win = curses.newwin(h, w, y, x)
|
||||||
|
win.keypad(True) # Enable keypad mode for arrow keys
|
||||||
scroll_offset = 0
|
scroll_offset = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -1913,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)
|
||||||
@@ -1940,14 +2166,10 @@ class TUI:
|
|||||||
return
|
return
|
||||||
|
|
||||||
desc = self._input_dialog("New Evidence - Step 2/3", "Enter description (optional):")
|
desc = self._input_dialog("New Evidence - Step 2/3", "Enter description (optional):")
|
||||||
if desc is None:
|
# For optional fields, treat None as empty string (user pressed Enter on empty field)
|
||||||
self.show_message("Evidence creation cancelled.")
|
|
||||||
return
|
|
||||||
|
|
||||||
source_hash = self._input_dialog("New Evidence - Step 3/3", "Enter source hash (optional, e.g. SHA256):")
|
source_hash = self._input_dialog("New Evidence - Step 3/3", "Enter source hash (optional, e.g. SHA256):")
|
||||||
if source_hash is None:
|
# For optional fields, treat None as empty string (user pressed Enter on empty field)
|
||||||
self.show_message("Evidence creation cancelled.")
|
|
||||||
return
|
|
||||||
|
|
||||||
ev = Evidence(name=name, description=desc or "")
|
ev = Evidence(name=name, description=desc or "")
|
||||||
if source_hash:
|
if source_hash:
|
||||||
@@ -1966,12 +2188,12 @@ class TUI:
|
|||||||
|
|
||||||
if self.current_view == "evidence_detail" and self.active_evidence:
|
if self.current_view == "evidence_detail" and self.active_evidence:
|
||||||
context_title = f"Add Note → Evidence: {self.active_evidence.name}"
|
context_title = f"Add Note → Evidence: {self.active_evidence.name}"
|
||||||
context_prompt = f"Case: {self.active_case.case_number if self.active_case else '?'}\nEvidence: {self.active_evidence.name}\n\nNote will be added to this evidence."
|
context_prompt = f"Case: {self.active_case.case_number if self.active_case else '?'}\nEvidence: {self.active_evidence.name}\n"
|
||||||
recent_notes = self.active_evidence.notes[-5:] if len(self.active_evidence.notes) > 0 else []
|
recent_notes = self.active_evidence.notes[-5:] if len(self.active_evidence.notes) > 0 else []
|
||||||
target_evidence = self.active_evidence
|
target_evidence = self.active_evidence
|
||||||
elif self.current_view == "case_detail" and self.active_case:
|
elif self.current_view == "case_detail" and self.active_case:
|
||||||
context_title = f"Add Note → Case: {self.active_case.case_number}"
|
context_title = f"Add Note → Case: {self.active_case.case_number}"
|
||||||
context_prompt = f"Case: {self.active_case.case_number}\n{self.active_case.name if self.active_case.name else ''}\n\nNote will be added to case notes."
|
context_prompt = f"Case: {self.active_case.case_number}\n{self.active_case.name if self.active_case.name else ''}\nNote will be added to case notes."
|
||||||
recent_notes = self.active_case.notes[-5:] if len(self.active_case.notes) > 0 else []
|
recent_notes = self.active_case.notes[-5:] if len(self.active_case.notes) > 0 else []
|
||||||
target_case = self.active_case
|
target_case = self.active_case
|
||||||
elif self.current_view == "case_list":
|
elif self.current_view == "case_list":
|
||||||
@@ -1984,14 +2206,14 @@ class TUI:
|
|||||||
for ev in active_case.evidence:
|
for ev in active_case.evidence:
|
||||||
if ev.evidence_id == self.global_active_evidence_id:
|
if ev.evidence_id == self.global_active_evidence_id:
|
||||||
context_title = f"Add Note → Evidence: {ev.name}"
|
context_title = f"Add Note → Evidence: {ev.name}"
|
||||||
context_prompt = f"Case: {active_case.case_number}\nEvidence: {ev.name}\n\nNote will be added to this evidence."
|
context_prompt = f"Case: {active_case.case_number}\nEvidence: {ev.name}\n"
|
||||||
recent_notes = ev.notes[-5:] if len(ev.notes) > 0 else []
|
recent_notes = ev.notes[-5:] if len(ev.notes) > 0 else []
|
||||||
target_case = active_case
|
target_case = active_case
|
||||||
target_evidence = ev
|
target_evidence = ev
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
context_title = f"Add Note → Case: {active_case.case_number}"
|
context_title = f"Add Note → Case: {active_case.case_number}"
|
||||||
context_prompt = f"Case: {active_case.case_number}\n\nNote will be added to case notes."
|
context_prompt = f"Case: {active_case.case_number}\nNote will be added to case notes."
|
||||||
recent_notes = active_case.notes[-5:] if len(active_case.notes) > 0 else []
|
recent_notes = active_case.notes[-5:] if len(active_case.notes) > 0 else []
|
||||||
target_case = active_case
|
target_case = active_case
|
||||||
|
|
||||||
@@ -2024,7 +2246,7 @@ class TUI:
|
|||||||
|
|
||||||
signed = False
|
signed = False
|
||||||
if pgp_enabled:
|
if pgp_enabled:
|
||||||
sig = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id)
|
sig = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id or "")
|
||||||
if sig:
|
if sig:
|
||||||
note.signature = sig
|
note.signature = sig
|
||||||
signed = True
|
signed = True
|
||||||
@@ -2064,9 +2286,25 @@ class TUI:
|
|||||||
self.show_message(f"Case {case_to_del.case_number} deleted.")
|
self.show_message(f"Case {case_to_del.case_number} deleted.")
|
||||||
|
|
||||||
elif self.current_view == "case_detail" and self.active_case:
|
elif self.current_view == "case_detail" and self.active_case:
|
||||||
|
# Determine if we're deleting a note or evidence based on selected index
|
||||||
|
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")
|
||||||
if filtered:
|
|
||||||
ev_to_del = filtered[self.selected_index]
|
# Check if selecting a note (indices 0 to len(notes)-1)
|
||||||
|
if self.selected_index < len(case_notes):
|
||||||
|
# Delete case note
|
||||||
|
note_to_del = case_notes[self.selected_index]
|
||||||
|
preview = note_to_del.content[:50] + "..." if len(note_to_del.content) > 50 else note_to_del.content
|
||||||
|
if self.dialog_confirm(f"Delete note: '{preview}'?"):
|
||||||
|
self.active_case.notes.remove(note_to_del)
|
||||||
|
self.storage.save_data()
|
||||||
|
self.selected_index = 0
|
||||||
|
self.scroll_offset = 0
|
||||||
|
self.show_message("Note deleted.")
|
||||||
|
elif filtered and self.selected_index - len(case_notes) < len(filtered):
|
||||||
|
# Delete evidence (adjust index by subtracting case notes count)
|
||||||
|
evidence_idx = self.selected_index - len(case_notes)
|
||||||
|
ev_to_del = filtered[evidence_idx]
|
||||||
if self.dialog_confirm(f"Delete Evidence {ev_to_del.name}?"):
|
if self.dialog_confirm(f"Delete Evidence {ev_to_del.name}?"):
|
||||||
self.storage.delete_evidence(self.active_case.case_id, ev_to_del.evidence_id)
|
self.storage.delete_evidence(self.active_case.case_id, ev_to_del.evidence_id)
|
||||||
# Check active state
|
# Check active state
|
||||||
@@ -2074,9 +2312,7 @@ class TUI:
|
|||||||
# Fallback to case active
|
# Fallback to case active
|
||||||
self.state_manager.set_active(self.active_case.case_id, None)
|
self.state_manager.set_active(self.active_case.case_id, None)
|
||||||
self.global_active_evidence_id = None
|
self.global_active_evidence_id = None
|
||||||
# Refresh (in-memory update was done by storage usually? No, storage reloads or we reload)
|
# Refresh
|
||||||
# We need to reload active_case evidence list or trust storage.cases
|
|
||||||
# It's better to reload from storage to be safe
|
|
||||||
updated_case = self.storage.get_case(self.active_case.case_id)
|
updated_case = self.storage.get_case(self.active_case.case_id)
|
||||||
if updated_case:
|
if updated_case:
|
||||||
self.active_case = updated_case
|
self.active_case = updated_case
|
||||||
@@ -2109,7 +2345,7 @@ class TUI:
|
|||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
self.show_message("Note deleted.")
|
self.show_message("Note deleted.")
|
||||||
|
|
||||||
def view_case_notes(self):
|
def view_case_notes(self, highlight_note_index=None):
|
||||||
if not self.active_case: return
|
if not self.active_case: return
|
||||||
|
|
||||||
h = int(self.height * 0.8)
|
h = int(self.height * 0.8)
|
||||||
@@ -2117,45 +2353,115 @@ class TUI:
|
|||||||
y = int(self.height * 0.1)
|
y = int(self.height * 0.1)
|
||||||
x = int(self.width * 0.1)
|
x = int(self.width * 0.1)
|
||||||
|
|
||||||
|
scroll_offset = 0
|
||||||
|
highlight_idx = highlight_note_index # Store for persistent highlighting
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
win = curses.newwin(h, w, y, x)
|
win = curses.newwin(h, w, y, x)
|
||||||
|
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}", curses.A_BOLD)
|
win.addstr(1, 2, f"Notes: {self.active_case.case_number} ({len(self.active_case.notes)} total)", curses.A_BOLD)
|
||||||
|
|
||||||
notes = self.active_case.notes
|
notes = self.active_case.notes
|
||||||
max_lines = h - 4
|
content_lines = []
|
||||||
|
note_line_ranges = [] # Track which lines belong to which note
|
||||||
|
|
||||||
# Scroll last notes
|
# Build all content lines with separators between notes
|
||||||
display_notes = notes[-max_lines:] if len(notes) > max_lines else notes
|
for note_idx, note in enumerate(notes):
|
||||||
|
start_line = len(content_lines)
|
||||||
|
timestamp_str = time.ctime(note.timestamp)
|
||||||
|
content_lines.append(f"[{timestamp_str}]")
|
||||||
|
# Split multi-line notes and wrap long lines
|
||||||
|
for line in note.content.split('\n'):
|
||||||
|
# Wrap long lines
|
||||||
|
while len(line) > w - 6:
|
||||||
|
content_lines.append(" " + line[:w-6])
|
||||||
|
line = line[w-6:]
|
||||||
|
content_lines.append(" " + line)
|
||||||
|
content_lines.append("") # Blank line between notes
|
||||||
|
end_line = len(content_lines) - 1
|
||||||
|
note_line_ranges.append((start_line, end_line, note_idx))
|
||||||
|
|
||||||
for i, note in enumerate(display_notes):
|
max_display_lines = h - 5
|
||||||
# Replace newlines with spaces for single-line display
|
total_lines = len(content_lines)
|
||||||
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
|
|
||||||
display_str = f"- [{time.ctime(note.timestamp)}] {note_content}"
|
|
||||||
# Truncate safely for Unicode
|
|
||||||
display_str = self._safe_truncate(display_str, w - 4)
|
|
||||||
win.addstr(3 + i, 2, display_str)
|
|
||||||
|
|
||||||
win.addstr(h-2, 2, "[n] Add Note [b/q/Esc] Close", curses.color_pair(3))
|
# Jump to highlighted note on first render
|
||||||
|
if highlight_note_index is not None and note_line_ranges:
|
||||||
|
for start, end, idx in note_line_ranges:
|
||||||
|
if idx == highlight_note_index:
|
||||||
|
# Center the note in the view
|
||||||
|
note_middle = (start + end) // 2
|
||||||
|
scroll_offset = max(0, note_middle - max_display_lines // 2)
|
||||||
|
highlight_note_index = None # Only jump once
|
||||||
|
break
|
||||||
|
|
||||||
|
# Adjust scroll bounds
|
||||||
|
max_scroll = max(0, total_lines - max_display_lines)
|
||||||
|
scroll_offset = max(0, min(scroll_offset, max_scroll))
|
||||||
|
|
||||||
|
# Display lines with highlighting
|
||||||
|
for i in range(max_display_lines):
|
||||||
|
line_idx = scroll_offset + i
|
||||||
|
if line_idx >= total_lines:
|
||||||
|
break
|
||||||
|
display_line = self._safe_truncate(content_lines[line_idx], w - 4)
|
||||||
|
|
||||||
|
# Check if this line belongs to the highlighted note
|
||||||
|
is_highlighted = False
|
||||||
|
if highlight_idx is not None:
|
||||||
|
for start, end, idx in note_line_ranges:
|
||||||
|
if start <= line_idx <= end and idx == highlight_idx:
|
||||||
|
is_highlighted = True
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
y_pos = 3 + i
|
||||||
|
# Use unified highlighting function
|
||||||
|
self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Show scroll indicator
|
||||||
|
if total_lines > max_display_lines:
|
||||||
|
scroll_info = f"[{scroll_offset + 1}-{min(scroll_offset + max_display_lines, total_lines)}/{total_lines}]"
|
||||||
|
try:
|
||||||
|
win.addstr(2, w - len(scroll_info) - 3, scroll_info, curses.A_DIM)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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
|
||||||
if key == ord('n') or key == ord('N'):
|
if key == curses.KEY_UP:
|
||||||
|
scroll_offset = max(0, scroll_offset - 1)
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
scroll_offset = min(max_scroll, scroll_offset + 1)
|
||||||
|
elif key == curses.KEY_PPAGE: # Page Up
|
||||||
|
scroll_offset = max(0, scroll_offset - max_display_lines)
|
||||||
|
elif key == curses.KEY_NPAGE: # Page Down
|
||||||
|
scroll_offset = min(max_scroll, scroll_offset + max_display_lines)
|
||||||
|
elif key == curses.KEY_HOME:
|
||||||
|
scroll_offset = 0
|
||||||
|
elif key == curses.KEY_END:
|
||||||
|
scroll_offset = max_scroll
|
||||||
|
elif key == ord('n') or key == ord('N'):
|
||||||
# Save current view and switch to case_detail temporarily for context
|
# Save current view and switch to case_detail temporarily for context
|
||||||
saved_view = self.current_view
|
saved_view = self.current_view
|
||||||
self.current_view = "case_detail"
|
self.current_view = "case_detail"
|
||||||
self.dialog_add_note()
|
self.dialog_add_note()
|
||||||
self.current_view = saved_view
|
self.current_view = saved_view
|
||||||
# Continue loop to refresh with new note
|
scroll_offset = max_scroll # Jump to bottom to show new note
|
||||||
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
|
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
|
||||||
break
|
break
|
||||||
else:
|
|
||||||
# Any other key also closes (backwards compatibility)
|
|
||||||
break
|
|
||||||
|
|
||||||
def view_evidence_notes(self):
|
def view_evidence_notes(self, highlight_note_index=None):
|
||||||
if not self.active_evidence: return
|
if not self.active_evidence: return
|
||||||
|
|
||||||
h = int(self.height * 0.8)
|
h = int(self.height * 0.8)
|
||||||
@@ -2163,43 +2469,113 @@ class TUI:
|
|||||||
y = int(self.height * 0.1)
|
y = int(self.height * 0.1)
|
||||||
x = int(self.width * 0.1)
|
x = int(self.width * 0.1)
|
||||||
|
|
||||||
|
scroll_offset = 0
|
||||||
|
highlight_idx = highlight_note_index # Store for persistent highlighting
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
win = curses.newwin(h, w, y, x)
|
win = curses.newwin(h, w, y, x)
|
||||||
|
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}", curses.A_BOLD)
|
win.addstr(1, 2, f"Notes: {self.active_evidence.name} ({len(self.active_evidence.notes)} total)", curses.A_BOLD)
|
||||||
|
|
||||||
notes = self.active_evidence.notes
|
notes = self.active_evidence.notes
|
||||||
max_lines = h - 4
|
content_lines = []
|
||||||
|
note_line_ranges = [] # Track which lines belong to which note
|
||||||
|
|
||||||
# Scroll last notes
|
# Build all content lines with separators between notes
|
||||||
display_notes = notes[-max_lines:] if len(notes) > max_lines else notes
|
for note_idx, note in enumerate(notes):
|
||||||
|
start_line = len(content_lines)
|
||||||
|
timestamp_str = time.ctime(note.timestamp)
|
||||||
|
content_lines.append(f"[{timestamp_str}]")
|
||||||
|
# Split multi-line notes and wrap long lines
|
||||||
|
for line in note.content.split('\n'):
|
||||||
|
# Wrap long lines
|
||||||
|
while len(line) > w - 6:
|
||||||
|
content_lines.append(" " + line[:w-6])
|
||||||
|
line = line[w-6:]
|
||||||
|
content_lines.append(" " + line)
|
||||||
|
content_lines.append("") # Blank line between notes
|
||||||
|
end_line = len(content_lines) - 1
|
||||||
|
note_line_ranges.append((start_line, end_line, note_idx))
|
||||||
|
|
||||||
for i, note in enumerate(display_notes):
|
max_display_lines = h - 5
|
||||||
# Replace newlines with spaces for single-line display
|
total_lines = len(content_lines)
|
||||||
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
|
|
||||||
display_str = f"- [{time.ctime(note.timestamp)}] {note_content}"
|
|
||||||
# Truncate safely for Unicode
|
|
||||||
display_str = self._safe_truncate(display_str, w - 4)
|
|
||||||
win.addstr(3 + i, 2, display_str)
|
|
||||||
|
|
||||||
win.addstr(h-2, 2, "[n] Add Note [b/q/Esc] Close", curses.color_pair(3))
|
# Jump to highlighted note on first render
|
||||||
|
if highlight_note_index is not None and note_line_ranges:
|
||||||
|
for start, end, idx in note_line_ranges:
|
||||||
|
if idx == highlight_note_index:
|
||||||
|
# Center the note in the view
|
||||||
|
note_middle = (start + end) // 2
|
||||||
|
scroll_offset = max(0, note_middle - max_display_lines // 2)
|
||||||
|
highlight_note_index = None # Only jump once
|
||||||
|
break
|
||||||
|
|
||||||
|
# Adjust scroll bounds
|
||||||
|
max_scroll = max(0, total_lines - max_display_lines)
|
||||||
|
scroll_offset = max(0, min(scroll_offset, max_scroll))
|
||||||
|
|
||||||
|
# Display lines with highlighting
|
||||||
|
for i in range(max_display_lines):
|
||||||
|
line_idx = scroll_offset + i
|
||||||
|
if line_idx >= total_lines:
|
||||||
|
break
|
||||||
|
display_line = self._safe_truncate(content_lines[line_idx], w - 4)
|
||||||
|
|
||||||
|
# Check if this line belongs to the highlighted note
|
||||||
|
is_highlighted = False
|
||||||
|
if highlight_idx is not None:
|
||||||
|
for start, end, idx in note_line_ranges:
|
||||||
|
if start <= line_idx <= end and idx == highlight_idx:
|
||||||
|
is_highlighted = True
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
y_pos = 3 + i
|
||||||
|
# Use unified highlighting function
|
||||||
|
self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Show scroll indicator
|
||||||
|
if total_lines > max_display_lines:
|
||||||
|
scroll_info = f"[{scroll_offset + 1}-{min(scroll_offset + max_display_lines, total_lines)}/{total_lines}]"
|
||||||
|
try:
|
||||||
|
win.addstr(2, w - len(scroll_info) - 3, scroll_info, curses.A_DIM)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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
|
||||||
if key == ord('n') or key == ord('N'):
|
if key == curses.KEY_UP:
|
||||||
|
scroll_offset = max(0, scroll_offset - 1)
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
scroll_offset = min(max_scroll, scroll_offset + 1)
|
||||||
|
elif key == curses.KEY_PPAGE: # Page Up
|
||||||
|
scroll_offset = max(0, scroll_offset - max_display_lines)
|
||||||
|
elif key == curses.KEY_NPAGE: # Page Down
|
||||||
|
scroll_offset = min(max_scroll, scroll_offset + max_display_lines)
|
||||||
|
elif key == curses.KEY_HOME:
|
||||||
|
scroll_offset = 0
|
||||||
|
elif key == curses.KEY_END:
|
||||||
|
scroll_offset = max_scroll
|
||||||
|
elif key == ord('n') or key == ord('N'):
|
||||||
# Save current view and switch to evidence_detail temporarily for context
|
# Save current view and switch to evidence_detail temporarily for context
|
||||||
saved_view = self.current_view
|
saved_view = self.current_view
|
||||||
self.current_view = "evidence_detail"
|
self.current_view = "evidence_detail"
|
||||||
self.dialog_add_note()
|
self.dialog_add_note()
|
||||||
self.current_view = saved_view
|
self.current_view = saved_view
|
||||||
# Continue loop to refresh with new note
|
scroll_offset = max_scroll # Jump to bottom to show new note
|
||||||
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
|
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
|
||||||
break
|
break
|
||||||
else:
|
|
||||||
# Any other key also closes (backwards compatibility)
|
|
||||||
break
|
|
||||||
|
|
||||||
def export_iocs(self):
|
def export_iocs(self):
|
||||||
"""Export IOCs from current context to a text file"""
|
"""Export IOCs from current context to a text file"""
|
||||||
|
|||||||
Reference in New Issue
Block a user