Mapline gains kev_cve_set() (the known-exploited CVE set, derived from the already-ingested KEV cases) and resolve_cves() — flags any of a case's CVEs that are known-exploited and escalates a non-KEV case's severity to HIGH when one surfaces. Folded into map-case / map-all / demo. Honest limit: only KEV-sourced cases carry CVEs today, so the cross-check is largely self-referential until a CVE-bearing source or model extraction feeds CVEs into other cases — the escalation path is verified against a synthetic case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
375 lines
14 KiB
Python
375 lines
14 KiB
Python
"""psyc CLI — flat Typer commands, hyphenated names."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import List
|
|
|
|
import typer
|
|
import uvicorn
|
|
|
|
from psyc import db, log
|
|
from psyc.cockpit import inference
|
|
from psyc.lines import classify, courier, proof, route, scout, seal, train
|
|
from psyc.lines import map as map_line
|
|
from psyc.models import Outcome
|
|
from psyc.result import Err, Ok
|
|
|
|
|
|
app = typer.Typer(add_completion=False, help="psyc — defensive CTI routing & sealing")
|
|
log.configure()
|
|
_log = log.get(__name__)
|
|
|
|
|
|
@app.command("init")
|
|
def init() -> None:
|
|
path = db.init_db()
|
|
typer.echo(f"db ready @ {path}")
|
|
|
|
|
|
@app.command("status")
|
|
def status() -> None:
|
|
typer.echo(f"cases: {db.case_count()}")
|
|
|
|
|
|
def _ingest(source: str, limit: int) -> None:
|
|
db.init_db()
|
|
typer.echo(f"fetching {source} (limit={limit})…")
|
|
cases = scout.fetch_and_signal(source, limit=limit)
|
|
for c in cases:
|
|
db.upsert_case(c)
|
|
typer.echo(f"ingested {len(cases)} case(s) from {source}. total now: {db.case_count()}")
|
|
|
|
|
|
@app.command("fetch-urlhaus")
|
|
def fetch_urlhaus(limit: int = typer.Option(50, help="max rows to ingest")) -> None:
|
|
_ingest("urlhaus", limit)
|
|
|
|
|
|
@app.command("fetch-cisa-kev")
|
|
def fetch_cisa_kev(limit: int = typer.Option(100, help="max vulnerabilities to ingest")) -> None:
|
|
_ingest("cisa-kev", limit)
|
|
|
|
|
|
@app.command("fetch-feodo")
|
|
def fetch_feodo(limit: int = typer.Option(50, help="max C2 records to ingest")) -> None:
|
|
_ingest("feodo", limit)
|
|
|
|
|
|
@app.command("fetch-all")
|
|
def fetch_all() -> None:
|
|
for source, limit in (("urlhaus", 50), ("cisa-kev", 100), ("feodo", 50)):
|
|
_ingest(source, limit)
|
|
|
|
|
|
@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("prove-case")
|
|
def prove_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 = proof.prove(case_result.value)
|
|
db.upsert_case(case)
|
|
c = case.confidence
|
|
typer.echo(f"proved {case.case_id}: confidence {c.level} · reliability {c.source_reliability}{c.information_credibility} · {c.freshness}")
|
|
|
|
|
|
@app.command("prove-all")
|
|
def prove_all() -> None:
|
|
cases = db.list_cases(limit=10_000)
|
|
for c in cases:
|
|
proof.prove(c)
|
|
db.upsert_case(c)
|
|
typer.echo(f"proved {len(cases)} case(s).")
|
|
|
|
|
|
@app.command("map-case")
|
|
def map_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)
|
|
kev = map_line.kev_cve_set(db.list_cases(limit=10_000))
|
|
case = map_line.resolve(case_result.value)
|
|
case = map_line.resolve_cves(case, kev)
|
|
db.upsert_case(case)
|
|
line = f"mapped {case.case_id}: country={case.victim.country or '—'} ips={case.observables.ips}"
|
|
if case.source_metadata.get("kev_cves"):
|
|
line += f" · known-exploited CVEs: {case.source_metadata['kev_cves']}"
|
|
typer.echo(line)
|
|
|
|
|
|
@app.command("map-all")
|
|
def map_all(limit: int = typer.Option(50, help="max cases to process this run")) -> None:
|
|
cases = db.list_cases(limit=limit)
|
|
kev = map_line.kev_cve_set(db.list_cases(limit=10_000))
|
|
geo = 0
|
|
kev_hits = 0
|
|
for c in cases:
|
|
before = c.victim.country
|
|
c = map_line.resolve(c)
|
|
c = map_line.resolve_cves(c, kev)
|
|
db.upsert_case(c)
|
|
if c.victim.country != before:
|
|
geo += 1
|
|
if c.source_metadata.get("kev_cves"):
|
|
kev_hits += 1
|
|
typer.echo(f"mapped {len(cases)} case(s): {geo} geo-resolved, {kev_hits} with known-exploited CVEs.")
|
|
|
|
|
|
@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("train-build")
|
|
def train_build(
|
|
task: str = typer.Option(..., "--task", "-t", help=f"one of: {', '.join(train.TASKS)}"),
|
|
limit: int = typer.Option(10_000, help="max cases to process"),
|
|
) -> None:
|
|
if task not in train.TASKS:
|
|
typer.echo(f"unknown task: {task}; choices: {', '.join(train.TASKS)}", err=True)
|
|
raise typer.Exit(1)
|
|
cases = db.list_cases(limit=limit)
|
|
report = train.build(task, cases)
|
|
typer.echo(f"task: {report.task}")
|
|
typer.echo(f"path: {report.path}")
|
|
typer.echo(f" written: {report.written}")
|
|
typer.echo(f" skipped (empty): {report.skipped_empty}")
|
|
typer.echo(f" skipped (tlp red): {report.skipped_tlp_red}")
|
|
typer.echo(f" skipped (source): {report.skipped_restricted_source}")
|
|
typer.echo(f" skipped (quality): {report.skipped_quality}")
|
|
|
|
|
|
@app.command("train-build-all")
|
|
def train_build_all(limit: int = typer.Option(10_000, help="max cases per task")) -> None:
|
|
cases = db.list_cases(limit=limit)
|
|
for task in train.TASKS:
|
|
report = train.build(task, cases)
|
|
typer.echo(f" {task}: wrote {report.written} → {report.path.name}")
|
|
|
|
|
|
@app.command("train-list-datasets")
|
|
def train_list_datasets() -> None:
|
|
rows = train.list_datasets()
|
|
if not rows:
|
|
typer.echo("(no datasets)")
|
|
return
|
|
for r in rows:
|
|
typer.echo(f"{r['name']:<40} {r['examples']:>6} examples {int(r['size_bytes']):>8} B {r['modified']}")
|
|
|
|
|
|
@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("urlhaus", 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}"
|
|
)
|
|
case = proof.prove(case)
|
|
db.upsert_case(case)
|
|
typer.echo(f" + proved: confidence {case.confidence.level} · reliability {case.confidence.source_reliability}{case.confidence.information_credibility} · {case.confidence.freshness}")
|
|
kev = map_line.kev_cve_set(db.list_cases(limit=10_000))
|
|
case = map_line.resolve(case)
|
|
case = map_line.resolve_cves(case, kev)
|
|
db.upsert_case(case)
|
|
kev_note = f" · KEV CVEs: {case.source_metadata['kev_cves']}" if case.source_metadata.get("kev_cves") else ""
|
|
typer.echo(f" + mapped: hosting country = {case.victim.country or '—'}{kev_note}")
|
|
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("── see it in the cockpit ──")
|
|
typer.echo(f" Worker Mesh: http://127.0.0.1:8767/cases/{case.case_id}/journey")
|
|
typer.echo(f" Case detail: http://127.0.0.1:8767/cases/{case.case_id}")
|
|
typer.echo(f" Ledger: http://127.0.0.1:8767/ledger")
|
|
adapter = inference.server_adapter()
|
|
if adapter:
|
|
typer.echo(f" Live model: up ({adapter}) — the Classifier bot shows its verdict")
|
|
else:
|
|
typer.echo(" Live model: inference server offline — Classifier bot falls back to rules")
|
|
|
|
|
|
@app.command("serve")
|
|
def serve(host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> None:
|
|
db.init_db()
|
|
uvicorn.run("psyc.cockpit.app:app", host=host, port=port, reload=reload)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|