stage-25: response actions — human-gated enforcement + the disco
Closes the loop: intel -> decision -> enforcement -> audit. High/critical cases propose response actions (alert SOC, push IOCs to perimeter firewall+DNS). Nothing fires automatically — each sits PROPOSED until a human approves, then it's POSTed to the enforcement sink (PSYC_SOAR_URL, default mock-cert /soar/enforce) and written to the ledger as ACTIONED. - models: ActionType / ActionStatus / ResponseAction - db: response_actions table - lines/respond.py: propose_for_case (idempotent, sev-gated), execute_action (fire + ledger + mark), reject_action; mock SOAR endpoint in mock_cert - cockpit /response page: proposed/enforced/declined tabs, ⚡ Enforce + decline, and the disco — a full-screen strobe + "ENFORCED" + IOC-scatter animation that fires on approval (respects prefers-reduced-motion) - cli: respond / actions / act-approve / act-reject - 8 tests; verified the full loop live (propose -> enforce -> disco -> SOAR receipt -> ledger ACTIONED row) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ services:
|
|||||||
VIRTUAL_HOST: psyc.neuronetz.ai
|
VIRTUAL_HOST: psyc.neuronetz.ai
|
||||||
VIRTUAL_PORT: "8767"
|
VIRTUAL_PORT: "8767"
|
||||||
PSYC_MOCK_CERT_URL: http://mock-cert:8770
|
PSYC_MOCK_CERT_URL: http://mock-cert:8770
|
||||||
|
PSYC_SOAR_URL: http://mock-cert:8770
|
||||||
PSYC_INFERENCE_URL: http://inference:8771
|
PSYC_INFERENCE_URL: http://inference:8771
|
||||||
ports:
|
ports:
|
||||||
- "8767:8767" # direct/debug access; the proxy serves psyc.neuronetz.ai on :80
|
- "8767:8767" # direct/debug access; the proxy serves psyc.neuronetz.ai on :80
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from psyc import db, log
|
|||||||
|
|
||||||
load_dotenv() # per-dev .env (API keys) is loaded into os.environ for venv CLI
|
load_dotenv() # per-dev .env (API keys) is loaded into os.environ for venv CLI
|
||||||
from psyc.cockpit import inference
|
from psyc.cockpit import inference
|
||||||
from psyc.lines import classify, courier, lookup, proof, route, scout, seal, train
|
from psyc.lines import classify, courier, lookup, proof, respond, route, scout, seal, train
|
||||||
from psyc.lines import map as map_line
|
from psyc.lines import map as map_line
|
||||||
from psyc.models import Outcome
|
from psyc.models import Outcome
|
||||||
from psyc.result import Err, Ok
|
from psyc.result import Err, Ok
|
||||||
@@ -396,6 +396,63 @@ def export_blocklist(
|
|||||||
typer.echo(text)
|
typer.echo(text)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("respond")
|
||||||
|
def respond_propose(case_id: str = typer.Argument(..., help="case to propose response actions for")) -> None:
|
||||||
|
"""Propose human-gated response actions for a high-severity case."""
|
||||||
|
result = db.get_case(case_id)
|
||||||
|
if isinstance(result, Err):
|
||||||
|
typer.echo(f"error: {result.reason}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
ids = respond.propose_for_case(result.value)
|
||||||
|
if not ids:
|
||||||
|
typer.echo(f"{case_id}: no actions proposed (not high-severity, or already has actions)")
|
||||||
|
return
|
||||||
|
typer.echo(f"{case_id}: proposed {len(ids)} action(s) → ids {', '.join(map(str, ids))}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("actions")
|
||||||
|
def actions_list(status: str = typer.Option("proposed", help="proposed | executed | rejected | failed | all")) -> None:
|
||||||
|
"""List response actions."""
|
||||||
|
from psyc.models import ActionStatus
|
||||||
|
sf = None if status == "all" else ActionStatus(status)
|
||||||
|
rows = respond.list_actions(status=sf)
|
||||||
|
if not rows:
|
||||||
|
typer.echo(f"(no actions with status={status})")
|
||||||
|
return
|
||||||
|
for a in rows:
|
||||||
|
appr = f" by {a.approver}" if a.approver else ""
|
||||||
|
typer.echo(f" #{a.id} {a.status.value:9s} [{a.action_type.value:9s}] {a.case_id} sev={a.severity or '?'}{appr}")
|
||||||
|
typer.echo(f" {a.summary}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("act-approve")
|
||||||
|
def act_approve(
|
||||||
|
action_id: int = typer.Argument(..., help="response action id"),
|
||||||
|
approver: str = typer.Option("operator", "--by", help="approver identity"),
|
||||||
|
) -> None:
|
||||||
|
"""Approve + fire a response action (pushes to the enforcement sink)."""
|
||||||
|
result = respond.execute_action(action_id, approver=approver)
|
||||||
|
if isinstance(result, Err):
|
||||||
|
typer.echo(f"error: {result.reason}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
a = result.value
|
||||||
|
typer.echo(f"⚡ enforced #{action_id} [{a.action_type.value}] → {a.detail}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("act-reject")
|
||||||
|
def act_reject(
|
||||||
|
action_id: int = typer.Argument(..., help="response action id"),
|
||||||
|
approver: str = typer.Option("operator", "--by", help="reviewer identity"),
|
||||||
|
reason: str = typer.Option("", "--reason", help="why declined"),
|
||||||
|
) -> None:
|
||||||
|
"""Decline a proposed response action — nothing fires."""
|
||||||
|
result = respond.reject_action(action_id, approver=approver, reason=reason)
|
||||||
|
if isinstance(result, Err):
|
||||||
|
typer.echo(f"error: {result.reason}", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
typer.echo(f"declined #{action_id}{(': ' + reason) if reason else ''}")
|
||||||
|
|
||||||
|
|
||||||
@app.command("mock-cert")
|
@app.command("mock-cert")
|
||||||
def mock_cert_serve(host: str = "127.0.0.1", port: int = 8770) -> None:
|
def mock_cert_serve(host: str = "127.0.0.1", port: int = 8770) -> None:
|
||||||
uvicorn.run("psyc.mock_cert:app", host=host, port=port)
|
uvicorn.run("psyc.mock_cert:app", host=host, port=port)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from psyc.cockpit import inference, journey as journey_view
|
|||||||
from psyc.lines import courier as courier_line
|
from psyc.lines import courier as courier_line
|
||||||
from psyc.lines import ledger as ledger_line
|
from psyc.lines import ledger as ledger_line
|
||||||
from psyc.lines import lookup as lookup_line
|
from psyc.lines import lookup as lookup_line
|
||||||
|
from psyc.lines import respond as respond_line
|
||||||
from psyc.lines import route as route_line
|
from psyc.lines import route as route_line
|
||||||
from psyc.lines import seal as seal_line
|
from psyc.lines import seal as seal_line
|
||||||
from psyc.lines import train as train_line
|
from psyc.lines import train as train_line
|
||||||
@@ -138,6 +139,41 @@ def export_blocklist(type: str = "ip", min_severity: str = "") -> PlainTextRespo
|
|||||||
return PlainTextResponse(header + "\n".join(values) + "\n")
|
return PlainTextResponse(header + "\n".join(values) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/response", response_class=HTMLResponse)
|
||||||
|
def response_view(request: Request, status: str = "proposed", fired: int = 0, kind: str = "") -> HTMLResponse:
|
||||||
|
from psyc.models import ActionStatus
|
||||||
|
sf = None if status == "all" else ActionStatus(status)
|
||||||
|
actions = respond_line.list_actions(status=sf, limit=200)
|
||||||
|
counts = {
|
||||||
|
"proposed": respond_line.action_count(ActionStatus.PROPOSED),
|
||||||
|
"executed": respond_line.action_count(ActionStatus.EXECUTED),
|
||||||
|
"rejected": respond_line.action_count(ActionStatus.REJECTED),
|
||||||
|
"failed": respond_line.action_count(ActionStatus.FAILED),
|
||||||
|
}
|
||||||
|
return TEMPLATES.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"response.html",
|
||||||
|
{"actions": actions, "counts": counts, "current_status": status, "fired": fired, "fired_kind": kind},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/response/approve/{action_id}")
|
||||||
|
def response_approve(action_id: int, approver: str = Form("operator")) -> RedirectResponse:
|
||||||
|
result = respond_line.execute_action(action_id, approver=approver)
|
||||||
|
if isinstance(result, Err):
|
||||||
|
_log.warning("cockpit.response.approve.error", action_id=action_id, reason=result.reason)
|
||||||
|
return RedirectResponse("/response", status_code=303)
|
||||||
|
# Carry the fired action id + type so the page can set off the disco.
|
||||||
|
kind = result.value.action_type.value
|
||||||
|
return RedirectResponse(f"/response?fired={action_id}&kind={kind}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/response/reject/{action_id}")
|
||||||
|
def response_reject(action_id: int, approver: str = Form("operator"), reason: str = Form("")) -> RedirectResponse:
|
||||||
|
respond_line.reject_action(action_id, approver=approver, reason=reason)
|
||||||
|
return RedirectResponse("/response", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/queue", response_class=HTMLResponse)
|
@app.get("/queue", response_class=HTMLResponse)
|
||||||
def queue_view(request: Request, status: str = "pending") -> HTMLResponse:
|
def queue_view(request: Request, status: str = "pending") -> HTMLResponse:
|
||||||
from psyc.models import ApprovalStatus
|
from psyc.models import ApprovalStatus
|
||||||
|
|||||||
@@ -325,3 +325,53 @@ tr.sev-low .sev-badge { color: var(--muted); }
|
|||||||
.verdict { padding: 12px 16px; border-radius: 6px; margin: 14px 0; font-size: 14px; }
|
.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-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); }
|
.verdict-clean { background: rgba(74, 222, 128, 0.10); border: 1px solid var(--green); color: var(--green); }
|
||||||
|
|
||||||
|
/* ── response actions ───────────────────────────────────────── */
|
||||||
|
.act-type { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; text-transform: uppercase; border: 1px solid var(--border); }
|
||||||
|
.act-alert { color: var(--amber); border-color: var(--amber); }
|
||||||
|
.act-blocklist { color: var(--red); border-color: var(--red); }
|
||||||
|
.act-ticket { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.btn-enforce { color: var(--bg); background: var(--accent); border-color: var(--accent); font-weight: 700; }
|
||||||
|
.btn-enforce:hover { background: #5ad8ff; box-shadow: 0 0 12px var(--accent-glow); }
|
||||||
|
.outcome-failed { background: rgba(248,113,113,0.15); color: var(--red); border: 1px solid var(--red); }
|
||||||
|
|
||||||
|
/* ── the disco: enforcement payoff ──────────────────────────── */
|
||||||
|
.disco {
|
||||||
|
position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(8,10,14,0.72); backdrop-filter: blur(2px); animation: disco-in 0.25s ease-out;
|
||||||
|
}
|
||||||
|
.disco.disco-out { animation: disco-fade 0.7s ease forwards; }
|
||||||
|
.disco-strobe {
|
||||||
|
position: absolute; inset: 0; mix-blend-mode: screen; animation: strobe 0.42s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes strobe {
|
||||||
|
0% { background: radial-gradient(circle at 30% 40%, rgba(30,200,255,0.55), transparent 55%); }
|
||||||
|
25% { background: radial-gradient(circle at 70% 30%, rgba(248,113,113,0.55), transparent 55%); }
|
||||||
|
50% { background: radial-gradient(circle at 50% 70%, rgba(74,222,128,0.50), transparent 55%); }
|
||||||
|
75% { background: radial-gradient(circle at 25% 65%, rgba(251,191,36,0.50), transparent 55%); }
|
||||||
|
100% { background: radial-gradient(circle at 75% 50%, rgba(167,139,250,0.55), transparent 55%); }
|
||||||
|
}
|
||||||
|
.disco-core { position: relative; text-align: center; animation: disco-pop 0.45s cubic-bezier(.2,1.4,.4,1); }
|
||||||
|
.disco-bolt { font-size: 84px; line-height: 1; filter: drop-shadow(0 0 18px var(--accent)); animation: bolt-buzz 0.18s steps(2) infinite; }
|
||||||
|
@keyframes bolt-buzz { 0%{transform:rotate(-6deg) scale(1);} 100%{transform:rotate(6deg) scale(1.08);} }
|
||||||
|
.disco-headline {
|
||||||
|
font-family: var(--font-display); font-size: 64px; font-weight: 700; letter-spacing: 0.12em;
|
||||||
|
color: #fff; text-shadow: 0 0 24px var(--accent), 0 0 48px rgba(30,200,255,0.6); margin-top: 6px;
|
||||||
|
}
|
||||||
|
.disco-sub { color: var(--text); font-size: 14px; margin-top: 4px; opacity: 0.9; }
|
||||||
|
.disco-iocs { margin-top: 18px; height: 28px; position: relative; }
|
||||||
|
.ioc-fly {
|
||||||
|
position: absolute; left: 50%; font-size: 22px; opacity: 0;
|
||||||
|
animation: ioc-scatter 1.1s ease-out var(--d) forwards;
|
||||||
|
}
|
||||||
|
@keyframes ioc-scatter {
|
||||||
|
0% { opacity: 0; transform: translate(-50%, 0) scale(0.4); }
|
||||||
|
30% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translate(calc(-50% + var(--x)), -120px) scale(1.1); }
|
||||||
|
}
|
||||||
|
@keyframes disco-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes disco-fade { to { opacity: 0; } }
|
||||||
|
@keyframes disco-pop { from { transform: scale(0.6); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.disco-strobe, .disco-bolt, .ioc-fly { animation: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/cases">Cases</a>
|
<a href="/cases">Cases</a>
|
||||||
<a href="/lookup">Lookup</a>
|
<a href="/lookup">Lookup</a>
|
||||||
|
<a href="/response">Response</a>
|
||||||
<a href="/queue">Queue</a>
|
<a href="/queue">Queue</a>
|
||||||
<a href="/ledger">Ledger</a>
|
<a href="/ledger">Ledger</a>
|
||||||
<a href="/train">Trainline</a>
|
<a href="/train">Trainline</a>
|
||||||
|
|||||||
81
src/psyc/cockpit/templates/response.html
Normal file
81
src/psyc/cockpit/templates/response.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Response — psyc{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if fired %}
|
||||||
|
<div class="disco" id="disco">
|
||||||
|
<div class="disco-strobe"></div>
|
||||||
|
<div class="disco-core">
|
||||||
|
<div class="disco-bolt">⚡</div>
|
||||||
|
<div class="disco-headline">ENFORCED</div>
|
||||||
|
<div class="disco-sub">action #{{ fired }} · {{ fired_kind }} pushed to the perimeter</div>
|
||||||
|
<div class="disco-iocs">
|
||||||
|
{% for i in range(8) %}<span class="ioc-fly" style="--d: {{ i * 0.08 }}s; --x: {{ (i - 4) * 60 }}px;">⛔</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTimeout(function () {
|
||||||
|
var d = document.getElementById('disco');
|
||||||
|
if (d) { d.classList.add('disco-out'); setTimeout(function(){ d.remove(); }, 700); }
|
||||||
|
}, 2600);
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h1>Response Actions</h1>
|
||||||
|
<span class="count">{{ counts.proposed }} proposed · {{ counts.executed }} enforced · {{ counts.rejected }} declined{% if counts.failed %} · {{ counts.failed }} failed{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<p class="page-intro">When a high-severity case lands, psyc proposes what to <em>do</em> about it — alert the SOC, push its IOCs to the perimeter firewall + DNS. Nothing fires on its own: you approve, psyc enforces, the ledger records it. Detection that acts, with a human on the trigger.</p>
|
||||||
|
<details class="page-help">
|
||||||
|
<summary>how to use this view</summary>
|
||||||
|
<div class="help-body">
|
||||||
|
<p><b>How to use.</b> Each proposed action is one defensive move. Hit <b>⚡ Enforce</b> to fire it (and enjoy the disco), or Decline to drop it. Both decisions are logged to the immutable ledger.</p>
|
||||||
|
<p><b>What you're seeing.</b> Actions generated by Respondline for HIGH/CRITICAL cases. The frozen payload is exactly what gets pushed to the enforcement sink on approval.</p>
|
||||||
|
<p><b>Why it matters.</b> Closing the loop — intel → decision → enforcement → audit — is what separates a threat <em>viewer</em> from a threat <em>response</em> platform. The human gate keeps automation safe.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="queue-tabs">
|
||||||
|
<a class="queue-tab{% if current_status == 'proposed' %} is-active{% endif %}" href="/response?status=proposed">proposed ({{ counts.proposed }})</a>
|
||||||
|
<a class="queue-tab{% if current_status == 'executed' %} is-active{% endif %}" href="/response?status=executed">enforced ({{ counts.executed }})</a>
|
||||||
|
<a class="queue-tab{% if current_status == 'rejected' %} is-active{% endif %}" href="/response?status=rejected">declined ({{ counts.rejected }})</a>
|
||||||
|
<a class="queue-tab{% if current_status == 'all' %} is-active{% endif %}" href="/response?status=all">all</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not actions %}
|
||||||
|
<p class="empty">No actions here. Propose some with <code>psyc respond <case_id></code> on a HIGH/CRITICAL case, or run <code>psyc demo</code>.</p>
|
||||||
|
{% else %}
|
||||||
|
<table class="ledger">
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>Type</th><th>Case</th><th>Sev</th><th>What it does</th><th>Status</th><th>Action</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in actions %}
|
||||||
|
<tr class="ledger-row sev-{{ a.severity or 'none' }}{% if a.status.value == 'rejected' %} is-rejected{% elif a.status.value == 'executed' %} is-actioned{% endif %}">
|
||||||
|
<td>#{{ a.id }}</td>
|
||||||
|
<td><span class="act-type act-{{ a.action_type.value }}">{{ a.action_type.value }}</span></td>
|
||||||
|
<td class="lg-case"><a href="/cases/{{ a.case_id }}">{{ a.case_id }}</a></td>
|
||||||
|
<td><span class="sev-badge">{{ a.severity or '—' }}</span></td>
|
||||||
|
<td>{{ a.summary }}</td>
|
||||||
|
<td><span class="outcome-badge outcome-{{ 'actioned' if a.status.value == 'executed' else ('rejected' if a.status.value == 'rejected' else ('failed' if a.status.value == 'failed' else 'pending_approval')) }}">{{ a.status.value }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if a.status.value == 'proposed' %}
|
||||||
|
<form method="post" action="/response/approve/{{ a.id }}" class="queue-action">
|
||||||
|
<button type="submit" class="btn btn-enforce">⚡ Enforce</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/response/reject/{{ a.id }}" class="queue-action">
|
||||||
|
<button type="submit" class="btn btn-reject">decline</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="lg-sub">{{ a.approver or '—' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -82,6 +82,24 @@ pending = Table(
|
|||||||
Index("pending_status_idx", pending.c.status)
|
Index("pending_status_idx", pending.c.status)
|
||||||
Index("pending_case_idx", pending.c.case_id)
|
Index("pending_case_idx", pending.c.case_id)
|
||||||
|
|
||||||
|
response_actions = Table(
|
||||||
|
"response_actions", _metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("case_id", String, nullable=False),
|
||||||
|
Column("action_type", String, nullable=False),
|
||||||
|
Column("target", String, nullable=False),
|
||||||
|
Column("summary", Text, nullable=False),
|
||||||
|
Column("payload_json", Text, nullable=False),
|
||||||
|
Column("severity", String, nullable=True),
|
||||||
|
Column("status", String, nullable=False),
|
||||||
|
Column("created_at", String, nullable=False),
|
||||||
|
Column("approver", String, nullable=True),
|
||||||
|
Column("executed_at", String, nullable=True),
|
||||||
|
Column("detail", Text, nullable=True),
|
||||||
|
)
|
||||||
|
Index("actions_status_idx", response_actions.c.status)
|
||||||
|
Index("actions_case_idx", response_actions.c.case_id)
|
||||||
|
|
||||||
iocs = Table(
|
iocs = Table(
|
||||||
"iocs", _metadata,
|
"iocs", _metadata,
|
||||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
|||||||
211
src/psyc/lines/respond.py
Normal file
211
src/psyc/lines/respond.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""Respondline — human-gated response actions (SOAR-lite).
|
||||||
|
|
||||||
|
High-severity cases propose response actions (alert the SOC, push IOCs to
|
||||||
|
enforcement, open a ticket). Nothing fires automatically: each action sits in
|
||||||
|
PROPOSED until a human approves it, mirroring the submission approval gate.
|
||||||
|
On approval the action is dispatched to the configured enforcement sink
|
||||||
|
(PSYC_SOAR_URL, default = the mock-cert container) and recorded in the ledger.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import func as sa_func
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from psyc import db, log
|
||||||
|
from psyc.lines import ledger as ledger_line
|
||||||
|
from psyc.models import ActionStatus, ActionType, Case, Outcome, ResponseAction, Severity, TLP
|
||||||
|
from psyc.result import Err, Ok, Result
|
||||||
|
|
||||||
|
|
||||||
|
_log = log.get(__name__)
|
||||||
|
|
||||||
|
SOAR_BASE = os.environ.get("PSYC_SOAR_URL", "http://127.0.0.1:8770")
|
||||||
|
SOAR_ENDPOINT = f"{SOAR_BASE}/soar/enforce"
|
||||||
|
DEFAULT_TIMEOUT = 10.0
|
||||||
|
APPROVER_DEFAULT = "operator"
|
||||||
|
|
||||||
|
# Only act on cases this severe or worse.
|
||||||
|
_ACTIONABLE = {Severity.HIGH, Severity.CRITICAL}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def propose_for_case(case: Case) -> List[int]:
|
||||||
|
"""Generate response actions for a high-severity case. Returns new action ids.
|
||||||
|
|
||||||
|
Idempotent per case: if actions already exist for this case, propose none.
|
||||||
|
"""
|
||||||
|
if case.classification.severity not in _ACTIONABLE:
|
||||||
|
return []
|
||||||
|
if _action_count_for_case(case.case_id) > 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sev = case.classification.severity.value
|
||||||
|
obs = case.observables
|
||||||
|
ioc_total = len(obs.ips) + len(obs.domains) + len(obs.urls) + len(obs.hashes)
|
||||||
|
actions: List[ResponseAction] = []
|
||||||
|
|
||||||
|
# 1. Alert the SOC.
|
||||||
|
actions.append(ResponseAction(
|
||||||
|
case_id=case.case_id,
|
||||||
|
action_type=ActionType.ALERT,
|
||||||
|
target="soc-webhook",
|
||||||
|
summary=f"Alert SOC: {sev.upper()} {case.classification.incident_type.value if case.classification.incident_type else 'threat'} — {case.summary[:80]}",
|
||||||
|
payload_json=json.dumps({
|
||||||
|
"kind": "alert", "case_id": case.case_id, "severity": sev,
|
||||||
|
"summary": case.summary, "ioc_count": ioc_total,
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
severity=sev,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
))
|
||||||
|
|
||||||
|
# 2. Push IOCs to enforcement, if there are any network indicators.
|
||||||
|
if obs.ips or obs.domains or obs.urls:
|
||||||
|
actions.append(ResponseAction(
|
||||||
|
case_id=case.case_id,
|
||||||
|
action_type=ActionType.BLOCKLIST,
|
||||||
|
target="perimeter-firewall+dns",
|
||||||
|
summary=f"Block {len(obs.ips)} IP(s), {len(obs.domains)} domain(s), {len(obs.urls)} URL(s) at the perimeter",
|
||||||
|
payload_json=json.dumps({
|
||||||
|
"kind": "blocklist", "case_id": case.case_id, "severity": sev,
|
||||||
|
"ips": obs.ips, "domains": obs.domains, "urls": obs.urls,
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
severity=sev,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
))
|
||||||
|
|
||||||
|
ids: List[int] = []
|
||||||
|
with db.engine().begin() as conn:
|
||||||
|
for a in actions:
|
||||||
|
res = conn.execute(db.response_actions.insert().values(
|
||||||
|
case_id=a.case_id, action_type=a.action_type.value, target=a.target,
|
||||||
|
summary=a.summary, payload_json=a.payload_json, severity=a.severity,
|
||||||
|
status=ActionStatus.PROPOSED.value, created_at=a.created_at.isoformat(),
|
||||||
|
))
|
||||||
|
ids.append(int(res.inserted_primary_key[0]))
|
||||||
|
_log.info("respond.proposed", case_id=case.case_id, actions=len(ids))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _action_count_for_case(case_id: str) -> int:
|
||||||
|
stmt = select(sa_func.count()).select_from(db.response_actions).where(db.response_actions.c.case_id == case_id)
|
||||||
|
with db.engine().connect() as conn:
|
||||||
|
return int(conn.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_action(row: Any) -> ResponseAction:
|
||||||
|
return ResponseAction(
|
||||||
|
id=row.id, case_id=row.case_id, action_type=ActionType(row.action_type),
|
||||||
|
target=row.target, summary=row.summary, payload_json=row.payload_json,
|
||||||
|
severity=row.severity, status=ActionStatus(row.status),
|
||||||
|
created_at=datetime.fromisoformat(row.created_at),
|
||||||
|
approver=row.approver,
|
||||||
|
executed_at=datetime.fromisoformat(row.executed_at) if row.executed_at else None,
|
||||||
|
detail=row.detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_actions(status: Optional[ActionStatus] = None, limit: int = 200) -> List[ResponseAction]:
|
||||||
|
stmt = select(db.response_actions)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(db.response_actions.c.status == status.value)
|
||||||
|
stmt = stmt.order_by(db.response_actions.c.created_at.desc()).limit(limit)
|
||||||
|
with db.engine().connect() as conn:
|
||||||
|
return [_row_to_action(r) for r in conn.execute(stmt).fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_action(action_id: int) -> Result[ResponseAction, str]:
|
||||||
|
stmt = select(db.response_actions).where(db.response_actions.c.id == action_id)
|
||||||
|
with db.engine().connect() as conn:
|
||||||
|
row = conn.execute(stmt).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return Err(f"action not found: {action_id}")
|
||||||
|
return Ok(_row_to_action(row))
|
||||||
|
|
||||||
|
|
||||||
|
def action_count(status: ActionStatus) -> int:
|
||||||
|
stmt = select(sa_func.count()).select_from(db.response_actions).where(db.response_actions.c.status == status.value)
|
||||||
|
with db.engine().connect() as conn:
|
||||||
|
return int(conn.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def execute_action(action_id: int, approver: str = APPROVER_DEFAULT) -> Result[ResponseAction, str]:
|
||||||
|
"""Approve + fire an action: POST to the enforcement sink, ledger it, mark executed."""
|
||||||
|
got = get_action(action_id)
|
||||||
|
if isinstance(got, Err):
|
||||||
|
return Err(got.reason)
|
||||||
|
a = got.value
|
||||||
|
if a.status != ActionStatus.PROPOSED:
|
||||||
|
return Err(f"action {action_id} is already {a.status.value}")
|
||||||
|
|
||||||
|
payload = json.loads(a.payload_json)
|
||||||
|
payload["action_type"] = a.action_type.value
|
||||||
|
payload["approved_by"] = approver
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=DEFAULT_TIMEOUT) as client:
|
||||||
|
resp = client.post(SOAR_ENDPOINT, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
receipt = str(body.get("receipt_id", ""))
|
||||||
|
ok = True
|
||||||
|
detail = f"enforced_by={approver} → {receipt}"
|
||||||
|
except Exception as exc: # noqa: BLE001 — network/sink failure is expected-path
|
||||||
|
ok = False
|
||||||
|
detail = f"enforcement failed: {exc}"
|
||||||
|
_log.warning("respond.execute.error", action_id=action_id, error=str(exc))
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
new_status = ActionStatus.EXECUTED if ok else ActionStatus.FAILED
|
||||||
|
ledger_line.write(
|
||||||
|
case_id=a.case_id,
|
||||||
|
destination=f"SOAR:{a.action_type.value}:{a.target}",
|
||||||
|
payload_hash="",
|
||||||
|
submitter_identity="psyc/respond@0.1",
|
||||||
|
tlp=TLP.AMBER,
|
||||||
|
outcome=Outcome.ACTIONED if ok else Outcome.FAILED,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
with db.engine().begin() as conn:
|
||||||
|
conn.execute(update(db.response_actions).where(db.response_actions.c.id == action_id).values(
|
||||||
|
status=new_status.value, approver=approver, executed_at=now, detail=detail,
|
||||||
|
))
|
||||||
|
_log.info("respond.executed", action_id=action_id, ok=ok, approver=approver)
|
||||||
|
if not ok:
|
||||||
|
return Err(detail)
|
||||||
|
refreshed = get_action(action_id)
|
||||||
|
return refreshed if isinstance(refreshed, Ok) else Err("post-execute read failed")
|
||||||
|
|
||||||
|
|
||||||
|
def reject_action(action_id: int, approver: str = APPROVER_DEFAULT, reason: str = "") -> Result[None, str]:
|
||||||
|
"""Decline a proposed action — nothing fires; ledger records the decision."""
|
||||||
|
got = get_action(action_id)
|
||||||
|
if isinstance(got, Err):
|
||||||
|
return Err(got.reason)
|
||||||
|
a = got.value
|
||||||
|
if a.status != ActionStatus.PROPOSED:
|
||||||
|
return Err(f"action {action_id} is already {a.status.value}")
|
||||||
|
ledger_line.write(
|
||||||
|
case_id=a.case_id,
|
||||||
|
destination=f"SOAR:{a.action_type.value}:{a.target}",
|
||||||
|
payload_hash="",
|
||||||
|
submitter_identity="psyc/respond@0.1",
|
||||||
|
tlp=TLP.AMBER,
|
||||||
|
outcome=Outcome.REJECTED,
|
||||||
|
detail=f"declined_by={approver}: {reason}" if reason else f"declined_by={approver}",
|
||||||
|
)
|
||||||
|
with db.engine().begin() as conn:
|
||||||
|
conn.execute(update(db.response_actions).where(db.response_actions.c.id == action_id).values(
|
||||||
|
status=ActionStatus.REJECTED.value, approver=approver, executed_at=_now(),
|
||||||
|
detail=reason or None,
|
||||||
|
))
|
||||||
|
_log.info("respond.rejected", action_id=action_id, approver=approver)
|
||||||
|
return Ok(None)
|
||||||
@@ -75,6 +75,13 @@ def submit_abuseipdb(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return {"receipt_id": sub.receipt_id, "destination": sub.destination, "status": sub.status, "received_at": sub.received_at.isoformat()}
|
return {"receipt_id": sub.receipt_id, "destination": sub.destination, "status": sub.status, "received_at": sub.received_at.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/soar/enforce")
|
||||||
|
def soar_enforce(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Mock enforcement sink — stands in for a firewall/DNS/SOAR webhook."""
|
||||||
|
sub = _record(f"SOAR:{payload.get('action_type', 'action')}", "enforced", payload)
|
||||||
|
return {"receipt_id": sub.receipt_id, "destination": sub.destination, "status": sub.status, "received_at": sub.received_at.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/received")
|
@app.get("/received")
|
||||||
def received() -> Dict[str, Any]:
|
def received() -> Dict[str, Any]:
|
||||||
return {"count": len(_submissions), "submissions": [s.model_dump(mode="json") for s in _submissions]}
|
return {"count": len(_submissions), "submissions": [s.model_dump(mode="json") for s in _submissions]}
|
||||||
|
|||||||
@@ -168,3 +168,31 @@ class PendingSubmission(BaseModel):
|
|||||||
reviewer: Optional[str] = None
|
reviewer: Optional[str] = None
|
||||||
reviewed_at: Optional[datetime] = None
|
reviewed_at: Optional[datetime] = None
|
||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActionType(str, Enum):
|
||||||
|
ALERT = "alert" # notify the SOC (webhook/Slack)
|
||||||
|
BLOCKLIST = "blocklist" # push IOCs to firewall/DNS enforcement
|
||||||
|
TICKET = "ticket" # open an incident ticket
|
||||||
|
|
||||||
|
|
||||||
|
class ActionStatus(str, Enum):
|
||||||
|
PROPOSED = "proposed" # generated, awaiting human approval
|
||||||
|
EXECUTED = "executed" # approved + fired successfully
|
||||||
|
REJECTED = "rejected" # human declined; nothing fired
|
||||||
|
FAILED = "failed" # approved but execution errored
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseAction(BaseModel):
|
||||||
|
id: Optional[int] = None
|
||||||
|
case_id: str
|
||||||
|
action_type: ActionType
|
||||||
|
target: str # enforcement target label
|
||||||
|
summary: str # human-readable "what this does"
|
||||||
|
payload_json: str # frozen payload sent on execution
|
||||||
|
severity: Optional[str] = None
|
||||||
|
status: ActionStatus = ActionStatus.PROPOSED
|
||||||
|
created_at: datetime
|
||||||
|
approver: Optional[str] = None
|
||||||
|
executed_at: Optional[datetime] = None
|
||||||
|
detail: Optional[str] = None
|
||||||
|
|||||||
144
tests/test_respond.py
Normal file
144
tests/test_respond.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Respondline — proposal gating, human-gated execution, rejection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
|
from psyc import db
|
||||||
|
from psyc.lines import ledger as ledger_line
|
||||||
|
from psyc.lines import respond
|
||||||
|
from psyc.models import ActionStatus, ActionType, Outcome, Severity
|
||||||
|
from psyc.result import Ok
|
||||||
|
from conftest import make_case
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_db(tmp_path, monkeypatch):
|
||||||
|
test_db = tmp_path / "test.db"
|
||||||
|
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||||
|
db._metadata.create_all(eng, checkfirst=True)
|
||||||
|
monkeypatch.setattr(db, "_engine", eng)
|
||||||
|
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||||
|
yield test_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_severity_proposes_nothing(fresh_db):
|
||||||
|
case = make_case(feed="urlhaus", ips=["1.2.3.4"], severity=Severity.LOW)
|
||||||
|
db.upsert_case(case)
|
||||||
|
assert respond.propose_for_case(case) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_severity_proposes_alert_and_blocklist(fresh_db):
|
||||||
|
case = make_case(feed="feodo", ips=["9.9.9.9"], domains=["evil.com"], severity=Severity.HIGH)
|
||||||
|
db.upsert_case(case)
|
||||||
|
ids = respond.propose_for_case(case)
|
||||||
|
assert len(ids) == 2
|
||||||
|
actions = respond.list_actions(status=ActionStatus.PROPOSED)
|
||||||
|
types = {a.action_type for a in actions}
|
||||||
|
assert types == {ActionType.ALERT, ActionType.BLOCKLIST}
|
||||||
|
|
||||||
|
|
||||||
|
def test_proposal_is_idempotent_per_case(fresh_db):
|
||||||
|
case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.CRITICAL)
|
||||||
|
db.upsert_case(case)
|
||||||
|
respond.propose_for_case(case)
|
||||||
|
assert respond.propose_for_case(case) == [] # second call adds nothing
|
||||||
|
assert respond.action_count(ActionStatus.PROPOSED) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocklist_skipped_when_no_network_iocs(fresh_db):
|
||||||
|
case = make_case(feed="malware-bazaar", hashes=["a" * 64], severity=Severity.HIGH)
|
||||||
|
db.upsert_case(case)
|
||||||
|
respond.propose_for_case(case)
|
||||||
|
actions = respond.list_actions()
|
||||||
|
# hash-only case → alert yes, blocklist no
|
||||||
|
assert {a.action_type for a in actions} == {ActionType.ALERT}
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_fires_and_marks_executed(fresh_db, monkeypatch):
|
||||||
|
case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||||
|
db.upsert_case(case)
|
||||||
|
aid = respond.propose_for_case(case)[0]
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def raise_for_status(self): pass
|
||||||
|
def json(self): return {"receipt_id": "MOCK-AB12"}
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *a, **k): pass
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
def post(self, url, json):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["payload"] = json
|
||||||
|
return _Resp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(respond.httpx, "Client", _Client)
|
||||||
|
|
||||||
|
result = respond.execute_action(aid, approver="alice")
|
||||||
|
assert isinstance(result, Ok)
|
||||||
|
assert result.value.status is ActionStatus.EXECUTED
|
||||||
|
assert result.value.approver == "alice"
|
||||||
|
assert captured["payload"]["approved_by"] == "alice"
|
||||||
|
# ledger has an ACTIONED row
|
||||||
|
entries = ledger_line.list_by_case(case.case_id, limit=10)
|
||||||
|
assert any(e.outcome is Outcome.ACTIONED for e in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_failure_marks_failed(fresh_db, monkeypatch):
|
||||||
|
case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||||
|
db.upsert_case(case)
|
||||||
|
aid = respond.propose_for_case(case)[0]
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *a, **k): pass
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
def post(self, url, json): raise RuntimeError("sink down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(respond.httpx, "Client", _Client)
|
||||||
|
result = respond.execute_action(aid)
|
||||||
|
assert not isinstance(result, Ok)
|
||||||
|
assert respond.get_action(aid).value.status is ActionStatus.FAILED
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_fires_nothing_and_records(fresh_db, monkeypatch):
|
||||||
|
case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||||
|
db.upsert_case(case)
|
||||||
|
aid = respond.propose_for_case(case)[0]
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise AssertionError("reject must not POST")
|
||||||
|
monkeypatch.setattr(respond.httpx, "Client", boom)
|
||||||
|
|
||||||
|
result = respond.reject_action(aid, approver="bob", reason="false positive")
|
||||||
|
assert isinstance(result, Ok)
|
||||||
|
a = respond.get_action(aid).value
|
||||||
|
assert a.status is ActionStatus.REJECTED
|
||||||
|
assert a.approver == "bob"
|
||||||
|
entries = ledger_line.list_by_case(case.case_id, limit=10)
|
||||||
|
assert any(e.outcome is Outcome.REJECTED and "false positive" in (e.detail or "") for e in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_execute_refused(fresh_db, monkeypatch):
|
||||||
|
case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||||
|
db.upsert_case(case)
|
||||||
|
aid = respond.propose_for_case(case)[0]
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def raise_for_status(self): pass
|
||||||
|
def json(self): return {"receipt_id": "X"}
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *a, **k): pass
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
def post(self, url, json): return _Resp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(respond.httpx, "Client", _Client)
|
||||||
|
respond.execute_action(aid)
|
||||||
|
again = respond.execute_action(aid)
|
||||||
|
assert not isinstance(again, Ok) # already executed
|
||||||
Reference in New Issue
Block a user