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:
m17hr1l
2026-05-23 00:24:31 +02:00
parent d0a71d0226
commit 73a932d8be
11 changed files with 635 additions and 1 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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; }
}

View File

@@ -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>

View 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 &lt;case_id&gt;</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 %}

View File

@@ -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
View 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)

View File

@@ -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]}

View File

@@ -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
View 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