352 lines
13 KiB
Python
352 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Iterator
|
|
|
|
from evidence_chain import evidence_sha256, normalize_evidence_chain
|
|
|
|
|
|
class ForensicsHostMismatchError(ValueError):
|
|
pass
|
|
|
|
|
|
class CaseStorage:
|
|
def __init__(self, db_path: Path) -> None:
|
|
self.db_path = db_path
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._init_db()
|
|
|
|
@contextmanager
|
|
def conn(self) -> Iterator[sqlite3.Connection]:
|
|
c = sqlite3.connect(self.db_path)
|
|
c.row_factory = sqlite3.Row
|
|
c.execute("PRAGMA journal_mode=WAL")
|
|
c.execute("PRAGMA foreign_keys=ON")
|
|
try:
|
|
yield c
|
|
finally:
|
|
c.close()
|
|
|
|
def _init_db(self) -> None:
|
|
with self.conn() as c:
|
|
c.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS cases (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
incident_id TEXT NOT NULL,
|
|
host TEXT,
|
|
title TEXT NOT NULL,
|
|
severity TEXT NOT NULL DEFAULT 'medium',
|
|
assignee TEXT,
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
source_bucket TEXT,
|
|
source_event_ts TEXT,
|
|
evidence_json TEXT,
|
|
forensics_json TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cases_incident_id ON cases(incident_id);
|
|
CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status);
|
|
|
|
CREATE TABLE IF NOT EXISTS case_comments (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
case_id INTEGER NOT NULL,
|
|
comment TEXT NOT NULL,
|
|
author TEXT,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY(case_id) REFERENCES cases(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS case_audit (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
case_id INTEGER NOT NULL,
|
|
action TEXT NOT NULL,
|
|
actor TEXT,
|
|
details_json TEXT,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY(case_id) REFERENCES cases(id) ON DELETE CASCADE
|
|
);
|
|
"""
|
|
)
|
|
self._ensure_column(c, "cases", "forensics_json", "TEXT")
|
|
c.commit()
|
|
|
|
@staticmethod
|
|
def _ensure_column(c: sqlite3.Connection, table: str, column: str, definition: str) -> None:
|
|
columns = {
|
|
str(row["name"])
|
|
for row in c.execute(f"PRAGMA table_info({table})").fetchall()
|
|
}
|
|
if column not in columns:
|
|
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
|
|
|
@staticmethod
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
@staticmethod
|
|
def _normalize_host(value: Any) -> str:
|
|
return str(value or "").strip().lower()
|
|
|
|
@staticmethod
|
|
def _load_json_field(raw: Any) -> dict[str, Any] | None:
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return None
|
|
|
|
@classmethod
|
|
def _to_case_dict(cls, row: sqlite3.Row) -> dict[str, Any]:
|
|
evidence = cls._load_json_field(row["evidence_json"])
|
|
forensics = cls._load_json_field(row["forensics_json"])
|
|
return {
|
|
"id": int(row["id"]),
|
|
"incident_id": row["incident_id"],
|
|
"host": row["host"],
|
|
"title": row["title"],
|
|
"severity": row["severity"],
|
|
"assignee": row["assignee"],
|
|
"status": row["status"],
|
|
"source_bucket": row["source_bucket"],
|
|
"source_event_ts": row["source_event_ts"],
|
|
"evidence": evidence,
|
|
"forensics": forensics,
|
|
"created_at": row["created_at"],
|
|
"updated_at": row["updated_at"],
|
|
}
|
|
|
|
def create_case(self, payload: dict[str, Any], actor: str | None = None) -> dict[str, Any]:
|
|
now = self._now()
|
|
normalized_evidence = None
|
|
evidence_digest = None
|
|
if payload.get("evidence") is not None:
|
|
normalized_evidence = normalize_evidence_chain(
|
|
payload=payload.get("evidence"),
|
|
source_bucket=payload.get("source_bucket"),
|
|
source_event_ts=payload.get("source_event_ts"),
|
|
)
|
|
evidence_digest = normalized_evidence.get("latest_sha256") or evidence_sha256(payload.get("evidence"))
|
|
with self.conn() as c:
|
|
existing = c.execute(
|
|
"""
|
|
SELECT * FROM cases
|
|
WHERE incident_id = ? AND COALESCE(host, '') = COALESCE(?, '')
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
""",
|
|
(payload["incident_id"], payload.get("host")),
|
|
).fetchone()
|
|
if existing:
|
|
return self._to_case_dict(existing)
|
|
cur = c.execute(
|
|
"""
|
|
INSERT INTO cases (
|
|
incident_id, host, title, severity, assignee, status,
|
|
source_bucket, source_event_ts, evidence_json, forensics_json, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
payload["incident_id"],
|
|
payload.get("host"),
|
|
payload["title"],
|
|
payload.get("severity", "medium"),
|
|
payload.get("assignee"),
|
|
payload.get("source_bucket"),
|
|
payload.get("source_event_ts"),
|
|
json.dumps(normalized_evidence, ensure_ascii=False) if normalized_evidence is not None else None,
|
|
None,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
case_id = int(cur.lastrowid)
|
|
self._insert_audit(
|
|
c,
|
|
case_id=case_id,
|
|
action="create",
|
|
actor=actor,
|
|
details={
|
|
"fields": {k: v for k, v in payload.items() if k != "evidence"},
|
|
"evidence_sha256": evidence_digest,
|
|
},
|
|
)
|
|
c.commit()
|
|
return self.get_case(case_id, c)
|
|
|
|
def list_cases(self, status: str | None = None, host: str | None = None, limit: int = 200) -> list[dict[str, Any]]:
|
|
q = "SELECT * FROM cases"
|
|
clauses = []
|
|
args: list[Any] = []
|
|
if status:
|
|
clauses.append("status = ?")
|
|
args.append(status)
|
|
if host:
|
|
clauses.append("host = ?")
|
|
args.append(host)
|
|
if clauses:
|
|
q += " WHERE " + " AND ".join(clauses)
|
|
q += " ORDER BY id DESC LIMIT ?"
|
|
args.append(int(limit))
|
|
with self.conn() as c:
|
|
rows = c.execute(q, args).fetchall()
|
|
return [self._to_case_dict(r) for r in rows]
|
|
|
|
def get_case(self, case_id: int, c: sqlite3.Connection | None = None) -> dict[str, Any]:
|
|
own = False
|
|
if c is None:
|
|
own = True
|
|
c = sqlite3.connect(self.db_path)
|
|
c.row_factory = sqlite3.Row
|
|
try:
|
|
row = c.execute("SELECT * FROM cases WHERE id = ?", (int(case_id),)).fetchone()
|
|
if not row:
|
|
raise KeyError(case_id)
|
|
return self._to_case_dict(row)
|
|
finally:
|
|
if own:
|
|
c.close()
|
|
|
|
def update_case(self, case_id: int, patch: dict[str, Any], actor: str | None = None) -> dict[str, Any]:
|
|
fields = []
|
|
args: list[Any] = []
|
|
for key in ("status", "assignee", "title", "severity"):
|
|
if key in patch and patch[key] is not None:
|
|
fields.append(f"{key} = ?")
|
|
args.append(patch[key])
|
|
if not fields:
|
|
return self.get_case(case_id)
|
|
fields.append("updated_at = ?")
|
|
args.append(self._now())
|
|
args.append(int(case_id))
|
|
with self.conn() as c:
|
|
c.execute(f"UPDATE cases SET {', '.join(fields)} WHERE id = ?", args)
|
|
self._insert_audit(c, case_id=case_id, action="update", actor=actor, details=patch)
|
|
c.commit()
|
|
return self.get_case(case_id, c)
|
|
|
|
def link_hayabusa(self, case_id: int, payload: dict[str, Any], actor: str | None = None) -> dict[str, Any]:
|
|
now = self._now()
|
|
with self.conn() as c:
|
|
existing = self.get_case(case_id, c)
|
|
case_host = self._normalize_host(existing.get("host"))
|
|
forensic_host = self._normalize_host(payload.get("host"))
|
|
if case_host and forensic_host and case_host != forensic_host:
|
|
raise ForensicsHostMismatchError(
|
|
f"hayabusa host mismatch: case host={existing.get('host')} payload host={payload.get('host')}"
|
|
)
|
|
forensics = existing.get("forensics") or {}
|
|
forensics["hayabusa"] = {
|
|
"tool": "hayabusa",
|
|
"host": payload["host"],
|
|
"mode": payload["mode"],
|
|
"status": payload["status"],
|
|
"intake_id": payload.get("intake_id"),
|
|
"package_path": payload.get("package_path"),
|
|
"sha256": payload.get("sha256"),
|
|
"report_dir": payload.get("report_dir"),
|
|
"summary_html": payload.get("summary_html"),
|
|
"timeline_path": payload.get("timeline_path"),
|
|
"manifest_path": payload.get("manifest_path"),
|
|
"linked_at": payload.get("linked_at") or now,
|
|
"link_source": payload.get("link_source") or "api",
|
|
}
|
|
c.execute(
|
|
"UPDATE cases SET forensics_json = ?, updated_at = ? WHERE id = ?",
|
|
(json.dumps(forensics, ensure_ascii=False), now, int(case_id)),
|
|
)
|
|
self._insert_audit(
|
|
c,
|
|
case_id=case_id,
|
|
action="link_hayabusa",
|
|
actor=actor,
|
|
details={
|
|
"host": payload["host"],
|
|
"mode": payload["mode"],
|
|
"status": payload["status"],
|
|
"intake_id": payload.get("intake_id"),
|
|
"report_dir": payload.get("report_dir"),
|
|
},
|
|
)
|
|
c.commit()
|
|
return self.get_case(case_id, c)
|
|
|
|
def add_comment(self, case_id: int, comment: str, author: str | None = None) -> dict[str, Any]:
|
|
now = self._now()
|
|
with self.conn() as c:
|
|
cur = c.execute(
|
|
"INSERT INTO case_comments (case_id, comment, author, created_at) VALUES (?, ?, ?, ?)",
|
|
(int(case_id), comment, author, now),
|
|
)
|
|
cid = int(cur.lastrowid)
|
|
self._insert_audit(
|
|
c,
|
|
case_id=case_id,
|
|
action="comment",
|
|
actor=author,
|
|
details={"comment_id": cid},
|
|
)
|
|
c.commit()
|
|
row = c.execute("SELECT id, case_id, comment, author, created_at FROM case_comments WHERE id = ?", (cid,)).fetchone()
|
|
return dict(row)
|
|
|
|
def list_comments(self, case_id: int, limit: int = 200) -> list[dict[str, Any]]:
|
|
with self.conn() as c:
|
|
rows = c.execute(
|
|
"SELECT id, case_id, comment, author, created_at FROM case_comments WHERE case_id = ? ORDER BY id DESC LIMIT ?",
|
|
(int(case_id), int(limit)),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def list_audit(self, case_id: int, limit: int = 200) -> list[dict[str, Any]]:
|
|
with self.conn() as c:
|
|
rows = c.execute(
|
|
"SELECT id, case_id, action, actor, details_json, created_at FROM case_audit WHERE case_id = ? ORDER BY id DESC LIMIT ?",
|
|
(int(case_id), int(limit)),
|
|
).fetchall()
|
|
out: list[dict[str, Any]] = []
|
|
for r in rows:
|
|
details = None
|
|
if r["details_json"]:
|
|
try:
|
|
details = json.loads(r["details_json"])
|
|
except Exception:
|
|
details = None
|
|
out.append(
|
|
{
|
|
"id": int(r["id"]),
|
|
"case_id": int(r["case_id"]),
|
|
"action": r["action"],
|
|
"actor": r["actor"],
|
|
"details": details,
|
|
"created_at": r["created_at"],
|
|
}
|
|
)
|
|
return out
|
|
|
|
def _insert_audit(
|
|
self,
|
|
c: sqlite3.Connection,
|
|
case_id: int,
|
|
action: str,
|
|
actor: str | None,
|
|
details: dict[str, Any] | None = None,
|
|
) -> None:
|
|
c.execute(
|
|
"INSERT INTO case_audit (case_id, action, actor, details_json, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
(
|
|
int(case_id),
|
|
action,
|
|
actor,
|
|
json.dumps(details, ensure_ascii=False) if details is not None else None,
|
|
self._now(),
|
|
),
|
|
)
|