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:
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
src/psyc/cockpit/templates/lookup.html
Normal file
66
src/psyc/cockpit/templates/lookup.html
Normal 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://… · <sha256> · 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 %}
|
||||
Reference in New Issue
Block a user