feat(scans): persistencia de escaneos de red + POST /api/scan

Tabla network_scans (migración 005, schema main, lleva note_path) que otras
herramientas pueblan vía HTTP con escaneos de reconocimiento (whois/rdap/dns/
nmap/traceroute/ping). Endpoint POST /api/scan: id determinista
<target_slug>:<scan_type>:<YYYYMMDD-HHMM> derivado de scan_ts, idempotente por
id (duckdb_upsert ON CONFLICT DO UPDATE) bajo el lock single-writer del service.
summary (dict) se serializa a JSON.

network_scans no se deriva de notas: ni ingest_vault ni ingest_dav la tocan, así
que un re-ingest del vault no la trunca (test lo verifica).

Tests: inserción + id derivado, idempotencia mismo-minuto, validación de campos
requeridos (422), y no-truncado por ingest del vault.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 13:13:36 +02:00
parent 9677903ca6
commit 3063d3c44f
5 changed files with 277 additions and 1 deletions
+35
View File
@@ -64,6 +64,13 @@ from server import writes # noqa: E402
RENDER_MAX_ROWS = 200
def _utcnow_iso() -> str:
"""Timestamp ISO 8601 en UTC (default de scan_ts si la herramienta no lo da)."""
from datetime import datetime, timezone
return datetime.now(tz=timezone.utc).isoformat()
class QueryBody(BaseModel):
"""Body de POST /api/query."""
@@ -154,6 +161,23 @@ class CalendarBody(BaseModel):
color: str | None = None
class ScanBody(BaseModel):
"""Body de POST /api/scan: escaneo de red registrado por otra herramienta.
target/target_slug/scan_type/note_path son obligatorios; tool y summary son
opcionales. scan_ts es un timestamp ISO del momento del escaneo (default: el
ahora del service si la herramienta no lo aporta) del que se deriva el id.
"""
target: str
target_slug: str
scan_type: str
tool: str | None = None
note_path: str
summary: dict = Field(default_factory=dict)
scan_ts: str | None = None
def create_app(cfg: Config) -> FastAPI:
"""Construye la app FastAPI con la configuración dada (inyectable en tests)."""
app = FastAPI(title="osint_db", docs_url=None, redoc_url=None)
@@ -393,6 +417,17 @@ def create_app(cfg: Config) -> FastAPI:
# last-write-wins por etag (incremental, distinto de ingest_dav).
return _guard(lambda: writes.pull_dav(cfg))
@app.post("/api/scan")
def record_scan(body: ScanBody) -> dict:
# Escaneo de red (whois/rdap/dns/nmap/...) registrado por otra
# herramienta. La escritura va bajo el lock single-writer vía
# writes.record_scan (duckdb_upsert idempotente por id). Si la
# herramienta no aporta scan_ts, usamos el ahora del service en ISO.
fields = body.model_dump()
if not fields.get("scan_ts"):
fields["scan_ts"] = _utcnow_iso()
return _guard(lambda: writes.record_scan(cfg, fields))
@app.post("/api/org/render-contacts")
def org_render_contacts() -> dict:
# Materializa en la ficha .md de cada organización la tabla de sus
+106
View File
@@ -1438,6 +1438,112 @@ def pull_dav(cfg: Config) -> dict:
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# network_scans (escaneos de red registrados por otras herramientas)
# ---------------------------------------------------------------------------
def _scan_ts_compact(scan_ts: str) -> tuple:
"""Deriva (datetime, 'YYYYMMDD-HHMM') de un timestamp ISO del escaneo.
Acepta ISO 8601 con o sin 'Z'/offset. Devuelve (dt, compact) o lanza
ValueError si el string no es parseable (el caller lo convierte en error).
"""
raw = (scan_ts or "").strip()
if not raw:
raise ValueError("scan_ts vacío")
# datetime.fromisoformat no acepta el sufijo 'Z' (Python < 3.11), lo
# normalizamos a +00:00 para que parsee el caso UTC más común.
iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
dt = datetime.fromisoformat(iso)
return dt, dt.strftime("%Y%m%d-%H%M")
def record_scan(cfg: Config, fields: dict) -> dict:
"""Registra un escaneo de red en network_scans (idempotente por id).
El id se deriva de ``<target_slug>:<scan_type>:<ts_compact>`` donde
ts_compact = YYYYMMDD-HHMM de ``scan_ts``. Dos escaneos del mismo target,
tipo y minuto colapsan al mismo id: el upsert (ON CONFLICT DO UPDATE) los
hace idempotentes en vez de duplicar. La escritura va bajo el lock
single-writer del service vía ``duckdb_upsert`` (misma conexión que el resto
de endpoints de escritura; DuckDB comparte la instancia de la base dentro del
proceso, así que no rompe el lock).
``summary`` es un dict que se serializa a JSON (la columna es de tipo JSON).
No se pushea nada a la red: a diferencia de contacts/events, los escaneos no
salen del service.
Args:
cfg: configuración del service (db_path).
fields: dict con target, target_slug, scan_type, note_path (requeridos),
tool, summary, scan_ts (opcionales/derivables).
Returns:
{"status":"ok","id":<id>, "inserted":N, "updated":N} o
{"status":"error","error":...}.
"""
target = (fields.get("target") or "").strip()
target_slug = (fields.get("target_slug") or "").strip()
scan_type = (fields.get("scan_type") or "").strip()
note_path = (fields.get("note_path") or "").strip()
if not target:
return {"status": "error", "error": "falta 'target'"}
if not target_slug:
return {"status": "error", "error": "falta 'target_slug'"}
if not scan_type:
return {"status": "error", "error": "falta 'scan_type'"}
if not note_path:
return {"status": "error", "error": "falta 'note_path'"}
try:
scan_dt, ts_compact = _scan_ts_compact(fields.get("scan_ts"))
except ValueError as e:
return {"status": "error", "error": f"scan_ts inválido: {e}"}
scan_id = f"{target_slug}:{scan_type}:{ts_compact}"
summary = fields.get("summary")
row = {
"id": scan_id,
"target": target,
"target_slug": target_slug,
"scan_type": scan_type,
"tool": fields.get("tool"),
"scan_ts": scan_dt,
"note_path": note_path,
"summary": _json(summary) if summary is not None else None,
"created_at": _now(),
}
res = duckdb_upsert(
cfg.db_path,
"network_scans",
[row],
key_cols=["id"],
update_cols=[
"target",
"target_slug",
"scan_type",
"tool",
"scan_ts",
"note_path",
"summary",
],
)
if res.get("status") != "ok":
return {"status": "error", "error": res.get("error")}
return {
"status": "ok",
"id": scan_id,
"inserted": res.get("inserted", 0),
"updated": res.get("updated", 0),
}
# ---------------------------------------------------------------------------
# Materialización de los contactos de una organización en su ficha .md
# ---------------------------------------------------------------------------
def render_org_contacts(cfg: Config, slug: str) -> dict:
"""Escribe en la ficha de una organización la tabla de sus contactos.