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.).