7 Commits

Author SHA1 Message Date
egutierrez 73f41a3474 feat(dav): dav_get_collection + dav_collection_ctag — bulk DAV en 1 request + ctag cache
dav_get_collection trae TODOS los recursos de una coleccion CardDAV/CalDAV en
UNA peticion REPORT (addressbook-query / calendar-query) con el contenido vCard
/ VCALENDAR inline, evitando el patron N+1 (PROPFIND + un GET por recurso). Para
1064 contactos baja de ~9s a ~1s. dav_collection_ctag lee el ctag de la
coleccion (PROPFIND Depth:0 barato) para validar caches sin descargar cuando
nada cambio. Ambas: solo stdlib, basic auth, verify_tls, error-safe, tests que
mockean el multistatus. Grupo dav, verificadas contra Xandikos real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:07:39 +02:00
egutierrez eb8dbf66a1 feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 00:16:46 +02:00
egutierrez 6bc97df5c0 Merge quick/orquestador-command: /orquestador + grupo orchestration (launch_claude_agent_kitty, list_claude_agents) 2026-06-08 21:15:16 +02:00
egutierrez e769836b0d feat(pipelines): auto-commit con 1 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:33:13 +02:00
egutierrez 93756fbd0c chore: auto-commit (1 archivos)
- .claude/settings.local.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:28:02 +02:00
egutierrez 0a6d1b8d17 feat(infra): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 01:57:00 +02:00
egutierrez 82f1f1bd58 feat(infra): parse_unibus_health — healthz del cluster unibus → []PromSample
Función del grupo fleet-metrics que convierte la respuesta JSON del endpoint /healthz
de un nodo unibus (membershipd) en series Prometheus (unibus_up, unibus_status_ok,
unibus_posture_enforce/acl/tls/cluster, unibus_store_kv) con labels node/instance.
Pura de transformación (impure solo por el error de unmarshal). La consume el daemon
unibus_exporter del project fleet_monitoring. Con tests golden/edge/error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:26:15 +02:00
142 changed files with 12620 additions and 308 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ Cualquier `SELECT ... FROM functions/types/apps/proposals WHERE ...` plano se ha
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema` **functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema`
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5. - `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps) - Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser - Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, obsidian
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file` **types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
- Enums: `algebraic`(product|sum) - Enums: `algebraic`(product|sum)
+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(CGO_ENABLED=1 go test *)", "Bash(CGO_ENABLED=1 go test *)",
"Bash(sqlite3 *)" "Bash(sqlite3 *)",
"Read(//home/enmanuel/.claude/**)"
] ]
}, },
"enabledMcpjsonServers": [ "enabledMcpjsonServers": [
@@ -0,0 +1,58 @@
---
name: ensure_project_gitignore
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "ensure_project_gitignore(project_dir: string) -> void"
description: "Garantiza de forma idempotente que el .gitignore de un directorio de project contiene las lineas canonicas que excluyen del repo del project el contenido de sus sub-repos hijos (apps y analyses son repos Gitea independientes) y sus vaults (datos fuera de git). Evita el doble-tracking al hacer push del project."
tags: [git, gitignore, projects, infra]
params:
- name: project_dir
desc: "Ruta al directorio del project (p. ej. projects/aurgi). Debe existir; si no, error a stderr y return 1. El .gitignore se escribe/actualiza en <project_dir>/.gitignore."
output: "Sin salida en stdout. A stderr informa de la accion realizada: 'created' si creo el .gitignore, 'updated: anadidas N lineas' si anadio lineas faltantes, u 'ok: ya completo' si nada cambiaba. Codigo de salida 0 en exito, 1 si project_dir falta o no existe."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/ensure_project_gitignore.sh"
---
## Ejemplo
```bash
source bash/functions/infra/ensure_project_gitignore.sh
# Asegura que projects/aurgi/.gitignore excluye el contenido de sus hijos.
ensure_project_gitignore projects/aurgi
# stderr: ensure_project_gitignore: created projects/aurgi/.gitignore
# (o: updated: anadidas 2 lineas / ok: ya completo)
```
Las lineas canonicas que la funcion garantiza son:
```
apps/*/
analysis/*/
vaults/*
!vaults/.gitkeep
!vaults/vault.yaml
```
## Cuando usarla
Llamala justo despues de crear un project nuevo (`mkdir -p projects/<nombre>/{apps,analysis,vaults}`) y antes de inicializar su repo Gitea con `ensure_repo_synced`, para que el repo del project nunca trackee el contenido de sus sub-repos hijos. Tambien al adoptar un project existente que aun no tiene estas exclusiones, o como paso de saneamiento cuando `git status` del project muestra contenido de `apps/`/`analysis/` que deberia estar ignorado.
## Gotchas
- La funcion modifica el filesystem (escribe en `<project_dir>/.gitignore`): es impura. No commitea ni hace push — solo deja el `.gitignore` correcto.
- La comparacion para no duplicar es linea-exacta (`grep -Fxq`). Una linea equivalente pero con espacios extra, comentario adjunto o glob distinto (p. ej. `apps/*` sin la barra final) NO se considera presente y la canonica se anade igualmente; podrian quedar ambas formas. Mantener el `.gitignore` con las lineas canonicas tal cual evita ruido.
- Si el `.gitignore` existente no termina en salto de linea, la funcion anade uno antes de apendar para no pegar la primera linea nueva al final de la ultima existente.
- Solo gestiona las exclusiones de sub-repos hijos y vaults del nivel-project; no toca otras reglas que el `.gitignore` ya contenga ni las reordena.
- Si una linea canonica ya existia con su forma exacta, no se vuelve a anadir (idempotente): re-ejecutar es seguro.
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# ensure_project_gitignore — Garantiza de forma idempotente que el .gitignore de
# un directorio de project (projects/<nombre>/) contiene las lineas canonicas que
# excluyen del repo del project el contenido de sus sub-repos hijos (apps y
# analyses son repos Gitea independientes) y sus vaults (datos fuera de git).
#
# Esto evita que al hacer push del project se trackee por error el contenido de
# los hijos (doble-tracking). Ver .claude/rules/apps_subrepo.md y
# .claude/rules/projects.md.
#
# Uso:
# ensure_project_gitignore <project_dir>
#
# Salida:
# stdout vacio. A stderr informa de la accion realizada (created / updated / ok).
ensure_project_gitignore() {
local project_dir="$1"
if [[ -z "$project_dir" ]]; then
echo "ensure_project_gitignore: se requiere project_dir" >&2
return 1
fi
if [[ ! -d "$project_dir" ]]; then
echo "ensure_project_gitignore: directorio '$project_dir' no existe" >&2
return 1
fi
local gitignore="$project_dir/.gitignore"
# Lineas canonicas que deben estar presentes (orden de referencia).
local -a canonical=(
"apps/*/"
"analysis/*/"
"vaults/*"
"!vaults/.gitkeep"
"!vaults/vault.yaml"
)
# Caso 1: el .gitignore no existe — crearlo con el contenido canonico.
if [[ ! -f "$gitignore" ]]; then
printf '%s\n' "${canonical[@]}" > "$gitignore"
echo "ensure_project_gitignore: created $gitignore" >&2
return 0
fi
# Caso 2: existe — anadir solo las lineas que falten (comparacion linea-exacta),
# preservando el contenido y el orden existentes.
# Si el archivo no termina en newline, anadir uno antes de apendar para no
# pegar la primera linea nueva al final de la ultima existente.
if [[ -s "$gitignore" && -n "$(tail -c 1 "$gitignore")" ]]; then
printf '\n' >> "$gitignore"
fi
local line added=0
for line in "${canonical[@]}"; do
# grep -F -x: match literal de linea completa, sin interpretar metacaracteres.
if ! grep -Fxq -- "$line" "$gitignore"; then
printf '%s\n' "$line" >> "$gitignore"
added=$((added + 1))
fi
done
if [[ $added -gt 0 ]]; then
echo "ensure_project_gitignore: updated: anadidas $added lineas a $gitignore" >&2
else
echo "ensure_project_gitignore: ok: ya completo $gitignore" >&2
fi
return 0
}
# Si se invoca como script (no source), ejecutar la funcion.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
ensure_project_gitignore "$@"
fi
+10 -3
View File
@@ -3,14 +3,15 @@ name: full_git_pull
kind: pipeline kind: pipeline
lang: bash lang: bash
domain: pipelines domain: pipelines
version: "1.0.0" version: "1.1.0"
purity: impure purity: impure
signature: "full_git_pull() -> stdout: tabla resumen" signature: "full_git_pull() -> stdout: tabla resumen"
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index y ejecuta fn sync." description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index, ejecuta fn sync y reclona los sub-repos hijos faltantes de cada project (apps/analysis) via clone_project_subrepos."
tags: [git, pull, sync, registry, pipeline, pendiente-usar] tags: [git, pull, sync, registry, pipeline, pendiente-usar]
uses_functions: uses_functions:
- discover_git_repos_bash_infra - discover_git_repos_bash_infra
- git_pull_with_stash_bash_infra - git_pull_with_stash_bash_infra
- clone_project_subrepos_bash_pipelines
- pass_get_bash_infra - pass_get_bash_infra
uses_types: [] uses_types: []
returns: [] returns: []
@@ -51,4 +52,10 @@ bash bash/functions/pipelines/full_git_pull.sh
## Notas ## Notas
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. No clona repos faltantes: cada PC tiene el subset que le interesa (clonar manualmente si se necesita uno nuevo). Modo completamente no-interactivo. Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. Modo completamente no-interactivo.
Desde v1.1.0 SI reclona los sub-repos hijos faltantes de cada project: tras `fn sync` (que trae a `registry.db` las filas de apps/analysis de todos los PCs), itera los projects y llama `clone_project_subrepos` para traer al disco los hijos que falten, re-indexando si clono alguno. `registry.db` actua como manifest de sub-repos, asi que clonar el project paraguas + `/full-git-pull` reconstruye su arbol entero sin adivinar nombres. Los repos sueltos (sin project) siguen sin auto-clonarse: cada PC tiene el subset que le interesa.
## Capability growth log
- v1.1.0 (2026-06-10) — anade el paso 6: reclonado de sub-repos hijos de cada project via `clone_project_subrepos` tras `fn sync`, con re-index si clona alguno. Permite reconstruir el arbol completo de un project en un PC nuevo (issue 0171).
+39
View File
@@ -149,6 +149,42 @@ full_git_pull() {
fi fi
fi fi
# --- Paso 6: Reclonar sub-repos hijos de cada project (issue 0171) ---
# Tras fn sync, registry.db contiene las filas apps/analysis de TODOS los PCs.
# clone_project_subrepos clona en este disco los hijos que falten (skip si ya
# existen). Asi, clonar el project paraguas y correr /full-git-pull reconstruye
# su arbol entero sin adivinar nombres de sub-repos: registry.db ES el manifest.
echo "" >&2
echo "[6/6] Reclonando sub-repos de projects..." >&2
local reclone_summary=" [skip] sin projects o registry.db"
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
export FN_REGISTRY_ROOT="$registry_root"
export GITEA_URL="${GITEA_URL:-$(pass_get agentes/gitea-url | head -n1 2>/dev/null || true)}"
local clone_script="$SCRIPT_DIR/clone_project_subrepos.sh"
local any_cloned=0
if [[ -f "$clone_script" ]]; then
while IFS= read -r proj_id; do
[[ -z "$proj_id" ]] && continue
local clone_out
clone_out=$(bash "$clone_script" "$proj_id" 2>&1 || true)
if echo "$clone_out" | grep -q '\[cloned\]'; then
any_cloned=1
echo " $proj_id: nuevos sub-repos clonados" >&2
fi
done < <(sqlite3 "$registry_root/registry.db" "SELECT id FROM projects;" 2>/dev/null)
if [[ "$any_cloned" -eq 1 ]]; then
echo " re-index tras clonado..." >&2
[[ -x "$fn_bin" ]] && CGO_ENABLED=1 "$fn_bin" index >/dev/null 2>&1 || true
reclone_summary=" OK: nuevos sub-repos clonados + re-index"
else
reclone_summary=" OK: nada que clonar (todo presente)"
fi
else
reclone_summary=" [skip] clone_project_subrepos.sh no encontrado"
fi
fi
echo " $reclone_summary" >&2
# --- Resumen --- # --- Resumen ---
echo "" echo ""
echo "===== RESUMEN full_git_pull =====" echo "===== RESUMEN full_git_pull ====="
@@ -171,6 +207,9 @@ full_git_pull() {
echo "" echo ""
echo "fn sync:" echo "fn sync:"
echo "$sync_summary" echo "$sync_summary"
echo ""
echo "Reclonado sub-repos de projects:"
echo "$reclone_summary"
if [[ ${#diverged[@]} -gt 0 || ${#conflicts[@]} -gt 0 ]]; then if [[ ${#diverged[@]} -gt 0 || ${#conflicts[@]} -gt 0 ]]; then
echo "" echo ""
+7 -2
View File
@@ -3,10 +3,10 @@ name: full_git_push
kind: pipeline kind: pipeline
lang: bash lang: bash
domain: pipelines domain: pipelines
version: "1.0.0" version: "1.1.0"
purity: impure purity: impure
signature: "full_git_push(commit_message?: string) -> stdout: tabla resumen" signature: "full_git_push(commit_message?: string) -> stdout: tabla resumen"
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses sin .git via ensure_repo_synced, auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync." description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses Y projects paraguas sin .git via ensure_repo_synced (asegurando el .gitignore canonico del project antes), auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
tags: [git, push, sync, registry, pipeline, pendiente-usar] tags: [git, push, sync, registry, pipeline, pendiente-usar]
uses_functions: uses_functions:
- discover_git_repos_bash_infra - discover_git_repos_bash_infra
@@ -14,6 +14,7 @@ uses_functions:
- git_auto_commit_dirty_bash_infra - git_auto_commit_dirty_bash_infra
- git_push_if_ahead_bash_infra - git_push_if_ahead_bash_infra
- ensure_repo_synced_bash_infra - ensure_repo_synced_bash_infra
- ensure_project_gitignore_bash_infra
- pass_get_bash_infra - pass_get_bash_infra
uses_types: [] uses_types: []
returns: [] returns: []
@@ -62,3 +63,7 @@ bash bash/functions/pipelines/full_git_push.sh "feat: nueva funcion"
## Notas ## Notas
El unico motivo para abortar antes de commitear es la deteccion de secrets. Cualquier otro error (push rechazado por non-fast-forward, fn sync no disponible) se reporta en el resumen y el pipeline continua con el resto de repos. Modo completamente no-interactivo. El unico motivo para abortar antes de commitear es la deteccion de secrets. Cualquier otro error (push rechazado por non-fast-forward, fn sync no disponible) se reporta en el resumen y el pipeline continua con el resto de repos. Modo completamente no-interactivo.
## Capability growth log
- v1.1.0 (2026-06-10) — auto-inicializa tambien los projects paraguas (`projects/<p>/`) sin repo Gitea, no solo apps/analyses. Antes de pushear cada project asegura su `.gitignore` canonico via `ensure_project_gitignore` para no trackear el contenido de los sub-repos hijos. Cierra el agujero por el que projects como aurgi/obsidian/osint vivian solo en disco y se perdian al borrar el PC (issue 0171).
+32 -20
View File
@@ -13,6 +13,7 @@ source "$INFRA_DIR/git_auto_commit_dirty.sh"
source "$INFRA_DIR/git_push_if_ahead.sh" source "$INFRA_DIR/git_push_if_ahead.sh"
source "$INFRA_DIR/pass_get.sh" source "$INFRA_DIR/pass_get.sh"
source "$INFRA_DIR/ensure_repo_synced.sh" source "$INFRA_DIR/ensure_repo_synced.sh"
source "$INFRA_DIR/ensure_project_gitignore.sh"
source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh" source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
full_git_push() { full_git_push() {
@@ -65,6 +66,32 @@ full_git_push() {
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \ ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
echo " [warn] fallo inicializando $d" >&2 echo " [warn] fallo inicializando $d" >&2
done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null) done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null)
# Paso 1c: Auto-inicializar los PROJECTS paraguas sin .git (issue 0171).
# El directorio projects/<p>/ versiona SOLO las docs de nivel-project
# (project.md, vault.yaml, CONVENTIONS.md, tools/...). Sus hijos apps/* y
# analysis/* son sub-repos Gitea independientes, excluidos por el .gitignore
# canonico que ensure_project_gitignore garantiza ANTES del push para no
# trackear su contenido (doble-tracking). Sin esto, un project sin repo
# (aurgi, obsidian, osint) vivia solo en disco y se perdia al borrar el PC.
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
while IFS= read -r proj_dir; do
[[ -z "$proj_dir" ]] && continue
local pd="$registry_root/$proj_dir"
[[ -d "$pd" ]] || continue
# Garantizar el .gitignore canonico ANTES de cualquier git add -A.
ensure_project_gitignore "$pd" || \
echo " [warn] no se pudo asegurar .gitignore de $pd" >&2
if [[ -d "$pd/.git" ]]; then
git -C "$pd" remote get-url origin >/dev/null 2>&1 && continue
echo " fix-remote: $pd (.git sin origin)" >&2
else
echo " auto-init project: $pd" >&2
fi
ensure_repo_synced "$pd" dataforge "$(basename "$pd")" master "chore: initial sync project" || \
echo " [warn] fallo inicializando project $pd" >&2
done < <(sqlite3 "$registry_root/registry.db" "SELECT CASE WHEN dir_path != '' THEN dir_path ELSE 'projects/'||id END FROM projects;" 2>/dev/null)
fi
else else
echo " [warn] registry.db o sqlite3 no disponibles — omitiendo auto-init BD-driven" >&2 echo " [warn] registry.db o sqlite3 no disponibles — omitiendo auto-init BD-driven" >&2
fi fi
@@ -72,28 +99,13 @@ full_git_push() {
echo " [skip] GITEA_URL/GITEA_TOKEN no disponibles — omitiendo auto-init" >&2 echo " [skip] GITEA_URL/GITEA_TOKEN no disponibles — omitiendo auto-init" >&2
fi fi
# Redescubrir repos tras posibles inicializaciones # Redescubrir repos tras posibles inicializaciones.
# El repo de config de Claude (dataforge/repo_Claude, al que apuntan los
# symlinks de ~/.claude/) vive en fn_registry/external/repo_Claude, asi que
# discover_git_repos ya lo encuentra y pasa por scan-secrets/commit/push
# como un repo mas. No necesita tratamiento especial.
repos=$(discover_git_repos "$registry_root") repos=$(discover_git_repos "$registry_root")
# --- Paso 1c: Incluir el repo de configuracion de Claude ---
# Los archivos de ~/.claude/ (settings.json, commands, skills, CLAUDE.md...)
# son symlinks a un repo git externo (dataforge/repo_Claude). Lo resolvemos
# de forma portable siguiendo el symlink de settings.json — sin hardcodear
# el path, que difiere entre PCs. Si resuelve a un repo git, lo anadimos a
# la lista para que pase por scan-secrets + auto-commit + push como los demas.
local claude_repo=""
if [[ -L "$HOME/.claude/settings.json" ]]; then
local _claude_settings_real
_claude_settings_real=$(readlink -f "$HOME/.claude/settings.json" 2>/dev/null || true)
if [[ -n "$_claude_settings_real" ]]; then
claude_repo=$(git -C "$(dirname "$_claude_settings_real")" rev-parse --show-toplevel 2>/dev/null || true)
fi
fi
if [[ -n "$claude_repo" && -d "$claude_repo/.git" ]]; then
echo "[1c] Incluyendo repo de config Claude: $claude_repo" >&2
repos="$repos"$'\n'"$claude_repo"
fi
# --- Paso 2: Escanear secrets --- # --- Paso 2: Escanear secrets ---
echo "" >&2 echo "" >&2
echo "[2/6] Escaneando secrets en dirty trees..." >&2 echo "[2/6] Escaneando secrets en dirty trees..." >&2
+26
View File
@@ -70,6 +70,8 @@ func cmdDoctor(args []string) {
doctorDod(r, jsonOut) doctorDod(r, jsonOut)
case "e2e-coverage": case "e2e-coverage":
doctorE2ECoverage(r, jsonOut) doctorE2ECoverage(r, jsonOut)
case "projects":
doctorProjects(r, jsonOut)
default: default:
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub) fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
doctorUsage() doctorUsage()
@@ -100,6 +102,7 @@ Subcommands:
modules Drift entre uses_modules (app.md) y fn_module_<x> link calls (CMakeLists.txt) - issue 0097 modules Drift entre uses_modules (app.md) y fn_module_<x> link calls (CMakeLists.txt) - issue 0097
dod Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114) dod Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114)
e2e-coverage Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b) e2e-coverage Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b)
projects Cobertura de projects vs sub-repos Gitea (repo propio + hijos clonables) (issue 0171)
Flags: Flags:
--json Salida JSON (para scripting/agentes) --json Salida JSON (para scripting/agentes)
@@ -505,6 +508,29 @@ func doctorML(root string, jsonOut bool) {
fmt.Printf("\nOverall ML environment: %s\n", overall) fmt.Printf("\nOverall ML environment: %s\n", overall)
} }
func doctorProjects(root string, jsonOut bool) {
rows, err := infra.AuditProjectsCoverage(root)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
orphans, oerr := infra.FindOrphanProjectRefs(root)
if oerr != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", oerr)
os.Exit(1)
}
if jsonOut {
emit(map[string]any{
"coverage": rows,
"orphan_project_ids": orphans,
})
return
}
fmt.Print(infra.FormatProjectsCoverage(rows))
fmt.Println("\n--- Check inverso: project_id huérfanos (apps/analysis sin project declarado) ---")
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
}
func emit(v any) { func emit(v any) {
b, err := json.MarshalIndent(v, "", " ") b, err := json.MarshalIndent(v, "", " ")
if err != nil { if err != nil {
@@ -0,0 +1,199 @@
---
id: "0171"
title: "Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea"
status: pendiente
type: enhancement
domain:
- registry-quality
- infra
scope: registry-only
priority: alta
depends: []
blocks: []
related: ["0166"]
created: 2026-06-10
updated: 2026-06-10
tags: [projects, subrepo, gitea, clone, backup, manifest, fn-doctor]
---
> **Actualización 10/06/2026 — implementado el núcleo (enfoque KISS).** El manifest
> `subrepos.yaml` propuesto abajo se **descartó**: `registry.db` (tablas `apps`/`analysis`
> con `project_id`, propagadas entre PCs por `fn sync`) **ya es** el manifest de sub-repos, y
> `clone_project_subrepos_bash_pipelines` ya lo consume. No hace falta un archivo nuevo. Lo que
> faltaba era integración + auditoría. Ver `## Estado de implementación` al final.
# 0171 — Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea
## APP Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0171 |
| **Estado** | pendiente |
| **Prioridad** | alta (riesgo de pérdida de datos) |
| **Tipo** | enhancement — metadata de projects + `/full-git-pull` + `fn doctor` |
## Contexto
El 10/06/2026, al preparar un dashboard sobre el project `aurgi`, se descubrió que el project
paraguas **no existía en Gitea** (`dataforge/aurgi` → 404). Sus 3 analyses sí estaban a salvo como
sub-repos independientes (`dataforge/venta_web`, `dataforge/sale_prices_comprobation`,
`dataforge/presupuestos_callcenter`), pero **el `project.md`, `vault.yaml` y `CONVENTIONS.md` de
nivel-project no estaban versionados en ningún sitio**. Reconstruir el project obligó a *adivinar*
los nombres de los sub-repos hijos uno a uno desde la lista completa de repos de Gitea.
Una auditoría de cobertura `projects ↔ Gitea` confirmó el agujero:
| Project | Repo Gitea | Riesgo |
|---|---|---|
| fleet_monitoring, fn_monitoring, message_bus, web_scraping | ✅ | ninguno |
| **obsidian**, **osint** | ❌ (solo en disco local) | alto — resuelto en esta sesión (subidos a `dataforge/obsidian`, `dataforge/osint`) |
| **aurgi** | ❌ (404, paraguas inexistente) | pendiente — analyses salvados, docs nivel-project no |
Dos problemas estructurales quedan abiertos:
1. **Projects sin repo Gitea**: su contenido de nivel-project vive solo en disco. Si se borra el
disco (o el project no se sincroniza a otro PC), se pierde. La regla `projects.md` dice que cada
project debe ser su propio repo Gitea, pero no hay nada que lo **verifique ni lo fuerce**.
2. **Sub-repos hijos no referenciados**: el `.gitignore` de cada project excluye `apps/*/` y
`analysis/*/` (son sub-repos independientes). Por tanto, **un clon fresco del project NO trae sus
hijos**, y no existe ningún manifest que diga *qué hijos clonar*. Hoy `/full-git-pull` solo
descubre repos vía `discover_git_repos_bash_infra` (busca `.git` ya presentes en disco): si el
hijo nunca se clonó, es invisible. Resultado: para reconstruir un project en una máquina nueva hay
que adivinar sus sub-repos (exactamente lo que pasó con aurgi).
## Objetivo
Que **todo project** (a) tenga su repo Gitea garantizado y (b) **referencie declarativamente sus
sub-repos hijos** (apps + analyses), de modo que clonar el project en cualquier PC permita
re-clonar automáticamente todo su árbol sin adivinar nada.
## Propuesta
### 1. Manifest de sub-repos por project
Añadir a cada project un manifest declarativo de sus hijos. Dos opciones de formato (decidir una):
- **Opción A (KISS, preferida): `subrepos.yaml`** en la raíz del project, análogo a `vault.yaml`:
```yaml
# projects/<p>/subrepos.yaml — sub-repos hijos de este project (apps + analyses)
subrepos:
- kind: analysis # app | analysis
name: venta_web
path: analysis/venta_web
repo: dataforge/venta_web
url: https://gitea-.../dataforge/venta_web
- kind: analysis
name: sale_prices_comprobation
path: analysis/sale_prices_comprobation
repo: dataforge/sale_prices_comprobation
url: https://gitea-.../dataforge/sale_prices_comprobation
```
- **Opción B: sección `## Sub-repos`** en `project.md` con una tabla `kind | name | path | url`.
`subrepos.yaml` (Opción A) es más fácil de parsear por las funciones de git y se versiona con el
project (no está en el `.gitignore`). El manifest se **autogenera/actualiza** escaneando los `.git`
hijos presentes en disco + su `remote get-url origin` (reusar `discover_git_repos_bash_infra`).
### 2. Generación y mantenimiento del manifest
Función/pipeline nueva (delegar a `fn-constructor`, grupo `infra`/git) que, dado un project:
- Escanea `apps/*/.git` y `analysis/*/.git`, lee su remote origin.
- Escribe/actualiza `subrepos.yaml`.
- Idempotente. Se invoca dentro de `/full-git-push` (o `fn index`) para mantener el manifest al día.
### 3. Re-clonado desde el manifest en `/full-git-pull`
Extender `/full-git-pull` para que, tras actualizar cada project, lea su `subrepos.yaml` y **clone
los hijos que falten** (`url` → `path`). Así, en un PC nuevo: clonar `dataforge/<project>` →
`/full-git-pull` → reconstruye apps + analyses automáticamente. Requiere una función
`clone_missing_subrepos_bash_infra(project_dir)` (delegar a `fn-constructor`).
### 4. Garantizar repo Gitea de cada project + auditoría en `fn doctor`
- Subcomando nuevo `fn doctor projects` (función `audit_projects_coverage_go_infra`): por cada
project en disco reporta `repo_gitea` (existe en Gitea sí/no), `repo_url` (declarado en project.md
sí/no), y `subrepos_manifest` (presente + cuántos hijos en disco sin entrada / en manifest sin
clonar). Salida `--json`. Cero hallazgos = sano.
- Acción derivada documentada: `repo_gitea=no` → `ensure_repo_synced_bash_infra projects/<p>
dataforge <p> master "init: project <p>"`.
### 5. Backfill inicial
- `aurgi`: traer su `project.md` / `vault.yaml` / `CONVENTIONS.md` de `aurgi-pc` (o `home-wsl`) y
crear `dataforge/aurgi` + `subrepos.yaml` con los 3 analyses ya conocidos. **No** reconstruir a
mano un `project.md` mínimo (divergiría del real).
- Resto de projects con hijos (`fleet_monitoring`, `fn_monitoring`, `message_bus`, `web_scraping`):
generar su `subrepos.yaml` con la función del punto 2.
## Definition of Done
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: clon fresco reconstruye árbol | e2e | clonar `dataforge/<p>` en dir limpio → `/full-git-pull` | apps + analyses del project re-clonados desde `subrepos.yaml` |
| Edge: project sin hijos (obsidian) | e2e | generar manifest | `subrepos.yaml` válido y vacío (o ausente), sin error |
| Edge: hijo en disco sin `.git` | unit | auditoría | `fn doctor projects` lo reporta como "hijo sin sub-repo" |
| Error: project sin repo Gitea | e2e | `fn doctor projects --json` | lo marca `repo_gitea=false`, sugiere `ensure_repo_synced` |
| Cobertura | audit | `fn doctor projects` | 0 projects sin repo, 0 hijos sin referenciar |
## Decisiones abiertas
1. **Formato del manifest**: `subrepos.yaml` (A) vs. sección en `project.md` (B). Recomendado A.
2. **¿Auto-generar el manifest en `fn index`** o solo en `/full-git-push`? (evitar I/O de red en
`fn index`; preferible en push).
3. **aurgi**: ¿traer de `aurgi-pc` por SSH ahora, o dejarlo para cuando el project se sincronice?
## Notas
En esta sesión ya se resolvió el riesgo inmediato: `obsidian` y `osint` se subieron a Gitea
(`dataforge/obsidian`, `dataforge/osint`) con `ensure_repo_synced_bash_infra` y se les añadió
`repo_url` en su `project.md`. Este issue cubre la solución **estructural y reutilizable** para que
el caso no vuelva a ocurrir con ningún project. Relacionado con #0166 (dependencias app→app para
build reproducible): ambos persiguen que clonar el ecosistema en un PC nuevo sea determinista.
## Estado de implementación (10/06/2026)
Implementado con enfoque KISS, **sin** `subrepos.yaml` (registry.db + `fn sync` ya cumplen esa
función). Cambios:
**Funciones nuevas:**
- `ensure_project_gitignore_bash_infra` — garantiza idempotente el `.gitignore` canónico de un
project (`apps/*/`, `analysis/*/`, `vaults/*` + excepciones) antes de cualquier `git add -A`,
para no trackear el contenido de los sub-repos hijos.
- `audit_projects_coverage_go_infra` (+ `FormatProjectsCoverage`) — motor de `fn doctor projects`.
Reporta por project: `git`/`remote`/`repo_url`/`children (cloned/inDB)` + issues
(`no_gitea_repo`, `children_missing`, `dir_not_found`). Solo git local + registry.db, sin red.
**Integraciones:**
- `full_git_push` v1.1.0 — paso 1c: auto-inicializa y pushea los **projects paraguas** sin repo
(antes solo apps/analyses), asegurando el `.gitignore` canónico primero. Cierra el agujero
aurgi/obsidian/osint.
- `full_git_pull` v1.1.0 — paso 6: tras `fn sync`, reclona los sub-repos hijos faltantes de cada
project con `clone_project_subrepos` + re-index. Clonar el paraguas + `/full-git-pull` reconstruye
el árbol entero.
- `fn doctor projects` — nuevo subcomando (`cmd/fn/doctor.go`). Hoy reporta **0 projects con
problemas**.
**Hecho aparte (riesgo inmediato):** `dataforge/obsidian` + `dataforge/osint` creados, `repo_url`
en sus `project.md`.
### Pendientes (no bloquean el núcleo)
1. **Check inverso — HECHO (10/06/2026).** `FindOrphanProjectRefs` + `FormatOrphanProjectRefs` en
`audit_projects_coverage_go_infra`, enchufado en `fn doctor projects`. Detecta apps/analysis con
`project_id` sin fila en `projects`. Hoy reporta 4 paraguas huérfanos (existen en otro PC, nunca
subidos a Gitea — mismo caso que aurgi):
- `element_agents` (6 apps: agents_and_robots, agents_dashboard, device_agent, element_matrix_chat,
matrix_admin_panel, matrix_client_pc)
- `imagegen` (image_to_3d_studio)
- `osint_graph` (graph_explorer)
- `aurgi` (sus analyses sí están en Gitea; el paraguas no)
2. **Fix de datos de los 4 paraguas huérfanos — pendiente, requiere el PC origen.** No están en disco
ni en Gitea en este PC (`lucas-linux`), así que no se pueden reconstruir aquí sin inventar. El fix
correcto: correr `/full-git-push` en el PC donde cada paraguas existe en disco (`aurgi-pc` /
`home-wsl`). Con `full_git_push` v1.1.0 (paso 1c) eso ya los crea en Gitea automáticamente. Tras
eso, `/full-git-pull` aquí (paso 6) los traerá. NO reconstruir un `project.md` mínimo a mano.
3. **DoD vida útil**: validar el reclonado en un PC nuevo real (clon limpio del paraguas →
`/full-git-pull` → árbol reconstruido) antes de declarar el issue cerrado.
+184
View File
@@ -0,0 +1,184 @@
---
id: "0172"
title: "App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes sobre el vault osint"
status: pendiente
type: app
domain:
- osint
- frontend
scope: app-scoped
priority: media
depends: []
blocks: []
related: ["0171"]
created: 2026-06-10
updated: 2026-06-10
tags: [osint, web, sigma, graph, mantine, obsidian, vault, dashboard]
---
# 0172 — App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes
## APP Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0172 |
| **Estado** | pendiente (solo plan — se construye cuando el vault tenga más datos) |
| **Prioridad** | media |
| **Tipo** | app — nueva app web en `projects/osint/apps/osint_web` |
| **Project** | osint (`projects/osint/`) |
## Contexto
El project `osint` guarda sus investigaciones en el vault de Obsidian
`/home/enmanuel/Obsidian/osint` (sub-repo `dataforge/osint`). Hoy ese vault tiene:
- **~82 nodos** repartidos en carpetas tipadas: `personas/` (45), `organizaciones/` (25),
`lugares/` (10), `dominios/` (1), `casos/` (1).
- **Datos tabulares** en el frontmatter YAML de cada ficha: `tipo`, `nombre`, `sexo`,
`fecha_nacimiento`, `dni`, `direccion`, `pais`, `aliases`, `tags`, etc.
- **Aristas implícitas**: los wikilinks `[[...]]` en las secciones `Relaciones`, `Lugares` y
`Documentos` conectan unas fichas con otras (y con sus attachments).
- **~240 attachments**: fotos, DNIs, certificados y PDFs en `attachments/<tipo>/<slug>/`,
embebidos en las notas con `![[...]]`.
Obsidian es bueno para *escribir* la investigación, pero malo para *explorarla* de un vistazo:
no da un grafo navegable de todos los objetivos, ni una tabla filtrable, ni una ficha-resumen
con la galería de imágenes de cada persona. Metabase/Grafana no encajan: leen BD SQL (no `.md`),
y no muestran ni grafo de nodos ni imágenes inline.
Decisión del usuario (10/06/2026): construir una **app web propia** que lea el vault y ofrezca
tres vistas — **grafo explorable con sigma.js**, **tablas filtradas por tipo**, y **fichas con
imágenes**. Este issue es **solo el plan**: la recopilación de datos en Obsidian continúa primero;
la app se implementa cuando haya suficiente material que justifique la inversión.
## Objetivo
Una app web local que, leyendo directamente los `.md` del vault `osint` (sin BD intermedia
obligatoria en v1), permita:
1. **Explorar el grafo** de nodos (personas, organizaciones, lugares, dominios, casos) y sus
conexiones por wikilinks, con sigma.js: zoom, pan, click en nodo → ficha, colores por tipo,
filtro de tipos visibles, búsqueda de nodo.
2. **Ver tablas filtradas por tipo**: una tabla por categoría (personas, organizaciones, ...)
con las columnas del frontmatter, ordenable y filtrable (por dni, lugar, fecha, tag).
3. **Abrir la ficha** de cualquier nodo: frontmatter renderizado + cuerpo Markdown + galería de
sus attachments (fotos, DNIs, PDFs) servidos por el backend.
## Arquitectura propuesta
```
projects/osint/apps/osint_web/ (sub-repo Gitea dataforge/osint_web)
app.md frontmatter de registro (framework: react-vite-mantine)
server/ backend Python (lee el vault, sirve JSON + attachments)
main.py FastAPI o stdlib http
frontend/ React + Vite + Mantine + sigma.js
src/
views/GraphView.tsx sigma.js + graphology
views/TablesView.tsx Mantine DataTable filtrable por tipo
views/NodeCard.tsx ficha + galería de attachments
```
### Backend (Python — máximo reuso del grupo `obsidian`)
Python porque el grupo de capacidad `obsidian` (11 funciones, dominio `obsidian`) ya cubre casi
todo el parseo del vault. **Registry-first**: el backend orquesta estas funciones, no reimplementa
el parseo.
Funciones del registry a reutilizar:
| Función | Uso en la app |
|---|---|
| `list_obsidian_notes_py_obsidian` | enumerar nodos por carpeta/tipo |
| `read_obsidian_note_py_obsidian` | leer ficha: `{frontmatter, body, wikilinks, tags}` |
| `parse_obsidian_frontmatter_py_obsidian` | datos tabulares de cada nodo |
| `extract_obsidian_wikilinks_py_obsidian` | aristas del grafo |
| `extract_obsidian_embeds_py_obsidian` | attachments embebidos en cada nota |
| `resolve_obsidian_embed_py_obsidian` | resolver `![[foto.jpg]]` → path real en disco para servir la imagen |
| `slugify_obsidian_name_py_obsidian` | normalizar nombre de wikilink → id de nodo |
| `search_obsidian_notes_py_obsidian` | búsqueda global en el grafo |
Funciones **nuevas** a delegar a `fn-constructor` (no escribir inline en la app):
- `build_obsidian_graph_py_obsidian` (impure) — dado `vault_dir`, devuelve
`{"nodes": [{id, tipo, label, frontmatter}], "edges": [{source, target, kind}]}`.
Resuelve cada wikilink a un nodo existente (vía slug / nombre de archivo); los wikilinks que
no resuelven a un `.md` del vault se marcan como aristas "dangling" o se descartan según flag.
Tag de grupo: `obsidian`. Es la pieza que el grupo declara como frontera no cubierta
("No indexa el grafo agregado") — esta función la cierra.
Endpoints HTTP (JSON salvo el de attachments):
| Método | Ruta | Devuelve |
|---|---|---|
| GET | `/api/graph` | grafo completo `{nodes, edges}` para sigma.js |
| GET | `/api/nodes?tipo=persona` | filas de la tabla de ese tipo (frontmatter aplanado) |
| GET | `/api/node/{slug}` | ficha: frontmatter + body (HTML/markdown) + lista de attachments |
| GET | `/api/attachment?path=...` | sirve el binario del attachment (image/pdf), con allowlist al vault |
| GET | `/api/search?q=...` | nodos que matchean |
Seguridad: el backend solo sirve archivos **dentro** del vault osint (path traversal bloqueado).
El vault contiene datos personales sensibles (DNIs) → la app escucha **solo en `127.0.0.1`**, sin
exponer a red. No es un service desplegable a VPS.
### Frontend (React + Vite + Mantine + sigma.js)
- Sistema del registry: React + Vite + Mantine v9 + `@fn_library` (grupo `mantine`, 63 funciones).
Componentes propios de `@fn_library` antes que HTML nativo (regla `frontend_theming.md`).
- **Grafo**: `sigma.js` + `graphology`. Color por `tipo`, tamaño por grado, layout
force-directed (graphology-layout-forceatlas2). Click en nodo → abre `NodeCard`. Panel lateral
con toggles de tipos visibles y caja de búsqueda.
- **Tablas**: una pestaña por tipo, Mantine `Table`/DataTable con columnas del frontmatter,
orden y filtro por columna (dni, lugar, fecha_nacimiento, tags).
- **Fichas**: `NodeCard` con frontmatter en formato clave-valor (fechas en formato europeo
DD/MM/AAAA — memoria `formato-fecha-europeo`), cuerpo Markdown, y galería de attachments
(imágenes con lightbox; PDFs como enlace/embed).
`sigma.js` y `graphology` son dependencias nuevas del frontend (no en `@fn_library`). KISS:
añadir solo esas dos; el resto (tabla, layout, modales) sale de Mantine/`@fn_library`.
## Decisiones abiertas
1. **¿BD intermedia o lectura directa del vault?** v1 lee el vault en cada arranque (cachea el
grafo en memoria). Si el vault crece mucho o se quiere histórico/diff, evaluar un
`operations.db` con `entities`/`relations` (encaja con el bucle reactivo). Recomendado:
empezar sin BD (KISS), añadirla solo si el rendimiento o un caso de uso lo exige.
2. **Backend FastAPI vs stdlib http**: FastAPI da validación y OpenAPI gratis; stdlib evita una
dependencia. Como el backend es fino (orquesta funciones del registry), decidir al construir.
3. **Live-reload del vault**: ¿re-escanear bajo demanda (botón "refrescar") o watcher de
filesystem? v1: botón refrescar (simple). Watcher si molesta.
4. **Aristas dangling**: wikilinks a notas que aún no existen — ¿mostrarlos como nodos fantasma
(útil para ver "objetivos pendientes de fichar") o esconderlos? Propuesta: nodo fantasma con
estilo atenuado, toggle para ocultar.
## Definition of Done
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: grafo carga el vault | e2e | `GET /api/graph` con el vault osint real | `nodes` ≥ nº de `.md`, `edges` con los wikilinks resueltos; sigma.js los pinta |
| Golden: ficha con imágenes | e2e | `GET /api/node/<persona con fotos>` + abrir NodeCard | frontmatter + cuerpo + galería con las imágenes de `attachments/personas/<slug>/` |
| Edge: tabla filtrada por tipo | e2e | `GET /api/nodes?tipo=organizacion` | solo nodos de ese tipo, columnas del frontmatter |
| Edge: wikilink dangling | unit | nota con `[[Persona-Inexistente]]` | arista marcada dangling / nodo fantasma, sin crash |
| Edge: nombre con mayúsculas/acentos | unit | wikilink `[[María del Mar]]` → slug | resuelve a `maria-del-mar-...md` vía `slugify_obsidian_name` |
| Error: path traversal en attachment | e2e | `GET /api/attachment?path=../../etc/passwd` | 403/404, jamás sirve fuera del vault |
| Error: vault inexistente | e2e | arrancar con `--vault /no/existe` | error claro al arrancar, no 500 silencioso |
| Cobertura | audit | `uses_functions` del `app.md` | declara todas las funciones del grupo `obsidian` consumidas |
Vida útil (cuando se construya): usar la app de verdad sobre el vault osint durante ≥7 días en
investigaciones reales; medir que el grafo sigue cargando sin romperse al crecer el vault.
## Notas
**Estado actual: solo plan.** No construir todavía — la recopilación de datos en Obsidian
continúa; cuando el vault tenga masa crítica de objetivos/relaciones, se arranca con
`/new-cpp-app` no aplica (es web): se hace `git init` del sub-repo `dataforge/osint_web` dentro de
`projects/osint/apps/osint_web/` antes de limpiar cualquier worktree (regla `apps_subrepo.md`),
scaffolding de frontend con el stack Mantine del registry, y backend Python orquestando el grupo
`obsidian`.
Onboarding (para cuando exista): arrancar backend `python server/main.py --vault
/home/enmanuel/Obsidian/osint --port 8470` y `pnpm dev` en `frontend/`; abrir
`http://127.0.0.1:5173`. Pestañas: Grafo / Tablas / (ficha al click). Solo localhost por los
datos sensibles del vault.
Relación con #0171 (manifest de sub-repos): cuando esta app exista será un hijo del project
`osint` y debe entrar en su `subrepos.yaml` para re-clonarse en otros PCs.
+4
View File
@@ -25,6 +25,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat | | [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp | | [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy | | [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
| [hoppscotch](hoppscotch.md) | 7 | Operar Hoppscotch SELF-HOSTED (docker en selfhost/) via API GraphQL: login (magic link headless via mailpit), CRUD de requests (create/update/delete/list), set_environment (idempotente, resuelve secretos pass:). El agente crea/edita y el humano lo ve en vivo en su GUI (subscriptions). build es helper interno de serializacion. Modo .json local ELIMINADO |
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions | | [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas | | [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) | | [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
@@ -49,6 +50,9 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL | | [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos | | [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use | | [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
| [obsidian](obsidian.md) | 14 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve. Sin app GUI |
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
## Como anadir grupo ## Como anadir grupo
+83
View File
@@ -0,0 +1,83 @@
# Capability group: `hoppscotch`
Operar una instancia **self-hosted de Hoppscotch** (consola de APIs, alternativa open-source a
Postman) desde el registry, vía su **API GraphQL**. El agente crea/edita requests, colecciones y
environments por la API; el humano los ve **en vivo** en su GUI (subscriptions = hot-reload real).
Las requests viven en la base de datos del self-host (Postgres), compartida entre el agente y la GUI.
Este es el **flujo canónico**. El antiguo modo "archivo `.json` local" (funciones
`parse_*` / `run_*` / `add_hoppscotch_request`) **fue eliminado**: escribía un `.json` en disco que
NO subía al workspace, así que el humano no lo veía en la GUI. No lo reintroduzcas.
## Stack self-host
Vive en `projects/web_scraping/hoppscotch/selfhost/` (docker compose: AIO + Postgres + mailpit).
| Servicio | URL | Para qué |
|---|---|---|
| App (cliente) | `http://localhost:3009` | la GUI donde el humano usa las colecciones (instalable como PWA) |
| Admin dashboard | `http://localhost:3100` | gestión (usuarios, config) |
| Backend GraphQL | `http://localhost:3170/graphql` | la API que usan las funciones |
| Mailpit | `http://localhost:8025` | captura el magic link del login (SMTP de pruebas, sin correo real) |
Levantar: `cd selfhost && docker compose up -d`. Team de trabajo: **"registry"**. Cuenta: `admin@example.com`.
## Funciones
| ID | Firma corta | Qué hace |
|---|---|---|
| `hoppscotch_login_py_infra` | `(email, *, backend_url, mailpit_url) -> {access_token,...}` | login por magic link headless (lee el link de mailpit) → JWT |
| `hoppscotch_create_request_py_infra` | `(collection_id, method, url, *, title, headers, body, body_type, team_id, access_token) -> dict` | crea una request en una colección de la team |
| `hoppscotch_update_request_py_infra` | `(request_id, method, url, *, title, headers, body, body_type, access_token) -> dict` | actualiza una request |
| `hoppscotch_delete_request_py_infra` | `(request_id, *, access_token) -> dict` | borra una request |
| `hoppscotch_list_requests_py_infra` | `(collection_id, *, access_token) -> {requests:[...]}` | lista las requests de una colección |
| `hoppscotch_set_environment_py_infra` | `(team_id, name, variables, *, access_token) -> dict` | crea/actualiza (idempotente) el environment de la team; resuelve secretos `pass:` |
| `build_hoppscotch_collection_py_infra` | `(calls, *, name, request_names) -> dict` | **helper interno** de create/update: serializa call specs al formato HoppRESTRequest. NO para escribir `.json` a mano |
| `pass_get_secret_py_infra` | `(path, *, line) -> {value}` | lee un secreto de `pass` (lo consume `set_environment` para no hardcodear keys) |
`access_token` se pasa como **cookie**, no header `Authorization`. Caduca a 24h → re-login con `hoppscotch_login`.
## Ejemplo canónico (end-to-end)
```python
import sys, os
sys.path.insert(0, os.path.join(os.path.expanduser("~/fn_registry"), "python", "functions"))
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_create_request import hoppscotch_create_request
from infra.hoppscotch_set_environment import hoppscotch_set_environment
TEAM = "cmq8kn0v500030xls1nvminjy" # team "registry"
COLL = "cmq8knppc00040xlskt4ist27" # colección registry_api (de hoppscotch_list/DB)
tok = hoppscotch_login("admin@example.com")["access_token"]
# 1. Variables del workspace (secreto resuelto desde pass, no hardcodeado)
hoppscotch_set_environment(TEAM, "registry", [
{"key": "baseURL", "value": "https://registry.organic-machine.com", "secret": False},
{"key": "api_key", "value": "pass:apis/registry", "secret": True}, # pass: -> pass_get_secret
], access_token=tok)
# 2. Crear una request → aparece EN VIVO en la GUI del humano (subscriptions)
hoppscotch_create_request(
COLL, "GET", "<<baseURL>>/api/status",
title="status", headers={"Accept": "application/json"},
team_id=TEAM, access_token=tok,
)
```
## Fronteras (qué NO cubre)
- **No es modo archivo**: no escribe colecciones `.json` locales como fuente. Las requests viven en el
Postgres del self-host. (Los `.json` en `collections/` son solo respaldo/semilla importable.)
- **No automatiza la GUI**: opera por la API; la GUI la mira el humano.
- **No gestiona usuarios/teams del dashboard**: eso es el admin dashboard (`:3100`).
- **No ejecuta los scripts pre/post-request JS** de Hoppscotch.
## Gotchas
- `access_token` como **cookie** (`cookies={"access_token": tok}`), no `Authorization`. 24h de vida.
- `createRequestInCollection` de esta instancia **exige `team_id`** en el input (no solo el collectionID).
- Variables `<<var>>` se resuelven con el environment de la team (subscriptions las propagan a la GUI).
- Secretos: usa `value="pass:<ruta>"` en `set_environment` → se resuelve de `pass`, nunca se hardcodea
ni se logea en crudo.
- El secreto viaja en claro al backend local por GraphQL — es local (`127.0.0.1`), aceptable.
+80
View File
@@ -0,0 +1,80 @@
# Capability: obsidian
CRUD headless de vaults y notas de Obsidian, tratadas como Markdown plano con frontmatter YAML y wikilinks `[[...]]`. NO depende de la app GUI de Obsidian ni de su URI scheme — manipula los archivos `.md` directamente en disco. Scriptable, rapido, con telemetria del registry.
Los vaults de Obsidian del usuario viven en `/home/enmanuel/Obsidian/` y estan enlazados como vaults del registry en el project `obsidian` (`projects/obsidian/vaults/`). Ver `projects/obsidian/project.md`.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `parse_obsidian_frontmatter_py_obsidian` | `parse_obsidian_frontmatter(content: str) -> {"frontmatter": dict, "body": str}` | **Pure.** Separa el frontmatter YAML (bloque `---` inicial) del cuerpo. Si no hay frontmatter valido devuelve `{}` + el contenido completo. |
| `extract_obsidian_wikilinks_py_obsidian` | `extract_obsidian_wikilinks(body: str) -> list` | **Pure.** Extrae los targets de los wikilinks `[[...]]` y embeds `![[...]]`. Normaliza `[[nota\|alias]]`, `[[nota#heading]]`, `[[nota#^block]]` -> `nota`. Dedup preservando orden. |
| `format_obsidian_note_py_obsidian` | `format_obsidian_note(frontmatter: dict, body: str) -> str` | **Pure.** Inversa de parse: serializa frontmatter (YAML entre `---`) + body a una nota `.md` completa. |
| `read_obsidian_note_py_obsidian` | `read_obsidian_note(path: str) -> dict` | Lee una nota: `{path, frontmatter, body, wikilinks, tags}`. Compone parse + extract. |
| `create_obsidian_note_py_obsidian` | `create_obsidian_note(vault_dir, rel_path, body="", frontmatter=None, overwrite=False) -> str` | Crea nota nueva (crea dirs padre, añade `.md`). Error si existe y `overwrite=False`. |
| `update_obsidian_note_py_obsidian` | `update_obsidian_note(path, body=None, set_frontmatter=None, append=None) -> str` | Edita nota existente: merge de frontmatter, reemplazo de body, o append al final. |
| `delete_obsidian_note_py_obsidian` | `delete_obsidian_note(path: str) -> bool` | Borra una nota (solo archivo, nunca directorio). Error si no existe. |
| `list_obsidian_notes_py_obsidian` | `list_obsidian_notes(vault_dir, subfolder="", tag="") -> list` | Lista paths de notas `.md` (recursivo). Excluye `.obsidian/` y `.trash/`. Filtro opcional por tag de frontmatter. |
| `search_obsidian_notes_py_obsidian` | `search_obsidian_notes(vault_dir, query, in_body=True, in_frontmatter=True) -> list` | Busca substring (case-insensitive) en las notas. Devuelve `[{path, matches:[{line, text}]}]`. |
| `list_obsidian_vaults_py_obsidian` | `list_obsidian_vaults(base_dir: str) -> list` | Lista los vaults (subdirs con `.obsidian/`) bajo `base_dir`. `[{name, path}]`. |
| `create_obsidian_vault_py_obsidian` | `create_obsidian_vault(parent_dir, name) -> str` | Crea un vault nuevo: carpeta + `.obsidian/app.json` minimo. Error si ya existe. |
## Ejemplo canonico
Componer varias funciones del grupo se hace por heredoc importando del registry (las funciones se importan, no se reescriben):
```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys
sys.path.insert(0, "python/functions")
from obsidian import (
list_obsidian_vaults, list_obsidian_notes, search_obsidian_notes,
create_obsidian_note, read_obsidian_note, update_obsidian_note, delete_obsidian_note,
)
# 1. Descubrir vaults del usuario
vaults = list_obsidian_vaults("/home/enmanuel/Obsidian")
print("vaults:", [v["name"] for v in vaults])
# 2. Listar y buscar notas en un vault
finanzas = "/home/enmanuel/Obsidian/Finanzas"
print("notas:", len(list_obsidian_notes(finanzas)))
print("hits:", [h["path"] for h in search_obsidian_notes(finanzas, "presupuesto")][:5])
# 3. CRUD de una nota (crear -> leer -> editar -> borrar)
p = create_obsidian_note(finanzas, "inbox/idea_x", body="Primera linea",
frontmatter={"tags": ["inbox"], "created": "2026-06-09"})
note = read_obsidian_note(p)
print("creada:", note["path"], note["frontmatter"], note["wikilinks"])
update_obsidian_note(p, set_frontmatter={"status": "done"}, append="Ver [[Otra Nota]]")
delete_obsidian_note(p)
PYEOF
```
Para una sola operacion con un id conocido, `fn run` tambien sirve:
```bash
./fn run list_obsidian_vaults /home/enmanuel/Obsidian
./fn run list_obsidian_notes /home/enmanuel/Obsidian/Finanzas
```
## Cuando usar el grupo
- Crear/editar/leer notas de cualquier vault de Obsidian desde un agente o script, sin abrir la app.
- Buscar o listar notas por contenido o tag (ingesta, migracion, reporting sobre el vault).
- Crear vaults nuevos o inventariar los existentes.
## Fronteras (que NO cubre)
- **No habla con la app GUI** (no usa el URI scheme `obsidian://`, no abre notas en la interfaz, no dispara plugins). Si la app esta abierta, escribir en disco puede chocar con sus locks/cache — cerrar la app o refrescar manualmente.
- **No resuelve wikilinks a paths** automaticamente (devuelve los targets crudos). Resolver `[[nota]]` -> archivo real es responsabilidad del caller (busqueda por nombre en el vault).
- **No renderiza Markdown** ni evalua Dataview/templating. Trata las notas como texto + frontmatter.
- **No indexa el grafo** de enlaces entre notas (solo extrae links por nota). Para grafo agregado, componer sobre `list_obsidian_notes` + `extract_obsidian_wikilinks`.
## Gotchas
- Vaults grandes son caros: `NotasDeObsidian` pesa ~554M. `list_obsidian_notes` / `search_obsidian_notes` recorren todo el arbol — filtra por `subfolder` cuando puedas.
- `delete_obsidian_note` borra de verdad (no manda a `.trash/`). Para acciones destructivas masivas, listar primero y confirmar.
- El frontmatter `tags` puede venir como lista o como CSV string; `read_obsidian_note` lo normaliza a lista.
+51
View File
@@ -0,0 +1,51 @@
# Capability: osint-enrich
Orquestadores de enriquecimiento OSINT: componen las funciones atómicas de
[osint-passive](osint-passive.md) para aumentar los datapoints de una entidad (persona u
organización) del vault `osint` a partir de fuentes públicas. No tocan al objetivo de forma
intrusiva. Mismo encuadre dual-use que `osint-passive`: solo investigación autorizada.
## Funciones
| ID | Firma | Qué hace |
|---|---|---|
| `scan_ficha_attachments_metadata_py_cybersecurity` | `scan_ficha_attachments_metadata(attachments_dir) -> dict` | Escanea los attachments de una ficha (imágenes + PDFs), extrae EXIF/PDF metadata y agrega GPS y fechas. |
| `enrich_person_passive_py_cybersecurity` | `enrich_person_passive(nombre, apellidos, dominios=None, usernames=None) -> dict` | Candidatos para una persona: emails (guess), username hits, dorks. No verifica ni ejecuta. |
| `enrich_org_passive_py_cybersecurity` | `enrich_org_passive(dominio) -> dict` | Perfil pasivo de una org: whois + dns + subdominios. Resiliente a fallo parcial (campo `errors`). |
## Ejemplo canónico
```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys; sys.path.insert(0, "python/functions")
from cybersecurity import (scan_ficha_attachments_metadata,
enrich_person_passive, enrich_org_passive)
# 1. Metadatos de los documentos ya guardados de una persona (datos propios)
m = scan_ficha_attachments_metadata(
"/home/enmanuel/Obsidian/osint/attachments/personas/enmanuel-gutierrez-perez")
print(m["summary"]) # {n_files, n_images, n_pdfs, n_gps_points, n_dates, errors}
# 2. Candidatos de enriquecimiento de una persona (no toca al objetivo)
p = enrich_person_passive("Enmanuel", "Gutierrez Perez",
dominios=["gmail.com"], usernames=["enmanuelgp"])
print(p["email_candidates"][:5], len(p["dorks"]))
# 3. Perfil pasivo de una organización por su dominio
o = enrich_org_passive("organic-machine.com")
print(o["whois"].get("registrar"), o["dns"].get("A"), len(o["subdomains"]), o["errors"])
PYEOF
```
## Fronteras
- Compone solo funciones `osint-passive`. Para activa (port scan, fingerprint) haría falta
`osint-active` (no construido).
- Devuelve candidatos/datos crudos; **decidir qué escribir en la ficha** (y verificar) es del
caller. Encaja con el reporte de `projects/osint/tools/person_datapoints.py`.
## Gotchas
- `enrich_org_passive` nunca peta por una fuente lenta (crt.sh): el fallo va a `errors`.
- `enrich_person_passive` puede tardar por `enumerate_username_sites` (un request por sitio).
+64
View File
@@ -0,0 +1,64 @@
# Capability: osint-passive
Recolección OSINT **pasiva**: obtener información sin interactuar de forma intrusiva con el
objetivo, usando solo fuentes públicas (DNS público, RDAP, Certificate Transparency, metadatos
de documentos propios, servicios de perfil públicos). Pensado para investigación autorizada,
due diligence, pentest con permiso y enriquecimiento de las fichas del vault `osint`.
**Encuadre:** dual-use. Úsese solo contra objetivos propios o con autorización. Las funciones
que tocan servicios públicos (`enumerate_username_sites`, `enum_subdomains_crtsh`) dejan una
huella mínima (un request a cada servicio); respeta sus rate limits.
## Funciones
| ID | Firma | Qué hace |
|---|---|---|
| `extract_exif_metadata_py_cybersecurity` | `extract_exif_metadata(image_path) -> dict` | EXIF de una imagen (fecha, cámara, software, GPS decimal) vía Pillow. |
| `extract_pdf_metadata_py_cybersecurity` | `extract_pdf_metadata(pdf_path) -> dict` | Document Info de un PDF (autor, fechas, software, páginas) vía pypdf. |
| `guess_email_formats_py_cybersecurity` | `guess_email_formats(nombre, apellidos, dominio) -> list` | **Pure.** Candidatos de email comunes a partir de nombre + dominio. |
| `enumerate_username_sites_py_cybersecurity` | `enumerate_username_sites(username, ...) -> list` | ¿Existe un username en ~12 redes públicas? (sherlock ligero, por código HTTP). |
| `build_search_dorks_py_cybersecurity` | `build_search_dorks(target, tipo, ...) -> list` | **Pure.** Genera dorks de motor de búsqueda (persona/email/dominio/usuario). |
| `dns_records_py_cybersecurity` | `dns_records(dominio, types=None) -> dict` | Registros DNS (A/AAAA/MX/TXT/NS/CNAME) vía `dig`. |
| `whois_lookup_py_cybersecurity` | `whois_lookup(dominio, ...) -> dict` | Datos de registro vía RDAP (WHOIS moderno HTTP/JSON, sin CLI). |
| `enum_subdomains_crtsh_py_cybersecurity` | `enum_subdomains_crtsh(dominio, ...) -> list` | Subdominios desde Certificate Transparency (crt.sh). |
Orquestadores (grupo [osint-enrich](osint-enrich.md)): `scan_ficha_attachments_metadata`,
`enrich_person_passive`, `enrich_org_passive`.
## Ejemplo canónico
```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys; sys.path.insert(0, "python/functions")
from cybersecurity import (dns_records, whois_lookup, enum_subdomains_crtsh,
guess_email_formats, build_search_dorks, extract_exif_metadata)
# Dominio (org)
print(whois_lookup("organic-machine.com")["registrar"]) # OVH sas
print(dns_records("organic-machine.com")["A"]) # ['135.125.201.30']
print(enum_subdomains_crtsh("organic-machine.com")[:5])
# Persona
print(guess_email_formats("Enmanuel", "Gutierrez Perez", "gmail.com")[:5])
print(build_search_dorks("Enmanuel Gutierrez Perez", "persona")[:3])
# Metadatos de un documento propio
print(extract_exif_metadata("/home/enmanuel/Obsidian/osint/attachments/personas/enmanuel-gutierrez-perez/dni-1.jpg"))
PYEOF
```
## Fronteras (qué NO es)
- **No es recolección activa**: no hace port scan, dns brute, ni sondea la infra del objetivo.
Eso sería el grupo `osint-active` (no construido todavía).
- **No verifica** los candidatos: `guess_email_formats` propone, no confirma que el email exista.
- **No ejecuta** los dorks: `build_search_dorks` los genera; ejecutarlos es otro paso (browser).
- **No incluye breach/leak lookup** (HIBP requiere API key de pago) — pendiente.
## Gotchas
- `crt.sh` va lento / rate-limitado y a veces responde 404; los orquestadores lo capturan en
`errors` y siguen.
- `enumerate_username_sites` da falsos positivos/negativos por anti-bot de algunos sitios.
- El GPS de EXIF revela ubicación — dato sensible; trátese como PII.
+347
View File
@@ -0,0 +1,347 @@
package infra
import (
"database/sql"
"fmt"
"os/exec"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
_ "github.com/mattn/go-sqlite3"
)
// ProjectCoverage reports how well a project registered in registry.db is
// backed by a Gitea sub-repo and how many of its children (apps + analyses)
// are actually cloned on disk. It is the engine of `fn doctor projects`.
//
// The audit only touches the local filesystem and registry.db: it never hits
// the network nor the Gitea API, so it runs fast and requires no token.
type ProjectCoverage struct {
ProjectID string `json:"project_id"`
DirPath string `json:"dir_path"`
HasGit bool `json:"has_git"` // <root>/<dir_path>/.git exists as a directory
HasRemote bool `json:"has_remote"` // git -C <dir> remote get-url origin returned a non-empty url
RepoURLDeclared bool `json:"repo_url_declared"` // projects.repo_url != ""
ChildrenInDB int `json:"children_in_db"` // apps + analyses with project_id = ProjectID
ChildrenCloned int `json:"children_cloned"` // of those, how many have a .git on disk
ChildrenMissing int `json:"children_missing"` // ChildrenInDB - ChildrenCloned (at risk when re-cloning)
Issues []string `json:"issues"` // e.g. "dir_not_found", "no_gitea_repo", "children_missing"
}
// AuditProjectsCoverage walks every row in the projects table and reports, per
// project, whether it has a local git repo, whether that repo declares a remote
// origin, whether its repo_url is filled in registry.db, and how many of its
// children (apps + analyses) are cloned versus only known to the database.
//
// registryRoot is the repository root (the directory that holds registry.db).
// All relative dir_path values are resolved against it.
//
// Returns an error only if registry.db cannot be opened or queried. Projects
// whose directory is missing on disk are still reported, flagged with the
// "dir_not_found" issue, so the caller can surface them rather than silently
// dropping them.
func AuditProjectsCoverage(registryRoot string) ([]ProjectCoverage, error) {
dbPath := filepath.Join(registryRoot, "registry.db")
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("audit_projects_coverage: open db: %w", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("audit_projects_coverage: ping db: %w", err)
}
rows, err := db.Query(`SELECT id, COALESCE(dir_path,''), COALESCE(repo_url,'') FROM projects ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("audit_projects_coverage: query projects: %w", err)
}
defer rows.Close()
var out []ProjectCoverage
for rows.Next() {
var pc ProjectCoverage
var dirPath, repoURL string
if err := rows.Scan(&pc.ProjectID, &dirPath, &repoURL); err != nil {
return nil, fmt.Errorf("audit_projects_coverage: scan project: %w", err)
}
// DirPath: use projects.dir_path; if empty, derive projects/<id>.
if dirPath == "" {
dirPath = filepath.Join("projects", pc.ProjectID)
}
pc.DirPath = dirPath
pc.RepoURLDeclared = repoURL != ""
absDir := dirPath
if !filepath.IsAbs(absDir) {
absDir = filepath.Join(registryRoot, dirPath)
}
dirFound := dirExists(absDir)
if !dirFound {
pc.Issues = append(pc.Issues, "dir_not_found")
} else {
pc.HasGit = dirExists(filepath.Join(absDir, ".git"))
if pc.HasGit {
pc.HasRemote = gitHasRemoteOrigin(absDir)
}
}
// Children: apps + analyses with this project_id.
children, err := projectChildren(db, pc.ProjectID)
if err != nil {
return nil, err
}
pc.ChildrenInDB = len(children)
for _, childDir := range children {
absChild := childDir
if !filepath.IsAbs(absChild) {
absChild = filepath.Join(registryRoot, childDir)
}
if dirExists(filepath.Join(absChild, ".git")) {
pc.ChildrenCloned++
}
}
pc.ChildrenMissing = pc.ChildrenInDB - pc.ChildrenCloned
// Issues derived from the gathered state.
if !pc.HasRemote && !pc.RepoURLDeclared {
pc.Issues = append(pc.Issues, "no_gitea_repo")
}
if pc.ChildrenMissing > 0 {
pc.Issues = append(pc.Issues, "children_missing")
}
out = append(out, pc)
}
return out, rows.Err()
}
// projectChildren returns the dir_path of every app and analysis whose
// project_id matches the given project id.
func projectChildren(db *sql.DB, projectID string) ([]string, error) {
var dirs []string
for _, table := range []string{"apps", "analysis"} {
q := fmt.Sprintf(`SELECT COALESCE(dir_path,'') FROM %s WHERE project_id = ?`, table)
rows, err := db.Query(q, projectID)
if err != nil {
return nil, fmt.Errorf("audit_projects_coverage: query %s children: %w", table, err)
}
for rows.Next() {
var dp string
if err := rows.Scan(&dp); err != nil {
rows.Close()
return nil, fmt.Errorf("audit_projects_coverage: scan %s child: %w", table, err)
}
if dp != "" {
dirs = append(dirs, dp)
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
}
return dirs, nil
}
// gitHasRemoteOrigin reports whether the git repo at dir declares an origin
// remote with a non-empty URL. It shells out to `git -C <dir> remote get-url
// origin` and treats exit 0 with non-empty output as success. Any error
// (no origin, not a repo, git missing) is reported as false.
func gitHasRemoteOrigin(dir string) bool {
cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin")
outBytes, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(outBytes)) != ""
}
// FormatProjectsCoverage renders a tabwriter table, one row per project, with
// git / remote / repo_url presence, children cloned vs declared, and the
// issues list. When no project has coverage problems it makes that explicit.
func FormatProjectsCoverage(rows []ProjectCoverage) string {
var sb strings.Builder
w := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "PROJECT\tGIT\tREMOTE\tREPO_URL\tCHILDREN\tISSUES")
withIssues := 0
for _, r := range rows {
issues := "-"
if len(r.Issues) > 0 {
issues = strings.Join(r.Issues, "; ")
withIssues++
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d/%d\t%s\n",
r.ProjectID,
checkMark(r.HasGit),
checkMark(r.HasRemote),
checkMark(r.RepoURLDeclared),
r.ChildrenCloned, r.ChildrenInDB,
issues,
)
}
w.Flush()
if len(rows) == 0 {
sb.WriteString("\nNo projects registered.\n")
return sb.String()
}
if withIssues == 0 {
sb.WriteString("\n0 projects con problemas de cobertura.\n")
} else {
fmt.Fprintf(&sb, "\n%d/%d projects con problemas de cobertura.\n", withIssues, len(rows))
}
return sb.String()
}
// checkMark returns ✓ for true, ✗ for false.
func checkMark(b bool) string {
if b {
return "✓"
}
return "✗"
}
// OrphanProjectRef describes a project_id that one or more children (apps and
// analyses) reference in registry.db but for which no row exists in the projects
// table. This is the inverse drift of AuditProjectsCoverage: instead of a
// registered project whose children are not cloned, it surfaces an umbrella
// project that was never synced to this PC (or never created here at all), so
// the children are pointing at a project that this registry does not know about.
// That is a data-loss risk: the umbrella project may exist on another machine
// and the link would be silently broken on a clone/sync into this one.
type OrphanProjectRef struct {
ProjectID string `json:"project_id"` // referenced by children but missing from projects
Apps []string `json:"apps"` // ids of apps that reference it (sorted)
Analyses []string `json:"analyses"` // ids of analyses that reference it (sorted)
}
// FindOrphanProjectRefs scans every app and analysis that declares a non-empty
// project_id and reports those project_id values that have no matching row in
// the projects table. Each orphan groups the ids of the apps and analyses that
// reference it.
//
// registryRoot is the repository root (the directory that holds registry.db).
//
// The result is sorted by ProjectID, and within each entry the Apps and
// Analyses lists are sorted alphabetically. When every child references a known
// project, an empty (non-nil) slice is returned with a nil error.
//
// Like AuditProjectsCoverage it only reads registry.db (opened read-only) and
// never touches the network nor the Gitea API. Returns an error only when
// registry.db cannot be opened or queried.
func FindOrphanProjectRefs(registryRoot string) ([]OrphanProjectRef, error) {
dbPath := filepath.Join(registryRoot, "registry.db")
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("find_orphan_project_refs: open db: %w", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("find_orphan_project_refs: ping db: %w", err)
}
// Set of known project ids.
known := map[string]bool{}
prows, err := db.Query(`SELECT id FROM projects`)
if err != nil {
return nil, fmt.Errorf("find_orphan_project_refs: query projects: %w", err)
}
for prows.Next() {
var id string
if err := prows.Scan(&id); err != nil {
prows.Close()
return nil, fmt.Errorf("find_orphan_project_refs: scan project: %w", err)
}
known[id] = true
}
if err := prows.Err(); err != nil {
prows.Close()
return nil, err
}
prows.Close()
// Accumulate orphan references from apps and analyses.
orphans := map[string]*OrphanProjectRef{}
get := func(pid string) *OrphanProjectRef {
o, ok := orphans[pid]
if !ok {
o = &OrphanProjectRef{ProjectID: pid}
orphans[pid] = o
}
return o
}
for _, table := range []string{"apps", "analysis"} {
q := fmt.Sprintf(`SELECT id, project_id FROM %s WHERE project_id != ''`, table)
rows, err := db.Query(q)
if err != nil {
return nil, fmt.Errorf("find_orphan_project_refs: query %s: %w", table, err)
}
for rows.Next() {
var id, pid string
if err := rows.Scan(&id, &pid); err != nil {
rows.Close()
return nil, fmt.Errorf("find_orphan_project_refs: scan %s row: %w", table, err)
}
if known[pid] {
continue
}
o := get(pid)
if table == "apps" {
o.Apps = append(o.Apps, id)
} else {
o.Analyses = append(o.Analyses, id)
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
}
out := make([]OrphanProjectRef, 0, len(orphans))
for _, o := range orphans {
sort.Strings(o.Apps)
sort.Strings(o.Analyses)
out = append(out, *o)
}
sort.Slice(out, func(i, j int) bool { return out[i].ProjectID < out[j].ProjectID })
return out, nil
}
// FormatOrphanProjectRefs renders a human-readable report of orphan project_id
// references, one block per orphan, listing how many apps and analyses point at
// the missing project plus their ids. When there are no orphans it makes that
// explicit on a single line.
func FormatOrphanProjectRefs(rows []OrphanProjectRef) string {
if len(rows) == 0 {
return "0 project_id huérfanos (todos los hijos tienen project declarado)\n"
}
var sb strings.Builder
w := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "PROJECT_ID\tAPPS\tANALYSES\tREFERENCED_BY")
for _, r := range rows {
refs := append(append([]string{}, r.Apps...), r.Analyses...)
refStr := strings.Join(refs, ", ")
if refStr == "" {
refStr = "-"
}
fmt.Fprintf(w, "%s\t%d\t%d\t%s\n",
r.ProjectID, len(r.Apps), len(r.Analyses), refStr)
}
w.Flush()
fmt.Fprintf(&sb, "\n%d project_id huérfanos (referenciados por hijos pero sin fila en projects).\n", len(rows))
return sb.String()
}
+109
View File
@@ -0,0 +1,109 @@
---
name: audit_projects_coverage
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func AuditProjectsCoverage(registryRoot string) ([]ProjectCoverage, error)"
description: "Audita la cobertura de los projects del registry frente a sus sub-repos Gitea: comprueba si cada project tiene .git local, remote origin y repo_url declarado, y cuantos de sus hijos (apps + analyses) estan clonados en disco versus solo conocidos por la BD. Motor del subcomando fn doctor projects. Solo lee registry.db + filesystem + git local, nunca la red ni la API de Gitea. Incluye FindOrphanProjectRefs, el check inverso: detecta apps o analyses que declaran un project_id sin fila en la tabla projects (project paraguas huerfano, riesgo de perdida al sincronizar)."
tags: [projects, gitea, subrepo, audit, infra, fn-doctor, doctor]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["database/sql", "os/exec", "path/filepath", "strings", "text/tabwriter", "github.com/mattn/go-sqlite3"]
tested: true
tests: ["healthy project con un hijo sin clonar marca children_missing", "project sin repo_url ni remote marca no_gitea_repo", "project sin directorio en disco marca dir_not_found", "error si registry.db no existe", "repo con origin devuelve true", "repo sin origin devuelve false", "sin issues lo deja claro", "con issues cuenta los afectados", "app con project_id huerfano lo detecta y agrupa ordenado", "app con project_id valido no aparece", "sin huerfanos devuelve slice vacio sin error", "sin huerfanos lo deja claro", "con huerfanos lista ids y cuenta"]
test_file_path: "functions/infra/audit_projects_coverage_test.go"
file_path: "functions/infra/audit_projects_coverage.go"
params:
- name: registryRoot
desc: "Raiz del repositorio (el directorio que contiene registry.db). Los dir_path relativos de projects, apps y analysis se resuelven contra esta raiz."
output: "Slice de ProjectCoverage, una entrada por fila de la tabla projects, con flags de git/remote/repo_url, conteos de hijos clonados vs declarados, y la lista de issues detectados. La funcion de formato FormatProjectsCoverage produce una tabla de texto humano."
---
## Ejemplo
```go
package main
import (
"fmt"
"fn-registry/functions/infra"
)
func main() {
rows, err := infra.AuditProjectsCoverage("/home/enmanuel/fn_registry")
if err != nil {
panic(err)
}
fmt.Print(infra.FormatProjectsCoverage(rows))
}
```
Salida típica:
```
PROJECT GIT REMOTE REPO_URL CHILDREN ISSUES
fleet_monitoring ✓ ✓ ✓ 2/2 -
fn_monitoring ✓ ✓ ✓ 3/3 -
message_bus ✓ ✓ ✓ 3/4 children_missing
web_scraping ✗ ✗ ✗ 0/3 no_gitea_repo; children_missing
1/4 projects con problemas de cobertura.
```
## Check inverso: FindOrphanProjectRefs
Mientras `AuditProjectsCoverage` parte de la tabla `projects` y mira hacia abajo (¿están sus hijos clonados?), `FindOrphanProjectRefs` recorre el grafo en sentido contrario: parte de las apps y analyses y mira hacia arriba (¿existe el project paraguas que declaran?). Detecta el drift inverso, apps o analyses cuyo `project_id` no tiene ninguna fila en la tabla `projects`. Es un project huérfano: existe en otro PC y nunca se sincronizó a este, o nunca se creó aquí. Es un riesgo de pérdida silenciosa, porque el enlace del hijo apunta a un project que este registro no conoce.
```go
package main
import (
"fmt"
"fn-registry/functions/infra"
)
func main() {
orphans, err := infra.FindOrphanProjectRefs("/home/enmanuel/fn_registry")
if err != nil {
panic(err)
}
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
}
```
La firma es `func FindOrphanProjectRefs(registryRoot string) ([]OrphanProjectRef, error)`. Cada `OrphanProjectRef` agrupa, por `ProjectID` huérfano, los ids de las apps (`Apps`) y analyses (`Analyses`) que lo referencian, ambas listas ordenadas alfabéticamente y el slice resultante ordenado por `ProjectID`. Cuando todos los hijos apuntan a un project conocido devuelve un slice vacío (no nil) sin error. `FormatOrphanProjectRefs` produce una tabla de texto humano con el `project_id`, cuántas apps y analyses lo referencian y sus ids; si no hay huérfanos imprime una sola línea dejándolo claro.
Caso real detectado en este registro: apps con `project_id` ∈ {`element_agents`, `imagegen`, `osint_graph`} sin fila correspondiente en `projects`.
Salida típica con huérfanos:
```
PROJECT_ID APPS ANALYSES REFERENCED_BY
element_agents 1 0 shell_agent
imagegen 1 0 imagegen_ui
osint_graph 2 1 graph_explorer, scraper, gliner_glirel_tuning
3 project_id huérfanos (referenciados por hijos pero sin fila en projects).
```
## Cuando usarla
Úsala antes de un `/full-git-pull` masivo o tras clonar el registry en un PC nuevo para saber qué projects están realmente respaldados por su sub-repo Gitea y cuántos de sus hijos (apps y analyses) quedarían sin clonar. También como motor del futuro subcomando `fn doctor projects`: el caller la enchufa desde `cmd/fn/doctor.go` igual que `AuditUsesFunctions` o `AuditServicesSpec`, formatea con `FormatProjectsCoverage` para texto humano y serializa el slice directamente para `--json`.
## Gotchas
- Es **impura**: lee `registry.db` (abierto en modo read-only `?mode=ro`), recorre el filesystem y ejecuta `git -C <dir> remote get-url origin`. No toca la red ni la API de Gitea, así que no necesita token y es rápida.
- `HasRemote` solo se evalúa cuando el project tiene `.git` local; si no hay `.git`, queda `false` sin intentar el comando git.
- `gitHasRemoteOrigin` devuelve `false` ante cualquier error (no hay remote `origin`, no es un repo, git no instalado). No distingue "sin origin" de "git ausente"; si necesitas esa distinción, comprueba `git` por separado.
- El issue `no_gitea_repo` se emite solo cuando faltan **ambos** indicadores (`!HasRemote && !RepoURLDeclared`). Un project con `repo_url` declarado pero sin clonar (`dir_not_found`) NO se marca `no_gitea_repo` — el repo existe en Gitea, simplemente no está en este disco.
- `ChildrenMissing` cuenta los hijos (apps + analyses con ese `project_id`) cuya carpeta no tiene `.git` en disco: son los que se perderían o habría que reclonar. Cero hijos en la BD produce `0/0` y no genera issue.
- Si `projects.dir_path` está vacío, se deriva `projects/<id>`. Los `dir_path` ya absolutos se respetan tal cual.
- Devuelve error únicamente si `registry.db` no puede abrirse o consultarse. Los projects cuyo directorio no existe SÍ aparecen en el resultado, marcados con `dir_not_found`, para que el caller los muestre en vez de descartarlos en silencio.
- `FindOrphanProjectRefs` también es **impura**: lee `registry.db` en modo read-only (`?mode=ro`), pero no toca el filesystem ni git, solo cruza las tablas `projects`, `apps` y `analysis`. Ignora los hijos con `project_id` vacío (no son huérfanos, simplemente no pertenecen a ningún project). Devuelve un slice vacío no-nil cuando no hay huérfanos, así que el caller puede distinguir "sin huérfanos" (slice vacío, error nil) de "fallo al leer la BD" (error no nil).
@@ -0,0 +1,417 @@
package infra
import (
"database/sql"
"os"
"os/exec"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// seedProjectsRegistry builds a temp registry.db with the columns
// AuditProjectsCoverage reads, plus a handful of on-disk directories that
// model the cloned/missing states.
func seedProjectsRegistry(t *testing.T) string {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open temp db: %v", err)
}
defer db.Close()
_, err = db.Exec(`
CREATE TABLE projects (
id TEXT PRIMARY KEY,
dir_path TEXT NOT NULL DEFAULT '',
repo_url TEXT NOT NULL DEFAULT ''
);
CREATE TABLE apps (
id TEXT PRIMARY KEY,
dir_path TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT ''
);
CREATE TABLE analysis (
id TEXT PRIMARY KEY,
dir_path TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT ''
);
`)
if err != nil {
t.Fatalf("create schema: %v", err)
}
_, err = db.Exec(`
INSERT INTO projects VALUES
('healthy', 'projects/healthy', 'https://gitea.example/dataforge/healthy'),
('no_repo', 'projects/no_repo', ''),
('missing', 'projects/missing', 'https://gitea.example/dataforge/missing');
INSERT INTO apps VALUES
('app_cloned', 'projects/healthy/apps/app_cloned', 'healthy'),
('app_orphan', 'projects/healthy/apps/app_orphan', 'healthy');
INSERT INTO analysis VALUES
('an_cloned', 'projects/healthy/analysis/an_cloned', 'healthy');
`)
if err != nil {
t.Fatalf("seed data: %v", err)
}
// healthy: directory + .git present; one child cloned, one missing.
mkGitDir(t, dir, "projects/healthy")
mkGitDir(t, dir, "projects/healthy/apps/app_cloned")
mkGitDir(t, dir, "projects/healthy/analysis/an_cloned")
// app_orphan has no .git on disk → counts as missing.
// no_repo: directory exists, no .git.
if err := os.MkdirAll(filepath.Join(dir, "projects/no_repo"), 0o755); err != nil {
t.Fatalf("mkdir no_repo: %v", err)
}
// missing: no directory at all on disk.
return dir
}
// mkGitDir creates <root>/<rel>/.git so dirExists treats it as a git repo.
func mkGitDir(t *testing.T, root, rel string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, rel, ".git"), 0o755); err != nil {
t.Fatalf("mkdir %s/.git: %v", rel, err)
}
}
func findCoverage(rows []ProjectCoverage, id string) *ProjectCoverage {
for i := range rows {
if rows[i].ProjectID == id {
return &rows[i]
}
}
return nil
}
func hasIssue(pc *ProjectCoverage, issue string) bool {
for _, i := range pc.Issues {
if i == issue {
return true
}
}
return false
}
func TestAuditProjectsCoverage(t *testing.T) {
t.Run("healthy project con un hijo sin clonar marca children_missing", func(t *testing.T) {
dir := seedProjectsRegistry(t)
rows, err := AuditProjectsCoverage(dir)
if err != nil {
t.Fatalf("AuditProjectsCoverage error: %v", err)
}
pc := findCoverage(rows, "healthy")
if pc == nil {
t.Fatal("project 'healthy' missing from results")
}
if !pc.HasGit {
t.Error("expected HasGit=true for healthy")
}
if !pc.RepoURLDeclared {
t.Error("expected RepoURLDeclared=true for healthy")
}
if pc.ChildrenInDB != 3 {
t.Errorf("expected 3 children in db, got %d", pc.ChildrenInDB)
}
if pc.ChildrenCloned != 2 {
t.Errorf("expected 2 cloned children, got %d", pc.ChildrenCloned)
}
if pc.ChildrenMissing != 1 {
t.Errorf("expected 1 missing child, got %d", pc.ChildrenMissing)
}
if !hasIssue(pc, "children_missing") {
t.Errorf("expected children_missing issue, got %v", pc.Issues)
}
})
t.Run("project sin repo_url ni remote marca no_gitea_repo", func(t *testing.T) {
dir := seedProjectsRegistry(t)
rows, err := AuditProjectsCoverage(dir)
if err != nil {
t.Fatalf("error: %v", err)
}
pc := findCoverage(rows, "no_repo")
if pc == nil {
t.Fatal("project 'no_repo' missing")
}
if pc.HasGit {
t.Error("expected HasGit=false for no_repo")
}
if pc.RepoURLDeclared {
t.Error("expected RepoURLDeclared=false for no_repo")
}
if !hasIssue(pc, "no_gitea_repo") {
t.Errorf("expected no_gitea_repo issue, got %v", pc.Issues)
}
})
t.Run("project sin directorio en disco marca dir_not_found", func(t *testing.T) {
dir := seedProjectsRegistry(t)
rows, err := AuditProjectsCoverage(dir)
if err != nil {
t.Fatalf("error: %v", err)
}
pc := findCoverage(rows, "missing")
if pc == nil {
t.Fatal("project 'missing' missing")
}
if !hasIssue(pc, "dir_not_found") {
t.Errorf("expected dir_not_found issue, got %v", pc.Issues)
}
if pc.HasGit {
t.Error("expected HasGit=false when dir not found")
}
})
t.Run("error si registry.db no existe", func(t *testing.T) {
dir := t.TempDir()
if _, err := AuditProjectsCoverage(dir); err == nil {
t.Error("expected error for missing db, got nil")
}
})
}
func TestGitHasRemoteOrigin(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
t.Run("repo con origin devuelve true", func(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
runGit(t, dir, "remote", "add", "origin", "https://example.com/x.git")
if !gitHasRemoteOrigin(dir) {
t.Error("expected true for repo with origin")
}
})
t.Run("repo sin origin devuelve false", func(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if gitHasRemoteOrigin(dir) {
t.Error("expected false for repo without origin")
}
})
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// seedOrphanRefsRegistry builds a temp registry.db where some children declare
// a project_id with no matching row in the projects table (orphan refs) while
// others reference a valid project. It returns the registry root directory.
func seedOrphanRefsRegistry(t *testing.T, withOrphans bool) string {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open temp db: %v", err)
}
defer db.Close()
_, err = db.Exec(`
CREATE TABLE projects (
id TEXT PRIMARY KEY,
dir_path TEXT NOT NULL DEFAULT '',
repo_url TEXT NOT NULL DEFAULT ''
);
CREATE TABLE apps (
id TEXT PRIMARY KEY,
dir_path TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT ''
);
CREATE TABLE analysis (
id TEXT PRIMARY KEY,
dir_path TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT ''
);
`)
if err != nil {
t.Fatalf("create schema: %v", err)
}
// One known project exists in the projects table.
if _, err := db.Exec(`INSERT INTO projects VALUES ('known', 'projects/known', '')`); err != nil {
t.Fatalf("seed projects: %v", err)
}
// app_valid + an_valid reference the known project: never orphans.
if _, err := db.Exec(`
INSERT INTO apps VALUES ('app_valid', 'projects/known/apps/app_valid', 'known');
INSERT INTO analysis VALUES ('an_valid', 'projects/known/analysis/an_valid', 'known');
`); err != nil {
t.Fatalf("seed valid children: %v", err)
}
// app_no_project has an empty project_id: must be ignored.
if _, err := db.Exec(`INSERT INTO apps VALUES ('app_no_project', 'apps/app_no_project', '')`); err != nil {
t.Fatalf("seed no-project app: %v", err)
}
if withOrphans {
// Two apps + one analysis pointing at 'ghost' (missing from projects),
// plus one app pointing at 'specter' (also missing). Insert the apps for
// 'ghost' out of alphabetical order to exercise sorting.
if _, err := db.Exec(`
INSERT INTO apps VALUES ('app_zeta', 'apps/app_zeta', 'ghost');
INSERT INTO apps VALUES ('app_alpha', 'apps/app_alpha', 'ghost');
INSERT INTO analysis VALUES ('an_ghost', 'analysis/an_ghost', 'ghost');
INSERT INTO apps VALUES ('app_specter', 'apps/app_specter', 'specter');
`); err != nil {
t.Fatalf("seed orphan children: %v", err)
}
}
return dir
}
func findOrphan(rows []OrphanProjectRef, id string) *OrphanProjectRef {
for i := range rows {
if rows[i].ProjectID == id {
return &rows[i]
}
}
return nil
}
func TestFindOrphanProjectRefs(t *testing.T) {
t.Run("app con project_id huerfano lo detecta y agrupa ordenado", func(t *testing.T) {
dir := seedOrphanRefsRegistry(t, true)
rows, err := FindOrphanProjectRefs(dir)
if err != nil {
t.Fatalf("FindOrphanProjectRefs error: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 orphan refs, got %d: %+v", len(rows), rows)
}
// Sorted by ProjectID: ghost before specter.
if rows[0].ProjectID != "ghost" || rows[1].ProjectID != "specter" {
t.Fatalf("expected [ghost specter], got [%s %s]", rows[0].ProjectID, rows[1].ProjectID)
}
ghost := findOrphan(rows, "ghost")
if ghost == nil {
t.Fatal("orphan 'ghost' missing")
}
wantApps := []string{"app_alpha", "app_zeta"} // alphabetical
if len(ghost.Apps) != 2 || ghost.Apps[0] != wantApps[0] || ghost.Apps[1] != wantApps[1] {
t.Errorf("expected ghost.Apps=%v, got %v", wantApps, ghost.Apps)
}
if len(ghost.Analyses) != 1 || ghost.Analyses[0] != "an_ghost" {
t.Errorf("expected ghost.Analyses=[an_ghost], got %v", ghost.Analyses)
}
specter := findOrphan(rows, "specter")
if specter == nil {
t.Fatal("orphan 'specter' missing")
}
if len(specter.Apps) != 1 || specter.Apps[0] != "app_specter" {
t.Errorf("expected specter.Apps=[app_specter], got %v", specter.Apps)
}
if len(specter.Analyses) != 0 {
t.Errorf("expected specter.Analyses empty, got %v", specter.Analyses)
}
})
t.Run("app con project_id valido no aparece", func(t *testing.T) {
dir := seedOrphanRefsRegistry(t, true)
rows, err := FindOrphanProjectRefs(dir)
if err != nil {
t.Fatalf("error: %v", err)
}
if findOrphan(rows, "known") != nil {
t.Error("valid project 'known' should not appear as orphan")
}
// Children of 'known' must never be listed in any orphan entry.
for _, r := range rows {
for _, a := range append(append([]string{}, r.Apps...), r.Analyses...) {
if a == "app_valid" || a == "an_valid" || a == "app_no_project" {
t.Errorf("child %q with valid/empty project leaked into orphan %q", a, r.ProjectID)
}
}
}
})
t.Run("sin huerfanos devuelve slice vacio sin error", func(t *testing.T) {
dir := seedOrphanRefsRegistry(t, false)
rows, err := FindOrphanProjectRefs(dir)
if err != nil {
t.Fatalf("error: %v", err)
}
if rows == nil {
t.Fatal("expected non-nil empty slice, got nil")
}
if len(rows) != 0 {
t.Errorf("expected 0 orphans, got %d: %+v", len(rows), rows)
}
})
t.Run("error si registry.db no existe", func(t *testing.T) {
dir := t.TempDir()
if _, err := FindOrphanProjectRefs(dir); err == nil {
t.Error("expected error for missing db, got nil")
}
})
}
func TestFormatOrphanProjectRefs(t *testing.T) {
t.Run("sin huerfanos lo deja claro", func(t *testing.T) {
out := FormatOrphanProjectRefs(nil)
if !contains(out, "0 project_id huérfanos") {
t.Errorf("expected clean message, got:\n%s", out)
}
})
t.Run("con huerfanos lista ids y cuenta", func(t *testing.T) {
rows := []OrphanProjectRef{
{ProjectID: "ghost", Apps: []string{"app_alpha", "app_zeta"}, Analyses: []string{"an_ghost"}},
}
out := FormatOrphanProjectRefs(rows)
if !contains(out, "ghost") {
t.Errorf("expected project_id in output, got:\n%s", out)
}
if !contains(out, "app_alpha") || !contains(out, "an_ghost") {
t.Errorf("expected referencing ids in output, got:\n%s", out)
}
if !contains(out, "1 project_id huérfanos") {
t.Errorf("expected count line, got:\n%s", out)
}
})
}
func TestFormatProjectsCoverage(t *testing.T) {
t.Run("sin issues lo deja claro", func(t *testing.T) {
rows := []ProjectCoverage{
{ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true, ChildrenInDB: 2, ChildrenCloned: 2},
}
out := FormatProjectsCoverage(rows)
if !contains(out, "0 projects con problemas de cobertura") {
t.Errorf("expected clean message, got:\n%s", out)
}
})
t.Run("con issues cuenta los afectados", func(t *testing.T) {
rows := []ProjectCoverage{
{ProjectID: "bad", Issues: []string{"no_gitea_repo"}},
{ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true},
}
out := FormatProjectsCoverage(rows)
if !contains(out, "1/2 projects con problemas de cobertura") {
t.Errorf("expected 1/2 count, got:\n%s", out)
}
})
}
+150
View File
@@ -0,0 +1,150 @@
package infra
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// natsVarz refleja los campos relevantes de la respuesta JSON del endpoint
// /varz del monitoring HTTP embebido de un nats-server (puerto 8222, loopback).
// Solo se mapean los campos que producen series; el resto se ignora.
type natsVarz struct {
InMsgs int64 `json:"in_msgs"`
OutMsgs int64 `json:"out_msgs"`
InBytes int64 `json:"in_bytes"`
OutBytes int64 `json:"out_bytes"`
Connections int `json:"connections"`
SlowConsumers int `json:"slow_consumers"`
Subscriptions int `json:"subscriptions"`
Mem int64 `json:"mem"`
Start string `json:"start"`
}
// natsConnz refleja los campos relevantes de /connz.
type natsConnz struct {
NumConnections int `json:"num_connections"`
}
// natsStreamDetail refleja un stream dentro de account_details[].stream_detail[].
type natsStreamDetail struct {
Name string `json:"name"`
Cluster struct {
Leader string `json:"leader"`
} `json:"cluster"`
State struct {
Messages int64 `json:"messages"`
Bytes int64 `json:"bytes"`
} `json:"state"`
}
// natsJsz refleja los campos relevantes de /jsz?streams=1.
type natsJsz struct {
Streams int64 `json:"streams"`
Messages int64 `json:"messages"`
Bytes int64 `json:"bytes"`
Memory int64 `json:"memory"`
Storage int64 `json:"storage"`
AccountDetails []struct {
StreamDetail []natsStreamDetail `json:"stream_detail"`
} `json:"account_details"`
}
// ParseNatsMonitor convierte las respuestas JSON del endpoint de monitoring HTTP
// embebido de un nats-server (puerto 8222, loopback) en una serie de PromSample
// lista para empujar a VictoriaMetrics. Es la hermana de ParseUnibusHealth para
// las métricas server-level de NATS/JetStream (msgs/s, conexiones, KV bucket
// msgs, RAFT leader por stream, memoria). La consume el unibus_exporter de
// fleet_monitoring en modo scraper local por nodo.
//
// node es el nombre lógico del nodo (p.ej. "magnus"); se adjunta a CADA serie
// como las labels "node" e "instance" para distinguir los nodos cuando un único
// exporter scrapea varios.
//
// varz, connz y jsz son los cuerpos crudos de GET /varz, GET /connz y
// GET /jsz?streams=1 respectivamente:
// - varz es el core: si NO parsea como JSON válido devuelve (nil, error).
// - connz y jsz son best-effort: si vienen vacíos o no parsean, sus series se
// omiten sin abortar (no error), para que el scraper resista que un endpoint
// falle. nats_connections cae a varz.connections cuando connz no parsea.
func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error) {
var v natsVarz
if err := json.Unmarshal(varz, &v); err != nil {
return nil, fmt.Errorf("parse nats varz for node %q: %w", node, err)
}
// mk construye un PromSample con las labels base {node, instance} más, de
// forma opcional, labels extra (clave/valor alternados). Las labels base no
// se pueden sobreescribir desde extra.
mk := func(name string, val float64, extra ...string) PromSample {
labels := map[string]string{"node": node, "instance": node}
for i := 0; i+1 < len(extra); i += 2 {
labels[extra[i]] = extra[i+1]
}
return PromSample{Name: name, Labels: labels, Value: val}
}
out := []PromSample{
mk("nats_msgs_in_total", float64(v.InMsgs)),
mk("nats_msgs_out_total", float64(v.OutMsgs)),
mk("nats_bytes_in_total", float64(v.InBytes)),
mk("nats_bytes_out_total", float64(v.OutBytes)),
}
// nats_connections: prefiere connz.num_connections; si connz no parsea, cae
// a varz.connections para no perder la serie.
connections := float64(v.Connections)
if len(connz) > 0 {
var c natsConnz
if err := json.Unmarshal(connz, &c); err == nil {
connections = float64(c.NumConnections)
}
}
out = append(out,
mk("nats_connections", connections),
mk("nats_slow_consumers", float64(v.SlowConsumers)),
mk("nats_mem_bytes", float64(v.Mem)),
mk("nats_subscriptions", float64(v.Subscriptions)),
)
// nats_server_start_seconds: epoch (segundos Unix) del campo start (RFC3339).
// Proxy de reinicios del nats-server: un cambio de este valor = el server
// reinició. Si el parse de la fecha falla, se omite la serie (no se aborta).
if t, err := time.Parse(time.RFC3339, v.Start); err == nil {
out = append(out, mk("nats_server_start_seconds", float64(t.Unix())))
}
// jsz es best-effort: si vacío o inválido, se omiten todas sus series.
if len(jsz) > 0 {
var j natsJsz
if err := json.Unmarshal(jsz, &j); err == nil {
out = append(out,
mk("nats_jetstream_streams", float64(j.Streams)),
mk("nats_jetstream_messages", float64(j.Messages)),
mk("nats_jetstream_bytes", float64(j.Bytes)),
mk("nats_jetstream_memory_bytes", float64(j.Memory)),
mk("nats_jetstream_storage_bytes", float64(j.Storage)),
)
for _, acc := range j.AccountDetails {
for _, sd := range acc.StreamDetail {
out = append(out,
mk("nats_stream_messages", float64(sd.State.Messages), "stream", sd.Name),
mk("nats_stream_bytes", float64(sd.State.Bytes), "stream", sd.Name),
)
leader := 0.0
if sd.Cluster.Leader == node {
leader = 1
}
out = append(out, mk("nats_jetstream_raft_leader", leader, "stream", sd.Name))
if bucket, ok := strings.CutPrefix(sd.Name, "KV_"); ok {
out = append(out, mk("kv_bucket_msgs", float64(sd.State.Messages), "bucket", bucket))
}
}
}
}
}
return out, nil
}
+119
View File
@@ -0,0 +1,119 @@
---
name: parse_nats_monitor
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error)"
description: "Convierte las respuestas JSON del endpoint de monitoring HTTP embebido de un nats-server (puerto 8222, loopback) en una serie de PromSample lista para empujar a VictoriaMetrics. Hermana de ParseUnibusHealth pero para las métricas server-level de NATS/JetStream: msgs/s, bytes, conexiones, slow consumers, memoria RSS, start epoch (proxy de reinicios), streams/messages/bytes/memory/storage de JetStream, y por stream nats_stream_messages/bytes, nats_jetstream_raft_leader y kv_bucket_msgs para los buckets KV_. Adjunta labels node e instance a cada serie. varz es el core (error si no parsea); connz y jsz son best-effort (se omiten sin abortar). La consume el unibus_exporter de fleet_monitoring como scraper local por nodo."
tags: [prometheus, metrics, nats, jetstream, monitoring, varz, connz, jsz, kv, raft, fleet-metrics, infra]
uses_functions: []
uses_types: ["PromSample_go_infra"]
returns: []
returns_optional: true
error_type: "error_go_core"
imports: ["encoding/json", "fmt", "strings", "time"]
params:
- name: node
desc: "nombre lógico del nodo (p.ej. \"magnus\"); se adjunta como labels node e instance a CADA serie y se compara con cluster.leader de cada stream para nats_jetstream_raft_leader"
- name: varz
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/varz; core de la función (in_msgs, out_msgs, in_bytes, out_bytes, connections, slow_consumers, subscriptions, mem, start). Si no parsea, la función devuelve error"
- name: connz
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/connz; best-effort (num_connections). Si vacío o inválido, nats_connections cae a varz.connections sin abortar"
- name: jsz
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/jsz?streams=1; best-effort (streams, messages, bytes, memory, storage y account_details[].stream_detail[]). Si vacío o inválido, se omiten sus series sin abortar. Necesita ?streams=1 para traer stream_detail"
output: "slice de PromSample con labels base {node,instance}: nats_msgs_in/out_total, nats_bytes_in/out_total, nats_connections, nats_slow_consumers, nats_mem_bytes, nats_subscriptions, nats_server_start_seconds (omitida si start no parsea), nats_jetstream_streams/messages/bytes/memory_bytes/storage_bytes; y por stream nats_stream_messages{stream}, nats_stream_bytes{stream}, nats_jetstream_raft_leader{stream} (1 si cluster.leader==node) y, para streams KV_, kv_bucket_msgs{bucket} con el prefijo KV_ recortado. Error solo si varz no es JSON válido."
tested: true
test_file_path: "functions/infra/parse_nats_monitor_test.go"
tests:
- "TestParseNatsMonitorGolden"
- "TestParseNatsMonitorEmptyJsz"
- "TestParseNatsMonitorInvalidConnz"
- "TestParseNatsMonitorInvalidVarz"
---
# parse_nats_monitor
Función de transformación (clasificada `impure` porque devuelve `error` al fallar el
unmarshal del core; no hace I/O ni red por sí misma) que traduce las métricas
server-level de un **nats-server** a series Prometheus. Es la hermana de
`parse_unibus_health_go_infra`: aquella lee el `/healthz` de `membershipd` (posture),
esta lee el monitoring embebido de NATS (puerto 8222) para las métricas profundas que
`/healthz` no expone: msgs/s, conexiones, RAFT leader por stream, memoria, KV buckets.
Pertenece al grupo de capacidad `fleet-metrics`: se compone con
`format_prom_exposition_go_infra` (serializar) y `push_prom_remote_go_infra` (empujar a
VictoriaMetrics). La consume el `unibus_exporter` de `fleet_monitoring` en modo scraper
local por nodo, que hace los tres GET y le pasa los cuerpos crudos.
## Ejemplo
```go
package main
import (
"fmt"
"io"
"net/http"
"time"
"fn-registry/functions/infra"
)
func get(url string) []byte {
resp, err := http.Get(url)
if err != nil {
return nil // best-effort: connz/jsz pueden faltar
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return b
}
func main() {
base := "http://127.0.0.1:8222"
varz := get(base + "/varz")
connz := get(base + "/connz")
jsz := get(base + "/jsz?streams=1")
samples, err := infra.ParseNatsMonitor("magnus", varz, connz, jsz)
if err != nil {
panic(err) // varz es el core: sin él no hay métricas
}
fmt.Print(infra.FormatPromExposition(samples, time.Now().UnixMilli()))
// nats_msgs_in_total{instance="magnus",node="magnus"} 17 ...
// kv_bucket_msgs{bucket="UNIBUS_users",instance="magnus",node="magnus"} 2 ...
// nats_jetstream_raft_leader{instance="magnus",node="magnus",stream="KV_UNIBUS_users"} 1 ...
}
```
## Cuando usarla
Úsala dentro de un exporter que monitoriza un nats-server con el monitoring HTTP
embebido activado (`http: 127.0.0.1:8222` en la config de NATS): tras hacer
`GET /varz`, `GET /connz` y `GET /jsz?streams=1` contra loopback, pasa los tres cuerpos
crudos a esta función para obtener todas las series server-level del nodo. Llámala como
scraper local por nodo (cada nodo expone su 8222 solo en loopback), no centralizado.
## Gotchas
- **Impura por contrato**: solo devuelve `error` si `varz` no es JSON válido (es el core).
`connz` y `jsz` son **best-effort**: si vienen vacíos o no parsean, sus series se omiten
sin abortar. Esto hace al scraper resistente a que un endpoint falle de forma puntual.
- **Monitoring loopback-only sin auth**: el puerto 8222 de NATS no tiene autenticación; por
eso debe bindearse a `127.0.0.1` y scrapearse localmente en cada nodo, nunca exponerse a
la red. El push agregado a VictoriaMetrics lo hace el exporter, no esta función.
- **`/jsz` necesita `?streams=1`** para traer `account_details[].stream_detail[]`. Sin ese
parámetro el cuerpo trae los totales pero no el detalle por stream, y entonces no salen
`nats_stream_*`, `nats_jetstream_raft_leader` ni `kv_bucket_msgs`.
- **`nats_connections`**: prefiere `connz.num_connections`; si `connz` no parsea, cae a
`varz.connections` para no perder la serie.
- **RAFT leader en standalone**: en un nats-server sin clúster, el objeto `cluster` puede
faltar o `leader` venir vacío; en ese caso `nats_jetstream_raft_leader` sale 0 salvo que
`cluster.leader == node`. Es esperado: en standalone no hay quorum RAFT real.
- **`kv_bucket_msgs`** solo se emite para streams cuyo nombre empieza por `KV_`, recortando
el prefijo (stream `KV_UNIBUS_users` → bucket `UNIBUS_users`).
- **`nats_server_start_seconds`** es el epoch Unix del campo `start` (RFC3339): sirve como
proxy de reinicios (un cambio de valor = el server reinició). Si el campo no parsea como
fecha válida, la serie se omite en lugar de abortar.
+160
View File
@@ -0,0 +1,160 @@
package infra
import (
"os"
"testing"
)
// findNatsSample devuelve el primer PromSample cuyo Name coincide y cuyos labels
// extra (clave/valor alternados) están todos presentes con el valor esperado.
// El segundo retorno indica si se encontró.
func findNatsSample(samples []PromSample, name string, labels ...string) (PromSample, bool) {
for _, s := range samples {
if s.Name != name {
continue
}
match := true
for i := 0; i+1 < len(labels); i += 2 {
if s.Labels[labels[i]] != labels[i+1] {
match = false
break
}
}
if match {
return s, true
}
}
return PromSample{}, false
}
func mustRead(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %s: %v", path, err)
}
return b
}
// golden: fixtures reales de un nats-server 2.11.15, node="probe" (== el leader
// de los streams), valores concretos verificados a mano.
func TestParseNatsMonitorGolden(t *testing.T) {
varz := mustRead(t, "testdata/nats_varz.json")
connz := mustRead(t, "testdata/nats_connz.json")
jsz := mustRead(t, "testdata/nats_jsz.json")
got, err := ParseNatsMonitor("probe", varz, connz, jsz)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := map[string]float64{
"nats_msgs_in_total": 17,
"nats_msgs_out_total": 17,
"nats_mem_bytes": 18288640,
"nats_jetstream_streams": 3,
"nats_connections": 1,
"nats_jetstream_messages": 6,
}
for name, w := range want {
s, ok := findNatsSample(got, name)
if !ok {
t.Errorf("missing sample %q", name)
continue
}
if s.Value != w {
t.Errorf("%s = %v, want %v", name, s.Value, w)
}
if s.Labels["node"] != "probe" || s.Labels["instance"] != "probe" {
t.Errorf("%s labels = %v, want node=instance=probe", name, s.Labels)
}
}
// kv_bucket_msgs por cada KV bucket (prefijo KV_ recortado).
for bucket, w := range map[string]float64{
"UNIBUS_users": 2,
"UNIBUS_rooms": 2,
"UNIBUS_members": 2,
} {
s, ok := findNatsSample(got, "kv_bucket_msgs", "bucket", bucket)
if !ok {
t.Errorf("missing kv_bucket_msgs{bucket=%q}", bucket)
continue
}
if s.Value != w {
t.Errorf("kv_bucket_msgs{bucket=%q} = %v, want %v", bucket, s.Value, w)
}
}
// raft leader: probe == node, así que el stream KV_UNIBUS_users tiene leader=1.
s, ok := findNatsSample(got, "nats_jetstream_raft_leader", "stream", "KV_UNIBUS_users")
if !ok {
t.Fatal("missing nats_jetstream_raft_leader{stream=KV_UNIBUS_users}")
}
if s.Value != 1 {
t.Errorf("nats_jetstream_raft_leader{stream=KV_UNIBUS_users} = %v, want 1", s.Value)
}
// stream_detail también emite nats_stream_messages con label stream completo.
if s, ok := findNatsSample(got, "nats_stream_messages", "stream", "KV_UNIBUS_users"); !ok || s.Value != 2 {
t.Errorf("nats_stream_messages{stream=KV_UNIBUS_users} = %v ok=%v, want 2", s.Value, ok)
}
// nats_server_start_seconds presente (start es RFC3339 válido).
if _, ok := findNatsSample(got, "nats_server_start_seconds"); !ok {
t.Error("missing nats_server_start_seconds (start is a valid RFC3339)")
}
}
// edge: jsz sin streams ni account_details. No produce series kv_bucket_msgs ni
// nats_stream_*, pero sí las de varz/connz y las jetstream top-level (en 0).
func TestParseNatsMonitorEmptyJsz(t *testing.T) {
varz := mustRead(t, "testdata/nats_varz.json")
connz := mustRead(t, "testdata/nats_connz.json")
jsz := []byte(`{"streams":0,"account_details":[]}`)
got, err := ParseNatsMonitor("probe", varz, connz, jsz)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := findNatsSample(got, "kv_bucket_msgs", "bucket", "UNIBUS_users"); ok {
t.Error("did not expect kv_bucket_msgs with empty account_details")
}
if _, ok := findNatsSample(got, "nats_stream_messages"); ok {
t.Error("did not expect nats_stream_messages with empty account_details")
}
// varz/connz siguen presentes.
if s, ok := findNatsSample(got, "nats_msgs_in_total"); !ok || s.Value != 17 {
t.Errorf("nats_msgs_in_total = %v ok=%v, want 17", s.Value, ok)
}
if s, ok := findNatsSample(got, "nats_connections"); !ok || s.Value != 1 {
t.Errorf("nats_connections = %v ok=%v, want 1", s.Value, ok)
}
}
// edge: connz inválido. No es error; nats_connections cae a varz.connections (1).
// varz/jsz siguen produciendo sus series.
func TestParseNatsMonitorInvalidConnz(t *testing.T) {
varz := mustRead(t, "testdata/nats_varz.json")
jsz := mustRead(t, "testdata/nats_jsz.json")
got, err := ParseNatsMonitor("probe", varz, []byte("not json"), jsz)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// fallback a varz.connections (= 1).
if s, ok := findNatsSample(got, "nats_connections"); !ok || s.Value != 1 {
t.Errorf("nats_connections = %v ok=%v, want 1 (fallback varz.connections)", s.Value, ok)
}
// jsz sigue vivo.
if s, ok := findNatsSample(got, "nats_jetstream_streams"); !ok || s.Value != 3 {
t.Errorf("nats_jetstream_streams = %v ok=%v, want 3", s.Value, ok)
}
}
// error path: varz inválido devuelve error no-nil (es el core, sin él no hay nada).
func TestParseNatsMonitorInvalidVarz(t *testing.T) {
if _, err := ParseNatsMonitor("probe", []byte("{{{"), nil, nil); err == nil {
t.Fatal("expected error for invalid varz, got nil")
}
}
+67
View File
@@ -0,0 +1,67 @@
package infra
import (
"encoding/json"
"fmt"
)
// unibusHealth refleja la respuesta JSON del endpoint /healthz de un nodo del
// cluster de mensajería unibus (membershipd). Forma verificada en producción:
//
// {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
type unibusHealth struct {
Status string `json:"status"`
Posture struct {
Enforce bool `json:"enforce"`
ACL bool `json:"acl"`
TLS bool `json:"tls"`
Cluster bool `json:"cluster"`
Store string `json:"store"`
} `json:"posture"`
}
// ParseUnibusHealth convierte la respuesta JSON del endpoint /healthz de un nodo
// del cluster de mensajería unibus en una serie de PromSample lista para empujar
// a VictoriaMetrics, sin instrumentar el bus (solo lee su endpoint de salud).
//
// node es el nombre lógico del nodo (p.ej. "magnus"); se adjunta a cada serie
// como las labels "node" e "instance" para distinguir los nodos cuando un único
// exporter scrapea varios. La función SOLO debe llamarse cuando el nodo
// respondió: el caso "no responde" (unibus_up=0) lo emite el llamador, no esta
// función, porque sin cuerpo no hay nada que parsear.
//
// Devuelve siete series por nodo:
// - unibus_up = 1 (si el body parseó, el nodo respondió)
// - unibus_status_ok = 1 si status=="ok", si no 0
// - unibus_posture_enforce / _acl / _tls / _cluster = 1/0 según el booleano
// - unibus_store_kv = 1 si posture.store=="kv", si no 0
//
// Si el body no es JSON válido con la forma esperada, devuelve (nil, error).
func ParseUnibusHealth(node string, body []byte) ([]PromSample, error) {
var h unibusHealth
if err := json.Unmarshal(body, &h); err != nil {
return nil, fmt.Errorf("parse unibus healthz for node %q: %w", node, err)
}
b2f := func(b bool) float64 {
if b {
return 1
}
return 0
}
mk := func(name string, v float64) PromSample {
return PromSample{
Name: name,
Labels: map[string]string{"node": node, "instance": node},
Value: v,
}
}
return []PromSample{
mk("unibus_up", 1),
mk("unibus_status_ok", b2f(h.Status == "ok")),
mk("unibus_posture_enforce", b2f(h.Posture.Enforce)),
mk("unibus_posture_acl", b2f(h.Posture.ACL)),
mk("unibus_posture_tls", b2f(h.Posture.TLS)),
mk("unibus_posture_cluster", b2f(h.Posture.Cluster)),
mk("unibus_store_kv", b2f(h.Posture.Store == "kv")),
}, nil
}
+89
View File
@@ -0,0 +1,89 @@
---
name: parse_unibus_health
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ParseUnibusHealth(node string, body []byte) ([]PromSample, error)"
description: "Convierte la respuesta JSON del endpoint /healthz de un nodo del cluster de mensajería unibus (membershipd) en una serie de PromSample lista para empujar a VictoriaMetrics, sin instrumentar el bus: solo lee su endpoint de salud. Adjunta a cada serie las labels node e instance (= nombre lógico del nodo) para distinguir los nodos cuando un único exporter scrapea varios. Emite siete series por nodo: unibus_up, unibus_status_ok, unibus_posture_enforce/acl/tls/cluster y unibus_store_kv. Devuelve error si el body no es JSON válido con la forma esperada."
tags: [prometheus, metrics, unibus, nats, healthz, posture, fleet-metrics, infra, monitoring]
uses_functions: []
uses_types: ["PromSample_go_infra"]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["encoding/json", "fmt"]
params:
- name: node
desc: "nombre lógico del nodo (p.ej. \"magnus\"); se adjunta como labels node e instance a cada serie"
- name: body
desc: "cuerpo JSON crudo devuelto por GET https://<nodo>:8470/healthz, forma {\"posture\":{enforce,acl,tls,cluster bool; store string},\"status\":string}"
output: "slice de 7 PromSample con labels {node,instance}: unibus_up=1, unibus_status_ok (1 si status==ok), unibus_posture_enforce/acl/tls/cluster (1/0), unibus_store_kv (1 si posture.store==kv). Error si el body no es JSON válido."
tested: true
test_file_path: "functions/infra/parse_unibus_health_test.go"
tests:
- "TestParseUnibusHealthGolden"
- "TestParseUnibusHealthDegraded"
- "TestParseUnibusHealthInvalid"
---
# parse_unibus_health
Función pura de transformación (clasificada `impure` solo porque devuelve `error` al
fallar el unmarshal; no hace I/O ni red) que traduce la salud de un nodo del bus de
mensajería **unibus** a métricas Prometheus. Pertenece al grupo de capacidad
`fleet-metrics`: se compone con `format_prom_exposition_go_infra` (serializar) y
`push_prom_remote_go_infra` (empujar a VictoriaMetrics).
El endpoint `/healthz` de cada nodo (`membershipd`) responde, verificado en producción:
```json
{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
```
## Ejemplo
```go
package main
import (
"fmt"
"time"
"fn-registry/functions/infra"
)
func main() {
body := []byte(`{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}`)
samples, err := infra.ParseUnibusHealth("magnus", body)
if err != nil {
panic(err)
}
// Serializa y (en un exporter real) empuja a VictoriaMetrics.
fmt.Print(infra.FormatPromExposition(samples, time.Now().UnixMilli()))
// unibus_up{instance="magnus",node="magnus"} 1 ...
// unibus_posture_enforce{instance="magnus",node="magnus"} 1 ...
}
```
## Cuando usarla
Úsala dentro de un exporter que monitoriza el cluster unibus: tras hacer
`GET https://<nodo>:8470/healthz` con la CA del cluster, pasa el cuerpo a esta función
para obtener las series del nodo. Llámala **solo cuando el nodo respondió**; si el GET
falla (timeout, TLS, no-2xx), emite tú `unibus_up=0` para ese nodo, porque sin cuerpo
no hay nada que parsear.
## Gotchas
- No emite `unibus_up=0`: ese caso (nodo caído) es responsabilidad del llamador, que sabe
si el GET falló. Esta función siempre emite `unibus_up=1` porque solo se la llama con un
cuerpo recibido.
- Las labels `node` e `instance` toman el mismo valor (el nombre lógico del nodo). El
`push_prom_remote_go_infra` añadiría `instance` vía `extra_label` por igual a todas las
series del body; por eso aquí ya se fija `instance` por-serie, para que cada nodo unibus
conserve su identidad cuando un solo exporter empuja los de varios nodos en un único POST.
- Solo lee la posture y el status que hoy expone `/healthz`. Métricas profundas de
NATS/JetStream (msgs/s, conexiones, RAFT leader por stream) NO salen de aquí: requieren
el monitoring embebido de NATS (puerto 8222), que en producción está cerrado.
@@ -0,0 +1,67 @@
package infra
import "testing"
// golden: nodo seguro con la posture homogénea esperada en producción.
func TestParseUnibusHealthGolden(t *testing.T) {
body := []byte(`{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}`)
got, err := ParseUnibusHealth("magnus", body)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := map[string]float64{
"unibus_up": 1,
"unibus_status_ok": 1,
"unibus_posture_enforce": 1,
"unibus_posture_acl": 1,
"unibus_posture_tls": 1,
"unibus_posture_cluster": 1,
"unibus_store_kv": 1,
}
if len(got) != len(want) {
t.Fatalf("got %d samples, want %d", len(got), len(want))
}
for _, s := range got {
w, ok := want[s.Name]
if !ok {
t.Errorf("unexpected sample %q", s.Name)
continue
}
if s.Value != w {
t.Errorf("%s = %v, want %v", s.Name, s.Value, w)
}
if s.Labels["node"] != "magnus" || s.Labels["instance"] != "magnus" {
t.Errorf("%s labels = %v, want node=instance=magnus", s.Name, s.Labels)
}
}
}
// edge: nodo degradado (posture todo false, store distinto de kv, status != ok).
func TestParseUnibusHealthDegraded(t *testing.T) {
body := []byte(`{"posture":{"enforce":false,"acl":false,"tls":false,"cluster":false,"store":"sqlite"},"status":"degraded"}`)
got, err := ParseUnibusHealth("homer", body)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := map[string]float64{
"unibus_up": 1,
"unibus_status_ok": 0,
"unibus_posture_enforce": 0,
"unibus_posture_acl": 0,
"unibus_posture_tls": 0,
"unibus_posture_cluster": 0,
"unibus_store_kv": 0,
}
for _, s := range got {
if s.Value != want[s.Name] {
t.Errorf("%s = %v, want %v", s.Name, s.Value, want[s.Name])
}
}
}
// error path: body que no es JSON válido devuelve error, no panic.
func TestParseUnibusHealthInvalid(t *testing.T) {
if _, err := ParseUnibusHealth("datardos", []byte("not json at all")); err == nil {
t.Fatal("expected error for invalid body, got nil")
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
"now": "2026-06-07T19:02:25.326833943Z",
"num_connections": 1,
"total": 1,
"offset": 0,
"limit": 1024,
"connections": [
{
"cid": 5,
"kind": "Client",
"type": "nats",
"ip": "127.0.0.1",
"port": 52734,
"start": "2026-06-07T21:02:24.812382826+02:00",
"last_activity": "2026-06-07T21:02:24.821005187+02:00",
"rtt": "623µs",
"uptime": "0s",
"idle": "0s",
"pending_bytes": 0,
"in_msgs": 17,
"out_msgs": 17,
"in_bytes": 1304,
"out_bytes": 3905,
"subscriptions": 2,
"lang": "go",
"version": "1.49.0"
}
]
}
+97
View File
@@ -0,0 +1,97 @@
{
"memory": 0,
"storage": 310,
"reserved_memory": 0,
"reserved_storage": 0,
"accounts": 1,
"ha_assets": 0,
"api": {
"level": 1,
"total": 6,
"errors": 0
},
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
"now": "2026-06-07T19:02:25.327216549Z",
"config": {
"max_memory": 3221225472,
"max_storage": 546399169536,
"store_dir": "/tmp/natsprobe4019469486/jetstream",
"sync_interval": 120000000000
},
"limits": {},
"streams": 3,
"consumers": 0,
"messages": 6,
"bytes": 310,
"account_details": [
{
"name": "$G",
"id": "$G",
"memory": 0,
"storage": 310,
"reserved_memory": 18446744073709551615,
"reserved_storage": 18446744073709551615,
"accounts": 0,
"ha_assets": 0,
"api": {
"level": 0,
"total": 6,
"errors": 0
},
"stream_detail": [
{
"name": "KV_UNIBUS_rooms",
"created": "2026-06-07T19:02:24.8170934Z",
"cluster": {
"leader": "probe"
},
"state": {
"messages": 2,
"bytes": 102,
"first_seq": 1,
"first_ts": "2026-06-07T19:02:24.817910599Z",
"last_seq": 2,
"last_ts": "2026-06-07T19:02:24.818011867Z",
"num_subjects": 2,
"consumer_count": 0
}
},
{
"name": "KV_UNIBUS_members",
"created": "2026-06-07T19:02:24.818494147Z",
"cluster": {
"leader": "probe"
},
"state": {
"messages": 2,
"bytes": 106,
"first_seq": 1,
"first_ts": "2026-06-07T19:02:24.81917932Z",
"last_seq": 2,
"last_ts": "2026-06-07T19:02:24.819283444Z",
"num_subjects": 2,
"consumer_count": 0
}
},
{
"name": "KV_UNIBUS_users",
"created": "2026-06-07T19:02:24.814500069Z",
"cluster": {
"leader": "probe"
},
"state": {
"messages": 2,
"bytes": 102,
"first_seq": 1,
"first_ts": "2026-06-07T19:02:24.81638123Z",
"last_seq": 2,
"last_ts": "2026-06-07T19:02:24.816570377Z",
"num_subjects": 2,
"consumer_count": 0
}
}
]
}
],
"total": 1
}
+80
View File
@@ -0,0 +1,80 @@
{
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
"server_name": "probe",
"version": "2.11.15",
"proto": 1,
"go": "go1.26.4",
"host": "127.0.0.1",
"port": 14260,
"max_connections": 65536,
"ping_interval": 120000000000,
"ping_max": 2,
"http_host": "127.0.0.1",
"http_port": 8222,
"http_base_path": "",
"https_port": 0,
"auth_timeout": 2,
"max_control_line": 4096,
"max_payload": 1048576,
"max_pending": 67108864,
"cluster": {},
"gateway": {},
"leaf": {},
"mqtt": {},
"websocket": {},
"jetstream": {
"config": {
"max_memory": 3221225472,
"max_storage": 546399169536,
"store_dir": "/tmp/natsprobe4019469486/jetstream",
"sync_interval": 120000000000
},
"stats": {
"memory": 0,
"storage": 310,
"reserved_memory": 0,
"reserved_storage": 0,
"accounts": 1,
"ha_assets": 0,
"api": {
"level": 1,
"total": 6,
"errors": 0
}
},
"limits": {}
},
"tls_timeout": 2,
"write_deadline": 10000000000,
"start": "2026-06-07T19:02:24.785745698Z",
"now": "2026-06-07T19:02:25.325501038Z",
"uptime": "0s",
"mem": 18288640,
"cores": 24,
"gomaxprocs": 24,
"gomemlimit": 4294967296,
"cpu": 0,
"connections": 1,
"total_connections": 1,
"routes": 0,
"remotes": 0,
"leafnodes": 0,
"in_msgs": 17,
"out_msgs": 17,
"in_bytes": 1304,
"out_bytes": 3905,
"slow_consumers": 0,
"subscriptions": 75,
"http_req_stats": {
"/varz": 1
},
"config_load_time": "2026-06-07T19:02:24.785745698Z",
"config_digest": "",
"system_account": "$SYS",
"slow_consumer_stats": {
"clients": 0,
"routes": 0,
"gateways": 0,
"leafs": 0
}
}
-281
View File
@@ -1,281 +0,0 @@
---
name: fn_monitoring
description: "Monitoreo y visualizacion del estado del fn_registry. API HTTP read-only sobre las bases de datos SQLite y dashboard ImGui que consume la API."
tags: [monitoring, api, dashboard, sqlite, visualization]
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/fn_monitoring"
---
## Apps
| App | Lang | Descripcion |
|-----|------|-------------|
| [sqlite_api](apps/sqlite_api/app.md) | Go | API REST HTTP read-only sobre `registry.db` y todas las `operations.db`. Puerto `8484`. |
| [registry_dashboard](apps/registry_dashboard/app.md) | C++ / ImGui | Dashboard con KPIs, charts y tablas del registry. Consume `sqlite_api` (HTTP) con fallback a SQLite directo. |
Cada `app.md` es la referencia canonica del binario — endpoints completos, flags, dependencias. Este documento cubre **como operar el proyecto como un todo**: arranque, service, flujo de datos, troubleshooting.
---
## Arquitectura
```
registry.db (raiz)
apps/*/operations.db
projects/*/apps/*/operations.db
│ (read-only, mode=ro)
┌──────────────────────────────────────────────┐
│ sqlite_api (Go net/http, :8484) │
│ /health │
│ /api/databases │
│ /api/databases/:db/tables │
│ /api/databases/:db/schema │
│ /api/databases/:db/query (POST, SELECT) │
│ /api/databases/:db/fts │
└──────────────────────────────────────────────┘
│ HTTP GET/POST
│ (cpp-httplib + nlohmann/json)
┌──────────────────────────────────────────────┐
│ registry_dashboard (C++ / ImGui + ImPlot) │
│ main.cpp → reload_data() │
│ data_http.cpp (primario, HTTP) │
│ data.cpp (fallback, SQLite C API) │
│ views.cpp → KPI row, charts, tables │
└──────────────────────────────────────────────┘
```
**Separacion de responsabilidades:**
- `sqlite_api` **no conoce el dashboard**. Es una API generica: expone cualquier DB SQLite de `fn_registry/` read-only con FTS5.
- `registry_dashboard` **no conoce la estructura de registry.db directamente**, solo a traves del JSON que devuelve la API. El modo SQLite directo es fallback para entornos sin red.
**Puerto `8484`** — elegido para no colisionar con Metabase (3000), Jupyter (8888) ni deploy_server (9090).
---
## Servicio sqlite_api
### Modos de arranque
| Modo | Comando | Cuando usarlo |
|------|---------|---------------|
| Dev (foreground, `go run`) | `cd projects/fn_monitoring/apps/sqlite_api && go run -tags fts5 .` | Iteracion rapida, ver logs en la terminal |
| Dev (background) | `./start.sh` (dentro de `apps/sqlite_api/`) | Probar el dashboard rapido sin systemd. Escribe PID en `sqlite_api.pid` y log en `sqlite_api.log` |
| Production (systemd) | `sudo systemctl start sqlite_api` | Arranque en boot, restart on failure, logs en journal |
### Variables de entorno
| Var | Valor | Proposito |
|-----|-------|-----------|
| `FN_REGISTRY_ROOT` | ruta absoluta a la raiz del registry | Evita que el binario busque `registry.db` subiendo por el cwd. Obligatoria bajo systemd. |
### Instalar como servicio systemd (local)
Usar el pipeline del registry `install_systemd_service_bash_pipelines`:
```bash
cd /home/lucas/fn_registry
# 1. Build del binario
CGO_ENABLED=1 go build -tags fts5 \
-o projects/fn_monitoring/apps/sqlite_api/sqlite_api \
./projects/fn_monitoring/apps/sqlite_api/
# 2. Instalar unit + enable + start (requiere sudo sin password para systemctl)
source bash/functions/pipelines/install_systemd_service.sh
install_systemd_service \
--name sqlite_api \
--exec "$(pwd)/projects/fn_monitoring/apps/sqlite_api/sqlite_api" \
--workdir "$(pwd)" \
--env "FN_REGISTRY_ROOT=$(pwd)" \
--description "fn_registry SQLite HTTP API" \
--after network.target \
--restart on-failure
```
### Operacion
```bash
sudo systemctl status sqlite_api # estado + ultimas lineas del journal
sudo systemctl restart sqlite_api # tras rebuild del binario
sudo systemctl stop sqlite_api # parar
journalctl -u sqlite_api -f # logs en vivo
curl http://127.0.0.1:8484/health # health check
```
### Redeploy tras cambios en el codigo Go
```bash
cd /home/lucas/fn_registry
CGO_ENABLED=1 go build -tags fts5 \
-o projects/fn_monitoring/apps/sqlite_api/sqlite_api \
./projects/fn_monitoring/apps/sqlite_api/
sudo systemctl restart sqlite_api
```
No hace falta reinstalar el unit — solo recompilar y reiniciar.
---
## Dashboard registry_dashboard
### Build
```bash
cd cpp
cmake -B build/linux -S .
cmake --build build/linux --target registry_dashboard -j$(nproc)
```
El binario queda en `cpp/build/linux/registry_dashboard` (o `projects/fn_monitoring/apps/registry_dashboard/registry_dashboard.exe` en Windows).
### Ejecucion
```bash
# Modo API (por defecto, intenta localhost:8484)
./registry_dashboard
# API remoto
./registry_dashboard --api http://192.168.1.10:8484
# API + fallback SQLite
./registry_dashboard --api http://127.0.0.1:8484 /home/lucas/fn_registry/registry.db
# Solo SQLite (sin API)
./registry_dashboard /home/lucas/fn_registry/registry.db
```
La UI muestra en la cabecera de donde vienen los datos (HTTP vs SQLite). `F5` recarga.
### Flujo de datos
1. `main.cpp::reload_data()` intenta HTTP primero via `load_registry_data_http()`.
2. Si la API responde `200` y el JSON parsea, los datos pueblan `RegistryData`.
3. Si falla la API (timeout, 5xx, JSON invalido) y hay `--db`, cae a `load_registry_data()` (SQLite directo).
4. Si ninguno funciona, la UI muestra un mensaje de error y no hay reintento automatico — hay que pulsar reload.
### Vistas
| Seccion | Datos | Query subyacente |
|---------|-------|------------------|
| KPI row (8 cards) | totales y porcentajes | `SELECT COUNT(*)` sobre functions, types, apps, analysis, unit_tests, proposals + agregados tested/pure |
| Charts (bar + pie) | funciones por lang/domain, reparto pure/impure, kind | `GROUP BY lang`, `GROUP BY domain`, `GROUP BY purity`, `GROUP BY kind` |
| Tablas | ultimas 20 functions, apps, analysis, types | `ORDER BY updated_at DESC LIMIT 20` |
Detalle de composicion de componentes viz en `apps/registry_dashboard/app.md`.
---
## Troubleshooting
| Sintoma | Causa probable | Verificacion / Fix |
|---------|----------------|--------------------|
| Dashboard dice "HTTP API failed, falling back to SQLite" | `sqlite_api` no esta corriendo | `curl http://127.0.0.1:8484/health` — si falla, `systemctl status sqlite_api` o `./start.sh` |
| `sqlite_api` arranca y muere inmediatamente | No encuentra `registry.db` | Exportar `FN_REGISTRY_ROOT=/home/lucas/fn_registry` o correr desde la raiz del registry |
| `systemctl start sqlite_api` pide password | Falta sudoers para systemctl | Ver `.claude/rules/deploy.md` — el usuario necesita `NOPASSWD` para `systemctl`, `mv` a `/etc/systemd/system/` |
| Dashboard abre pero todas las cifras son 0 | API conecta pero devuelve DB vacia | `curl -X POST http://127.0.0.1:8484/api/databases/registry/query -d '{"sql":"SELECT COUNT(*) FROM functions"}'` |
| API responde lento / timeout | Query pesada sobre FTS5 | Timeout hardcoded a 5s en `handlers.go`. Revisar la query en journal. |
| Bind rechazado (`address already in use`) | Otro proceso en `8484` | `ss -tlnp | grep 8484` — matar el huerfano o cambiar `--bind` |
### Logs
```bash
# systemd
journalctl -u sqlite_api -n 100 --no-pager
journalctl -u sqlite_api -f
# start.sh
tail -f projects/fn_monitoring/apps/sqlite_api/sqlite_api.log
```
---
## Como extender
### Anadir un endpoint a sqlite_api
1. Registrar la ruta en `Server.Routes()` (`handlers.go`).
2. Handler lee `r.URL.Path` / `r.Body`, delega en `DBPool` para resolver la DB, ejecuta SQL read-only.
3. Test en `handlers_test.go` (patron: tabla de casos HTTP).
4. Rebuild + `systemctl restart sqlite_api`.
5. Documentar en `apps/sqlite_api/app.md` (tabla de endpoints).
### Anadir una vista al dashboard
1. Nuevo campo en `RegistryData` (`data.h`) + su equivalente en la respuesta JSON.
2. Parseo en `data_http.cpp` y carga SQL en `data.cpp` (ambos paths, para mantener el fallback).
3. Renderizado en `views.cpp` usando componentes del dominio `viz` (`kpi_card`, `bar_chart`, etc.) — ver regla `frontend_theming` analoga para C++: usar primitivos del registry antes que ImGui crudo.
4. Rebuild con CMake.
### Anadir una DB nueva
La descubre automaticamente `DiscoverDatabases()` escaneando `apps/*/operations.db` y `projects/*/apps/*/operations.db`. No hay que registrar nada — al reiniciar `sqlite_api` aparecen con alias `ops:{app_name}`.
---
## Deploy en otros PCs
Este proyecto se instala identico en cualquier maquina con el registry clonado:
1. `fn sync` para traer los metadatos del proyecto.
2. Build + systemd install (seccion "Instalar como servicio systemd" arriba).
3. Build del dashboard.
Los datos son los `.db` locales — cada PC ve su propio estado del registry y sus propias `operations.db`. No hay sincronizacion remota de datos en este servicio: para eso existe `fn sync` contra `registry_api` (proyecto diferente, ver memoria `project_registry_api`).
---
## Estado actual
### Fase — projects view + mutaciones desde el dashboard `[done 2026-04-25]`
El dashboard pasa de read-only a manipular el registry via la API. Ampliacion en tres patas:
**Backend (`sqlite_api`)** — endpoints nuevos en `handlers_projects.go` y `handlers_mutations.go`:
| Metodo | Path | Que hace |
|---|---|---|
| `GET` | `/api/projects` | Lista con conteos `apps_count` / `analyses_count` / `vaults_count` por proyecto + bloque `orphans` (entidades con `project_id` vacio). |
| `GET` | `/api/projects/{id}` | Detalle: apps[], analyses[], vaults[]. Acepta `id="orphans"` para devolver las huerfanas. |
| `POST` | `/api/reindex` | Ejecuta `fn index` desde `registryRoot`, devuelve `{ok, output}`. |
| `POST` | `/api/add/app` | Body `{name, lang, domain, project, description}` → crea `apps/{name}/` o `projects/{p}/apps/{name}/` con `app.md` minimo + `fn index`. |
| `POST` | `/api/add/analysis` | Body `{name, project, packages[], description}` → invoca `fn run init_jupyter_analysis [--project p] name pkg1 pkg2 ...`. |
| `POST` | `/api/add/vault` | Body `{name, project, path, description}` → crea dir o symlink en `projects/{p}/vaults/` + entry append en `vault.yaml`. |
`Server.registryRoot` se inyecta en `NewServer(pool, root)` (rebajado de `findRegistryRoot()` en `main.go`). Helpers `runFN()` y `runShell()` ejecutan con `cmd.Dir = registryRoot` y `FN_REGISTRY_ROOT` en el env.
**Dashboard (`registry_dashboard`)** — actions bar + tab Projects + modal Add:
- Toolbar nueva en el header (`fn_ui::toolbar`): boton `Reindex` (Primary) → dispara `http_post_reindex` via `process_runner`; boton `+ Add` → abre `modal_dialog`; boton `Reload`; `toast_inbox_button` con badge.
- Modal Add con `select` para kind (App / Analysis / Vault), `select` de proyecto (obligatorio para Vault, opcional para resto), `text_input` Name + Description y campos especificos por kind (lang/domain para App, packages CSV para Analysis, abs path para Vault). Submit dispara el endpoint correspondiente via `process_runner`. Toast al completar + reload automatico.
- Tab Projects con dos columnas: `tree_view` izquierda (proyectos + entrada "(orphans)" cuando hay entidades huerfanas), detalle derecha con tabs internas Apps / Analysis / Vaults. Click en un proyecto dispara `load_project_detail_http`.
**Datos en `RegistryData`**: nuevos `projects[]`, `orphan_apps`, `orphan_analyses`, `orphan_vaults`. Tipos nuevos `ProjectRow`, `VaultRow`, `ProjectDetail`. `load_registry_data_http` llama a `load_projects_http` al final como best-effort (no fatal si falla).
### Bug fix — vibracion al redimensionar `[done 2026-04-25]`
Dos fuentes de "vibracion" durante drag-resize de la ventana:
- `fullscreen_window_cpp_core` v0.2: anadido `NoScrollbar | NoScrollWithMouse`. Sin esto, si el contenido excedia por 1-2px aparecia un scrollbar fugaz que reducia el ancho ~14px y reflowaba todo.
- `views.cpp::draw_dashboard`: altura de charts pasa de `GetContentRegionAvail().y * 0.35` a constante 260 px. La proporcion relativa propagaba el resize a todos los plots.
- `kpi_card_cpp_viz` v1.2: altura fija 78 px (antes 108) + scale 1.4x (antes 1.8) + padding sm + `NoScrollbar`. El `AutoResizeY` con 8 cards generaba lag perceptible al redimensionar.
### Bug fix — HTTP POST timeout en thread de background `[done 2026-04-25]`
`http_client.cpp::request()` pasaba `struct timeval` a `setsockopt(SO_RCVTIMEO)` en Windows, donde MSDN especifica `DWORD` ms. Resultado: timeout efectivo de **5 ms** en lugar de **5 s**. Se nota especialmente en POST desde threads (background runners) porque la latencia de scheduling puede pasar de 5 ms. Fix: rama `_WIN32` con `DWORD timeout_ms`. Tambien `wsa_init` envuelto en `std::call_once` para evitar race entre main thread + runners. Mensajes de error formateados con ASCII (em dash U+2014 falla render con la fuente default).
### Tooling sibling — primitives_gallery `[done 2026-04-25]`
Nueva app dev en `cpp/apps/primitives_gallery/` (no es app del registry, vive en el source tree). Catalogo visual interactivo de los 19 primitivos UI de `cpp/functions/{core,viz}` con sidebar + panel + snippet por demo. Doble rol: smoke test visual al modificar tokens/componentes y build gate (esta en el CMake principal — si un primitivo rompe API la gallery no compila).
Demo destacada: `graph_viewport` con sliders de Nodes (100-20 000), Clusters (2-16) y los tres parametros de `ForceLayoutConfig` (Repulsion / Attraction / Gravity) aplicados en vivo. Util tambien como benchmark de rendimiento del stack `graph_renderer` + `graph_force_layout` + `graph_spatial_hash`.
`README.md` propio en `cpp/apps/primitives_gallery/README.md`.
### Lo siguiente que pega
- Tests unitarios de logica pura (Phase A del plan de tests): vendoreado de `doctest`, ~6 tests para `label_stride`, `slice_at`, `process_runner` transitions, `toast` queue, `tokens` sanity, `parse_url`. Cierra el ciclo gallery (visual) + ctest (logica).
- Para que algunos tests sean posibles hace falta exponer funciones internas de `bar_chart.cpp` y `pie_chart.cpp` (actualmente en namespace anonimo).
- `loginctl enable-linger lucas` para que el `sqlite_api.service` (user-level systemd) sobreviva al logout. Requiere sudo una vez. Decision pendiente del usuario.
@@ -22,6 +22,21 @@ from .extract_mac_addresses import extract_mac_addresses
from .extract_phone_numbers import extract_phone_numbers from .extract_phone_numbers import extract_phone_numbers
from .extract_iocs import extract_iocs from .extract_iocs import extract_iocs
# OSINT passive atomic functions (grupo osint-passive).
from .extract_exif_metadata import extract_exif_metadata
from .extract_pdf_metadata import extract_pdf_metadata
from .guess_email_formats import guess_email_formats
from .enumerate_username_sites import enumerate_username_sites
from .build_search_dorks import build_search_dorks
from .whois_lookup import whois_lookup
from .dns_records import dns_records
from .enum_subdomains_crtsh import enum_subdomains_crtsh
# OSINT passive enrichment orchestrators (grupo osint-enrich).
from .scan_ficha_attachments_metadata import scan_ficha_attachments_metadata
from .enrich_person_passive import enrich_person_passive
from .enrich_org_passive import enrich_org_passive
__all__ = [ __all__ = [
"hash_sha256", "hash_sha256",
"hash_md5", "hash_md5",
@@ -44,4 +59,15 @@ __all__ = [
"extract_mac_addresses", "extract_mac_addresses",
"extract_phone_numbers", "extract_phone_numbers",
"extract_iocs", "extract_iocs",
"extract_exif_metadata",
"extract_pdf_metadata",
"guess_email_formats",
"enumerate_username_sites",
"build_search_dorks",
"whois_lookup",
"dns_records",
"enum_subdomains_crtsh",
"scan_ficha_attachments_metadata",
"enrich_person_passive",
"enrich_org_passive",
] ]
@@ -0,0 +1,64 @@
---
name: build_search_dorks
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: pure
signature: "def build_search_dorks(target: str, tipo: str = 'persona', extra_domains: list | None = None) -> list"
description: "Genera consultas (dorks) de motor de busqueda para investigar un target segun su tipo (persona|email|dominio|usuario): frase exacta, site:linkedin, filetype:pdf, intext con cv/curriculum, site:<dominio> filetype:xlsx, dorks de leaks/pastebin para email, redes sociales para usuario, etc. extra_domains acota via site:. OSINT pasivo puro, sin red."
tags: [osint-passive, dork, search, recon, google-dork, cybersecurity, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: target
desc: "Cadena objetivo: nombre de persona, email, dominio o username segun el tipo."
- name: tipo
desc: "Uno de 'persona', 'email', 'dominio', 'usuario'. Cualquier otro valor devuelve solo la frase exacta. Default 'persona'."
- name: extra_domains
desc: "Lista opcional de dominios para añadir dorks 'site:<dominio> \"<target>\"' independientemente del tipo."
output: "Lista de strings de dork listos para pegar en un buscador, en orden de generacion."
tested: true
tests:
- "test_persona_genera_dorks_esperados"
- "test_email_genera_dorks_esperados"
- "test_dominio_genera_site_dorks"
- "test_usuario_genera_redes_sociales"
- "test_extra_domains_acota_con_site"
- "test_tipo_desconocido_solo_frase_exacta"
test_file_path: "python/functions/cybersecurity/build_search_dorks_test.py"
file_path: "python/functions/cybersecurity/build_search_dorks.py"
---
## Ejemplo
```python
build_search_dorks("Juan Perez", tipo="persona", extra_domains=["acme.com"])
# ['"Juan Perez"',
# '"Juan Perez" filetype:pdf',
# 'site:linkedin.com/in "Juan Perez"',
# 'site:twitter.com "Juan Perez"',
# 'intext:"Juan Perez" (curriculum OR cv OR resume)',
# '"Juan Perez" (email OR correo OR contacto)',
# '"Juan Perez" filetype:doc OR "Juan Perez" filetype:docx',
# 'site:acme.com "Juan Perez"']
build_search_dorks("empresa.com", tipo="dominio")
# ['"empresa.com"', 'site:empresa.com', 'site:empresa.com filetype:pdf',
# 'site:empresa.com filetype:xlsx', 'site:empresa.com (login OR admin OR dashboard)', ...]
```
## Cuando usarla
Usala cuando ya tengas un target identificado (persona, email, dominio o alias) y quieras una bateria de consultas de buscador listas para pegar manualmente y mapear documentos, perfiles y posibles filtraciones. Encaja despues de `guess_email_formats` (dorks de email) o `enumerate_username_sites` (dorks de usuario/persona) en una investigacion autorizada.
## Gotchas
- Funcion pura: solo genera strings de consulta, NO ejecuta busquedas ni toca la red. Los dorks se pegan a mano en el buscador.
- La sintaxis usa operadores de Google (site:, filetype:, intext:, inurl:); otros buscadores soportan un subconjunto distinto y algunos dorks no funcionaran igual.
- Para tipos no reconocidos devuelve unicamente la frase exacta entre comillas (mas los `extra_domains` si se pasan), no falla.
- Uso solo para investigacion OSINT autorizada; los dorks de leaks/breaches/pastebin pueden devolver datos sensibles cuyo tratamiento esta sujeto a ley.
@@ -0,0 +1,81 @@
"""Genera consultas (dorks) de motor de busqueda para investigar un target.
Funcion pura de OSINT pasivo: a partir de un target y su tipo (persona,
email, dominio o usuario) produce una lista de cadenas de busqueda listas
para pegar en un buscador. No toca la red.
"""
def build_search_dorks(
target: str,
tipo: str = "persona",
extra_domains: list | None = None,
) -> list:
"""Genera dorks de motor de busqueda adaptados al tipo de target.
Segun el tipo seleccionado produce las consultas mas utiles para
investigar:
- persona: nombre exacto, en linkedin, con cv/curriculum, en ficheros.
- email: el email entre comillas, en pastebin/breaches, en filetypes.
- dominio: site:dominio, ficheros expuestos, subdominios, paneles.
- usuario: el alias en redes sociales y foros.
`extra_domains` añade dorks `site:<dominio> "<target>"` para acotar la
busqueda a dominios concretos, independientemente del tipo.
Args:
target: cadena objetivo (nombre, email, dominio o username).
tipo: uno de "persona", "email", "dominio", "usuario". Cualquier
otro valor cae al conjunto generico (solo la frase exacta).
extra_domains: lista opcional de dominios para acotar via site:.
Returns:
lista de strings de dork en orden de generacion (sin deduplicar
salvo el dork de frase exacta, que es la base comun).
"""
q = f'"{target}"' # frase exacta, base de casi todo dork
dorks = [q]
t = tipo.strip().lower()
if t == "persona":
dorks += [
f"{q} filetype:pdf",
f'site:linkedin.com/in {q}',
f'site:twitter.com {q}',
f'intext:{q} (curriculum OR cv OR resume)',
f'{q} (email OR correo OR contacto)',
f'{q} filetype:doc OR {q} filetype:docx',
]
elif t == "email":
dorks += [
f'{q} site:pastebin.com',
f'{q} (leak OR breach OR dump OR password)',
f'{q} filetype:txt',
f'{q} filetype:csv',
f'{q} site:github.com',
]
elif t == "dominio":
dorks += [
f'site:{target}',
f'site:{target} filetype:pdf',
f'site:{target} filetype:xlsx',
f'site:{target} (login OR admin OR dashboard)',
f'site:{target} intext:(password OR contraseña)',
f'site:*.{target}',
f'-www site:{target}',
]
elif t == "usuario":
dorks += [
f'{q} site:github.com',
f'{q} site:reddit.com',
f'{q} (profile OR perfil OR user OR usuario)',
f'inurl:{target}',
]
# Cualquier otro tipo: solo la frase exacta (ya añadida).
if extra_domains:
for dom in extra_domains:
dorks.append(f'site:{dom} {q}')
return dorks
@@ -0,0 +1,56 @@
"""Tests para build_search_dorks."""
from build_search_dorks import build_search_dorks
def test_persona_genera_dorks_esperados():
"""El tipo persona produce frase exacta, linkedin, cv y filetypes."""
out = build_search_dorks("Juan Perez", tipo="persona")
assert '"Juan Perez"' in out
assert '"Juan Perez" filetype:pdf' in out
assert any("linkedin.com" in d for d in out)
assert any("curriculum OR cv OR resume" in d for d in out)
def test_email_genera_dorks_esperados():
"""El tipo email busca en pastebin, breaches y filetypes."""
out = build_search_dorks("bob@corp.com", tipo="email")
assert '"bob@corp.com"' in out
assert any("pastebin.com" in d for d in out)
assert any("leak OR breach OR dump OR password" in d for d in out)
assert '"bob@corp.com" filetype:csv' in out
def test_dominio_genera_site_dorks():
"""El tipo dominio produce site: y ficheros expuestos."""
out = build_search_dorks("empresa.com", tipo="dominio")
assert "site:empresa.com" in out
assert "site:empresa.com filetype:xlsx" in out
assert any("login OR admin OR dashboard" in d for d in out)
assert "site:*.empresa.com" in out
def test_usuario_genera_redes_sociales():
"""El tipo usuario busca en github, reddit y por inurl."""
out = build_search_dorks("jdoe", tipo="usuario")
assert '"jdoe"' in out
assert any("github.com" in d for d in out)
assert any("reddit.com" in d for d in out)
assert "inurl:jdoe" in out
def test_extra_domains_acota_con_site():
"""extra_domains añade dorks site:<dominio> con la frase exacta."""
out = build_search_dorks(
"Ana Ruiz", tipo="persona", extra_domains=["uni.edu", "gov.es"]
)
assert 'site:uni.edu "Ana Ruiz"' in out
assert 'site:gov.es "Ana Ruiz"' in out
def test_tipo_desconocido_solo_frase_exacta():
"""Un tipo no reconocido devuelve solo la frase exacta (mas extras)."""
out = build_search_dorks("algo", tipo="otracosa")
assert out[0] == '"algo"'
# Sin extras y tipo desconocido, solo la frase base.
assert out == ['"algo"']
@@ -0,0 +1,58 @@
---
name: dns_records
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "def dns_records(dominio: str, types: list | None = None) -> dict"
description: "Recoleccion OSINT pasiva de registros DNS de un dominio ejecutando `dig +short <tipo> <dominio>` por subprocess para cada tipo (default A, AAAA, MX, TXT, NS, CNAME). Parsea la salida (una linea por valor) y devuelve un dict {tipo: [valores]}. Pasivo: solo consulta DNS publico."
tags: [osint-passive, dns, recon, cybersecurity, dig]
params:
- name: dominio
desc: "Dominio a resolver, ej. organic-machine.com. Vacio lanza RuntimeError."
- name: types
desc: "Lista de tipos de registro DNS (ej. ['A','MX']). None usa los 6 defaults: A, AAAA, MX, TXT, NS, CNAME."
output: "dict {tipo: [valores]} con una clave por tipo consultado; cada valor es la lista de lineas devueltas por `dig +short` para ese tipo (lista vacia si no hay registro o el dominio no existe)."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_parsea_salida_de_dig", "test_dominio_inexistente_listas_vacias", "test_usa_tipos_default", "test_timeout_devuelve_lista_vacia", "test_dominio_vacio_lanza_error"]
test_file_path: "python/functions/cybersecurity/dns_records_test.py"
file_path: "python/functions/cybersecurity/dns_records.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity import dns_records
# Resolver todos los tipos por defecto
records = dns_records("organic-machine.com")
print(records["A"]) # ['135.125.201.30']
print(records["MX"]) # ['10 mail.organic-machine.com.', ...]
# Solo los tipos que interesan
solo_a_mx = dns_records("organic-machine.com", types=["A", "MX"])
```
## Cuando usarla
Usala al iniciar el reconocimiento pasivo de un dominio para mapear su
infraestructura DNS publica (IPs, servidores de correo, nameservers, TXT con
SPF/DKIM/verificaciones). Es el primer paso barato antes de enumerar
subdominios o consultar RDAP.
## Gotchas
- Requiere el binario `dig` en PATH (paquete `dnsutils`/`bind-utils`). Si falta, lanza `RuntimeError`.
- Cada consulta tiene timeout de 10s; si una expira, esa clave queda como lista vacia y el resto continua.
- La salida es la de `dig +short` cruda: los MX incluyen prioridad ("10 mail..."), los nombres pueden venir con punto final (FQDN), y los TXT entre comillas. No se normaliza para mantener fidelidad.
- Un dominio inexistente o sin un registro concreto devuelve lista vacia (no error): distingue "sin datos" mirando las listas vacias.
- Resuelve contra el resolver configurado en el sistema; resultados pueden variar segun el DNS recursivo usado.
@@ -0,0 +1,63 @@
"""Recoleccion OSINT pasiva de registros DNS via el binario `dig`.
Funcion IMPURA: ejecuta `dig +short <tipo> <dominio>` como subprocess para
cada tipo de registro y parsea la salida (una linea por valor). Es OSINT
pasivo: consulta DNS publico, no envia trafico intrusivo al objetivo.
"""
import subprocess
DEFAULT_TYPES = ["A", "AAAA", "MX", "TXT", "NS", "CNAME"]
def dns_records(dominio: str, types: list | None = None) -> dict:
"""Resuelve registros DNS de un dominio ejecutando `dig +short`.
Para cada tipo en ``types`` ejecuta ``dig +short <tipo> <dominio>`` y
parsea la salida: cada linea no vacia es un valor del registro. Un
dominio inexistente (o un registro ausente) produce una lista vacia.
Args:
dominio: Dominio a resolver (ej. ``"organic-machine.com"``).
types: Lista de tipos de registro DNS (ej. ``["A", "MX"]``). Si es
None usa los defaults: A, AAAA, MX, TXT, NS, CNAME.
Returns:
Dict ``{tipo: [valores]}`` con una clave por tipo consultado. Cada
valor es la lista de lineas devueltas por dig para ese tipo.
Raises:
RuntimeError: Si el binario `dig` no esta instalado o el dominio
esta vacio.
"""
if not dominio or not dominio.strip():
raise RuntimeError("dns_records: dominio vacio")
query_types = types if types is not None else list(DEFAULT_TYPES)
result: dict = {}
for record_type in query_types:
try:
proc = subprocess.run(
["dig", "+short", record_type, dominio],
capture_output=True,
text=True,
timeout=10.0,
)
except FileNotFoundError as e:
raise RuntimeError(
"dns_records: binario `dig` no encontrado en PATH"
) from e
except subprocess.TimeoutExpired:
# Timeout en una consulta concreta: dejamos lista vacia y seguimos.
result[record_type] = []
continue
values = [
line.strip()
for line in proc.stdout.splitlines()
if line.strip()
]
result[record_type] = values
return result
@@ -0,0 +1,90 @@
"""Tests para dns_records."""
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(__file__))
from dns_records import dns_records
class _FakeProc:
def __init__(self, stdout: str):
self.stdout = stdout
self.returncode = 0
def test_parsea_salida_de_dig(monkeypatch):
"""Cada linea no vacia de dig se convierte en un valor del registro."""
fixtures = {
"A": "135.125.201.30\n",
"MX": "10 mail.organic-machine.com.\n20 mail2.organic-machine.com.\n",
"TXT": '"v=spf1 -all"\n',
}
def fake_run(cmd, **kwargs):
record_type = cmd[2]
return _FakeProc(fixtures.get(record_type, ""))
monkeypatch.setattr(subprocess, "run", fake_run)
result = dns_records("organic-machine.com", types=["A", "MX", "TXT"])
assert result["A"] == ["135.125.201.30"]
assert result["MX"] == [
"10 mail.organic-machine.com.",
"20 mail2.organic-machine.com.",
]
assert result["TXT"] == ['"v=spf1 -all"']
def test_dominio_inexistente_listas_vacias(monkeypatch):
"""Salida vacia de dig (dominio inexistente) produce listas vacias."""
def fake_run(cmd, **kwargs):
return _FakeProc("")
monkeypatch.setattr(subprocess, "run", fake_run)
result = dns_records("nope-no-existe-xyz.invalid", types=["A", "AAAA"])
assert result == {"A": [], "AAAA": []}
def test_usa_tipos_default(monkeypatch):
"""Sin types consulta los 6 tipos por defecto."""
consultados = []
def fake_run(cmd, **kwargs):
consultados.append(cmd[2])
return _FakeProc("")
monkeypatch.setattr(subprocess, "run", fake_run)
result = dns_records("organic-machine.com")
assert set(result.keys()) == {"A", "AAAA", "MX", "TXT", "NS", "CNAME"}
assert consultados == ["A", "AAAA", "MX", "TXT", "NS", "CNAME"]
def test_timeout_devuelve_lista_vacia(monkeypatch):
"""Un timeout en una consulta deja lista vacia y no aborta el resto."""
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, 10.0)
monkeypatch.setattr(subprocess, "run", fake_run)
result = dns_records("organic-machine.com", types=["A"])
assert result == {"A": []}
def test_dominio_vacio_lanza_error():
"""Dominio vacio lanza RuntimeError."""
try:
dns_records("")
assert False, "deberia haber lanzado RuntimeError"
except RuntimeError:
pass
@@ -0,0 +1,52 @@
---
name: enrich_org_passive
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "def enrich_org_passive(dominio: str) -> dict"
description: "Orquestador OSINT pasivo: perfil de una organizacion por su dominio usando solo fuentes publicas. Compone whois_lookup (registro WHOIS), dns_records (registros DNS) y enum_subdomains_crtsh (subdominios via Certificate Transparency / crt.sh). Devuelve whois + dns + subdomains."
tags: [osint-enrich, osint-passive, cybersecurity, org, whois, dns, subdomains]
uses_functions: [whois_lookup_py_cybersecurity, dns_records_py_cybersecurity, enum_subdomains_crtsh_py_cybersecurity]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: dominio
desc: "dominio de la organizacion, p.ej. organic-machine.com. No puede estar vacio (ValueError). Se hace strip de espacios"
output: "dict con whois (salida de whois_lookup(dominio)), dns (salida de dns_records(dominio)) y subdomains (salida de enum_subdomains_crtsh(dominio))"
tested: true
tests: ["test_golden_compone_whois_dns_subdomains", "test_dominio_vacio_lanza", "test_dominio_con_espacios_se_normaliza"]
test_file_path: "python/functions/cybersecurity/enrich_org_passive_test.py"
file_path: "python/functions/cybersecurity/enrich_org_passive.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity import enrich_org_passive
res = enrich_org_passive("organic-machine.com")
print(res["whois"]["registrar"]) # registrar segun WHOIS
print(res["dns"].get("A")) # registros A del dominio
print(res["subdomains"][:5]) # primeros subdominios vistos en crt.sh
```
## Cuando usarla
- Cuando arrancas la ficha OSINT de una organizacion y quieres su perfil pasivo (quien registro el dominio, su DNS y su superficie de subdominios) sin enviar trafico ofensivo a la infraestructura.
- Como reconocimiento previo, completamente pasivo, antes de cualquier evaluacion activa (que requeriria otra autorizacion explicita).
- Para mapear de un golpe la huella publica de un dominio: WHOIS + DNS + Certificate Transparency en una sola llamada.
## Gotchas
- **Uso solo para investigacion autorizada.** El reconocimiento de infraestructura ajena debe contar con permiso del propietario o ampararse en una base legitima.
- Funcion IMPURA: hace consultas de red (WHOIS, DNS y HTTP a crt.sh). Es pasiva respecto al objetivo (no le envia trafico ofensivo), pero **deja huella** en los servicios consultados (logs de crt.sh, resolvers DNS, servidores WHOIS).
- La salida depende de la disponibilidad y rate-limiting de las fuentes: WHOIS puede venir truncado/redactado (GDPR), crt.sh puede limitar o tardar, y algunos registros DNS pueden no existir. Maneja claves ausentes en el dict resultante.
- crt.sh solo ve subdominios que hayan emitido certificados TLS publicos: la lista NO es exhaustiva (no incluye subdominios sin cert o con certs privados).
@@ -0,0 +1,47 @@
"""Orquestador OSINT pasivo: perfil de una organizacion por su dominio.
Compone funciones atomicas del registro (`whois_lookup`, `dns_records`,
`enum_subdomains_crtsh`) para construir un perfil pasivo de una organizacion a
partir de su dominio, usando solo fuentes publicas (WHOIS, DNS, Certificate
Transparency via crt.sh) sin contactar directamente con la infraestructura del
objetivo mas alla de las consultas DNS estandar.
Funcion IMPURA: hace consultas de red (WHOIS, DNS, HTTP a crt.sh).
"""
from cybersecurity import dns_records, enum_subdomains_crtsh, whois_lookup
def enrich_org_passive(dominio: str) -> dict:
"""Construye un perfil OSINT pasivo de una organizacion por su dominio.
Agrega tres fuentes publicas: el registro WHOIS del dominio, sus registros
DNS y los subdominios observados en Certificate Transparency (crt.sh).
Args:
dominio: dominio de la organizacion, p.ej. `organic-machine.com`.
Returns:
dict con las claves:
- whois: salida de whois_lookup(dominio).
- dns: salida de dns_records(dominio).
- subdomains: salida de enum_subdomains_crtsh(dominio).
Raises:
ValueError: si el dominio esta vacio.
"""
if not (dominio or "").strip():
raise ValueError("dominio no puede estar vacio")
dominio = dominio.strip()
# Resiliente a fallo parcial: si una fuente externa falla (p.ej. crt.sh lento o
# rate-limitado), se registra el error y se devuelven las demas igualmente.
result = {"whois": {}, "dns": {}, "subdomains": [], "errors": {}}
for key, fn in (("whois", whois_lookup), ("dns", dns_records),
("subdomains", enum_subdomains_crtsh)):
try:
result[key] = fn(dominio)
except Exception as exc: # noqa: BLE001 — captura amplia a proposito por fuente
result["errors"][key] = f"{type(exc).__name__}: {exc}"
return result
@@ -0,0 +1,87 @@
"""Tests para enrich_org_passive.
Las funciones compuestas (whois_lookup, dns_records, enum_subdomains_crtsh) se
monkeypatchean para no tocar la red. Solo se verifica la orquestacion: que
ensambla las tres fuentes bajo las claves correctas y normaliza el dominio.
"""
import importlib
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from cybersecurity import enrich_org_passive
# El paquete re-exporta la funcion bajo el mismo nombre que el submodulo; para
# parchear los globals del modulo lo tomamos via importlib.
mod = importlib.import_module("cybersecurity.enrich_org_passive")
def test_golden_compone_whois_dns_subdomains(monkeypatch):
monkeypatch.setattr(mod, "whois_lookup", lambda d: {"domain": d, "registrar": "OVH"})
monkeypatch.setattr(mod, "dns_records", lambda d: {"A": ["1.2.3.4"], "MX": ["mx.x"]})
monkeypatch.setattr(mod, "enum_subdomains_crtsh", lambda d: [f"www.{d}", f"api.{d}"])
res = enrich_org_passive("organic-machine.com")
assert res["whois"] == {"domain": "organic-machine.com", "registrar": "OVH"}
assert res["dns"] == {"A": ["1.2.3.4"], "MX": ["mx.x"]}
assert res["subdomains"] == ["www.organic-machine.com", "api.organic-machine.com"]
assert set(res.keys()) == {"whois", "dns", "subdomains", "errors"}
assert res["errors"] == {} # sin fallos cuando todas las fuentes responden
def test_fuente_que_falla_se_captura_en_errors(monkeypatch):
"""Si una fuente externa peta (p.ej. crt.sh), se registra en errors y el resto se devuelve."""
monkeypatch.setattr(mod, "whois_lookup", lambda d: {"registrar": "OVH"})
monkeypatch.setattr(mod, "dns_records", lambda d: {"A": ["1.2.3.4"]})
def boom(d):
raise RuntimeError("crt.sh 404")
monkeypatch.setattr(mod, "enum_subdomains_crtsh", boom)
res = enrich_org_passive("organic-machine.com")
assert res["whois"] == {"registrar": "OVH"}
assert res["dns"] == {"A": ["1.2.3.4"]}
assert res["subdomains"] == []
assert "subdomains" in res["errors"] and "crt.sh 404" in res["errors"]["subdomains"]
def test_dominio_vacio_lanza(monkeypatch):
monkeypatch.setattr(mod, "whois_lookup", lambda d: {})
monkeypatch.setattr(mod, "dns_records", lambda d: {})
monkeypatch.setattr(mod, "enum_subdomains_crtsh", lambda d: [])
with pytest.raises(ValueError):
enrich_org_passive(" ")
def test_dominio_con_espacios_se_normaliza(monkeypatch):
seen = {}
def whois(d):
seen["whois"] = d
return {}
def dns(d):
seen["dns"] = d
return {}
def subs(d):
seen["subs"] = d
return []
monkeypatch.setattr(mod, "whois_lookup", whois)
monkeypatch.setattr(mod, "dns_records", dns)
monkeypatch.setattr(mod, "enum_subdomains_crtsh", subs)
enrich_org_passive(" organic-machine.com ")
# Las tres funciones reciben el dominio ya sin espacios.
assert seen == {
"whois": "organic-machine.com",
"dns": "organic-machine.com",
"subs": "organic-machine.com",
}
@@ -0,0 +1,64 @@
---
name: enrich_person_passive
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "def enrich_person_passive(nombre: str, apellidos: str, dominios: list | None = None, usernames: list | None = None) -> dict"
description: "Orquestador OSINT pasivo: genera candidatos de enriquecimiento de una persona SIN tocar al objetivo. Compone guess_email_formats (emails candidatos por cada dominio dado, o gmail/outlook por defecto), enumerate_username_sites (comprobacion de usernames en servicios publicos) y build_search_dorks (dorks tipo persona, que NO se ejecutan, solo se generan)."
tags: [osint-enrich, osint-passive, cybersecurity, person, email, username, dorks]
uses_functions: [guess_email_formats_py_cybersecurity, enumerate_username_sites_py_cybersecurity, build_search_dorks_py_cybersecurity]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: nombre
desc: "nombre de pila de la persona"
- name: apellidos
desc: "apellido(s) de la persona. nombre y apellidos no pueden estar ambos vacios (ValueError)"
- name: dominios
desc: "lista de dominios de correo donde generar formatos de email candidatos. None o vacia => usa los dominios comunes gmail.com/outlook.com"
- name: usernames
desc: "lista de usernames a comprobar en sitios publicos via enumerate_username_sites. None o vacia => no se comprueba ningun username"
output: "dict con email_candidates (lista de emails candidatos NO verificados, deduplicada y ordenada), username_hits (lista de {username, hits} con el resultado de enumerate_username_sites por username) y dorks (lista de dorks de busqueda tipo persona generados pero NO ejecutados)"
tested: true
tests: ["test_golden_compone_emails_usernames_y_dorks", "test_sin_dominios_usa_comunes", "test_sin_usernames_no_comprueba", "test_nombre_y_apellidos_vacios_lanza", "test_emails_deduplicados"]
test_file_path: "python/functions/cybersecurity/enrich_person_passive_test.py"
file_path: "python/functions/cybersecurity/enrich_person_passive.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity import enrich_person_passive
res = enrich_person_passive(
"Juan", "Perez",
dominios=["organic-machine.com"],
usernames=["jperez", "juanp"],
)
print(res["email_candidates"]) # ['juan.perez@organic-machine.com', 'jperez@organic-machine.com', ...]
for u in res["username_hits"]:
print(u["username"], len(u["hits"])) # sitios publicos donde aparece cada username
print(res["dorks"]) # dorks listos para pegar en un buscador (no se ejecutan aqui)
```
## Cuando usarla
- Cuando arrancas la ficha OSINT de una persona y quieres una primera tanda de candidatos (emails probables, presencia de usernames, dorks) sin enviar nada al objetivo ni a su correo.
- Antes de verificar manualmente: genera el espacio de candidatos para que despues confirmes cuales son reales.
- Como paso pasivo previo a cualquier accion activa (que requeriria otra autorizacion y otras funciones).
## Gotchas
- **Uso solo para investigacion autorizada.** Generar candidatos sobre una persona sin base legitima puede vulnerar privacidad/leyes de proteccion de datos.
- Los `email_candidates` son **candidatos NO verificados**: son permutaciones plausibles del nombre, NO emails confirmados. No asumas que existen ni los uses para envio.
- Funcion IMPURA: `enumerate_username_sites` consulta servicios publicos por red, lo que **deja una huella minima** (requests a esos sitios). `build_search_dorks` y `guess_email_formats` son locales.
- Los dorks se **generan pero NO se ejecutan** aqui: ejecutarlos en un buscador es un paso aparte y deja su propia huella.
- Si no aportas `dominios`, se usan gmail.com/outlook.com como heuristica; ajusta la lista a los dominios reales del entorno de la persona para candidatos utiles.
@@ -0,0 +1,79 @@
"""Orquestador OSINT pasivo: genera candidatos de enriquecimiento de una persona.
Compone funciones atomicas del registro (`guess_email_formats`,
`enumerate_username_sites`, `build_search_dorks`) para producir candidatos OSINT
de una persona SIN contactar ni atacar al objetivo. Los dorks se generan pero
NO se ejecutan.
Funcion IMPURA: `enumerate_username_sites` consulta servicios publicos (red).
"""
from cybersecurity import (
build_search_dorks,
enumerate_username_sites,
guess_email_formats,
)
# Dominios de correo comunes usados cuando el caller no aporta dominios propios.
_COMMON_EMAIL_DOMAINS = ("gmail.com", "outlook.com")
def enrich_person_passive(
nombre: str,
apellidos: str,
dominios: list | None = None,
usernames: list | None = None,
) -> dict:
"""Genera candidatos OSINT pasivos para una persona.
Para cada dominio aportado (o los dominios comunes gmail/outlook si no se da
ninguno) genera los formatos de email candidatos. Para cada username
aportado comprueba en que sitios publicos existe. Ademas genera dorks de
busqueda de tipo persona (que NO se ejecutan, solo se devuelven).
Args:
nombre: nombre de pila de la persona.
apellidos: apellido(s) de la persona.
dominios: lista de dominios de correo donde buscar formatos de email.
Si es None o vacia, se usan los dominios comunes gmail/outlook.
usernames: lista de usernames a comprobar en sitios publicos. Si es None
o vacia, no se realiza ninguna comprobacion de username.
Returns:
dict con las claves:
- email_candidates: lista de emails candidatos (no verificados).
- username_hits: lista de resultados de enumerate_username_sites por
cada username comprobado.
- dorks: lista de dorks de busqueda generados (no ejecutados).
Raises:
ValueError: si nombre y apellidos estan ambos vacios.
"""
if not (nombre or "").strip() and not (apellidos or "").strip():
raise ValueError("nombre y apellidos no pueden estar ambos vacios")
target_domains = [d for d in (dominios or []) if d] or list(_COMMON_EMAIL_DOMAINS)
email_candidates: list = []
for dominio in target_domains:
candidates = guess_email_formats(nombre, apellidos, dominio)
if candidates:
email_candidates.extend(candidates)
# Deduplicar preservando orden.
email_candidates = list(dict.fromkeys(email_candidates))
username_hits: list = []
for username in usernames or []:
if not username:
continue
hits = enumerate_username_sites(username)
username_hits.append({"username": username, "hits": hits})
target = f"{nombre} {apellidos}".strip()
dorks = build_search_dorks(target, "persona")
return {
"email_candidates": email_candidates,
"username_hits": username_hits,
"dorks": dorks,
}
@@ -0,0 +1,97 @@
"""Tests para enrich_person_passive.
Las funciones compuestas (guess_email_formats, enumerate_username_sites,
build_search_dorks) se monkeypatchean. Solo se verifica la orquestacion: que
itera por dominios/usernames, deduplica emails, usa dominios comunes por
defecto y genera (sin ejecutar) los dorks.
"""
import importlib
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from cybersecurity import enrich_person_passive
# El paquete re-exporta la funcion bajo el mismo nombre que el submodulo; para
# parchear los globals del modulo lo tomamos via importlib.
mod = importlib.import_module("cybersecurity.enrich_person_passive")
def _patch(monkeypatch, *, emails_fn=None, usernames_fn=None, dorks_fn=None):
monkeypatch.setattr(
mod,
"guess_email_formats",
emails_fn or (lambda n, a, d: [f"{n.lower()}.{a.lower()}@{d}", f"{n[0].lower()}{a.lower()}@{d}"]),
)
monkeypatch.setattr(
mod,
"enumerate_username_sites",
usernames_fn or (lambda u: [{"site": "github", "url": f"https://github.com/{u}", "exists": True}]),
)
monkeypatch.setattr(
mod,
"build_search_dorks",
dorks_fn or (lambda target, tipo: [f'"{target}" {tipo}', f'intext:"{target}"']),
)
def test_golden_compone_emails_usernames_y_dorks(monkeypatch):
_patch(monkeypatch)
res = enrich_person_passive(
"Juan", "Perez",
dominios=["organic-machine.com"],
usernames=["jperez", "juanp"],
)
assert res["email_candidates"] == [
"juan.perez@organic-machine.com",
"jperez@organic-machine.com",
]
assert [u["username"] for u in res["username_hits"]] == ["jperez", "juanp"]
assert res["username_hits"][0]["hits"][0]["site"] == "github"
assert res["dorks"] == ['"Juan Perez" persona', 'intext:"Juan Perez"']
def test_sin_dominios_usa_comunes(monkeypatch):
seen_domains = []
def emails(n, a, d):
seen_domains.append(d)
return [f"{n}@{d}"]
_patch(monkeypatch, emails_fn=emails)
res = enrich_person_passive("Ana", "Lopez")
assert seen_domains == ["gmail.com", "outlook.com"]
assert res["email_candidates"] == ["Ana@gmail.com", "Ana@outlook.com"]
def test_sin_usernames_no_comprueba(monkeypatch):
called = []
_patch(monkeypatch, usernames_fn=lambda u: called.append(u) or [])
res = enrich_person_passive("Ana", "Lopez", dominios=["x.com"])
assert res["username_hits"] == []
assert called == [] # enumerate_username_sites no se invoca
def test_nombre_y_apellidos_vacios_lanza(monkeypatch):
_patch(monkeypatch)
with pytest.raises(ValueError):
enrich_person_passive("", " ")
def test_emails_deduplicados(monkeypatch):
# Dos dominios distintos que devuelven el mismo email -> debe deduplicar.
_patch(monkeypatch, emails_fn=lambda n, a, d: ["dup@same.com", "dup@same.com"])
res = enrich_person_passive("Juan", "Perez", dominios=["a.com", "b.com"])
assert res["email_candidates"] == ["dup@same.com"]
@@ -0,0 +1,66 @@
---
name: enum_subdomains_crtsh
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "def enum_subdomains_crtsh(dominio: str, timeout_s: float = 20.0) -> list"
description: "Enumeracion OSINT pasiva de subdominios desde Certificate Transparency (crt.sh). Consulta https://crt.sh/?q=%25.<dominio>&output=json con http_get_json, extrae name_value de cada certificado, separa por saltos de linea, deduplica, filtra wildcards (*.) y devuelve la lista ordenada de subdominios unicos. Pasivo: no toca al objetivo, consulta logs CT publicos."
tags: [osint-passive, subdomains, crtsh, certificate-transparency, recon, cybersecurity]
params:
- name: dominio
desc: "Dominio base a enumerar, ej. organic-machine.com. Vacio lanza RuntimeError."
- name: timeout_s
desc: "Segundos maximo de espera de la peticion HTTP a crt.sh (default 20.0)."
output: "Lista ordenada de subdominios unicos (en minusculas, sin wildcards) que aparecen en certificados emitidos para el dominio. Lista vacia si crt.sh no devuelve resultados."
uses_functions: ["http_get_json_py_infra"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_dedup_y_orden", "test_filtra_wildcards", "test_respuesta_vacia", "test_respuesta_no_lista_lanza_error", "test_dominio_vacio_lanza_error"]
test_file_path: "python/functions/cybersecurity/enum_subdomains_crtsh_test.py"
file_path: "python/functions/cybersecurity/enum_subdomains_crtsh.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity import enum_subdomains_crtsh
subs = enum_subdomains_crtsh("organic-machine.com")
for s in subs:
print(s)
# api.organic-machine.com
# mail.organic-machine.com
# organic-machine.com
# www.organic-machine.com
```
## Cuando usarla
Usala para descubrir la superficie de subdominios de un objetivo sin enviarle
trafico: los logs de Certificate Transparency listan todos los nombres para
los que se han emitido certificados TLS. Complementa a `dns_records`
(resolucion) y `whois_lookup` (registro) en el reconocimiento pasivo inicial.
## Gotchas
- Es OSINT **pasivo**: consulta los logs CT publicos via crt.sh, NO toca al
dominio objetivo ni resuelve los subdominios encontrados (algunos pueden
estar muertos o no resolver).
- crt.sh suele ir lento o rate-limitear bajo carga; para dominios grandes la
respuesta puede tardar varios segundos o agotar el `timeout_s`. Subir el
timeout o reintentar si falla.
- Solo encuentra subdominios que han tenido certificado TLS emitido y logueado
en CT; subdominios internos sin certificado publico no apareceran.
- Los wildcards (`*.dominio`) se filtran porque no son hosts concretos.
- crt.sh devuelve un array JSON (no un objeto); por eso si la respuesta no es
una lista se lanza `RuntimeError`.
- Puede incluir subdominios de niveles arbitrarios y dominios relacionados que
compartieron certificado SAN; revisa la salida antes de usarla como verdad.
@@ -0,0 +1,68 @@
"""Enumeracion OSINT pasiva de subdominios via Certificate Transparency (crt.sh).
Funcion IMPURA: consulta los logs publicos de Certificate Transparency a
traves de crt.sh y extrae los subdominios que han aparecido en certificados
emitidos para el dominio. Es OSINT pasivo: no toca al dominio objetivo, solo
consulta registros CT publicos.
"""
import os
import sys
sys.path.insert(
0, os.path.join(os.path.dirname(__file__), "..", "..", "functions")
)
from infra.http_get_json import http_get_json # noqa: E402
def enum_subdomains_crtsh(dominio: str, timeout_s: float = 20.0) -> list:
"""Enumera subdominios de un dominio desde Certificate Transparency.
Consulta ``https://crt.sh/?q=%25.<dominio>&output=json`` (``%25`` = ``%``,
el wildcard de crt.sh) usando ``http_get_json`` del registry. crt.sh
devuelve un array JSON de certificados; de cada uno se toma el campo
``name_value`` (que puede contener varios nombres separados por saltos de
linea, uno por SAN). Se separan, deduplican, se filtran los wildcards
(``*.``) y se devuelve la lista ordenada de subdominios unicos.
Args:
dominio: Dominio base a enumerar (ej. ``"organic-machine.com"``).
timeout_s: Segundos maximo de espera de la peticion HTTP (default 20).
Returns:
Lista ordenada de subdominios unicos (sin wildcards). Lista vacia si
crt.sh no devuelve resultados.
Raises:
RuntimeError: Si el dominio esta vacio o la peticion HTTP falla.
"""
if not dominio or not dominio.strip():
raise RuntimeError("enum_subdomains_crtsh: dominio vacio")
dom = dominio.strip().lower()
# %25 es el wildcard '%' de crt.sh: busca cualquier nombre que termine en .<dominio>
url = f"https://crt.sh/?q=%25.{dom}&output=json"
data = http_get_json(url, timeout=timeout_s)
if not isinstance(data, list):
raise RuntimeError(
f"enum_subdomains_crtsh: respuesta crt.sh inesperada "
f"(tipo {type(data).__name__})"
)
found: set = set()
for cert in data:
if not isinstance(cert, dict):
continue
name_value = cert.get("name_value", "")
for raw_name in name_value.split("\n"):
name = raw_name.strip().lower()
if not name:
continue
if name.startswith("*."):
continue
found.add(name)
return sorted(found)
@@ -0,0 +1,81 @@
"""Tests para enum_subdomains_crtsh."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
import enum_subdomains_crtsh as esc
from enum_subdomains_crtsh import enum_subdomains_crtsh
def _crtsh_sample() -> list:
# crt.sh devuelve un array; name_value puede traer varios SAN separados
# por '\n', con duplicados y wildcards.
return [
{"name_value": "www.organic-machine.com\norganic-machine.com"},
{"name_value": "api.organic-machine.com"},
{"name_value": "www.organic-machine.com"}, # duplicado
{"name_value": "*.organic-machine.com"}, # wildcard, se filtra
{"name_value": "MAIL.Organic-Machine.com"}, # case distinto
]
def test_dedup_y_orden(monkeypatch):
"""Subdominios deduplicados, en minusculas y ordenados."""
monkeypatch.setattr(
esc, "http_get_json", lambda url, timeout=20.0: _crtsh_sample()
)
result = enum_subdomains_crtsh("organic-machine.com")
assert result == [
"api.organic-machine.com",
"mail.organic-machine.com",
"organic-machine.com",
"www.organic-machine.com",
]
def test_filtra_wildcards(monkeypatch):
"""Las entradas '*.dominio' se descartan."""
monkeypatch.setattr(
esc,
"http_get_json",
lambda url, timeout=20.0: [{"name_value": "*.organic-machine.com"}],
)
result = enum_subdomains_crtsh("organic-machine.com")
assert result == []
def test_respuesta_vacia(monkeypatch):
"""crt.sh sin resultados devuelve lista vacia."""
monkeypatch.setattr(esc, "http_get_json", lambda url, timeout=20.0: [])
result = enum_subdomains_crtsh("organic-machine.com")
assert result == []
def test_respuesta_no_lista_lanza_error(monkeypatch):
"""Una respuesta que no es lista lanza RuntimeError."""
monkeypatch.setattr(
esc, "http_get_json", lambda url, timeout=20.0: {"unexpected": "obj"}
)
try:
enum_subdomains_crtsh("organic-machine.com")
assert False, "deberia haber lanzado RuntimeError"
except RuntimeError:
pass
def test_dominio_vacio_lanza_error():
"""Dominio vacio lanza RuntimeError."""
try:
enum_subdomains_crtsh("")
assert False, "deberia haber lanzado RuntimeError"
except RuntimeError:
pass
@@ -0,0 +1,60 @@
---
name: enumerate_username_sites
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "def enumerate_username_sites(username: str, timeout_s: float = 8.0, sites: list | None = None) -> list"
description: "Comprueba si un username existe en ~12 sitios publicos (github, twitter/x, instagram, tiktok, reddit, gitlab, keybase, medium, telegram, youtube, pinterest, about.me) consultando la URL de perfil estilo sherlock ligero. Detecta por codigo HTTP (200 existe, 404 no existe). Cada sitio se consulta aislado en try/except con User-Agent de navegador y allow_redirects: un timeout no aborta el resto. OSINT pasivo."
tags: [osint-passive, username, enumeration, recon, identity, sherlock, cybersecurity, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests]
params:
- name: username
desc: "Nombre de usuario a buscar, sin arroba ni URL (ej. 'jdoe')."
- name: timeout_s
desc: "Timeout en segundos por peticion HTTP. Default 8.0."
- name: sites
desc: "Lista opcional de dicts {'site','url'} donde 'url' lleva el placeholder '{u}'. Si es None usa DEFAULT_SITES (~12 sitios)."
output: "Lista de dicts {'site','url','exists','status'} en el orden de los sitios. 'exists' es True/False/None y 'status' el codigo HTTP (int) o None si la peticion fallo."
tested: true
tests:
- "test_status_200_es_existe"
- "test_status_404_es_no_existe"
- "test_status_otro_es_indeterminado"
- "test_excepcion_por_sitio_no_aborta_el_resto"
- "test_estructura_y_url_formateada"
- "test_lista_por_defecto_tiene_doce_sitios"
test_file_path: "python/functions/cybersecurity/enumerate_username_sites_test.py"
file_path: "python/functions/cybersecurity/enumerate_username_sites.py"
---
## Ejemplo
```python
enumerate_username_sites("torvalds", timeout_s=8.0)
# [{"site": "github", "url": "https://github.com/torvalds", "exists": True, "status": 200},
# {"site": "twitter", "url": "https://x.com/torvalds", "exists": None, "status": 403},
# {"site": "instagram","url": "https://www.instagram.com/torvalds/","exists": False, "status": 404},
# ...]
# Lista propia de sitios:
enumerate_username_sites("jdoe", sites=[{"site": "github", "url": "https://github.com/{u}"}])
```
## Cuando usarla
Usala cuando tengas un username (o alias) y quieras un barrido rapido de presencia en redes/plataformas publicas para mapear la huella digital de un objetivo en una investigacion autorizada. Util tras `guess_email_formats` para pivotar de identidad a perfiles, o como entrada para construir dorks con `build_search_dorks`.
## Gotchas
- **Deja huella**: aunque es recoleccion "pasiva" desde el punto de vista del objetivo, lanza una peticion HTTP real a cada sitio. Esas peticiones quedan en logs del sitio y pueden asociarse a tu IP. Usa proxy/VPN si la investigacion lo requiere.
- **Falsos positivos/negativos por anti-bot**: muchos sitios (instagram, tiktok, x) devuelven 200 con paginas de login/captcha o bloquean por User-Agent, dando exists=True erroneo o status indeterminado. El 200/404 no es garantia; verifica manualmente los hits relevantes.
- **Respeta rate limits**: lanzar muchas comprobaciones seguidas puede activar bloqueos o baneos temporales por IP. Espacia las consultas en barridos grandes.
- **Estados intermedios**: cualquier codigo distinto de 200/404 (301, 403, 429, 5xx) deja `exists=None`; un fallo de red por sitio deja `status=None` y `exists=None` sin abortar el resto.
- **Solo para investigacion autorizada.** No uses esta funcion para acoso, doxing ni vigilancia sin base legal.
@@ -0,0 +1,91 @@
"""Comprueba la existencia de un username en sitios publicos (sherlock ligero).
OSINT pasivo: para un username dado, consulta la URL de perfil de una lista
de sitios conocidos y deduce si la cuenta existe por el codigo de estado
HTTP (200 = existe, 404 = no existe). Cada sitio se consulta de forma
aislada: un fallo (timeout, error de red) no aborta el resto.
"""
import requests
# Cada entrada describe un sitio: como construir la URL de perfil a partir
# del username. La deteccion es por codigo de estado (200 existe / 404 no).
DEFAULT_SITES = [
{"site": "github", "url": "https://github.com/{u}"},
{"site": "twitter", "url": "https://x.com/{u}"},
{"site": "instagram", "url": "https://www.instagram.com/{u}/"},
{"site": "tiktok", "url": "https://www.tiktok.com/@{u}"},
{"site": "reddit", "url": "https://www.reddit.com/user/{u}"},
{"site": "gitlab", "url": "https://gitlab.com/{u}"},
{"site": "keybase", "url": "https://keybase.io/{u}"},
{"site": "medium", "url": "https://medium.com/@{u}"},
{"site": "telegram", "url": "https://t.me/{u}"},
{"site": "youtube", "url": "https://www.youtube.com/@{u}"},
{"site": "pinterest", "url": "https://www.pinterest.com/{u}/"},
{"site": "about_me", "url": "https://about.me/{u}"},
]
_BROWSER_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
def enumerate_username_sites(
username: str,
timeout_s: float = 8.0,
sites: list | None = None,
) -> list:
"""Comprueba si un username existe en una lista de sitios publicos.
Para cada sitio construye la URL de perfil con el username y hace un
GET con User-Agent de navegador siguiendo redirecciones. Interpreta el
codigo de estado final: 200 -> existe, 404 -> no existe, cualquier otro
-> indeterminado (exists=None). Los errores de red por sitio (timeout,
conexion) se capturan y se reportan con status=None y exists=None sin
interrumpir la enumeracion del resto.
Args:
username: nombre de usuario a buscar (sin arroba ni URL).
timeout_s: timeout en segundos por peticion. Default 8.0.
sites: lista opcional de dicts {"site", "url"} donde "url" contiene
el placeholder "{u}". Si es None se usa DEFAULT_SITES.
Returns:
lista de dicts {"site", "url", "exists", "status"} en el mismo orden
que la lista de sitios. "exists" es True/False/None y "status" es el
codigo HTTP (int) o None si la peticion fallo.
"""
targets = sites if sites is not None else DEFAULT_SITES
headers = {"User-Agent": _BROWSER_UA}
results = []
for entry in targets:
site = entry.get("site", "")
url = entry["url"].format(u=username)
status = None
exists = None
try:
resp = requests.get(
url,
headers=headers,
timeout=timeout_s,
allow_redirects=True,
)
status = resp.status_code
if status == 200:
exists = True
elif status == 404:
exists = False
else:
exists = None
except requests.RequestException:
# Timeout, error de conexion, demasiados redirects, etc.
status = None
exists = None
results.append(
{"site": site, "url": url, "exists": exists, "status": status}
)
return results
@@ -0,0 +1,109 @@
"""Tests para enumerate_username_sites (red mockeada con monkeypatch)."""
import requests
import enumerate_username_sites as mod
from enumerate_username_sites import enumerate_username_sites
class _FakeResp:
"""Respuesta HTTP minima con solo status_code."""
def __init__(self, status_code):
self.status_code = status_code
def _make_get(status_by_url):
"""Construye un fake de requests.get que devuelve status por URL."""
def _fake_get(url, **kwargs):
return _FakeResp(status_by_url[url])
return _fake_get
SITES = [
{"site": "alpha", "url": "https://alpha.test/{u}"},
{"site": "beta", "url": "https://beta.test/{u}"},
{"site": "gamma", "url": "https://gamma.test/{u}"},
]
def test_status_200_es_existe(monkeypatch):
"""Un 200 marca exists=True."""
status_map = {
"https://alpha.test/bob": 200,
"https://beta.test/bob": 200,
"https://gamma.test/bob": 200,
}
monkeypatch.setattr(requests, "get", _make_get(status_map))
out = enumerate_username_sites("bob", sites=SITES)
assert all(r["exists"] is True for r in out)
assert all(r["status"] == 200 for r in out)
def test_status_404_es_no_existe(monkeypatch):
"""Un 404 marca exists=False."""
status_map = {
"https://alpha.test/ghost": 404,
"https://beta.test/ghost": 404,
"https://gamma.test/ghost": 404,
}
monkeypatch.setattr(requests, "get", _make_get(status_map))
out = enumerate_username_sites("ghost", sites=SITES)
assert all(r["exists"] is False for r in out)
assert all(r["status"] == 404 for r in out)
def test_status_otro_es_indeterminado(monkeypatch):
"""Un codigo distinto de 200/404 deja exists=None pero conserva status."""
status_map = {
"https://alpha.test/x": 301,
"https://beta.test/x": 403,
"https://gamma.test/x": 500,
}
monkeypatch.setattr(requests, "get", _make_get(status_map))
out = enumerate_username_sites("x", sites=SITES)
assert [r["exists"] for r in out] == [None, None, None]
assert [r["status"] for r in out] == [301, 403, 500]
def test_excepcion_por_sitio_no_aborta_el_resto(monkeypatch):
"""Un fallo de red en un sitio no interrumpe la enumeracion."""
def _fake_get(url, **kwargs):
if "beta" in url:
raise requests.Timeout("simulado")
return _FakeResp(200)
monkeypatch.setattr(requests, "get", _fake_get)
out = enumerate_username_sites("u", sites=SITES)
assert len(out) == 3
by_site = {r["site"]: r for r in out}
assert by_site["alpha"]["exists"] is True
assert by_site["alpha"]["status"] == 200
# El sitio que fallo queda con status/exists None.
assert by_site["beta"]["exists"] is None
assert by_site["beta"]["status"] is None
assert by_site["gamma"]["exists"] is True
def test_estructura_y_url_formateada(monkeypatch):
"""Cada resultado lleva las claves esperadas y la URL con el username."""
status_map = {
"https://alpha.test/neo": 200,
"https://beta.test/neo": 404,
"https://gamma.test/neo": 200,
}
monkeypatch.setattr(requests, "get", _make_get(status_map))
out = enumerate_username_sites("neo", sites=SITES)
for r in out:
assert set(r.keys()) == {"site", "url", "exists", "status"}
assert "neo" in r["url"]
def test_lista_por_defecto_tiene_doce_sitios():
"""La lista DEFAULT_SITES cubre ~12 sitios publicos."""
assert len(mod.DEFAULT_SITES) == 12
sites = {s["site"] for s in mod.DEFAULT_SITES}
assert {"github", "instagram", "reddit", "telegram"} <= sites
@@ -0,0 +1,66 @@
---
name: extract_exif_metadata
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "def extract_exif_metadata(image_path: str) -> dict"
description: "Lee los metadatos EXIF de una imagen con Pillow y los devuelve normalizados (fecha, camara, software, GPS en grados decimales) mas el volcado completo de tags en `raw`. OSINT pasiva sobre documentos propios: revela cuando, con que dispositivo y donde se tomo una foto."
tags: [osint-passive, exif, metadata, image, gps, forensics, pillow, extract, cybersecurity, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [PIL]
params:
- name: image_path
desc: "ruta al archivo de imagen en disco (JPEG, PNG, TIFF, ...)"
output: "dict con datetime, camera_make, camera_model, software, gps_lat, gps_lon (grados decimales o None) y raw (dict con todos los tags EXIF legibles por nombre). Si la imagen no tiene EXIF, los campos van a None y raw={}."
tested: true
tests:
- "imagen sin EXIF (PNG) devuelve campos None y raw vacio"
- "imagen con EXIF devuelve camara, software y fecha"
- "GPSInfo DMS se convierte a grados decimales con signo por hemisferio"
test_file_path: "python/functions/cybersecurity/extract_exif_metadata_test.py"
file_path: "python/functions/cybersecurity/extract_exif_metadata.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity.extract_exif_metadata import extract_exif_metadata
meta = extract_exif_metadata(
"/home/enmanuel/Obsidian/osint/attachments/personas/objetivo_01.jpg"
)
print(meta["datetime"]) # '2024:08:12 19:43:07' (cuando se tomo)
print(meta["camera_model"]) # 'iPhone 13 Pro' (con que dispositivo)
print(meta["gps_lat"], meta["gps_lon"]) # 40.4168 -3.7038 (donde) o None, None
```
## Cuando usarla
Cuando recolectes inteligencia pasiva sobre una imagen propia o de un objetivo
y necesites saber cuando, con que dispositivo y desde donde se capturo, antes de
publicarla o compartirla. Usala tambien para auditar tus propios documentos y
detectar fugas de metadatos (geolocalizacion, modelo de telefono, software de
edicion) antes de subirlos a un sitio publico.
## Gotchas
- Funcion impura: abre el archivo del disco. Lanza si la ruta no existe o no es
una imagen valida (Pillow `UnidentifiedImageError` / `FileNotFoundError`).
- JPEG y HEIC de moviles suelen traer GPS embebido; PNG normalmente no lleva
EXIF y devolvera todos los campos en None con `raw={}`.
- El GPS revela la ubicacion fisica donde se tomo la foto — dato sensible. No
lo loguees ni lo compartas sin consentimiento.
- `DateTimeOriginal` vive en el sub-IFD EXIF, no en el IFD raiz; algunas
imagenes solo tienen `DateTime` (fecha de modificacion del archivo), que se
usa como fallback.
- Las coordenadas se convierten de DMS (grados/minutos/segundos) a grados
decimales y se les aplica el signo segun `GPSLatitudeRef`/`GPSLongitudeRef`
(S y W => negativo).
@@ -0,0 +1,99 @@
"""Extrae metadatos EXIF de una imagen (OSINT pasiva sobre documentos propios)."""
from PIL import ExifTags, Image
# Mapa inverso nombre -> id no hace falta: usamos ExifTags.TAGS (id -> nombre).
_GPS_TAGS = ExifTags.GPSTAGS # id -> nombre para el sub-IFD de GPS.
def _to_degrees(value) -> float | None:
"""Convierte una coordenada GPS en formato DMS (grados, minutos, segundos) a grados decimales.
Pillow devuelve cada componente como un IFDRational o una tupla (num, den).
"""
try:
d, m, s = value
return float(d) + float(m) / 60.0 + float(s) / 3600.0
except (TypeError, ValueError, ZeroDivisionError):
return None
def _extract_gps(gps_info: dict) -> tuple[float | None, float | None]:
"""Devuelve (lat, lon) en grados decimales desde el sub-IFD GPSInfo, o (None, None)."""
if not gps_info:
return None, None
named = {_GPS_TAGS.get(k, k): v for k, v in gps_info.items()}
lat = _to_degrees(named.get("GPSLatitude")) if "GPSLatitude" in named else None
lon = _to_degrees(named.get("GPSLongitude")) if "GPSLongitude" in named else None
if lat is not None and str(named.get("GPSLatitudeRef", "N")).upper() == "S":
lat = -lat
if lon is not None and str(named.get("GPSLongitudeRef", "E")).upper() == "W":
lon = -lon
return lat, lon
def extract_exif_metadata(image_path: str) -> dict:
"""Lee los metadatos EXIF de una imagen y los devuelve normalizados.
Abre la imagen con Pillow y extrae los tags EXIF. Normaliza los campos
mas relevantes para OSINT (fecha, camara, software, GPS) y adjunta el
diccionario completo de tags legibles por nombre en `raw`.
Args:
image_path: ruta al archivo de imagen (JPEG, PNG, TIFF, ...).
Returns:
dict con las claves: datetime, camera_make, camera_model, software,
gps_lat, gps_lon (grados decimales o None) y raw (dict tag->valor).
Si la imagen no tiene EXIF, los campos van a None y raw queda {}.
"""
result = {
"datetime": None,
"camera_make": None,
"camera_model": None,
"software": None,
"gps_lat": None,
"gps_lon": None,
"raw": {},
}
with Image.open(image_path) as img:
exif = img.getexif()
if not exif:
return result
# Tags de nivel raiz por nombre.
raw = {ExifTags.TAGS.get(tag_id, tag_id): value for tag_id, value in exif.items()}
# Sub-IFD EXIF (DateTimeOriginal vive aqui, no en el IFD raiz).
try:
exif_ifd = exif.get_ifd(ExifTags.IFD.Exif)
except (AttributeError, KeyError, ValueError):
exif_ifd = {}
for tag_id, value in exif_ifd.items():
raw[ExifTags.TAGS.get(tag_id, tag_id)] = value
# GPS IFD.
try:
gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo)
except (AttributeError, KeyError, ValueError):
gps_ifd = {}
if gps_ifd:
raw["GPSInfo"] = {_GPS_TAGS.get(k, k): v for k, v in gps_ifd.items()}
result["raw"] = raw
result["datetime"] = raw.get("DateTimeOriginal") or raw.get("DateTime")
result["camera_make"] = raw.get("Make")
result["camera_model"] = raw.get("Model")
result["software"] = raw.get("Software")
lat, lon = _extract_gps(gps_ifd)
result["gps_lat"] = lat
result["gps_lon"] = lon
return result
@@ -0,0 +1,110 @@
"""Tests para extract_exif_metadata."""
import os
import sys
from PIL import Image
from PIL.ExifTags import Base, GPS, IFD
from PIL.TiffImagePlugin import IFDRational
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from cybersecurity.extract_exif_metadata import extract_exif_metadata
def _make_png_without_exif(path: str) -> None:
"""Crea un PNG pequeño sin EXIF."""
Image.new("RGB", (4, 4), (10, 20, 30)).save(path, format="PNG")
def _make_jpeg_with_exif(path: str) -> None:
"""Crea un JPEG pequeño con EXIF de camara/software/fecha."""
img = Image.new("RGB", (4, 4), (200, 100, 50))
exif = img.getexif()
exif[Base.Make.value] = "TestCam"
exif[Base.Model.value] = "Model X"
exif[Base.Software.value] = "PyTestRig 1.0"
exif[Base.DateTime.value] = "2024:08:12 19:43:07"
# DateTimeOriginal vive en el sub-IFD EXIF.
exif_ifd = exif.get_ifd(IFD.Exif)
exif_ifd[Base.DateTimeOriginal.value] = "2024:08:12 19:43:07"
img.save(path, format="JPEG", exif=exif)
def _make_jpeg_with_gps(path: str) -> None:
"""Crea un JPEG con GPSInfo en DMS, hemisferio sur y oeste."""
img = Image.new("RGB", (4, 4), (0, 128, 64))
exif = img.getexif()
gps_ifd = exif.get_ifd(IFD.GPSInfo)
# 40 deg, 25 min, 0.48 seg => 40.41680 grados.
gps_ifd[GPS.GPSLatitude.value] = (
IFDRational(40, 1),
IFDRational(25, 1),
IFDRational(48, 100),
)
gps_ifd[GPS.GPSLatitudeRef.value] = "S" # hemisferio sur => negativo
# 3 deg, 42 min, 13.68 seg => 3.7038 grados.
gps_ifd[GPS.GPSLongitude.value] = (
IFDRational(3, 1),
IFDRational(42, 1),
IFDRational(1368, 100),
)
gps_ifd[GPS.GPSLongitudeRef.value] = "W" # oeste => negativo
img.save(path, format="JPEG", exif=exif)
def test_imagen_sin_exif_png_devuelve_none(tmp_path):
"""imagen sin EXIF (PNG) devuelve campos None y raw vacio."""
p = str(tmp_path / "plain.png")
_make_png_without_exif(p)
meta = extract_exif_metadata(p)
assert meta["datetime"] is None
assert meta["camera_make"] is None
assert meta["camera_model"] is None
assert meta["software"] is None
assert meta["gps_lat"] is None
assert meta["gps_lon"] is None
assert meta["raw"] == {}
def test_imagen_con_exif_devuelve_camara_software_fecha(tmp_path):
"""imagen con EXIF devuelve camara, software y fecha."""
p = str(tmp_path / "withexif.jpg")
_make_jpeg_with_exif(p)
meta = extract_exif_metadata(p)
assert meta["camera_make"] == "TestCam"
assert meta["camera_model"] == "Model X"
assert meta["software"] == "PyTestRig 1.0"
assert meta["datetime"] == "2024:08:12 19:43:07"
assert meta["raw"] # no vacio
def test_gps_dms_a_grados_decimales_con_signo(tmp_path):
"""GPSInfo DMS se convierte a grados decimales con signo por hemisferio."""
p = str(tmp_path / "withgps.jpg")
_make_jpeg_with_gps(p)
meta = extract_exif_metadata(p)
assert meta["gps_lat"] is not None
assert meta["gps_lon"] is not None
# Sur y Oeste => ambos negativos.
assert meta["gps_lat"] < 0
assert meta["gps_lon"] < 0
assert abs(meta["gps_lat"] - (-40.41680)) < 1e-4
assert abs(meta["gps_lon"] - (-3.7038)) < 1e-4
if __name__ == "__main__":
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as d:
test_imagen_sin_exif_png_devuelve_none(Path(d))
test_imagen_con_exif_devuelve_camara_software_fecha(Path(d))
test_gps_dms_a_grados_decimales_con_signo(Path(d))
print("Todos los tests pasaron.")
@@ -0,0 +1,66 @@
---
name: extract_pdf_metadata
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "def extract_pdf_metadata(pdf_path: str) -> dict"
description: "Lee los metadatos del Document Info de un PDF con pypdf (titulo, autor, creador, productor, fechas, numero de paginas) mas el volcado completo en `raw`. OSINT pasiva sobre documentos propios: revela quien y con que software genero el documento. Tolerante a PDFs cifrados (no falla, rellena `error`)."
tags: [osint-passive, pdf, metadata, document, forensics, pypdf, extract, cybersecurity, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [pypdf]
params:
- name: pdf_path
desc: "ruta al archivo PDF en disco"
output: "dict con title, author, creator, producer, creation_date, mod_date (ISO 8601 si parseables, sino valor crudo), num_pages, raw (todo el doc info) y error (None si todo fue bien, mensaje en caso contrario)."
tested: true
tests:
- "PDF con metadatos devuelve titulo, autor y num_pages"
- "PDF sin doc info devuelve campos None sin petar"
- "fechas parseables se devuelven en ISO 8601"
test_file_path: "python/functions/cybersecurity/extract_pdf_metadata_test.py"
file_path: "python/functions/cybersecurity/extract_pdf_metadata.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity.extract_pdf_metadata import extract_pdf_metadata
meta = extract_pdf_metadata(
"/home/enmanuel/Obsidian/osint/attachments/personas/cv_objetivo.pdf"
)
print(meta["author"]) # 'Enmanuel G.' (quien lo creo)
print(meta["producer"]) # 'Microsoft Word 2021' (con que software)
print(meta["creation_date"]) # '2024-03-11T10:22:00+01:00'
print(meta["num_pages"]) # 3
```
## Cuando usarla
Cuando recolectes inteligencia pasiva sobre un PDF propio o de un objetivo y
necesites saber quien lo creo, con que herramienta y cuando. Usala tambien para
auditar tus propios documentos antes de publicarlos: el campo `author` y
`producer` suelen filtrar el nombre real del usuario, la version del software y
la organizacion, datos que no quieres exponer.
## Gotchas
- Funcion impura: abre el archivo del disco. Captura la excepcion en lugar de
propagarla — si el PDF esta corrupto, cifrado o no es un PDF, devuelve el dict
con lo que pudo leer y un mensaje en `error` (no lanza).
- PDFs cifrados: intenta abrir con password vacio (caso comun de "restriccion
de copia"). Si requiere password real, `error` empieza por `encrypted:` y los
campos pueden quedar None.
- Muchas fechas de PDF vienen en formato `D:YYYYMMDDHHmmSS+ZZ`; se convierten a
ISO 8601 cuando pypdf las parsea, sino se devuelven crudas.
- `raw` serializa los valores a string para evitar tipos no JSON-friendly
(IndirectObject, ByteStringObject). Los campos normalizados conservan el
contenido textual.
@@ -0,0 +1,86 @@
"""Extrae metadatos de un PDF con pypdf (OSINT pasiva sobre documentos propios)."""
from pypdf import PdfReader
def _iso_or_raw(reader: PdfReader, getter_name: str, raw_value):
"""Devuelve una fecha en ISO 8601 si pypdf la sabe parsear, sino el valor crudo.
pypdf expone `creation_date`/`modification_date` (datetime) ademas del
string crudo `/CreationDate`/`/ModDate` en formato `D:YYYYMMDDHHmmSS`.
"""
try:
dt = getattr(reader.metadata, getter_name, None)
except Exception:
dt = None
if dt is not None:
try:
return dt.isoformat()
except Exception:
pass
return str(raw_value) if raw_value is not None else None
def extract_pdf_metadata(pdf_path: str) -> dict:
"""Lee los metadatos del Document Info de un PDF.
Abre el PDF con pypdf y extrae los campos estandar del diccionario de
informacion (titulo, autor, creador, productor, fechas) mas el numero de
paginas. Las fechas se devuelven en ISO 8601 cuando son parseables, en su
valor crudo en caso contrario. No falla si el PDF esta cifrado: captura la
excepcion, devuelve lo que pueda y rellena el campo `error`.
Args:
pdf_path: ruta al archivo PDF en disco.
Returns:
dict con las claves: title, author, creator, producer, creation_date,
mod_date, num_pages, raw (dict con todo el doc info) y error (None si
todo fue bien, mensaje de error en caso contrario).
"""
result = {
"title": None,
"author": None,
"creator": None,
"producer": None,
"creation_date": None,
"mod_date": None,
"num_pages": None,
"raw": {},
"error": None,
}
try:
reader = PdfReader(pdf_path)
# PDF cifrado: intentar abrir con password vacio (caso comun).
if getattr(reader, "is_encrypted", False):
try:
reader.decrypt("")
except Exception as exc:
result["error"] = f"encrypted: {exc}"
try:
result["num_pages"] = len(reader.pages)
except Exception as exc:
result["error"] = result["error"] or f"pages: {exc}"
meta = reader.metadata
if meta is not None:
result["title"] = str(meta.title) if meta.title is not None else None
result["author"] = str(meta.author) if meta.author is not None else None
result["creator"] = str(meta.creator) if meta.creator is not None else None
result["producer"] = (
str(meta.producer) if meta.producer is not None else None
)
result["creation_date"] = _iso_or_raw(
reader, "creation_date", meta.get("/CreationDate")
)
result["mod_date"] = _iso_or_raw(
reader, "modification_date", meta.get("/ModDate")
)
result["raw"] = {str(k): str(v) for k, v in meta.items()}
except Exception as exc:
result["error"] = result["error"] or str(exc)
return result
@@ -0,0 +1,92 @@
"""Tests para extract_pdf_metadata."""
import os
import sys
from pypdf import PdfWriter
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from cybersecurity.extract_pdf_metadata import extract_pdf_metadata
def _make_pdf_with_metadata(path: str) -> None:
"""Crea un PDF de 2 paginas con doc info (titulo, autor, fechas)."""
writer = PdfWriter()
writer.add_blank_page(width=200, height=200)
writer.add_blank_page(width=200, height=200)
writer.add_metadata(
{
"/Title": "Documento OSINT",
"/Author": "Enmanuel G.",
"/Creator": "PyTestRig",
"/Producer": "pypdf",
"/CreationDate": "D:20240311102200+01'00'",
"/ModDate": "D:20240312113000+01'00'",
}
)
with open(path, "wb") as fh:
writer.write(fh)
def _make_pdf_without_metadata(path: str) -> None:
"""Crea un PDF de 1 pagina sin doc info."""
writer = PdfWriter()
writer.add_blank_page(width=100, height=100)
with open(path, "wb") as fh:
writer.write(fh)
def test_pdf_con_metadatos_devuelve_titulo_autor_paginas(tmp_path):
"""PDF con metadatos devuelve titulo, autor y num_pages."""
p = str(tmp_path / "withmeta.pdf")
_make_pdf_with_metadata(p)
meta = extract_pdf_metadata(p)
assert meta["error"] is None
assert meta["title"] == "Documento OSINT"
assert meta["author"] == "Enmanuel G."
assert meta["creator"] == "PyTestRig"
assert meta["producer"] == "pypdf"
assert meta["num_pages"] == 2
assert meta["raw"] # no vacio
def test_pdf_sin_doc_info_devuelve_none_sin_petar(tmp_path):
"""PDF sin doc info devuelve campos None sin petar."""
p = str(tmp_path / "nometa.pdf")
_make_pdf_without_metadata(p)
meta = extract_pdf_metadata(p)
assert meta["error"] is None
assert meta["num_pages"] == 1
assert meta["title"] is None
assert meta["author"] is None
def test_fechas_parseables_en_iso_8601(tmp_path):
"""fechas parseables se devuelven en ISO 8601."""
p = str(tmp_path / "dates.pdf")
_make_pdf_with_metadata(p)
meta = extract_pdf_metadata(p)
# pypdf parsea D:YYYYMMDDHHmmSS a datetime; isoformat() lleva 'T'.
assert meta["creation_date"] is not None
assert "2024-03-11" in meta["creation_date"]
assert "T" in meta["creation_date"]
assert meta["mod_date"] is not None
assert "2024-03-12" in meta["mod_date"]
if __name__ == "__main__":
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as d:
test_pdf_con_metadatos_devuelve_titulo_autor_paginas(Path(d))
test_pdf_sin_doc_info_devuelve_none_sin_petar(Path(d))
test_fechas_parseables_en_iso_8601(Path(d))
print("Todos los tests pasaron.")
@@ -0,0 +1,63 @@
---
name: guess_email_formats
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: pure
signature: "def guess_email_formats(nombre: str, apellidos: str, dominio: str) -> list"
description: "Genera candidatos de email comunes (nombre.apellido, n.apellido, apellido.nombre, inicial+apellido, variantes con dos apellidos, etc.) a partir de nombre, apellidos y dominio. Normaliza acentos y ñ a ASCII en minusculas y deduplica preservando el orden. OSINT pasivo puro, sin red."
tags: [osint-passive, email, enumeration, recon, identity, cybersecurity, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [unicodedata]
params:
- name: nombre
desc: "Nombre de pila. Si tiene varios tokens se usa el primero como nombre principal."
- name: apellidos
desc: "Uno o dos apellidos separados por espacio. Con dos apellidos se generan variantes que los unen."
- name: dominio
desc: "Dominio de correo sin arroba (ej. 'empresa.com'). Si viene con '@' delante se limpia."
output: "Lista de strings '<local>@<dominio>' con los candidatos de email en orden de generacion, sin duplicados."
tested: true
tests:
- "test_nombre_simple_un_apellido"
- "test_acentos_y_enie_normalizados"
- "test_dos_apellidos_genera_variantes_unidas"
- "test_dedup_preserva_orden"
- "test_dominio_con_arroba_se_limpia"
test_file_path: "python/functions/cybersecurity/guess_email_formats_test.py"
file_path: "python/functions/cybersecurity/guess_email_formats.py"
---
## Ejemplo
```python
guess_email_formats("José", "García López", "empresa.com")
# ['jose@empresa.com',
# 'jose.garcia@empresa.com',
# 'josegarcia@empresa.com',
# 'j.garcia@empresa.com',
# 'jgarcia@empresa.com',
# 'jose_garcia@empresa.com',
# 'garcia.jose@empresa.com',
# 'joseg@empresa.com',
# 'jose.garcialopez@empresa.com',
# 'josegarcialopez@empresa.com',
# 'jose.lopez@empresa.com',
# 'jgarcialopez@empresa.com']
```
## Cuando usarla
Usala al arrancar una investigacion de identidad cuando conoces nombre + apellidos + dominio de una organizacion y quieres una lista de direcciones probables para verificar despues (MX, catch-all, validacion SMTP, breach lookup). Es el primer paso antes de cualquier comprobacion activa.
## Gotchas
- Funcion pura: NO valida que el email exista ni hace ninguna comprobacion de red. Solo genera candidatos sintacticos.
- La lista de patrones es heuristica (los formatos mas comunes), no exhaustiva: organizaciones con esquemas propios (ej. id numerico) no quedaran cubiertas.
- La normalizacion translitera acentos y ñ a ASCII y elimina cualquier caracter no alfanumerico del local part; nombres con guiones o apostrofes pierden esos separadores.
- Solo usa el primer token del nombre como nombre principal; nombres compuestos (ej. "Maria Jose") no generan variantes con el segundo nombre.
@@ -0,0 +1,84 @@
"""Genera candidatos de email a partir de nombre, apellidos y dominio.
Funcion pura de OSINT pasivo: produce los patrones de direccion de correo
mas habituales en organizaciones, sin tocar la red. Util como punto de
partida para verificacion posterior (MX, catch-all, validacion SMTP, etc.).
"""
import unicodedata
def _ascii_lower(text: str) -> str:
"""Normaliza un texto a ASCII en minusculas.
Translitera acentos y caracteres latinos (a, e, n, ...) a su forma
ASCII, pasa a minusculas y elimina cualquier caracter que no sea
alfanumerico. No introduce separadores (a diferencia de un slug).
Args:
text: texto de entrada (puede contener acentos, ñ, mayusculas).
Returns:
cadena ASCII en minusculas formada solo por [a-z0-9].
"""
# NFKD descompone los caracteres acentuados en base + diacritico.
decomposed = unicodedata.normalize("NFKD", text)
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
lowered = stripped.lower()
return "".join(c for c in lowered if c.isalnum())
def guess_email_formats(nombre: str, apellidos: str, dominio: str) -> list:
"""Genera candidatos de email comunes a partir de identidad y dominio.
Combina el nombre y los apellidos en los patrones de direccion mas
frecuentes (nombre.apellido, inicial+apellido, apellido.nombre, etc.),
normalizando acentos/ñ a ASCII y minusculas. Si hay dos apellidos
tambien genera variantes que los unen. Deduplica preservando el orden
de aparicion.
Args:
nombre: nombre de pila (puede incluir varios tokens separados por
espacio; se usa el primero como nombre principal).
apellidos: uno o dos apellidos separados por espacio.
dominio: dominio de correo sin arroba (ej. "empresa.com").
Returns:
lista de strings "<local>@<dominio>" con los candidatos, en el
orden en que se generan los patrones y sin duplicados.
"""
n = _ascii_lower(nombre.split()[0]) if nombre.split() else ""
apellido_tokens = [_ascii_lower(a) for a in apellidos.split() if _ascii_lower(a)]
a1 = apellido_tokens[0] if apellido_tokens else ""
a2 = apellido_tokens[1] if len(apellido_tokens) > 1 else ""
dom = dominio.strip().lstrip("@").lower()
ni = n[0] if n else ""
a1i = a1[0] if a1 else ""
apellido_full = "".join(apellido_tokens) # apellido1apellido2 unidos
locals_raw = [
n, # nombre
f"{n}.{a1}" if n and a1 else "", # nombre.apellido
f"{n}{a1}" if n and a1 else "", # nombreapellido
f"{ni}.{a1}" if ni and a1 else "", # n.apellido
f"{ni}{a1}" if ni and a1 else "", # napellido
f"{n}_{a1}" if n and a1 else "", # nombre_apellido
f"{a1}.{n}" if a1 and n else "", # apellido.nombre
f"{n}{a1i}" if n and a1i else "", # nombre+inicial_apellido
f"{n}.{apellido_full}" if n and a2 else "", # nombre.apellido1apellido2
f"{n}{apellido_full}" if n and a2 else "", # nombreapellido1apellido2
f"{n}.{a2}" if n and a2 else "", # nombre.apellido2
f"{ni}{apellido_full}" if ni and a2 else "", # n+apellidos unidos
]
seen = set()
out = []
for local in locals_raw:
if not local:
continue
email = f"{local}@{dom}"
if email not in seen:
seen.add(email)
out.append(email)
return out
@@ -0,0 +1,53 @@
"""Tests para guess_email_formats."""
from guess_email_formats import guess_email_formats
def test_nombre_simple_un_apellido():
"""Nombre simple con un apellido produce los patrones base."""
result = guess_email_formats("Juan", "Perez", "empresa.com")
assert "juan.perez@empresa.com" in result
assert "juanperez@empresa.com" in result
assert "j.perez@empresa.com" in result
assert "jperez@empresa.com" in result
assert "juan_perez@empresa.com" in result
assert "perez.juan@empresa.com" in result
assert "juan@empresa.com" in result
# Sin segundo apellido no hay variantes unidas.
assert all("@empresa.com" in e for e in result)
def test_acentos_y_enie_normalizados():
"""Acentos y ñ se transliteran a ASCII en minusculas."""
result = guess_email_formats("José", "Muñoz", "acme.org")
assert "jose.munoz@acme.org" in result
assert "j.munoz@acme.org" in result
# No debe aparecer ningun caracter no ASCII.
for email in result:
assert email == email.encode("ascii", "ignore").decode("ascii")
def test_dos_apellidos_genera_variantes_unidas():
"""Dos apellidos producen variantes que los unen."""
result = guess_email_formats("Maria", "Garcia Lopez", "uni.edu")
assert "maria.garcialopez@uni.edu" in result
assert "mariagarcialopez@uni.edu" in result
assert "maria.lopez@uni.edu" in result
assert "mgarcialopez@uni.edu" in result
# El patron de primer apellido sigue presente.
assert "maria.garcia@uni.edu" in result
def test_dedup_preserva_orden():
"""Los candidatos no se repiten y mantienen el orden de generacion."""
result = guess_email_formats("Ana", "Ruiz", "x.com")
assert len(result) == len(set(result))
# nombre.apellido aparece antes que apellido.nombre.
assert result.index("ana.ruiz@x.com") < result.index("ruiz.ana@x.com")
def test_dominio_con_arroba_se_limpia():
"""Un dominio prefijado con @ se normaliza sin duplicar la arroba."""
result = guess_email_formats("Luis", "Soto", "@corp.io")
assert "luis.soto@corp.io" in result
assert all(e.count("@") == 1 for e in result)
@@ -0,0 +1,57 @@
---
name: scan_ficha_attachments_metadata
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "def scan_ficha_attachments_metadata(attachments_dir: str) -> dict"
description: "Orquestador OSINT pasivo: recorre un directorio de attachments de una ficha (imagenes y PDFs), extrae sus metadatos componiendo extract_exif_metadata (imagenes .jpg/.jpeg/.png/.heic) y extract_pdf_metadata (.pdf), y agrega los puntos GPS y las fechas encontradas. Devuelve files + gps_points + dates + summary."
tags: [osint-enrich, osint-passive, cybersecurity, metadata, exif, pdf]
uses_functions: [extract_exif_metadata_py_cybersecurity, extract_pdf_metadata_py_cybersecurity]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os]
params:
- name: attachments_dir
desc: "ruta a un directorio de attachments, p.ej. /home/enmanuel/Obsidian/osint/attachments/personas/<slug>/. Se recorre recursivamente. Si no es un directorio existente lanza NotADirectoryError"
output: "dict con files (lista de {path, type, metadata} por archivo procesado: type es 'image' o 'pdf'), gps_points (lista de {file, lat, lon} agregando las coordenadas EXIF), dates (lista de fechas str de EXIF y PDF) y summary ({n_files, n_images, n_pdfs, n_gps_points, n_dates, errors}). Los archivos cuya extraccion falla quedan con metadata={'error': ...} y suman a summary.errors"
tested: true
tests: ["test_golden_agrega_gps_y_fechas_de_imagen_y_pdf", "test_directorio_inexistente_lanza", "test_ignora_extensiones_no_soportadas", "test_error_en_archivo_se_captura_en_summary"]
test_file_path: "python/functions/cybersecurity/scan_ficha_attachments_metadata_test.py"
file_path: "python/functions/cybersecurity/scan_ficha_attachments_metadata.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity import scan_ficha_attachments_metadata
# Escanea los attachments de una persona en el vault OSINT.
res = scan_ficha_attachments_metadata(
"/home/enmanuel/Obsidian/osint/attachments/personas/juan-perez/"
)
print(res["summary"]) # {'n_files': 7, 'n_images': 5, 'n_pdfs': 2, 'n_gps_points': 2, 'n_dates': 4, 'errors': 0}
for p in res["gps_points"]:
print(p["file"], p["lat"], p["lon"]) # coordenadas extraidas de las fotos
print(res["dates"]) # fechas EXIF + fechas de creacion/modificacion de los PDFs
```
## Cuando usarla
- Cuando montas la ficha OSINT de una persona u organizacion y quieres saber de un vistazo que huella de metadatos dejan los documentos/fotos que ya tienes guardados (donde y cuando se hicieron).
- Antes de publicar/compartir attachments propios: para auditar que GPS y fechas se filtrarian.
- Como paso de enriquecimiento dentro de un pipeline de investigacion, justo despues de descargar los attachments al directorio de la ficha.
## Gotchas
- **Uso solo para investigacion autorizada.** Extraer metadatos de archivos ajenos sin permiso puede ser ilegal segun jurisdiccion. Limitate a tus propios documentos o a material que estes autorizado a analizar.
- Funcion IMPURA: hace I/O sobre el sistema de archivos (recorrido recursivo con `os.walk`). Si `attachments_dir` no existe lanza `NotADirectoryError`.
- La extraccion por archivo se aisla con try/except: un archivo corrupto no aborta el escaneo, queda con `metadata={"error": ...}` y suma a `summary.errors`.
- Solo procesa imagenes .jpg/.jpeg/.png/.heic y PDFs .pdf; el resto se ignora silenciosamente. El soporte real de HEIC/EXIF depende de lo que `extract_exif_metadata` (Pillow) pueda abrir en el sistema.
- Las fechas se devuelven tal cual las reportan EXIF/PDF (formatos heterogeneos, sin normalizar a ISO).
@@ -0,0 +1,107 @@
"""Orquestador OSINT pasivo: escanea metadatos de los attachments de una ficha.
Recorre un directorio de attachments (imagenes y PDFs) y extrae sus metadatos
componiendo las funciones atomicas del registro (`extract_exif_metadata`,
`extract_pdf_metadata`). Agrega los puntos GPS y las fechas encontradas para
dar una vista rapida de la huella de metadatos de una persona/organizacion.
Funcion IMPURA: hace I/O sobre el sistema de archivos.
"""
import os
from cybersecurity import extract_exif_metadata, extract_pdf_metadata
_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".heic")
_PDF_EXTS = (".pdf",)
def _iter_files(attachments_dir: str):
"""Recorre recursivamente el directorio devolviendo rutas de archivo ordenadas."""
for root, _dirs, files in os.walk(attachments_dir):
for name in sorted(files):
yield os.path.join(root, name)
def scan_ficha_attachments_metadata(attachments_dir: str) -> dict:
"""Escanea un directorio de attachments y agrega los metadatos extraidos.
Aplica `extract_exif_metadata` a las imagenes (.jpg/.jpeg/.png/.heic) y
`extract_pdf_metadata` a los PDFs (.pdf). Agrega los puntos GPS de las
imagenes y todas las fechas detectadas (EXIF + PDF).
Args:
attachments_dir: ruta a un directorio de attachments, p.ej.
`/home/enmanuel/Obsidian/osint/attachments/personas/<slug>/`.
Se recorre recursivamente.
Returns:
dict con las claves:
- files: lista de {path, type, metadata} por archivo procesado.
- gps_points: lista de {file, lat, lon} con las coordenadas EXIF.
- dates: lista de fechas (str) encontradas en EXIF y PDF.
- summary: {n_files, n_images, n_pdfs, n_gps_points, n_dates,
errors} con conteos agregados.
Raises:
NotADirectoryError: si `attachments_dir` no es un directorio existente.
"""
if not os.path.isdir(attachments_dir):
raise NotADirectoryError(f"no es un directorio: {attachments_dir}")
files: list[dict] = []
gps_points: list[dict] = []
dates: list[str] = []
n_images = 0
n_pdfs = 0
errors = 0
for path in _iter_files(attachments_dir):
ext = os.path.splitext(path)[1].lower()
if ext in _IMAGE_EXTS:
ftype = "image"
elif ext in _PDF_EXTS:
ftype = "pdf"
else:
continue
try:
if ftype == "image":
metadata = extract_exif_metadata(path)
n_images += 1
lat = metadata.get("gps_lat")
lon = metadata.get("gps_lon")
if lat is not None and lon is not None:
gps_points.append({"file": path, "lat": lat, "lon": lon})
dt = metadata.get("datetime")
if dt:
dates.append(str(dt))
else:
metadata = extract_pdf_metadata(path)
n_pdfs += 1
for key in ("creation_date", "modification_date", "creationDate", "modDate"):
val = metadata.get(key)
if val:
dates.append(str(val))
except Exception as exc: # noqa: BLE001 - I/O sobre archivos heterogeneos
errors += 1
metadata = {"error": f"{type(exc).__name__}: {exc}"}
files.append({"path": path, "type": ftype, "metadata": metadata})
summary = {
"n_files": len(files),
"n_images": n_images,
"n_pdfs": n_pdfs,
"n_gps_points": len(gps_points),
"n_dates": len(dates),
"errors": errors,
}
return {
"files": files,
"gps_points": gps_points,
"dates": dates,
"summary": summary,
}
@@ -0,0 +1,113 @@
"""Tests para scan_ficha_attachments_metadata.
Las funciones compuestas (extract_exif_metadata / extract_pdf_metadata) se
monkeypatchean para no depender ni de archivos reales ni de Pillow. Solo se
verifica la orquestacion y la agregacion (GPS, fechas, summary, manejo de
errores y filtrado de extensiones).
"""
import importlib
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from cybersecurity import scan_ficha_attachments_metadata
# El paquete re-exporta la funcion bajo el mismo nombre que el submodulo, asi
# que `cybersecurity.scan_ficha_attachments_metadata` resuelve a la funcion.
# Para parchear los globals del modulo orquestador lo tomamos via importlib.
mod = importlib.import_module("cybersecurity.scan_ficha_attachments_metadata")
def _make_tree(tmp_path, names):
"""Crea archivos vacios en tmp_path; devuelve el directorio."""
d = tmp_path / "ficha"
d.mkdir()
for name in names:
(d / name).write_bytes(b"")
return str(d)
def test_golden_agrega_gps_y_fechas_de_imagen_y_pdf(tmp_path, monkeypatch):
attachments = _make_tree(tmp_path, ["foto1.jpg", "foto2.png", "doc.pdf"])
exif_by_name = {
"foto1.jpg": {"datetime": "2024:01:02 10:11:12", "gps_lat": 40.4, "gps_lon": -3.7},
"foto2.png": {"datetime": None, "gps_lat": None, "gps_lon": None},
}
def fake_exif(path):
return exif_by_name[os.path.basename(path)]
def fake_pdf(path):
return {"creation_date": "D:20230101000000", "modification_date": "D:20230202000000"}
monkeypatch.setattr(mod, "extract_exif_metadata", fake_exif)
monkeypatch.setattr(mod, "extract_pdf_metadata", fake_pdf)
res = scan_ficha_attachments_metadata(attachments)
# 3 archivos procesados (2 imagenes + 1 pdf).
assert res["summary"] == {
"n_files": 3,
"n_images": 2,
"n_pdfs": 1,
"n_gps_points": 1,
"n_dates": 3, # 1 de la imagen + 2 del pdf
"errors": 0,
}
# GPS solo de foto1.
assert res["gps_points"] == [
{"file": os.path.join(attachments, "foto1.jpg"), "lat": 40.4, "lon": -3.7}
]
# Fechas agregadas de EXIF + PDF.
assert "2024:01:02 10:11:12" in res["dates"]
assert "D:20230101000000" in res["dates"]
assert "D:20230202000000" in res["dates"]
# files lleva path/type/metadata por cada uno.
types = {os.path.basename(f["path"]): f["type"] for f in res["files"]}
assert types == {"foto1.jpg": "image", "foto2.png": "image", "doc.pdf": "pdf"}
def test_directorio_inexistente_lanza(tmp_path):
with pytest.raises(NotADirectoryError):
scan_ficha_attachments_metadata(str(tmp_path / "no-existe"))
def test_ignora_extensiones_no_soportadas(tmp_path, monkeypatch):
attachments = _make_tree(tmp_path, ["nota.txt", "video.mp4", "foto.jpg"])
monkeypatch.setattr(mod, "extract_exif_metadata", lambda p: {"gps_lat": None, "gps_lon": None, "datetime": None})
monkeypatch.setattr(mod, "extract_pdf_metadata", lambda p: {})
res = scan_ficha_attachments_metadata(attachments)
# Solo la imagen cuenta; txt y mp4 se ignoran.
assert res["summary"]["n_files"] == 1
assert res["summary"]["n_images"] == 1
assert res["summary"]["n_pdfs"] == 0
assert [os.path.basename(f["path"]) for f in res["files"]] == ["foto.jpg"]
def test_error_en_archivo_se_captura_en_summary(tmp_path, monkeypatch):
attachments = _make_tree(tmp_path, ["roto.jpg", "ok.pdf"])
def boom(path):
raise OSError("imagen corrupta")
monkeypatch.setattr(mod, "extract_exif_metadata", boom)
monkeypatch.setattr(mod, "extract_pdf_metadata", lambda p: {"creation_date": "D:20200101"})
res = scan_ficha_attachments_metadata(attachments)
assert res["summary"]["errors"] == 1
assert res["summary"]["n_files"] == 2 # ambos archivos quedan registrados
roto = next(f for f in res["files"] if os.path.basename(f["path"]) == "roto.jpg")
assert "error" in roto["metadata"]
assert "imagen corrupta" in roto["metadata"]["error"]
@@ -0,0 +1,68 @@
---
name: whois_lookup
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "def whois_lookup(dominio: str, timeout_s: float = 15.0) -> dict"
description: "Recoleccion OSINT pasiva de datos de registro de dominio via RDAP (reemplazo moderno de WHOIS sobre HTTP/JSON). Consulta https://rdap.org/domain/<dominio> con http_get_json y normaliza registrar, fechas de creacion/expiracion/ultimo cambio, nameservers, estados y entidades. Devuelve {found: False} si el dominio no existe (404)."
tags: [osint-passive, whois, rdap, recon, cybersecurity]
params:
- name: dominio
desc: "Dominio a consultar, ej. organic-machine.com. Vacio lanza RuntimeError."
- name: timeout_s
desc: "Segundos maximo de espera de la peticion HTTP a rdap.org (default 15.0)."
output: "dict normalizado con found (bool), registrar, creation_date, expiration_date, last_changed, nameservers (lista), status (lista), entities (lista de {handle, roles}) y raw (RDAP completo). Si el dominio no existe (HTTP 404) devuelve {found: False}."
uses_functions: ["http_get_json_py_infra"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_normaliza_respuesta_rdap", "test_dominio_no_encontrado_404", "test_otro_error_http_se_propaga", "test_sin_registrar_ni_fechas", "test_dominio_vacio_lanza_error"]
test_file_path: "python/functions/cybersecurity/whois_lookup_test.py"
file_path: "python/functions/cybersecurity/whois_lookup.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity import whois_lookup
info = whois_lookup("organic-machine.com")
if info["found"]:
print(info["registrar"]) # 'Example Registrar Inc.'
print(info["creation_date"]) # '2020-01-15T10:00:00Z'
print(info["expiration_date"]) # '2027-01-15T10:00:00Z'
print(info["nameservers"]) # ['ns1.example.net', 'ns2.example.net']
print(info["status"]) # ['client transfer prohibited']
else:
print("dominio no registrado")
```
## Cuando usarla
Usala para obtener metadatos de registro de un dominio sin depender del CLI
`whois` (no instalado): edad del dominio, fecha de expiracion (dominios a
punto de caducar), registrar y nameservers autoritativos. Util en perfilado
pasivo, deteccion de dominios recien creados (typosquatting/phishing) y
validacion de propiedad.
## Gotchas
- RDAP no esta uniformemente desplegado en todos los TLD: algunos devuelven
campos vacios o ni siquiera responden. Por eso los campos opcionales pueden
quedar `None` y `nameservers`/`status`/`entities` listas vacias.
- rdap.org actua como bootstrap y redirige al servidor RDAP autoritativo del
TLD; depende de su disponibilidad.
- El registrante (`entities` con rol distinto de `registrar`) suele estar
redactado por privacy/GDPR: casi siempre solo veras `handle` y `roles`, sin
datos personales.
- Un dominio no registrado devuelve `{"found": False}` (HTTP 404); cualquier
otro error HTTP (rate limit 429, 5xx) se propaga como `RuntimeError`.
- Las fechas se devuelven tal cual las da RDAP (ISO 8601 UTC), sin parsear a
objetos `datetime`.
@@ -0,0 +1,114 @@
"""Recoleccion OSINT pasiva de datos de registro de dominio via RDAP.
Funcion IMPURA: consulta el servicio RDAP publico (reemplazo moderno de
WHOIS, sobre HTTP/JSON) y normaliza la respuesta. Es OSINT pasivo: no toca
al dominio objetivo, solo el directorio RDAP publico.
"""
import os
import sys
sys.path.insert(
0, os.path.join(os.path.dirname(__file__), "..", "..", "functions")
)
from infra.http_get_json import http_get_json # noqa: E402
def _events_by_action(raw: dict) -> dict:
"""Indexa la lista RDAP ``events`` por ``eventAction`` -> ``eventDate``."""
out: dict = {}
for event in raw.get("events", []) or []:
action = event.get("eventAction")
date = event.get("eventDate")
if action and date:
out[action] = date
return out
def _extract_registrar(raw: dict) -> str | None:
"""Busca la entidad con rol ``registrar`` y devuelve su nombre vCard."""
for entity in raw.get("entities", []) or []:
roles = entity.get("roles", []) or []
if "registrar" not in roles:
continue
vcard = entity.get("vcardArray")
if isinstance(vcard, list) and len(vcard) == 2:
for field in vcard[1]:
if isinstance(field, list) and field and field[0] == "fn":
return field[3]
return entity.get("handle")
return None
def _extract_nameservers(raw: dict) -> list:
"""Extrae los ldhName de los nameservers RDAP, ordenados."""
servers = []
for ns in raw.get("nameservers", []) or []:
name = ns.get("ldhName")
if name:
servers.append(name.lower())
return sorted(set(servers))
def whois_lookup(dominio: str, timeout_s: float = 15.0) -> dict:
"""Consulta RDAP de un dominio y normaliza la informacion de registro.
Usa ``http_get_json`` del registry contra ``https://rdap.org/domain/<dominio>``
(rdap.org redirige al servidor RDAP autoritativo del TLD). Normaliza
registrar, fechas (creacion / expiracion / ultimo cambio), nameservers,
estados y entidades, e incluye la respuesta cruda en ``raw``.
Args:
dominio: Dominio a consultar (ej. ``"organic-machine.com"``).
timeout_s: Segundos maximo de espera de la peticion HTTP (default 15).
Returns:
Dict normalizado con claves: ``found`` (bool), ``registrar``,
``creation_date``, ``expiration_date``, ``last_changed``,
``nameservers`` (lista), ``status`` (lista), ``entities`` (lista de
roles/handles) y ``raw`` (respuesta RDAP completa). Si el dominio no
existe (HTTP 404) devuelve ``{"found": False}``.
Raises:
RuntimeError: Si el dominio esta vacio o la peticion falla por una
razon distinta de 404.
"""
if not dominio or not dominio.strip():
raise RuntimeError("whois_lookup: dominio vacio")
url = f"https://rdap.org/domain/{dominio.strip()}"
try:
raw = http_get_json(url, timeout=timeout_s)
except RuntimeError as e:
# http_get_json envuelve los HTTPError como "HTTP <code>".
if "HTTP 404" in str(e):
return {"found": False}
raise
if not isinstance(raw, dict):
raise RuntimeError(
f"whois_lookup: respuesta RDAP inesperada (tipo {type(raw).__name__})"
)
events = _events_by_action(raw)
entities = [
{
"handle": ent.get("handle"),
"roles": ent.get("roles", []) or [],
}
for ent in raw.get("entities", []) or []
]
return {
"found": True,
"registrar": _extract_registrar(raw),
"creation_date": events.get("registration"),
"expiration_date": events.get("expiration"),
"last_changed": events.get("last changed"),
"nameservers": _extract_nameservers(raw),
"status": raw.get("status", []) or [],
"entities": entities,
"raw": raw,
}
@@ -0,0 +1,109 @@
"""Tests para whois_lookup."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
import whois_lookup as wl
from whois_lookup import whois_lookup
def _rdap_sample() -> dict:
return {
"ldhName": "organic-machine.com",
"status": ["client transfer prohibited"],
"events": [
{"eventAction": "registration", "eventDate": "2020-01-15T10:00:00Z"},
{"eventAction": "expiration", "eventDate": "2027-01-15T10:00:00Z"},
{"eventAction": "last changed", "eventDate": "2026-01-10T08:30:00Z"},
],
"nameservers": [
{"ldhName": "ns1.example.net"},
{"ldhName": "NS2.EXAMPLE.NET"},
],
"entities": [
{
"handle": "REG-123",
"roles": ["registrar"],
"vcardArray": [
"vcard",
[
["version", {}, "text", "4.0"],
["fn", {}, "text", "Example Registrar Inc."],
],
],
},
{"handle": "REGISTRANT-9", "roles": ["registrant"]},
],
}
def test_normaliza_respuesta_rdap(monkeypatch):
"""Extrae registrar, fechas, nameservers, status y entities."""
monkeypatch.setattr(wl, "http_get_json", lambda url, timeout=15.0: _rdap_sample())
result = whois_lookup("organic-machine.com")
assert result["found"] is True
assert result["registrar"] == "Example Registrar Inc."
assert result["creation_date"] == "2020-01-15T10:00:00Z"
assert result["expiration_date"] == "2027-01-15T10:00:00Z"
assert result["last_changed"] == "2026-01-10T08:30:00Z"
assert result["nameservers"] == ["ns1.example.net", "ns2.example.net"]
assert result["status"] == ["client transfer prohibited"]
assert {"handle": "REGISTRANT-9", "roles": ["registrant"]} in result["entities"]
assert result["raw"]["ldhName"] == "organic-machine.com"
def test_dominio_no_encontrado_404(monkeypatch):
"""Un HTTP 404 de http_get_json devuelve {'found': False}."""
def fake(url, timeout=15.0):
raise RuntimeError("http_get_json: HTTP 404 at 'rdap.org' — not found")
monkeypatch.setattr(wl, "http_get_json", fake)
result = whois_lookup("nope-no-existe-xyz.invalid")
assert result == {"found": False}
def test_otro_error_http_se_propaga(monkeypatch):
"""Un error HTTP distinto de 404 se propaga como RuntimeError."""
def fake(url, timeout=15.0):
raise RuntimeError("http_get_json: HTTP 500 at 'rdap.org' — boom")
monkeypatch.setattr(wl, "http_get_json", fake)
try:
whois_lookup("organic-machine.com")
assert False, "deberia haberse propagado el error 500"
except RuntimeError as e:
assert "HTTP 500" in str(e)
def test_sin_registrar_ni_fechas(monkeypatch):
"""RDAP minimo: campos opcionales quedan None / listas vacias."""
monkeypatch.setattr(
wl, "http_get_json", lambda url, timeout=15.0: {"ldhName": "x.com"}
)
result = whois_lookup("x.com")
assert result["found"] is True
assert result["registrar"] is None
assert result["creation_date"] is None
assert result["nameservers"] == []
assert result["status"] == []
assert result["entities"] == []
def test_dominio_vacio_lanza_error():
"""Dominio vacio lanza RuntimeError."""
try:
whois_lookup("")
assert False, "deberia haber lanzado RuntimeError"
except RuntimeError:
pass
+18
View File
@@ -1,10 +1,28 @@
from .setup_logger import setup_logger, get_logger from .setup_logger import setup_logger, get_logger
from .generate_app_icon import generate_app_icon from .generate_app_icon import generate_app_icon
from .generate_initials_avatar import generate_initials_avatar
from .http_replay_sequence import http_replay_sequence from .http_replay_sequence import http_replay_sequence
from .hoppscotch_login import hoppscotch_login
from .hoppscotch_create_request import hoppscotch_create_request
from .hoppscotch_update_request import hoppscotch_update_request
from .hoppscotch_delete_request import hoppscotch_delete_request
from .hoppscotch_list_requests import hoppscotch_list_requests
from .pass_get_secret import pass_get_secret
from .hoppscotch_set_environment import hoppscotch_set_environment
from .hoppscotch_run_request import hoppscotch_run_request
__all__ = [ __all__ = [
"setup_logger", "setup_logger",
"get_logger", "get_logger",
"generate_app_icon", "generate_app_icon",
"generate_initials_avatar",
"http_replay_sequence", "http_replay_sequence",
"hoppscotch_login",
"hoppscotch_create_request",
"hoppscotch_update_request",
"hoppscotch_delete_request",
"hoppscotch_list_requests",
"pass_get_secret",
"hoppscotch_set_environment",
"hoppscotch_run_request",
] ]
@@ -0,0 +1,115 @@
---
name: build_hoppscotch_collection
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def build_hoppscotch_collection(calls: list[dict], *, name: str = \"Collection\", request_names: list[str] | None = None) -> dict"
description: "Helper interno de serializacion del grupo hoppscotch: convierte call specs (method/url/headers/body/body_type) en el formato HoppRESTRequest/coleccion Hoppscotch (request v:2). Lo usan hoppscotch_create_request y hoppscotch_update_request para construir el campo request de las mutations GraphQL del self-host. NO uses el dict resultante para escribir un .json e importarlo a mano: el flujo canonico es operar el self-host por la API (ver docs/capabilities/hoppscotch.md). Pura: solo stdlib, sin red."
tags: [hoppscotch, flow-replay, http, infra, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: calls
desc: "lista de call specs (tipicamente la salida de har_extract_calls). Cada elemento es un dict con claves opcionales: method (str), url (str), headers (dict name->value), cookies (dict name->value), body (str|None), body_type (json|form|raw|None). Otras claves como status o sets_cookies se ignoran."
- name: name
desc: "nombre de la coleccion Hoppscotch resultante. Default 'Collection'."
- name: request_names
desc: "nombres explicitos por request, alineados por indice con calls. Si se pasa y existe el indice, sobreescribe el nombre derivado. None = derivar todos como '<METHOD> <path>'."
output: "dict de coleccion Hoppscotch JSON-serializable: {\"v\": 1, \"name\", \"folders\": [], \"requests\": [...]}. Cada request lleva v:'2', endpoint, name, method (upper), headers (lista key/value/active con header Cookie inyectado si habia cookies), body (contentType+body segun body_type), auth none, params/requestVariables vacios."
tested: true
tests:
- "test_golden_get_simple"
- "test_edge_post_json_con_headers_y_cookies"
- "test_request_names_sobreescribe_nombre_derivado"
- "test_form_body_genera_contenttype_urlencoded"
- "test_raw_body_genera_contenttype_text_plain"
- "test_body_type_desconocido_da_body_null"
- "test_lista_vacia"
- "test_call_spec_sin_url_ni_method"
- "test_sin_cookies_no_anade_header_cookie"
test_file_path: "python/functions/infra/build_hoppscotch_collection_test.py"
file_path: "python/functions/infra/build_hoppscotch_collection.py"
---
## Ejemplo
```python
import json
from build_hoppscotch_collection import build_hoppscotch_collection
# Call specs tal cual salen de har_extract_calls.
calls = [
{
"method": "GET",
"url": "https://api.example.com/api/search?q=foo",
"headers": {"Accept": "application/json"},
},
{
"method": "POST",
"url": "https://api.example.com/login",
"headers": {"Content-Type": "application/json"},
"cookies": {"session": "abc", "csrf": "xyz"},
"body": '{"user":"neo","pass":"<<password>>"}',
"body_type": "json",
},
]
collection = build_hoppscotch_collection(calls, name="Example flow")
# {
# "v": 1,
# "name": "Example flow",
# "folders": [],
# "requests": [
# {
# "v": "2",
# "endpoint": "https://api.example.com/api/search?q=foo",
# "name": "GET /api/search",
# "params": [],
# "headers": [{"key": "Accept", "value": "application/json", "active": True}],
# "method": "GET",
# "auth": {"authType": "none", "authActive": True},
# "preRequestScript": "",
# "testScript": "",
# "body": {"contentType": None, "body": None},
# "requestVariables": [],
# },
# {
# "v": "2",
# "endpoint": "https://api.example.com/login",
# "name": "POST /login",
# "params": [],
# "headers": [
# {"key": "Content-Type", "value": "application/json", "active": True},
# {"key": "Cookie", "value": "session=abc; csrf=xyz", "active": True},
# ],
# "method": "POST",
# "auth": {"authType": "none", "authActive": True},
# "preRequestScript": "",
# "testScript": "",
# "body": {"contentType": "application/json", "body": '{"user":"neo","pass":"<<password>>"}'},
# "requestVariables": [],
# },
# ],
# }
# Listo para escribir a disco e importar en la app Desktop / hopp CLI.
with open("flow.collection.json", "w") as f:
json.dump(collection, f, indent=2)
```
## Cuando usarla
Usala cuando quieras abrir en la GUI de Hoppscotch unas peticiones que ya grabaste y destilaste con el patron grabar->destilar->reproducir (HAR -> har_filter_flows -> har_extract_calls). Pasas las call specs por esta funcion, guardas el dict resultante como `.json` y lo importas en la app Desktop o con el CLI `hopp`. Es la salida amigable para humanos del flujo de replay: cuando prefieras inspeccionar/tocar las peticiones a mano en el GUI antes de promoverlas a una funcion-accion del registry con http_replay_sequence. La funcion inversa, parse_hoppscotch_collection, reimporta una coleccion editada en el GUI de vuelta a call specs.
## Gotchas
- **Genera el formato canonico estable v1/v2.** La coleccion sale como `v:1` y cada request como `v:"2"` — la forma de los fixtures oficiales de Hoppscotch, garantizada importable. Hoppscotch la migra automaticamente a su ultima version interna al importar; no intentes emitir la version "ultima" a mano.
- **Las cookies se inyectan como header Cookie.** Si un call spec trae `cookies` no vacio, se anade un unico header `Cookie` al final con formato `k1=v1; k2=v2`. Hoppscotch no tiene un slot de cookies separado en el request, asi que viajan en headers; al reimportar con parse_hoppscotch_collection se vuelven a separar.
- **Los secretos NO se sustituyen.** La funcion copia headers, cookies y body tal cual. Tokens de sesion, `Authorization` y contrasenas viajan en claro en el dict resultante. Si quieres parametrizar, es el caller quien debe marcar los valores con `<<var>>` (referencia a variable de environment de Hoppscotch) antes de llamar a esta funcion. NO commitear el `.json` resultante sin redactar.
- **Claves extra del call spec se ignoran.** `status`, `sets_cookies` y cualquier otra clave que no sea method/url/headers/cookies/body/body_type no aparecen en la coleccion (son metadata de la captura, no del request a reproducir).
@@ -0,0 +1,135 @@
"""Convierte call specs del registry en una coleccion Hoppscotch importable.
Mitad "exportar al GUI" del puente entre el motor de replay del registry y
Hoppscotch. La funcion inversa es parse_hoppscotch_collection.
"""
from urllib.parse import urlparse
def _request_name(call: dict, fallback_index: int) -> str:
"""Deriva un nombre legible para la request a partir del metodo y el path.
Args:
call: call spec con (opcional) method y url.
fallback_index: indice de la call dentro de la lista (no usado en el
nombre derivado, reservado para desambiguar si hiciera falta).
Returns:
nombre del estilo "GET /api/search".
"""
method = str(call.get("method") or "GET").upper()
url = str(call.get("url") or "")
path = urlparse(url).path or "/"
return f"{method} {path}"
def _build_headers(call: dict) -> list[dict]:
"""Construye la lista de headers Hoppscotch desde el dict del call spec.
Convierte el dict headers (preservando orden de insercion) a la lista
[{"key", "value", "active": True}, ...] y, si el call spec trae cookies no
vacias, anade un header extra "Cookie" al final con formato "k1=v1; k2=v2".
Args:
call: call spec con (opcional) headers y cookies.
Returns:
lista de headers Hoppscotch.
"""
headers: list[dict] = []
raw_headers = call.get("headers") or {}
for key, value in raw_headers.items():
headers.append({"key": key, "value": value, "active": True})
cookies = call.get("cookies") or {}
if cookies:
cookie_value = "; ".join(f"{name}={val}" for name, val in cookies.items())
headers.append({"key": "Cookie", "value": cookie_value, "active": True})
return headers
def _build_body(call: dict) -> dict:
"""Construye el objeto body Hoppscotch segun body_type del call spec.
Args:
call: call spec con (opcional) body y body_type.
Returns:
dict con contentType y body. Si no hay body o el body_type es
desconocido/None, ambos campos son None.
"""
body = call.get("body")
body_type = call.get("body_type")
content_types = {
"json": "application/json",
"form": "application/x-www-form-urlencoded",
"raw": "text/plain",
}
if body is None or body_type not in content_types:
return {"contentType": None, "body": None}
return {"contentType": content_types[body_type], "body": body}
def build_hoppscotch_collection(
calls: list[dict],
*,
name: str = "Collection",
request_names: list[str] | None = None,
) -> dict:
"""Convierte una lista de call specs en una coleccion Hoppscotch importable.
Genera el formato canonico estable (coleccion v:1, request v:"2") que
Hoppscotch migra a la ultima version al importar. Pura: sin I/O ni red,
solo stdlib, determinista.
Args:
calls: lista de call specs (salida de har_extract_calls). Cada elemento
es un dict con claves opcionales: method, url, headers, cookies,
body, body_type. Otras claves (status, sets_cookies, ...) se ignoran.
name: nombre de la coleccion Hoppscotch.
request_names: nombres explicitos por request, alineados por indice. Si
se pasa y existe el indice, sobreescribe el nombre derivado. None =
derivar todos los nombres como "<METHOD> <path>".
Returns:
dict con la coleccion Hoppscotch: {"v": 1, "name", "folders": [],
"requests": [...]}. JSON-serializable.
"""
requests: list[dict] = []
for index, call in enumerate(calls):
if request_names is not None and index < len(request_names):
req_name = request_names[index]
else:
req_name = _request_name(call, index)
endpoint = str(call.get("url") or "")
method = str(call.get("method") or "GET").upper()
requests.append(
{
"v": "2",
"endpoint": endpoint,
"name": req_name,
"params": [],
"headers": _build_headers(call),
"method": method,
"auth": {"authType": "none", "authActive": True},
"preRequestScript": "",
"testScript": "",
"body": _build_body(call),
"requestVariables": [],
}
)
return {
"v": 1,
"name": name,
"folders": [],
"requests": requests,
}
@@ -0,0 +1,172 @@
"""Tests para build_hoppscotch_collection."""
import json
from build_hoppscotch_collection import build_hoppscotch_collection
def test_golden_get_simple():
calls = [
{
"method": "GET",
"url": "https://api.example.com/api/search?q=foo",
"headers": {"Accept": "application/json"},
}
]
result = build_hoppscotch_collection(calls, name="MyCol")
assert result["v"] == 1
assert result["name"] == "MyCol"
assert result["folders"] == []
assert len(result["requests"]) == 1
req = result["requests"][0]
assert req["v"] == "2"
assert req["endpoint"] == "https://api.example.com/api/search?q=foo"
assert req["name"] == "GET /api/search"
assert req["method"] == "GET"
assert req["params"] == []
assert req["headers"] == [
{"key": "Accept", "value": "application/json", "active": True}
]
assert req["auth"] == {"authType": "none", "authActive": True}
assert req["preRequestScript"] == ""
assert req["testScript"] == ""
assert req["requestVariables"] == []
assert req["body"] == {"contentType": None, "body": None}
# JSON-serializable
json.dumps(result)
def test_edge_post_json_con_headers_y_cookies():
calls = [
{
"method": "post",
"url": "https://api.example.com/login",
"headers": {
"Content-Type": "application/json",
"X-Csrf": "tok",
},
"cookies": {"session": "abc", "csrf": "xyz"},
"body": '{"user":"neo"}',
"body_type": "json",
}
]
result = build_hoppscotch_collection(calls)
req = result["requests"][0]
assert req["method"] == "POST"
assert req["name"] == "POST /login"
# Header Cookie generado al final con formato "; " join
assert req["headers"] == [
{"key": "Content-Type", "value": "application/json", "active": True},
{"key": "X-Csrf", "value": "tok", "active": True},
{"key": "Cookie", "value": "session=abc; csrf=xyz", "active": True},
]
assert req["body"] == {
"contentType": "application/json",
"body": '{"user":"neo"}',
}
json.dumps(result)
def test_request_names_sobreescribe_nombre_derivado():
calls = [
{"method": "GET", "url": "https://api.example.com/a"},
{"method": "GET", "url": "https://api.example.com/b"},
]
result = build_hoppscotch_collection(
calls, request_names=["Custom A"]
)
# Indice 0 usa el nombre explicito; indice 1 cae al derivado.
assert result["requests"][0]["name"] == "Custom A"
assert result["requests"][1]["name"] == "GET /b"
def test_form_body_genera_contenttype_urlencoded():
calls = [
{
"method": "POST",
"url": "https://api.example.com/form",
"body": "a=1&b=2",
"body_type": "form",
}
]
req = build_hoppscotch_collection(calls)["requests"][0]
assert req["body"] == {
"contentType": "application/x-www-form-urlencoded",
"body": "a=1&b=2",
}
def test_raw_body_genera_contenttype_text_plain():
calls = [
{
"method": "POST",
"url": "https://api.example.com/raw",
"body": "hello",
"body_type": "raw",
}
]
req = build_hoppscotch_collection(calls)["requests"][0]
assert req["body"] == {"contentType": "text/plain", "body": "hello"}
def test_body_type_desconocido_da_body_null():
calls = [
{
"method": "POST",
"url": "https://api.example.com/x",
"body": "ignored",
"body_type": "binary",
}
]
req = build_hoppscotch_collection(calls)["requests"][0]
assert req["body"] == {"contentType": None, "body": None}
def test_lista_vacia():
result = build_hoppscotch_collection([], name="Empty")
assert result == {
"v": 1,
"name": "Empty",
"folders": [],
"requests": [],
}
json.dumps(result)
def test_call_spec_sin_url_ni_method():
calls = [{}]
req = build_hoppscotch_collection(calls)["requests"][0]
assert req["endpoint"] == ""
assert req["method"] == "GET"
assert req["name"] == "GET /"
assert req["headers"] == []
assert req["body"] == {"contentType": None, "body": None}
def test_sin_cookies_no_anade_header_cookie():
calls = [
{
"method": "GET",
"url": "https://api.example.com/x",
"headers": {"Accept": "*/*"},
"cookies": {},
}
]
req = build_hoppscotch_collection(calls)["requests"][0]
assert all(h["key"] != "Cookie" for h in req["headers"])
@@ -0,0 +1,88 @@
---
name: dav_collection_ctag
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def dav_collection_ctag(base_url: str, username: str, password: str, collection_path: str, *, timeout_s: float = 10.0, verify_tls: bool = True) -> dict"
description: "Lee el ctag (token de version) de una coleccion DAV en UNA peticion PROPFIND Depth:0 barata. Pide el getctag de CalendarServer (http://calendarserver.org/ns/) y, como respaldo, el getetag DAV de la propia coleccion. El ctag cambia solo cuando cambia algun recurso de la coleccion: comparandolo con un ctag cacheado se decide si recargar el contenido (REPORT) o servir de cache sin tocar la red. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib y parsea el multistatus con regex simple. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl). Probado contra Xandikos."
tags: [dav, carddav, caldav, ctag, getctag, propfind, cache, sync, http, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [base64, re, ssl, urllib.error, urllib.request]
params:
- name: base_url
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
- name: username
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
- name: password
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
- name: collection_path
desc: "ruta de la coleccion (CardDAV '/enmanuel/contacts/addressbook/' o CalDAV '/enmanuel/calendars/calendar/')."
- name: timeout_s
desc: "timeout de la peticion HTTP en segundos. Default 10.0 (la respuesta es minuscula)."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
output: "dict. En exito: {status:'ok', http_status:int, ctag:str} donde ctag es el getctag de CalendarServer si el servidor lo expone, o el getetag DAV de la coleccion como respaldo. En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
tested: true
tests:
- "test_construye_propfind_depth_0"
- "test_basic_auth_header_correcto"
- "test_devuelve_getctag"
- "test_fallback_a_getetag"
- "test_sin_ctag_ni_etag_devuelve_error"
- "test_httperror_devuelve_status_error"
test_file_path: "python/functions/infra/dav_collection_ctag_test.py"
file_path: "python/functions/infra/dav_collection_ctag.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.dav_collection_ctag import dav_collection_ctag
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
res = dav_collection_ctag(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/calendars/calendar/",
)
print(res["status"], res["ctag"]) # ok e8b39a8b180d25a674b35f0fee3013992b59e51e
# Patron de cache: si el ctag no cambio, sirve del disco sin descargar.
if res["ctag"] == cached_ctag:
return cached_payload
# ...si cambio, recargar con dav_get_collection y guardar el nuevo ctag.
```
## Cuando usarla
Antes de descargar una coleccion DAV completa: pides el ctag (peticion
minuscula, ~10ms) y lo comparas con el ctag de tu cache. Si coincide, sirves de
cache sin tocar la red (arranque instantaneo); si difiere, recargas con
`dav_get_collection` y guardas el nuevo ctag. Es el primitivo de validacion de
cache para CardDAV/CalDAV: una sola comprobacion barata decide si la copia local
sigue vigente.
## Gotchas
- El `getctag` es la extension de CalendarServer (`http://calendarserver.org/ns/`),
ampliamente soportada (Xandikos la expone). Si el servidor no la implementa, la
funcion cae al `getetag` DAV de la coleccion, que en Xandikos tambien cambia al
cambiar cualquier recurso — sirve igual como token de version.
- El ctag es OPACO: no lo interpretes, solo comparalo por igualdad con el que
guardaste. No asumas orden ni formato (Xandikos usa un hash hex; otros
servidores usan timestamps u otros formatos).
- No garantiza deteccion de cambios sub-recurso (etag por recurso): solo dice si
ALGO cambio en la coleccion. Para sync incremental fino combina con
`dav_list_resources` (mapa href->etag).
- Lectura remota real sobre TLS; password de `pass`, no se logea.
@@ -0,0 +1,104 @@
"""Lee el ctag de una coleccion DAV en UNA peticion barata (PROPFIND Depth:0).
Funcion impura: hace un unico PROPFIND Depth:0 sobre la coleccion pidiendo el
`getctag` de CalendarServer (`http://calendarserver.org/ns/`) y, como respaldo,
el `getetag` DAV de la propia coleccion. El ctag es un token opaco que cambia
SOLO cuando cambia algun recurso de la coleccion: comparandolo con el ctag
cacheado se decide si hay que recargar el contenido (REPORT) o servir de cache
sin tocar la red.
Construye el header `Authorization: Basic base64(user:pass)` a mano con stdlib y
parsea el multistatus con regex simple. Maneja errores sin lanzar. Solo usa
stdlib (urllib, base64, re, ssl). Probado contra Xandikos.
"""
import base64
import re
import ssl
import urllib.error
import urllib.request
_CTAG_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?getctag>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?getctag>",
re.DOTALL | re.IGNORECASE,
)
_ETAG_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?getetag>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?getetag>",
re.DOTALL | re.IGNORECASE,
)
_PROPFIND_BODY = (
'<?xml version="1.0" encoding="utf-8" ?>'
'<D:propfind xmlns:D="DAV:" xmlns:CS="http://calendarserver.org/ns/">'
"<D:prop><CS:getctag/><D:getetag/></D:prop>"
"</D:propfind>"
)
def _basic_auth_header(username: str, password: str) -> str:
raw = ("%s:%s" % (username, password)).encode("utf-8")
return "Basic " + base64.b64encode(raw).decode("ascii")
def _join_url(base_url: str, collection_path: str) -> str:
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/"
def dav_collection_ctag(
base_url: str,
username: str,
password: str,
collection_path: str,
*,
timeout_s: float = 10.0,
verify_tls: bool = True,
) -> dict:
"""Lee el ctag (token de version) de una coleccion DAV.
Args:
base_url: URL base del servidor DAV.
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth. Resolver desde pass.
collection_path: ruta de la coleccion (CardDAV o CalDAV).
timeout_s: timeout de la peticion en segundos. Default 10.0.
verify_tls: si True (default) verifica el certificado TLS.
Returns:
dict. En exito: {status:'ok', http_status:int, ctag:str} donde ctag es
el getctag de CalendarServer si el servidor lo expone, o el getetag DAV
de la coleccion como respaldo. En error (sin lanzar):
{status:'error', error:str, http_status:int|None}.
"""
url = _join_url(base_url, collection_path)
headers = {
"Authorization": _basic_auth_header(username, password),
"Content-Type": "application/xml; charset=utf-8",
"Depth": "0",
}
req = urllib.request.Request(
url, data=_PROPFIND_BODY.encode("utf-8"), method="PROPFIND", headers=headers
)
context = None if verify_tls else ssl._create_unverified_context()
try:
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
status = resp.status
xml = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
except urllib.error.URLError as e:
return {"status": "error", "error": str(e.reason), "http_status": None}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e), "http_status": None}
ctag_m = _CTAG_RE.search(xml)
if ctag_m:
return {"status": "ok", "http_status": status, "ctag": ctag_m.group(1).strip()}
etag_m = _ETAG_RE.search(xml)
if etag_m:
return {"status": "ok", "http_status": status, "ctag": etag_m.group(1).strip()}
return {
"status": "error",
"error": "ni getctag ni getetag en la respuesta",
"http_status": status,
}
@@ -0,0 +1,119 @@
"""Tests para dav_collection_ctag.
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
Request (method PROPFIND, Depth:0, auth) y devolver un multistatus simulado.
Cubren: getctag presente, fallback a getetag cuando no hay getctag, ninguno de
los dos, y el path de error HTTP.
"""
import base64
import sys
import infra.dav_collection_ctag # noqa: F401
mod = sys.modules["infra.dav_collection_ctag"]
dav_collection_ctag = mod.dav_collection_ctag
_XML_CTAG = (
'<?xml version="1.0"?>'
'<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="http://calendarserver.org/ns/">'
"<ns0:response><ns0:href>/enmanuel/calendars/calendar/</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
"<ns1:getctag>ctag-abc123</ns1:getctag>"
'<ns0:getetag>"etag-abc123"</ns0:getetag>'
"</ns0:prop></ns0:propstat></ns0:response></ns0:multistatus>"
)
_XML_SOLO_ETAG = (
'<?xml version="1.0"?>'
'<ns0:multistatus xmlns:ns0="DAV:">'
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
'<ns0:getetag>"etag-only-999"</ns0:getetag>'
"</ns0:prop></ns0:propstat></ns0:response></ns0:multistatus>"
)
_XML_VACIO = (
'<?xml version="1.0"?>'
'<ns0:multistatus xmlns:ns0="DAV:">'
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 404 Not Found</ns0:status>"
"<ns0:prop></ns0:prop></ns0:propstat></ns0:response></ns0:multistatus>"
)
class _FakeResp:
def __init__(self, payload: str):
self._payload = payload
self.status = 207
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._payload.encode("utf-8")
def _capture(monkeypatch, payload: str):
captured = {}
def fake_urlopen(req, timeout=None, context=None):
captured["method"] = req.get_method()
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
return _FakeResp(payload)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
return captured
def _call(path="/enmanuel/calendars/calendar/"):
return dav_collection_ctag(
"https://dav.example.com", "enmanuel", "secret-pw", path
)
def test_construye_propfind_depth_0(monkeypatch):
cap = _capture(monkeypatch, _XML_CTAG)
_call()
assert cap["method"] == "PROPFIND"
assert cap["headers"]["depth"] == "0"
def test_basic_auth_header_correcto(monkeypatch):
cap = _capture(monkeypatch, _XML_CTAG)
_call()
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
assert cap["headers"]["authorization"] == expected
def test_devuelve_getctag(monkeypatch):
_capture(monkeypatch, _XML_CTAG)
res = _call()
assert res["status"] == "ok"
assert res["ctag"] == "ctag-abc123"
def test_fallback_a_getetag(monkeypatch):
_capture(monkeypatch, _XML_SOLO_ETAG)
res = _call("/enmanuel/contacts/addressbook/")
assert res["status"] == "ok"
assert res["ctag"] == '"etag-only-999"'
def test_sin_ctag_ni_etag_devuelve_error(monkeypatch):
_capture(monkeypatch, _XML_VACIO)
res = _call("/enmanuel/contacts/addressbook/")
assert res["status"] == "error"
def test_httperror_devuelve_status_error(monkeypatch):
def fake_urlopen(req, timeout=None, context=None):
raise mod.urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
res = _call()
assert res["status"] == "error"
assert res["http_status"] == 401
@@ -0,0 +1,107 @@
---
name: dav_get_collection
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def dav_get_collection(base_url: str, username: str, password: str, collection_path: str, content_type: str = 'vcard', *, timeout_s: float = 30.0, verify_tls: bool = True) -> dict"
description: "Descarga TODOS los recursos de una coleccion DAV en UNA peticion HTTP REPORT con el contenido inline, evitando el patron N+1 (PROPFIND + un GET por recurso). Usa addressbook-query (CardDAV, content_type='vcard') o calendar-query (CalDAV, content_type='ical'); el servidor responde un multistatus con el vCard/VCALENDAR de cada recurso embebido. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib, parsea el XML con regex simple y des-escapa las entidades XML del contenido. Para 1064 contactos baja de ~9s (N GETs) a ~1s (1 REPORT). verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl, html). Probado contra Xandikos."
tags: [dav, carddav, caldav, report, multiget, addressbook-query, calendar-query, bulk, http, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [base64, html, re, ssl, urllib.error, urllib.request]
params:
- name: base_url
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
- name: username
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
- name: password
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
- name: collection_path
desc: "ruta de la coleccion (CardDAV '/enmanuel/contacts/addressbook/' o CalDAV '/enmanuel/calendars/calendar/')."
- name: content_type
desc: "tipo de la coleccion: 'vcard' (CardDAV, default) o 'ical' (CalDAV). Acepta sinonimos: 'carddav'/'contacts'/'addressbook' -> vcard; 'caldav'/'calendar'/'icalendar' -> ical."
- name: timeout_s
desc: "timeout de la peticion HTTP en segundos. Default 30.0 (la respuesta puede ser grande: ~600KB para 1000 contactos)."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
output: "dict. En exito: {status:'ok', http_status:int, resources:[{href:str, etag:str|None, data:str}, ...]} con un elemento por recurso de la coleccion; data es el vCard / VCALENDAR completo ya des-escapado. En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
tested: true
tests:
- "test_vcard_construye_report_addressbook_query"
- "test_ical_construye_report_calendar_query_con_filtro"
- "test_basic_auth_header_correcto"
- "test_parsea_resources_con_data_inline"
- "test_desescapa_entidades_xml_del_data"
- "test_ical_parsea_calendar_data"
- "test_acepta_sinonimos_de_content_type"
- "test_content_type_invalido_devuelve_error"
- "test_httperror_devuelve_status_error"
test_file_path: "python/functions/infra/dav_get_collection_test.py"
file_path: "python/functions/infra/dav_get_collection.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from infra.dav_get_collection import dav_get_collection
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
# Todos los contactos en UNA peticion (~1s para 1064 vCards):
res = dav_get_collection(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/contacts/addressbook/",
content_type="vcard",
)
print(res["status"], len(res["resources"])) # ok 1064
print(res["resources"][0]["data"][:40]) # BEGIN:VCARD\nVERSION:3.0\nFN:...
# Todos los eventos del calendario en UNA peticion:
cal = dav_get_collection(
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/calendars/calendar/",
content_type="ical",
)
print(cal["status"], len(cal["resources"])) # ok 98
```
## Cuando usarla
Cuando necesitas el contenido de TODOS los recursos de una coleccion CardDAV o
CalDAV (renderizar la agenda completa, listar todos los eventos, sincronizar en
bloque) y no solo sus hrefs. Sustituye a `dav_list_resources` + un
`dav_get_resource` por recurso: una sola ida y vuelta en lugar de N+1, lo que
para colecciones de cientos/miles de recursos es la diferencia entre ~9s y ~1s.
Si solo necesitas los hrefs/etags (sin contenido), usa `dav_list_resources`; si
necesitas un unico recurso, usa `dav_get_resource`.
## Gotchas
- Usa los REPORT `addressbook-query` / `calendar-query` (RFC 6352 / 4791) con
Depth:1, NO `addressbook-multiget` (que en Xandikos exige Depth:0 + una lista
explicita de hrefs en el cuerpo). El query no necesita conocer los hrefs de
antemano: una sola peticion trae todo.
- El namespace CardDAV/CalDAV es el "legacy" `urn:ietf:params:xml:ns:carddav`
(con `:ns:`), que es el que Xandikos anuncia en su `supported-report-set`. El
namespace sin `:ns:` (`urn:ietf:params:xml:carddav`) provoca un 403
"Unknown report" en Xandikos.
- El contenido inline viene XML-escapado en el multistatus (`&lt;`, `&gt;`,
`&amp;`); la funcion lo des-escapa con `html.unescape` antes de devolverlo.
El `data` resultante es el vCard / VCALENDAR tal cual lo guardo el servidor.
- El parseo es regex simple sobre el multistatus (KISS, sin parser XML): robusto
para la salida estandar de Xandikos, podria fallar con XML muy exotico.
- La respuesta puede ser grande (~600KB para 1000 contactos): el timeout default
es 30s, mayor que el de `dav_list_resources` por eso.
- Lectura remota real sobre TLS; password de `pass`, no se logea.
@@ -0,0 +1,200 @@
"""Descarga TODOS los recursos de una coleccion DAV en UNA peticion (REPORT).
Funcion impura: hace una unica peticion HTTP REPORT (`addressbook-query` para
CardDAV, `calendar-query` para CalDAV) que el servidor responde con un XML
multistatus que lleva el contenido de cada recurso INLINE (vCard / VCALENDAR).
Esto reemplaza el patron N+1 (un PROPFIND + un GET por recurso) por una sola
ida y vuelta: para 1064 contactos baja de ~9s a ~1s.
Construye el header `Authorization: Basic base64(user:pass)` a mano con stdlib y
parsea el multistatus con regex simple (sin parser XML externo), des-escapando
las entidades XML del contenido inline. Maneja errores sin lanzar. Solo usa
stdlib (urllib, base64, re, ssl, html). Probado contra Xandikos.
"""
import base64
import html
import re
import ssl
import urllib.error
import urllib.request
# Namespaces y elementos por tipo de coleccion. Xandikos (y la mayoria de
# servidores) usan el namespace "legacy" con `:ns:` que es el que aparece en el
# supported-report-set, no `urn:ietf:params:xml:carddav`.
_PROFILES = {
"vcard": {
"ns": "urn:ietf:params:xml:ns:carddav",
"report": "addressbook-query",
"data_prop": "address-data",
},
"ical": {
"ns": "urn:ietf:params:xml:ns:caldav",
"report": "calendar-query",
"data_prop": "calendar-data",
},
}
# Aceptamos sinonimos comunes para no atar al caller a un literal exacto.
_ALIASES = {
"vcard": "vcard",
"carddav": "vcard",
"contacts": "vcard",
"addressbook": "vcard",
"ical": "ical",
"icalendar": "ical",
"caldav": "ical",
"calendar": "ical",
}
_RESPONSE_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?response>(.*?)</(?:[A-Za-z0-9]+:)?response>",
re.DOTALL | re.IGNORECASE,
)
_HREF_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?href>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?href>",
re.DOTALL | re.IGNORECASE,
)
_ETAG_RE = re.compile(
r"<(?:[A-Za-z0-9]+:)?getetag>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?getetag>",
re.DOTALL | re.IGNORECASE,
)
def _basic_auth_header(username: str, password: str) -> str:
raw = ("%s:%s" % (username, password)).encode("utf-8")
return "Basic " + base64.b64encode(raw).decode("ascii")
def _join_url(base_url: str, collection_path: str) -> str:
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/"
def _report_body(profile: dict) -> str:
"""Construye el cuerpo XML del REPORT query para el perfil dado.
`addressbook-query` (CardDAV) no lleva filtro de tiempo: trae todos los
vCards. `calendar-query` (CalDAV) exige un `<filter>` con un comp-filter de
VCALENDAR; sin un comp-filter interno trae todos los componentes (todos los
eventos), que es lo que queremos.
"""
ns = profile["ns"]
report = profile["report"]
data_prop = profile["data_prop"]
prop = "<D:prop><D:getetag/><C:%s/></D:prop>" % data_prop
if report == "calendar-query":
filt = '<C:filter><C:comp-filter name="VCALENDAR"/></C:filter>'
else:
filt = ""
return (
'<?xml version="1.0" encoding="utf-8" ?>'
'<C:%s xmlns:D="DAV:" xmlns:C="%s">%s%s</C:%s>'
% (report, ns, prop, filt, report)
)
def _data_re(data_prop: str) -> "re.Pattern":
"""Regex para extraer el contenido inline del elemento de datos.
El servidor namespacea el elemento (`<ns1:address-data>`); el contenido va
XML-escapado. Capturamos el cuerpo y lo des-escapamos con html.unescape.
"""
return re.compile(
r"<(?:[A-Za-z0-9]+:)?%s[^>]*>(.*?)</(?:[A-Za-z0-9]+:)?%s>"
% (re.escape(data_prop), re.escape(data_prop)),
re.DOTALL | re.IGNORECASE,
)
def dav_get_collection(
base_url: str,
username: str,
password: str,
collection_path: str,
content_type: str = "vcard",
*,
timeout_s: float = 30.0,
verify_tls: bool = True,
) -> dict:
"""Descarga el contenido de TODOS los recursos de una coleccion en 1 request.
Hace un REPORT `addressbook-query` (vcard) o `calendar-query` (ical) que
devuelve el multistatus con el contenido inline de cada recurso, evitando
el patron N+1 (PROPFIND + un GET por recurso).
Args:
base_url: URL base del servidor DAV.
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth. Resolver desde pass.
collection_path: ruta de la coleccion (CardDAV o CalDAV).
content_type: 'vcard' (CardDAV) o 'ical' (CalDAV). Acepta sinonimos
('carddav', 'contacts', 'caldav', 'calendar', ...).
timeout_s: timeout de la peticion en segundos. Default 30.0 (la
respuesta puede ser grande: ~600KB para 1000 contactos).
verify_tls: si True (default) verifica el certificado TLS.
Returns:
dict. En exito: {status:'ok', http_status:int,
resources:[{href:str, etag:str|None, data:str}, ...]} con un elemento
por recurso de la coleccion (el `data` es el vCard / VCALENDAR ya
des-escapado). En error (sin lanzar): {status:'error', error:str,
http_status:int|None}.
"""
key = _ALIASES.get((content_type or "").strip().lower())
if key is None:
return {
"status": "error",
"error": "content_type invalido: %r (usa 'vcard' o 'ical')"
% content_type,
"http_status": None,
}
profile = _PROFILES[key]
url = _join_url(base_url, collection_path)
headers = {
"Authorization": _basic_auth_header(username, password),
"Content-Type": "application/xml; charset=utf-8",
# RFC 6352 / 4791: el query REPORT se aplica con Depth:1 sobre la
# coleccion (multiget exigiria Depth:0 + lista de hrefs; query no).
"Depth": "1",
}
req = urllib.request.Request(
url,
data=_report_body(profile).encode("utf-8"),
method="REPORT",
headers=headers,
)
context = None if verify_tls else ssl._create_unverified_context()
try:
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
status = resp.status
xml = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
except urllib.error.URLError as e:
return {"status": "error", "error": str(e.reason), "http_status": None}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e), "http_status": None}
coll_tail = collection_path.strip("/").rsplit("/", 1)[-1]
data_re = _data_re(profile["data_prop"])
resources = []
for block in _RESPONSE_RE.findall(xml):
href_m = _HREF_RE.search(block)
if not href_m:
continue
href = href_m.group(1).strip()
# Skip la propia coleccion si el servidor la incluyera.
tail = href.rstrip("/").rsplit("/", 1)[-1]
if tail == coll_tail:
continue
data_m = data_re.search(block)
if not data_m:
# Recurso sin contenido inline (404 en ese propstat): se omite.
continue
data = html.unescape(data_m.group(1)).strip()
etag_m = _ETAG_RE.search(block)
etag = etag_m.group(1).strip() if etag_m else None
resources.append({"href": href, "etag": etag, "data": data})
return {"status": "ok", "http_status": status, "resources": resources}
@@ -0,0 +1,203 @@
"""Tests para dav_get_collection.
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
Request (method REPORT, Depth, auth, cuerpo del query) y devolver un XML
multistatus simulado con contenido inline. Cubren ambos perfiles (vcard / ical),
el des-escapado de entidades XML, los sinonimos de content_type, el content_type
invalido y el path de error HTTP.
"""
import base64
import sys
import infra.dav_get_collection # noqa: F401
mod = sys.modules["infra.dav_get_collection"]
dav_get_collection = mod.dav_get_collection
# Multistatus de un addressbook-query: 2 vCards inline. El segundo contiene una
# entidad XML (&lt;) que debe des-escaparse a '<' en el campo data.
_VCARD_XML = (
'<?xml version="1.0"?>'
'<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:carddav">'
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/ada.vcf</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
'<ns0:getetag>"etag-ada"</ns0:getetag>'
"<ns1:address-data>BEGIN:VCARD\nFN:Ada\nUID:ada\nEND:VCARD\n</ns1:address-data>"
"</ns0:prop></ns0:propstat></ns0:response>"
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/alan.vcf</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
'<ns0:getetag>"etag-alan"</ns0:getetag>'
"<ns1:address-data>BEGIN:VCARD\nFN:Alan &lt;turing&gt;\nUID:alan\nEND:VCARD\n</ns1:address-data>"
"</ns0:prop></ns0:propstat></ns0:response>"
"</ns0:multistatus>"
)
# Multistatus de un calendar-query: 1 VEVENT inline.
_ICAL_XML = (
'<?xml version="1.0"?>'
'<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:caldav">'
"<ns0:response><ns0:href>/enmanuel/calendars/calendar/e1.ics</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
'<ns0:getetag>"etag-e1"</ns0:getetag>'
"<ns1:calendar-data>BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Cita\nUID:e1\nEND:VEVENT\nEND:VCALENDAR\n</ns1:calendar-data>"
"</ns0:prop></ns0:propstat></ns0:response>"
"</ns0:multistatus>"
)
class _FakeResp:
def __init__(self, payload: str):
self._payload = payload
self.status = 207
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._payload.encode("utf-8")
def _capture(monkeypatch, payload: str):
captured = {}
def fake_urlopen(req, timeout=None, context=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
captured["body"] = req.data.decode("utf-8") if req.data else ""
return _FakeResp(payload)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
return captured
def test_vcard_construye_report_addressbook_query(monkeypatch):
cap = _capture(monkeypatch, _VCARD_XML)
dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
content_type="vcard",
)
assert cap["method"] == "REPORT"
assert cap["headers"]["depth"] == "1"
assert "addressbook-query" in cap["body"]
assert "address-data" in cap["body"]
assert "urn:ietf:params:xml:ns:carddav" in cap["body"]
def test_ical_construye_report_calendar_query_con_filtro(monkeypatch):
cap = _capture(monkeypatch, _ICAL_XML)
dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/calendars/calendar/",
content_type="ical",
)
assert cap["method"] == "REPORT"
assert "calendar-query" in cap["body"]
assert 'comp-filter name="VCALENDAR"' in cap["body"]
assert "urn:ietf:params:xml:ns:caldav" in cap["body"]
def test_basic_auth_header_correcto(monkeypatch):
cap = _capture(monkeypatch, _VCARD_XML)
dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
)
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
assert cap["headers"]["authorization"] == expected
def test_parsea_resources_con_data_inline(monkeypatch):
_capture(monkeypatch, _VCARD_XML)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
)
assert res["status"] == "ok"
assert len(res["resources"]) == 2
by_href = {r["href"]: r for r in res["resources"]}
ada = by_href["/enmanuel/contacts/addressbook/ada.vcf"]
assert ada["etag"] == '"etag-ada"'
assert "BEGIN:VCARD" in ada["data"]
assert "FN:Ada" in ada["data"]
def test_desescapa_entidades_xml_del_data(monkeypatch):
_capture(monkeypatch, _VCARD_XML)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
)
alan = next(r for r in res["resources"] if r["href"].endswith("alan.vcf"))
# &lt;turing&gt; debe quedar des-escapado a <turing>.
assert "FN:Alan <turing>" in alan["data"]
def test_ical_parsea_calendar_data(monkeypatch):
_capture(monkeypatch, _ICAL_XML)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/calendars/calendar/",
content_type="ical",
)
assert res["status"] == "ok"
assert len(res["resources"]) == 1
assert "BEGIN:VEVENT" in res["resources"][0]["data"]
def test_acepta_sinonimos_de_content_type(monkeypatch):
_capture(monkeypatch, _VCARD_XML)
for alias in ("contacts", "carddav", "addressbook"):
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
content_type=alias,
)
assert res["status"] == "ok"
def test_content_type_invalido_devuelve_error(monkeypatch):
_capture(monkeypatch, _VCARD_XML)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
content_type="json",
)
assert res["status"] == "error"
assert res["http_status"] is None
def test_httperror_devuelve_status_error(monkeypatch):
def fake_urlopen(req, timeout=None, context=None):
raise mod.urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
)
assert res["status"] == "error"
assert res["http_status"] == 401
@@ -0,0 +1,94 @@
---
name: generate_initials_avatar
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def generate_initials_avatar(text: str, out_path: str, bg_hex: str = \"\", size: int = 256, fg_hex: str = \"#FFFFFF\") -> str"
description: "Genera un avatar circular de iniciales (foto de perfil) como PNG: circulo de color con 1-2 iniciales blancas centradas. Color de fondo derivado de forma determinista del texto si no se especifica."
tags: [avatar, icon, rofi, pillow, profile, initials]
params:
- name: text
desc: "Nombre del que derivar las iniciales (ej. 'John Doe', 'osint_01'). Se trocea por espacios, guiones y guiones bajos."
- name: out_path
desc: "Ruta de salida del PNG. Se crea el directorio padre si no existe. Rutas relativas se resuelven contra el cwd."
- name: bg_hex
desc: "Color de fondo del circulo en formato '#RRGGBB'. Si va vacio ('') se deriva de forma determinista de text via md5 sobre una paleta de 12 colores."
- name: size
desc: "Lado del PNG cuadrado en pixels. Default 256. El circulo deja ~4% de margen; fuera queda transparente."
- name: fg_hex
desc: "Color del texto de las iniciales en '#RRGGBB'. Default blanco '#FFFFFF'."
output: "La misma out_path recibida. Efecto: escribe un PNG RGBA cuadrado con el avatar circular en disco."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [hashlib, os, pathlib, PIL]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/generate_initials_avatar.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.generate_initials_avatar import generate_initials_avatar
# Color de fondo determinista derivado del nombre.
generate_initials_avatar("Aurgi", "/tmp/aurgi.png") # -> circulo con "A"
generate_initials_avatar("John Doe", "/tmp/john.png") # -> circulo con "JD"
# Color de fondo explicito + tamano custom.
generate_initials_avatar("Personal", "/tmp/personal.png", bg_hex="#7c3aed", size=128)
```
Desde el dispatcher (genera con defaults, fondo derivado del texto):
```bash
./fn run generate_initials_avatar_py_infra "Aurgi" /tmp/aurgi.png
```
## Cuando usarla
Cuando necesites un icono reconocible de un perfil (navegador, usuario, cuenta)
y no tengas una foto real: genera un avatar de iniciales determinista por nombre.
Util para entradas de rofi, launchers, listas de perfiles o cualquier UI que
muestre un identificador visual estable. Mismo `text` -> mismo color siempre.
## Gotchas
- **Impura**: escribe un PNG a disco. Crea el directorio padre si falta y lanza
`OSError` con mensaje claro si la escritura falla.
- **Fondo transparente**: solo el circulo (con ~4% de margen) lleva color; las
esquinas del PNG quedan con alpha 0. Si lo pegas sobre un fondo claro, el
circulo se ve recortado correctamente, pero un visor que ignore el alpha
mostrara las esquinas negras.
- **Dependencia Pillow**: requiere `PIL` (Pillow) instalado en el venv del
registry (`python/.venv`). No usa cairosvg.
- **Fuente DejaVu hardcodeada**: usa `/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf`.
Si no existe (otro SO/distro), cae a `ImageFont.load_default()`, que es mas
pequena y pixelada — las iniciales se veran peor pero no falla.
- **Antialiasing 4x**: renderiza a `size*4` y reduce con LANCZOS. Para `size`
muy grande (>1024) el coste de memoria/tiempo crece cuadraticamente.
## Notas
Reglas de iniciales: trocea por espacios, `-` y `_`; toma la primera letra
alfabetica de los dos primeros tokens que empiecen por letra (max 2, en
mayusculas). Si solo un token tiene letra inicial -> 1 inicial. Si ninguno
empieza por letra -> primer caracter alfanumerico del texto. Ejemplos:
"Aurgi" -> "A", "Work" -> "W", "osint_01" -> "O", "John Doe" -> "JD",
"Personal" -> "P".
Paleta determinista (12 colores tipo Tailwind 500): sky, emerald, violet,
amber, rose, indigo, teal, orange, fuchsia, lime, cyan, red. El indice se
elige con `int(md5(text), 16) % 12`, estable entre procesos.
Las funciones auxiliares `derive_initials(text)` y `derive_bg_color(text)` son
publicas y reutilizables por separado si solo necesitas la logica de iniciales
o de color sin generar el PNG.
@@ -0,0 +1,192 @@
"""Genera un avatar circular de iniciales tipo foto de perfil.
Dibuja un circulo relleno de color con 1-2 iniciales blancas centradas sobre
fondo transparente y lo exporta como PNG cuadrado. El color de fondo se puede
fijar explicitamente o derivar de forma DETERMINISTA del texto (mismo texto ->
mismo color siempre), lo que produce avatares reconocibles y distintos por
nombre sin necesidad de una imagen real.
Funciones publicas reutilizables:
derive_initials — extrae 1-2 iniciales en mayusculas de un nombre
derive_bg_color — mapea un texto a un color de paleta de forma estable
"""
import hashlib
import os
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# Fuente bold preinstalada en este Linux. Si no existe, se cae al default de PIL.
DEFAULT_FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
# Paleta agradable tipo Tailwind 500-600 (12 colores). El indice se elige de
# forma determinista a partir del hash del texto -> mismo texto, mismo color.
PALETTE = [
"#0ea5e9", # sky-500
"#10b981", # emerald-500
"#8b5cf6", # violet-500
"#f59e0b", # amber-500
"#f43f5e", # rose-500
"#6366f1", # indigo-500
"#14b8a6", # teal-500
"#f97316", # orange-500
"#d946ef", # fuchsia-500
"#84cc16", # lime-500
"#06b6d4", # cyan-500
"#ef4444", # red-500
]
# Factor de supersampling para antialiasing: se renderiza a NxN veces el tamano
# final y se reduce con LANCZOS para obtener bordes suaves.
_SUPERSAMPLE = 4
# Margen del circulo respecto al canvas (~4% por lado).
_MARGIN_RATIO = 0.04
# Tamano de fuente como fraccion del lado del canvas.
_FONT_RATIO = 0.46
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
"""Convierte un color "#RRGGBB" (o "RRGGBB") a una tupla RGB."""
h = h.lstrip("#")
if len(h) != 6:
raise ValueError(f"color hex invalido, se espera #RRGGBB: {h!r}")
return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4))
def derive_initials(text: str) -> str:
"""Extrae 1-2 iniciales en mayusculas a partir de un nombre.
Trocea el texto por espacios, guiones y guiones bajos. Toma la primera
letra alfabetica de los dos primeros tokens que empiecen por letra. Si solo
un token tiene letra inicial, devuelve 1 inicial. Si ninguno empieza por
letra, usa el primer caracter alfanumerico del texto completo.
Args:
text: Nombre del que derivar las iniciales (ej. "John Doe", "osint_01").
Returns:
1 o 2 caracteres en mayusculas. Cadena vacia si no hay nada alfanumerico.
"""
# Normaliza separadores a espacios.
normalized = text.replace("-", " ").replace("_", " ")
tokens = [t for t in normalized.split() if t]
initials = []
for token in tokens:
# Primera letra alfabetica del token (el token debe empezar por letra).
if token[0].isalpha():
initials.append(token[0].upper())
if len(initials) == 2:
break
if initials:
return "".join(initials)
# Fallback: primer caracter alfanumerico del texto entero.
for ch in text:
if ch.isalnum():
return ch.upper()
return ""
def derive_bg_color(text: str) -> str:
"""Mapea un texto a un color de la paleta de forma estable y determinista.
Usa md5 del texto para indexar la paleta, de modo que el mismo texto
produce siempre el mismo color entre ejecuciones y procesos.
Args:
text: Texto del que derivar el color.
Returns:
Color en formato "#RRGGBB" de la paleta interna.
"""
digest = hashlib.md5(text.encode("utf-8")).hexdigest()
idx = int(digest, 16) % len(PALETTE)
return PALETTE[idx]
def _load_font(font_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""Carga la fuente DejaVu Bold al tamano dado, con fallback al default."""
try:
return ImageFont.truetype(DEFAULT_FONT_PATH, font_size)
except OSError:
return ImageFont.load_default()
def generate_initials_avatar(
text: str,
out_path: str,
bg_hex: str = "",
size: int = 256,
fg_hex: str = "#FFFFFF",
) -> str:
"""Genera un avatar circular de iniciales y lo guarda como PNG.
Dibuja un circulo relleno con `bg_hex` (o un color derivado de `text` si va
vacio) y centra 1-2 iniciales en `fg_hex` sobre el. El fondo fuera del
circulo queda transparente. Renderiza a 4x y reduce con LANCZOS para bordes
suaves.
Args:
text: Nombre del que derivar las iniciales (ej. "Aurgi", "John Doe").
out_path: Ruta de salida del PNG. El directorio padre se crea si falta.
bg_hex: Color de fondo del circulo en "#RRGGBB". Si va vacio (""), se
deriva de forma determinista de `text`.
size: Lado del PNG cuadrado en pixels (default 256).
fg_hex: Color del texto en "#RRGGBB" (default blanco "#FFFFFF").
Returns:
La misma `out_path` recibida.
Raises:
ValueError: Si algun color no tiene formato "#RRGGBB" o `size` <= 0.
OSError: Si falla la escritura del archivo a disco.
"""
if size <= 0:
raise ValueError(f"size debe ser positivo, recibido: {size!r}")
background = bg_hex if bg_hex else derive_bg_color(text)
bg_rgb = _hex_to_rgb(background)
fg_rgb = _hex_to_rgb(fg_hex)
initials = derive_initials(text)
# Render a 4x para antialiasing, luego se reduce con LANCZOS.
big = size * _SUPERSAMPLE
canvas = Image.new("RGBA", (big, big), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
margin = int(big * _MARGIN_RATIO)
circle_box = [margin, margin, big - margin - 1, big - margin - 1]
draw.ellipse(circle_box, fill=bg_rgb + (255,))
if initials:
font_size = max(1, int(big * _FONT_RATIO))
font = _load_font(font_size)
# Bounding box real del glyph (no solo ascent) para centrado optico.
bbox = draw.textbbox((0, 0), initials, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
# El origen del texto se desplaza por el offset del bbox para que el
# glyph quede centrado tanto horizontal como verticalmente.
text_x = (big - text_w) / 2 - bbox[0]
text_y = (big - text_h) / 2 - bbox[1]
draw.text((text_x, text_y), initials, font=font, fill=fg_rgb + (255,))
final = canvas.resize((size, size), Image.LANCZOS)
out = Path(out_path)
if not out.is_absolute():
out = Path.cwd() / out
out.parent.mkdir(parents=True, exist_ok=True)
try:
final.save(out, format="PNG")
except OSError as exc:
raise OSError(f"no se pudo escribir el avatar en {out}: {exc}") from exc
return out_path
@@ -0,0 +1,103 @@
---
name: hoppscotch_create_request
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def hoppscotch_create_request(collection_id: str, method: str, url: str, *, title: str | None = None, headers: dict | None = None, body: str | None = None, body_type: str | None = None, team_id: str | None = None, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
description: "Crea una request REST dentro de una team collection de Hoppscotch self-hosted via la mutation GraphQL createRequestInCollection. Construye el HoppRESTRequest canonico reusando build_hoppscotch_collection del registry y lo envia como json string en el campo request del input. La mutation esta protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token. Algunas versiones del backend exigen team_id dentro del input."
tags: [hoppscotch, flow-replay, http, infra, crud]
uses_functions: [build_hoppscotch_collection_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, requests]
params:
- name: collection_id
desc: "ID de la team collection donde insertar la request."
- name: method
desc: "metodo HTTP de la request (GET, POST, PUT, ...). Se normaliza a mayusculas por build_hoppscotch_collection."
- name: url
desc: "endpoint completo de la request (con query string si aplica)."
- name: title
desc: "nombre visible de la request en la GUI de Hoppscotch. None = derivar de method + path (p.ej. 'GET /ping')."
- name: headers
desc: "dict name->value de cabeceras de la request. None = sin cabeceras."
- name: body
desc: "cuerpo de la request como texto YA serializado (no se re-serializa). None = sin cuerpo."
- name: body_type
desc: "tipo de cuerpo: 'json' | 'form' | 'raw' | None. Determina el contentType del HoppRESTRequest."
- name: team_id
desc: "ID de la team duena de la collection. Requerido por las versiones del backend cuyo CreateTeamRequestInput exige teamID (el self-host de referencia lo exige). Si el backend no lo pide, None."
- name: access_token
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
- name: backend_url
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
output: "dict. En exito: {status: 'ok', id: str, title: str} con el ID de la request creada. En error (GraphQL errors, respuesta no JSON, sin id, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
tested: true
tests:
- "test_golden_crea_request_y_devuelve_id"
- "test_body_json_se_serializa_en_el_request"
- "test_team_id_se_incluye_en_data"
- "test_team_id_omitido_no_aparece_en_data"
- "test_error_graphql_errors"
test_file_path: "python/functions/infra/hoppscotch_create_request_test.py"
file_path: "python/functions/infra/hoppscotch_create_request.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_create_request import hoppscotch_create_request
token = hoppscotch_login("admin@example.com")["access_token"]
# Crear una request POST con body JSON en una team collection.
result = hoppscotch_create_request(
collection_id="cmq8lt8ta000t0xls4ddy6sdz",
method="POST",
url="https://api.example.com/login",
title="Login",
headers={"Content-Type": "application/json"},
body='{"user":"neo","pass":"<<secret>>"}',
body_type="json",
team_id="cmq8kn0v500030xls1nvminjy", # requerido por este backend
access_token=token,
)
print(result) # {"status": "ok", "id": "...", "title": "Login"}
```
## Cuando usarla
Cuando el agente quiera preparar una request REST en una team Hoppscotch
self-hosted via API para que el humano la vea aparecer en vivo en la GUI (las
subscriptions de Hoppscotch propagan la creacion en tiempo real). Util en el
patron grabar->destilar->reproducir: tras destilar un flujo a call specs, se
materializan como requests dentro de una collection que el humano inspecciona.
Primero obten el `access_token` con `hoppscotch_login`.
## Gotchas
- **El access_token va como cookie, no como header Authorization.** La mutation
esta protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
con `hoppscotch_login`.
- **`request` debe ser un json string de un HoppRESTRequest v:"2".** No se pasa el
dict directo: el campo `request` del input es un string. Esta funcion serializa
con `json.dumps` el item que produce `build_hoppscotch_collection`.
- **`team_id` puede ser obligatorio.** El self-host de referencia exige `teamID`
dentro de `CreateTeamRequestInput`. Si lo omites contra ese backend, GraphQL
responde "Field teamID of required type ID! was not provided". Pasa `team_id`.
- **Secreto — nunca logear el token en crudo.** No imprimas `access_token` en
claro; trata el JWT como un secreto.
## Capability growth log
v1.0.0 — version inicial. CRUD validado contra el self-host vivo el 10/06/2026.
Se anadio `team_id` opcional porque el backend de referencia exige `teamID` en el
input.
@@ -0,0 +1,122 @@
"""Crea una request REST dentro de una team collection de Hoppscotch.
Construye el HoppRESTRequest canonico (reusando build_hoppscotch_collection del
registry) y lo inserta en una team collection via la mutation GraphQL
createRequestInCollection del backend self-hosted. La mutation esta protegida
por GqlAuthGuard: el JWT de sesion viaja en la cookie `access_token`.
"""
import json
import requests
from infra.build_hoppscotch_collection import build_hoppscotch_collection
_MUTATION = (
"mutation($c:ID!,$d:CreateTeamRequestInput!){"
" createRequestInCollection(collectionID:$c, data:$d){ id title } }"
)
def hoppscotch_create_request(
collection_id: str,
method: str,
url: str,
*,
title: str | None = None,
headers: dict | None = None,
body: str | None = None,
body_type: str | None = None,
team_id: str | None = None,
access_token: str,
backend_url: str = "http://localhost:3170",
) -> dict:
"""Crea una request en una team collection de Hoppscotch.
Args:
collection_id: ID de la team collection donde insertar la request.
method: metodo HTTP de la request (GET, POST, ...).
url: endpoint de la request.
title: nombre visible de la request en la GUI. None = derivar de
method + path via build_hoppscotch_collection.
headers: dict name->value de cabeceras de la request.
body: cuerpo de la request como texto ya serializado.
body_type: tipo de cuerpo ("json"|"form"|"raw"|None).
team_id: ID de la team duena de la collection. Requerido por las
versiones del backend cuyo CreateTeamRequestInput exige `teamID`
(el self-host de referencia lo exige). Si el backend no lo pide,
dejar None.
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
`access_token`, NO en el header Authorization.
backend_url: base del backend Hoppscotch (sin barra final).
Returns:
Dict. En exito: ``{"status": "ok", "id": str, "title": str}``. En error
(GraphQL errors, HTTP no 200, transporte): ``{"status": "error",
"error": str, "data": ...}`` con el cuerpo GraphQL si lo hubo.
"""
spec = {
"method": method,
"url": url,
"headers": headers or {},
"body": body,
"body_type": body_type,
}
req_names = [title] if title else None
req_item = build_hoppscotch_collection([spec], request_names=req_names)[
"requests"
][0]
data: dict = {
"title": req_item["name"],
"request": json.dumps(req_item),
}
if team_id is not None:
data["teamID"] = team_id
payload = {
"query": _MUTATION,
"variables": {
"c": collection_id,
"d": data,
},
}
try:
resp = requests.post(
f"{backend_url}/graphql",
json=payload,
cookies={"access_token": access_token},
timeout=30.0,
)
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
try:
data = resp.json()
except ValueError:
return {
"status": "error",
"error": f"non-JSON response (HTTP {resp.status_code})",
}
if data.get("errors"):
return {
"status": "error",
"error": "graphql errors",
"data": data,
}
created = (data.get("data") or {}).get("createRequestInCollection")
if not created or not created.get("id"):
return {
"status": "error",
"error": "createRequestInCollection returned no id",
"data": data,
}
return {
"status": "ok",
"id": created["id"],
"title": created.get("title"),
}
@@ -0,0 +1,165 @@
"""Tests para hoppscotch_create_request.
Deterministas: monkeypatchean requests.post para no tocar la red. Verifican que
el POST GraphQL lleva la mutation createRequestInCollection, el access_token en
la cookie, y que `request` es el json string de un HoppRESTRequest v:"2".
"""
import json
import sys
import infra.hoppscotch_create_request # noqa: F401
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
mod = sys.modules["infra.hoppscotch_create_request"]
class _FakeResponse:
def __init__(self, status_code=200, json_data=None):
self.status_code = status_code
self._json = json_data
def json(self):
if self._json is None:
raise ValueError("no json")
return self._json
def test_golden_crea_request_y_devuelve_id(monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["kwargs"] = kwargs
return _FakeResponse(
200,
{
"data": {
"createRequestInCollection": {
"id": "req-99",
"title": "Ping",
}
}
},
)
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_create_request(
"col-1",
"GET",
"https://api.example.com/ping",
title="Ping",
headers={"Accept": "application/json"},
access_token="ACCESS-JWT",
)
assert result["status"] == "ok"
assert result["id"] == "req-99"
assert result["title"] == "Ping"
# El POST fue al endpoint GraphQL con la cookie access_token.
assert captured["url"].endswith("/graphql")
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
payload = captured["kwargs"]["json"]
assert "createRequestInCollection" in payload["query"]
variables = payload["variables"]
assert variables["c"] == "col-1"
assert variables["d"]["title"] == "Ping"
# `request` es un json string de un HoppRESTRequest v:"2".
req = json.loads(variables["d"]["request"])
assert req["v"] == "2"
assert req["method"] == "GET"
assert req["endpoint"] == "https://api.example.com/ping"
assert req["name"] == "Ping"
def test_body_json_se_serializa_en_el_request(monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["kwargs"] = kwargs
return _FakeResponse(
200,
{"data": {"createRequestInCollection": {"id": "r", "title": "t"}}},
)
monkeypatch.setattr(mod.requests, "post", fake_post)
mod.hoppscotch_create_request(
"col-2",
"POST",
"https://api.example.com/login",
body='{"user":"neo"}',
body_type="json",
access_token="A",
)
req = json.loads(captured["kwargs"]["json"]["variables"]["d"]["request"])
assert req["method"] == "POST"
assert req["body"] == {
"contentType": "application/json",
"body": '{"user":"neo"}',
}
def test_team_id_se_incluye_en_data(monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["kwargs"] = kwargs
return _FakeResponse(
200,
{"data": {"createRequestInCollection": {"id": "r", "title": "t"}}},
)
monkeypatch.setattr(mod.requests, "post", fake_post)
mod.hoppscotch_create_request(
"col-3",
"GET",
"https://api.example.com/x",
team_id="team-abc",
access_token="A",
)
data = captured["kwargs"]["json"]["variables"]["d"]
assert data["teamID"] == "team-abc"
def test_team_id_omitido_no_aparece_en_data(monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["kwargs"] = kwargs
return _FakeResponse(
200,
{"data": {"createRequestInCollection": {"id": "r", "title": "t"}}},
)
monkeypatch.setattr(mod.requests, "post", fake_post)
mod.hoppscotch_create_request(
"col-4", "GET", "https://api.example.com/x", access_token="A"
)
data = captured["kwargs"]["json"]["variables"]["d"]
assert "teamID" not in data
def test_error_graphql_errors(monkeypatch):
def fake_post(url, **kwargs):
return _FakeResponse(
200, {"errors": [{"message": "team_req/not_found"}]}
)
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_create_request(
"bad", "GET", "https://x", access_token="A"
)
assert result["status"] == "error"
assert result["error"] == "graphql errors"
assert "errors" in result["data"]
@@ -0,0 +1,73 @@
---
name: hoppscotch_delete_request
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def hoppscotch_delete_request(request_id: str, *, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
description: "Borra una request REST de una team collection de Hoppscotch self-hosted via la mutation GraphQL deleteRequest. Protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token. Confirma que la mutation devolvio true antes de reportar exito."
tags: [hoppscotch, flow-replay, http, infra, crud]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests]
params:
- name: request_id
desc: "ID de la request a borrar."
- name: access_token
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
- name: backend_url
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
output: "dict. En exito (data.deleteRequest == true): {status: 'ok', deleted: str}. En error (GraphQL errors, deleteRequest != true, respuesta no JSON, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
tested: true
tests:
- "test_golden_delete_true"
- "test_error_delete_false"
test_file_path: "python/functions/infra/hoppscotch_delete_request_test.py"
file_path: "python/functions/infra/hoppscotch_delete_request.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_delete_request import hoppscotch_delete_request
token = hoppscotch_login("admin@example.com")["access_token"]
result = hoppscotch_delete_request(
request_id="cmq8lue8l000x0xlsd62bncpi",
access_token=token,
)
print(result) # {"status": "ok", "deleted": "cmq8lue8l000x0xlsd62bncpi"}
```
## Cuando usarla
Cuando quieras eliminar una request que el agente creo (o que ya no hace falta) de
una team collection Hoppscotch self-hosted, y que el humano vea la baja en vivo en
la GUI por subscriptions. Util para limpiar requests temporales tras un flujo de
prueba. Necesitas el `request_id` (de `hoppscotch_list_requests`) y un
`access_token` fresco de `hoppscotch_login`.
## Gotchas
- **El access_token va como cookie, no como header Authorization.** La mutation
esta protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
con `hoppscotch_login`.
- **Operacion destructiva.** Borra la request de verdad; no es reversible. Confirma
el `request_id` (p.ej. con `hoppscotch_list_requests`) antes de borrar.
- **Solo `data.deleteRequest == true` es exito.** Cualquier otro valor (false, null)
o un bloque `errors` se reporta como `status: 'error'`.
- **Secreto — nunca logear el token en crudo.** Trata el JWT como un secreto.
## Capability growth log
v1.0.0 — version inicial. Validado contra el self-host vivo el 10/06/2026
(delete confirmo que la request desaparece de la lista posterior).
@@ -0,0 +1,69 @@
"""Borra una request REST de una team collection de Hoppscotch.
Invoca la mutation GraphQL deleteRequest del backend self-hosted. Protegida por
GqlAuthGuard: el JWT de sesion viaja en la cookie `access_token`.
"""
import requests
_MUTATION = "mutation($r:ID!){ deleteRequest(requestID:$r) }"
def hoppscotch_delete_request(
request_id: str,
*,
access_token: str,
backend_url: str = "http://localhost:3170",
) -> dict:
"""Borra una request de Hoppscotch por su ID.
Args:
request_id: ID de la request a borrar.
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
`access_token`, NO en el header Authorization.
backend_url: base del backend Hoppscotch (sin barra final).
Returns:
Dict. En exito: ``{"status": "ok", "deleted": str}``. En error (GraphQL
errors, deleteRequest != true, HTTP no 200, transporte):
``{"status": "error", "error": str, "data": ...}``.
"""
payload = {
"query": _MUTATION,
"variables": {"r": request_id},
}
try:
resp = requests.post(
f"{backend_url}/graphql",
json=payload,
cookies={"access_token": access_token},
timeout=30.0,
)
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
try:
data = resp.json()
except ValueError:
return {
"status": "error",
"error": f"non-JSON response (HTTP {resp.status_code})",
}
if data.get("errors"):
return {
"status": "error",
"error": "graphql errors",
"data": data,
}
deleted = (data.get("data") or {}).get("deleteRequest")
if deleted is not True:
return {
"status": "error",
"error": "deleteRequest did not return true",
"data": data,
}
return {"status": "ok", "deleted": request_id}
@@ -0,0 +1,54 @@
"""Tests para hoppscotch_delete_request.
Deterministas: monkeypatchean requests.post. Verifican el camino ok
(deleteRequest=true) y el de error (deleteRequest=false).
"""
import sys
import infra.hoppscotch_delete_request # noqa: F401
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
mod = sys.modules["infra.hoppscotch_delete_request"]
class _FakeResponse:
def __init__(self, status_code=200, json_data=None):
self.status_code = status_code
self._json = json_data
def json(self):
if self._json is None:
raise ValueError("no json")
return self._json
def test_golden_delete_true(monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["kwargs"] = kwargs
return _FakeResponse(200, {"data": {"deleteRequest": True}})
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_delete_request("req-1", access_token="ACCESS-JWT")
assert result["status"] == "ok"
assert result["deleted"] == "req-1"
assert captured["url"].endswith("/graphql")
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
assert "deleteRequest" in captured["kwargs"]["json"]["query"]
assert captured["kwargs"]["json"]["variables"] == {"r": "req-1"}
def test_error_delete_false(monkeypatch):
def fake_post(url, **kwargs):
return _FakeResponse(200, {"data": {"deleteRequest": False}})
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_delete_request("req-2", access_token="A")
assert result["status"] == "error"
assert "did not return true" in result["error"]
@@ -0,0 +1,79 @@
"""Test e2e real del grupo hoppscotch contra un self-host VIVO.
NO corre en la suite normal (skip). Para ejecutarlo a mano contra la instancia
viva, quita temporalmente el skip y asegura:
- backend Hoppscotch en http://localhost:3170
- mailpit en http://localhost:8025
- una team collection real cuyo ID pongas en COLLECTION_ID abajo.
El flujo: login(admin@example.com) -> create_request -> list -> delete.
Nota: el backend de referencia exige `teamID` dentro de CreateTeamRequestInput,
asi que la create pasa `team_id=TEAM_ID`. Rellena COLLECTION_ID y TEAM_ID con
una team collection real (consultables via myTeams / rootCollectionsOfTeam).
Este flujo se valido con exito contra la instancia viva el 10/06/2026.
"""
import pytest
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_create_request import hoppscotch_create_request
from infra.hoppscotch_list_requests import hoppscotch_list_requests
from infra.hoppscotch_delete_request import hoppscotch_delete_request
from infra.hoppscotch_run_request import hoppscotch_run_request
# Rellenar con IDs reales del self-host antes de correr.
TEAM_ID = "REPLACE_WITH_REAL_TEAM_ID"
COLLECTION_ID = "REPLACE_WITH_REAL_COLLECTION_ID"
@pytest.mark.skip(reason="e2e real contra self-host vivo, correr a mano")
def test_e2e_crud_request_real():
login = hoppscotch_login("admin@example.com")
assert login["status"] == "ok", login
token = login["access_token"]
created = hoppscotch_create_request(
COLLECTION_ID,
"GET",
"https://api.example.com/e2e-ping",
title="e2e ping",
team_id=TEAM_ID,
access_token=token,
)
assert created["status"] == "ok", created
req_id = created["id"]
listed = hoppscotch_list_requests(COLLECTION_ID, access_token=token)
assert listed["status"] == "ok", listed
assert any(r["id"] == req_id for r in listed["requests"])
deleted = hoppscotch_delete_request(req_id, access_token=token)
assert deleted["status"] == "ok", deleted
assert deleted["deleted"] == req_id
@pytest.mark.skip(reason="e2e real self-host vivo")
def test_e2e_run_request_aparece_en_user_history():
"""Ejecuta una request real y verifica que entra en el UserHistory.
login -> run_request GET <<baseURL>>/api/status -> status_code 200 +
recorded True. La entry debe verse en la pestana History de la GUI
(subscription userHistoryCreated). Validado contra la instancia viva.
"""
login = hoppscotch_login("admin@example.com")
assert login["status"] == "ok", login
token = login["access_token"]
result = hoppscotch_run_request(
"GET",
"<<baseURL>>/api/status",
title="Status (e2e)",
variables={"baseURL": "https://registry.organic-machine.com"},
access_token=token,
)
assert result["status"] == "ok", result
assert result["status_code"] == 200, result
assert result["recorded"] is True, result
assert result["history_id"], result
@@ -0,0 +1,78 @@
---
name: hoppscotch_list_requests
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def hoppscotch_list_requests(collection_id: str, *, access_token: str, backend_url: str = \"http://localhost:3170\", take: int = 50) -> dict"
description: "Lista las requests de una team collection de Hoppscotch self-hosted via la query GraphQL requestsInCollection. Devuelve cada request como {id, title}. Protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token."
tags: [hoppscotch, flow-replay, http, infra, crud]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests]
params:
- name: collection_id
desc: "ID de la team collection cuyas requests se quieren listar."
- name: access_token
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
- name: backend_url
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
- name: take
desc: "numero maximo de requests a devolver (argumento take de la query). Default 50."
output: "dict. En exito: {status: 'ok', requests: [{id: str, title: str}, ...]}. En error (GraphQL errors, requestsInCollection null, respuesta no JSON, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
tested: true
tests:
- "test_golden_lista_dos_requests"
- "test_take_se_pasa_a_la_query"
- "test_error_graphql_errors"
test_file_path: "python/functions/infra/hoppscotch_list_requests_test.py"
file_path: "python/functions/infra/hoppscotch_list_requests.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_list_requests import hoppscotch_list_requests
token = hoppscotch_login("admin@example.com")["access_token"]
result = hoppscotch_list_requests(
collection_id="cmq8lt8ta000t0xls4ddy6sdz",
access_token=token,
)
print(result)
# {"status": "ok", "requests": [{"id": "...", "title": "Login"}, ...]}
```
## Cuando usarla
Cuando necesites enumerar las requests de una team collection Hoppscotch
self-hosted: para verificar que un `hoppscotch_create_request` aparecio, para
obtener el `request_id` que pasar a `hoppscotch_update_request` /
`hoppscotch_delete_request`, o para auditar el contenido de una collection antes
de modificarla. Necesitas un `access_token` fresco de `hoppscotch_login`.
## Gotchas
- **El access_token va como cookie, no como header Authorization.** La query esta
protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
con `hoppscotch_login`.
- **Devuelve solo {id, title}, no el HoppRESTRequest completo.** La query pide
unicamente id y title; no incluye method/url/headers/body. Para el cuerpo
completo de una request, consulta su detalle aparte.
- **`take` limita el resultado.** Solo se devuelven hasta `take` requests (default
50). Sube `take` si la collection tiene mas.
- **Secreto — nunca logear el token en crudo.** Trata el JWT como un secreto.
## Capability growth log
v1.0.0 — version inicial. Validado contra el self-host vivo el 10/06/2026
(list reflejo la creacion y la baja de una request real).
@@ -0,0 +1,79 @@
"""Lista las requests de una team collection de Hoppscotch.
Invoca la query GraphQL requestsInCollection del backend self-hosted. Protegida
por GqlAuthGuard: el JWT de sesion viaja en la cookie `access_token`.
"""
import requests
_QUERY = (
"query($c:ID!,$t:Int){"
" requestsInCollection(collectionID:$c, take:$t){ id title } }"
)
def hoppscotch_list_requests(
collection_id: str,
*,
access_token: str,
backend_url: str = "http://localhost:3170",
take: int = 50,
) -> dict:
"""Lista las requests de una team collection de Hoppscotch.
Args:
collection_id: ID de la team collection a listar.
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
`access_token`, NO en el header Authorization.
backend_url: base del backend Hoppscotch (sin barra final).
take: numero maximo de requests a devolver (default 50).
Returns:
Dict. En exito: ``{"status": "ok", "requests": [{"id": str,
"title": str}, ...]}``. En error (GraphQL errors, HTTP no 200,
transporte): ``{"status": "error", "error": str, "data": ...}``.
"""
payload = {
"query": _QUERY,
"variables": {"c": collection_id, "t": take},
}
try:
resp = requests.post(
f"{backend_url}/graphql",
json=payload,
cookies={"access_token": access_token},
timeout=30.0,
)
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
try:
data = resp.json()
except ValueError:
return {
"status": "error",
"error": f"non-JSON response (HTTP {resp.status_code})",
}
if data.get("errors"):
return {
"status": "error",
"error": "graphql errors",
"data": data,
}
rows = (data.get("data") or {}).get("requestsInCollection")
if rows is None:
return {
"status": "error",
"error": "requestsInCollection returned null",
"data": data,
}
return {
"status": "ok",
"requests": [
{"id": r.get("id"), "title": r.get("title")} for r in rows
],
}
@@ -0,0 +1,85 @@
"""Tests para hoppscotch_list_requests.
Deterministas: monkeypatchean requests.post. Verifican el camino ok (devuelve la
lista normalizada) y el de error (GraphQL errors).
"""
import sys
import infra.hoppscotch_list_requests # noqa: F401
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
mod = sys.modules["infra.hoppscotch_list_requests"]
class _FakeResponse:
def __init__(self, status_code=200, json_data=None):
self.status_code = status_code
self._json = json_data
def json(self):
if self._json is None:
raise ValueError("no json")
return self._json
def test_golden_lista_dos_requests(monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["kwargs"] = kwargs
return _FakeResponse(
200,
{
"data": {
"requestsInCollection": [
{"id": "r1", "title": "Ping"},
{"id": "r2", "title": "Login"},
]
}
},
)
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_list_requests("col-1", access_token="ACCESS-JWT")
assert result["status"] == "ok"
assert result["requests"] == [
{"id": "r1", "title": "Ping"},
{"id": "r2", "title": "Login"},
]
assert captured["url"].endswith("/graphql")
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
assert "requestsInCollection" in captured["kwargs"]["json"]["query"]
assert captured["kwargs"]["json"]["variables"] == {"c": "col-1", "t": 50}
def test_take_se_pasa_a_la_query(monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["kwargs"] = kwargs
return _FakeResponse(200, {"data": {"requestsInCollection": []}})
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_list_requests("col", access_token="A", take=10)
assert result["status"] == "ok"
assert result["requests"] == []
assert captured["kwargs"]["json"]["variables"]["t"] == 10
def test_error_graphql_errors(monkeypatch):
def fake_post(url, **kwargs):
return _FakeResponse(
200, {"errors": [{"message": "team_coll/not_found"}]}
)
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_list_requests("bad", access_token="A")
assert result["status"] == "error"
assert result["error"] == "graphql errors"
assert "errors" in result["data"]
@@ -0,0 +1,94 @@
---
name: hoppscotch_login
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def hoppscotch_login(email: str, *, backend_url: str = \"http://localhost:3170\", mailpit_url: str = \"http://localhost:8025\", timeout_s: float = 15.0) -> dict"
description: "Login headless contra un Hoppscotch self-hosted via magic link, leyendo el correo de verificacion desde una instancia Mailpit de pruebas. Reproduce el flujo sin navegador: POST /v1/auth/signin (deviceIdentifier) -> lee el correo 'Sign in' del email en Mailpit -> extrae el token (?token=...) del cuerpo -> POST /v1/auth/verify (Set-Cookie access_token + refresh_token). Devuelve los JWT de sesion que las mutations GraphQL protegidas esperan en la cookie access_token."
tags: [hoppscotch, flow-replay, http, infra, auth]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [re, requests]
params:
- name: email
desc: "correo del usuario que inicia sesion. Debe poder recibir el correo de verificacion en la instancia Mailpit indicada (en el self-host de pruebas, admin@example.com)."
- name: backend_url
desc: "base del backend Hoppscotch sin barra final. Los endpoints REST de auth cuelgan de {backend_url}/v1/auth/signin y /v1/auth/verify. Default http://localhost:3170."
- name: mailpit_url
desc: "base de la API de Mailpit donde aterriza el correo de verificacion, sin barra final. Default http://localhost:8025."
- name: timeout_s
desc: "timeout por request HTTP en segundos. Default 15.0."
output: "dict. En exito: {status: 'ok', access_token: str, refresh_token: str, email: str}. En error (signin != 201, no llega correo 'Sign in', token no encontrado en el correo, verify != 200, o fallo de transporte): {status: 'error', error: str}. Nunca lanza por errores de red esperables."
tested: true
tests:
- "test_golden_login_devuelve_tokens"
- "test_verify_recibe_token_extraido_y_device_identifier"
- "test_error_signin_no_201"
- "test_error_correo_no_encontrado"
- "test_error_token_no_en_correo"
test_file_path: "python/functions/infra/hoppscotch_login_test.py"
file_path: "python/functions/infra/hoppscotch_login.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_create_request import hoppscotch_create_request
# 1) Obtener un JWT de sesion via magic link (headless, lee el correo de Mailpit).
login = hoppscotch_login("admin@example.com")
assert login["status"] == "ok", login["error"]
token = login["access_token"]
# 2) Usar el token para crear una request en una team collection.
# El self-host de referencia exige team_id dentro del input.
created = hoppscotch_create_request(
collection_id="cmq8lt8ta000t0xls4ddy6sdz",
method="GET",
url="https://api.example.com/ping",
title="Ping",
team_id="cmq8kn0v500030xls1nvminjy",
access_token=token,
)
print(created) # {"status": "ok", "id": "...", "title": "Ping"}
```
## Cuando usarla
Cuando necesites un JWT de sesion de un Hoppscotch self-hosted para operar su API
GraphQL protegida (crear/editar/borrar requests, gestionar collections) sin abrir
el navegador. Es el primer paso de cualquier flujo CRUD del grupo `hoppscotch`:
llama esto, captura `access_token`, y paselo a `hoppscotch_create_request` /
`hoppscotch_update_request` / `hoppscotch_delete_request` / `hoppscotch_list_requests`.
Requiere que el backend mande el correo de verificacion a una instancia Mailpit
accesible (entorno de pruebas).
## Gotchas
- **El access_token va como cookie, no como header Authorization.** Las mutations
GraphQL leen el JWT de la cookie `access_token`. Cada funcion del grupo lo manda
con `cookies={"access_token": ...}`.
- **El token expira (~24h).** Cuando una llamada GraphQL devuelva un error de auth,
re-loguea con `hoppscotch_login` para obtener un access_token fresco.
- **Depende de Mailpit.** El flujo lee el correo de verificacion de una instancia
Mailpit de pruebas. No funciona contra un backend que mande el correo a un buzon
real al que esta funcion no pueda consultar por API.
- **Secreto — nunca logear el token en crudo.** `access_token`/`refresh_token` son
credenciales de sesion. No los imprimas ni los persistas en claro; trataelos como
un secreto (vault/pass) si los guardas entre ejecuciones.
- **Coincidencia del correo por subject + destinatario.** Se elige el mensaje mas
reciente cuyo destinatario sea `email` y cuyo subject contenga "Sign in". Si hay
varios magic links pendientes para el mismo email, se usa el ultimo de la lista.
## Capability growth log
v1.0.0 — version inicial. Flujo magic link headless validado contra el self-host
vivo (login + CRUD completo) el 10/06/2026.
+179
View File
@@ -0,0 +1,179 @@
"""Login headless contra un Hoppscotch self-hosted via magic link.
Reproduce el flujo de magic link de Hoppscotch sin navegador, leyendo el
correo de verificacion desde una instancia Mailpit de pruebas:
1. POST /v1/auth/signin -> deviceIdentifier
2. GET mailpit messages -> ultimo correo "Sign in" para ese email
3. GET mailpit message/{id} -> extrae el token (?token=...) del cuerpo
4. POST /v1/auth/verify -> Set-Cookie access_token + refresh_token
Devuelve los JWT de sesion (access_token / refresh_token). El access_token es
el que las mutations GraphQL protegidas por GqlAuthGuard esperan en la cookie
`access_token` (no en el header Authorization).
"""
import re
import requests
# El correo de Hoppscotch incluye un enlace con ?token=<jwt>. El token es un
# JWT (3 segmentos base64url separados por puntos), asi que aceptamos letras,
# digitos, guion, guion bajo y punto.
_TOKEN_RE = re.compile(r"token=([A-Za-z0-9_\-.]+)")
def hoppscotch_login(
email: str,
*,
backend_url: str = "http://localhost:3170",
mailpit_url: str = "http://localhost:8025",
timeout_s: float = 15.0,
) -> dict:
"""Obtiene un JWT de sesion de Hoppscotch via magic link (headless).
Args:
email: correo del usuario que inicia sesion. Debe poder recibir el
correo de verificacion en la instancia Mailpit indicada.
backend_url: base del backend Hoppscotch (sin barra final). El endpoint
REST de auth cuelga de ``{backend_url}/v1/auth/...``.
mailpit_url: base de la API de Mailpit donde aterriza el correo de
verificacion (sin barra final).
timeout_s: timeout por request HTTP en segundos.
Returns:
Dict. En exito:
``{"status": "ok", "access_token": str, "refresh_token": str,
"email": str}``. En error (signin no 201, no llega correo, token no
encontrado, verify no 200, o fallo de transporte):
``{"status": "error", "error": str}``.
"""
session = requests.Session()
try:
# 1) Signin: pide el magic link. Respuesta 201 con deviceIdentifier.
signin = session.post(
f"{backend_url}/v1/auth/signin",
json={"email": email},
timeout=timeout_s,
)
if signin.status_code != 201:
return {
"status": "error",
"error": (
f"signin returned {signin.status_code} "
f"(expected 201): {signin.text[:200]}"
),
}
try:
device_identifier = signin.json().get("deviceIdentifier")
except ValueError:
device_identifier = None
if not device_identifier:
return {
"status": "error",
"error": "signin response missing deviceIdentifier",
}
# 2) Localiza el correo de verificacion mas reciente para este email.
messages = session.get(
f"{mailpit_url}/api/v1/messages",
params={"limit": 5},
timeout=timeout_s,
)
if messages.status_code != 200:
return {
"status": "error",
"error": (
f"mailpit messages returned {messages.status_code} "
"(expected 200)"
),
}
try:
inbox = messages.json().get("messages") or []
except ValueError:
return {
"status": "error",
"error": "mailpit messages response is not valid JSON",
}
message_id = None
for msg in inbox:
recipients = msg.get("To") or []
to_match = any(
(addr.get("Address") or "").lower() == email.lower()
for addr in recipients
)
subject = msg.get("Subject") or ""
if to_match and "Sign in" in subject:
message_id = msg.get("ID")
break
if not message_id:
return {
"status": "error",
"error": f"no 'Sign in' email found for {email} in mailpit",
}
# 3) Descarga el cuerpo del correo y extrae el token.
message = session.get(
f"{mailpit_url}/api/v1/message/{message_id}",
timeout=timeout_s,
)
if message.status_code != 200:
return {
"status": "error",
"error": (
f"mailpit message returned {message.status_code} "
"(expected 200)"
),
}
try:
body = message.json()
except ValueError:
return {
"status": "error",
"error": "mailpit message response is not valid JSON",
}
haystack = f"{body.get('Text') or ''}\n{body.get('HTML') or ''}"
token_match = _TOKEN_RE.search(haystack)
if not token_match:
return {
"status": "error",
"error": "magic-link token not found in verification email",
}
token = token_match.group(1)
# 4) Verify: canjea el token + deviceIdentifier por las cookies de
# sesion (access_token / refresh_token).
verify = session.post(
f"{backend_url}/v1/auth/verify",
json={"token": token, "deviceIdentifier": device_identifier},
timeout=timeout_s,
)
if verify.status_code != 200:
return {
"status": "error",
"error": (
f"verify returned {verify.status_code} "
f"(expected 200): {verify.text[:200]}"
),
}
access_token = session.cookies.get("access_token")
refresh_token = session.cookies.get("refresh_token")
if not access_token:
return {
"status": "error",
"error": "verify succeeded but no access_token cookie was set",
}
return {
"status": "ok",
"access_token": access_token,
"refresh_token": refresh_token,
"email": email,
}
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
finally:
session.close()
@@ -0,0 +1,216 @@
"""Tests para hoppscotch_login.
Deterministas: monkeypatchean requests.Session para no tocar la red. Simulan el
flujo magic link completo (signin -> mailpit list -> mailpit message -> verify)
y verifican que se devuelven los JWT, asi como los caminos de error.
"""
import sys
import infra.hoppscotch_login # noqa: F401 (registra el submodulo en sys.modules)
# El __init__ del paquete rebinds el nombre `hoppscotch_login` a la funcion,
# que sombrea el submodulo. Recuperamos el submodulo real desde sys.modules
# para monkeypatchear su simbolo `requests`.
mod = sys.modules["infra.hoppscotch_login"]
class _FakeResponse:
"""Respuesta HTTP mockeada minima: status_code, json(), text."""
def __init__(self, status_code=200, json_data=None, text=""):
self.status_code = status_code
self._json = json_data
self.text = text
def json(self):
if self._json is None:
raise ValueError("no json")
return self._json
class _FakeCookies:
def __init__(self, store):
self._store = store
def get(self, name):
return self._store.get(name)
class _FakeSession:
"""Session mockeada: despacha por (method, path) a respuestas predefinidas."""
def __init__(self, routes, cookie_store):
self._routes = routes
self.cookies = _FakeCookies(cookie_store)
self.calls = []
def _dispatch(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
for (m, fragment), resp in self._routes.items():
if m == method and fragment in url:
return resp
raise AssertionError(f"unexpected {method} {url}")
def post(self, url, **kwargs):
return self._dispatch("POST", url, **kwargs)
def get(self, url, **kwargs):
return self._dispatch("GET", url, **kwargs)
def close(self):
pass
def _install_session(monkeypatch, routes, cookie_store):
session = _FakeSession(routes, cookie_store)
monkeypatch.setattr(mod.requests, "Session", lambda: session)
return session
def test_golden_login_devuelve_tokens(monkeypatch):
routes = {
("POST", "/v1/auth/signin"): _FakeResponse(
201, {"deviceIdentifier": "dev-123"}
),
("GET", "/api/v1/messages"): _FakeResponse(
200,
{
"messages": [
{
"ID": "msg-1",
"Subject": "Sign in to Hoppscotch",
"To": [{"Address": "admin@example.com"}],
}
]
},
),
("GET", "/api/v1/message/msg-1"): _FakeResponse(
200,
{
"Text": "Click here",
"HTML": (
"<a href='http://localhost:3170/?token="
"eyJhbGciOi.JhbGci_Q-zz'>Sign in</a>"
),
},
),
("POST", "/v1/auth/verify"): _FakeResponse(200, {"ok": True}),
}
_install_session(
monkeypatch,
routes,
{"access_token": "ACCESS-JWT", "refresh_token": "REFRESH-JWT"},
)
result = mod.hoppscotch_login("admin@example.com")
assert result["status"] == "ok"
assert result["access_token"] == "ACCESS-JWT"
assert result["refresh_token"] == "REFRESH-JWT"
assert result["email"] == "admin@example.com"
def test_verify_recibe_token_extraido_y_device_identifier(monkeypatch):
routes = {
("POST", "/v1/auth/signin"): _FakeResponse(
201, {"deviceIdentifier": "dev-xyz"}
),
("GET", "/api/v1/messages"): _FakeResponse(
200,
{
"messages": [
{
"ID": "m9",
"Subject": "Sign in",
"To": [{"Address": "admin@example.com"}],
}
]
},
),
("GET", "/api/v1/message/m9"): _FakeResponse(
200,
{"Text": "verify at ?token=abc.DEF-123_456", "HTML": ""},
),
("POST", "/v1/auth/verify"): _FakeResponse(200, {}),
}
session = _install_session(
monkeypatch, routes, {"access_token": "A", "refresh_token": "R"}
)
result = mod.hoppscotch_login("admin@example.com")
assert result["status"] == "ok"
# El POST a verify llevo el token extraido del correo + el deviceIdentifier.
verify_call = next(
c for c in session.calls if c[0] == "POST" and "verify" in c[1]
)
sent = verify_call[2]["json"]
assert sent["token"] == "abc.DEF-123_456"
assert sent["deviceIdentifier"] == "dev-xyz"
def test_error_signin_no_201(monkeypatch):
routes = {
("POST", "/v1/auth/signin"): _FakeResponse(
500, None, text="boom"
),
}
_install_session(monkeypatch, routes, {})
result = mod.hoppscotch_login("admin@example.com")
assert result["status"] == "error"
assert "signin returned 500" in result["error"]
def test_error_correo_no_encontrado(monkeypatch):
routes = {
("POST", "/v1/auth/signin"): _FakeResponse(
201, {"deviceIdentifier": "d"}
),
("GET", "/api/v1/messages"): _FakeResponse(
200,
{
"messages": [
{
"ID": "x",
"Subject": "Newsletter",
"To": [{"Address": "other@example.com"}],
}
]
},
),
}
_install_session(monkeypatch, routes, {})
result = mod.hoppscotch_login("admin@example.com")
assert result["status"] == "error"
assert "no 'Sign in' email" in result["error"]
def test_error_token_no_en_correo(monkeypatch):
routes = {
("POST", "/v1/auth/signin"): _FakeResponse(
201, {"deviceIdentifier": "d"}
),
("GET", "/api/v1/messages"): _FakeResponse(
200,
{
"messages": [
{
"ID": "m",
"Subject": "Sign in",
"To": [{"Address": "admin@example.com"}],
}
]
},
),
("GET", "/api/v1/message/m"): _FakeResponse(
200, {"Text": "no token here", "HTML": "<p>nada</p>"}
),
}
_install_session(monkeypatch, routes, {})
result = mod.hoppscotch_login("admin@example.com")
assert result["status"] == "error"
assert "token not found" in result["error"]
@@ -0,0 +1,118 @@
---
name: hoppscotch_run_request
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def hoppscotch_run_request(method: str, url: str, *, title: str | None = None, headers: dict | None = None, body: str | None = None, body_type: str | None = None, variables: dict | None = None, access_token: str, backend_url: str = \"http://localhost:3170\", record_history: bool = True, timeout_s: float = 30.0, verify_tls: bool = True) -> dict"
description: "Ejecuta una peticion HTTP real (resolviendo placeholders <<var>>/{{var}} con un dict de variables) y la registra en el UserHistory de un Hoppscotch self-hosted via la mutation GraphQL createUserHistory, para que el humano la vea aparecer en vivo en la pestana History de su GUI (subscription userHistoryCreated). La request se ejecuta con las variables resueltas, pero en el History se guarda SIN resolver (con los literales <<var>>) igual que en el editor. resMetadata minimo: statusCode + duration. El access_token va como cookie, no como header Authorization."
tags: [hoppscotch, flow-replay, http]
uses_functions: [build_hoppscotch_collection_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, re, requests]
params:
- name: method
desc: "metodo HTTP de la peticion (GET, POST, ...)."
- name: url
desc: "endpoint de la peticion. Puede contener placeholders <<var>> o {{var}} que se resuelven con `variables` antes de ejecutar."
- name: title
desc: "nombre visible de la request en el History. None = derivar de method + path via build_hoppscotch_collection."
- name: headers
desc: "dict name->value de cabeceras. Sus values tambien admiten placeholders <<var>>/{{var}}."
- name: body
desc: "cuerpo de la peticion como texto ya serializado. Admite placeholders. None = sin cuerpo."
- name: body_type
desc: "tipo de cuerpo para el HoppRESTRequest del History: 'json' | 'form' | 'raw' | None."
- name: variables
desc: "dict name->value para resolver los placeholders al EJECUTAR. Una variable que falte deja el literal intacto. None = no se resuelve nada."
- name: access_token
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization. Necesario para registrar en el History."
- name: backend_url
desc: "base del backend Hoppscotch self-host sin barra final. La mutation cuelga de {backend_url}/graphql. Default http://localhost:3170."
- name: record_history
desc: "si True y hay access_token, registra la request ejecutada en el UserHistory via createUserHistory. Default True."
- name: timeout_s
desc: "timeout en segundos de la peticion HTTP ejecutada (y del POST de History). Default 30.0."
- name: verify_tls
desc: "verificacion del certificado TLS de la peticion ejecutada. Default True."
output: "dict. En exito de la ejecucion HTTP: {status: 'ok', status_code: int, duration_ms: int, response_body: str (truncado a 5000 chars), response_headers: dict, recorded: bool, history_id: str|None}. Si la ejecucion fue ok pero el registro de History fallo, status sigue 'ok', recorded False y se anade history_error. Si la ejecucion HTTP falla (RequestException): {status: 'error', error: str, recorded: False}. Nunca lanza por errores de red esperables."
tested: true
tests:
- "test_ejecuta_resolviendo_variables_angle"
- "test_ejecuta_resolviendo_variables_brace"
- "test_record_history_registra_request_sin_resolver"
- "test_record_history_false_no_llama_create_user_history"
- "test_request_exception_status_error"
- "test_variable_faltante_conserva_literal"
test_file_path: "python/functions/infra/hoppscotch_run_request_test.py"
file_path: "python/functions/infra/hoppscotch_run_request.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_run_request import hoppscotch_run_request
# 1) Obtener un JWT de sesion (headless, lee el correo de Mailpit).
login = hoppscotch_login("admin@example.com")
assert login["status"] == "ok", login["error"]
token = login["access_token"]
# 2) Ejecutar una request con una variable y dejar rastro en el History de la GUI.
result = hoppscotch_run_request(
"GET",
"<<baseURL>>/api/status",
title="Status",
variables={"baseURL": "https://registry.organic-machine.com"},
access_token=token,
)
print(result["status_code"], result["recorded"], result["history_id"])
# 200 True hist-...
# -> aparece en vivo en la pestana History del Hoppscotch self-host.
```
## Cuando usarla
Cuando el agente ejecuta una consulta HTTP y quiere que el humano la vea en el
History de su GUI Hoppscotch self-hosted, en vivo. La entry aparece via la
subscription `userHistoryCreated` sin que el humano refresque. Util para hacer
auditable/observable lo que el agente prueba: cada `hoppscotch_run_request` deja
en la pestana History la request (con sus variables sin resolver) y su statusCode
+ duracion. Encadena con `hoppscotch_login` para obtener el `access_token`.
## Gotchas
- **El access_token va como cookie, no como header Authorization.** La mutation
`createUserHistory` lee el JWT de la cookie `access_token`. Se manda con
`cookies={"access_token": ...}`. Si expira (~24h), re-loguea con
`hoppscotch_login`.
- **reqData lleva la request SIN resolver.** Lo que se guarda en el History es el
HoppRESTRequest con los placeholders `<<var>>`/`{{var}}` literales, igual que en
el editor de la GUI, para que el humano vea la plantilla con sus variables y no
los valores expandidos. La peticion SI se ejecuta con las variables resueltas.
- **Soporta `<<>>` y `{{}}`.** Hoppscotch usa `<<var>>`; muchas plantillas traen
`{{var}}`. Ambas sintaxis se resuelven al ejecutar. Una variable que falte en
`variables` deja el literal intacto (no rompe).
- **resMetadata minimo: statusCode + duration.** Se envia
`{"statusCode": ..., "duration": ...}`. Si una version del backend exigiera mas
campos, el registro fallaria con `history_error` (la ejecucion HTTP sigue siendo
ok). Ajustar el shape si el self-host lo pide.
- **El body de respuesta se trunca a 5000 chars** en `response_body` del output,
para no devolver payloads enormes. Los `response_headers` van completos.
- **duration_ms viene de `resp.elapsed`,** no de `time.time()`: es la latencia que
midio `requests` para la peticion ejecutada.
- **Degradacion suave del History:** si la ejecucion HTTP fue ok pero el POST de la
mutation falla (transporte, no-JSON, errores GraphQL, sin id), `status` sigue
"ok", `recorded` es False y se anade `history_error` con el detalle.
## Capability growth log
v1.0.0 — version inicial. Ejecucion + registro en UserHistory del self-host;
resolucion de placeholders `<<>>`/`{{}}`.
@@ -0,0 +1,212 @@
"""Ejecuta una peticion HTTP y la registra en el History de Hoppscotch self-host.
Doble proposito: (1) lanza la request real con `requests` resolviendo placeholders
de variables y (2) opcionalmente la persiste en el UserHistory del backend
Hoppscotch self-hosted via la mutation GraphQL createUserHistory, de modo que el
humano la vea aparecer en vivo en la pestana History de su GUI (la GUI escucha la
subscription `userHistoryCreated`).
La request se ejecuta con las variables resueltas, pero lo que se guarda en el
History es la request SIN resolver (con `<<var>>`/`{{var}}` literales), igual que
en la GUI: asi el humano ve la plantilla con sus variables, no los valores
expandidos. La mutation esta protegida por GqlAuthGuard: el JWT de sesion viaja en
la cookie `access_token`.
"""
import json
import re
import requests
from infra.build_hoppscotch_collection import build_hoppscotch_collection
# Hoppscotch usa la sintaxis <<var>>; muchas plantillas tambien traen {{var}}.
# Aceptamos ambas: grupo 1 = delimitador de apertura, grupo 2 = nombre de la
# variable, grupo 3 = delimitador de cierre.
_VAR_RE = re.compile(r"(<<|\{\{)\s*([A-Za-z0-9_]+)\s*(>>|\}\})")
# Limite del cuerpo de respuesta en el output, para no devolver payloads enormes.
_BODY_TRUNCATE = 5000
_HISTORY_MUTATION = (
"mutation($d:String!,$m:String!,$t:ReqType!){"
" createUserHistory(reqData:$d, resMetadata:$m, reqType:$t){ id } }"
)
def _resolve_placeholders(text: str, variables: dict) -> str:
"""Sustituye <<var>>/{{var}} por su valor en `variables`.
Si la variable no esta en `variables`, se conserva el literal tal cual
(incluidos los delimitadores). Determinista y sin I/O.
Args:
text: cadena con (opcionales) placeholders.
variables: dict name->value con los valores de sustitucion.
Returns:
la cadena con los placeholders conocidos resueltos.
"""
def repl(match: re.Match) -> str:
name = match.group(2)
if name in variables:
return str(variables[name])
return match.group(0)
return _VAR_RE.sub(repl, text)
def hoppscotch_run_request(
method: str,
url: str,
*,
title: str | None = None,
headers: dict | None = None,
body: str | None = None,
body_type: str | None = None,
variables: dict | None = None,
access_token: str,
backend_url: str = "http://localhost:3170",
record_history: bool = True,
timeout_s: float = 30.0,
verify_tls: bool = True,
) -> dict:
"""Ejecuta una request HTTP y la registra en el History de Hoppscotch.
Resuelve los placeholders `<<var>>`/`{{var}}` de la url, los headers y el
body usando `variables`, lanza la peticion real con `requests`, y (si
`record_history`) guarda en el UserHistory del backend self-host la request
SIN resolver (para que en la GUI History se vea con las variables, igual que
en el editor).
Args:
method: metodo HTTP (GET, POST, ...).
url: endpoint, puede contener placeholders `<<var>>`/`{{var}}`.
title: nombre visible de la request en el History. None = derivar de
method + path via build_hoppscotch_collection.
headers: dict name->value de cabeceras. Sus values admiten placeholders.
body: cuerpo de la request como texto ya serializado. Admite placeholders.
body_type: tipo de cuerpo ("json"|"form"|"raw"|None).
variables: dict name->value para resolver los placeholders al EJECUTAR.
None = no se resuelve nada (los literales viajan tal cual).
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
`access_token`, NO en el header Authorization. Necesario para grabar
en el History.
backend_url: base del backend Hoppscotch self-host (sin barra final).
record_history: si True y hay access_token, registra la request en el
UserHistory via createUserHistory.
timeout_s: timeout de la peticion HTTP en segundos.
verify_tls: verificacion del certificado TLS de la request ejecutada.
Returns:
Dict. En exito de la ejecucion HTTP:
``{"status": "ok", "status_code": int, "duration_ms": int,
"response_body": str (truncado a 5000 chars), "response_headers": dict,
"recorded": bool, "history_id": str|None}``. Si la ejecucion fue ok pero
el registro de History fallo, `status` sigue "ok", `recorded` False y se
anade `history_error`. Si la ejecucion HTTP falla (RequestException):
``{"status": "error", "error": str, "recorded": False}``.
"""
variables = variables or {}
headers = headers or {}
# 1) Resolver placeholders para EJECUTAR (copia; los originales se conservan
# para registrarlos sin resolver en el History).
resolved_url = _resolve_placeholders(url, variables)
resolved_headers = {
key: _resolve_placeholders(str(value), variables)
for key, value in headers.items()
}
resolved_body = (
_resolve_placeholders(body, variables) if body is not None else None
)
# 2) Ejecutar la peticion real.
try:
resp = requests.request(
method,
resolved_url,
headers=resolved_headers,
data=resolved_body if resolved_body is not None else None,
timeout=timeout_s,
verify=verify_tls,
)
except requests.RequestException as exc:
return {
"status": "error",
"error": f"transport error: {exc}",
"recorded": False,
}
duration_ms = int(resp.elapsed.total_seconds() * 1000)
status_code = resp.status_code
response_body = resp.text[:_BODY_TRUNCATE]
response_headers = dict(resp.headers)
result = {
"status": "ok",
"status_code": status_code,
"duration_ms": duration_ms,
"response_body": response_body,
"response_headers": response_headers,
"recorded": False,
"history_id": None,
}
# 3) Registrar en el UserHistory (request SIN resolver, como en la GUI).
if not record_history or not access_token:
return result
spec = {
"method": method,
"url": url,
"headers": headers,
"body": body,
"body_type": body_type,
}
req_names = [title] if title else None
req_item = build_hoppscotch_collection([spec], request_names=req_names)[
"requests"
][0]
req_data = json.dumps(req_item)
res_metadata = json.dumps(
{"statusCode": status_code, "duration": duration_ms}
)
payload = {
"query": _HISTORY_MUTATION,
"variables": {"d": req_data, "m": res_metadata, "t": "REST"},
}
try:
hist_resp = requests.post(
f"{backend_url}/graphql",
json=payload,
cookies={"access_token": access_token},
timeout=timeout_s,
)
except requests.RequestException as exc:
result["history_error"] = f"transport error: {exc}"
return result
try:
hist_data = hist_resp.json()
except ValueError:
result["history_error"] = (
f"non-JSON history response (HTTP {hist_resp.status_code})"
)
return result
if hist_data.get("errors"):
result["history_error"] = f"graphql errors: {hist_data['errors']}"
return result
created = (hist_data.get("data") or {}).get("createUserHistory")
if not created or not created.get("id"):
result["history_error"] = "createUserHistory returned no id"
return result
result["recorded"] = True
result["history_id"] = created["id"]
return result
@@ -0,0 +1,206 @@
"""Tests para hoppscotch_run_request.
Deterministas: monkeypatchean requests.request (ejecucion HTTP) y requests.post
(mutation createUserHistory). Verifican la resolucion de placedores `<<>>`/`{{}}`
para EJECUTAR, que el History recibe la request SIN resolver, y los caminos de
record_history=False y de error de transporte.
"""
import json
import sys
from datetime import timedelta
import infra.hoppscotch_run_request # noqa: F401
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
mod = sys.modules["infra.hoppscotch_run_request"]
class _FakeResponse:
"""Respuesta minima para requests.request (ejecucion)."""
def __init__(self, status_code=200, text="OK", headers=None, elapsed_ms=12):
self.status_code = status_code
self.text = text
self.headers = headers or {"Content-Type": "text/plain"}
self.elapsed = timedelta(milliseconds=elapsed_ms)
class _FakeGraphQLResponse:
"""Respuesta minima para requests.post (createUserHistory)."""
def __init__(self, status_code=200, json_data=None):
self.status_code = status_code
self._json = json_data
def json(self):
if self._json is None:
raise ValueError("no json")
return self._json
def _patch_history_ok(monkeypatch, captured):
def fake_post(url, **kwargs):
captured["history_url"] = url
captured["history_kwargs"] = kwargs
return _FakeGraphQLResponse(
200, {"data": {"createUserHistory": {"id": "hist-42"}}}
)
monkeypatch.setattr(mod.requests, "post", fake_post)
def test_ejecuta_resolviendo_variables_angle(monkeypatch):
captured = {}
def fake_request(method, url, **kwargs):
captured["method"] = method
captured["url"] = url
captured["kwargs"] = kwargs
return _FakeResponse(200, text="pong")
monkeypatch.setattr(mod.requests, "request", fake_request)
_patch_history_ok(monkeypatch, captured)
result = mod.hoppscotch_run_request(
"GET",
"<<baseURL>>/x",
variables={"baseURL": "https://h"},
access_token="A",
)
# La request ejecutada lleva la url resuelta.
assert captured["url"] == "https://h/x"
assert result["status"] == "ok"
assert result["status_code"] == 200
assert result["response_body"] == "pong"
assert result["duration_ms"] == 12
def test_ejecuta_resolviendo_variables_brace(monkeypatch):
captured = {}
def fake_request(method, url, **kwargs):
captured["url"] = url
return _FakeResponse(200)
monkeypatch.setattr(mod.requests, "request", fake_request)
_patch_history_ok(monkeypatch, captured)
mod.hoppscotch_run_request(
"GET",
"{{baseURL}}/x",
variables={"baseURL": "https://h"},
access_token="A",
)
# {{var}} resuelve igual que <<var>>.
assert captured["url"] == "https://h/x"
def test_record_history_registra_request_sin_resolver(monkeypatch):
captured = {}
def fake_request(method, url, **kwargs):
return _FakeResponse(200, text="body", headers={"X-Test": "1"})
monkeypatch.setattr(mod.requests, "request", fake_request)
_patch_history_ok(monkeypatch, captured)
result = mod.hoppscotch_run_request(
"GET",
"<<baseURL>>/api/status",
title="Status",
variables={"baseURL": "https://h"},
access_token="ACCESS-JWT",
record_history=True,
)
assert result["recorded"] is True
assert result["history_id"] == "hist-42"
# El POST de History fue al endpoint GraphQL con la cookie access_token.
assert captured["history_url"].endswith("/graphql")
assert captured["history_kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
payload = captured["history_kwargs"]["json"]
assert "createUserHistory" in payload["query"]
variables = payload["variables"]
assert variables["t"] == "REST"
# reqData es el json string de un HoppRESTRequest v:"2" con la url SIN resolver.
req = json.loads(variables["d"])
assert req["v"] == "2"
assert req["method"] == "GET"
assert req["endpoint"] == "<<baseURL>>/api/status"
assert req["name"] == "Status"
# resMetadata minimo: statusCode + duration.
res_meta = json.loads(variables["m"])
assert res_meta == {"statusCode": 200, "duration": 12}
def test_record_history_false_no_llama_create_user_history(monkeypatch):
calls = {"post": 0}
def fake_request(method, url, **kwargs):
return _FakeResponse(200)
def fake_post(url, **kwargs):
calls["post"] += 1
return _FakeGraphQLResponse(200, {"data": {"createUserHistory": {"id": "x"}}})
monkeypatch.setattr(mod.requests, "request", fake_request)
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_run_request(
"GET",
"https://h/x",
access_token="A",
record_history=False,
)
assert result["recorded"] is False
assert result["history_id"] is None
assert calls["post"] == 0
def test_request_exception_status_error(monkeypatch):
def fake_request(method, url, **kwargs):
raise mod.requests.RequestException("boom")
# Si llegara a postear seria un fallo del test: no debe.
def fake_post(url, **kwargs):
raise AssertionError("no debe registrar history si la ejecucion fallo")
monkeypatch.setattr(mod.requests, "request", fake_request)
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_run_request(
"GET", "https://h/x", access_token="A"
)
assert result["status"] == "error"
assert result["recorded"] is False
assert "boom" in result["error"]
def test_variable_faltante_conserva_literal(monkeypatch):
captured = {}
def fake_request(method, url, **kwargs):
captured["url"] = url
return _FakeResponse(200)
monkeypatch.setattr(mod.requests, "request", fake_request)
_patch_history_ok(monkeypatch, captured)
mod.hoppscotch_run_request(
"GET",
"<<baseURL>>/x",
variables={"otra": "y"},
access_token="A",
)
# baseURL no esta en variables -> el literal se conserva.
assert captured["url"] == "<<baseURL>>/x"
@@ -0,0 +1,99 @@
---
name: hoppscotch_set_environment
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def hoppscotch_set_environment(team_id: str, name: str, variables: list[dict], *, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
description: "Crea o actualiza (idempotente por nombre) un Team Environment de Hoppscotch self-hosted via GraphQL, resolviendo secretos desde pass. Lista los environments de la team y, si ya existe uno con ese name, llama updateTeamEnvironment; si no, createTeamEnvironment. Cualquier variable cuyo value empiece por 'pass:' se resuelve con pass_get_secret y se fuerza secret=True. Los valores secretos nunca se logean ni aparecen en el output: resolved_secrets lista solo los keys. Las mutations estan protegidas por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token."
tags: [hoppscotch, flow-replay, http, secret, infra]
uses_functions: [pass_get_secret_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, requests]
params:
- name: team_id
desc: "ID de la team duena del environment."
- name: name
desc: "nombre del environment. La idempotencia es por este nombre dentro de la team: si ya existe uno con este name se actualiza, si no se crea."
- name: variables
desc: "lista de dicts {key: str, value: str, secret: bool}. Si un value empieza por 'pass:' el resto se resuelve como ruta de pass con pass_get_secret y el secreto resuelto se usa como value real, forzando secret=True. Campos secret ausentes se tratan como False."
- name: access_token
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
- name: backend_url
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
output: "dict. En exito: {status: 'ok', id: str, name: str, action: 'created'|'updated', resolved_secrets: list[str]} donde resolved_secrets son SOLO los keys resueltos desde pass (nunca valores). En error: {status: 'error', error: str} (resolucion pass fallida con el key afectado, GraphQL errors, HTTP no JSON, o fallo de transporte). Si una variable pass: no se resuelve, NO se crea/actualiza el environment."
tested: true
tests:
- "test_crea_cuando_no_existe"
- "test_actualiza_cuando_existe"
- "test_resuelve_secreto_desde_pass"
- "test_error_pass_no_llama_mutation"
test_file_path: "python/functions/infra/hoppscotch_set_environment_test.py"
file_path: "python/functions/infra/hoppscotch_set_environment.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_set_environment import hoppscotch_set_environment
token = hoppscotch_login("admin@example.com")["access_token"]
# Una variable normal + una resuelta desde pass (se marca secret=True sola).
result = hoppscotch_set_environment(
team_id="cmq8kn0v500030xls1nvminjy",
name="registry",
variables=[
{"key": "base_url", "value": "https://api.example.com", "secret": False},
{"key": "api_key", "value": "pass:apis/licenseplatedata"},
],
access_token=token,
)
print(result)
# {"status": "ok", "id": "...", "name": "registry",
# "action": "updated", "resolved_secrets": ["api_key"]}
# El valor crudo de api_key NUNCA aparece en el output.
```
## Cuando usarla
Cuando quieras definir o actualizar las variables de un workspace (team
environment) Hoppscotch self-hosted desde el registry, con los secretos
resueltos desde `pass` en vez de hardcodearlos. Util en el patron grabar->
destilar->reproducir: tras destilar un flujo, dejas sus tokens/credenciales como
variables `pass:` de un environment que el humano ve en la GUI, sin que el
secreto pase por el codigo. Idempotente por nombre: vuelve a llamarla para
actualizar sin duplicar. Primero obten el `access_token` con `hoppscotch_login`.
## Gotchas
- **Idempotente por nombre.** Busca un environment con ese `name` en la team: si
existe lo actualiza, si no lo crea. Dos teams pueden tener environments con el
mismo nombre sin colisionar (la busqueda es por team).
- **`pass:` resuelve de pass y fuerza `secret=True`.** Si el `value` empieza por
`pass:`, el resto es la ruta de pass; el secreto resuelto reemplaza al value y
la variable queda marcada como secreta aunque pasaras `secret=False`.
- **Nunca logea secretos.** Ni en stdout ni en el output: `resolved_secrets`
contiene solo los KEYS resueltos desde pass, jamas los valores. El valor crudo
no aparece en el dict de retorno.
- **Falla en pass = no se toca el environment.** Si una variable `pass:` no se
puede resolver, la funcion aborta con `{"status": "error"}` y el key afectado
ANTES de cualquier mutation: no deja el environment a medias.
- **El access_token va como cookie, no como header Authorization.** Las mutations
estan protegidas por GqlAuthGuard que lee el JWT de la cookie `access_token`.
- **El secreto viaja en claro al backend self-host local por GraphQL.** Hoppscotch
recibe el valor resuelto en el campo `variables`. Es aceptable porque el backend
de referencia es local; no apuntes esta funcion a un Hoppscotch remoto sin TLS.
## Capability growth log
v1.0.0 — version inicial. Listado + create + update validados contra el self-host
vivo el 11/06/2026 (createTeamEnvironment / updateTeamEnvironment / listado via
team{ teamEnvironments }). Resolucion `pass:` via pass_get_secret_py_infra.
@@ -0,0 +1,175 @@
"""Crea o actualiza (idempotente por nombre) un Team Environment de Hoppscotch.
Define las variables de un workspace Hoppscotch self-hosted via GraphQL,
resolviendo secretos desde `pass`: cualquier variable cuyo `value` empiece por
``pass:`` se resuelve con pass_get_secret y se marca como `secret=True`.
Idempotencia por nombre: lista los environments de la team y, si ya existe uno
con el `name` dado, lo actualiza; si no, lo crea. Las mutations estan protegidas
por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie
`access_token`.
Los valores secretos NUNCA se logean ni aparecen en el output: `resolved_secrets`
lista solo los KEYS resueltos desde pass, jamas sus valores.
"""
import json
import requests
from infra.pass_get_secret import pass_get_secret
_LIST_QUERY = "query($t:ID!){ team(teamID:$t){ teamEnvironments{ id name } } }"
_CREATE_MUTATION = (
"mutation($n:String!,$t:ID!,$v:String!){"
" createTeamEnvironment(name:$n,teamID:$t,variables:$v){ id name } }"
)
_UPDATE_MUTATION = (
"mutation($id:ID!,$n:String!,$v:String!){"
" updateTeamEnvironment(id:$id,name:$n,variables:$v){ id name } }"
)
_PASS_PREFIX = "pass:"
def hoppscotch_set_environment(
team_id: str,
name: str,
variables: list[dict],
*,
access_token: str,
backend_url: str = "http://localhost:3170",
) -> dict:
"""Crea o actualiza un Team Environment de Hoppscotch (idempotente por nombre).
Args:
team_id: ID de la team duena del environment.
name: nombre del environment. La idempotencia es por este nombre dentro
de la team: si ya existe uno con este name se actualiza, si no se crea.
variables: lista de dicts ``{"key": str, "value": str, "secret": bool}``.
Si un `value` empieza por ``pass:`` el resto se resuelve como ruta de
pass con pass_get_secret y el secreto resuelto se usa como value real,
forzando `secret=True` en esa variable. Campos `secret` ausentes se
tratan como False.
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
`access_token`, NO en el header Authorization.
backend_url: base del backend Hoppscotch sin barra final. El endpoint
GraphQL es ``{backend_url}/graphql``.
Returns:
Dict. En exito: ``{"status": "ok", "id": str, "name": str,
"action": "created"|"updated", "resolved_secrets": list[str]}`` donde
`resolved_secrets` son SOLO los keys resueltos desde pass (nunca valores).
En error: ``{"status": "error", "error": str}`` (resolucion pass fallida,
GraphQL errors, HTTP no 200, o fallo de transporte). Si una variable
`pass:` no se puede resolver, NO se crea/actualiza el environment.
"""
resolved: list[dict] = []
resolved_secrets: list[str] = []
for var in variables:
key = var.get("key")
value = var.get("value", "")
secret = bool(var.get("secret", False))
if isinstance(value, str) and value.startswith(_PASS_PREFIX):
pass_path = value[len(_PASS_PREFIX):]
secret_res = pass_get_secret(pass_path)
if secret_res.get("status") != "ok":
# NO crear el env a medias: aborta con el key afectado.
return {
"status": "error",
"error": (
f"pass resolution failed for key {key!r} "
f"(path {pass_path!r}): {secret_res.get('error')}"
),
}
value = secret_res["value"]
secret = True
resolved_secrets.append(key)
resolved.append({"key": key, "value": value, "secret": secret})
variables_json = json.dumps(resolved)
# 1) Localiza un environment existente con este nombre (idempotencia).
try:
list_resp = requests.post(
f"{backend_url}/graphql",
json={"query": _LIST_QUERY, "variables": {"t": team_id}},
cookies={"access_token": access_token},
timeout=30.0,
)
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
list_data = _parse_json(list_resp)
if list_data is None:
return {
"status": "error",
"error": f"non-JSON list response (HTTP {list_resp.status_code})",
}
if list_data.get("errors"):
return {"status": "error", "error": "graphql errors", "data": list_data}
team = (list_data.get("data") or {}).get("team") or {}
existing_id = None
for env in team.get("teamEnvironments") or []:
if env.get("name") == name:
existing_id = env.get("id")
break
# 2) Update si existe, create si no.
if existing_id:
query = _UPDATE_MUTATION
gql_vars = {"id": existing_id, "n": name, "v": variables_json}
result_field = "updateTeamEnvironment"
action = "updated"
else:
query = _CREATE_MUTATION
gql_vars = {"n": name, "t": team_id, "v": variables_json}
result_field = "createTeamEnvironment"
action = "created"
try:
resp = requests.post(
f"{backend_url}/graphql",
json={"query": query, "variables": gql_vars},
cookies={"access_token": access_token},
timeout=30.0,
)
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
data = _parse_json(resp)
if data is None:
return {
"status": "error",
"error": f"non-JSON response (HTTP {resp.status_code})",
}
if data.get("errors"):
return {"status": "error", "error": "graphql errors", "data": data}
env = (data.get("data") or {}).get(result_field)
if not env or not env.get("id"):
return {
"status": "error",
"error": f"{result_field} returned no id",
"data": data,
}
return {
"status": "ok",
"id": env["id"],
"name": env.get("name", name),
"action": action,
"resolved_secrets": resolved_secrets,
}
def _parse_json(resp):
"""Devuelve el JSON de la respuesta o None si no es JSON valido."""
try:
return resp.json()
except ValueError:
return None
@@ -0,0 +1,226 @@
"""Tests para hoppscotch_set_environment.
Deterministas: monkeypatchean requests.post (capa de red) y pass_get_secret.
Verifican crear vs actualizar (idempotencia por nombre), resolucion de secretos
`pass:` (fuerza secret=True, key en resolved_secrets, valor crudo fuera del
output), y abortar sin llamar la mutation si pass falla.
Hay un test e2e real marcado skip por defecto (self-host vivo).
"""
import json
import sys
import pytest
import infra.hoppscotch_set_environment # noqa: F401
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
mod = sys.modules["infra.hoppscotch_set_environment"]
class _FakeResponse:
def __init__(self, status_code=200, json_data=None):
self.status_code = status_code
self._json = json_data
def json(self):
if self._json is None:
raise ValueError("no json")
return self._json
def _make_post(call_log, list_envs, mutation_result):
"""Construye un fake requests.post que distingue listado de mutation.
El listado lleva 'teamEnvironments' en la query; cualquier otra es mutation.
Cada llamada se registra en call_log para inspeccion.
"""
def fake_post(url, **kwargs):
payload = kwargs["json"]
query = payload["query"]
call_log.append({"url": url, "kwargs": kwargs, "query": query})
if "teamEnvironments" in query:
return _FakeResponse(
200, {"data": {"team": {"teamEnvironments": list_envs}}}
)
# mutation (create o update)
field = (
"updateTeamEnvironment"
if "updateTeamEnvironment" in query
else "createTeamEnvironment"
)
return _FakeResponse(200, {"data": {field: mutation_result}})
return fake_post
def test_crea_cuando_no_existe(monkeypatch):
calls = []
monkeypatch.setattr(
mod.requests,
"post",
_make_post(calls, list_envs=[], mutation_result={"id": "env-1", "name": "test_env"}),
)
result = mod.hoppscotch_set_environment(
"team-1",
"test_env",
[{"key": "foo", "value": "bar", "secret": False}],
access_token="ACCESS-JWT",
)
assert result["status"] == "ok"
assert result["id"] == "env-1"
assert result["action"] == "created"
assert result["resolved_secrets"] == []
# Segunda llamada = mutation createTeamEnvironment con las variables.
mutation = calls[1]
assert "createTeamEnvironment" in mutation["query"]
assert mutation["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
gql_vars = mutation["kwargs"]["json"]["variables"]
assert gql_vars["n"] == "test_env"
assert gql_vars["t"] == "team-1"
sent_vars = json.loads(gql_vars["v"])
assert sent_vars == [{"key": "foo", "value": "bar", "secret": False}]
def test_actualiza_cuando_existe(monkeypatch):
calls = []
monkeypatch.setattr(
mod.requests,
"post",
_make_post(
calls,
list_envs=[{"id": "env-existing", "name": "test_env"}],
mutation_result={"id": "env-existing", "name": "test_env"},
),
)
result = mod.hoppscotch_set_environment(
"team-1",
"test_env",
[{"key": "foo", "value": "bar"}],
access_token="A",
)
assert result["status"] == "ok"
assert result["id"] == "env-existing"
assert result["action"] == "updated"
mutation = calls[1]
assert "updateTeamEnvironment" in mutation["query"]
gql_vars = mutation["kwargs"]["json"]["variables"]
assert gql_vars["id"] == "env-existing"
def test_resuelve_secreto_desde_pass(monkeypatch):
calls = []
monkeypatch.setattr(
mod.requests,
"post",
_make_post(calls, list_envs=[], mutation_result={"id": "env-2", "name": "e"}),
)
def fake_pass(path, **kwargs):
assert path == "apis/lpd"
return {"status": "ok", "value": "TOP-SECRET-VALUE"}
monkeypatch.setattr(mod, "pass_get_secret", fake_pass)
result = mod.hoppscotch_set_environment(
"team-1",
"e",
[
{"key": "plain", "value": "visible", "secret": False},
{"key": "apikey", "value": "pass:apis/lpd", "secret": False},
],
access_token="A",
)
assert result["status"] == "ok"
# El key resuelto aparece, pero NUNCA el valor crudo.
assert result["resolved_secrets"] == ["apikey"]
assert "TOP-SECRET-VALUE" not in json.dumps(result)
# La variable resuelta viaja con el valor real y secret=True forzado.
mutation = calls[1]
sent_vars = json.loads(mutation["kwargs"]["json"]["variables"]["v"])
by_key = {v["key"]: v for v in sent_vars}
assert by_key["apikey"]["value"] == "TOP-SECRET-VALUE"
assert by_key["apikey"]["secret"] is True
assert by_key["plain"]["value"] == "visible"
assert by_key["plain"]["secret"] is False
def test_error_pass_no_llama_mutation(monkeypatch):
calls = []
def fake_post(url, **kwargs):
calls.append(kwargs["json"]["query"])
return _FakeResponse(200, {"data": {"team": {"teamEnvironments": []}}})
monkeypatch.setattr(mod.requests, "post", fake_post)
def fake_pass(path, **kwargs):
return {"status": "error", "error": "pass not installed"}
monkeypatch.setattr(mod, "pass_get_secret", fake_pass)
result = mod.hoppscotch_set_environment(
"team-1",
"e",
[{"key": "apikey", "value": "pass:apis/lpd"}],
access_token="A",
)
assert result["status"] == "error"
assert "apikey" in result["error"]
# No se hizo ninguna llamada de red (ni listado ni mutation): aborta antes.
assert calls == []
@pytest.mark.skip(reason="e2e real contra self-host vivo")
def test_e2e_create_then_update_live():
"""End-to-end real contra el Hoppscotch self-host vivo.
login -> set_environment("test_env") -> created -> set_environment de nuevo
-> updated. Limpia el env al final con deleteTeamEnvironment.
"""
sys.path.insert(0, "python/functions")
from infra.hoppscotch_login import hoppscotch_login
import requests
team_id = "cmq8kn0v500030xls1nvminjy"
token = hoppscotch_login("admin@example.com")["access_token"]
first = mod.hoppscotch_set_environment(
team_id,
"test_env",
[{"key": "foo", "value": "bar", "secret": False}],
access_token=token,
)
assert first["status"] == "ok"
assert first["action"] == "created"
env_id = first["id"]
second = mod.hoppscotch_set_environment(
team_id,
"test_env",
[{"key": "foo", "value": "baz", "secret": False}],
access_token=token,
)
assert second["status"] == "ok"
assert second["action"] == "updated"
assert second["id"] == env_id
# Cleanup: borra el env de prueba.
del_q = "mutation($id:ID!){ deleteTeamEnvironment(id:$id) }"
requests.post(
"http://localhost:3170/graphql",
json={"query": del_q, "variables": {"id": env_id}},
cookies={"access_token": token},
timeout=15.0,
)
@@ -0,0 +1,92 @@
---
name: hoppscotch_update_request
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def hoppscotch_update_request(request_id: str, method: str, url: str, *, title: str | None = None, headers: dict | None = None, body: str | None = None, body_type: str | None = None, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
description: "Actualiza una request REST existente en Hoppscotch self-hosted via la mutation GraphQL updateRequest. Reconstruye el HoppRESTRequest canonico reusando build_hoppscotch_collection del registry y lo aplica sobre la request identificada por request_id. Protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token."
tags: [hoppscotch, flow-replay, http, infra, crud]
uses_functions: [build_hoppscotch_collection_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, requests]
params:
- name: request_id
desc: "ID de la request existente a actualizar."
- name: method
desc: "metodo HTTP de la request (GET, POST, ...). Se normaliza a mayusculas."
- name: url
desc: "endpoint completo de la request (con query string si aplica)."
- name: title
desc: "nuevo nombre visible de la request. None = derivar de method + path."
- name: headers
desc: "dict name->value de cabeceras de la request. None = sin cabeceras."
- name: body
desc: "cuerpo de la request como texto YA serializado. None = sin cuerpo."
- name: body_type
desc: "tipo de cuerpo: 'json' | 'form' | 'raw' | None."
- name: access_token
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
- name: backend_url
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
output: "dict. En exito: {status: 'ok', id: str, title: str}. En error (GraphQL errors, respuesta no JSON, sin id, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
tested: true
tests:
- "test_golden_actualiza_request_y_devuelve_id"
- "test_error_graphql_errors"
test_file_path: "python/functions/infra/hoppscotch_update_request_test.py"
file_path: "python/functions/infra/hoppscotch_update_request.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_update_request import hoppscotch_update_request
token = hoppscotch_login("admin@example.com")["access_token"]
# Actualizar una request existente: cambiar metodo, url, titulo y body.
result = hoppscotch_update_request(
request_id="cmq8lue8l000x0xlsd62bncpi",
method="POST",
url="https://api.example.com/login",
title="Login (actualizado)",
body='{"user":"neo"}',
body_type="json",
access_token=token,
)
print(result) # {"status": "ok", "id": "...", "title": "Login (actualizado)"}
```
## Cuando usarla
Cuando una request ya existe en una team collection y quieres reescribir su
contenido (metodo, url, cabeceras, body o titulo) desde el agente, para que el
humano vea el cambio reflejado en vivo en la GUI por subscriptions. Necesitas el
`request_id` (de `hoppscotch_list_requests` o de un `hoppscotch_create_request`
previo) y un `access_token` fresco de `hoppscotch_login`.
## Gotchas
- **El access_token va como cookie, no como header Authorization.** La mutation
esta protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
con `hoppscotch_login`.
- **`request` debe ser un json string de un HoppRESTRequest v:"2".** El campo
`request` del input es un string; esta funcion lo serializa con `json.dumps`.
- **Reescribe la request entera, no hace patch.** El HoppRESTRequest enviado
reemplaza el contenido: pasa todos los campos que quieras conservar, no solo los
que cambian.
- **Secreto — nunca logear el token en crudo.** Trata el JWT como un secreto.
## Capability growth log
v1.0.0 — version inicial. Validado contra el self-host vivo el 10/06/2026
(create -> update -> list confirmo el titulo actualizado).
@@ -0,0 +1,113 @@
"""Actualiza una request REST existente en Hoppscotch.
Reconstruye el HoppRESTRequest canonico (reusando build_hoppscotch_collection
del registry) y lo aplica sobre una request existente via la mutation GraphQL
updateRequest del backend self-hosted. Protegida por GqlAuthGuard: el JWT de
sesion viaja en la cookie `access_token`.
"""
import json
import requests
from infra.build_hoppscotch_collection import build_hoppscotch_collection
_MUTATION = (
"mutation($r:ID!,$d:UpdateTeamRequestInput!){"
" updateRequest(requestID:$r, data:$d){ id title } }"
)
def hoppscotch_update_request(
request_id: str,
method: str,
url: str,
*,
title: str | None = None,
headers: dict | None = None,
body: str | None = None,
body_type: str | None = None,
access_token: str,
backend_url: str = "http://localhost:3170",
) -> dict:
"""Actualiza una request existente en Hoppscotch.
Args:
request_id: ID de la request a actualizar.
method: metodo HTTP de la request (GET, POST, ...).
url: endpoint de la request.
title: nombre visible de la request en la GUI. None = derivar de
method + path via build_hoppscotch_collection.
headers: dict name->value de cabeceras de la request.
body: cuerpo de la request como texto ya serializado.
body_type: tipo de cuerpo ("json"|"form"|"raw"|None).
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
`access_token`, NO en el header Authorization.
backend_url: base del backend Hoppscotch (sin barra final).
Returns:
Dict. En exito: ``{"status": "ok", "id": str, "title": str}``. En error
(GraphQL errors, HTTP no 200, transporte): ``{"status": "error",
"error": str, "data": ...}`` con el cuerpo GraphQL si lo hubo.
"""
spec = {
"method": method,
"url": url,
"headers": headers or {},
"body": body,
"body_type": body_type,
}
req_names = [title] if title else None
req_item = build_hoppscotch_collection([spec], request_names=req_names)[
"requests"
][0]
payload = {
"query": _MUTATION,
"variables": {
"r": request_id,
"d": {
"title": req_item["name"],
"request": json.dumps(req_item),
},
},
}
try:
resp = requests.post(
f"{backend_url}/graphql",
json=payload,
cookies={"access_token": access_token},
timeout=30.0,
)
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
try:
data = resp.json()
except ValueError:
return {
"status": "error",
"error": f"non-JSON response (HTTP {resp.status_code})",
}
if data.get("errors"):
return {
"status": "error",
"error": "graphql errors",
"data": data,
}
updated = (data.get("data") or {}).get("updateRequest")
if not updated or not updated.get("id"):
return {
"status": "error",
"error": "updateRequest returned no id",
"data": data,
}
return {
"status": "ok",
"id": updated["id"],
"title": updated.get("title"),
}
@@ -0,0 +1,90 @@
"""Tests para hoppscotch_update_request.
Deterministas: monkeypatchean requests.post para no tocar la red. Verifican que
el POST GraphQL lleva la mutation updateRequest con requestID, el access_token
en la cookie, y que `request` es el json string de un HoppRESTRequest v:"2".
"""
import json
import sys
import infra.hoppscotch_update_request # noqa: F401
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
mod = sys.modules["infra.hoppscotch_update_request"]
class _FakeResponse:
def __init__(self, status_code=200, json_data=None):
self.status_code = status_code
self._json = json_data
def json(self):
if self._json is None:
raise ValueError("no json")
return self._json
def test_golden_actualiza_request_y_devuelve_id(monkeypatch):
captured = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["kwargs"] = kwargs
return _FakeResponse(
200,
{
"data": {
"updateRequest": {"id": "req-7", "title": "New title"}
}
},
)
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_update_request(
"req-7",
"POST",
"https://api.example.com/x",
title="New title",
body="a=1&b=2",
body_type="form",
access_token="ACCESS-JWT",
)
assert result["status"] == "ok"
assert result["id"] == "req-7"
assert result["title"] == "New title"
assert captured["url"].endswith("/graphql")
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
payload = captured["kwargs"]["json"]
assert "updateRequest" in payload["query"]
variables = payload["variables"]
assert variables["r"] == "req-7"
assert variables["d"]["title"] == "New title"
req = json.loads(variables["d"]["request"])
assert req["v"] == "2"
assert req["method"] == "POST"
assert req["body"] == {
"contentType": "application/x-www-form-urlencoded",
"body": "a=1&b=2",
}
def test_error_graphql_errors(monkeypatch):
def fake_post(url, **kwargs):
return _FakeResponse(
200, {"errors": [{"message": "team_req/not_found"}]}
)
monkeypatch.setattr(mod.requests, "post", fake_post)
result = mod.hoppscotch_update_request(
"missing", "GET", "https://x", access_token="A"
)
assert result["status"] == "error"
assert result["error"] == "graphql errors"
assert "errors" in result["data"]
+77
View File
@@ -0,0 +1,77 @@
---
name: pass_get_secret
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def pass_get_secret(path: str, *, line: int = 1, timeout_s: float = 10.0) -> dict"
description: "Lee un secreto del gestor de contrasenas pass (passwordstore.org) ejecutando `pass show <path>` como subproceso (lista de args, nunca shell=True). Devuelve la linea solicitada (1-indexed): line=1 es la contrasena por convencion de pass, line=N es metadata multilinea (usuario, URL, notas). El valor es sensible y la funcion NUNCA lo logea. Maneja errores sin lanzar: pass no instalado, entry inexistente, linea fuera de rango. Solo usa stdlib (subprocess)."
tags: [pass, secret, credential, infra, flow-replay]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [subprocess]
params:
- name: path
desc: "ruta del secreto dentro del store (p.ej. 'gitea/dataforge-git-token'). Es el argumento que recibiria `pass show <path>`."
- name: line
desc: "numero de linea a devolver, 1-indexed. line=1 (default) = primera linea = contrasena por convencion de pass. line=N = linea N para metadata multilinea."
- name: timeout_s
desc: "timeout del subproceso `pass show` en segundos. Default 10.0."
output: "dict. En exito: {status: 'ok', value: str} con la linea pedida sin el salto de linea final. En error (sin lanzar): {status: 'error', error: str} para pass no instalado ('pass not installed'), entry inexistente o fallo de pass (stderr stripeado), o linea fuera de rango ('line N out of range')."
tested: true
tests:
- "test_line_1_devuelve_la_password"
- "test_line_2_devuelve_metadata"
- "test_returncode_distinto_de_cero_es_error"
- "test_pass_no_instalado_es_error"
- "test_linea_fuera_de_rango_es_error"
- "test_timeout_es_error"
test_file_path: "python/functions/infra/pass_get_secret_test.py"
file_path: "python/functions/infra/pass_get_secret.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
# Primera linea = la contrasena/token (convencion de pass).
res = pass_get_secret("gitea/dataforge-git-token")
print(res) # {"status": "ok", "value": "ghp_..."} -- NO logear el value en prod
# Linea 2 = metadata (p.ej. el usuario), si el entry es multilinea.
user = pass_get_secret("apis/licenseplatedata", line=2)
print(user) # {"status": "ok", "value": "user: neo"}
```
## Cuando usarla
Cuando necesites resolver un secreto de `pass` para inyectarlo en una config,
un header HTTP, una variable de entorno o un body de request sin hardcodearlo en
el codigo. Es el lector de secretos del registry en Python: el caller pide la
ruta del store y recibe el valor en `value`, listo para enchufar donde haga
falta. line=1 para la password; line=N para metadata (usuario, URL, notas).
## Gotchas
- **Requiere `pass` instalado y el GPG agent desbloqueado.** Si `pass` no esta en
el PATH devuelve `{"status": "error", "error": "pass not installed"}`. Si el
agente GPG esta bloqueado, `pass show` puede colgarse hasta el `timeout_s`.
- **El valor es un secreto: no lo logees.** La funcion nunca lo imprime ni lo
registra. Trata el campo `value` como sensible aguas arriba (no `print` en
produccion, no persistir en claro).
- **line=1 es la contrasena.** Por convencion de pass la primera linea es el
secreto principal; las lineas siguientes son metadata 1-indexed.
- **No usa shell.** Ejecuta `["pass", "show", path]` como lista de args, nunca
`shell=True`, asi que `path` no puede inyectar comandos.
## Capability growth log
v1.0.0 — version inicial. Lector de secretos `pass` para Python, base de la
resolucion `pass:` en hoppscotch_set_environment.
+52
View File
@@ -0,0 +1,52 @@
"""Lee un secreto del gestor de contrasenas `pass` (passwordstore.org).
Ejecuta `pass show <path>` como subproceso (lista de args, nunca shell=True) y
devuelve la linea solicitada del secreto. Por convencion de pass, la primera
linea es la contrasena y las lineas siguientes son metadata (usuario, URL,
notas, etc.).
El valor devuelto es sensible: esta funcion NUNCA lo logea. El caller es
responsable de tratarlo como secreto (no imprimirlo, no persistirlo en claro).
"""
import subprocess
def pass_get_secret(path: str, *, line: int = 1, timeout_s: float = 10.0) -> dict:
"""Lee una linea de un secreto del password store (pass).
Args:
path: ruta del secreto dentro del store (p.ej. "gitea/dataforge-git-token").
Es el argumento que recibiria `pass show <path>`.
line: numero de linea a devolver, 1-indexed. line=1 (default) es la
primera linea = la contrasena por convencion de pass. line=N
devuelve la linea N para metadata multilinea.
timeout_s: timeout del subproceso en segundos.
Returns:
Dict. En exito: ``{"status": "ok", "value": str}`` con la linea pedida
sin el salto de linea final. En error (sin lanzar):
``{"status": "error", "error": str}`` para: pass no instalado, entry
inexistente / fallo de pass (returncode != 0), o linea fuera de rango.
"""
try:
proc = subprocess.run(
["pass", "show", path],
capture_output=True,
text=True,
timeout=timeout_s,
)
except FileNotFoundError:
return {"status": "error", "error": "pass not installed"}
except subprocess.TimeoutExpired:
return {"status": "error", "error": f"pass timed out after {timeout_s}s"}
if proc.returncode != 0:
return {"status": "error", "error": (proc.stderr or "").strip()}
# `pass show` termina con un salto de linea; splitlines lo absorbe.
lines = proc.stdout.splitlines()
if line < 1 or line > len(lines):
return {"status": "error", "error": f"line {line} out of range"}
return {"status": "ok", "value": lines[line - 1]}
@@ -0,0 +1,85 @@
"""Tests para pass_get_secret.
Deterministas: monkeypatchean subprocess.run para no ejecutar `pass` real.
Verifican seleccion de linea (1-indexed), errores de pass (returncode != 0),
pass no instalado (FileNotFoundError) y linea fuera de rango.
"""
import subprocess
import sys
import infra.pass_get_secret # noqa: F401
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
mod = sys.modules["infra.pass_get_secret"]
class _FakeCompleted:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def test_line_1_devuelve_la_password(monkeypatch):
def fake_run(args, **kwargs):
assert args == ["pass", "show", "apis/lpd"]
assert kwargs.get("shell") is None # nunca shell=True
return _FakeCompleted(0, stdout="s3cr3t-pass\nuser: neo\nurl: https://x\n")
monkeypatch.setattr(mod.subprocess, "run", fake_run)
result = mod.pass_get_secret("apis/lpd")
assert result == {"status": "ok", "value": "s3cr3t-pass"}
def test_line_2_devuelve_metadata(monkeypatch):
def fake_run(args, **kwargs):
return _FakeCompleted(0, stdout="s3cr3t-pass\nuser: neo\nurl: https://x\n")
monkeypatch.setattr(mod.subprocess, "run", fake_run)
result = mod.pass_get_secret("apis/lpd", line=2)
assert result == {"status": "ok", "value": "user: neo"}
def test_returncode_distinto_de_cero_es_error(monkeypatch):
def fake_run(args, **kwargs):
return _FakeCompleted(1, stdout="", stderr="Error: apis/nope is not in the password store.\n")
monkeypatch.setattr(mod.subprocess, "run", fake_run)
result = mod.pass_get_secret("apis/nope")
assert result["status"] == "error"
assert result["error"] == "Error: apis/nope is not in the password store."
def test_pass_no_instalado_es_error(monkeypatch):
def fake_run(args, **kwargs):
raise FileNotFoundError("no such file: pass")
monkeypatch.setattr(mod.subprocess, "run", fake_run)
result = mod.pass_get_secret("apis/lpd")
assert result == {"status": "error", "error": "pass not installed"}
def test_linea_fuera_de_rango_es_error(monkeypatch):
def fake_run(args, **kwargs):
return _FakeCompleted(0, stdout="solo-una-linea\n")
monkeypatch.setattr(mod.subprocess, "run", fake_run)
result = mod.pass_get_secret("apis/lpd", line=5)
assert result == {"status": "error", "error": "line 5 out of range"}
def test_timeout_es_error(monkeypatch):
def fake_run(args, **kwargs):
raise subprocess.TimeoutExpired(cmd=args, timeout=10.0)
monkeypatch.setattr(mod.subprocess, "run", fake_run)
result = mod.pass_get_secret("apis/lpd")
assert result["status"] == "error"
assert "timed out" in result["error"]

Some files were not shown because too many files have changed in this diff Show More