stage-24: indicator lookup page + blocklist download in cockpit

Surfaces the stage-23 index in the UI. New /lookup page: paste any
indicator (IP/domain/URL/hash/CVE) → red KNOWN-BAD banner with the
matching cases/feeds/severities, or green clean banner. New
/export/blocklist endpoint returns deduplicated plain-text indicator
lists (all or high+ severity) for firewall/DNS/SIEM ingestion, linked
from a download table on the lookup page. Lookup added to topbar nav.

Verified live: lookup of a real corpus IP returns the OTX case;
8.8.8.8 returns clean; blocklist endpoint emits 26 high-severity IPs
with a descriptive header line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-22 23:55:50 +02:00
parent 9a2a31ec9a
commit d0a71d0226
4 changed files with 107 additions and 1 deletions

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from typing import List
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -14,6 +14,7 @@ from psyc import db, log
from psyc.cockpit import inference, journey as journey_view
from psyc.lines import courier as courier_line
from psyc.lines import ledger as ledger_line
from psyc.lines import lookup as lookup_line
from psyc.lines import route as route_line
from psyc.lines import seal as seal_line
from psyc.lines import train as train_line
@@ -110,6 +111,33 @@ def inference_status() -> dict:
return {"online": adapter is not None, "adapter": adapter}
@app.get("/lookup", response_class=HTMLResponse)
def lookup_view(request: Request, q: str = "") -> HTMLResponse:
query = q.strip()
matches = lookup_line.lookup(query) if query else []
counts = {t: len(lookup_line.export_blocklist(t)) for t in lookup_line.IOC_TYPES}
return TEMPLATES.TemplateResponse(
request,
"lookup.html",
{
"query": query,
"matches": matches,
"searched": bool(query),
"total_iocs": db.ioc_count(),
"counts": counts,
},
)
@app.get("/export/blocklist", response_class=PlainTextResponse)
def export_blocklist(type: str = "ip", min_severity: str = "") -> PlainTextResponse:
if type not in lookup_line.IOC_TYPES:
raise HTTPException(status_code=400, detail=f"unknown type: {type}")
values = lookup_line.export_blocklist(type, min_severity or None)
header = f"# psyc blocklist — type={type} min_severity={min_severity or 'any'} count={len(values)}\n"
return PlainTextResponse(header + "\n".join(values) + "\n")
@app.get("/queue", response_class=HTMLResponse)
def queue_view(request: Request, status: str = "pending") -> HTMLResponse:
from psyc.models import ApprovalStatus

View File

@@ -314,3 +314,14 @@ tr.sev-low .sev-badge { color: var(--muted); }
}
.reject-reason::placeholder { color: var(--muted); }
.outcome-pending_approval { background: rgba(251, 191, 36, 0.15); color: var(--amber); border: 1px solid rgba(251, 191, 36, 0.4); }
/* ── indicator lookup ───────────────────────────────────────── */
.lookup-form { display: flex; gap: 8px; margin: 14px 0 18px; }
.lookup-input {
flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--border);
border-radius: 4px; padding: 9px 12px; font: inherit; font-size: 14px;
}
.lookup-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.verdict { padding: 12px 16px; border-radius: 6px; margin: 14px 0; font-size: 14px; }
.verdict-bad { background: rgba(248, 113, 113, 0.12); border: 1px solid var(--red); color: var(--red); }
.verdict-clean { background: rgba(74, 222, 128, 0.10); border: 1px solid var(--green); color: var(--green); }

View File

@@ -18,6 +18,7 @@
</a>
<nav class="nav">
<a href="/cases">Cases</a>
<a href="/lookup">Lookup</a>
<a href="/queue">Queue</a>
<a href="/ledger">Ledger</a>
<a href="/train">Trainline</a>

View File

@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}Lookup — psyc{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Indicator Lookup</h1>
<span class="count">{{ total_iocs }} indicators indexed</span>
</div>
<p class="page-intro">Paste any indicator — IP, domain, URL, file hash, or CVE — and psyc tells you whether it's known-bad across the whole case corpus, which feed flagged it, and at what severity. This is the "is this thing dangerous?" desk check.</p>
<details class="page-help">
<summary>how to use this view</summary>
<div class="help-body">
<p><b>How to use.</b> Type or paste an indicator and hit Look up. A green banner means it's clean (not in the corpus); a red banner means it matched known threat intel — open the case to see the full context.</p>
<p><b>What you're seeing.</b> Matches come from the IOC index built across all {{ total_iocs }} indicators in the corpus. Lookup is case- and format-insensitive (EVIL.COM = evil.com).</p>
<p><b>Why it matters.</b> A defender investigating an alert needs a fast verdict on a raw indicator — and a way to push the whole known-bad set into a firewall or DNS sinkhole (see Blocklist export below).</p>
</div>
</details>
<form method="get" action="/lookup" class="lookup-form">
<input type="text" name="q" value="{{ query }}" placeholder="1.2.3.4 · evil.com · http://… · &lt;sha256&gt; · CVE-2024-3721" class="lookup-input" autofocus>
<button type="submit" class="btn btn-approve">Look up</button>
</form>
{% if searched %}
{% if matches %}
<div class="verdict verdict-bad"><strong>{{ query }}</strong> is KNOWN-BAD — {{ matches|length }} match(es) in the corpus</div>
<table class="ledger">
<thead>
<tr><th>Type</th><th>Case</th><th>Feed</th><th>Severity</th><th>First seen</th></tr>
</thead>
<tbody>
{% for m in matches %}
<tr class="ledger-row sev-{{ m.severity or 'none' }}">
<td>{{ m.ioc_type }}</td>
<td class="lg-case"><a href="/cases/{{ m.case_id }}">{{ m.case_id }}</a></td>
<td class="lg-dest">{{ m.feed or '—' }}</td>
<td>{% if m.severity %}<span class="sev-badge">{{ m.severity }}</span>{% else %}—{% endif %}</td>
<td class="lg-ts">{{ (m.first_seen or '')[:10] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="verdict verdict-clean"><strong>{{ query }}</strong> is not in the corpus — no known-bad match</div>
{% endif %}
{% endif %}
</section>
<section class="panel">
<div class="panel-head"><h2>Blocklist export</h2></div>
<p class="page-intro">Download the deduplicated set of known-bad indicators of one type as plain text — ready to paste into a firewall denylist, DNS sinkhole, or SIEM watchlist.</p>
<table class="ledger">
<thead><tr><th>Type</th><th>Count</th><th>Download (all)</th><th>Download (high+)</th></tr></thead>
<tbody>
{% for t, n in counts.items() %}
<tr class="ledger-row">
<td>{{ t }}</td>
<td>{{ n }}</td>
<td><a href="/export/blocklist?type={{ t }}" target="_blank">{{ t }} blocklist ▾</a></td>
<td><a href="/export/blocklist?type={{ t }}&min_severity=high" target="_blank">{{ t }} (high+) ▾</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}