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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user