Files
fn_registry/python/functions/cybersecurity/save_scan_to_osint.py
T
egutierrez 935008ec3f feat(recon): grupo de reconocimiento de red + servicios + fingerprint web
Añade el capability group `recon` (dominio cybersecurity + pipelines, Python),
con la política de archivado OSINT y página madre docs/capabilities/recon.md.

Lookups y sondeo (wrappers de CLI):
- whois_lookup, rdap_lookup, dns_records, ping_host, traceroute_host, nmap_scan
- save_scan_to_osint (sink común) + recon_osint (pipeline one-shot scan+archivado)

Escaneo de puertos/servicios nativo (stdlib, sin nmap ni sudo):
- scan_tcp_ports: connect-scan TCP concurrente (open/closed/filtered)
- grab_service_banner: banner grab + identificación de servicio/versión real
- identify_port_service: puro, puerto -> servicio IANA esperado (~120 puertos)
- scan_port_services: pipeline one-shot (scan -> identify + banner por puerto abierto)

Fingerprint de tecnología web (estilo Wappalyzer), patrón pura/impura:
- fetch_http_fingerprint: GET stdlib, recoge headers/html/cookies (solo nombres)
- detect_web_tech: puro, matchea ~50 firmas regex -> tecnologías por categoría
- fingerprint_web_stack: pipeline one-shot url -> tecnologías

Todas devuelven dict {status} sin lanzar. Tests: 43 verdes, sin red externa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:12:07 +02:00

234 lines
8.6 KiB
Python

"""Sink comun: persiste el resultado de cualquier escaneo de red en el ecosistema OSINT.
Toda funcion de scan (whois, rdap, dns, nmap, traceroute, ping) llama a esta funcion
DESPUES de ejecutarse para que el resultado quede archivado y navegable. Tiene dos capas:
1. Capa nota (SIEMPRE, fuente de verdad): escribe una nota Markdown en el vault de
Obsidian OSINT bajo `dominios/<slug>/recon/<scan_type>-<ts>.md` con el raw del scan
en un bloque de codigo y un frontmatter tipado. Compone create_obsidian_note del
grupo obsidian.
2. Capa registro estructurado (best-effort): hace POST al service osint_db
(FastAPI + DuckDB single-writer) en /api/scan para indexar el scan. Si el endpoint
no existe todavia (404) o el service esta caido (ConnectionError), degrada a solo-nota
con un register_warning, SIN fallar: la nota ya quedo guardada.
Funcion impura: escribe en disco y hace red. No lanza; devuelve un dict de estado.
"""
import json
import os
import re
import urllib.error
import urllib.request
from datetime import datetime
from obsidian import create_obsidian_note
# Tipos de scan reconocidos. scan_type es texto libre pero se sanea a slug seguro.
_KNOWN_SCAN_TYPES = {"whois", "rdap", "dns", "nmap", "traceroute", "ping"}
# Limite del raw embebido en la nota (caracteres). Por encima se trunca.
_RAW_MAX = 200_000
def _slugify(value: str) -> str:
"""Normaliza un texto a slug seguro: minusculas, solo [a-z0-9._-]."""
s = re.sub(r"[^a-z0-9._-]+", "-", value.strip().lower()).strip("-")
return s or "unknown"
def _fence(raw: str) -> str:
"""Envuelve raw en un bloque de codigo fenced, evitando colisionar con ``` interiores."""
# Elige un cercado con suficientes backticks para que el contenido no lo cierre.
longest = 0
for run in re.findall(r"`+", raw):
longest = max(longest, len(run))
fence = "`" * max(3, longest + 1)
return f"{fence}text\n{raw}\n{fence}"
def save_scan_to_osint(
target: str,
scan_type: str,
raw: str,
summary: dict | None = None,
vault_dir: str = "~/Obsidian/osint",
service_url: str = "http://127.0.0.1:8771",
tool: str | None = None,
) -> dict:
"""Persiste un resultado de escaneo de red en el vault OSINT (nota + registro DuckDB).
Args:
target: objetivo del scan (dominio, host o IP). Define el slug de la carpeta.
scan_type: tipo de scan (whois|rdap|dns|nmap|traceroute|ping); texto libre que
se saneara a slug seguro para nombres de archivo y tags.
raw: salida cruda del scan (texto). Se embebe en un bloque de codigo en la nota;
si supera ~200KB se trunca dejando una marca.
summary: dict opcional con campos resumidos del scan (registrar, ips, puertos,
rtt, etc.). Se anade al frontmatter y se envia al registro estructurado.
vault_dir: raiz del vault OSINT. Se expande ~ . Default ~/Obsidian/osint.
service_url: base del service osint_db. Default http://127.0.0.1:8771.
tool: nombre de la herramienta usada (nmap, dig, whois...). Si None, usa scan_type.
Returns:
dict de estado. Caso ok:
{"status": "ok", "target": str, "slug": str, "scan_type": str,
"note_path": str (rel al vault), "note_abs": str (ruta absoluta),
"registered": bool, "register_warning": str | None,
"scan_id": str | None}
Caso error (solo si falla la escritura critica de la nota):
{"status": "error", "error": str}
"""
try:
scan_type_slug = _slugify(scan_type)
slug = _slugify(target)
tool_name = tool or scan_type_slug
now = datetime.now()
ts_compact = now.strftime("%Y%m%d-%H%M")
ts_iso = now.isoformat()
rel_path = f"dominios/{slug}/recon/{scan_type_slug}-{ts_compact}.md"
summary = summary if isinstance(summary, dict) else {}
# --- Capa nota (critica) ---
frontmatter = {
"tipo": "scan-red",
"scan_tipo": scan_type_slug,
"target": target,
"slug": slug,
"fecha": ts_iso,
"herramienta": tool_name,
"tags": ["scan-red", scan_type_slug, "recon"],
}
if summary:
frontmatter["summary"] = summary
raw_body = raw if isinstance(raw, str) else str(raw)
truncated = False
if len(raw_body) > _RAW_MAX:
raw_body = raw_body[:_RAW_MAX]
truncated = True
lines = [
f"# {scan_type_slug} scan — {target}",
"",
f"- **Target:** {target}",
f"- **Tipo:** {scan_type_slug}",
f"- **Herramienta:** {tool_name}",
f"- **Fecha:** {ts_iso}",
]
if summary:
lines.append("")
lines.append("## Resumen")
for k, v in summary.items():
lines.append(f"- **{k}:** {v}")
lines.append("")
lines.append("## Salida cruda")
lines.append("")
lines.append(_fence(raw_body))
if truncated:
lines.append("")
lines.append(
f"> Salida truncada a {_RAW_MAX} caracteres (el original era mas largo)."
)
body = "\n".join(lines) + "\n"
note_abs = create_obsidian_note(
os.path.expanduser(vault_dir),
rel_path,
body=body,
frontmatter=frontmatter,
overwrite=True,
)
# --- Capa registro estructurado (best-effort) ---
registered = False
register_warning = None
scan_id = None
payload = {
"target": target,
"target_slug": slug,
"scan_type": scan_type_slug,
"tool": tool_name,
"note_path": rel_path,
"summary": summary,
"scan_ts": ts_iso,
}
url = service_url.rstrip("/") + "/api/scan"
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
registered = True
try:
raw_resp = resp.read().decode("utf-8")
parsed = json.loads(raw_resp) if raw_resp else {}
if isinstance(parsed, dict) and parsed.get("id") is not None:
scan_id = str(parsed["id"])
except (ValueError, UnicodeDecodeError):
# 2xx sin body JSON: cuenta como registrado igualmente.
pass
except urllib.error.HTTPError as e:
register_warning = f"HTTP {e.code} desde {url}: {e.reason}"
except urllib.error.URLError as e:
register_warning = f"service osint_db inaccesible en {url}: {e.reason}"
except Exception as e: # noqa: BLE001 - degradacion: red nunca rompe la nota
register_warning = f"registro fallido: {type(e).__name__}: {e}"
return {
"status": "ok",
"target": target,
"slug": slug,
"scan_type": scan_type_slug,
"note_path": rel_path,
"note_abs": note_abs,
"registered": registered,
"register_warning": register_warning,
"scan_id": scan_id,
}
except Exception as e: # noqa: BLE001 - contrato: nunca lanzar
return {"status": "error", "error": f"{type(e).__name__}: {e}"}
if __name__ == "__main__":
import tempfile
tmp_vault = tempfile.mkdtemp()
# service_url apunta a un puerto muerto para ejercitar la degradacion graceful.
result = save_scan_to_osint(
"example.com",
"whois",
"Domain: EXAMPLE.COM\nRegistrar: X",
summary={"registrar": "X"},
vault_dir=tmp_vault,
service_url="http://127.0.0.1:1",
)
assert result["status"] == "ok", result
assert result["slug"] == "example.com", result
assert result["scan_type"] == "whois", result
assert result["note_path"] == result["note_path"], result
assert os.path.isfile(result["note_abs"]), result
assert result["registered"] is False, result
assert result["register_warning"], result
assert result["scan_id"] is None, result
content = open(result["note_abs"], encoding="utf-8").read()
assert "Registrar: X" in content, content
assert "scan-red" in content, content
print("save_scan_to_osint smoke OK")
print(f" note_path: {result['note_path']}")
print(f" note_abs: {result['note_abs']}")
print(f" registered: {result['registered']}")
print(f" register_warning: {result['register_warning']}")