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:
@@ -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`
|
Xandikos — fuente de verdad del lado agenda/calendario. `contacts.note_path`
|
||||||
se enlaza contra `persons` matcheando por UID `osint-<slug>`, por el
|
se enlaza contra `persons` matcheando por UID `osint-<slug>`, por el
|
||||||
`dav_uid` extraído del campo `fuente` de la ficha, por teléfono normalizado
|
`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/<slug>/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:
|
3. **Derivadas** (schema `derived`): SOLO datos computados. **Regla dura:
|
||||||
ninguna tabla de `derived` lleva columna que referencie notas** (`note_path`
|
ninguna tabla de `derived` lleva columna que referencie notas** (`note_path`
|
||||||
prohibido ahí; hay un test que lo verifica vía `information_schema`). Se
|
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/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/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/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 = <target_slug>:<scan_type>:<YYYYMMDD-HHMM>` derivado de `scan_ts` (default: ahora); idempotente por id (upsert). Responde `{"status":"ok","id":<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` | 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 |
|
| 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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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/<slug>/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_slug>:<scan_type>:<ts_compact>"
|
||||||
|
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/<slug>/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);
|
||||||
@@ -64,6 +64,13 @@ from server import writes # noqa: E402
|
|||||||
RENDER_MAX_ROWS = 200
|
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):
|
class QueryBody(BaseModel):
|
||||||
"""Body de POST /api/query."""
|
"""Body de POST /api/query."""
|
||||||
|
|
||||||
@@ -154,6 +161,23 @@ class CalendarBody(BaseModel):
|
|||||||
color: str | None = None
|
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:
|
def create_app(cfg: Config) -> FastAPI:
|
||||||
"""Construye la app FastAPI con la configuración dada (inyectable en tests)."""
|
"""Construye la app FastAPI con la configuración dada (inyectable en tests)."""
|
||||||
app = FastAPI(title="osint_db", docs_url=None, redoc_url=None)
|
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).
|
# last-write-wins por etag (incremental, distinto de ingest_dav).
|
||||||
return _guard(lambda: writes.pull_dav(cfg))
|
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")
|
@app.post("/api/org/render-contacts")
|
||||||
def org_render_contacts() -> dict:
|
def org_render_contacts() -> dict:
|
||||||
# Materializa en la ficha .md de cada organización la tabla de sus
|
# Materializa en la ficha .md de cada organización la tabla de sus
|
||||||
|
|||||||
@@ -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:
|
def render_org_contacts(cfg: Config, slug: str) -> dict:
|
||||||
"""Escribe en la ficha de una organización la tabla de sus contactos.
|
"""Escribe en la ficha de una organización la tabla de sus contactos.
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
by_uid = {row["uid"]: row["etag"] for row in rows}
|
||||||
assert by_uid["c-a"] == '"etag-a"'
|
assert by_uid["c-a"] == '"etag-a"'
|
||||||
assert by_uid["c-b"] == '"etag-b"'
|
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 <slug>:<type>:<YYYYMMDD-HHMM>."""
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user