Security · python · 35 copies
Host Vulnerability Scanner
Agentless vulnerability scanner that connects via SSH (Linux) to enumerate installed packages, OS/kernel version, and running services. Matches against a CVE database and checks CIS Benchmark controls: SSH hardening (PermitRootLogin, PasswordAuthentication, X11Forwarding), world-writable files, unexpected SUID binaries, and EOL OS detection. Produces risk-scored findings (CRITICAL/HIGH/MEDIUM/LOW) with patch remediation steps. Optionally queries the NVD API for live CVSS scores.
- Linux
- macOS
- Windows
pip install "paramiko>=3.0" "requests>=2.28"#!/usr/bin/env python3
"""
Host Vulnerability Scanner
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/host-vulnerability-scanner
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "paramiko>=3.0" "requests>=2.28"
"""
import argparse, json, os, re, sys, socket
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
# Windows consoles default to a legacy code page (cp1252), which cannot encode
# the arrows, box-drawing and check-mark characters used in the output below.
# Printing one raises UnicodeEncodeError and kills the script mid-scan, so make
# stdout UTF-8 and degrade unencodable characters instead of crashing.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass # already wrapped, or not a real stream (piped/redirected)
# Optional imports
try:
import paramiko
HAS_PARAMIKO = True
except ImportError:
HAS_PARAMIKO = False
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
TIMEOUT = 20
# ─── Known vulnerable package versions (subset for demo) ───
KNOWN_VULNS = [
{"package": "glibc", "max_version": "2.35-0ubuntu3.3", "cve": "CVE-2023-4911",
"cvss": 7.8, "severity": "CRITICAL",
"description": "Looney Tunables — local privilege escalation via GLIBC_TUNABLES",
"fix": "apt upgrade libc6 → 2.35-0ubuntu3.4+"},
{"package": "openssh-server", "max_version": "9.2p1", "cve": "CVE-2023-38408",
"cvss": 9.8, "severity": "CRITICAL",
"description": "Remote code execution via ssh-agent forwarding (PKCS#11)",
"fix": "apt/yum upgrade openssh-server → 9.3p1+"},
{"package": "openssl", "max_version": "3.0.8", "cve": "CVE-2023-0464",
"cvss": 5.9, "severity": "MEDIUM",
"description": "DoS via crafted ASN.1 object with excessive memory consumption",
"fix": "apt/yum upgrade openssl → 3.0.9+"},
{"package": "curl", "max_version": "7.88.0", "cve": "CVE-2023-38545",
"cvss": 9.8, "severity": "CRITICAL",
"description": "SOCKS5 heap overflow — worst curl vulnerability in years",
"fix": "apt/yum upgrade curl → 8.4.0+"},
{"package": "sudo", "max_version": "1.9.12", "cve": "CVE-2023-42465",
"cvss": 7.0, "severity": "HIGH",
"description": "Rowhammer attack via sudo exploitation",
"fix": "apt/yum upgrade sudo → 1.9.13+"},
{"package": "linux-kernel", "max_version": "5.15.99", "cve": "CVE-2023-32233",
"cvss": 7.8, "severity": "HIGH",
"description": "Use-after-free in Netfilter nf_tables — local privilege escalation",
"fix": "Update kernel to 5.15.100+ or 6.2+"},
{"package": "bash", "max_version": "5.1", "cve": "CVE-2022-3715",
"cvss": 6.6, "severity": "MEDIUM",
"description": "Heap buffer write overflow in bash read_mbstring_char",
"fix": "Update bash to 5.2+"},
{"package": "python3", "max_version": "3.10.8", "cve": "CVE-2022-45061",
"cvss": 7.5, "severity": "HIGH",
"description": "DoS via slow IDNA decoding attack",
"fix": "Update Python to 3.10.9+"},
]
EOL_OS = {
"CentOS 8": "2021-12-31",
"CentOS 6": "2020-11-30",
"CentOS 7": "2024-06-30",
"Ubuntu 18.04": "2023-04-30",
"Ubuntu 20.04": "2025-04-30",
"Debian 10": "2024-06-30",
"Windows Server 2008": "2020-01-14",
"Windows Server 2012": "2023-10-10",
"Red Hat Enterprise Linux 7": "2024-06-30",
"RHEL 7": "2024-06-30",
}
def run_ssh(conn, cmd: str) -> str:
try:
stdin, stdout, stderr = conn.exec_command(cmd, timeout=TIMEOUT)
return stdout.read().decode("utf-8", errors="replace").strip()
except Exception:
return ""
def connect_ssh(host: str, username: str = "root", key_file: str = None,
password: str = None, port: int = 22):
if not HAS_PARAMIKO:
return None
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
if key_file:
client.connect(host, port=port, username=username,
key_filename=key_file, timeout=TIMEOUT)
else:
client.connect(host, port=port, username=username,
password=password, timeout=TIMEOUT)
return client
except Exception as e:
print(f" [!] SSH connection failed: {e}")
return None
def get_os_info(conn) -> dict:
os_info = {}
os_info["os"] = run_ssh(conn, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'") or \
run_ssh(conn, "cat /etc/redhat-release 2>/dev/null") or "Unknown"
os_info["kernel"] = run_ssh(conn, "uname -r")
os_info["hostname"] = run_ssh(conn, "hostname")
os_info["arch"] = run_ssh(conn, "uname -m")
os_info["uptime"] = run_ssh(conn, "uptime -p 2>/dev/null || uptime")
return os_info
def list_packages_linux(conn) -> dict:
packages = {}
# Try dpkg (Debian/Ubuntu)
dpkg_out = run_ssh(conn, "dpkg-query -W -f='${Package}\\t${Version}\\n' 2>/dev/null | head -500")
if dpkg_out:
for line in dpkg_out.splitlines():
parts = line.split("\t")
if len(parts) == 2:
packages[parts[0]] = parts[1]
return packages
# Try rpm (RHEL/CentOS)
rpm_out = run_ssh(conn, "rpm -qa --queryformat='%{NAME}\\t%{VERSION}-%{RELEASE}\\n' 2>/dev/null | head -500")
if rpm_out:
for line in rpm_out.splitlines():
parts = line.split("\t")
if len(parts) == 2:
packages[parts[0]] = parts[1]
return packages
def get_listening_services(conn) -> list:
out = run_ssh(conn, "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null")
services = []
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 4:
addr = parts[3] if len(parts) > 3 else ""
process = parts[-1] if "pid" in parts[-1].lower() or "/" in parts[-1] else ""
services.append({"address": addr, "process": process})
return services
def check_cis_ssh(conn) -> list:
"""CIS Benchmark SSH hardening checks."""
issues = []
checks = {
"PermitRootLogin": ("no", "Root login should be disabled"),
"PasswordAuthentication": ("no", "Password auth should be disabled — use keys only"),
"Protocol": ("2", "SSH Protocol 1 is insecure"),
"PermitEmptyPasswords": ("no", "Empty passwords must be forbidden"),
"MaxAuthTries": ("4", "Limit auth attempts to reduce brute-force"),
"X11Forwarding": ("no", "X11 forwarding exposes display — disable unless needed"),
"AllowAgentForwarding": ("no", "Agent forwarding enables pivoting"),
}
ssh_conf = run_ssh(conn, "cat /etc/ssh/sshd_config 2>/dev/null")
for param, (safe_val, desc) in checks.items():
m = re.search(rf"^\s*{param}\s+(\S+)", ssh_conf, re.MULTILINE | re.IGNORECASE)
current = m.group(1).lower() if m else "default"
if current != safe_val.lower() and current != "default":
issues.append({"check": f"SSH {param}", "current": current,
"expected": safe_val, "description": desc})
elif not m:
issues.append({"check": f"SSH {param}", "current": "not set (using default)",
"expected": safe_val, "description": desc})
return issues
def check_cis_linux(conn) -> list:
"""Basic CIS Linux checks: world-writable files, SUID binaries, cron."""
issues = []
ww = run_ssh(conn, "find /tmp /var /home -maxdepth 3 -perm -002 -not -type d 2>/dev/null | head -10")
if ww:
for f in ww.splitlines():
issues.append({"check": "World-writable file", "path": f,
"description": "World-writable file can be modified by any user — tampering risk"})
suid = run_ssh(conn, "find / -perm -4000 -type f 2>/dev/null | grep -v snap | head -10")
known_suid = {"/usr/bin/sudo", "/usr/bin/passwd", "/bin/su", "/usr/bin/pkexec"}
if suid:
for f in suid.splitlines():
if f not in known_suid:
issues.append({"check": "Unexpected SUID binary", "path": f,
"description": "Unexpected SUID binary — could be used for privilege escalation"})
return issues
def check_cve_match(packages: dict) -> list:
matches = []
for vuln in KNOWN_VULNS:
pkg = vuln["package"]
if pkg in packages:
matches.append({
"cve": vuln["cve"], "package": pkg, "installed_version": packages[pkg],
"cvss": vuln["cvss"], "severity": vuln["severity"],
"description": vuln["description"], "fix": vuln["fix"]
})
return matches
def check_eol(os_str: str) -> str:
for name, eol_date in EOL_OS.items():
if name.lower() in os_str.lower():
return eol_date
return None
def query_nvd_cve(cve_id: str) -> dict:
"""Query NVD API v2 for CVE details — free, no key required."""
if not HAS_REQUESTS:
return {}
try:
r = requests.get(f"https://services.nvd.nist.gov/rest/json/cves/2.0?cveId={cve_id}",
timeout=15, headers={"User-Agent": "VulnScanner/1.0"})
if r.status_code == 200:
items = r.json().get("vulnerabilities", [])
if items:
cve_data = items[0].get("cve", {})
metrics = cve_data.get("metrics", {})
cvss_v3 = metrics.get("cvssMetricV31", []) or metrics.get("cvssMetricV30", [])
score = cvss_v3[0]["cvssData"]["baseScore"] if cvss_v3 else None
return {"id": cve_id, "score": score}
except Exception:
pass
return {}
def scan_host(host: str, username: str, key_file: str, password: str) -> dict:
"""
Scan one host and return its findings.
Output is COLLECTED rather than printed, because several hosts are
scanned concurrently and interleaved prints would be unreadable. The
caller prints result['_lines'] in input order.
"""
lines: list = []
emit = lines.append
emit(f"\n[+] {host}")
result = {"host": host, "reachable": False, "os_info": {}, "packages": {},
"cve_findings": [], "cis_ssh": [], "cis_linux": [], "risk_score": "UNKNOWN"}
conn = connect_ssh(host, username, key_file, password)
if not conn:
emit(f" [!] Cannot connect — check SSH credentials and access")
result['_lines'] = lines
return result
result["reachable"] = True
os_info = get_os_info(conn)
result["os_info"] = os_info
emit(f" OS: {os_info.get('os','')} | Kernel: {os_info.get('kernel','')}")
eol = check_eol(os_info.get("os", ""))
if eol:
emit(f" ✗ CRITICAL: OS is End-of-Life since {eol} — no security patches available!")
result["eol"] = eol
packages = list_packages_linux(conn)
result["package_count"] = len(packages)
emit(f" Packages: {len(packages)} installed")
cve_matches = check_cve_match(packages)
result["cve_findings"] = cve_matches
if cve_matches:
emit(f" CVE Findings ({len(cve_matches)}):")
for m in cve_matches:
icon = {"CRITICAL": "✗ CRITICAL", "HIGH": "✗ HIGH",
"MEDIUM": "⚠ MEDIUM", "LOW": "ℹ LOW"}.get(m["severity"], m["severity"])
emit(f" {icon} {m['cve']} {m['package']} {m['installed_version']} CVSS {m['cvss']}")
emit(f" {m['description']}")
emit(f" Fix: {m['fix']}")
cis_ssh = check_cis_ssh(conn)
result["cis_ssh"] = cis_ssh
if cis_ssh:
emit(f" CIS SSH Benchmark ({len(cis_ssh)} issues):")
for issue in cis_ssh:
emit(f" ✗ {issue['check']}: {issue['current']} → should be '{issue['expected']}'")
cis_linux = check_cis_linux(conn)
result["cis_linux"] = cis_linux
if cis_linux:
for issue in cis_linux:
emit(f" ⚠ {issue['check']}: {issue.get('path','')}")
critical = sum(1 for c in cve_matches if c["severity"] == "CRITICAL")
high = sum(1 for c in cve_matches if c["severity"] == "HIGH")
eol_risk = 2 if eol else 0
if critical + eol_risk >= 1:
result["risk_score"] = "CRITICAL"
elif high >= 2:
result["risk_score"] = "HIGH"
elif high >= 1 or len(cis_ssh) >= 3:
result["risk_score"] = "MEDIUM"
else:
result["risk_score"] = "LOW"
emit(f" Risk Score: {result['risk_score']}")
conn.close()
result['_lines'] = lines
return result
def main():
parser = argparse.ArgumentParser(description="Host Vulnerability Scanner")
parser.add_argument("--hosts", required=True, help="Comma-separated host IPs or hostnames")
parser.add_argument("--ssh-key", default=None, help="SSH private key file (e.g. ~/.ssh/id_rsa)")
parser.add_argument("--username", default="root", help="SSH username (default: root)")
parser.add_argument("--output", default=None, help="JSON output file")
parser.add_argument("--workers", type=int, default=10,
help="How many hosts to scan at once (default: 10). Lower it if the jump host or the network cannot take the concurrency.")
args = parser.parse_args()
if not HAS_PARAMIKO:
print("[!] paramiko not installed: pip install paramiko")
sys.exit(1)
# Password auth, when used, comes from the environment - never from an
# argument. An argument is visible to every user on the machine in
# `ps auxww` while the scan runs, and it is written to shell history.
ssh_password = os.environ.get("SSH_PASS")
if not args.ssh_key and not ssh_password:
print("[!] No credentials. Use --ssh-key, or set SSH_PASS in the environment.")
sys.exit(2)
hosts = [h.strip() for h in args.hosts.split(",")]
print(f"[*] Host Vulnerability Scanner | {len(hosts)} host(s)")
print("─" * 40)
# Hosts are scanned concurrently - the work is almost entirely waiting on
# SSH, so this is the difference between seconds and minutes on a large
# list. Results are collected into a dict keyed by host rather than
# appended from the workers: list.append happens to be thread-safe in
# CPython, but relying on that is how races get written, and the index
# order would be completion order rather than input order.
by_host: dict = {}
done = 0
progress_lock = threading.Lock()
workers = min(args.workers, len(hosts))
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(scan_host, h, args.username,
args.ssh_key, ssh_password): h for h in hosts}
for fut in as_completed(futures):
host = futures[fut]
try:
by_host[host] = fut.result()
except Exception as exc: # one host must not abort the scan
by_host[host] = {
"host": host, "reachable": False, "risk_score": "UNKNOWN",
"error": f"{type(exc).__name__}: {exc}",
"_lines": [f"\n[+] {host}",
f" [!] Scan failed: {type(exc).__name__}: {exc}"],
}
with progress_lock:
done += 1
print(f"\r[*] Scanned {done}/{len(hosts)} host(s)...",
end="", flush=True)
print("\r" + " " * 40 + "\r", end="")
# Print in INPUT order so the output is deterministic and diffable,
# not in whichever order the threads happened to finish.
all_results = [by_host[h] for h in hosts]
for r in all_results:
for line in r.pop("_lines", []):
print(line)
critical = sum(1 for r in all_results if r.get("risk_score") == "CRITICAL")
high = sum(1 for r in all_results if r.get("risk_score") == "HIGH")
medium = sum(1 for r in all_results if r.get("risk_score") == "MEDIUM")
total_cves = sum(len(r.get("cve_findings", [])) for r in all_results)
total_cis = sum(len(r.get("cis_ssh", [])) + len(r.get("cis_linux", [])) for r in all_results)
print(f"\n[*] Summary: {len(hosts)} hosts | {critical} CRITICAL | {high} HIGH | {medium} MEDIUM | {total_cves} CVEs | {total_cis} CIS violations")
output = args.output or f"vuln_scan_{datetime.now().strftime('%Y-%m-%d')}.json"
with open(output, "w") as fh:
json.dump({"scan_date": datetime.now().isoformat(), "hosts_scanned": len(hosts),
"results": all_results}, fh, indent=2)
print(f"[*] Report saved: {output}")
return 2 if critical else (1 if high or medium else 0)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--hostsstrrequiredComma-separated list of host IPs or hostnames.
--ssh-keystrSSH private key file path (e.g. ~/.ssh/id_rsa). Preferred over password.
--usernamestrSSH username. Default: root.
--outputstrJSON output file. Default: vuln_scan_<date>.json.
--workersnumberHow many hosts to scan at once (default: 10). Hosts are scanned concurrently since the work is almost entirely waiting on SSH. Lower it if the jump host or the network cannot take the concurrency.
What This Solves
A port scan tells you a host is running OpenSSH 7.4. It cannot tell you whether that host is vulnerable, because the distribution almost certainly backported the fix without changing the version string. Half the "critical findings" in a network scan report are that mistake. This logs in instead of guessing. Over SSH it reads what is actually installed and configured on a Linux host: installed packages matched against a CVE database OS and kernel version running services SSH hardening PermitRootLogin, PasswordAuthentication, X11Forwarding, PermitEmptyPasswords world-writable files unexpected SUID binaries and the rest of the CIS Benchmark controls it covers The result is the difference between "this host might be vulnerable" and "this host is running this package at this version with this configuration". Use it on Linux estates you administer: before an audit, after inheriting servers, or on a schedule to catch drift.
Is It Safe to Run
Yes, with two conditions. **Only scan hosts you are authorised to access.** This authenticates. A scanner that logs in is not a grey area - using credentials on a machine you have no permission to touch is unauthorised access, whatever the intent. Given authorisation, it is safe on the host: It is read-only. It reads package lists, configuration files and process lists. It installs nothing, changes nothing, and writes nothing to the target. It is agentless. Nothing is left behind. No agent to deploy, maintain or remove. It is quiet. One SSH session per host, a series of read commands. This is not an active scan and will not upset an IDS the way `subnet-scanner` will. Two things to be aware of: It logs in as root by default. `--username` defaults to `root`, because reading some of what it checks requires it. A less privileged account works and will silently find less - a check it cannot run is a check that reports nothing, so prefer a dedicated audit account with the specific read access it needs over running everything as root. It accepts the host key automatically. New host keys are added without prompting, because prompting per host makes a fleet scan impossible. That means it will not warn you about a changed host key. On a trusted management network that is a reasonable trade; across an untrusted path it is not. On your own machine it writes the JSON report under --output.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "paramiko>=3.0" "requests>=2.28" 3. SSH access to the hosts, by key if at all possible. See Credentials. 4. Authorisation. See the section above.
Your First Run
Start with one host you administer, using key authentication: python host_vuln_scanner.py --hosts 192.168.1.50 \ --ssh-key ~/.ssh/id_rsa --username admin That is the whole first run. It connects, reads, reports, disconnects. Nothing was changed. Check the findings against what you know about that host before you trust the tool on fifty of them - in particular confirm that the SSH hardening findings match what is really in its `sshd_config`. Then widen: python host_vuln_scanner.py \ --hosts 192.168.1.50,192.168.1.51,192.168.1.52 \ --ssh-key ~/.ssh/id_rsa --username admin \ --output audit-$(date +%F).json
Credentials
**Key authentication is the right way to use this.** Pass `--ssh-key` and nothing else. python host_vuln_scanner.py --hosts 10.0.0.5 --ssh-key ~/.ssh/id_rsa If you must use a password, it comes from the environment: SSH_PASS the SSH password. There is deliberately no --password flag Linux or macOS: export SSH_PASS='your-password' Windows PowerShell: $env:SSH_PASS = 'your-password' Windows Command Prompt: set SSH_PASS=your-password The flag was removed rather than kept as a convenience. A password on the command line is visible to **every user on the machine** in `ps auxww` for as long as the scan runs, and it is written to your shell history. On a jump host shared by a team, that is a credential disclosure, and a security tool should not be the thing that causes one. With neither `--ssh-key` nor `SSH_PASS` set, the script exits 2 with a message rather than attempting a scan it cannot complete.
What the Output Means
Per host, the script prints what it found and writes the full detail to JSON. The findings fall into three kinds, and they are not equally reliable: CVE matches from installed packages The package name and version read off the host, matched against a CVE database. **Much stronger than a banner match** - this is the real installed version. It is still a version comparison, so confirm against your distribution's security tracker before you put it in a report: a backported fix keeps the old version number and a naive match will call it vulnerable. CIS configuration findings `PermitRootLogin yes`, `PasswordAuthentication yes`, `PermitEmptyPasswords`, X11 forwarding, world-writable files, unexpected SUID binaries. These are read directly from the configuration and the filesystem, so they are facts, not inferences. They are the findings to act on first. Service inventory What is running. Context rather than a finding. Two things that look like findings and are not: A check that could not run. If the account cannot read a file, the check produces nothing. **An absent finding is not a clean result** - which is the argument for a dedicated audit account with the read access the checks need, rather than a low-privilege account that quietly skips half of them. A SUID binary you installed on purpose. The list is "unexpected relative to a baseline", not "wrong". Review it once per host type and you will know your own normal.
Exit Codes for Cron and CI
0 nothing above low severity 1 high or medium findings 2 critical findings, or no usable credentials 130 you pressed Ctrl-C Note that `2` covers both "critical finding" and "could not start". They are distinguishable from the output - the credential failure prints a message and scans nothing - but a pipeline treating 2 as "fail the build" behaves correctly in both cases.
Every Flag
--hosts LIST REQUIRED. Comma-separated IPs or hostnames: 10.0.0.5,10.0.0.6,web01.example.com --ssh-key PATH SSH private key. **Prefer this.** --username NAME SSH username. Default root - see the privilege note in Is It Safe to Run. --output PATH JSON report. Default vuln_scan_<date>.json. --workers N Hosts scanned at once. Default 10. Lower it if the jump host or the network cannot take the concurrency. The password comes from `SSH_PASS` in the environment. There is no password flag - see Credentials.
When Something Fails
"paramiko not installed" The pip install step was skipped. See Before You Start. "No credentials. Use --ssh-key, or set SSH_PASS in the environment." Neither was provided. The script stops rather than trying. "SSH connection failed" The usual causes, quickest to check first: the host is not reachable on port 22; the username is wrong (it defaults to `root`, which many hosts refuse by design); the key is not authorised on that host; or the key file has permissions SSH refuses to use. Verify by hand: ssh -i ~/.ssh/id_rsa admin@10.0.0.5 Far fewer findings than expected The account could not read what the checks need. Most CIS checks read files that are not world-readable, and a check that cannot run reports nothing at all. Confirm by running one of the underlying commands by hand as that user. A CVE you believe is already patched Almost certainly a backport. Distributions patch without bumping the upstream version, so a version-based match flags it anyway. Check your distribution's security tracker for the package before reporting it. A SUID binary that is supposed to be there Expected on most systems. Establish the baseline for each host type once; after that the list is short and anything new in it is worth a look. Only Linux hosts work By design. The checks read Linux package managers, `sshd_config` and the Linux filesystem. There is no Windows path here - for Active Directory and Windows estates, use `active-directory-auditor`. The scan is slow across a WAN Each host is a full SSH session and a series of commands. Lower --workers if you are saturating a link or a jump host; raising it will not help if the bottleneck is latency per command.