Vulnerability triage today is browser tab soup. NVD in one tab. Vendor advisory in another. CISA KEV in a third. ChatGPT in a fourth. Yesterday's runbook in a fifth.
The analyst context-switches every thirty seconds. The mental model — this CVE, this vendor, these products, these mitigations — never lives in one place. Close the browser, and it's gone.
Panopticon collapses that soup into a single native window. It draws a force-directed threat graph of vendor↔CVE relationships. It wires that graph to a local AI agent that drafts mitigation plans, keeps a versioned chat history, and resumes conversations across sessions. CVEs actively exploited in the wild get flagged straight from the CISA KEV catalog.

The graph recentered on CVE-2021-44228. Node color encodes CVSS; the right panel is the analysis workspace.
Local-first, with honest boundaries. The CVE database, chat history, reports and API keys never leave your disk. What does go out is exactly what you configure: LLM prompts to OpenRouter (your model, your key), plus lookups to the public NVD and CISA feeds. No telemetry. No accounts. No cloud lock-in.
Why not Electron
Electron ships a full Chromium runtime with every app. That means hundreds of megabytes of install size and a heavy idle footprint — for what is usually a single-page UI. Tauri 2 reuses the OS-native WebView instead: WebView2 on Windows, WebKit elsewhere. In practice the binary lands an order of magnitude smaller, and the idle memory profile is dramatically lighter. For a tool analysts keep open all day next to a SIEM, that matters.
The trade-off is real. WebView2 has quirks: programmatic blob downloads silently no-op, some CSS lags Chromium. Each quirk had a workaround. None justified shipping our own browser.
The deliberately thin Rust layer
Vanilla JS (Vite + vis-network)
│ invoke("chat_with_agent", { cveId, messages, model })
▼
Tauri 2 · Rust command handlers ← owns window, routes IPC, spawns processes
│ spawn python.exe · history via stdin
▼
Python asyncio layer
ai_agent.py ──► MCP client ──► mcp_server.py (CVE context, KEV, CWE stats)
│ ├─► SQLite (local CVE DB)
│ └─► CISA KEV catalog + NVD live fallback
└──► OpenRouter API + SQLite (chat_reports)
Rust owns the window and the IPC. All heavy logic lives in Python: LLM orchestration, graph building, search, persistence. Iterating on prompts and parsers is simply faster there.
The Python layer reaches the LLM through the Model Context Protocol (MCP) — a small standard that lets models call tools. The same CVE-context tools serve the built-in chat and any external MCP-compatible client.
Four decisions worth stealing
1. Conversation history goes through stdin, not CLI args
Windows caps a command line at ~32,767 characters. A ten-message history with mitigation payloads and code blocks hits 30-50 KB of JSON. Long sessions died silently.
let mut child = Command::new(&python_path)
.arg("--chat").arg(&cve_id)
.stdin(Stdio::piped()).spawn()?;
child.stdin.as_mut().unwrap().write_all(messages.as_bytes())?;
// stdin dropped here → child sees EOF
No deadlock: the child writes stdout only after draining stdin, so the streams never block each other.
2. CISA KEV as a first-class signal
CVSS alone misprioritizes. A 7.5 that is exploited beats a 9.8 that is theoretical. Panopticon lazy-loads the public KEV catalog (~1.7k entries) once per process. Listed CVEs get a thick red ring on the graph and an ACTIVELY EXPLOITED row with the patch due date in the details panel.

CISA KEV is public government data. The flag cuts through CVSS noise during triage.
One bug worth mentioning here was self-inflicted. The KEV fields were computed correctly, then dropped by a serialization whitelist that copied details into the graph JSON key by key. The UI never saw the flag. Whitelists are fine — until you add a field and forget the projection.
3. Defensive LLM parsing, twice
Free-tier providers sometimes return HTTP 200 with {"error": {...}} instead of {"choices": [...]}}. Naive parsing crashes with KeyError. So every response is categorized: rate limit, credits, overload, network. The user sees an actionable message, not a stack trace.
The second failure mode is degenerate repetition: the model collapses into loops like health health health....). mid-answer. Prevention is sampling penalties. Detection is a punctuation-insensitive n-gram run check on the response tail. Sanitation truncates at the first degenerate segment — before the response is cached or saved, so poisoned text never persists.
4. Vanilla JS over a framework
main.js is about nine hundred lines. vis-network needs raw DOM access. The state is a handful of explicit globals with race guards. For a single-user desktop tool, a component tree would add ceremony, not clarity. XSS-safety is handled by escaping HTML before the markdown transform.
Walkthrough
Search is navigation. Typing log4j resolves CVEs, vendors and CWEs across tables. Clicking a hit rebuilds the graph around that CVE's vendor scope and flies the camera to the node — even when the CVE sits outside the currently loaded slice.

One query across cve_id, description, vendor and CWE tables.
The chat is the workspace. The session below runs on CVE-2021-45046:
- What's the mitigation plan for this CVE?
- How does this compare to Log4Shell (CVE-2021-44228)?
- Show me remediation commands for Ubuntu and Docker.
Question 2 is the point. The agent holds the CVE context from MCP and the conversation at the same time. Regenerate keeps every draft — the 2/3 indicator walks the versions.

Every assistant turn is auto-saved. The panel expands for long reads.
Work resumes. Conversations persist as reports. Pin the ones you return to, tag by campaign or asset, bulk-delete the rest, export any session to Markdown.

Reports carry the vendor scope, so reopening rebuilds the exact graph you left.
Exported Markdown is explicitly labeled as an AI-generated draft that requires human verification. Hiding that would not protect the analyst who forwards it. It would remove the only signal that says "check these commands first".
Lessons learned
-
Absolute paths from day one. A relative
DB_PATHcreated two databases in two directories, depending on the working directory. OnePath(__file__).resolve().parentfixed the whole genre of bugs. -
PowerShell 5.1 drops empty native-exe arguments. My
script.py "" "" argtest silently becamescript.py arg, which produced a very confusing "empty graph" diagnosis. Test CLI contracts from Python, not from PS 5.1. -
Real data beats mock data in public. NVD and CISA KEV are public feeds. Screenshots with
banana softwareread as a toy. Screenshots with Log4Shell read as a tool.
Roadmap
- Streaming responses via Tauri Events (kill the micro-freeze on long reports)
- In-UI sync button with a live ingest log stream
- KEV catalog persisted to a local table (offline flags)
- CWE layer in the graph: vendor weakness patterns
- CI release builds per OS
Try it
git clone https://github.com/sleepti3ht/Panopticon.git
cd Panopticon # repo root IS the desktop project
# Python backend
cd panopticon-python
python -m venv venv
venv\Scripts\activate # Windows; on PS policy errors: .\venv\Scripts\Activate.ps1
# source venv/bin/activate # macOS / Linux
pip install -r requirements.txt
Copy-Item .env.example .env # PowerShell (cmd/Unix: cp .env.example .env)
# add your OPENROUTER_API_KEY to .env
python seed_mock.py # instant demo data, no keys needed
# Frontend + Tauri
cd ..
npm install
npm run tauri dev
Ready for the real feed? python ingestor.py 4000 90 pulls a rolling 90-day window from NVD, auto-chunked against the 120-day query limit.
Repo: github.com/sleepti3ht/Panopticon — MIT, issues and PRs open.
Panopticon does not replace your scanner or your SIEM. It replaces the six tabs you keep open during triage — and remembers what you figured out yesterday.
Top comments (0)