Files
psyc/src/psyc/cli.py
m17hr1l bc61b9a3a1 stage-13: CVEResolver — cross-check cases against the CISA KEV catalog
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>
2026-05-18 23:31:13 +02:00

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