ENISA_EUVD/check_EUVD.py

299 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
import time
import json
import yaml
import requests
import re
from datetime import datetime, timedelta, timezone
BASE_URL = "https://euvdservices.enisa.europa.eu/api/search"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) EUVD-Auditor/1.0"
}
def matches_product_version(target_version, product_version_str):
"""
Compara la versión del objetivo contra la cadena product_version de ENISA.
Maneja coincidencia exacta, rangos "X to Y", "<= X", y patrones simples.
"""
if not product_version_str or product_version_str.lower() in ["n/a", "unknown", "*"]:
return True, "Versión indeterminada en la fuente (Revisar manualmente)"
p_ver_clean = product_version_str.strip().lower()
t_ver = target_version.strip().lower()
# Coincidencia exacta o contención directa
if t_ver == p_ver_clean or t_ver in p_ver_clean:
return True, f"Coincidencia directa con patrón '{product_version_str}'"
# Evaluador de rangos "X to Y"
if " to " in p_ver_clean:
parts = p_ver_clean.split(" to ")
if len(parts) == 2:
start_ver = parts[0].replace("openjdk", "").replace("v", "").strip()
end_ver = parts[1].replace("openjdk", "").replace("v", "").strip()
# Si target empieza igual que la rama del rango
if t_ver.startswith(start_ver.split('_')[0]) or t_ver.startswith(start_ver.split('.')[0]):
return True, f"Afecta al rango: {product_version_str}"
return False, f"No coincide con la versión/rango indicado ({product_version_str})"
def fetch_fast_enisa_vulnerabilities(vendor, product, from_score=7.0, days_back=730):
vulnerabilities = []
from_date = (datetime.now(timezone.utc) - timedelta(days=days_back)).strftime("%Y-%m-%d")
# Búsqueda usando directamente vendor y product
params = {
"vendor": vendor,
"product": product,
"fromScore": from_score,
"fromDate": from_date,
"page": 0,
"size": 100
}
max_retries = 3
for attempt in range(max_retries):
try:
response = requests.get(BASE_URL, headers=HEADERS, params=params, timeout=15)
if response.status_code == 429:
wait_time = (attempt + 1) * 5
time.sleep(wait_time)
continue
response.raise_for_status()
data = response.json()
items = data.get("items", data.get("content", [])) if isinstance(data, dict) else data
# Si no devolvió nada buscando con vendor/product exactos, probar fallback con "text"
if not items and product:
params_fallback = {
"text": product,
"fromScore": from_score,
"fromDate": from_date,
"page": 0,
"size": 100
}
res_fb = requests.get(BASE_URL, headers=HEADERS, params=params_fallback, timeout=15)
if res_fb.status_code == 200:
items = res_fb.json().get("items", [])
vulnerabilities.extend(items)
break
except Exception as e:
if attempt == max_retries - 1:
print(f" ❌ Error final para {product}: {e}")
time.sleep(2)
return vulnerabilities
def generate_markdown_report(total_consolidados, total_analizadas, total_aplicables,
total_no_aplicables, total_revision, resumen_objetivos,
aplicables_severidad, output_file="informe_vulnerabilidades.md"):
"""
Genera el informe ejecutivo en Markdown compatible con Pandoc/XeLaTeX.
"""
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
md_content = f"""# Informe Ejecutivo de Vulnerabilidades
**Generado en:** {now_iso} | **Fuentes:** ENISA
* **Informes consolidados:** {total_consolidados}
* **Vulnerabilidades analizadas:** {total_analizadas}
* **Aplicables:** {total_aplicables}
* **No aplicables:** {total_no_aplicables}
* **Revisión manual:** {total_revision}
---
## Resumen por objetivo
| Fuente | Producto | Versión | Analizadas | Aplicables | No aplicables | Revisión manual |
|---|---|---|---|---|---|---|
"""
for obj in resumen_objetivos:
md_content += f"| {obj['Fuente']} | {obj['Producto']} | {obj['Versión']} | {obj['Analizadas']} | {obj['Aplicables']} | {obj['No aplicables']} | {obj['Revisión manual']} |\n"
md_content += """
---
## Detalle de Vulnerabilidades Aplicables
"""
if not aplicables_severidad:
md_content += "\n*No se encontraron vulnerabilidades aplicables con los criterios seleccionados en el período indicado.*\n"
else:
# Ordenar vulnerabilidades aplicables de mayor a menor puntuación CVSS
aplicables_ordenadas = sorted(
aplicables_severidad,
key=lambda x: float(x["BaseScore"].split()[0]) if x.get("BaseScore") and x["BaseScore"].split()[0].replace('.', '', 1).isdigit() else 0.0,
reverse=True
)
for app in aplicables_ordenadas:
score_val = app['BaseScore'].split()[0] if app.get('BaseScore') else "0.0"
try:
score_num = float(score_val)
except ValueError:
score_num = 0.0
if score_num >= 9.0:
badge = "[**CRÍTICA**]"
elif score_num >= 7.0:
badge = "[**ALTA**]"
elif score_num >= 4.0:
badge = "[**MEDIA**]"
else:
badge = "[**BAJA**]"
just = app['Justificación'].strip()
md_content += f"""
### {badge} - {app['ID']} - {app['CVE']}
* **Producto / Versión:** `{app['Producto']}` (v`{app['Versión']}`) | **Fuente:** {app['Fuente']}
* **BaseScore:** {app['BaseScore']}
* **Regla versión:** {app['Regla versión']}
* **Justificación / Descripción:**
> {just}
---
"""
with open(output_file, "w", encoding="utf-8") as f:
f.write(md_content)
print(f"📝 Informe Markdown generado exitosamente en '{output_file}'")
def process_targets(config_path="targets.yaml"):
try:
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
except FileNotFoundError:
print(f"❌ Archivo '{config_path}' no encontrado.")
return
resumen_objetivos = []
aplicables_severidad = []
total_consolidados = 0
total_analizadas = 0
total_aplicables = 0
total_no_aplicables = 0
total_revision = 0
print("🚀 Iniciando análisis de precisión en ENISA EUVD...\n")
for target in config.get("targets", []):
vendor = target.get("vendor", "")
product = target.get("product", "")
version = target.get("version", "")
total_consolidados += 1
print(f"👉 Analizando: {vendor}/{product} (v{version})")
items = fetch_fast_enisa_vulnerabilities(vendor=vendor, product=product, from_score=0.0, days_back=730)
num_analizadas = len(items)
num_aplicables = 0
num_no_aplicables = 0
num_revision = 0
for item in items:
score = item.get("baseScore", 0.0) or 0.0
score_ver = item.get("baseScoreVersion", "3.1")
euvd_id = item.get("id", "N/A")
aliases = item.get("aliases", "")
aliases_list = aliases.strip().split("\n") if isinstance(aliases, str) else []
cve_id = aliases_list[0] if aliases_list and aliases_list[0] else euvd_id
desc = item.get("description", "Sin descripción disponible.")
desc_short = desc[:180].strip() + "..." if len(desc) > 180 else desc
# --- EXTRACCIÓN Y EVALUACIÓN DE product_version DESDE enisaIdProduct ---
enisa_products = item.get("enisaIdProduct", [])
is_affected = False
match_rule = "Sin información de versión en la API"
if enisa_products:
for prod_entry in enisa_products:
p_version = prod_entry.get("product_version", "")
affected, rule_msg = matches_product_version(version, p_version)
if affected:
is_affected = True
match_rule = f"`product_version`: \"{p_version}\" -> ({rule_msg})"
break
else:
# Si no hay metadatos de producto, se marca para revisión manual o aplicable por defecto
is_affected = True
match_rule = "Evaluación por coincidencia general de producto"
if is_affected:
num_aplicables += 1
aplicables_severidad.append({
"Fuente": "ENISA",
"ID": euvd_id,
"CVE": cve_id,
"Producto": product,
"Versión": version,
"BaseScore": f"{score} {score_ver}".strip(),
"Regla versión": match_rule,
"Justificación": desc_short
})
else:
num_no_aplicables += 1
total_analizadas += num_analizadas
total_aplicables += num_aplicables
total_no_aplicables += num_no_aplicables
total_revision += num_revision
resumen_objetivos.append({
"Fuente": "ENISA",
"Producto": product,
"Versión": version,
"Analizadas": num_analizadas,
"Aplicables": num_aplicables,
"No aplicables": num_no_aplicables,
"Revisión manual": num_revision
})
print(f" 📊 Analizadas: {num_analizadas} | Aplicables: {num_aplicables} | Descartadas por versión: {num_no_aplicables}\n")
time.sleep(0.5)
# 1. Guardar JSON
reporte_json = {
"generado_en": datetime.now(timezone.utc).isoformat(),
"fuentes": ["ENISA"],
"resumen_por_objetivo": resumen_objetivos,
"aplicables_por_severidad": aplicables_severidad
}
with open("reporte_vulnerabilidades.json", "w", encoding="utf-8") as f:
json.dump(reporte_json, f, indent=2, ensure_ascii=False)
# 2. Generar el informe en Markdown
generate_markdown_report(
total_consolidados=total_consolidados,
total_analizadas=total_analizadas,
total_aplicables=total_aplicables,
total_no_aplicables=total_no_aplicables,
total_revision=total_revision,
resumen_objetivos=resumen_objetivos,
aplicables_severidad=aplicables_severidad,
output_file="informe_vulnerabilidades.md"
)
print("✅ Análisis finalizado y archivo 'informe_vulnerabilidades.md' actualizado con éxito.")
if __name__ == "__main__":
process_targets()