Created
May 1, 2025 19:25
-
-
Save jonaslejon/847e56676abd8bbfa883a767765a3674 to your computer and use it in GitHub Desktop.
Nmap SSH Banner EOL Checker using endoflife.date API (only Debian and Ubuntu fow now)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
Nmap SSH Banner EOL Checker using endoflife.date API (only Debian and Ubuntu fow now) | |
Description: | |
This script parses an Nmap XML output file (-oX) to identify hosts running | |
an SSH service, regardless of the port it runs on. For each detected SSH service | |
(identified by <service name="ssh"> on an open port), it attempts to: | |
1. Extract the SSH version banner provided by the service. | |
2. Guess the underlying Linux distribution (specifically Ubuntu or Debian) | |
and its release version based on common patterns found in the SSH package | |
details within the banner (e.g., '... Ubuntu 3ubuntu0.1 ...', | |
'... Debian 5+deb11u1 ...'). | |
3. Query the public endoflife.date API (https://endoflife.date/) to determine | |
if the guessed distribution version has reached its official End of Life (EOL). | |
4. Print a color-coded summary line for each host/port, showing the IP address:Port, | |
the full SSH banner, the guessed OS and version, and its EOL status | |
(Supported, EOL, or Unknown/Error). Results from the endoflife.date API | |
are cached in memory per script run to avoid redundant lookups. | |
Dependencies: | |
- Python 3 (Tested on 3.6+) | |
- The 'requests' library: Install using 'pip install requests' | |
- The 'colorama' library: Install using 'pip install colorama' | |
Usage: | |
python eol-check3.py <path_to_nmap_output.xml> | |
Example Nmap Scan Command: | |
# Scan common SSH ports or all ports, ensure -sV for version detection | |
nmap -sV -p 22,2222 --open -oX nmap_scan.xml <target_ips> | |
# Or scan all TCP ports (slower): | |
# nmap -sV -p- --open -oX nmap_scan.xml <target_ips> | |
""" | |
import argparse | |
import xml.etree.ElementTree as ET | |
import re | |
from datetime import datetime | |
import sys | |
import time # Kept for potential future use (e.g., API delay) | |
# Attempt to import required libraries and provide helpful errors | |
try: | |
import requests | |
except ImportError: | |
print("Error: The 'requests' library is required but not found.", file=sys.stderr) | |
print("Please install it using: pip install requests", file=sys.stderr) | |
sys.exit(1) | |
try: | |
from colorama import init, Fore, Style | |
# Initialize colorama | |
init(autoreset=True) | |
except ImportError: | |
print("Error: The 'colorama' library is required but not found.", file=sys.stderr) | |
print("Please install it using: pip install colorama", file=sys.stderr) | |
# Define dummy Fore/Style objects if colorama is missing to avoid NameErrors | |
class DummyColor: | |
def __getattr__(self, name): return "" | |
Fore = DummyColor() | |
Style = DummyColor() | |
print("Warning: colorama not found, output will not be colored.", file=sys.stderr) | |
# --- Caching Implementation --- | |
# Global dictionary to store API results within a single script run. | |
_eol_cache = {} | |
# --- End Caching Implementation --- | |
def check_eol_api(product, cycle): | |
""" | |
Checks EOL status using the endoflife.date API, with in-memory caching. | |
Returns: True (EOL), False (Supported), None (Unknown/Error). | |
""" | |
product_api_name = product.lower() | |
if product_api_name not in ['ubuntu', 'debian']: | |
return None # Only query for supported products | |
cache_key = (product_api_name, cycle) | |
if cache_key in _eol_cache: | |
# print(f"Cache hit for {product} {cycle}", file=sys.stderr) # Optional debug | |
return _eol_cache[cache_key] | |
api_url = f"https://endoflife.date/api/{product_api_name}/{cycle}.json" | |
headers = {'User-Agent': 'NmapEOLCheckScript/1.4'} # User agent version | |
today = datetime.today().date() | |
api_result = None # Default result if API call fails | |
try: | |
# Added timeout | |
response = requests.get(api_url, timeout=15, headers=headers) | |
if response.status_code == 404: | |
# Don't warn verbosely for 404, might just be a new/untracked version | |
api_result = None | |
elif response.status_code != 200: | |
print(f"{Fore.YELLOW}Warning: API request failed for {product} {cycle} (Status: {response.status_code}). URL: {api_url}{Style.RESET_ALL}", file=sys.stderr) | |
api_result = None # Treat as unknown | |
else: | |
# Process successful response | |
data = response.json() | |
eol_data = data.get('eol') | |
if eol_data is None: | |
# EOL data missing in response, treat as unknown | |
api_result = None | |
elif isinstance(eol_data, bool) and eol_data is False: | |
api_result = False # Explicitly marked as not EOL | |
elif isinstance(eol_data, str): | |
try: | |
eol_date = datetime.strptime(eol_data, "%Y-%m-%d").date() | |
api_result = today > eol_date # True if past EOL date | |
except ValueError: | |
print(f"{Fore.YELLOW}Warning: Could not parse EOL date '{eol_data}' for {product} {cycle}. URL: {api_url}{Style.RESET_ALL}", file=sys.stderr) | |
api_result = None # Invalid date format | |
else: | |
print(f"{Fore.YELLOW}Warning: Unexpected EOL data format '{eol_data}' for {product} {cycle}. URL: {api_url}{Style.RESET_ALL}", file=sys.stderr) | |
api_result = None # Unexpected format | |
except requests.exceptions.Timeout: | |
print(f"{Fore.RED}Error: Timeout contacting EOL API for {product} {cycle} ({api_url}){Style.RESET_ALL}", file=sys.stderr) | |
api_result = None | |
except requests.exceptions.RequestException as e: | |
print(f"{Fore.RED}Error: Network error contacting EOL API for {product} {cycle}: {e}{Style.RESET_ALL}", file=sys.stderr) | |
api_result = None | |
except Exception as e: # Catch potential JSON parsing errors or others | |
print(f"{Fore.RED}Error: An unexpected error occurred during EOL check for {product} {cycle}: {e}{Style.RESET_ALL}", file=sys.stderr) | |
api_result = None | |
# Store the result (True, False, or None) in the cache before returning | |
_eol_cache[cache_key] = api_result | |
return api_result | |
def guess_ubuntu_version(package): | |
"""Tries to guess the Ubuntu release version based on SSH package version.""" | |
# NOTE: Order matters! Check for newer versions first. | |
if package.startswith('3ubuntu13.1'): return '24.04' # Noble | |
if package.startswith('3ubuntu0') or package.startswith('3ubuntu13'): return '22.04' # Jammy | |
# Combined check for Focal - covers patterns like 4ubuntu0.X and 1ubuntuX | |
if package.startswith('4ubuntu0.') or ('-1ubuntu' in package and not package.startswith('3ubuntu')): return '20.04' # Focal | |
# Check for Bionic (might overlap with Focal's 4ubuntu0.*, hence order) - less likely now | |
# if package.startswith('4ubuntu0.'): return '18.04' # Bionic - Revisit if needed | |
if package.startswith('4ubuntu2') or '+esm' in package: return '16.04' # Xenial | |
return None | |
def guess_debian_version(package): | |
"""Tries to guess the Debian release number based on SSH package version.""" | |
if 'deb12' in package: return '12' # Bookworm | |
if 'deb11' in package: return '11' # Bullseye | |
if 'deb10' in package: return '10' # Buster | |
if 'deb9' in package: return '9' # Stretch | |
return None | |
def parse_nmap_ssh_banner(xml_file): | |
"""Parses Nmap XML, finds open ports with service name 'ssh', guesses OS, checks EOL.""" | |
try: | |
tree = ET.parse(xml_file) | |
root = tree.getroot() | |
except ET.ParseError as e: | |
print(f"{Fore.RED}Error parsing XML file '{xml_file}': {e}{Style.RESET_ALL}", file=sys.stderr) | |
return | |
except FileNotFoundError: | |
print(f"{Fore.RED}Error: XML file not found at '{xml_file}'{Style.RESET_ALL}", file=sys.stderr) | |
return | |
found_ssh_service = False # Track if we found any ssh service | |
# Note: datetime.now().strftime('%Z') might return empty string if TZ not available | |
current_time_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z').strip() | |
print(f"{Style.BRIGHT}--- SSH EOL Check Report ({current_time_str}) ---{Style.RESET_ALL}") | |
print(f"{Style.BRIGHT}Source File: {xml_file}{Style.RESET_ALL}\n") | |
# Iterate through all hosts found anywhere in the XML | |
for host in root.findall('.//host'): | |
# Find IP address for the host | |
ip_elem = host.find('./address[@addrtype="ipv4"]') # Prefer IPv4 | |
if ip_elem is None: ip_elem = host.find('./address[@addrtype="ipv6"]') # Fallback to IPv6 | |
ip = ip_elem.get('addr') if ip_elem is not None else "Unknown IP" | |
# Iterate through all ports found anywhere under the current host | |
for port in host.findall('.//port'): | |
port_id = port.get('portid', 'N/A') # Get port number string | |
# --- CORRECTED STATE CHECK (for Python 3.8 ElementTree compatibility) --- | |
# 1. Find the state element by tag name | |
state_elem = port.find('state') # Assumes <state> is direct child of <port> | |
# 2. Check if the element exists AND THEN check its 'state' attribute using Python | |
if state_elem is None or state_elem.get('state') != 'open': | |
continue # Skip this port if state element missing or not 'open' | |
# --- End CORRECTED STATE CHECK --- | |
# If we reach here, the state is confirmed 'open'. Now check the service. | |
service_elem = port.find('service') # Assumes <service> is direct child of <port> | |
if service_elem is None: | |
continue # Skip ports with no service info | |
# Check the service name attribute (case-insensitive) | |
service_name = service_elem.get('name', '') | |
if service_name.lower() == 'ssh': | |
found_ssh_service = True # Mark that we found and will process an SSH service | |
# --- Found an open SSH service, now process it --- | |
product = service_elem.get('product', '') | |
version_str = service_elem.get('version', '') | |
ostype = service_elem.get('ostype', '') | |
extrainfo = service_elem.get('extrainfo') | |
# Construct banner string | |
banner_parts = [product, version_str] | |
if extrainfo and extrainfo not in version_str: banner_parts.append(f"({extrainfo})") | |
banner = " ".join(filter(None, banner_parts)).strip() | |
# Attempt to improve banner if it's weak/missing using script output | |
if not banner or len(banner) < 10: | |
# Find script element potentially containing banner info | |
script_elem = port.find("script[@id='ssh-hostkey']") # Assumes direct child | |
if script_elem is not None and script_elem.get('output'): | |
potential_banner = script_elem.get('output', '').split('\n')[0].strip() | |
# Check if it looks like a valid SSH protocol string | |
if potential_banner.startswith("SSH-"): | |
# Use this only if the service banner was truly missing | |
if not banner: banner = potential_banner | |
# Fallback banner text | |
if not banner: banner = "SSH service detected (banner unavailable)" | |
# Initialize OS detection variables | |
distro = None | |
release_version = None | |
esm = False | |
package_info = "" | |
# Attempt OS detection using Regex on combined banner info | |
search_string = version_str + " " + banner | |
os_match = re.search(r'(Ubuntu|Debian)\s+([0-9a-zA-Z\.\-\+~:]+)', search_string) | |
if os_match: | |
distro_name = os_match.group(1) | |
package_info = os_match.group(2) | |
if distro_name == 'Ubuntu': | |
distro = 'Ubuntu' | |
release_version = guess_ubuntu_version(package_info) | |
# Check for ESM specifically in Ubuntu package strings | |
if release_version and '+esm' in package_info: esm = True | |
elif distro_name == 'Debian': | |
distro = 'Debian' | |
release_version = guess_debian_version(package_info) | |
# Fallback guess if Regex didn't match but Nmap guessed Linux OS type | |
if not distro and ostype == 'Linux': | |
if 'Ubuntu' in banner: distro = 'Ubuntu' # Can't guess version here | |
elif 'Debian' in banner: distro = 'Debian' # Can't guess version here | |
# --- EOL Check and Output --- | |
ip_port_str = f"{ip}:{port_id}" # Combine IP and Port for output line | |
if distro and release_version: | |
# Call API check (uses cache) | |
is_eol = check_eol_api(distro, release_version) | |
# Determine status string and color | |
if is_eol is True: status = f"{Fore.RED}EOL" | |
elif is_eol is False: status = f"{Fore.GREEN}Supported" | |
else: status = f"{Fore.YELLOW}EOL Unknown" # API check failed or inconclusive | |
# Format ESM note only if ESM was detected | |
esm_note = f" {Fore.MAGENTA}(ESM detected){Style.RESET_ALL}" if esm else "" | |
# Print formatted output line | |
print(f"{Fore.CYAN}{ip_port_str:<21}{Style.RESET_ALL} - {Style.BRIGHT}{distro} {release_version}{Style.RESET_ALL}{esm_note:<16} - {status:<18} - {Fore.WHITE}{banner}{Style.RESET_ALL}") | |
else: | |
# Handle cases where OS/version couldn't be reliably determined | |
distro_guess_note = f" ({distro} guessed)" if distro else "" | |
# Ensure consistent alignment using padding | |
print(f"{Fore.CYAN}{ip_port_str:<21}{Style.RESET_ALL} - {Fore.YELLOW}OS/Version Unknown{distro_guess_note}{Style.RESET_ALL:<16} - {Fore.YELLOW}EOL Unknown{Style.RESET_ALL:<18} - {Fore.WHITE}{banner}{Style.RESET_ALL}") | |
# End of processing for 'ssh' service | |
# End of port loop for a host | |
# End of host loop | |
# Report if no SSH services were found and processed | |
if not found_ssh_service: | |
print(f"{Fore.YELLOW}No open ports with service name 'ssh' found and processed in '{xml_file}'.{Style.RESET_ALL}") | |
print(f"{Fore.YELLOW}Check Nmap scan command (-sV) and XML file contents.{Style.RESET_ALL}") | |
print(f"\n{Style.BRIGHT}--- End of Report ---{Style.RESET_ALL}") | |
def main(): | |
# Setup command-line argument parser | |
parser = argparse.ArgumentParser( | |
description='Parse Nmap XML, find SSH services by name, check EOL via endoflife.date API.', | |
formatter_class=argparse.RawDescriptionHelpFormatter # Keep description formatting | |
) | |
parser.add_argument('xml_file', help='Path to the Nmap XML output file (generated with -oX)') | |
args = parser.parse_args() | |
# Run the main parsing function | |
parse_nmap_ssh_banner(args.xml_file) | |
# Script execution entry point | |
if __name__ == "__main__": | |
# Check for minimum Python version compatibility (optional but recommended) | |
if sys.version_info < (3, 6): | |
print("Error: This script requires Python 3.6 or higher.", file=sys.stderr) | |
sys.exit(1) | |
# Call the main function | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment