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.txtysitemap.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.pyrequirements.txtREADME.mdreport.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 luegorequestsreviente con errores menos claros. - Si no empieza por
http://ohttps://, le añadehttps://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
wwwni detecta canonical URL.
Mejoras típicas:
- Permitir
httppor defecto sihttpsfalla (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
notescon heurísticas:- falta HSTS
X-Content-Type-Optionsno esnosniff- 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)
- HSTS:
- Añadir chequeo de
Secure/HttpOnly/SameSiteen 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
Truesi 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 querequests.getrepetido):- 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 elLocation)verify=verify_tlstimeout=timeout
- Crea
entrycon datos:- path, url, error, ms
- Si hay respuesta:
- status, content-type, content-length, location
- Si
statuses “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-mso--rpspara no hacer ruido. - Añadir método
HEADpara 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
erroryms. - 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=Truey sin leer cuerpo (o leyendo muy poco).
main()
Propósito: orquestar todo: argumentos, normalización, ejecutar recon, guardar reporte, resumen por consola.
Qué hace:
argparsedefine CLI:- target, timeout, insecure, max-paths, paths-file, out, ua
- Normaliza URL (si falla, sale con error claro).
verify_tls = not args.insecure- Carga paths:
- si hay
--paths-file, lo lee y normaliza cada ruta a/...
- si hay
- Inicializa
report:- target, timestamp_utc, config, base_head, fingerprint, security_headers, findings
- Llama
head_base- si trae headers → fingerprint + security header analysis
- Llama
scan_paths - Guarda JSON en
args.out - 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.).


