Skip to content

Instantly share code, notes, and snippets.

@jonaslejon
Created May 1, 2025 19:25
Show Gist options
  • Save jonaslejon/847e56676abd8bbfa883a767765a3674 to your computer and use it in GitHub Desktop.
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)
#!/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