diff --git a/check_EUVD.py b/check_EUVD.py index ccc622f..fecd0c6 100755 --- a/check_EUVD.py +++ b/check_EUVD.py @@ -6,45 +6,109 @@ import yaml import requests import re from datetime import datetime, timedelta, timezone +from packaging.version import Version, InvalidVersion 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): + +def parse_single_version(v_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. + Limpia y extrae la versión semántica limpia de una cadena. + Ejemplos: 'v1.8.0' -> '1.8.0', 'openjdk 1.8.0_20' -> '1.8.0' + """ + if not v_str: + return None + cleaned = re.sub(r'^(openjdk|v|version)\s*', '', v_str.strip(), flags=re.IGNORECASE) + match = re.search(r'\b\d+(\.\d+)+', cleaned) + if match: + try: + return Version(match.group(0)) + except InvalidVersion: + return None + return None + + +def matches_product_version(target_version_str, product_version_str): + """ + Evalúa matemáticamente si target_version_str se ve afectada por product_version_str. + Soporta: + - Expresiones con operadores: "<19.1.1", "<=19.1.1", ">19.0", ">=19.0" + - Rangos combinados: "19.1 <19.1.1", "19.1 <19.2.3" + - Rangos con palabra "to": "1.7.0 to 1.7.10" + - Coincidencia exacta o comodines '*' """ 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() + t_ver = parse_single_version(target_version_str) + if not t_ver: + if target_version_str.strip().lower() in product_version_str.lower(): + return True, f"Coincidencia directa por cadena '{product_version_str}'" + return False, f"No coincide con '{product_version_str}'" - # 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}'" + p_str = product_version_str.strip() - # Evaluador de rangos "X to Y" - if " to " in p_ver_clean: - parts = p_ver_clean.split(" to ") + # 1. Manejo de rangos con palabra "to" (ej. "1.7.0 to 1.8.2") + if " to " in p_str.lower(): + parts = re.split(r'\s+to\s+', p_str, flags=re.IGNORECASE) 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}" + min_v = parse_single_version(parts[0]) + max_v = parse_single_version(parts[1]) + if min_v and max_v: + if min_v <= t_ver <= max_v: + return True, f"Afecta al rango {min_v} <= {t_ver} <= {max_v}" + else: + return False, f"Fuera del rango ({min_v} a {max_v})" - return False, f"No coincide con la versión/rango indicado ({product_version_str})" + # 2. Extraer condiciones/operadores de la cadena product_version + tokens = re.findall(r'(<=|>=|<|>)?\s*(\d+(?:\.\d+)+[a-zA-Z0-9_\.-]*)', p_str) + + if not tokens: + if t_ver.public in p_str: + return True, f"Coincidencia textual directa con '{product_version_str}'" + return False, f"No coincide con '{product_version_str}'" + + all_conditions_met = True + reasons = [] + + for op, ver_str in tokens: + cond_ver = parse_single_version(ver_str) + if not cond_ver: + continue + + if op == "<": + met = t_ver < cond_ver + reasons.append(f"{t_ver} < {cond_ver} ({'Sí' if met else 'No'})") + elif op == "<=": + met = t_ver <= cond_ver + reasons.append(f"{t_ver} <= {cond_ver} ({'Sí' if met else 'No'})") + elif op == ">": + met = t_ver > cond_ver + reasons.append(f"{t_ver} > {cond_ver} ({'Sí' if met else 'No'})") + elif op == ">=": + met = t_ver >= cond_ver + reasons.append(f"{t_ver} >= {cond_ver} ({'Sí' if met else 'No'})") + else: + met = (t_ver == cond_ver) or (t_ver.public.startswith(cond_ver.public)) + reasons.append(f"Rama/Coincidencia {cond_ver} ({'Sí' if met else 'No'})") + + if not met: + all_conditions_met = False + + rule_msg = " | ".join(reasons) + if all_conditions_met: + return True, f"Cumple condiciones: {rule_msg}" + else: + return False, f"No cumple condiciones: {rule_msg}" 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, @@ -69,7 +133,6 @@ def fetch_fast_enisa_vulnerabilities(vendor, product, from_score=7.0, days_back= 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, @@ -92,6 +155,7 @@ def fetch_fast_enisa_vulnerabilities(vendor, product, from_score=7.0, days_back= 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"): @@ -130,7 +194,6 @@ def generate_markdown_report(total_consolidados, total_analizadas, total_aplicab 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, @@ -154,12 +217,14 @@ def generate_markdown_report(total_consolidados, total_analizadas, total_aplicab badge = "[**BAJA**]" just = app['Justificación'].strip() + score_vector = app.get('BaseScoreVector', 'N/A') md_content += f""" ### {badge} - {app['ID']} - {app['CVE']} * **Producto / Versión:** `{app['Producto']}` (v`{app['Versión']}`) | **Fuente:** {app['Fuente']} * **BaseScore:** {app['BaseScore']} +* **Vector CVSS:** `{score_vector}` * **Regla versión:** {app['Regla versión']} * **Justificación / Descripción:** > {just} @@ -210,6 +275,7 @@ def process_targets(config_path="targets.yaml"): for item in items: score = item.get("baseScore", 0.0) or 0.0 score_ver = item.get("baseScoreVersion", "3.1") + score_vector = item.get("baseScoreVector", "N/A") euvd_id = item.get("id", "N/A") aliases = item.get("aliases", "") @@ -219,7 +285,6 @@ def process_targets(config_path="targets.yaml"): 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" @@ -233,7 +298,6 @@ def process_targets(config_path="targets.yaml"): 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" @@ -246,6 +310,7 @@ def process_targets(config_path="targets.yaml"): "Producto": product, "Versión": version, "BaseScore": f"{score} {score_ver}".strip(), + "BaseScoreVector": score_vector, "Regla versión": match_rule, "Justificación": desc_short }) @@ -270,7 +335,6 @@ def process_targets(config_path="targets.yaml"): 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"], @@ -280,7 +344,6 @@ def process_targets(config_path="targets.yaml"): 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,