From 69a9f6647cb2bb31a61d922edcdab6b22183d606 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Terrazzoni Date: Thu, 11 Jun 2026 15:26:14 +0200 Subject: [PATCH 1/5] feat(beta-26.6): add DNSDumpster-like host enrichment --- backend/app/services/host_enrichment.py | 476 ++++++++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 backend/app/services/host_enrichment.py diff --git a/backend/app/services/host_enrichment.py b/backend/app/services/host_enrichment.py new file mode 100644 index 0000000..a8518b1 --- /dev/null +++ b/backend/app/services/host_enrichment.py @@ -0,0 +1,476 @@ +from __future__ import annotations + +import asyncio +import ipaddress +import os +import re +import socket +import ssl +from collections import Counter, defaultdict +from datetime import datetime, timezone +from html import unescape +from typing import Any + +import dns.resolver +import httpx +from cryptography import x509 +from cryptography.x509.oid import ExtensionOID, NameOID + +from app.services.network_guard import resolve_ips + +MAX_HOSTS = int(os.getenv("OPENEASM_ENRICH_MAX_HOSTS", "150")) +CONCURRENCY = int(os.getenv("OPENEASM_ENRICH_CONCURRENCY", "10")) +HTTP_TIMEOUT = float(os.getenv("OPENEASM_HTTP_TIMEOUT", "8.0")) +RDAP_TIMEOUT = float(os.getenv("OPENEASM_RDAP_TIMEOUT", "5.0")) +ENABLE_RDAP = os.getenv("OPENEASM_ENABLE_RDAP", "true").lower() in {"1", "true", "yes", "on"} + +TECH_RULES = [ + ("Apache", re.compile(r"apache", re.I)), + ("nginx", re.compile(r"nginx", re.I)), + ("Microsoft-IIS", re.compile(r"microsoft-iis|\biis\b", re.I)), + ("OpenSSL", re.compile(r"openssl", re.I)), + ("PHP", re.compile(r"php", re.I)), + ("ASP.NET", re.compile(r"asp\.net|x-aspnet", re.I)), + ("jQuery", re.compile(r"jquery(?:[-.]|/)?([0-9][0-9A-Za-z_.-]*)?", re.I)), + ("Bootstrap", re.compile(r"bootstrap(?:[-.]|/)?([0-9][0-9A-Za-z_.-]*)?", re.I)), + ("Modernizr", re.compile(r"modernizr", re.I)), + ("OneTrust", re.compile(r"onetrust", re.I)), + ("WordPress", re.compile(r"wp-content|wordpress", re.I)), + ("GLPI", re.compile(r"\bglpi\b", re.I)), + ("Zabbix", re.compile(r"zabbix", re.I)), + ("Fortinet", re.compile(r"fortinet|fortigate|fortipam|fortiguard", re.I)), + ("Apache Tomcat", re.compile(r"tomcat", re.I)), +] + +SECURITY_HEADERS = [ + "strict-transport-security", + "content-security-policy", + "x-frame-options", + "x-content-type-options", + "referrer-policy", + "permissions-policy", +] + + +def _domain_match(hostname: str, domain: str) -> bool: + hostname = hostname.strip(".").lower() + domain = domain.strip(".").lower() + return hostname == domain or hostname.endswith("." + domain) + + +def _uniq(items: list[str]) -> list[str]: + return sorted({str(item).strip().strip(".").lower() for item in items if item}) + + +def _host_candidates(domain: str, subdomains_result: dict, ip_inventory: dict) -> list[dict[str, Any]]: + by_host: dict[str, dict[str, Any]] = {} + + def add(hostname: str, source: str = "candidate", entry: dict | None = None) -> None: + if not hostname or not _domain_match(hostname, domain): + return + hostname = hostname.strip(".").lower() + item = by_host.setdefault( + hostname, + { + "hostname": hostname, + "sources": set(), + "ips": set(), + "public_ips": set(), + "blocked_ips": set(), + "cname_chain": [], + "resolved_name": None, + }, + ) + item["sources"].add(source) + if entry: + item["resolved_name"] = entry.get("resolved_name") or item.get("resolved_name") + item["cname_chain"] = entry.get("cname_chain") or item.get("cname_chain") or [] + for ip in entry.get("ips", []) or []: + item["ips"].add(ip) + for ip in entry.get("public_ips", []) or []: + item["public_ips"].add(ip) + for ip in entry.get("blocked_ips", []) or []: + item["blocked_ips"].add(ip) + + add(domain, "root") + add(f"www.{domain}", "www") + + for sub in subdomains_result.get("subdomains", []) or []: + add(str(sub), "subdomain") + + for entry in ip_inventory.get("entries", []) or []: + add(entry.get("hostname"), entry.get("source") or "inventory", entry) + + for ip_item in ip_inventory.get("unique_ips", []) or []: + if not isinstance(ip_item, dict): + continue + for host in ip_item.get("hostnames", []) or []: + add(host, "ip_inventory") + item = by_host.get(host.strip(".").lower()) + if item: + if ip_item.get("is_public"): + item["public_ips"].add(ip_item.get("ip")) + else: + item["blocked_ips"].add(ip_item.get("ip")) + + # Resolve missing candidates. This keeps the enrichment useful even when a + # passive source discovered a hostname after the inventory cap was reached. + for hostname, item in by_host.items(): + if item["ips"] or item["public_ips"] or item["blocked_ips"]: + continue + guard = resolve_ips(hostname) + for ip in guard.get("ips", []) or []: + item["ips"].add(ip) + for ip in guard.get("public_ips", []) or []: + item["public_ips"].add(ip) + for ip in guard.get("blocked_ips", []) or []: + item["blocked_ips"].add(ip) + + ordered = [] + for hostname in sorted(by_host): + item = by_host[hostname] + ordered.append( + { + "hostname": hostname, + "sources": sorted(item["sources"]), + "ips": sorted(item["ips"] or item["public_ips"] or item["blocked_ips"]), + "public_ips": sorted(ip for ip in item["public_ips"] if ip), + "blocked_ips": sorted(ip for ip in item["blocked_ips"] if ip), + "cname_chain": item.get("cname_chain") or [], + "resolved_name": item.get("resolved_name") or hostname, + } + ) + return ordered[:MAX_HOSTS] + + +def _reverse_for_cymru(ip: str) -> tuple[str, str]: + address = ipaddress.ip_address(ip) + if address.version == 4: + return ".".join(reversed(ip.split("."))) + ".origin.asn.cymru.com", "TXT" + nibbles = address.exploded.replace(":", "") + return ".".join(reversed(nibbles)) + ".origin6.asn.cymru.com", "TXT" + + +def _cymru_lookup(ip: str) -> dict[str, Any]: + try: + qname, rtype = _reverse_for_cymru(ip) + resolver = dns.resolver.Resolver() + resolver.lifetime = 3.0 + resolver.timeout = 3.0 + answers = resolver.resolve(qname, rtype) + raw = " ".join(str(a).strip('"') for a in answers) + # Format: AS | BGP Prefix | CC | Registry | Allocated | AS Name + parts = [p.strip() for p in raw.split("|")] + if len(parts) >= 5 and parts[0].lower() != "as": + return { + "asn": f"AS{parts[0]}", + "network": parts[1] if len(parts) > 1 else None, + "country": parts[2] if len(parts) > 2 else None, + "registry": parts[3] if len(parts) > 3 else None, + "allocated": parts[4] if len(parts) > 4 else None, + "asn_name": parts[5] if len(parts) > 5 else None, + "source": "Team Cymru", + } + except Exception as exc: + return {"error": str(exc), "source": "Team Cymru"} + return {"source": "Team Cymru"} + + +async def _rdap_lookup(client: httpx.AsyncClient, ip: str) -> dict[str, Any]: + if not ENABLE_RDAP: + return {} + try: + response = await client.get(f"https://rdap.org/ip/{ip}") + response.raise_for_status() + data = response.json() + return { + "rdap_handle": data.get("handle"), + "rdap_name": data.get("name"), + "country": data.get("country"), + "start_address": data.get("startAddress"), + "end_address": data.get("endAddress"), + "provider": data.get("name") or data.get("handle"), + "rdap_source": "rdap.org", + } + except Exception as exc: + return {"rdap_error": str(exc), "rdap_source": "rdap.org"} + + +async def _enrich_ips(ip_inventory: dict) -> dict[str, dict[str, Any]]: + ip_meta: dict[str, dict[str, Any]] = {} + ips = [] + for item in ip_inventory.get("unique_ips", []) or []: + if isinstance(item, dict) and item.get("ip") and item.get("is_public"): + ips.append(item["ip"]) + ips = sorted(set(ips)) + + async with httpx.AsyncClient(timeout=RDAP_TIMEOUT, follow_redirects=True) as client: + rdap_tasks = {ip: asyncio.create_task(_rdap_lookup(client, ip)) for ip in ips} + for ip in ips: + cymru = await asyncio.to_thread(_cymru_lookup, ip) + rdap = await rdap_tasks[ip] + merged = {**cymru, **{k: v for k, v in rdap.items() if v}} + if rdap.get("country") and not merged.get("country"): + merged["country"] = rdap["country"] + if not merged.get("provider"): + merged["provider"] = merged.get("asn_name") or merged.get("rdap_name") + ip_meta[ip] = merged + + for item in ip_inventory.get("unique_ips", []) or []: + if isinstance(item, dict) and item.get("ip") in ip_meta: + item.update({k: v for k, v in ip_meta[item["ip"]].items() if v not in (None, "", [], {})}) + return ip_meta + + +def _title_from_html(text: str) -> str | None: + match = re.search(r"]*>(.*?)", text or "", re.I | re.S) + if not match: + return None + title = re.sub(r"\s+", " ", unescape(match.group(1))).strip() + return title[:180] if title else None + + +def _detect_technologies(headers: dict[str, str], body: str) -> tuple[list[str], list[str]]: + haystack = "\n".join([f"{k}: {v}" for k, v in headers.items()]) + "\n" + (body or "")[:200000] + technologies = [] + banners = [] + server = headers.get("server") or headers.get("Server") + powered = headers.get("x-powered-by") or headers.get("X-Powered-By") + if server: + banners.append(server) + if powered: + banners.append(powered) + for name, pattern in TECH_RULES: + match = pattern.search(haystack) + if match: + version = match.group(1) if match.groups() and match.group(1) else "" + technologies.append(f"{name}:{version}" if version and name in {"jQuery", "Bootstrap"} else name) + return sorted(set(technologies)), sorted(set(banners)) + + +async def _fetch_http(client: httpx.AsyncClient, url: str, verify: bool = True) -> dict[str, Any]: + try: + response = await client.get(url, follow_redirects=True) + headers = {k.lower(): v for k, v in response.headers.items()} + text = response.text[:250000] if response.text else "" + technologies, banners = _detect_technologies(headers, text) + return { + "url": url, + "reachable": True, + "status_code": response.status_code, + "final_url": str(response.url), + "server": headers.get("server"), + "x_powered_by": headers.get("x-powered-by"), + "title": _title_from_html(text), + "headers": {h: headers.get(h) for h in SECURITY_HEADERS if headers.get(h)}, + "technologies": technologies, + "banners": banners, + "error": None, + } + except Exception as exc: + return { + "url": url, + "reachable": False, + "status_code": None, + "final_url": None, + "server": None, + "x_powered_by": None, + "title": None, + "headers": {}, + "technologies": [], + "banners": [], + "error": str(exc), + } + + +def _name_attr(cert: x509.Certificate, oid: NameOID) -> str | None: + try: + attrs = cert.subject.get_attributes_for_oid(oid) + return attrs[0].value if attrs else None + except Exception: + return None + + +def _issuer_attr(cert: x509.Certificate, oid: NameOID) -> str | None: + try: + attrs = cert.issuer.get_attributes_for_oid(oid) + return attrs[0].value if attrs else None + except Exception: + return None + + +def _cert_datetime(cert: x509.Certificate, attr: str) -> datetime | None: + try: + value = getattr(cert, attr + "_utc") + except Exception: + value = getattr(cert, attr, None) + if value and value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value + + +def _tls_certificate(hostname: str, port: int = 443) -> dict[str, Any]: + try: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + with socket.create_connection((hostname, port), timeout=HTTP_TIMEOUT) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as ssock: + der = ssock.getpeercert(binary_form=True) + tls_version = ssock.version() + if not der: + return {"reachable": False, "error": "Aucun certificat présenté"} + cert = x509.load_der_x509_certificate(der) + san_dns = [] + try: + san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value + san_dns = san.get_values_for_type(x509.DNSName) + except Exception: + san_dns = [] + not_after = _cert_datetime(cert, "not_valid_after") + not_before = _cert_datetime(cert, "not_valid_before") + days_remaining = None + if not_after: + days_remaining = (not_after - datetime.now(timezone.utc)).days + return { + "reachable": True, + "tls_version": tls_version, + "subject_cn": _name_attr(cert, NameOID.COMMON_NAME), + "issuer_cn": _issuer_attr(cert, NameOID.COMMON_NAME), + "issuer_org": _issuer_attr(cert, NameOID.ORGANIZATION_NAME), + "not_before": not_before.isoformat() if not_before else None, + "not_after": not_after.isoformat() if not_after else None, + "days_remaining": days_remaining, + "san_dns": san_dns[:80], + "serial_number": str(cert.serial_number), + "error": None, + } + except Exception as exc: + return {"reachable": False, "error": str(exc)} + + +async def _enrich_host(host: dict[str, Any], ip_meta: dict[str, dict[str, Any]], sem: asyncio.Semaphore) -> dict[str, Any]: + async with sem: + hostname = host["hostname"] + public_ips = host.get("public_ips", []) or [] + if not public_ips or host.get("blocked_ips"): + host.update({ + "status": "skipped_by_guard", + "reason": "Résolution non publique ou mixte ; enrichissement actif HTTP/TLS bloqué.", + "http": None, + "https": None, + "tls_certificate": None, + "technologies": [], + "banners": [], + "services": [], + }) + return host + + limits = httpx.Limits(max_keepalive_connections=4, max_connections=8) + timeout = httpx.Timeout(HTTP_TIMEOUT) + async with httpx.AsyncClient(timeout=timeout, limits=limits, verify=False) as client: + http_result, https_result = await asyncio.gather( + _fetch_http(client, f"http://{hostname}", verify=False), + _fetch_http(client, f"https://{hostname}", verify=False), + ) + tls_cert = await asyncio.to_thread(_tls_certificate, hostname) + + all_tech = sorted(set((http_result.get("technologies") or []) + (https_result.get("technologies") or []))) + all_banners = sorted(set((http_result.get("banners") or []) + (https_result.get("banners") or []))) + ip_details = [ip_meta.get(ip, {}) | {"ip": ip} for ip in public_ips] + countries = sorted({str(d.get("country")) for d in ip_details if d.get("country")}) + providers = sorted({str(d.get("provider") or d.get("asn_name")) for d in ip_details if d.get("provider") or d.get("asn_name")}) + networks = sorted({str(d.get("network") or d.get("rdap_name")) for d in ip_details if d.get("network") or d.get("rdap_name")}) + + host.update({ + "status": "ok", + "http": http_result, + "https": https_result, + "tls_certificate": tls_cert, + "technologies": all_tech, + "banners": all_banners, + "title": https_result.get("title") or http_result.get("title"), + "best_url": https_result.get("final_url") if https_result.get("reachable") else http_result.get("final_url"), + "best_status": https_result.get("status_code") if https_result.get("reachable") else http_result.get("status_code"), + "countries": countries, + "providers": providers, + "networks": networks, + "ip_details": ip_details, + "services": [], + }) + return host + + +def _summary(hosts: list[dict[str, Any]], ip_inventory: dict) -> dict[str, Any]: + locations = Counter() + hosting = Counter() + banners = Counter() + techs = Counter() + for item in ip_inventory.get("unique_ips", []) or []: + if not isinstance(item, dict) or not item.get("is_public"): + continue + country = item.get("country") or "Unknown" + locations[country] += 1 + network_label = " / ".join([x for x in [item.get("asn"), item.get("asn_name") or item.get("provider"), item.get("network")] if x]) + if network_label: + hosting[network_label] += 1 + for host in hosts: + for banner in host.get("banners", []) or []: + banners[banner] += 1 + for tech in host.get("technologies", []) or []: + techs[tech] += 1 + return { + "host_count": len(hosts), + "active_host_count": len([h for h in hosts if h.get("status") == "ok"]), + "skipped_host_count": len([h for h in hosts if h.get("status") != "ok"]), + "location_counts": dict(locations.most_common()), + "hosting_networks": dict(hosting.most_common(40)), + "service_banners": dict(banners.most_common(40)), + "technology_counts": dict(techs.most_common(40)), + "note": "Enrichissement DNSDumpster-like : ASN/hébergeur/localisation IP, titres HTTP, banners, technologies, certificats TLS et rattachement des services Nmap. Aucun exploit, bruteforce ou script intrusif.", + } + + +async def enrich_public_hosts(domain: str, subdomains_result: dict, ip_inventory: dict) -> dict[str, Any]: + ip_meta = await _enrich_ips(ip_inventory) + candidates = _host_candidates(domain, subdomains_result, ip_inventory) + sem = asyncio.Semaphore(CONCURRENCY) + hosts = await asyncio.gather(*[_enrich_host(host, ip_meta, sem) for host in candidates]) if candidates else [] + return { + "domain": domain, + "version": "Beta 26.6", + "mode": "dnsdumpster_like_public_host_enrichment", + "max_hosts": MAX_HOSTS, + "concurrency": CONCURRENCY, + "hosts": list(hosts), + "summary": _summary(list(hosts), ip_inventory), + } + + +def attach_services_to_hosts(host_enrichment: dict, service_scan: dict) -> dict: + by_host = {h.get("hostname"): h for h in host_enrichment.get("hosts", []) if isinstance(h, dict)} + for port in service_scan.get("open_ports", []) or []: + hostname = port.get("hostname") + if not hostname or hostname not in by_host: + continue + service = { + "port": port.get("port"), + "protocol": port.get("protocol", "tcp"), + "service": port.get("name") or port.get("service"), + "product": port.get("product"), + "version": port.get("version") or "Version non exposée", + "cpe": port.get("cpe") or port.get("cpes") or [], + "cves": port.get("cves") or [], + "evidence": port.get("evidence"), + } + by_host[hostname].setdefault("services", []).append(service) + + service_banners = Counter((host_enrichment.get("summary") or {}).get("service_banners") or {}) + for port in service_scan.get("open_ports", []) or []: + label = " ".join([str(x) for x in [port.get("product"), port.get("version")] if x]).strip() + if not label: + label = str(port.get("name") or port.get("service") or "unknown") + service_banners[label] += 1 + host_enrichment.setdefault("summary", {})["service_banners"] = dict(service_banners.most_common(60)) + host_enrichment["summary"]["nmap_service_count"] = service_scan.get("count_open_ports", 0) + return host_enrichment From b8471c1dfca61c1b52a67b8d6cd6eacc460b6806 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Terrazzoni Date: Thu, 11 Jun 2026 15:27:27 +0200 Subject: [PATCH 2/5] feat(beta-26.6): scan services on all public subdomains --- backend/app/services/nmap_audit.py | 163 +++++++---------------------- 1 file changed, 35 insertions(+), 128 deletions(-) diff --git a/backend/app/services/nmap_audit.py b/backend/app/services/nmap_audit.py index 3238501..da73ed2 100644 --- a/backend/app/services/nmap_audit.py +++ b/backend/app/services/nmap_audit.py @@ -6,25 +6,15 @@ import subprocess import time import xml.etree.ElementTree as ET -from dataclasses import dataclass from typing import Any from app.services.network_guard import resolve_ips -TOP_PORTS = os.getenv("OPENEASM_NMAP_TOP_PORTS", "") +TOP_PORTS = os.getenv("OPENEASM_NMAP_TOP_PORTS", "1000") HOST_TIMEOUT = os.getenv("OPENEASM_NMAP_HOST_TIMEOUT", "120s") -PROCESS_TIMEOUT = int(os.getenv("OPENEASM_NMAP_PROCESS_TIMEOUT", "150")) - - -@dataclass(frozen=True) -class CveRule: - cve: str - technology: str - severity: str - cvss: float - confidence: str - description: str - recommendation: str +PROCESS_TIMEOUT = int(os.getenv("OPENEASM_NMAP_PROCESS_TIMEOUT", "300")) +SCAN_ALL_SUBDOMAINS = os.getenv("OPENEASM_SCAN_ALL_SUBDOMAINS", "true").lower() in {"1", "true", "yes", "on"} +MAX_TARGETS = int(os.getenv("OPENEASM_MAX_SERVICE_TARGETS", "150")) def _version_tuple(value: str | None) -> tuple[int, ...]: @@ -72,67 +62,31 @@ def correlate_service_cves(service: dict[str, Any]) -> list[dict[str, Any]]: if "apache" in haystack and ("httpd" in haystack or "apache http" in haystack or name in {"http", "https"}): if _version_eq(version, "2.4.49"): - matches.append(_rule( - "CVE-2021-41773", "Apache HTTP Server 2.4.49", "critical", 9.8, "élevée", - "Apache HTTP Server 2.4.49 est associé à une traversée de chemin et, selon la configuration, une exécution de code distante.", - "Mettre à jour Apache HTTP Server vers une version corrigée et vérifier la configuration des Alias/Require.", - )) + matches.append(_rule("CVE-2021-41773", "Apache HTTP Server 2.4.49", "critical", 9.8, "élevée", "Apache HTTP Server 2.4.49 est associé à une traversée de chemin et, selon la configuration, une exécution de code distante.", "Mettre à jour Apache HTTP Server vers une version corrigée et vérifier la configuration des Alias/Require.")) if _version_eq(version, "2.4.50"): - matches.append(_rule( - "CVE-2021-42013", "Apache HTTP Server 2.4.50", "critical", 9.8, "élevée", - "Apache HTTP Server 2.4.50 est associé à une traversée de chemin et, selon la configuration, une exécution de code distante.", - "Mettre à jour Apache HTTP Server vers une version corrigée et vérifier la configuration des Alias/Require.", - )) + matches.append(_rule("CVE-2021-42013", "Apache HTTP Server 2.4.50", "critical", 9.8, "élevée", "Apache HTTP Server 2.4.50 est associé à une traversée de chemin et, selon la configuration, une exécution de code distante.", "Mettre à jour Apache HTTP Server vers une version corrigée et vérifier la configuration des Alias/Require.")) if "openssh" in haystack: if _version_between(version, "8.5", "9.7"): - matches.append(_rule( - "CVE-2024-6387", "OpenSSH portable 8.5p1 à 9.7p1", "high", 8.1, "moyenne", - "La version OpenSSH détectée peut correspondre à la plage concernée par regreSSHion sur certains systèmes Linux glibc. La condition exacte dépend du paquet et de la distribution.", - "Confirmer la version paquet côté serveur et appliquer les correctifs OpenSSH/distribution.", - )) + matches.append(_rule("CVE-2024-6387", "OpenSSH portable 8.5p1 à 9.7p1", "high", 8.1, "moyenne", "La version OpenSSH détectée peut correspondre à la plage concernée par regreSSHion sur certains systèmes Linux glibc. La condition exacte dépend du paquet et de la distribution.", "Confirmer la version paquet côté serveur et appliquer les correctifs OpenSSH/distribution.")) if _version_between(version, "7.2", "7.7"): - matches.append(_rule( - "CVE-2018-15473", "OpenSSH 7.2 à 7.7", "medium", 5.3, "moyenne", - "Certaines versions OpenSSH 7.2 à 7.7 sont associées à une énumération d’utilisateurs.", - "Mettre à jour OpenSSH et vérifier la configuration d’authentification SSH.", - )) + matches.append(_rule("CVE-2018-15473", "OpenSSH 7.2 à 7.7", "medium", 5.3, "moyenne", "Certaines versions OpenSSH 7.2 à 7.7 sont associées à une énumération d’utilisateurs.", "Mettre à jour OpenSSH et vérifier la configuration d’authentification SSH.")) if "vsftpd" in haystack and _version_eq(version, "2.3.4"): - matches.append(_rule( - "CVE-2011-2523", "vsftpd 2.3.4", "critical", 10.0, "élevée", - "vsftpd 2.3.4 est connu pour une version backdoorée compromise.", - "Retirer immédiatement cette version, reconstruire le serveur depuis une source saine et investiguer une compromission potentielle.", - )) + matches.append(_rule("CVE-2011-2523", "vsftpd 2.3.4", "critical", 10.0, "élevée", "vsftpd 2.3.4 est connu pour une version backdoorée compromise.", "Retirer immédiatement cette version, reconstruire le serveur depuis une source saine et investiguer une compromission potentielle.")) if "nginx" in haystack and _version_between(version, "1.3.13", "1.4.0"): - matches.append(_rule( - "CVE-2013-2028", "nginx 1.3.13 à 1.4.0", "high", 7.5, "moyenne", - "Certaines versions nginx 1.3.13 à 1.4.0 sont associées à une vulnérabilité de traitement chunked pouvant provoquer une exécution de code selon les conditions.", - "Mettre à jour nginx vers une version maintenue par l’éditeur ou la distribution.", - )) + matches.append(_rule("CVE-2013-2028", "nginx 1.3.13 à 1.4.0", "high", 7.5, "moyenne", "Certaines versions nginx 1.3.13 à 1.4.0 sont associées à une vulnérabilité de traitement chunked pouvant provoquer une exécution de code selon les conditions.", "Mettre à jour nginx vers une version maintenue par l’éditeur ou la distribution.")) if ("openssl" in haystack or "ssl" in name.lower()) and re.search(r"\b1\.0\.1[a-f]?\b", version): - matches.append(_rule( - "CVE-2014-0160", "OpenSSL 1.0.1 à 1.0.1f", "high", 7.5, "moyenne", - "La version OpenSSL détectée peut correspondre à la plage Heartbleed. La confirmation dépend de la version exacte du paquet.", - "Mettre à jour OpenSSL et renouveler les certificats/clefs si une exposition passée est confirmée.", - )) + matches.append(_rule("CVE-2014-0160", "OpenSSL 1.0.1 à 1.0.1f", "high", 7.5, "moyenne", "La version OpenSSL détectée peut correspondre à la plage Heartbleed. La confirmation dépend de la version exacte du paquet.", "Mettre à jour OpenSSL et renouveler les certificats/clefs si une exposition passée est confirmée.")) if "microsoft-iis" in haystack or "microsoft iis" in haystack: if _version_eq(version, "6.0"): - matches.append(_rule( - "CVE-2017-7269", "Microsoft IIS 6.0", "critical", 9.8, "moyenne", - "IIS 6.0 avec WebDAV est associé à une vulnérabilité d’exécution de code distante. La présence de WebDAV doit être confirmée.", - "Migrer vers une version supportée de Windows Server/IIS et désactiver WebDAV si non nécessaire.", - )) + matches.append(_rule("CVE-2017-7269", "Microsoft IIS 6.0", "critical", 9.8, "moyenne", "IIS 6.0 avec WebDAV est associé à une vulnérabilité d’exécution de code distante. La présence de WebDAV doit être confirmée.", "Migrer vers une version supportée de Windows Server/IIS et désactiver WebDAV si non nécessaire.")) if "apache tomcat" in haystack and _version_between(version, "9.0.0", "9.0.30"): - matches.append(_rule( - "CVE-2020-1938", "Apache Tomcat 9.0.0 à 9.0.30", "high", 9.8, "faible", - "La version Tomcat peut correspondre à la plage Ghostcat. L’exposition du connecteur AJP doit être confirmée.", - "Mettre à jour Tomcat et désactiver ou restreindre le connecteur AJP.", - )) + matches.append(_rule("CVE-2020-1938", "Apache Tomcat 9.0.0 à 9.0.30", "high", 9.8, "faible", "La version Tomcat peut correspondre à la plage Ghostcat. L’exposition du connecteur AJP doit être confirmée.", "Mettre à jour Tomcat et désactiver ou restreindre le connecteur AJP.")) for match in matches: match.update({ @@ -188,18 +142,20 @@ def _parse_nmap_xml(xml_text: str, hostname: str) -> tuple[list[dict[str, Any]], } entry["cves"] = correlate_service_cves(entry) ports.append(entry) - return ports, errors def _target_candidates(domain: str, ip_inventory: dict | None) -> list[str]: - candidates = [domain, f"www.{domain}"] - # Keep V7 intentionally light: scan only the core public exposure, not every CT-discovered subdomain. - for item in (ip_inventory or {}).get("core_public_ips", [])[:3]: + candidates: list[str] = [domain, f"www.{domain}"] + inv = ip_inventory or {} + source_items = inv.get("unique_ips", []) if SCAN_ALL_SUBDOMAINS else inv.get("core_public_ips", []) + for item in source_items: + if not isinstance(item, dict) or item.get("is_public") is False or item.get("scope") == "non_public": + continue for host in item.get("hostnames", []) or []: - if host not in candidates: - candidates.append(host) - return sorted(dict.fromkeys(candidates))[:4] + if host and (host == domain or str(host).endswith("." + domain)): + candidates.append(str(host).strip(".").lower()) + return sorted(dict.fromkeys(candidates))[:MAX_TARGETS] def audit_service_versions(domain: str, ip_inventory: dict | None = None) -> dict[str, Any]: @@ -207,9 +163,11 @@ def audit_service_versions(domain: str, ip_inventory: dict | None = None) -> dic nmap_path = shutil.which("nmap") result: dict[str, Any] = { "enabled": bool(nmap_path), - "mode": "active_light_service_version_no_exploit", + "mode": "beta_26_6_all_public_subdomains_service_version_no_exploit", "tool": "nmap", - "command_policy": f"-sS -sV --version-all --reason -Pn --max-retries 2 --host-timeout {HOST_TIMEOUT} --open -oX -", + "command_policy": f"-sS -sV --version-all --reason -Pn --max-retries 2 --host-timeout {HOST_TIMEOUT} --open --top-ports {TOP_PORTS or 'default'} -oX -", + "scan_all_subdomains": SCAN_ALL_SUBDOMAINS, + "max_targets": MAX_TARGETS, "targets": [], "open_ports": [], "cves": [], @@ -217,11 +175,11 @@ def audit_service_versions(domain: str, ip_inventory: dict | None = None) -> dic "count_open_ports": 0, "count_cves": 0, "elapsed_seconds": 0, - "note": "Détection active légère : service/version/port uniquement. Scan SYN (-sS) pour se rapprocher d’un nmap classique root. Fallback TCP connect (-sT) seulement si les privilèges raw socket sont indisponibles. Aucun script exploit/brute/dos/intrusive n'est exécuté.", + "note": "Beta 26.6 : détection service/version sur les sous-domaines publics inventoriés. Scan SYN (-sS), fallback TCP connect (-sT) si nécessaire. Aucun exploit, bruteforce, DoS ou script NSE intrusif.", } if not nmap_path: - result["note"] = "Nmap n'est pas installé dans le conteneur. Installez le paquet nmap ou reconstruisez l'image Docker V7." + result["note"] = "Nmap n'est pas installé dans le conteneur. Installez le paquet nmap ou reconstruisez l'image Docker." result["findings"].append({ "severity": "info", "category": "Nmap service/version", @@ -234,16 +192,7 @@ def audit_service_versions(domain: str, ip_inventory: dict | None = None) -> dic return result for hostname in _target_candidates(domain, ip_inventory): - target = { - "hostname": hostname, - "status": "pending", - "guard": None, - "command": None, - "open_ports": [], - "cves": [], - "error": None, - "elapsed_seconds": 0, - } + target = {"hostname": hostname, "status": "pending", "guard": None, "command": None, "open_ports": [], "cves": [], "error": None, "elapsed_seconds": 0} result["targets"].append(target) guard = resolve_ips(hostname) @@ -253,57 +202,23 @@ def audit_service_versions(domain: str, ip_inventory: dict | None = None) -> dic target["error"] = "Résolution non publique ou mixte ; scan Nmap bloqué." continue - cmd = [ - nmap_path, - "-sS", - "-sV", - "--version-all", - "--reason", - "-Pn", - "--max-retries", "2", - "--host-timeout", HOST_TIMEOUT, - "--open", - "-oX", "-", - hostname, - ] + cmd = [nmap_path, "-sS", "-sV", "--version-all", "--reason", "-Pn", "--max-retries", "2", "--host-timeout", HOST_TIMEOUT, "--open", "-oX", "-", hostname] if TOP_PORTS: cmd[-1:-1] = ["--top-ports", str(TOP_PORTS)] target["command"] = " ".join(cmd[1:]) t0 = time.monotonic() try: - proc = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=PROCESS_TIMEOUT, - check=False, - ) + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=PROCESS_TIMEOUT, check=False) target["elapsed_seconds"] = round(time.monotonic() - t0, 2) stderr = proc.stderr or "" stdout = proc.stdout or "" - - # If the container cannot use raw sockets, retry with TCP connect. - raw_socket_error = any( - needle in stderr.lower() - for needle in [ - "requires root privileges", - "you requested a scan type which requires root privileges", - "operation not permitted", - "raw socket", - ] - ) + raw_socket_error = any(needle in stderr.lower() for needle in ["requires root privileges", "you requested a scan type which requires root privileges", "operation not permitted", "raw socket"]) if raw_socket_error: fallback_cmd = cmd.copy() fallback_cmd[fallback_cmd.index("-sS")] = "-sT" target["command"] = " ".join(fallback_cmd[1:]) target["fallback"] = "-sT" - proc = subprocess.run( - fallback_cmd, - capture_output=True, - text=True, - timeout=PROCESS_TIMEOUT, - check=False, - ) + proc = subprocess.run(fallback_cmd, capture_output=True, text=True, timeout=PROCESS_TIMEOUT, check=False) target["elapsed_seconds"] = round(time.monotonic() - t0, 2) stderr = proc.stderr or "" stdout = proc.stdout or "" @@ -332,7 +247,6 @@ def audit_service_versions(domain: str, ip_inventory: dict | None = None) -> dic target["status"] = "error" target["error"] = str(exc) - # De-duplicate CVE findings by hostname/port/cve. dedup = {} for cve in result["cves"]: key = f"{cve.get('hostname')}:{cve.get('port')}:{cve.get('cve')}" @@ -346,17 +260,10 @@ def audit_service_versions(domain: str, ip_inventory: dict | None = None) -> dic "severity": cve.get("severity", "medium"), "category": "CVE Nmap service/version", "title": f"{cve.get('cve')} potentielle sur {cve.get('service')} {cve.get('version') or ''}".strip(), - "description": ( - f"Corrélation non exploitante depuis Nmap -sV : {cve.get('description')} " - f"Preuve observée : {cve.get('evidence')}" - ), + "description": f"Corrélation non exploitante depuis Nmap -sV : {cve.get('description')} Preuve observée : {cve.get('evidence')}", "recommendation": cve.get("recommendation") or "Confirmer la version côté serveur puis appliquer les correctifs éditeur.", "applies_to": ["surface", "cve"], - "location": { - "hostname": cve.get("hostname"), - "control": "nmap -sV", - "display": f"{cve.get('hostname')}:{cve.get('port')}/{cve.get('protocol')}", - }, + "location": {"hostname": cve.get("hostname"), "control": "nmap -sV", "display": f"{cve.get('hostname')}:{cve.get('port')}/{cve.get('protocol')}"}, }) result["elapsed_seconds"] = round(time.monotonic() - started, 2) From 7cbca8edb232c06bd2ed14df3011aeae32413ac0 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Terrazzoni Date: Thu, 11 Jun 2026 15:30:21 +0200 Subject: [PATCH 3/5] feat(beta-26.6): enrich IP inventory with ASN hosting and location --- backend/app/services/ip_inventory.py | 68 ++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/backend/app/services/ip_inventory.py b/backend/app/services/ip_inventory.py index 80c5dde..bdbec81 100644 --- a/backend/app/services/ip_inventory.py +++ b/backend/app/services/ip_inventory.py @@ -1,6 +1,7 @@ from __future__ import annotations import dns.resolver +import ipaddress from collections import defaultdict from app.validators import is_public_ip @@ -18,6 +19,7 @@ "mailinblack.com", "sitec.fr", "akamai", "cloudflare", "googlehosted", ] + def _resolver(nameservers: list[str] | None = None) -> dns.resolver.Resolver: r = dns.resolver.Resolver(configure=(nameservers is None)) if nameservers: @@ -26,6 +28,7 @@ def _resolver(nameservers: list[str] | None = None) -> dns.resolver.Resolver: r.timeout = TIMEOUT return r + def _query(hostname: str, rtype: str) -> tuple[list[str], list[str]]: values = [] errors = [] @@ -44,6 +47,7 @@ def _query(hostname: str, rtype: str) -> tuple[list[str], list[str]]: errors.append(str(exc)) return sorted(set(values)), sorted(set(errors)) + def _resolve_any(hostname: str) -> dict: all_errors = [] cname_chain = [] @@ -76,6 +80,7 @@ def _resolve_any(hostname: str) -> dict: "errors": sorted(set(all_errors)), } + def _mx_hosts(mail_result: dict) -> list[str]: hosts = [] for value in mail_result.get("mx", {}).get("values", []): @@ -84,9 +89,11 @@ def _mx_hosts(mail_result: dict) -> list[str]: hosts.append(parts[-1].strip(".").lower()) return hosts + def _ns_hosts(dns_result: dict) -> list[str]: return [x.strip(".").lower() for x in dns_result.get("records", {}).get("NS", {}).get("values", [])] + def _is_third_party(hostname: str, resolved_name: str, sources: set[str]) -> bool: h = f"{hostname} {resolved_name}".lower() if "mx" in sources or "ns" in sources: @@ -95,11 +102,11 @@ def _is_third_party(hostname: str, resolved_name: str, sources: set[str]) -> boo return True return any(hint in h for hint in THIRD_PARTY_HINTS) + def _scope_for(hostnames: set[str], resolved_names: set[str], sources: set[str], is_public: bool) -> str: if not is_public: return "non_public" if "root" in sources or "www" in sources or "web_target" in sources: - # root/www are the primary exposed web surface return "core_exposure" sample_host = next(iter(hostnames), "") sample_resolved = next(iter(resolved_names), "") @@ -107,7 +114,45 @@ def _scope_for(hostnames: set[str], resolved_names: set[str], sources: set[str], return "third_party_provider" return "supporting_exposure" -def build_ip_inventory(domain: str, dns_result: dict, mail_result: dict, web_result: dict, subdomains_result: dict, max_subdomains: int = 150) -> dict: + +def _cymru_qname(ip: str) -> str: + address = ipaddress.ip_address(ip) + if address.version == 4: + return ".".join(reversed(ip.split("."))) + ".origin.asn.cymru.com" + nibbles = address.exploded.replace(":", "") + return ".".join(reversed(nibbles)) + ".origin6.asn.cymru.com" + + +def _asn_enrichment(ip: str) -> dict: + """Passive ASN/provider enrichment via Team Cymru DNS. + + This is DNS-only, fast, and does not require an API key. It gives OpenEASM a + DNSDumpster-like view: ASN, prefix/network, country and AS name/hoster. + """ + if not is_public_ip(ip): + return {} + try: + answers = _resolver(["1.1.1.1", "1.0.0.1"]).resolve(_cymru_qname(ip), "TXT") + raw = " ".join(str(a).strip('"') for a in answers) + parts = [p.strip() for p in raw.split("|")] + if len(parts) >= 5 and parts[0].lower() != "as": + asn_name = parts[5] if len(parts) > 5 else "" + return { + "asn": f"AS{parts[0]}", + "network": parts[1] if len(parts) > 1 else "", + "country": parts[2] if len(parts) > 2 else "", + "registry": parts[3] if len(parts) > 3 else "", + "allocated": parts[4] if len(parts) > 4 else "", + "asn_name": asn_name, + "provider": asn_name, + "geo_source": "Team Cymru DNS", + } + except Exception as exc: + return {"geo_source": "Team Cymru DNS", "geo_error": str(exc)} + return {"geo_source": "Team Cymru DNS"} + + +def build_ip_inventory(domain: str, dns_result: dict, mail_result: dict, web_result: dict, subdomains_result: dict, max_subdomains: int = 500) -> dict: hosts_by_source = defaultdict(set) hosts_by_source["root"].add(domain) @@ -155,6 +200,7 @@ def build_ip_inventory(domain: str, dns_result: dict, mail_result: dict, web_res unique_ips = [] for ip, data in sorted(ip_to_hosts.items()): scope = _scope_for(data["hostnames"], data["resolved_names"], data["sources"], data["is_public"]) + enrichment = _asn_enrichment(ip) if data["is_public"] else {} unique_ips.append({ "ip": ip, "is_public": data["is_public"], @@ -162,6 +208,7 @@ def build_ip_inventory(domain: str, dns_result: dict, mail_result: dict, web_res "sources": sorted(data["sources"]), "hostnames": sorted(data["hostnames"]), "resolved_names": sorted(data["resolved_names"]), + **enrichment, }) findings = [] @@ -181,11 +228,22 @@ def build_ip_inventory(domain: str, dns_result: dict, mail_result: dict, web_res third_party = [i for i in unique_ips if i["scope"] == "third_party_provider"] supporting = [i for i in unique_ips if i["scope"] == "supporting_exposure"] + location_counts = defaultdict(int) + hosting_networks = defaultdict(int) + for item in unique_ips: + if not item.get("is_public"): + continue + if item.get("country"): + location_counts[item["country"]] += 1 + label = " / ".join([x for x in [item.get("asn"), item.get("asn_name"), item.get("network")] if x]) + if label: + hosting_networks[label] += 1 + return { "domain": domain, "entries": entries, "unique_ips": unique_ips, - "display_ips": core + non_public + supporting[:20] + third_party[:20], + "display_ips": core + non_public + supporting[:80] + third_party[:80], "core_public_ips": core, "third_party_provider_ips": third_party, "supporting_ips": supporting, @@ -194,6 +252,8 @@ def build_ip_inventory(domain: str, dns_result: dict, mail_result: dict, web_res "core_public_ip_count": len(core), "third_party_provider_ip_count": len(third_party), "total_ip_count": len(unique_ips), + "location_counts": dict(sorted(location_counts.items())), + "hosting_networks": dict(sorted(hosting_networks.items(), key=lambda kv: kv[1], reverse=True)), "findings": findings, - "note": "Inventaire construit avec résolution DNS multi-résolveurs, CNAME, domaine racine, www, MX, NS, cibles web et sous-domaines. Les IP sont classées en exposition principale, prestataires tiers, support et non publiques.", + "note": "Inventaire Beta 26.6 construit avec résolution DNS multi-résolveurs, CNAME, domaine racine, www, MX, NS, cibles web et sous-domaines. Les IP publiques sont enrichies en ASN, pays, réseau et hébergeur via Team Cymru DNS.", } From 4724bbcec86dad52202f3f663f57cd022bd27b29 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Terrazzoni Date: Thu, 11 Jun 2026 15:33:16 +0200 Subject: [PATCH 4/5] feat(beta-26.6): wire DNSDumpster-like enrichment in API --- backend/app/main.py | 357 +++++++++++++++++++------------------------- 1 file changed, 150 insertions(+), 207 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index e92fad8..3c1dd98 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,50 +1,90 @@ from __future__ import annotations -from datetime import datetime, timezone -from uuid import uuid4 import asyncio -import traceback +from datetime import datetime, timezone from pathlib import Path +from uuid import uuid4 -from fastapi import FastAPI, HTTPException, Request, Depends +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field -from app.validators import normalize_domain -from app.services.dns_audit import audit_dns, audit_mail -from app.services.tls_audit import audit_tls -from app.services.web_audit import audit_web_targets -from app.services.subdomain_audit import discover_subdomains_ct -from app.services.domain_profile import classify_domain, adjust_findings_for_profile -from app.services.scoring import collect_findings, compute_score -from app.services.rate_limit import check_rate_limit -from app.services.ip_inventory import build_ip_inventory -from app.services.tls_scoring import score_tls -from app.services.cve_passive import detect_passive_cves -from app.services.cti_audit import audit_cti -from app.services.patching_sla import build_patching_sla -from app.services.finding_location import enrich_findings_locations +from app.database import get_db, init_db_with_retry from app.reports.excel_report import generate_excel_report +from app.reports.html_report import generate_html_report from app.reports.json_report import generate_json_report from app.reports.pdf_report import generate_pdf_report -from app.reports.html_report import generate_html_report -from app.database import init_db_with_retry, get_db -from app.services.storage import save_audit, list_audits, get_audit_record, dashboard_stats, compare_latest, delete_audit, delete_all_audits, delete_domain_audits -from app.services.domain_verification import start_verification, check_verification, get_domain_status, list_verified_domains, delete_verification, serialize_verification +from app.services.attack_graph import build_attack_graph +from app.services.cti_audit import audit_cti +from app.services.cve_passive import detect_passive_cves from app.services.diagnostics import build_system_diagnostics, test_export_dependencies +from app.services.dns_audit import audit_dns, audit_mail +from app.services.domain_profile import adjust_findings_for_profile, classify_domain +from app.services.domain_verification import ( + check_verification, + delete_verification, + get_domain_status, + list_verified_domains, + serialize_verification, + start_verification, +) from app.services.executive_risk import build_executive_risk +from app.services.finding_location import enrich_findings_locations +from app.services.host_enrichment import attach_services_to_hosts, enrich_public_hosts +from app.services.ip_inventory import build_ip_inventory +from app.services.legal_terms import create_acceptance, legal_payload, validate_acceptance from app.services.nmap_audit import audit_service_versions -from app.services.legal_terms import legal_payload, create_acceptance, validate_acceptance -from app.services.attack_graph import build_attack_graph +from app.services.patching_sla import build_patching_sla +from app.services.rate_limit import check_rate_limit +from app.services.scoring import collect_findings, compute_score +from app.services.storage import ( + compare_latest, + dashboard_stats, + delete_all_audits, + delete_audit, + delete_domain_audits, + get_audit_record, + list_audits, + save_audit, +) +from app.services.subdomain_audit import discover_subdomains_ct +from app.services.tls_audit import audit_tls +from app.services.tls_scoring import score_tls +from app.services.web_audit import audit_web_targets +from app.validators import normalize_domain + +APP_VERSION = "OpenEASM Beta 26.6" +REPORT_DIR = Path("/app/reports") +AUDITS: dict[str, dict] = {} app = FastAPI( - title="OpenEASM Beta", - description="OpenEASM Beta : EASM défensif avec avertissement juridique bloquant, Graph Explorer, Nmap service/version/CVE non exploitant et rapports professionnels.", - version="beta-1.0", + title=APP_VERSION, + description=( + "OpenEASM Beta 26.6 : EASM défensif avec enrichissement DNSDumpster-like, " + "ASN/hébergeur/localisation, scan service/version sur sous-domaines publics, " + "Graph Explorer et rapports professionnels." + ), + version="26.6", ) +class AuditRequest(BaseModel): + domain: str = Field(..., description="Nom de domaine à auditer, exemple : example.com") + accepted_terms: bool = Field(False, description="Confirme le cadre autorisé et défensif.") + terms_token: str | None = Field(None, description="Jeton d'acceptation juridique délivré par /api/legal/accept-terms.") + + +class LegalAcceptRequest(BaseModel): + accepted: bool = Field(False, description="Confirme que l'utilisateur a lu et accepté les conditions.") + terms_hash: str | None = Field(None, description="Hash SHA-256 du texte juridique affiché.") + terms_version: str | None = Field(None, description="Version du règlement affiché.") + + +class DomainVerificationRequest(BaseModel): + domain: str = Field(..., description="Nom de domaine à vérifier, exemple : example.com") + + @app.on_event("startup") def startup_event(): init_db_with_retry() @@ -61,30 +101,17 @@ async def openeasm_unhandled_exception_handler(request: Request, exc: Exception) }, ) -AUDITS: dict[str, dict] = {} -REPORT_DIR = Path("/app/reports") - -class AuditRequest(BaseModel): - domain: str = Field(..., description="Nom de domaine à auditer, exemple : example.com") - accepted_terms: bool = Field(False, description="L'utilisateur confirme être autorisé ou rester dans le cadre défensif autorisé.") - terms_token: str | None = Field(None, description="Jeton d'acceptation juridique délivré par /api/legal/accept-terms.") - -class LegalAcceptRequest(BaseModel): - accepted: bool = Field(False, description="Confirme que l'utilisateur a lu et accepté les conditions.") - terms_hash: str | None = Field(None, description="Hash SHA-256 du texte juridique affiché.") - terms_version: str | None = Field(None, description="Version du règlement affiché.") - -class DomainVerificationRequest(BaseModel): - domain: str = Field(..., description="Nom de domaine à vérifier, exemple : example.com") @app.get("/api/health") async def health(): - return {"status": "ok", "service": "openeasm-beta", "version": "beta-1.0"} + return {"status": "ok", "service": "openeasm-beta", "version": "26.6", "edition": APP_VERSION} + @app.get("/api/legal/terms") async def api_legal_terms(): return legal_payload() + @app.post("/api/legal/accept-terms") async def api_accept_terms(payload: LegalAcceptRequest, request: Request, db=Depends(get_db)): client_host = request.client.host if request.client else "unknown" @@ -100,32 +127,25 @@ async def api_accept_terms(payload: LegalAcceptRequest, request: Request, db=Dep except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) + @app.get("/api/legal/status") async def api_legal_status(token: str | None = None, db=Depends(get_db)): return validate_acceptance(db, token) + @app.post("/api/audit") async def create_audit(payload: AuditRequest, request: Request, db=Depends(get_db)): if not payload.accepted_terms: - raise HTTPException( - status_code=400, - detail="Vous devez accepter l'usage responsable avant de lancer l'audit.", - ) + raise HTTPException(status_code=400, detail="Vous devez accepter l'usage responsable avant de lancer l'audit.") legal_status = validate_acceptance(db, payload.terms_token) if not legal_status.get("accepted"): - raise HTTPException( - status_code=403, - detail="Acceptation juridique obligatoire avant d'utiliser OpenEASM.", - ) + raise HTTPException(status_code=403, detail="Acceptation juridique obligatoire avant d'utiliser OpenEASM.") client_host = request.client.host if request.client else "unknown" rate = check_rate_limit(client_host) if not rate["allowed"]: - raise HTTPException( - status_code=429, - detail=f"Trop d'audits lancés. Réessayez dans {rate['retry_after']} secondes.", - ) + raise HTTPException(status_code=429, detail=f"Trop d'audits lancés. Réessayez dans {rate['retry_after']} secondes.") try: domain = normalize_domain(payload.domain) @@ -138,23 +158,22 @@ async def create_audit(payload: AuditRequest, request: Request, db=Depends(get_d dns_result = audit_dns(domain) mail_result = audit_mail(domain) web_result = await audit_web_targets(domain) + subdomains_result = await discover_subdomains_ct(domain) + ip_inventory = build_ip_inventory(domain, dns_result, mail_result, web_result, subdomains_result) - tls_targets = [] - for target in [domain, f"www.{domain}"]: - tls_targets.append(audit_tls(target)) - + tls_targets = [audit_tls(target) for target in [domain, f"www.{domain}"]] tls_result = { "domain": domain, "targets": tls_targets, "findings": [f for t in tls_targets for f in t.get("findings", [])], } - - subdomains_result = await discover_subdomains_ct(domain) - ip_inventory = build_ip_inventory(domain, dns_result, mail_result, web_result, subdomains_result) tls_score = score_tls(tls_result, web_result) passive_cves = detect_passive_cves(web_result) cti_result = audit_cti(ip_inventory, domain) + service_scan = await asyncio.to_thread(audit_service_versions, domain, ip_inventory) + host_enrichment = await enrich_public_hosts(domain, subdomains_result, ip_inventory) + host_enrichment = attach_services_to_hosts(host_enrichment, service_scan) raw_findings = collect_findings( dns_result, @@ -174,14 +193,15 @@ async def create_audit(payload: AuditRequest, request: Request, db=Depends(get_d legacy_score = compute_score(findings, domain_profile) patching_sla = build_patching_sla(findings, created_at) executive_risk = build_executive_risk(findings, domain_profile, tls_score, ip_inventory, subdomains_result, passive_cves, cti_result) - score = executive_risk.get('global_score', legacy_score) + score = executive_risk.get("global_score", legacy_score) audit_id = str(uuid4()) audit = { "id": audit_id, + "product": APP_VERSION, "domain": domain, "created_at": created_at, - "mode": "public_defensive_beta_service_version_cve", + "mode": "public_defensive_beta_26_6_dnsdumpster_like", "verification": verification_status, "domain_profile": domain_profile, "dns": dns_result, @@ -191,6 +211,7 @@ async def create_audit(payload: AuditRequest, request: Request, db=Depends(get_d "web": web_result, "subdomains": subdomains_result, "ip_inventory": ip_inventory, + "host_enrichment": host_enrichment, "passive_cves": passive_cves, "service_scan": service_scan, "cti": cti_result, @@ -204,43 +225,30 @@ async def create_audit(payload: AuditRequest, request: Request, db=Depends(get_d "legal_acceptance": legal_status, "client": client_host, "rate_limit": rate, - "anti_ssrf": "HTTP/TLS only if target resolves to public IPs and no blocked IP.", - "active_scan": "enabled_light_service_version_only", + "anti_ssrf": "HTTP/TLS/Nmap only if target resolves to public IPs and no blocked IP.", + "active_scan": "enabled_light_service_version_all_public_subdomains", "nmap": service_scan.get("mode"), "cve_scan": "passive_headers_and_nmap_service_version_correlation", "leak_search": "disabled_public_mode", }, "report_filename": None, "json_filename": None, + "pdf_filename": None, "html_filename": None, } audit["attack_graph"] = build_attack_graph(audit) - audit["report_errors"] = [] - try: - report_filename = generate_excel_report(audit) - audit["report_filename"] = report_filename - except Exception as exc: - audit["report_errors"].append(f"Excel: {exc}") - - try: - json_filename = generate_json_report(audit) - audit["json_filename"] = json_filename - except Exception as exc: - audit["report_errors"].append(f"JSON: {exc}") - - try: - pdf_filename = generate_pdf_report(audit) - audit["pdf_filename"] = pdf_filename - except Exception as exc: - audit["report_errors"].append(f"PDF: {exc}") - - try: - html_filename = generate_html_report(audit) - audit["html_filename"] = html_filename - except Exception as exc: - audit["report_errors"].append(f"HTML: {exc}") + for label, key, generator in [ + ("Excel", "report_filename", generate_excel_report), + ("JSON", "json_filename", generate_json_report), + ("PDF", "pdf_filename", generate_pdf_report), + ("HTML", "html_filename", generate_html_report), + ]: + try: + audit[key] = generator(audit) + except Exception as exc: + audit["report_errors"].append(f"{label}: {exc}") AUDITS[audit_id] = audit save_audit(db, audit) @@ -248,32 +256,33 @@ async def create_audit(payload: AuditRequest, request: Request, db=Depends(get_d return { "id": audit_id, "domain": domain, + "version": "26.6", "mode": audit["mode"], "verification": verification_status, "created_at": audit["created_at"], "domain_profile": domain_profile, "score": audit["score"], "executive_risk": executive_risk, - "tls_score": tls_score, "findings": audit["findings"], "subdomains": { "source": subdomains_result.get("source"), "count": subdomains_result.get("count"), - "subdomains": subdomains_result.get("subdomains", [])[:80], + "subdomains": subdomains_result.get("subdomains", [])[:120], "error": subdomains_result.get("error"), }, "ip_inventory": { "public_ip_count": ip_inventory.get("public_ip_count"), "total_ip_count": ip_inventory.get("total_ip_count"), - "unique_ips": ip_inventory.get("display_ips", ip_inventory.get("unique_ips", []))[:100], + "unique_ips": ip_inventory.get("display_ips", ip_inventory.get("unique_ips", []))[:120], + "location_counts": ip_inventory.get("location_counts", {}), + "hosting_networks": ip_inventory.get("hosting_networks", {}), "core_public_ip_count": ip_inventory.get("core_public_ip_count", 0), "third_party_provider_ip_count": ip_inventory.get("third_party_provider_ip_count", 0), - "total_ip_count": ip_inventory.get("total_ip_count", 0), }, - "passive_cves": { - "count": passive_cves.get("count"), - "items": passive_cves.get("items", [])[:30], - "note": passive_cves.get("note"), + "host_enrichment": { + "summary": host_enrichment.get("summary", {}), + "hosts": host_enrichment.get("hosts", [])[:120], + "note": host_enrichment.get("summary", {}).get("note"), }, "service_scan": { "enabled": service_scan.get("enabled"), @@ -281,65 +290,33 @@ async def create_audit(payload: AuditRequest, request: Request, db=Depends(get_d "count_open_ports": service_scan.get("count_open_ports", 0), "count_cves": service_scan.get("count_cves", 0), "elapsed_seconds": service_scan.get("elapsed_seconds", 0), - "targets": service_scan.get("targets", [])[:10], - "open_ports": service_scan.get("open_ports", [])[:80], - "cves": service_scan.get("cves", [])[:50], + "targets": service_scan.get("targets", [])[:120], + "open_ports": service_scan.get("open_ports", [])[:200], + "cves": service_scan.get("cves", [])[:100], "note": service_scan.get("note"), }, - "cti": { - "summary": cti_result.get("summary", {}), - "ip_reputation": cti_result.get("ip_reputation", [])[:30], - "leak_monitoring": cti_result.get("leak_monitoring"), - "note": cti_result.get("note"), - }, - "patching_sla": { - "sla_policy": patching_sla.get("sla_policy"), - "items": patching_sla.get("items", [])[:30], - "note": patching_sla.get("note"), - }, - "web_targets": [ - { - "hostname": t.get("hostname"), - "reachable": t.get("reachable"), - "best_scheme": t.get("best_scheme"), - "public_ips": (t.get("guard") or {}).get("public_ips", []), - "blocked_ips": (t.get("guard") or {}).get("blocked_ips", []), - "http_status": (t.get("http") or {}).get("status_code"), - "https_status": (t.get("https") or {}).get("status_code"), - "https_final_url": (t.get("https") or {}).get("final_url"), - } - for t in web_result.get("targets", []) - ], "report_url": f"/api/reports/{audit_id}/excel", "json_url": f"/api/reports/{audit_id}/json", "pdf_url": f"/api/reports/{audit_id}/pdf", - "html_filename": audit.get("html_filename"), "html_url": f"/api/reports/{audit_id}/html", "graph_url": f"/api/audits/{audit_id}/graph", "attack_graph": audit.get("attack_graph", {}), "report_errors": audit.get("report_errors", []), "summary": { - "dns_public_ips": audit["dns"].get("public_ips", []), - "spf_records": audit["dns"].get("spf_records", []), - "mx_records": audit["mail"]["mx"].get("values", []), - "dmarc_records": audit["mail"].get("dmarc_records", []), - "has_web": web_result.get("has_web"), - "reachable_web_targets": domain_profile.get("reachable_web_targets", []), "subdomain_count": subdomains_result.get("count", 0), "public_ip_count": ip_inventory.get("public_ip_count", 0), - "core_public_ip_count": ip_inventory.get("core_public_ip_count", 0), - "third_party_provider_ip_count": ip_inventory.get("third_party_provider_ip_count", 0), - "passive_cve_count": passive_cves.get("count", 0), + "host_enrichment_active": host_enrichment.get("summary", {}).get("active_host_count", 0), "service_open_port_count": service_scan.get("count_open_ports", 0), "service_cve_count": service_scan.get("count_cves", 0), "service_scan_elapsed_seconds": service_scan.get("elapsed_seconds", 0), - "tls_score": tls_score.get("global_score"), - "tls_level": tls_score.get("global_level"), - "executive_score": executive_risk.get("overall_score"), - "executive_risk": executive_risk.get("risk_level"), + "locations": host_enrichment.get("summary", {}).get("location_counts", {}), + "hosting_networks": host_enrichment.get("summary", {}).get("hosting_networks", {}), + "service_banners": host_enrichment.get("summary", {}).get("service_banners", {}), + "technologies": host_enrichment.get("summary", {}).get("technology_counts", {}), }, } + @app.get("/api/audits") async def api_list_audits(limit: int = 50, domain: str | None = None, db=Depends(get_db)): records = list_audits(db, limit=limit, domain=domain) @@ -359,6 +336,7 @@ async def api_list_audits(limit: int = 50, domain: str | None = None, db=Depends for r in records ] + @app.get("/api/audits/{audit_id}") async def get_audit(audit_id: str, db=Depends(get_db)): audit = AUDITS.get(audit_id) @@ -369,6 +347,7 @@ async def get_audit(audit_id: str, db=Depends(get_db)): raise HTTPException(status_code=404, detail="Audit introuvable.") return record.audit_json + @app.get("/api/dashboard") async def api_dashboard(db=Depends(get_db)): return dashboard_stats(db) @@ -379,12 +358,10 @@ async def api_audit_graph(audit_id: str, db=Depends(get_db)): audit = AUDITS.get(audit_id) if not audit: record = get_audit_record(db, audit_id) - if record: - audit = record.audit_json - else: + if not record: raise HTTPException(status_code=404, detail="Audit introuvable.") - graph = audit.get("attack_graph") or build_attack_graph(audit) - return graph + audit = record.audit_json + return audit.get("attack_graph") or build_attack_graph(audit) @app.get("/api/graph/latest") @@ -400,15 +377,18 @@ async def api_latest_graph(db=Depends(get_db)): async def api_system_diagnostics(db=Depends(get_db)): return build_system_diagnostics(db) + @app.post("/api/system/export-test") async def api_export_test(): return test_export_dependencies(cleanup=True) + @app.get("/api/reports") async def api_reports_center(limit: int = 100, domain: str | None = None, db=Depends(get_db)): records = list_audits(db, limit=limit, domain=domain) items = [] for r in records: + html_filename = r.html_filename or (r.audit_json or {}).get("html_filename") items.append({ "id": r.id, "domain": r.domain, @@ -419,20 +399,17 @@ async def api_reports_center(limit: int = 100, domain: str | None = None, db=Dep "excel_filename": r.excel_filename, "json_filename": r.json_filename, "pdf_filename": r.pdf_filename, - "html_filename": r.html_filename or (r.audit_json or {}).get("html_filename"), + "html_filename": html_filename, "excel_exists": bool(r.excel_filename and (REPORT_DIR / r.excel_filename).exists()), "json_exists": bool(r.json_filename and (REPORT_DIR / r.json_filename).exists()), "pdf_exists": bool(r.pdf_filename and (REPORT_DIR / r.pdf_filename).exists()), - "html_exists": bool((r.html_filename or (r.audit_json or {}).get("html_filename")) and (REPORT_DIR / (r.html_filename or (r.audit_json or {}).get("html_filename"))).exists()), + "html_exists": bool(html_filename and (REPORT_DIR / html_filename).exists()), "excel_url": f"/api/reports/{r.id}/excel", "json_url": f"/api/reports/{r.id}/json", "pdf_url": f"/api/reports/{r.id}/pdf", "html_url": f"/api/reports/{r.id}/html", }) - return { - "count": len(items), - "items": items, - } + return {"count": len(items), "items": items} @app.post("/api/domains/verification/start") @@ -441,8 +418,8 @@ async def api_start_domain_verification(payload: DomainVerificationRequest, db=D domain = normalize_domain(payload.domain) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) - record = start_verification(db, domain) - return serialize_verification(record) + return serialize_verification(start_verification(db, domain)) + @app.post("/api/domains/{domain}/verification/check") async def api_check_domain_verification(domain: str, db=Depends(get_db)): @@ -455,10 +432,12 @@ async def api_check_domain_verification(domain: str, db=Depends(get_db)): raise HTTPException(status_code=404, detail="Aucune vérification démarrée pour ce domaine.") return serialize_verification(record) + @app.get("/api/domains/verified") async def api_list_verified_domains(db=Depends(get_db)): return [serialize_verification(r) for r in list_verified_domains(db)] + @app.get("/api/domains/{domain}/verification") async def api_get_domain_verification(domain: str, db=Depends(get_db)): try: @@ -467,6 +446,7 @@ async def api_get_domain_verification(domain: str, db=Depends(get_db)): raise HTTPException(status_code=400, detail=str(exc)) return get_domain_status(db, normalized) + @app.delete("/api/domains/{domain}/verification") async def api_delete_domain_verification(domain: str, db=Depends(get_db)): try: @@ -494,12 +474,14 @@ async def api_delete_audit(audit_id: str, db=Depends(get_db)): AUDITS.pop(audit_id, None) return {"deleted": True, "audit_id": audit_id} + @app.delete("/api/audits") async def api_delete_all_audits(db=Depends(get_db)): count = delete_all_audits(db) AUDITS.clear() return {"deleted": count} + @app.delete("/api/domains/{domain}/audits") async def api_delete_domain_audits(domain: str, db=Depends(get_db)): try: @@ -513,66 +495,44 @@ async def api_delete_domain_audits(domain: str, db=Depends(get_db)): return {"domain": normalized, "deleted": count} -@app.get("/api/reports/{audit_id}/excel") -async def download_excel_report(audit_id: str, db=Depends(get_db)): +def _load_audit(audit_id: str, db): audit = AUDITS.get(audit_id) + record = None if not audit: record = get_audit_record(db, audit_id) - if record: - audit = record.audit_json - else: + if not record: raise HTTPException(status_code=404, detail="Audit introuvable.") + audit = record.audit_json + return audit, record + +@app.get("/api/reports/{audit_id}/excel") +async def download_excel_report(audit_id: str, db=Depends(get_db)): + audit, _ = _load_audit(audit_id, db) filename = audit.get("report_filename") if not filename: raise HTTPException(status_code=404, detail="Rapport introuvable.") - path = REPORT_DIR / filename if not path.exists(): raise HTTPException(status_code=404, detail="Fichier rapport introuvable.") + return FileResponse(path, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename=filename) - return FileResponse( - path, - media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - filename=filename, - ) @app.get("/api/reports/{audit_id}/json") async def download_json_report(audit_id: str, db=Depends(get_db)): - audit = AUDITS.get(audit_id) - if not audit: - record = get_audit_record(db, audit_id) - if record: - audit = record.audit_json - else: - raise HTTPException(status_code=404, detail="Audit introuvable.") - + audit, _ = _load_audit(audit_id, db) filename = audit.get("json_filename") if not filename: raise HTTPException(status_code=404, detail="Rapport JSON introuvable.") - path = REPORT_DIR / filename if not path.exists(): raise HTTPException(status_code=404, detail="Fichier JSON introuvable.") - - return FileResponse( - path, - media_type="application/json", - filename=filename, - ) + return FileResponse(path, media_type="application/json", filename=filename) @app.get("/api/reports/{audit_id}/html") async def download_html_report(audit_id: str, db=Depends(get_db)): - audit = AUDITS.get(audit_id) - record = None - if not audit: - record = get_audit_record(db, audit_id) - if record: - audit = record.audit_json - else: - raise HTTPException(status_code=404, detail="Audit introuvable.") - + audit, record = _load_audit(audit_id, db) filename = audit.get("html_filename") or (record.html_filename if record else None) path = REPORT_DIR / filename if filename else None if not filename or not path or not path.exists(): @@ -585,38 +545,21 @@ async def download_html_report(audit_id: str, db=Depends(get_db)): except Exception as exc: raise HTTPException(status_code=500, detail=f"Impossible de générer le rapport HTML: {exc}") path = REPORT_DIR / filename - if not path.exists(): raise HTTPException(status_code=404, detail="Fichier HTML introuvable.") + return FileResponse(path, media_type="text/html", filename=filename) - return FileResponse( - path, - media_type="text/html", - filename=filename, - ) @app.get("/api/reports/{audit_id}/pdf") async def download_pdf_report(audit_id: str, db=Depends(get_db)): - audit = AUDITS.get(audit_id) - if not audit: - record = get_audit_record(db, audit_id) - if record: - audit = record.audit_json - else: - raise HTTPException(status_code=404, detail="Audit introuvable.") - + audit, _ = _load_audit(audit_id, db) filename = audit.get("pdf_filename") if not filename: raise HTTPException(status_code=404, detail="Rapport PDF introuvable.") - path = REPORT_DIR / filename if not path.exists(): raise HTTPException(status_code=404, detail="Fichier PDF introuvable.") + return FileResponse(path, media_type="application/pdf", filename=filename) - return FileResponse( - path, - media_type="application/pdf", - filename=filename, - ) app.mount("/", StaticFiles(directory="/app/app/static", html=True), name="static") From 851710d8fbb469d5f28993630ab72f1544a16d56 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Terrazzoni Date: Thu, 11 Jun 2026 15:34:24 +0200 Subject: [PATCH 5/5] feat(beta-26.6): enrich graph with ASN technology banner certificate nodes --- backend/app/services/attack_graph.py | 109 ++++++++++++++------------- 1 file changed, 58 insertions(+), 51 deletions(-) diff --git a/backend/app/services/attack_graph.py b/backend/app/services/attack_graph.py index 1292c99..ae765a4 100644 --- a/backend/app/services/attack_graph.py +++ b/backend/app/services/attack_graph.py @@ -3,18 +3,18 @@ from collections import Counter from typing import Any -MAX_SUBDOMAINS = 120 -MAX_IPS = 120 -MAX_FINDINGS = 60 -MAX_SERVICES = 100 +MAX_SUBDOMAINS = 180 +MAX_IPS = 180 +MAX_FINDINGS = 80 +MAX_SERVICES = 180 +MAX_HOSTS = 180 def build_attack_graph(audit: dict[str, Any]) -> dict[str, Any]: - """Build a defensive relationship graph for the Graph Explorer. + """Build a defensive relationship graph for Graph Explorer. - The graph is intentionally explanatory, not offensive: it links domains, - subdomains, public IPs, web targets, exposed services, CVE correlations and - prioritized findings already produced by OpenEASM. + Beta 26.6 adds DNSDumpster-like host enrichment nodes: ASN/provider, + locations, technologies, banners and TLS certificate summaries. """ nodes: dict[str, dict[str, Any]] = {} edges: dict[str, dict[str, Any]] = {} @@ -23,13 +23,7 @@ def add_node(node_id: str, label: str, node_type: str, **props: Any) -> str: if not node_id: return "" if node_id not in nodes: - nodes[node_id] = { - "id": node_id, - "label": label or node_id, - "type": node_type, - "weight": 1, - "properties": {}, - } + nodes[node_id] = {"id": node_id, "label": label or node_id, "type": node_type, "weight": 1, "properties": {}} else: nodes[node_id]["weight"] = int(nodes[node_id].get("weight", 1)) + 1 nodes[node_id]["properties"].update({k: v for k, v in props.items() if v not in (None, "", [], {})}) @@ -40,21 +34,13 @@ def add_edge(source: str, target: str, label: str, edge_type: str = "related", * return edge_id = f"{source}->{target}:{edge_type}:{label}" if edge_id not in edges: - edges[edge_id] = { - "id": edge_id, - "source": source, - "target": target, - "label": label, - "type": edge_type, - "weight": 1, - "properties": {}, - } + edges[edge_id] = {"id": edge_id, "source": source, "target": target, "label": label, "type": edge_type, "weight": 1, "properties": {}} else: edges[edge_id]["weight"] = int(edges[edge_id].get("weight", 1)) + 1 edges[edge_id]["properties"].update({k: v for k, v in props.items() if v not in (None, "", [], {})}) domain = audit.get("domain") or "domain" - root_id = add_node(f"domain:{domain}", domain, "domain", score=(audit.get("score") or {}).get("score")) + root_id = add_node(f"domain:{domain}", domain, "domain", score=(audit.get("score") or {}).get("score"), product=audit.get("product")) profile = audit.get("domain_profile") or {} if profile: @@ -82,14 +68,51 @@ def add_edge(source: str, target: str, label: str, edge_type: str = "related", * ip, "ip", scope=item.get("scope") if isinstance(item, dict) else None, + asn=item.get("asn") if isinstance(item, dict) else None, + asn_name=item.get("asn_name") if isinstance(item, dict) else None, + network=item.get("network") if isinstance(item, dict) else None, + country=item.get("country") if isinstance(item, dict) else None, + provider=item.get("provider") if isinstance(item, dict) else None, sources=item.get("sources") if isinstance(item, dict) else None, ) add_edge(root_id, ip_id, "IP inventoriée", "inventory") - for host in (item.get("hostnames", []) if isinstance(item, dict) else [])[:12]: + if isinstance(item, dict) and item.get("asn"): + asn_id = add_node(f"asn:{item.get('asn')}", item.get("asn_name") or item.get("asn"), "asn", network=item.get("network"), country=item.get("country")) + add_edge(ip_id, asn_id, "hébergé par", "hosted_by") + for host in (item.get("hostnames", []) if isinstance(item, dict) else [])[:16]: host_type = "domain" if host == domain else "subdomain" host_id = add_node(f"domain:{host}", host, host_type) add_edge(host_id, ip_id, "résout vers", "dns_resolution") + for host in (audit.get("host_enrichment") or {}).get("hosts", [])[:MAX_HOSTS]: + if not isinstance(host, dict): + continue + hostname = host.get("hostname") + if not hostname: + continue + host_id = add_node( + f"domain:{hostname}", + hostname, + "domain" if hostname == domain else "subdomain", + title=host.get("title"), + best_url=host.get("best_url"), + status=host.get("status"), + ) + add_edge(root_id, host_id, "host enrichi", "host_enrichment") + for ip in host.get("public_ips", [])[:12]: + ip_id = add_node(f"ip:{ip}", ip, "ip") + add_edge(host_id, ip_id, "résout vers", "host_resolution") + for tech in host.get("technologies", [])[:12]: + tech_id = add_node(f"tech:{tech}", tech, "technology") + add_edge(host_id, tech_id, "technologie", "fingerprint") + for banner in host.get("banners", [])[:8]: + banner_id = add_node(f"banner:{banner}", banner[:80], "banner") + add_edge(host_id, banner_id, "banner", "fingerprint") + cert = host.get("tls_certificate") or {} + if cert.get("subject_cn"): + cert_id = add_node(f"cert:{hostname}", cert.get("subject_cn"), "certificate", issuer=cert.get("issuer_cn"), not_after=cert.get("not_after")) + add_edge(host_id, cert_id, "certificat TLS", "tls_certificate") + for target in (audit.get("web") or {}).get("targets", [])[:MAX_SUBDOMAINS]: host = target.get("hostname") if not host: @@ -99,10 +122,6 @@ def add_edge(source: str, target: str, label: str, edge_type: str = "related", * if target.get("reachable"): web_id = add_node(f"web:{host}", target.get("best_scheme") or host, "web", reachable=True) add_edge(host_id, web_id, "HTTP(S)", "web_service") - guard = target.get("guard") or {} - for ip in guard.get("public_ips", [])[:12]: - ip_id = add_node(f"ip:{ip}", ip, "ip", source="web_guard") - add_edge(host_id, ip_id, "résout vers", "web_resolution") scan = audit.get("service_scan") or {} for port in scan.get("open_ports", [])[:MAX_SERVICES]: @@ -119,40 +138,23 @@ def add_edge(source: str, target: str, label: str, edge_type: str = "related", * ) add_edge(host_id, service_id, "expose", "exposes_service", port=port.get("port")) for cve in port.get("cves", []) or []: - cve_id = add_node( - f"cve:{cve.get('cve')}", - cve.get("cve", "CVE"), - "cve", - severity=cve.get("severity"), - cvss=cve.get("cvss"), - ) + cve_id = add_node(f"cve:{cve.get('cve')}", cve.get("cve", "CVE"), "cve", severity=cve.get("severity"), cvss=cve.get("cvss")) add_edge(service_id, cve_id, "corrélation CVE", "cve_correlation", confidence=cve.get("confidence")) for idx, finding in enumerate(sorted(audit.get("findings", []), key=lambda f: _severity_rank(f.get("severity")))[:MAX_FINDINGS]): title = finding.get("title") or finding.get("category") or "Constat" sev = finding.get("severity") or "info" - finding_id = add_node( - f"finding:{idx}:{abs(hash(title))}", - title[:80], - "finding", - severity=sev, - category=finding.get("category"), - recommendation=finding.get("recommendation"), - ) + finding_id = add_node(f"finding:{idx}:{abs(hash(title))}", title[:80], "finding", severity=sev, category=finding.get("category"), recommendation=finding.get("recommendation")) loc = finding.get("location") or {} host = loc.get("hostname") or loc.get("host") or loc.get("domain") or domain host_id = add_node(f"domain:{host}", host, "domain" if host == domain else "subdomain") add_edge(host_id, finding_id, sev, "has_finding", severity=sev) type_counts = Counter(node.get("type") for node in nodes.values()) - severity_counts = Counter( - str((node.get("properties") or {}).get("severity", "info")) - for node in nodes.values() - if node.get("type") in {"finding", "cve"} - ) + severity_counts = Counter(str((node.get("properties") or {}).get("severity", "info")) for node in nodes.values() if node.get("type") in {"finding", "cve"}) return { - "version": "v7.5", + "version": "OpenEASM Beta 26.6", "domain": domain, "generated_from_audit_id": audit.get("id"), "nodes": list(nodes.values()), @@ -166,11 +168,16 @@ def add_edge(source: str, target: str, label: str, edge_type: str = "related", * "service_cves": scan.get("count_cves", 0), "public_ips": inventory.get("public_ip_count", 0), "subdomains": (audit.get("subdomains") or {}).get("count", 0), + "enriched_hosts": (audit.get("host_enrichment") or {}).get("summary", {}).get("active_host_count", 0), }, "legend": { "domain": "Domaine racine", "subdomain": "Sous-domaine public", "ip": "Adresse IP publique", + "asn": "ASN / hébergeur", + "technology": "Technologie détectée", + "banner": "Bannière HTTP/service", + "certificate": "Certificat TLS", "web": "Service web observé", "service": "Port/service détecté par Nmap", "cve": "CVE corrélée sans exploitation",