Categoría: Librerias Python

  • 2.1 – Librerías en Python

    2.1 – Librerías en Python

    ¿Qué es una librería en Python?

    Una librería (o “biblioteca”) es código que alguien ya escribió para resolver un problema común: hacer peticiones web, leer PDFs, crear gráficos, trabajar con fechas, etc.

    • Python estándar: viene “de serie” (por ejemplo json, os, math, datetime).
    • Librerías externas: se instalan aparte (por ejemplo requests, pandas, flask, numpy).

    Regla mental útil:

    • Si lo importas y no lo instalas, normalmente es estándar.
    • Si lo importas y te da error, probablemente hay que instalarlo.

    ¿Qué significa import?

    Cuando haces:

    
    
    
    
    
    import requests
    

    Estás diciendo: “Python, carga ese paquete para que pueda usar sus funciones”.

    También puedes importar cosas concretas:

    
    
    
    
    
    from datetime import datetime
    

    O ponerle un alias (muy común):

    
    
    
    
    
    import pandas as pd
    

    ¿Dónde se instalan librerías?

    En Python, las librerías se instalan en “un Python concreto”, que puede ser:

    1. Python del sistema (global).
    2. Un entorno virtual (recomendado para clase/proyectos).
    3. Una instalación específica (por ejemplo, un Python dentro de VS Code, conda, etc.).

    La confusión típica del alumnado:

    “He instalado requests pero sigue dando error”
    Casi siempre es porque instalaron en un Python y ejecutan con otro.


    Gestores de paquetes: pip

    pip es el instalador estándar de paquetes Python.

    Comandos básicos:

    
    
    
    
    
    pip --version
    python3 -m pip --version
    

    Instalar una librería:

    
    
    
    
    
    python3 -m pip install requests
    

    Actualizar una librería:

    
    
    
    
    
    python3 -m pip install --upgrade requests
    

    Ver lo instalado:

    
    
    
    
    
    python3 -m pip list
    

    Ver información de un paquete:

    
    
    
    
    
    python3 -m pip show requests
    

    La forma “pro” para clase: entornos virtuales (venv)

    Esto evita romper el sistema y hace que cada proyecto tenga sus librerías.

    Crear entorno virtual

    En la carpeta del proyecto:

    
    
    
    
    
    python3 -m venv venv
    

    Activarlo

    Linux/Mac:

    
    
    
    
    
    source venv/bin/activate
    

    Windows (PowerShell):

    
    
    
    
    
    venv\Scripts\Activate.ps1
    

    Windows (CMD):

    
    
    
    
    
    venv\Scripts\activate.bat
    

    Cuando está activo, normalmente verás (venv) al inicio de la terminal.

    Instalar dentro del entorno

    
    
    
    
    
    python -m pip install requests
    

    5.4 Salir del entorno

    
    
    
    
    
    deactivate
    

    Cómo saber si algo es estándar o externo

    Ejemplos:

    • Estándar:
    
    
    
    
    
    import json
    import os
    import datetime
    
    • Externas (requieren pip):
    
    
    
    
    
    import requests
    import pandas
    import flask
    

    Buenas prácticas al trabajar con librerías

    Fijar dependencias: requirements.txt

    Generar lista de dependencias del entorno:

    
    
    
    
    
    python -m pip freeze > requirements.txt
    

    Instalar dependencias en otro equipo:

    
    
    
    
    
    python -m pip install -r requirements.txt
    

    Esto es oro puro para que los proyectos “funcionen igual” en todas las máquinas.

    Leer documentación y “ejemplos mínimos”

    La documentación oficial suele tener:

    • instalación
    • ejemplos pequeños
    • parámetros importantes
    • errores comunes

    Ejemplo completo con requests (GET)

    Vamos a hacer una petición HTTP a una API pública de ejemplo y procesar JSON.

    Instalar requests

    Si estás en venv, mejor.

    
    
    
    
    
    python -m pip install requests
    

    Script: peticion_get.py

    
    
    
    
    
    import requests
    
    URL = "https://httpbin.org/get"
    
    def main():
        try:
            # timeout: evita que el programa se quede colgado si el servidor no responde
            response = requests.get(URL, timeout=10)
    
            # Lanza excepción si el status code es 4xx/5xx
            response.raise_for_status()
    
            # httpbin devuelve JSON
            data = response.json()
    
            print("✅ Status:", response.status_code)
            print("✅ Tu IP según el servidor:", data.get("origin"))
            print("✅ Headers enviados (ejemplo):")
            headers = data.get("headers", {})
            print("   User-Agent:", headers.get("User-Agent"))
    
        except requests.exceptions.Timeout:
            print("⏳ Error: timeout (el servidor tardó demasiado en responder).")
        except requests.exceptions.HTTPError as e:
            print("🚫 Error HTTP:", e)
        except requests.exceptions.RequestException as e:
            # Cubre: problemas de red, DNS, conexión, etc.
            print("🌐 Error de red:", e)
        except ValueError:
            # Si .json() falla porque la respuesta no era JSON
            print("🧩 Error: la respuesta no era JSON válido.")
    
    if __name__ == "__main__":
        main()
    

    Ejecutarlo

    
    
    
    
    
    python peticion_get.py
    

    Qué deberían observar:

    • status_code (200 si ok)
    • origin (la IP “vista” por el servidor)
    • headers (cabeceras HTTP)

    Ejemplo con parámetros (GET con params)

    params construye la query ?clave=valor.

    
    
    
    
    
    import requests
    
    URL = "https://httpbin.org/get"
    
    params = {
        "busqueda": "python",
        "pagina": 1
    }
    
    r = requests.get(URL, params=params, timeout=10)
    print("URL final:", r.url)
    print("Respuesta JSON:", r.json().get("args"))
    

    Ejemplo POST (enviar datos)

    Enviar JSON

    
    
    
    
    
    import requests
    
    URL = "https://httpbin.org/post"
    
    payload = {
        "usuario": "alumno01",
        "rol": "tester"
    }
    
    r = requests.post(URL, json=payload, timeout=10)
    r.raise_for_status()
    
    data = r.json()
    print("Enviado:", data.get("json"))
    

    Enviar formulario (application/x-www-form-urlencoded)

    
    
    
    
    
    import requests
    
    URL = "https://httpbin.org/post"
    
    form_data = {
        "email": "alumno@ejemplo.com",
        "password": "1234"
    }
    
    r = requests.post(URL, data=form_data, timeout=10)
    print(r.json().get("form"))
    

    Cabeceras y autenticación básica

    Cabeceras (headers)

    
    
    
    
    
    import requests
    
    URL = "https://httpbin.org/headers"
    
    headers = {
        "User-Agent": "ClasePython/1.0",
        "X-Profesor": "Antonio"
    }
    
    r = requests.get(URL, headers=headers, timeout=10)
    print(r.json())
    

    Auth básica (solo ejemplo didáctico)

    
    
    
    
    
    import requests
    
    URL = "https://httpbin.org/basic-auth/user/pass"
    r = requests.get(URL, auth=("user", "pass"), timeout=10)
    print(r.status_code, r.json())
    

    Errores típicos (y cómo cazarlos rápido)

    “ModuleNotFoundError: No module named ‘requests’”

    • No está instalado en ese Python.
    • Solución:
    
    
    
    
    
    python -m pip install requests
    python -c "import requests; print(requests.__version__)"
    

    “Funciona en terminal pero no en VS Code”

    • VS Code está usando otro intérprete.
    • Solución: seleccionar el intérprete del venv en VS Code (Python: Select Interpreter).

    “Se queda colgado”

    • Falta timeout.
    • Solución: siempre pon timeout=... en peticiones.

  • 2.2 – ReconLite (mini escáner OSINT HTTP)

    2.2 – ReconLite (mini escáner OSINT HTTP)

    En este caso vamos a construir paso a paso ReconLite, un mini escáner HTTP orientado a reconocimiento OSINT ligero. No es una herramienta para “atacar”, ni pretende competir con scanners profesionales, sino un proyecto didáctico pensado para entender qué información expone un servicio web antes incluso de hablar de vulnerabilidades.

    ReconLite nace con una idea muy clara: en ciberseguridad, observar bien es más importante que correr rápido. Aprender a mirar cabeceras, códigos de estado, tiempos de respuesta, redirecciones o endpoints típicos permite formarse una primera imagen del objetivo sin romper nada, sin hacer ruido innecesario y, sobre todo, dejando evidencias claras y reproducibles.

    A lo largo del proyecto se trabajan conceptos clave que aparecen constantemente en auditorías reales y análisis forense web: normalización de objetivos, fingerprinting pasivo, revisión de hardening HTTP, enumeración controlada de rutas y generación de reportes estructurados. Todo ello usando Python y la librería requests, sin magia negra y con explicaciones claras de cada decisión tomada en el código.

    El objetivo no es solo que el script funcione, sino que entiendas por qué se hace cada cosa, qué información aporta y cuáles son sus límites. ReconLite está diseñado para fomentar una mentalidad ciber realista:
    mirar sin asumir, registrar sin interpretar de más y recordar siempre que un 200 no significa “seguro”, ni un 403 significa “no existe”.

    A partir de aquí encontrarás la estructura completa del proyecto, el código íntegro y una explicación detallada de cada parte, para que puedas usarlo, modificarlo y ampliarlo como base para prácticas, laboratorios o proyectos más avanzados.

    Qué hace:

    • Valida y normaliza una URL objetivo
    • Descubre endpoints típicos (con un wordlist pequeño)
    • Revisa cabeceras de seguridad (CSP, HSTS, etc.)
    • Detecta tecnologías básicas por headers (sin magia negra)
    • Comprueba robots.txt y sitemap.xml
    • Mide tiempos y códigos de estado
    • Saca un reporte en JSON

    Mentalidad ciber: “mirar sin romper”, registrar evidencias, y no asumir que “200 = seguro” ni que “403 = no existe”.


    Estructura

    Crea esta carpeta:

    • reconlite/
      • reconlite.py
      • requirements.txt
      • README.md
      • report.json (se genera)

    requirements.txt

    
    
    
    
    
    requests>=2.31.0
    

    reconlite.py (proyecto completo)

    
    
    
    
    
    #!/usr/bin/env python3
    import argparse
    import json
    import re
    import sys
    import time
    from urllib.parse import urljoin, urlparse
    
    import requests
    
    
    DEFAULT_PATHS = [
        "/", "/robots.txt", "/sitemap.xml",
        "/.git/", "/.env", "/config.php", "/phpinfo.php",
        "/admin", "/admin/", "/login", "/login/",
        "/wp-admin", "/wp-login.php",
        "/api", "/api/", "/swagger", "/swagger/", "/openapi.json",
        "/server-status", "/actuator", "/actuator/health"
    ]
    
    SEC_HEADERS = [
        "Strict-Transport-Security",
        "Content-Security-Policy",
        "X-Content-Type-Options",
        "X-Frame-Options",
        "Referrer-Policy",
        "Permissions-Policy",
        "Cross-Origin-Opener-Policy",
        "Cross-Origin-Resource-Policy",
        "Cross-Origin-Embedder-Policy",
    ]
    
    
    def normalize_url(raw: str) -> str:
        raw = raw.strip()
        if not raw:
            raise ValueError("URL vacía.")
        if not re.match(r"^https?://", raw, re.IGNORECASE):
            raw = "https://" + raw  # por defecto https
        u = urlparse(raw)
        if not u.netloc:
            raise ValueError("URL inválida. Ejemplo: https://example.com")
        # reconstrucción limpia (sin fragmentos)
        clean = f"{u.scheme}://{u.netloc}"
        if u.path and u.path != "/":
            clean += u.path.rstrip("/")
        return clean
    
    
    def safe_request(session: requests.Session, method: str, url: str, **kwargs):
        t0 = time.time()
        try:
            r = session.request(method, url, **kwargs)
            dt = (time.time() - t0) * 1000.0
            return r, dt, None
        except requests.RequestException as e:
            dt = (time.time() - t0) * 1000.0
            return None, dt, str(e)
    
    
    def extract_basic_fingerprint(headers: dict) -> dict:
        # Fingerprinting suave: headers típicos (no infalible)
        server = headers.get("Server")
        powered = headers.get("X-Powered-By")
        via = headers.get("Via")
        return {
            "server": server,
            "x_powered_by": powered,
            "via": via,
        }
    
    
    def analyze_security_headers(headers: dict) -> dict:
        present = {}
        missing = []
        for h in SEC_HEADERS:
            if h in headers:
                present[h] = headers.get(h)
            else:
                missing.append(h)
    
        # Heurísticas sencillas (no dogma)
        notes = []
        if "Strict-Transport-Security" not in headers:
            notes.append("No HSTS: si es un sitio web público, se suele recomendar forzar HTTPS con HSTS.")
        if headers.get("X-Content-Type-Options", "").lower() != "nosniff":
            notes.append("X-Content-Type-Options no es 'nosniff' (o falta).")
        if "Content-Security-Policy" not in headers:
            notes.append("Sin CSP: suele reducir impacto de XSS (no lo elimina).")
    
        return {"present": present, "missing": missing, "notes": notes}
    
    
    def is_interesting_status(code: int) -> bool:
        # 200/204/3xx/401/403 son “interesantes” para enumeración
        return code in (200, 201, 202, 204, 301, 302, 307, 308, 401, 403)
    
    
    def scan_paths(base_url: str, paths: list, timeout: int, verify_tls: bool, user_agent: str, max_paths: int):
        session = requests.Session()
        session.headers.update({"User-Agent": user_agent})
    
        results = []
        for i, p in enumerate(paths[:max_paths], start=1):
            full = urljoin(base_url + "/", p.lstrip("/"))
            r, dt, err = safe_request(
                session,
                "GET",
                full,
                timeout=timeout,
                allow_redirects=False,
                verify=verify_tls,
            )
    
            entry = {
                "path": p,
                "url": full,
                "error": err,
                "ms": round(dt, 2),
            }
    
            if r is not None:
                entry.update({
                    "status": r.status_code,
                    "content_type": r.headers.get("Content-Type"),
                    "content_length": r.headers.get("Content-Length"),
                    "location": r.headers.get("Location"),
                })
    
                # Guardamos solo “señales”, no el contenido entero (más limpio y ético)
                if is_interesting_status(r.status_code):
                    results.append(entry)
            else:
                # errores de red también son evidencia
                results.append(entry)
    
        return results
    
    
    def head_base(base_url: str, timeout: int, verify_tls: bool, user_agent: str):
        session = requests.Session()
        session.headers.update({"User-Agent": user_agent})
    
        r, dt, err = safe_request(
            session,
            "HEAD",
            base_url,
            timeout=timeout,
            allow_redirects=False,
            verify=verify_tls,
        )
    
        if r is None:
            return {"error": err, "ms": round(dt, 2)}
    
        return {
            "status": r.status_code,
            "ms": round(dt, 2),
            "headers": dict(r.headers),
        }
    
    
    def main():
        parser = argparse.ArgumentParser(
            description="ReconLite - Recon HTTP/OSINT ligero con mentalidad ciber (solo objetivos autorizados)."
        )
        parser.add_argument("target", help="URL o dominio (ej: https://example.com o example.com)")
        parser.add_argument("--timeout", type=int, default=8, help="Timeout por request en segundos (default: 8)")
        parser.add_argument("--insecure", action="store_true", help="No verificar TLS (NO recomendado)")
        parser.add_argument("--max-paths", type=int, default=40, help="Máximo de rutas a probar (default: 40)")
        parser.add_argument("--paths-file", help="Archivo de rutas (una por línea) para enumeración")
        parser.add_argument("--out", default="report.json", help="Ruta del reporte JSON (default: report.json)")
        parser.add_argument("--ua", default="ReconLite/1.0 (+educational)", help="User-Agent personalizado")
    
        args = parser.parse_args()
    
        try:
            base_url = normalize_url(args.target)
        except ValueError as e:
            print(f"[!] {e}", file=sys.stderr)
            sys.exit(1)
    
        verify_tls = not args.insecure
    
        paths = DEFAULT_PATHS
        if args.paths_file:
            try:
                with open(args.paths_file, "r", encoding="utf-8") as f:
                    custom = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
                # Normaliza para que siempre empiecen por "/"
                paths = [p if p.startswith("/") else "/" + p for p in custom]
            except OSError as e:
                print(f"[!] No se pudo leer paths-file: {e}", file=sys.stderr)
                sys.exit(1)
    
        report = {
            "target": base_url,
            "timestamp_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "config": {
                "timeout_s": args.timeout,
                "verify_tls": verify_tls,
                "max_paths": args.max_paths,
                "user_agent": args.ua,
            },
            "base_head": {},
            "fingerprint": {},
            "security_headers": {},
            "findings": [],
        }
    
        # 1) HEAD base
        base_head = head_base(base_url, args.timeout, verify_tls, args.ua)
        report["base_head"] = base_head
    
        if "headers" in base_head:
            headers = base_head["headers"]
            report["fingerprint"] = extract_basic_fingerprint(headers)
            report["security_headers"] = analyze_security_headers(headers)
    
        # 2) Enumeración de rutas
        findings = scan_paths(base_url, paths, args.timeout, verify_tls, args.ua, args.max_paths)
        report["findings"] = findings
    
        # 3) Guardar reporte
        try:
            with open(args.out, "w", encoding="utf-8") as f:
                json.dump(report, f, indent=2, ensure_ascii=False)
        except OSError as e:
            print(f"[!] No se pudo escribir el reporte: {e}", file=sys.stderr)
            sys.exit(1)
    
        # Resumen consola
        interesting = [x for x in findings if x.get("status") is not None]
        print(f"✅ Objetivo: {base_url}")
        if "status" in base_head:
            print(f"✅ HEAD status: {base_head['status']} ({base_head['ms']} ms)")
        print(f"✅ Hallazgos guardados en: {args.out}")
        print(f"✅ Endpoints interesantes (con status): {len(interesting)}")
    
    
    if __name__ == "__main__":
        main()
    

    README.md

    README.md 
    # ReconLite (proyecto resuelto) – Python + requests con mentalidad ciber

    ## Objetivo
    Hacer reconocimiento HTTP/OSINT de forma ligera y responsable:
    - Cabeceras de seguridad (HSTS, CSP, etc.)
    - Fingerprinting básico por headers
    - Enumeración “suave” de rutas típicas
    - Reporte JSON para evidencias

    > Úsalo solo contra objetivos autorizados.

    ## Instalación
    ```bash
    python3 -m venv venv
    source venv/bin/activate
    pip install -r requirements.txt
    ```

    ## Uso básico
    ```bash
    python reconlite.py https://example.com
    ```

    ## Cambiar User-Agent
    ```bash
    python reconlite.py example.com --ua "Mozilla/5.0 (ReconLite class)"
    ```

    ## Añadir tu propio wordlist de rutas
    Archivo `paths.txt` (una ruta por línea):
    ```
    admin
    admin/
    robots.txt
    api/v1
    ```

    Ejecución:
    ```bash
    python reconlite.py https://midominio.com --paths-file paths.txt --max-paths 200
    ```

    ## TLS
    Por defecto verifica TLS.
    Si estás en laboratorio con certificados raros (NO recomendado):
    ```bash
    python reconlite.py https://lab.local --insecure
    ```

    ## Salida
    Genera `report.json` con:
    - Config usada
    - HEAD base con headers
    - Seguridad de headers
    - Listado de rutas con status, tiempos, redirects y content-type
    ```

    ---

    ## Ejemplos de ejecución (para clase)
    ~~~bash
    # 1) Recon rápido
    python reconlite.py https://testphp.vulnweb.com

    # 2) Enumeración más grande con tu lista
    python reconlite.py https://testphp.vulnweb.com --paths-file paths.txt --max-paths 150

    # 3) Cambiar timeout (objetivos lentos)
    python reconlite.py https://testphp.vulnweb.com --timeout 15

    Aqhí tienes todos los archivos del proyecto para que puedas usarlos.

    Métodos comentados

    normalize_url(raw: str) -> str

    Propósito: convertir lo que te pase el usuario (example.com, http://..., https://.../algo) en una URL base “limpia” y usable.

    Qué hace paso a paso:

    • raw.strip() limpia espacios (entrada típica de alumno: " example.com ").
    • Si está vacío → ValueError. Evita que luego requests reviente con errores menos claros.
    • Si no empieza por http:// o https://, le añade https:// por defecto.
      • Mentalidad ciber: preferir TLS. Si el sitio no soporta HTTPS, ya verás error/redirect.
    • urlparse(raw) separa esquema, host, path, etc.
    • Si no hay netloc (host) → URL inválida.
    • Reconstruye una URL “limpia” sin fragmentos y con path normalizado.

    Por qué es importante en ciber:

    • Un recon decente empieza por normalizar objetivos. Evitas escanear accidentalmente https://https://... o rutas raras.

    Limitaciones:

    • No valida DNS ni conectividad; solo sintaxis.
    • No fuerza www ni detecta canonical URL.

    Mejoras típicas:

    • Permitir http por defecto si https falla (con aviso).
    • Bloquear esquemas raros aunque vengan “inyectados”.

    safe_request(session, method, url, **kwargs)

    Propósito: envoltorio robusto alrededor de requests para:

    • medir tiempo
    • capturar errores de red
    • devolver siempre un resultado uniforme

    Qué hace:

    • Arranca cronómetro (t0 = time.time()).
    • Intenta session.request(...) con método, URL y parámetros.
    • Si va bien → devuelve (response, ms, None).
    • Si falla (timeout, DNS, TLS, conexión, etc.) → devuelve (None, ms, "error string").

    Por qué es importante en ciber:

    • En recon real, los fallos son evidencia: “no resuelve”, “TLS handshake falla”, “timeout”… eso te dice cosas del perímetro.

    Limitaciones:

    • Devuelve str(e) sin clasificar error (timeout vs ssl vs conn).

    Mejoras:

    • Clasificar excepciones (Timeout, SSLError, ConnectionError) en un campo error_type.
    • Añadir reintentos con backoff (con cuidado de no hacer ruido).

    extract_basic_fingerprint(headers: dict) -> dict

    Propósito: fingerprinting básico y ético: mirar lo que el servidor declara en headers.

    Qué hace:

    • Lee Server, X-Powered-By, Via.
    • Devuelve un dict con esos valores.

    Por qué es ciber:

    • Esto a veces revela stack (“nginx”, “Apache”, “Express”, “PHP”, “cloudflare”, proxies…).
    • Es “OSINT pasivo” dentro de una request normal.

    Limitaciones (muy importantes):

    • No es fiable: muchos servidores mienten o lo ocultan.
    • No debe tomarse como prueba definitiva.

    Mejoras:

    • Añadir heurísticas con Set-Cookie, X-AspNet-Version, CF-RAY, etc.
    • Detectar CDN/WAF con señales comunes (pero siempre como “posible”).

    analyze_security_headers(headers: dict) -> dict

    Propósito: comprobar presencia/ausencia de headers de seguridad y generar notas.

    Qué hace:

    • Recorre SEC_HEADERS:
      • Si está presente → lo guarda con su valor.
      • Si falta → lo añade a missing.
    • Genera notes con heurísticas:
      • falta HSTS
      • X-Content-Type-Options no es nosniff
      • falta CSP

    Por qué es ciber:

    • Esto te da un “termómetro” rápido de hardening HTTP.
    • Útil para que alumnos aprendan que seguridad también es config.

    Limitaciones:

    • Que falte un header ≠ vulnerabilidad explotable.
    • CSP/HSTS requieren contexto (subdominios, preload, mixed content).

    Mejoras:

    • Analizar valores:
      • HSTS: max-age, includeSubDomains, preload
      • XFO: DENY/SAMEORIGIN
      • CSP: evitar unsafe-inline (ojo: no siempre posible)
    • Añadir chequeo de Secure/HttpOnly/SameSite en cookies (si haces GET real y hay cookies).

    is_interesting_status(code: int) -> bool

    Propósito: decidir qué códigos son “interesantes” para enumeración sin guardar ruido.

    Qué hace:

    • Devuelve True si status ∈ {200, 201, 202, 204, 301, 302, 307, 308, 401, 403}

    Por qué es ciber:

    • 401/403 son oro: “existe pero protegido”.
    • 3xx también: revela rutas canonical o paneles movidos.

    Limitaciones:

    • 404 también puede ser interesante si detectas “soft 404” (200 con página de error).
    • 500/502/503 deberían contarse como hallazgo porque indican superficie frágil.

    Mejoras:

    • Permitir configurar lista de códigos por CLI.
    • Detectar “soft 404” comparando longitud/huella de respuesta.

    scan_paths(base_url, paths, timeout, verify_tls, user_agent, max_paths)

    Propósito: enumeración “suave” de endpoints típicos con GET sin seguir redirecciones.

    Qué hace:

    • Crea requests.Session() (mejor que requests.get repetido):
      • Reutiliza conexión (keep-alive) → más rápido, menos carga.
    • Fija User-Agent.
    • Recorre rutas hasta max_paths.
    • Construye URL final con urljoin.
    • Llama safe_request(GET, ...) con:
      • allow_redirects=False (clave para recon: quieres ver el Location)
      • verify=verify_tls
      • timeout=timeout
    • Crea entry con datos:
      • path, url, error, ms
    • Si hay respuesta:
      • status, content-type, content-length, location
      • Si status es “interesante” → lo añade
    • Si hay error:
      • también lo añade (evidencia)

    Por qué es ciber:

    • Enumeración controlada, orientada a reporte.
    • No seguir redirects evita perder información y evita cadenas largas.

    Limitaciones / ética:

    • Un wordlist grande puede parecer “escaneo agresivo”.
    • No hay rate-limit → en un entorno real debes dormir entre requests.

    Mejoras pro:

    • Añadir --delay-ms o --rps para no hacer ruido.
    • Añadir método HEAD para paths (menos transferencia) y usar GET solo si “promete”.
    • Añadir comparación de contenido para detectar páginas trampa.

    head_base(base_url, timeout, verify_tls, user_agent)

    Propósito: obtener headers del objetivo base con HEAD (ligero).

    Qué hace:

    • Crea sesión, pone UA.
    • Llama safe_request("HEAD", base_url, allow_redirects=False, ...)
    • Si falla → devuelve dict con error y ms.
    • Si va bien → devuelve status, ms, headers.

    Por qué es ciber:

    • Minimiza impacto: HEAD normalmente no descarga cuerpo.
    • Buen punto de partida para fingerprint y seguridad headers.

    Limitaciones:

    • Algunos servidores no implementan HEAD bien:
      • responden distinto que GET
      • devuelven 405 Method Not Allowed
    • Si hay WAF/CDN, puede variar por método.

    Mejoras:

    • Si HEAD da 405 → fallback a GET con stream=True y sin leer cuerpo (o leyendo muy poco).

    main()

    Propósito: orquestar todo: argumentos, normalización, ejecutar recon, guardar reporte, resumen por consola.

    Qué hace:

    1. argparse define CLI:
      • target, timeout, insecure, max-paths, paths-file, out, ua
    2. Normaliza URL (si falla, sale con error claro).
    3. verify_tls = not args.insecure
    4. Carga paths:
      • si hay --paths-file, lo lee y normaliza cada ruta a /...
    5. Inicializa report:
      • target, timestamp_utc, config, base_head, fingerprint, security_headers, findings
    6. Llama head_base
      • si trae headers → fingerprint + security header analysis
    7. Llama scan_paths
    8. Guarda JSON en args.out
    9. Imprime resumen

    Por qué es ciber:

    • Deja un rastro reproducible: configuración + timestamp + evidencias.
    • Separa “descubrimiento” (head) de “enumeración” (paths).

    Limitaciones:

    • No hay control de concurrencia ni rate limit.
    • No hay logging estructurado (solo print final).

    Mejoras:

    • Añadir niveles de verbosidad -v / -vv.
    • Exportar también a Markdown (bonito para entregar como informe).
    • Añadir “modo aula”: limitar a X requests por minuto.

    if __name__ == "__main__": main()

    Propósito: permitir que el script funcione:

    • como programa ejecutable (CLI)
    • y también importable como módulo sin ejecutarse automáticamente

    • Puedes reutilizar funciones en otro fichero (tests, GUI, etc.).