diff --git a/.gitignore b/.gitignore index 1bbe76f..5de824d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,10 +8,7 @@ dist/ *.pyc # data / runtime -data/*.db -data/*.db-journal -data/sealed/ -data/keys/ +data/ # env .env diff --git a/src/psyc/__init__.py b/src/psyc/__init__.py index 3dc1f76..a5db1f8 100644 --- a/src/psyc/__init__.py +++ b/src/psyc/__init__.py @@ -1 +1,9 @@ +"""psyc — defensive CTI routing & evidence-sealing platform.""" + +from pathlib import Path + + __version__ = "0.1.0" + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +DATA_DIR = PROJECT_ROOT / "data" diff --git a/src/psyc/cli.py b/src/psyc/cli.py index 23fe6b8..8af34f8 100644 --- a/src/psyc/cli.py +++ b/src/psyc/cli.py @@ -2,11 +2,15 @@ from __future__ import annotations +from typing import List + import typer import uvicorn from psyc import db, log -from psyc.lines import scout +from psyc.lines import classify, courier, route, scout, seal +from psyc.models import Outcome +from psyc.result import Err, Ok app = typer.Typer(add_completion=False, help="psyc — defensive CTI routing & sealing") @@ -35,6 +39,201 @@ def fetch_urlhaus(limit: int = typer.Option(50, help="max rows to ingest from th typer.echo(f"ingested {len(cases)} case(s). total now: {db.case_count()}") +@app.command("classify-case") +def classify_case(case_id: str) -> None: + case_result = db.get_case(case_id) + if isinstance(case_result, Err): + typer.echo(f"error: {case_result.reason}", err=True) + raise typer.Exit(1) + case = classify.classify(case_result.value) + db.upsert_case(case) + typer.echo( + f"classified {case.case_id}: " + f"{case.classification.incident_type.value if case.classification.incident_type else '—'} / " + f"{case.classification.severity.value if case.classification.severity else '—'} / " + f"TLP:{case.classification.tlp.value} / " + f"class:{case.classification.internal_class.value if case.classification.internal_class else '—'}" + ) + + +@app.command("classify-all") +def classify_all() -> None: + cases = db.list_cases(limit=10_000) + for c in cases: + classify.classify(c) + db.upsert_case(c) + typer.echo(f"classified {len(cases)} case(s).") + + +@app.command("seal-keys-gen") +def seal_keys_gen(recipient: str) -> None: + path = seal.generate_recipient_keys(recipient) + typer.echo(f"keys generated for {recipient}: {path}") + + +@app.command("seal-keys-list") +def seal_keys_list() -> None: + recipients = seal.list_recipients() + if not recipients: + typer.echo("(no recipients)") + return + for r in recipients: + typer.echo(r) + + +@app.command("seal-pack") +def seal_pack(case_id: str, recipient: List[str] = typer.Option(..., "--recipient", "-r", help="recipient name (repeatable)")) -> None: + case_result = db.get_case(case_id) + if isinstance(case_result, Err): + typer.echo(f"error: {case_result.reason}", err=True) + raise typer.Exit(1) + case = case_result.value + plaintext = case.model_dump_json().encode("utf-8") + metadata = dict( + case_id=case.case_id, + tlp=case.classification.tlp.value, + severity=case.classification.severity.value if case.classification.severity else "", + incident_type=case.classification.incident_type.value if case.classification.incident_type else "", + ) + pkg_result = seal.seal(plaintext, recipient, metadata=metadata) + if isinstance(pkg_result, Err): + typer.echo(f"error: {pkg_result.reason}", err=True) + raise typer.Exit(1) + pkg = pkg_result.value + case.evidence.sealed_package_id = pkg.package_id + case.evidence.payload_hash = pkg.plaintext_hash + db.upsert_case(case) + typer.echo(f"sealed: {pkg.package_id} → {', '.join(recipient)}") + + +@app.command("seal-list") +def seal_list() -> None: + pkgs = seal.list_packages() + if not pkgs: + typer.echo("(no sealed packages)") + return + for p in pkgs: + typer.echo(p) + + +@app.command("seal-show") +def seal_show(package_id: str) -> None: + result = seal.load_package(package_id) + if isinstance(result, Err): + typer.echo(f"error: {result.reason}", err=True) + raise typer.Exit(1) + typer.echo(result.value.model_dump_json(indent=2)) + + +@app.command("seal-unseal") +def seal_unseal(package_id: str, recipient: str) -> None: + result = seal.unseal(package_id, recipient) + if isinstance(result, Err): + typer.echo(f"error: {result.reason}", err=True) + raise typer.Exit(1) + typer.echo(result.value.decode("utf-8")) + + +@app.command("route-plan") +def route_plan(case_id: str) -> None: + case_result = db.get_case(case_id) + if isinstance(case_result, Err): + typer.echo(f"error: {case_result.reason}", err=True) + raise typer.Exit(1) + case = case_result.value + routes, blocked = route.plan(case) + typer.echo(f"case {case_id} → {len(routes)} route(s), {len(blocked)} blocked") + for r in routes: + typer.echo(f" ✓ {r.destination_name} (priority={r.priority}, payload={r.payload_kind}, max_tlp={r.max_tlp_allowed.value})") + for b in blocked: + typer.echo(f" ✗ {b.destination_name}: {b.reason}") + + +@app.command("submit") +def submit_case(case_id: str) -> None: + case_result = db.get_case(case_id) + if isinstance(case_result, Err): + typer.echo(f"error: {case_result.reason}", err=True) + raise typer.Exit(1) + case = case_result.value + routes, blocked = route.plan(case) + sealed_pkg = None + if case.evidence.sealed_package_id: + pkg_result = seal.load_package(case.evidence.sealed_package_id) + if isinstance(pkg_result, Ok): + sealed_pkg = pkg_result.value + typer.echo(f"submitting {case_id} → {len(routes)} route(s), {len(blocked)} blocked") + courier.execute_blocked_routes(case, blocked) + results = courier.execute_routes(case, routes, sealed_pkg=sealed_pkg) + for r in results: + marker = "✓" if r.outcome != Outcome.FAILED else "✗" + rcpt = f" → {r.receipt_id}" if r.receipt_id else "" + detail = f" ({r.detail})" if r.detail else "" + typer.echo(f" {marker} {r.destination_name}: {r.outcome.value}{rcpt}{detail}") + for b in blocked: + typer.echo(f" ⊘ {b.destination_name}: {b.reason} (logged)") + + +@app.command("mock-cert") +def mock_cert_serve(host: str = "127.0.0.1", port: int = 8770) -> None: + uvicorn.run("psyc.mock_cert:app", host=host, port=port) + + +@app.command("demo") +def demo() -> None: + db.init_db() + typer.echo("== psyc demo: scout → classify → seal → route → submit → ledger ==") + for recipient in ("CERT-Bund", "MISP-Community"): + if recipient not in seal.list_recipients(): + seal.generate_recipient_keys(recipient) + typer.echo(f" + generated demo keys for {recipient}") + typer.echo("fetching one URLhaus row…") + cases = scout.fetch_and_signal(limit=1) + if not cases: + typer.echo("no cases ingested; URLhaus may be empty or unreachable", err=True) + raise typer.Exit(1) + case = cases[0] + db.upsert_case(case) + typer.echo(f" + ingested {case.case_id}") + case = classify.classify(case) + db.upsert_case(case) + typer.echo( + f" + classified: {case.classification.incident_type.value if case.classification.incident_type else '—'} / " + f"{case.classification.severity.value if case.classification.severity else '—'} / " + f"TLP:{case.classification.tlp.value}" + ) + plaintext = case.model_dump_json().encode("utf-8") + metadata = dict( + case_id=case.case_id, + tlp=case.classification.tlp.value, + severity=case.classification.severity.value if case.classification.severity else "", + incident_type=case.classification.incident_type.value if case.classification.incident_type else "", + ) + pkg_result = seal.seal(plaintext, ["CERT-Bund", "MISP-Community"], metadata=metadata) + if isinstance(pkg_result, Err): + typer.echo(f"seal failed: {pkg_result.reason}", err=True) + raise typer.Exit(1) + pkg = pkg_result.value + case.evidence.sealed_package_id = pkg.package_id + case.evidence.payload_hash = pkg.plaintext_hash + db.upsert_case(case) + typer.echo(f" + sealed: {pkg.package_id}") + routes, blocked = route.plan(case) + courier.execute_blocked_routes(case, blocked) + results = courier.execute_routes(case, routes, sealed_pkg=pkg) + typer.echo(f" + routed: {len(routes)} allowed, {len(blocked)} blocked") + for r in results: + marker = "✓" if r.outcome != Outcome.FAILED else "✗" + rcpt = f" → {r.receipt_id}" if r.receipt_id else "" + detail = f" ({r.detail})" if r.detail else "" + typer.echo(f" {marker} {r.destination_name}: {r.outcome.value}{rcpt}{detail}") + for b in blocked: + typer.echo(f" ⊘ {b.destination_name}: {b.reason}") + typer.echo("") + typer.echo(f"inspect: http://127.0.0.1:8767/cases/{case.case_id}") + typer.echo(f"ledger: http://127.0.0.1:8767/ledger") + + @app.command("serve") def serve(host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> None: db.init_db() diff --git a/src/psyc/cockpit/app.py b/src/psyc/cockpit/app.py index cf74d72..294dcad 100644 --- a/src/psyc/cockpit/app.py +++ b/src/psyc/cockpit/app.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from typing import List from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse @@ -10,6 +11,10 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from psyc import db, log +from psyc.lines import ledger as ledger_line +from psyc.lines import route as route_line +from psyc.lines import seal as seal_line +from psyc.lines.route import BlockedRoute, Route from psyc.result import Err @@ -41,7 +46,33 @@ def case_detail(request: Request, case_id: str) -> HTMLResponse: if isinstance(result, Err): _log.info("cockpit.case.miss", case_id=case_id) raise HTTPException(status_code=404, detail=result.reason) - return TEMPLATES.TemplateResponse(request, "case_detail.html", {"case": result.value}) + case = result.value + + routes: List[Route] = [] + blocked: List[BlockedRoute] = [] + if case.classification.incident_type is not None: + routes, blocked = route_line.plan(case) + + sealed_pkg = None + if case.evidence.sealed_package_id: + pkg_result = seal_line.load_package(case.evidence.sealed_package_id) + if not isinstance(pkg_result, Err): + sealed_pkg = pkg_result.value + + case_ledger = ledger_line.list_by_case(case_id, limit=50) + + return TEMPLATES.TemplateResponse( + request, + "case_detail.html", + {"case": case, "routes": routes, "blocked": blocked, "sealed_pkg": sealed_pkg, "ledger": case_ledger}, + ) + + +@app.get("/ledger", response_class=HTMLResponse) +def ledger_view(request: Request) -> HTMLResponse: + entries = ledger_line.list_recent(limit=200) + total = ledger_line.count() + return TEMPLATES.TemplateResponse(request, "ledger.html", {"entries": entries, "total": total}) @app.get("/healthz") diff --git a/src/psyc/cockpit/static/psyc-tokens.css b/src/psyc/cockpit/static/psyc-tokens.css new file mode 100644 index 0000000..382f428 --- /dev/null +++ b/src/psyc/cockpit/static/psyc-tokens.css @@ -0,0 +1,234 @@ +/* psyc-tokens.css + * Augments cockpit.css. Drop in after it: + * + * + * + * Dark theme only. Monospace stack inherited from cockpit.css. + * All text-bearing tokens below hit WCAG AA (>=4.5:1) on --panel (#161a22). + */ + +:root { + /* ---- Navy scale (anchored on cockpit bg/panel/border) ---- */ + --navy-50: #d8dee9; /* body text (12.9:1) */ + --navy-100: #aeb6c4; /* secondary text (7.7:1) */ + --navy-200: #7d8597; /* muted (4.6:1) */ + --navy-300: #5a6478; /* hairline emphasis */ + --navy-400: #3a4458; /* strong border */ + --navy-500: #262d3a; /* default border */ + --navy-600: #1c2230; /* raised surface (panel-2) */ + --navy-700: #161a22; /* panel surface */ + --navy-800: #0f1115; /* app bg */ + + /* ---- Cyan scale (anchored on --accent #1ec8ff) ---- */ + --cyan-50: #e0f7ff; /* highest-contrast text on panel */ + --cyan-100: #b8eaff; + --cyan-200: #7dd6ff; + --cyan-300: #1ec8ff; /* accent */ + --cyan-400: #0ba6db; + --cyan-500: #0982ad; + --cyan-600: #0a5f7d; /* filled-badge bg */ + --cyan-700: #07394d; /* deepest */ + + /* ---- TLP (text-on-panel; AA verified) ---- */ + --tlp-red: #ff5d5d; /* 5.3:1 */ + --tlp-amber: #fbbf24; /* 11.0:1 */ + --tlp-green: #4ade80; /* 11.4:1 */ + --tlp-clear: #cbd5e1; /* 11.5:1 */ + /* paired filled-badge tokens */ + --tlp-red-bg: #7f1d1d; --tlp-red-fg: #fee2e2; + --tlp-amber-bg: #78350f; --tlp-amber-fg: #fef3c7; + --tlp-green-bg: #14532d; --tlp-green-fg: #d1fae5; + --tlp-clear-bg: #1c2230; --tlp-clear-fg: #cbd5e1; + + /* ---- Severity (text-on-panel; AA verified) ---- */ + --sev-low: #94a3b8; /* 6.6:1 */ + --sev-medium: #fde047; /* 13.4:1 */ + --sev-high: #fb923c; /* 7.8:1 */ + --sev-critical: #ff5d5d; /* 5.3:1, paired with glow */ + + /* ---- Outcome (text-on-panel; AA verified) ---- */ + --out-submitted: #7dd6ff; /* 9.0:1 cyan */ + --out-acknowledged: #c4b5fd; /* 8.4:1 violet */ + --out-rejected: #f87171; /* 6.0:1 red */ + --out-actioned: #4ade80; /* 11.4:1 green */ + + /* ---- Spacing (4-step) ---- */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 16px; + --sp-4: 24px; + + /* ---- Type (4-step; px + weight) ---- */ + --fs-xs: 11px; --fw-xs: 600; /* badges, eyebrows */ + --fs-sm: 12px; --fw-sm: 400; /* meta, table head */ + --fs-md: 14px; --fw-md: 400; /* body (cockpit base) */ + --fs-lg: 18px; --fw-lg: 600; /* panel titles */ +} + +/* ===== Badges ============================================================ */ +/* Shared base — overrides cockpit's .sev-badge / .tlp-badge with tokens. */ +.sev-badge, +.tlp-badge, +.outcome-badge { + display: inline-block; + padding: 2px var(--sp-2); + border-radius: 3px; + font-size: var(--fs-xs); + font-weight: var(--fw-xs); + letter-spacing: 0.6px; + text-transform: uppercase; + line-height: 1.6; + border: 1px solid transparent; + white-space: nowrap; +} + +/* Severity — outlined, text-driven (works on panel or row hover) */ +.sev-badge { background: var(--navy-600); color: var(--navy-100); border-color: var(--navy-500); border-radius: 10px; } +.sev-badge.sev-low, tr.sev-low .sev-badge { color: var(--sev-low); border-color: var(--navy-400); } +.sev-badge.sev-medium, tr.sev-medium .sev-badge { color: var(--sev-medium); border-color: color-mix(in oklab, var(--sev-medium) 40%, var(--navy-500)); } +.sev-badge.sev-high, tr.sev-high .sev-badge { color: var(--sev-high); border-color: color-mix(in oklab, var(--sev-high) 50%, var(--navy-500)); } +.sev-badge.sev-critical, tr.sev-critical .sev-badge { + color: var(--sev-critical); + border-color: var(--sev-critical); + box-shadow: 0 0 0 1px color-mix(in oklab, var(--sev-critical) 25%, transparent); +} + +/* TLP — filled chips (FIRST.org-style emphasis) */ +.tlp-badge.tlp-RED { background: var(--tlp-red-bg); color: var(--tlp-red-fg); } +.tlp-badge.tlp-AMBER { background: var(--tlp-amber-bg); color: var(--tlp-amber-fg); } +.tlp-badge.tlp-GREEN { background: var(--tlp-green-bg); color: var(--tlp-green-fg); } +.tlp-badge.tlp-CLEAR { background: var(--tlp-clear-bg); color: var(--tlp-clear-fg); border-color: var(--navy-500); } + +/* Outcome — text-driven with a leading dot for scannability in dense rows */ +.outcome-badge { background: var(--navy-600); border-color: var(--navy-500); border-radius: 10px; } +.outcome-badge::before { + content: ""; + display: inline-block; + width: 6px; height: 6px; border-radius: 50%; + margin-right: 6px; vertical-align: 1px; + background: currentColor; + box-shadow: 0 0 6px currentColor; +} +.outcome-badge.outcome-submitted { color: var(--out-submitted); } +.outcome-badge.outcome-acknowledged { color: var(--out-acknowledged); } +.outcome-badge.outcome-rejected { color: var(--out-rejected); border-color: color-mix(in oklab, var(--out-rejected) 35%, var(--navy-500)); } +.outcome-badge.outcome-actioned { color: var(--out-actioned); border-color: color-mix(in oklab, var(--out-actioned) 35%, var(--navy-500)); } + +/* ===== Routes ============================================================ */ +/* Block-level row inside a case "Routes" panel. Markup: +
+
*/ +.route-allowed, +.route-blocked { + display: grid; + grid-template-columns: 1fr auto; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--navy-500); + border-left-width: 3px; + border-radius: 4px; + background: var(--navy-600); + font-size: var(--fs-md); + margin-bottom: var(--sp-1); +} +.route-allowed { border-left-color: var(--out-actioned); } +.route-blocked { border-left-color: var(--out-rejected); } +.route-allowed .route-reason, +.route-blocked .route-reason { + font-size: var(--fs-sm); + color: var(--navy-200); +} +.route-allowed .route-dest::before { content: "→ "; color: var(--out-actioned); } +.route-blocked .route-dest::before { content: "⊘ "; color: var(--out-rejected); } +.route-blocked .route-dest { color: var(--navy-100); text-decoration: line-through; text-decoration-color: var(--navy-300); } + +/* ===== Ledger row ======================================================= */ +/* Tabular dense row for /ledger. Markup: + + ……… + …… + AMBER + ACTIONED + */ +.ledger-row > td { + padding: var(--sp-1) var(--sp-2); + border-bottom: 1px solid var(--navy-500); + font-size: var(--fs-sm); + vertical-align: baseline; +} +.ledger-row:hover > td { background: var(--navy-600); } +.ledger-row .lg-ts { color: var(--navy-200); white-space: nowrap; width: 1%; } +.ledger-row .lg-case { color: var(--cyan-200); white-space: nowrap; width: 1%; } +.ledger-row .lg-dest { color: var(--navy-50); white-space: nowrap; } +.ledger-row .lg-hash { + color: var(--navy-100); + font-variant-ligatures: none; + max-width: 22ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; /* clip the head of the hash, keep the tail */ + text-align: left; +} +.ledger-row .lg-sub { color: var(--navy-100); white-space: nowrap; } +.ledger-row.is-rejected > td { background: color-mix(in oklab, var(--out-rejected) 6%, transparent); } +.ledger-row.is-actioned > td { background: color-mix(in oklab, var(--out-actioned) 5%, transparent); } + +/* ===== Sealed package card =============================================== */ +/* Panel/card variant for case detail + /sealed/{id}. Markup: +
+
+

SEALED PACKAGE

+ pkg_01H… +
+
+ +
*/ +.sealed-package-card { + position: relative; + background: + linear-gradient(180deg, color-mix(in oklab, var(--cyan-700) 35%, var(--navy-700)) 0, var(--navy-700) 64px), + var(--navy-700); + border: 1px solid var(--navy-500); + border-radius: 6px; + padding: var(--sp-3) var(--sp-4) var(--sp-4); +} +.sealed-package-card::before { + content: ""; + position: absolute; inset: 0 0 auto 0; height: 2px; + background: linear-gradient(90deg, transparent, var(--cyan-300), transparent); + opacity: 0.7; +} +.sealed-package-card .sp-head { + display: flex; justify-content: space-between; align-items: baseline; + gap: var(--sp-3); margin-bottom: var(--sp-3); +} +.sealed-package-card .sp-head h2 { + margin: 0; + font-size: var(--fs-xs); font-weight: var(--fw-xs); + letter-spacing: 1.4px; text-transform: uppercase; + color: var(--cyan-200); +} +.sealed-package-card .sp-id { + font-size: var(--fs-sm); color: var(--navy-50); + background: var(--navy-600); border: 1px solid var(--navy-500); + padding: 2px var(--sp-2); border-radius: 3px; +} +.sealed-package-card .sp-meta { + display: grid; grid-template-columns: 160px 1fr; gap: var(--sp-1) var(--sp-3); + margin: 0 0 var(--sp-3); font-size: var(--fs-md); +} +.sealed-package-card .sp-meta dt { color: var(--navy-200); } +.sealed-package-card .sp-meta dd { margin: 0; color: var(--navy-50); } +.sealed-package-card .sp-meta .sp-hash { font-size: var(--fs-sm); color: var(--navy-100); word-break: break-all; } +.sealed-package-card .sp-recipients { + list-style: none; margin: 0; padding: 0; + display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-2); +} +.sealed-package-card .sp-recipients > li { + font-size: var(--fs-sm); color: var(--navy-50); + background: var(--navy-600); border: 1px solid var(--navy-500); + padding: 2px var(--sp-2); border-radius: 3px; +} +.sealed-package-card .sp-recipients > li .sp-size { color: var(--navy-200); margin-left: var(--sp-2); } diff --git a/src/psyc/cockpit/templates/base.html b/src/psyc/cockpit/templates/base.html index 0c2b861..2446d3f 100644 --- a/src/psyc/cockpit/templates/base.html +++ b/src/psyc/cockpit/templates/base.html @@ -5,6 +5,7 @@ {% block title %}psyc cockpit{% endblock %} +
@@ -14,6 +15,7 @@
diff --git a/src/psyc/cockpit/templates/case_detail.html b/src/psyc/cockpit/templates/case_detail.html index 56f76a2..c19cd2b 100644 --- a/src/psyc/cockpit/templates/case_detail.html +++ b/src/psyc/cockpit/templates/case_detail.html @@ -10,7 +10,7 @@

Classification

-
Severity
{{ case.classification.severity.value if case.classification.severity else '—' }}
+
Severity
{% if case.classification.severity %}{{ case.classification.severity.value }}{% else %}—{% endif %}
TLP
{{ case.classification.tlp.value }}
Incident type
{{ case.classification.incident_type.value if case.classification.incident_type else '—' }}
Internal class
{{ case.classification.internal_class.value if case.classification.internal_class else '—' }}
@@ -47,4 +47,77 @@
+ +{% if sealed_pkg %} +
+
+

SEALED PACKAGE

+ {{ sealed_pkg.package_id }} +
+
+
Created
{{ sealed_pkg.created_at.strftime('%Y-%m-%d %H:%M UTC') }}
+
Plaintext SHA-256
{{ sealed_pkg.plaintext_hash }}
+
Recipients
+
+
    + {% for r in sealed_pkg.recipients %} +
  • {{ r }}{{ (sealed_pkg.ciphertext_per_recipient[r] | length) // 4 * 3 }} B
  • + {% endfor %} +
+
+
+
+{% endif %} + +{% if routes or blocked %} +
+
+

Routes

+ {{ routes|length }} allowed · {{ blocked|length }} blocked +
+ {% for r in routes %} +
+ {{ r.destination_name }} + priority {{ r.priority }} · {{ r.payload_kind }} · max_tlp {{ r.max_tlp_allowed.value }} +
+ {% endfor %} + {% for b in blocked %} +
+ {{ b.destination_name }} + {{ b.reason }} +
+ {% endfor %} +
+{% endif %} + +{% if ledger %} +
+
+

Ledger (this case)

+ {{ ledger|length }} entr{{ 'y' if ledger|length == 1 else 'ies' }} +
+ + + + + + + + + + + + {% for e in ledger %} + + + + + + + + {% endfor %} + +
TimestampDestinationPayload hashTLPOutcome
{{ e.timestamp.strftime('%H:%M:%S') }}{{ e.destination }}{{ e.payload_hash or '—' }}{{ e.tlp.value }}{{ e.outcome.value }}
+
+{% endif %} {% endblock %} diff --git a/src/psyc/cockpit/templates/ledger.html b/src/psyc/cockpit/templates/ledger.html new file mode 100644 index 0000000..ee7fec6 --- /dev/null +++ b/src/psyc/cockpit/templates/ledger.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Ledger — psyc{% endblock %} +{% block content %} +
+
+

Immutable Audit Ledger

+ {{ total }} entr{{ 'y' if total == 1 else 'ies' }} +
+ {% if not entries %} +

No ledger entries yet. Run psyc submit <case_id> or psyc demo.

+ {% else %} + + + + + + + + + + + + + + {% for e in entries %} + + + + + + + + + + {% endfor %} + +
TimestampCaseDestinationPayload hashSubmitterTLPOutcome
{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ e.case_id }}{{ e.destination }}{{ e.payload_hash or '—' }}{{ e.submitter_identity }}{{ e.tlp.value }}{{ e.outcome.value }}
+ {% endif %} +
+{% endblock %} diff --git a/src/psyc/db.py b/src/psyc/db.py index 3eead4a..8273ebf 100644 --- a/src/psyc/db.py +++ b/src/psyc/db.py @@ -23,12 +23,12 @@ from sqlalchemy import ( ) from sqlalchemy.dialects.sqlite import insert as sqlite_insert -from psyc import log +from psyc import DATA_DIR, log from psyc.models import Case from psyc.result import Err, Ok, Result -DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "psyc.db" +DB_PATH = DATA_DIR / "psyc.db" _metadata = MetaData() diff --git a/src/psyc/lines/__init__.py b/src/psyc/lines/__init__.py index e69de29..e5622a9 100644 --- a/src/psyc/lines/__init__.py +++ b/src/psyc/lines/__init__.py @@ -0,0 +1 @@ +"""Worker line modules — one file per pipeline stage.""" diff --git a/src/psyc/lines/classify.py b/src/psyc/lines/classify.py new file mode 100644 index 0000000..ef403c7 --- /dev/null +++ b/src/psyc/lines/classify.py @@ -0,0 +1,58 @@ +"""Classifyline — assigns incident_type, severity, TLP, internal_class.""" + +from __future__ import annotations + +from psyc import log +from psyc.models import Case, IncidentType, InternalClass, Severity, TLP + + +_log = log.get(__name__) + + +def classify(case: Case) -> Case: + _classify_incident_type_and_tlp(case) + _classify_severity(case) + _classify_internal_class(case) + _log.info( + "classify.applied", + case_id=case.case_id, + incident_type=case.classification.incident_type.value if case.classification.incident_type else None, + severity=case.classification.severity.value if case.classification.severity else None, + tlp=case.classification.tlp.value, + internal_class=case.classification.internal_class.value if case.classification.internal_class else None, + ) + return case + + +def _classify_incident_type_and_tlp(case: Case) -> None: + if case.classification.incident_type is not None: + return + if case.source_type == "abuse_feed" and case.observables.urls: + case.classification.incident_type = IncidentType.MALWARE + if case.classification.tlp == TLP.AMBER: + case.classification.tlp = TLP.GREEN + + +def _classify_severity(case: Case) -> None: + if case.classification.severity is not None: + return + if case.victim.critical_infrastructure: + case.classification.severity = Severity.CRITICAL + return + if case.classification.incident_type == IncidentType.MALWARE: + url_status = case.source_metadata.get("url_status", "") + case.classification.severity = Severity.HIGH if url_status == "online" else Severity.MEDIUM + return + case.classification.severity = Severity.MEDIUM + + +def _classify_internal_class(case: Case) -> None: + if case.classification.internal_class is not None: + return + if case.victim.critical_infrastructure or case.classification.severity == Severity.CRITICAL: + case.classification.internal_class = InternalClass.A + return + if case.classification.severity == Severity.HIGH: + case.classification.internal_class = InternalClass.C + return + case.classification.internal_class = InternalClass.D diff --git a/src/psyc/lines/courier.py b/src/psyc/lines/courier.py new file mode 100644 index 0000000..487fb4f --- /dev/null +++ b/src/psyc/lines/courier.py @@ -0,0 +1,168 @@ +"""Courier — payload building + HTTP submission to destination endpoints.""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Dict, List, Optional + +import httpx +from pydantic import BaseModel, Field + +from psyc import log +from psyc.lines import ledger as ledger_line +from psyc.lines.route import BlockedRoute, Route, endpoint_for +from psyc.models import Case, Outcome, SealedPackage +from psyc.result import Err, Ok, Result + + +_log = log.get(__name__) + +DEFAULT_TIMEOUT = 10.0 +SUBMITTER_IDENTITY = "psyc/courier@0.1" + +_STATUS_TO_OUTCOME: Dict[str, Outcome] = { + "submitted": Outcome.SUBMITTED, + "acknowledged": Outcome.ACKNOWLEDGED, + "actioned": Outcome.ACTIONED, + "rejected": Outcome.REJECTED, +} + + +class Receipt(BaseModel): + receipt_id: str + destination: str + status: str + response_body: Dict[str, Any] = Field(default_factory=dict) + + +class SubmitResult(BaseModel): + destination_name: str + outcome: Outcome + receipt_id: Optional[str] = None + detail: Optional[str] = None + + +def build_payload(case: Case, payload_kind: str, sealed_pkg: Optional[SealedPackage] = None) -> Dict[str, Any]: + if payload_kind == "sealed_evidence_package": + if sealed_pkg is None: + return {"case_id": case.case_id, "error": "no sealed package available"} + return sealed_pkg.model_dump(mode="json") + if payload_kind == "stix_indicators": + return { + "spec_version": "2.1", + "type": "bundle", + "case_id": case.case_id, + "tlp": case.classification.tlp.value, + "objects": [ + { + "type": "indicator", + "id": f"indicator--{case.case_id}-{i}", + "pattern": f"[url:value = '{u}']", + "valid_from": case.observed_at.isoformat(), + } + for i, u in enumerate(case.observables.urls) + ], + } + if payload_kind == "malware_url_report": + return { + "case_id": case.case_id, + "urls": case.observables.urls, + "threat": case.source_metadata.get("threat", "malware_distribution"), + "tags": [t for t in case.source_metadata.get("tags", "").split(",") if t], + } + if payload_kind == "ip_abuse_report": + return { + "case_id": case.case_id, + "ips": case.observables.ips, + "category": "malware_distribution", + "comment": case.summary, + } + return {"case_id": case.case_id, "payload_kind": payload_kind} + + +def _hash_payload(payload: Dict[str, Any]) -> str: + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest() + + +def submit(endpoint: str, payload: Dict[str, Any], timeout: float = DEFAULT_TIMEOUT) -> Result[Receipt, str]: + try: + with httpx.Client(timeout=timeout) as client: + r = client.post(endpoint, json=payload) + r.raise_for_status() + body = r.json() + except httpx.HTTPStatusError as exc: + _log.warning("courier.http.error", endpoint=endpoint, status=exc.response.status_code) + return Err(f"HTTP {exc.response.status_code}: {exc.response.text[:200]}") + except httpx.RequestError as exc: + _log.warning("courier.request.error", endpoint=endpoint, error=str(exc)) + return Err(f"network error: {exc}") + except Exception as exc: + _log.warning("courier.unknown.error", endpoint=endpoint, error=str(exc)) + return Err(f"submit failed: {exc}") + _log.info("courier.submitted", endpoint=endpoint, receipt_id=body.get("receipt_id", "")) + return Ok(Receipt( + receipt_id=body.get("receipt_id", ""), + destination=body.get("destination", ""), + status=body.get("status", "submitted"), + response_body=body, + )) + + +def execute_blocked_routes(case: Case, blocked: List[BlockedRoute]) -> None: + for b in blocked: + ledger_line.write( + case_id=case.case_id, + destination=b.destination_name, + payload_hash="", + submitter_identity="psyc/route-planner@0.1", + tlp=case.classification.tlp, + outcome=Outcome.REJECTED, + detail=b.reason, + ) + + +def execute_routes(case: Case, routes: List[Route], sealed_pkg: Optional[SealedPackage] = None) -> List[SubmitResult]: + results: List[SubmitResult] = [] + for r in routes: + endpoint = endpoint_for(r.destination_name) + if endpoint is None: + ledger_line.write( + case_id=case.case_id, + destination=r.destination_name, + payload_hash="", + submitter_identity=SUBMITTER_IDENTITY, + tlp=case.classification.tlp, + outcome=Outcome.FAILED, + detail="no endpoint configured", + ) + results.append(SubmitResult(destination_name=r.destination_name, outcome=Outcome.FAILED, detail="no endpoint configured")) + continue + payload = build_payload(case, r.payload_kind, sealed_pkg) + payload_hash = _hash_payload(payload) + result = submit(endpoint, payload) + if isinstance(result, Err): + ledger_line.write( + case_id=case.case_id, + destination=r.destination_name, + payload_hash=payload_hash, + submitter_identity=SUBMITTER_IDENTITY, + tlp=case.classification.tlp, + outcome=Outcome.FAILED, + detail=result.reason, + ) + results.append(SubmitResult(destination_name=r.destination_name, outcome=Outcome.FAILED, detail=result.reason)) + continue + receipt = result.value + outcome = _STATUS_TO_OUTCOME.get(receipt.status, Outcome.SUBMITTED) + ledger_line.write( + case_id=case.case_id, + destination=r.destination_name, + payload_hash=payload_hash, + submitter_identity=SUBMITTER_IDENTITY, + tlp=case.classification.tlp, + outcome=outcome, + response_id=receipt.receipt_id, + ) + results.append(SubmitResult(destination_name=r.destination_name, outcome=outcome, receipt_id=receipt.receipt_id)) + return results diff --git a/src/psyc/lines/ledger.py b/src/psyc/lines/ledger.py new file mode 100644 index 0000000..39de0e3 --- /dev/null +++ b/src/psyc/lines/ledger.py @@ -0,0 +1,75 @@ +"""Ledgerline — append-only audit records for external submissions and destructive actions.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import List, Optional + +from sqlalchemy import func, insert, select + +from psyc import db, log +from psyc.models import LedgerEntry, Outcome, TLP + + +_log = log.get(__name__) + + +def write( + case_id: str, + destination: str, + payload_hash: str, + submitter_identity: str, + tlp: TLP, + outcome: Outcome, + response_id: Optional[str] = None, + detail: Optional[str] = None, +) -> None: + stmt = insert(db.ledger).values( + timestamp=datetime.now(timezone.utc).isoformat(), + case_id=case_id, + destination=destination, + payload_hash=payload_hash, + submitter_identity=submitter_identity, + tlp=tlp.value, + response_id=response_id, + outcome=outcome.value, + detail=detail, + ) + with db.engine().begin() as conn: + conn.execute(stmt) + _log.info("ledger.write", case_id=case_id, destination=destination, outcome=outcome.value, response_id=response_id) + + +def _row_to_entry(r) -> LedgerEntry: + return LedgerEntry( + id=r.id, + timestamp=datetime.fromisoformat(r.timestamp), + case_id=r.case_id, + destination=r.destination, + payload_hash=r.payload_hash, + submitter_identity=r.submitter_identity, + tlp=TLP(r.tlp), + response_id=r.response_id, + outcome=Outcome(r.outcome), + detail=r.detail, + ) + + +def list_recent(limit: int = 200) -> List[LedgerEntry]: + stmt = select(db.ledger).order_by(db.ledger.c.timestamp.desc()).limit(limit) + with db.engine().connect() as conn: + rows = conn.execute(stmt).fetchall() + return [_row_to_entry(r) for r in rows] + + +def list_by_case(case_id: str, limit: int = 200) -> List[LedgerEntry]: + stmt = select(db.ledger).where(db.ledger.c.case_id == case_id).order_by(db.ledger.c.timestamp.desc()).limit(limit) + with db.engine().connect() as conn: + rows = conn.execute(stmt).fetchall() + return [_row_to_entry(r) for r in rows] + + +def count() -> int: + stmt = select(func.count()).select_from(db.ledger) + with db.engine().connect() as conn: + return conn.execute(stmt).scalar_one() diff --git a/src/psyc/lines/route.py b/src/psyc/lines/route.py new file mode 100644 index 0000000..655ec56 --- /dev/null +++ b/src/psyc/lines/route.py @@ -0,0 +1,116 @@ +"""Routeline — destination matrix + RoutePlanner.""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple + +from pydantic import BaseModel, Field + +from psyc import log +from psyc.models import Case, IncidentType, TLP + + +_log = log.get(__name__) + + +_TLP_RANK: Dict[TLP, int] = {TLP.CLEAR: 0, TLP.GREEN: 1, TLP.AMBER: 2, TLP.RED: 3} + +MOCK_CERT_BASE = "http://127.0.0.1:8770" + +ENDPOINTS: Dict[str, str] = { + "CERT-Bund": f"{MOCK_CERT_BASE}/cert-bund/submit", + "MISP-Community": f"{MOCK_CERT_BASE}/misp/event", + "URLhaus": f"{MOCK_CERT_BASE}/urlhaus/submit", + "AbuseIPDB": f"{MOCK_CERT_BASE}/abuseipdb/report", +} + + +def endpoint_for(destination_name: str) -> Optional[str]: + return ENDPOINTS.get(destination_name) + + +class Destination(BaseModel): + name: str + kind: str + max_tlp: TLP + accepts: List[IncidentType] = Field(default_factory=list) + priority: int + payload_kind: str + countries: List[str] = Field(default_factory=list) + + +class Route(BaseModel): + destination_name: str + priority: int + payload_kind: str + max_tlp_allowed: TLP + + +class BlockedRoute(BaseModel): + destination_name: str + reason: str + + +DESTINATIONS: List[Destination] = [ + Destination( + name="CERT-Bund", + kind="authority", + max_tlp=TLP.RED, + accepts=[IncidentType.MALWARE, IncidentType.RANSOMWARE, IncidentType.PHISHING, IncidentType.EXPLOIT, IncidentType.DATA_LEAK, IncidentType.CREDENTIAL_LEAK], + priority=1, + payload_kind="sealed_evidence_package", + countries=["DE"], + ), + Destination( + name="MISP-Community", + kind="cti_sharing", + max_tlp=TLP.AMBER, + accepts=list(IncidentType), + priority=2, + payload_kind="stix_indicators", + ), + Destination( + name="URLhaus", + kind="public_abuse", + max_tlp=TLP.GREEN, + accepts=[IncidentType.MALWARE], + priority=3, + payload_kind="malware_url_report", + ), + Destination( + name="AbuseIPDB", + kind="public_abuse", + max_tlp=TLP.CLEAR, + accepts=[IncidentType.MALWARE, IncidentType.BOTNET, IncidentType.PHISHING], + priority=4, + payload_kind="ip_abuse_report", + ), +] + + +def _tlp_allowed(submission_tlp: TLP, dest_max_tlp: TLP) -> bool: + return _TLP_RANK[submission_tlp] <= _TLP_RANK[dest_max_tlp] + + +def plan(case: Case) -> Tuple[List[Route], List[BlockedRoute]]: + routes: List[Route] = [] + blocked: List[BlockedRoute] = [] + for d in DESTINATIONS: + if not _tlp_allowed(case.classification.tlp, d.max_tlp): + blocked.append(BlockedRoute(destination_name=d.name, reason="tlp_exceeded")) + continue + if case.classification.incident_type is not None and case.classification.incident_type not in d.accepts: + blocked.append(BlockedRoute(destination_name=d.name, reason="incident_type_mismatch")) + continue + if d.countries and case.victim.country not in d.countries: + blocked.append(BlockedRoute(destination_name=d.name, reason="country_mismatch")) + continue + routes.append(Route( + destination_name=d.name, + priority=d.priority, + payload_kind=d.payload_kind, + max_tlp_allowed=d.max_tlp, + )) + routes.sort(key=lambda r: r.priority) + _log.info("route.planned", case_id=case.case_id, allowed=len(routes), blocked=len(blocked)) + return routes, blocked diff --git a/src/psyc/lines/scout.py b/src/psyc/lines/scout.py index 2913013..85c8558 100644 --- a/src/psyc/lines/scout.py +++ b/src/psyc/lines/scout.py @@ -1,4 +1,8 @@ -"""Scoutline — Fetcher + Signalizer for URLhaus.""" +"""Scoutline — Fetcher + Signalizer for URLhaus. + +Emits raw Case objects with source metadata + observables only. Classification, +victim/actor resolution, confidence scoring, sealing, and routing are downstream. +""" from __future__ import annotations @@ -11,15 +15,7 @@ from urllib.parse import urlparse import httpx from psyc import log -from psyc.models import ( - Case, - Classification, - Confidence, - IncidentType, - Observables, - Severity, - TLP, -) +from psyc.models import Case, Observables URLHAUS_RECENT_CSV = "https://urlhaus.abuse.ch/downloads/csv_recent/" @@ -68,31 +64,22 @@ def row_to_case(row: Dict[str, str]) -> Case: parsed = urlparse(url) host = parsed.hostname or "" tags = [t.strip() for t in row["tags"].split(",") if t.strip()] - - severity = Severity.HIGH if row["url_status"] == "online" else Severity.MEDIUM - classification = Classification( - severity=severity, - tlp=TLP.GREEN, - incident_type=IncidentType.MALWARE, - ) - confidence = Confidence( - level="medium", - source_reliability="B", - information_credibility="2", - ) observables = Observables(urls=[url], domains=[host] if host else []) summary = f"URLhaus: {row['threat'] or 'malware_distribution'} at {host or url}" if tags: summary += f" (tags: {', '.join(tags[:4])})" - return Case( case_id=f"PSYC-URLHAUS-{row['id']}", summary=summary, source_type="abuse_feed", source_ref=row["urlhaus_link"], + source_metadata=dict( + url_status=row["url_status"], + threat=row["threat"], + tags=row["tags"], + reporter=row["reporter"], + ), observed_at=_parse_urlhaus_date(row["dateadded"]), - classification=classification, - confidence=confidence, observables=observables, ) diff --git a/src/psyc/lines/seal.py b/src/psyc/lines/seal.py new file mode 100644 index 0000000..ab37e16 --- /dev/null +++ b/src/psyc/lines/seal.py @@ -0,0 +1,113 @@ +"""Sealine — PyNaCl sealed-box evidence encryption + recipient key management.""" + +from __future__ import annotations + +import base64 +import hashlib +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional +from uuid import uuid4 + +from nacl.public import PrivateKey, PublicKey, SealedBox + +from psyc import DATA_DIR, log +from psyc.models import SealedPackage +from psyc.result import Err, Ok, Result + + +KEYS_DIR = DATA_DIR / "keys" +SEALED_DIR = DATA_DIR / "sealed" + +_log = log.get(__name__) + + +def generate_recipient_keys(recipient: str) -> Path: + KEYS_DIR.mkdir(parents=True, exist_ok=True) + sk = PrivateKey.generate() + pk = sk.public_key + pub_path = KEYS_DIR / f"{recipient}.pub" + sec_path = KEYS_DIR / f"{recipient}.sec" + pub_path.write_text(base64.b64encode(bytes(pk)).decode()) + sec_path.write_text(base64.b64encode(bytes(sk)).decode()) + sec_path.chmod(0o600) + _log.info("seal.keys.generated", recipient=recipient, pub_path=str(pub_path)) + return pub_path + + +def load_public_key(recipient: str) -> Result[PublicKey, str]: + pub_path = KEYS_DIR / f"{recipient}.pub" + if not pub_path.exists(): + return Err(f"no public key for recipient: {recipient}") + return Ok(PublicKey(base64.b64decode(pub_path.read_text()))) + + +def load_private_key(recipient: str) -> Result[PrivateKey, str]: + sec_path = KEYS_DIR / f"{recipient}.sec" + if not sec_path.exists(): + return Err(f"no private key for recipient: {recipient}") + return Ok(PrivateKey(base64.b64decode(sec_path.read_text()))) + + +def list_recipients() -> List[str]: + if not KEYS_DIR.exists(): + return [] + return sorted(p.stem for p in KEYS_DIR.glob("*.pub")) + + +def seal(plaintext: bytes, recipients: List[str], metadata: Optional[Dict[str, str]] = None) -> Result[SealedPackage, str]: + if not recipients: + return Err("no recipients specified") + SEALED_DIR.mkdir(parents=True, exist_ok=True) + plaintext_hash = hashlib.sha256(plaintext).hexdigest() + ciphertext_per_recipient: Dict[str, str] = {} + for name in recipients: + pk_result = load_public_key(name) + if isinstance(pk_result, Err): + return Err(f"recipient {name}: {pk_result.reason}") + box = SealedBox(pk_result.value) + ciphertext = box.encrypt(plaintext) + ciphertext_per_recipient[name] = base64.b64encode(ciphertext).decode() + package = SealedPackage( + package_id=f"PKG-{uuid4().hex[:12].upper()}", + created_at=datetime.now(timezone.utc), + plaintext_hash=plaintext_hash, + recipients=list(recipients), + ciphertext_per_recipient=ciphertext_per_recipient, + metadata=metadata or {}, + ) + pkg_path = SEALED_DIR / f"{package.package_id}.json" + pkg_path.write_text(package.model_dump_json(indent=2)) + _log.info("seal.packaged", package_id=package.package_id, recipients=recipients, plaintext_hash=plaintext_hash[:16]) + return Ok(package) + + +def load_package(package_id: str) -> Result[SealedPackage, str]: + pkg_path = SEALED_DIR / f"{package_id}.json" + if not pkg_path.exists(): + return Err(f"no sealed package: {package_id}") + return Ok(SealedPackage.model_validate_json(pkg_path.read_text())) + + +def list_packages() -> List[str]: + if not SEALED_DIR.exists(): + return [] + return sorted(p.stem for p in SEALED_DIR.glob("PKG-*.json")) + + +def unseal(package_id: str, recipient: str) -> Result[bytes, str]: + pkg_result = load_package(package_id) + if isinstance(pkg_result, Err): + return pkg_result + pkg = pkg_result.value + if recipient not in pkg.ciphertext_per_recipient: + return Err(f"recipient {recipient} is not on this package") + sk_result = load_private_key(recipient) + if isinstance(sk_result, Err): + return sk_result + box = SealedBox(sk_result.value) + try: + plaintext = box.decrypt(base64.b64decode(pkg.ciphertext_per_recipient[recipient])) + except Exception as exc: + return Err(f"decrypt failed: {exc}") + return Ok(plaintext) diff --git a/src/psyc/mock_cert.py b/src/psyc/mock_cert.py new file mode 100644 index 0000000..b12a292 --- /dev/null +++ b/src/psyc/mock_cert.py @@ -0,0 +1,85 @@ +"""Mock CERT / abuse-API receiver for the hackathon demo. + +Pretends to be CERT-Bund, MISP, URLhaus, and AbuseIPDB. Accepts JSON payloads, +returns a receipt id + status. Submissions are kept in memory AND mirrored to +data/mock_cert/.json so the demo has an artifact trail. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List +from uuid import uuid4 + +from fastapi import FastAPI +from pydantic import BaseModel, Field + +from psyc import DATA_DIR, log + + +_log = log.get(__name__) + +MOCK_LOG_DIR = DATA_DIR / "mock_cert" + + +class MockSubmission(BaseModel): + receipt_id: str + destination: str + status: str + received_at: datetime + payload: Dict[str, Any] = Field(default_factory=dict) + + +_submissions: List[MockSubmission] = [] + + +def _record(destination: str, status: str, payload: Dict[str, Any]) -> MockSubmission: + sub = MockSubmission( + receipt_id=f"MOCK-{uuid4().hex[:12].upper()}", + destination=destination, + status=status, + received_at=datetime.now(timezone.utc), + payload=payload, + ) + _submissions.append(sub) + MOCK_LOG_DIR.mkdir(parents=True, exist_ok=True) + (MOCK_LOG_DIR / f"{sub.receipt_id}.json").write_text(sub.model_dump_json(indent=2)) + _log.info("mock_cert.received", destination=destination, receipt_id=sub.receipt_id, status=status) + return sub + + +app = FastAPI(title="psyc mock-cert", version="0.1.0") + + +@app.post("/cert-bund/submit") +def submit_cert_bund(payload: Dict[str, Any]) -> Dict[str, Any]: + sub = _record("CERT-Bund", "acknowledged", payload) + return {"receipt_id": sub.receipt_id, "destination": sub.destination, "status": sub.status, "received_at": sub.received_at.isoformat()} + + +@app.post("/misp/event") +def submit_misp(payload: Dict[str, Any]) -> Dict[str, Any]: + sub = _record("MISP-Community", "submitted", payload) + return {"receipt_id": sub.receipt_id, "destination": sub.destination, "status": sub.status, "received_at": sub.received_at.isoformat()} + + +@app.post("/urlhaus/submit") +def submit_urlhaus(payload: Dict[str, Any]) -> Dict[str, Any]: + sub = _record("URLhaus", "actioned", payload) + return {"receipt_id": sub.receipt_id, "destination": sub.destination, "status": sub.status, "received_at": sub.received_at.isoformat()} + + +@app.post("/abuseipdb/report") +def submit_abuseipdb(payload: Dict[str, Any]) -> Dict[str, Any]: + sub = _record("AbuseIPDB", "submitted", payload) + return {"receipt_id": sub.receipt_id, "destination": sub.destination, "status": sub.status, "received_at": sub.received_at.isoformat()} + + +@app.get("/received") +def received() -> Dict[str, Any]: + return {"count": len(_submissions), "submissions": [s.model_dump(mode="json") for s in _submissions]} + + +@app.get("/healthz") +def healthz() -> Dict[str, str]: + return {"status": "ok", "received": str(len(_submissions))} diff --git a/src/psyc/models.py b/src/psyc/models.py index 09ebad1..748b070 100644 --- a/src/psyc/models.py +++ b/src/psyc/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime, timezone from enum import Enum -from typing import List, Optional +from typing import Dict, List, Optional from uuid import uuid4 from pydantic import BaseModel, Field @@ -104,6 +104,7 @@ class Case(BaseModel): summary: str = "" source_type: str = "" source_ref: str = "" + source_metadata: Dict[str, str] = Field(default_factory=dict) observed_at: datetime = Field(default_factory=utcnow) ingested_at: datetime = Field(default_factory=utcnow) classification: Classification = Field(default_factory=Classification) @@ -113,3 +114,33 @@ class Case(BaseModel): observables: Observables = Field(default_factory=Observables) evidence: Evidence = Field(default_factory=Evidence) routing: Routing = Field(default_factory=Routing) + + +class SealedPackage(BaseModel): + package_id: str + created_at: datetime + plaintext_hash: str + recipients: List[str] = Field(default_factory=list) + ciphertext_per_recipient: Dict[str, str] = Field(default_factory=dict) + metadata: Dict[str, str] = Field(default_factory=dict) + + +class Outcome(str, Enum): + SUBMITTED = "submitted" + ACKNOWLEDGED = "acknowledged" + REJECTED = "rejected" + ACTIONED = "actioned" + FAILED = "failed" + + +class LedgerEntry(BaseModel): + id: int + timestamp: datetime + case_id: str + destination: str + payload_hash: str + submitter_identity: str + tlp: TLP + response_id: Optional[str] = None + outcome: Outcome + detail: Optional[str] = None