Software reaches end-of-life (EOL) when vendors end standard support for a product or version, including regular security updates. Organizations that continue using EOL software face increased security risks because newly discovered vulnerabilities may no longer receive security patches from the vendor, leaving systems exposed to known and potentially exploitable vulnerabilities.
Despite the security risks, EOL software remains common in production environments. Support lifecycles vary by vendor and product version, making it difficult for security teams to identify installed software that has reached its EOL date. Threat actors actively target systems running EOL software. In July 2021, CISA warned that threat actors were targeting SonicWall SRA and SMA appliances running unpatched, end-of-life 8.x firmware as part of a ransomware campaign. In July 2025, Google Threat Intelligence Group reported an ongoing campaign that deployed a backdoor on fully patched but end-of-life SonicWall SMA 100 series appliances.
Addressing this challenge requires visibility into the software installed across the monitored endpoints and their support lifecycles. Wazuh is a free and open source security platform that offers unified XDR and SIEM capabilities for endpoints and cloud workloads. The Wazuh Syscollector module collects software inventory from monitored endpoints, while the endoflife.date API provides lifecycle information for supported products.
In this blog post, we demonstrate how to combine the Wazuh system inventory with the endoflife.date API to detect end-of-life software across monitored endpoints.
Infrastructure
We use the following infrastructure to demonstrate the detection of EOL software:
- A pre-built, ready-to-use Wazuh OVA 4.14.6, which includes the Wazuh central components (Wazuh server, Wazuh indexer, and Wazuh dashboard). Follow this guide to download and set up the Wazuh virtual machine.
- An Ubuntu 24.04 endpoint with the Wazuh agent 4.14.6 installed and enrolled in the Wazuh server.
How it works
The detection pipeline runs on the Wazuh server and performs the following actions:
- The Wazuh Syscollector module collects the package and operating system information from every monitored endpoint and builds a system inventory.
- A scheduled Python script
eol_detector.pyauthenticates to the Wazuh server API and retrieves the inventory of every Wazuh agent. - The script cross-references each mapped package version and operating system release against the endoflife.date API, which returns the vendor-published support timeline for each product.
- The script writes each EOL finding as a JSON event to a log file on the Wazuh server.
- The Wazuh log collector reads the log file and forwards events to the Wazuh analysis engine, which evaluates them against the custom rules and generates severity-based alerts.
The endoflife.date project is a community-maintained database that tracks software support lifecycles based on vendor-published release and maintenance policies. For each product, its API provides release cycles, end-of-life dates, latest versions, and release dates. The script performs the EOL evaluation by matching the installed version to its release cycle and checking whether the associated EOL date has passed. When a product is past its EOL date, the script generates a finding, and the number of days since EOL determines the alert severity.
Configuration
Wazuh server
Perform the following steps on the Wazuh server:
- Create the findings log directory. Set the ownership to the
wazuhuser and group so the Wazuh log collector can read the directory. Assign permissions to allow the owner full access, the group read and execute access, and deny access to other users:
# mkdir -p /var/ossec/logs/eol-findings # chown wazuh:wazuh /var/ossec/logs/eol-findings # chmod 750 /var/ossec/logs/eol-findings
- Create the EOL detector script
/var/ossec/integrations/eol_detector.pywith the following content:
Note
This script is a proof of concept. Review and validate it to confirm it meets the operational and security requirements of your environment.
#!/usr/bin/env python3
import requests, json, datetime, os, re, sys
ENV_FILE = "/etc/eol_detector/eol_detector.env"
MAPPINGS_FILE = "/etc/eol_detector/mappings.json"
EOL_API_BASE = "https://endoflife.date/api"
ENV_KEYS = ("WAZUH_API", "WAZUH_USER", "WAZUH_PASS",
"VERIFY_SSL", "WAZUH_CA_BUNDLE", "EOL_LOG")
def load_env(path):
"""Read KEY=VALUE pairs from the env file. Environment variables win."""
values = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
except FileNotFoundError:
pass
values.update({k: os.environ[k] for k in ENV_KEYS if k in os.environ})
return values
def load_mappings(path):
"""Read the product and OS maps from the mappings file."""
try:
with open(path) as f:
data = json.load(f)
return data["products"], data["os"]
except (OSError, ValueError, KeyError) as err:
print(f"cannot load mappings from {path}: {err}", file=sys.stderr)
sys.exit(1)
_env = load_env(ENV_FILE)
WAZUH_API = _env.get("WAZUH_API", "")
WAZUH_USER = _env.get("WAZUH_USER", "")
WAZUH_PASS = _env.get("WAZUH_PASS", "")
EOL_LOG = _env.get("EOL_LOG", "/var/ossec/logs/eol-findings/eol.json")
if not all((WAZUH_API, WAZUH_USER, WAZUH_PASS)):
print(f"missing WAZUH_API, WAZUH_USER, or WAZUH_PASS in {ENV_FILE}",
file=sys.stderr)
sys.exit(1)
VERIFY = _env.get("WAZUH_CA_BUNDLE") or \
_env.get("VERIFY_SSL", "false").lower() == "true"
if VERIFY is False:
import urllib3
urllib3.disable_warnings()
EOL_PRODUCTS, EOL_OS = load_mappings(MAPPINGS_FILE)
_eol_cache = {}
def get_token():
r = requests.post(
f"{WAZUH_API}/security/user/authenticate?raw=true",
auth=(WAZUH_USER, WAZUH_PASS), verify=VERIFY, timeout=10,
)
r.raise_for_status()
return r.text.strip()
def get_eol_data(slug):
if slug in _eol_cache:
return _eol_cache[slug]
try:
r = requests.get(f"{EOL_API_BASE}/{slug}.json", timeout=10)
if r.ok:
_eol_cache[slug] = r.json()
return _eol_cache[slug]
print(f"warning: endoflife.date returned HTTP {r.status_code} "
f"for {slug}", file=sys.stderr)
except Exception as err:
print(f"warning: endoflife.date query failed for {slug}: {err}",
file=sys.stderr)
_eol_cache[slug] = None # cache the failure to avoid re-querying this run
return None
def cycle_candidates(version_str):
"""Return the possible release cycles for a version, most specific first."""
if ":" in version_str:
version_str = version_str.split(":", 1)[1]
version_str = re.split(r"[-~+]", version_str)[0]
parts = version_str.split(".")
candidates = []
if len(parts) >= 2 and parts[1]:
candidates.append(f"{parts[0]}.{parts[1]}")
if parts[0]:
candidates.append(parts[0])
return candidates
def check_eol(candidates, slug):
"""Match the cycle candidates against the endoflife.date product data."""
eol_data = get_eol_data(slug)
if not eol_data:
return None
today = datetime.date.today()
for candidate in candidates:
for rel in eol_data:
if str(rel.get("cycle", "")).strip() != candidate:
continue
eol_date = rel.get("eol")
if not eol_date or eol_date is True or eol_date is False:
break # cycle matched but has no EOL date; try the next candidate
try:
d = datetime.date.fromisoformat(str(eol_date))
except ValueError:
break
if d < today:
return {
"cycle": candidate,
"eol_date": eol_date,
"days_past_eol": (today - d).days,
"latest_available": rel.get("latest", "unknown"),
}
break # cycle matched and is still supported; stop this candidate
return None
def check_packages(token, agent_id, agent_name):
headers = {"Authorization": f"Bearer {token}"}
findings, offset = [], 0
while True:
r = requests.get(
f"{WAZUH_API}/syscollector/{agent_id}/packages",
headers=headers,
params={"limit": 500, "offset": offset},
verify=VERIFY, timeout=15,
)
if not r.ok:
print(f"warning: package query failed for agent {agent_id}: "
f"HTTP {r.status_code}", file=sys.stderr)
break
batch = r.json().get("data", {}).get("affected_items", [])
if not batch:
break
for pkg in batch:
name = pkg.get("name", "").lower().split(":")[0]
version = pkg.get("version", "")
slug = EOL_PRODUCTS.get(name)
if not slug or not version:
continue
result = check_eol(cycle_candidates(version), slug)
if result:
findings.append({
"integration": "eol_detector",
"eol": {
"type": "package",
"agent_id": agent_id,
"agent_name": agent_name,
"product": pkg.get("name"),
"version": version,
**result,
},
})
offset += 500
if len(batch) < 500:
break
return findings
def check_os(token, agent_id, agent_name):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{WAZUH_API}/syscollector/{agent_id}/os",
headers=headers, verify=VERIFY, timeout=10,
)
if not r.ok:
print(f"warning: OS query failed for agent {agent_id}: "
f"HTTP {r.status_code}", file=sys.stderr)
return []
items = r.json().get("data", {}).get("affected_items", [])
if not items:
return []
os_info = items[0].get("os", {})
platform = os_info.get("platform", "").lower()
slug = EOL_OS.get(platform)
if not slug:
return []
major, minor = os_info.get("major", ""), os_info.get("minor", "")
candidates = [f"{major}.{minor}", major] if minor else [major]
result = check_eol(candidates, slug)
if result:
return [{
"integration": "eol_detector",
"eol": {
"type": "os",
"agent_id": agent_id,
"agent_name": agent_name,
"product": os_info.get("name", platform),
"version": os_info.get("version", result["cycle"]),
**result,
},
}]
return []
def get_agents(token):
"""Return every active and disconnected agent, paginated."""
headers = {"Authorization": f"Bearer {token}"}
agents, offset = [], 0
while True:
r = requests.get(
f"{WAZUH_API}/agents",
headers=headers,
params={"limit": 500, "offset": offset,
"status": "active,disconnected"},
verify=VERIFY, timeout=10,
)
r.raise_for_status()
batch = r.json().get("data", {}).get("affected_items", [])
agents += batch
offset += 500
if len(batch) < 500:
break
return agents
def main():
token = get_token()
agents = get_agents(token)
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
total = 0
with open(EOL_LOG, "a") as log:
for agent in agents:
aid, aname = agent.get("id"), agent.get("name")
astatus = agent.get("status", "unknown")
if not aid:
continue
findings = (check_packages(token, aid, aname)
+ check_os(token, aid, aname))
total += len(findings)
for f in findings:
f["eol"]["agent_status"] = astatus
f["timestamp"] = timestamp
log.write(json.dumps(f) + "\n")
e = f["eol"]
print(f"[EOL] Agent {aname} ({aid}, {astatus}): {e['product']} "
f"{e['version']} EOL {e['eol_date']} "
f"({e['days_past_eol']}d ago)")
print(f"scan complete: {len(agents)} agents scanned, {total} findings",
file=sys.stderr)
if __name__ == "__main__":
try:
main()
except Exception as err:
print(f"eol_detector failed: {err}", file=sys.stderr)
sys.exit(1)
- Set the ownership to the
wazuhuser and group, and permissions to the detector script:
# chown wazuh:wazuh /var/ossec/integrations/eol_detector.py # chmod 750 /var/ossec/integrations/eol_detector.py
- Create the EOL script configuration directory and set the ownership and permissions to the
wazuhuser and group:
# mkdir /etc/eol_detector # chown wazuh:wazuh /etc/eol_detector # chmod 750 /etc/eol_detector
- Create the mappings file
/etc/eol_detector/mappings.jsonwith the following content. Theproductsmap covers packages, and theosmap covers operating systems. Theproductsandosdictionaries define which software and operating systems the detector monitors. Different Linux distributions often use different package names for the same software. The mapping allows the EOL detector to translate those names into the identifiers expected by the endoflife.date API. They map the software names collected by the Wazuh Syscollector module to the product names used by the endoflife.date API. Packages or operating systems without a mapping are skipped.
{
"products": {
"python": "python",
"python3": "python",
"python3-libs": "python",
"nodejs": "nodejs",
"ruby": "ruby",
"ruby3.1": "ruby",
"ruby3.2": "ruby",
"ruby3.3": "ruby",
"php": "php",
"php-cli": "php",
"php-fpm": "php",
"mysql-server": "mysql",
"mysql-community-server": "mysql",
"mariadb-server": "mariadb",
"postgresql": "postgresql",
"postgresql-server": "postgresql",
"mongodb-org": "mongodb",
"redis": "redis",
"redis-server": "redis",
"nginx": "nginx",
"apache2": "apache",
"httpd": "apache",
"openssl": "openssl",
"openssl-libs": "openssl",
"perl": "perl",
"varnish": "varnish",
"ansible-core": "ansible-core",
"golang": "go",
"golang-go": "go"
},
"os": {
"ubuntu": "ubuntu",
"debian": "debian",
"centos": "centos",
"rhel": "rhel",
"amzn": "amazon-linux",
"fedora": "fedora",
"sles": "sles"
}
}
To monitor additional software:
- Identify the package name reported by the Wazuh Syscollector module or the system package manager.
- Find the corresponding product slug:
# curl -s https://endoflife.date/api/all.json
- Add the mapping to
products. For example:
"grafana": "grafana",
The new product is automatically included in the next scheduled scan
- Retrieve the Wazuh server API certificate to enable secure communication between the detector script and the Wazuh server API:
# openssl s_client -connect localhost:55000 </dev/null 2>/dev/null | openssl x509 -outform PEM | sudo tee /etc/eol_detector/wazuh-api.pem
- Create the environment file
/etc/eol_detector/eol_detector.envwith the following values:
WAZUH_API=https://<WAZUH_SERVER_ADDRESS>:55000 WAZUH_USER=<WAZUH_SERVER_API_USERNAME> WAZUH_PASS=<WAZUH_SERVER_API_PASSWORD> WAZUH_CA_BUNDLE=/etc/eol_detector/wazuh-api.pem
Replace:
<WAZUH_SERVER_ADDRESS>: the value of thesubjectAltNamefrom the/etc/eol_detector/wazuh-api.pemcertificate. This can be obtained by running the command:
# openssl x509 -in /etc/eol_detector/wazuh-api.pem -noout -subject -ext subjectAltName
<WAZUH_SERVER_API_USERNAME>: the username of your Wazuh server API user.<WAZUH_SERVER_API_PASSWORD>: the password of your Wazuh server API user.
Use the following command to retrieve your Wazuh server API username and password:
# grep -E "username:|password:" /usr/share/wazuh-dashboard/data/wazuh/config/wazuh.yml | head -2
- Add the following block inside the
<ossec_config>section of the server configuration file/var/ossec/etc/ossec.conf:
<localfile>
<log_format>json</log_format>
<location>/var/ossec/logs/eol-findings/eol.json</location>
</localfile>
- Create a file called
eol_rules.xmlin the/var/ossec/etc/rules/directory and insert the following custom rules:
<group name="eol,">
<!-- Base rule: any EOL finding -->
<rule id="100400" level="7">
<decoded_as>json</decoded_as>
<field name="integration">^eol_detector$</field>
<description>EOL software detected: $(eol.product) $(eol.version) on agent $(eol.agent_name) - EOL since $(eol.eol_date)</description>
<options>no_full_log</options>
<group>eol_software,pci_dss_6.3.3,nist_800_53_CM.8,hipaa_164.312.a.1,</group>
</rule>
<!-- 100 to 999 days past EOL -->
<rule id="100401" level="10">
<if_sid>100400</if_sid>
<field name="eol.type">^package$</field>
<field name="eol.days_past_eol" type="pcre2">^\d{3}$</field>
<description>HIGH RISK EOL: $(eol.product) $(eol.version) on agent $(eol.agent_name) is $(eol.days_past_eol) days past end-of-life</description>
<options>no_full_log</options>
<group>eol_software,pci_dss_6.3.3,nist_800_53_CM.8,hipaa_164.312.a.1,</group>
</rule>
<!-- 1000 or more days past EOL -->
<rule id="100402" level="13">
<if_sid>100400</if_sid>
<field name="eol.type">^package$</field>
<field name="eol.days_past_eol" type="pcre2">^\d{4,}$</field>
<description>CRITICAL RISK EOL: $(eol.product) $(eol.version) on agent $(eol.agent_name) is $(eol.days_past_eol) days past end-of-life</description>
<options>no_full_log</options>
<group>eol_software,pci_dss_6.3.3,nist_800_53_CM.8,hipaa_164.312.a.1,</group>
</rule>
<!-- EOL operating system -->
<rule id="100403" level="12">
<if_sid>100400</if_sid>
<field name="eol.type">^os$</field>
<description>EOL operating system: $(eol.product) $(eol.version) on agent $(eol.agent_name) - EOL since $(eol.eol_date)</description>
<options>no_full_log</options>
<group>eol_os,pci_dss_6.3.3,nist_800_53_CM.8,hipaa_164.312.a.1,</group>
</rule>
</group>
Where:
- Rule ID
100400is the base rule. It matches every event from the detector and generates a level 7 alert. - Rule ID
100401matches packages that are 100 to 999 days past EOL and raises the alert to level 10. - Rule ID
100402matches packages that are 1,000 or more days past EOL and raises the alert to level 13. - Rule ID
100403matches EOL operating systems and raises the alert to level 12.
- Restart the Wazuh server to apply the changes:
# systemctl restart wazuh-manager
- Add the following entry to
/etc/crontabon the Wazuh server to run the detector daily at 01:00. Adjust the frequency to match your patching cycle:
0 1 * * * root /usr/bin/python3 /var/ossec/integrations/eol_detector.py
Results
The script scans every enrolled Wazuh agent through the Wazuh server API. In our test environment, the scanner detects nine EOL packages across two endpoints.
Perform the following steps on the Wazuh dashboard to visualize the EOL events:
- Navigate to Threat intelligence > Threat Hunting > Events on the Wazuh dashboard.
- In the search bar, type
rule.groups:eol, and click Update.

Conclusion
In this blog post, we demonstrated how to detect end-of-life software with Wazuh by combining the Wazuh system inventory with the endoflife.date API. This approach provides centralized visibility into unsupported software across managed endpoints. It also generates alerts that help security teams prioritize upgrades based on how long a product has been out of support. By identifying software that no longer receives standard security updates, organizations can prioritize upgrades and reduce the security risks associated with running unsupported products.
Wazuh is a free and open source security platform with an active community. If you have any questions about this blog post or Wazuh, we invite you to join our community.