diff --git a/app.md b/app.md index ae0f73b..b5dd9be 100644 --- a/app.md +++ b/app.md @@ -82,7 +82,11 @@ en el body (el plugin parsea el body, no el código HTTP). Xandikos — fuente de verdad del lado agenda/calendario. `contacts.note_path` se enlaza contra `persons` matcheando por UID `osint-`, por el `dav_uid` extraído del campo `fuente` de la ficha, por teléfono normalizado - o por email. + o por email. `network_scans` (schema `main`) registra escaneos de red + (whois/rdap/dns/nmap/traceroute/ping) que otras herramientas guardan vía + `POST /api/scan`; lleva `note_path` (la nota `dominios//recon/<...>.md` + donde se documenta cada escaneo) y NO se reconstruye en ningún ingest — la + pueblan los escaneos y nunca se trunca. 3. **Derivadas** (schema `derived`): SOLO datos computados. **Regla dura: ninguna tabla de `derived` lleva columna que referencie notas** (`note_path` prohibido ahí; hay un test que lo verifica vía `information_schema`). Se @@ -131,6 +135,7 @@ Health check: `curl http://127.0.0.1:8771/api/health`. | POST/PUT/DELETE | `/api/event[/{uid}]` | CRUD de eventos CalDAV. Push `caldav_put_event`/`dav_delete_resource` | | POST | `/api/addressbook` | `{slug, display_name?, description?, color?}` → `dav_make_addressbook` + INSERT en `addressbooks` | | POST | `/api/calendar` | `{slug, display_name?, color?}` → `dav_make_calendar` (paridad) | +| POST | `/api/scan` | `{target, target_slug, scan_type, note_path, tool?, summary?, scan_ts?}` → registra un escaneo de red en `network_scans`. `id = ::` derivado de `scan_ts` (default: ahora); idempotente por id (upsert). Responde `{"status":"ok","id":}` | | POST | `/api/push/dav` | reconcilia en bloque por HTTP: recorre `contacts` y `events` de la DB y los empuja a Xandikos (1 PUT + 1 PROPFIND + 1 commit git por recurso, sin borrar). Fallback cuando no hay SSH al host de Xandikos | | POST | `/api/push/dav-bulk` | vía RÁPIDA del push de **contactos** por DISCO: genera todos los `.vcf` en un tmpdir local y hace **1 rsync + 1 commit + 1 PROPFIND** contra el working tree git que Xandikos sirve. Reconcilia ~1000 contactos en <1s en vez de ~6 min. Requiere SSH por clave | diff --git a/migrations/005_network_scans.sql b/migrations/005_network_scans.sql new file mode 100644 index 0000000..2c14864 --- /dev/null +++ b/migrations/005_network_scans.sql @@ -0,0 +1,31 @@ +-- Migración 005: tabla de escaneos de red (network_scans). +-- +-- Persistencia de escaneos de reconocimiento de red (whois, rdap, dns, nmap, +-- traceroute, ping) que otras herramientas del ecosistema OSINT registran vía +-- HTTP en POST /api/scan. Vive en el schema main (no en derived) porque lleva +-- note_path: cada escaneo referencia la nota .md del vault donde se documenta su +-- salida (dominios//recon/<...>.md). La regla dura "derived sin note_path" +-- no aplica aquí — esta es una maestra con referencia a notas, igual que persons +-- o contacts. +-- +-- A diferencia de las maestras de espejo puro (notes, organizations, ...) y de +-- las DAV (contacts, events), network_scans NO se reconstruye en ningún ingest: +-- la pueblan los escaneos vía /api/scan y nunca se trunca. Por eso ni +-- ingest_vault ni ingest_dav la tocan. +-- +-- Aditiva e idempotente: CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. + +CREATE TABLE IF NOT EXISTS network_scans ( + id VARCHAR PRIMARY KEY, -- "::" + target VARCHAR NOT NULL, + target_slug VARCHAR NOT NULL, + scan_type VARCHAR NOT NULL, -- whois|rdap|dns|nmap|traceroute|ping + tool VARCHAR, + scan_ts TIMESTAMP NOT NULL, + note_path VARCHAR NOT NULL, -- dominios//recon/<...>.md + summary JSON, + created_at TIMESTAMP DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_network_scans_slug ON network_scans(target_slug); +CREATE INDEX IF NOT EXISTS idx_network_scans_type ON network_scans(scan_type); diff --git a/server/main.py b/server/main.py index 6dcf170..df39d34 100644 --- a/server/main.py +++ b/server/main.py @@ -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 diff --git a/server/writes.py b/server/writes.py index cc3dfe4..c093891 100644 --- a/server/writes.py +++ b/server/writes.py @@ -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 ``::`` 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":, "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. diff --git a/tests/test_osint_db.py b/tests/test_osint_db.py index 0d87385..b22ea54 100644 --- a/tests/test_osint_db.py +++ b/tests/test_osint_db.py @@ -845,3 +845,102 @@ def test_push_all_dav_bulk_flujo_mockeado(client, cfg, monkeypatch): by_uid = {row["uid"]: row["etag"] for row in rows} assert by_uid["c-a"] == '"etag-a"' assert by_uid["c-b"] == '"etag-b"' + + +# --- F6: escaneos de red (POST /api/scan) ---------------------------------- + + +def test_api_scan_inserta_fila_y_id_derivado(client): + """POST /api/scan inserta en network_scans con id ::.""" + body = { + "target": "example.com", + "target_slug": "example.com", + "scan_type": "whois", + "tool": "whois", + "note_path": "dominios/example.com/recon/whois-20260614-1200.md", + "summary": {"registrar": "X"}, + "scan_ts": "2026-06-14T12:00:00", + } + r = client.post("/api/scan", json=body) + assert r.status_code == 200 + j = r.json() + assert j["status"] == "ok" + assert j["id"] == "example.com:whois:20260614-1200" + assert j["inserted"] == 1 + + q = client.post( + "/api/query", + json={ + "sql": "SELECT id, target, scan_type, tool, note_path, summary " + "FROM network_scans WHERE id = 'example.com:whois:20260614-1200'" + }, + ).json() + assert q["status"] == "ok" + row = q["rows"][0] + assert row["target"] == "example.com" + assert row["scan_type"] == "whois" + assert row["tool"] == "whois" + assert row["note_path"].endswith("whois-20260614-1200.md") + # summary se guarda como JSON. + assert json.loads(row["summary"]) == {"registrar": "X"} + + +def test_api_scan_idempotente_mismo_minuto(client): + """Dos escaneos del mismo target/tipo/minuto colapsan al mismo id (upsert).""" + base = { + "target": "8.8.8.8", + "target_slug": "8.8.8.8", + "scan_type": "ping", + "tool": "ping", + "note_path": "dominios/8.8.8.8/recon/ping.md", + "scan_ts": "2026-06-14T09:30:15", + } + r1 = client.post("/api/scan", json={**base, "summary": {"loss": "0%"}}).json() + assert r1["status"] == "ok" + assert r1["inserted"] == 1 + # Mismo minuto, mismo id -> es un UPDATE, no una segunda fila. + r2 = client.post( + "/api/scan", json={**base, "scan_ts": "2026-06-14T09:30:55", "summary": {"loss": "10%"}} + ).json() + assert r2["status"] == "ok" + assert r2["id"] == r1["id"] == "8.8.8.8:ping:20260614-0930" + assert r2["updated"] == 1 + + q = client.post( + "/api/query", + json={"sql": "SELECT COUNT(*) AS n FROM network_scans WHERE target_slug = '8.8.8.8'"}, + ).json() + assert q["rows"][0]["n"] == 1 # una sola fila pese a los dos POST + + +def test_api_scan_valida_campos_requeridos(client): + """Falta un campo obligatorio -> 4xx (validación de Pydantic).""" + # note_path ausente -> 422 de FastAPI/Pydantic. + r = client.post( + "/api/scan", + json={"target": "x.com", "target_slug": "x.com", "scan_type": "dns"}, + ) + assert r.status_code == 422 + + +def test_api_scan_no_lo_borra_el_ingest_vault(client): + """El re-ingest del vault NO trunca network_scans (no se deriva de notas).""" + client.post("/api/ingest/vault") + client.post( + "/api/scan", + json={ + "target": "acme.com", + "target_slug": "acme.com", + "scan_type": "dns", + "note_path": "dominios/acme.com/recon/dns.md", + "summary": {"a": ["1.2.3.4"]}, + "scan_ts": "2026-06-14T08:00:00", + }, + ) + # Re-ingestar el vault (reconstruye notes + entidades de espejo) no debe + # tocar network_scans. + client.post("/api/ingest/vault") + q = client.post( + "/api/query", json={"sql": "SELECT COUNT(*) AS n FROM network_scans"} + ).json() + assert q["rows"][0]["n"] == 1