Autarch/modules/vuln_scanner.py

1378 lines
58 KiB
Python
Raw Normal View History

"""AUTARCH Vulnerability Scanner
Template-based vulnerability scanning with Nuclei/OpenVAS integration,
built-in CVE matching, default credential checking, and scan profiles.
"""
DESCRIPTION = "Vulnerability scanning & CVE detection"
AUTHOR = "darkHal"
VERSION = "1.0"
CATEGORY = "offense"
import os
import re
import json
import ssl
import csv
import time
import socket
import hashlib
import threading
import subprocess
from io import StringIO
from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, List, Optional, Any, Tuple
from urllib.parse import urlparse
try:
from core.paths import find_tool, get_data_dir
except ImportError:
import shutil
def find_tool(name):
return shutil.which(name)
def get_data_dir():
return str(Path(__file__).parent.parent / 'data')
try:
import requests
from requests.exceptions import RequestException
_HAS_REQUESTS = True
except ImportError:
_HAS_REQUESTS = False
try:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from core.banner import Colors, clear_screen, display_banner
except ImportError:
class Colors:
RED = YELLOW = GREEN = CYAN = DIM = RESET = WHITE = ''
def clear_screen(): pass
def display_banner(): pass
# ── Security Headers ─────────────────────────────────────────────────────────
SECURITY_HEADERS = [
'Content-Security-Policy',
'Strict-Transport-Security',
'X-Frame-Options',
'X-Content-Type-Options',
'Referrer-Policy',
'Permissions-Policy',
'Cross-Origin-Opener-Policy',
'Cross-Origin-Resource-Policy',
'Cross-Origin-Embedder-Policy',
'X-XSS-Protection',
]
# ── Default Credentials Database ─────────────────────────────────────────────
DEFAULT_CREDS: Dict[str, List[Tuple[str, str]]] = {
'ssh': [
('root', 'root'), ('root', 'toor'), ('root', 'password'), ('root', 'admin'),
('root', '123456'), ('root', '12345678'), ('admin', 'admin'), ('admin', 'password'),
('admin', '1234'), ('user', 'user'), ('user', 'password'), ('pi', 'raspberry'),
('ubuntu', 'ubuntu'), ('vagrant', 'vagrant'), ('deploy', 'deploy'),
('test', 'test'), ('guest', 'guest'), ('oracle', 'oracle'),
],
'ftp': [
('anonymous', ''), ('anonymous', 'anonymous'), ('ftp', 'ftp'),
('admin', 'admin'), ('admin', 'password'), ('root', 'root'),
('user', 'user'), ('test', 'test'), ('guest', 'guest'),
],
'mysql': [
('root', ''), ('root', 'root'), ('root', 'mysql'), ('root', 'password'),
('root', 'admin'), ('root', 'toor'), ('admin', 'admin'), ('admin', 'password'),
('mysql', 'mysql'), ('dbadmin', 'dbadmin'), ('db', 'db'),
('test', 'test'), ('user', 'user'),
],
'postgresql': [
('postgres', 'postgres'), ('postgres', 'password'), ('postgres', 'admin'),
('postgres', ''), ('admin', 'admin'), ('admin', 'password'),
('user', 'user'), ('pgsql', 'pgsql'),
],
'redis': [
('', ''), ('', 'redis'), ('', 'password'), ('', 'admin'),
('', 'foobared'), ('default', 'default'), ('default', ''),
],
'mongodb': [
('', ''), ('admin', 'admin'), ('admin', 'password'), ('admin', ''),
('root', 'root'), ('root', 'password'), ('mongouser', 'mongopass'),
],
'telnet': [
('root', 'root'), ('admin', 'admin'), ('admin', 'password'),
('admin', '1234'), ('user', 'user'), ('guest', 'guest'),
('support', 'support'), ('enable', 'enable'), ('cisco', 'cisco'),
],
'snmp': [
('', 'public'), ('', 'private'), ('', 'community'),
('', 'snmp'), ('', 'default'), ('', 'monitor'),
],
'http': [
('admin', 'admin'), ('admin', 'password'), ('admin', '1234'),
('admin', '12345'), ('admin', ''), ('root', 'root'),
('root', 'password'), ('administrator', 'administrator'),
('user', 'user'), ('guest', 'guest'), ('test', 'test'),
],
'tomcat': [
('tomcat', 'tomcat'), ('admin', 'admin'), ('manager', 'manager'),
('tomcat', 's3cret'), ('admin', 'tomcat'), ('role1', 'role1'),
('tomcat', 'password'), ('admin', 'password'), ('both', 'tomcat'),
],
'jenkins': [
('admin', 'admin'), ('admin', 'password'), ('admin', 'jenkins'),
('admin', ''), ('jenkins', 'jenkins'), ('user', 'user'),
],
'vnc': [
('', 'password'), ('', 'vnc'), ('', '1234'), ('', '12345'),
('', 'admin'), ('', 'root'),
],
'smb': [
('administrator', 'password'), ('administrator', 'admin'),
('admin', 'admin'), ('guest', ''), ('guest', 'guest'),
('user', 'user'), ('test', 'test'),
],
'mssql': [
('sa', ''), ('sa', 'sa'), ('sa', 'password'), ('sa', 'Password1'),
('sa', 'admin'), ('admin', 'admin'), ('admin', 'password'),
],
'oracle': [
('system', 'oracle'), ('system', 'manager'), ('sys', 'change_on_install'),
('scott', 'tiger'), ('dbsnmp', 'dbsnmp'), ('outln', 'outln'),
],
'ldap': [
('admin', 'admin'), ('admin', 'password'), ('cn=admin', 'admin'),
('cn=Manager', 'secret'), ('cn=root', 'secret'),
],
'mqtt': [
('', ''), ('admin', 'admin'), ('admin', 'password'),
('guest', 'guest'), ('user', 'user'),
],
}
# ── Scan Profiles ─────────────────────────────────────────────────────────────
SCAN_PROFILES = {
'quick': {
'description': 'Fast port scan + top service CVEs',
'ports': '21,22,23,25,53,80,110,111,135,139,143,443,445,993,995,1723,3306,3389,5432,5900,8080,8443',
'check_creds': False,
'check_headers': True,
'check_ssl': True,
'nuclei': False,
},
'standard': {
'description': 'Port scan + service detection + CVE matching + headers + SSL',
'ports': '1-1024,1433,1521,2049,3306,3389,5432,5900,5985,6379,8080,8443,8888,9090,9200,27017',
'check_creds': True,
'check_headers': True,
'check_ssl': True,
'nuclei': False,
},
'full': {
'description': 'All ports + full CVE + default creds + headers + SSL + nuclei',
'ports': '1-65535',
'check_creds': True,
'check_headers': True,
'check_ssl': True,
'nuclei': True,
},
'custom': {
'description': 'User-defined parameters',
'ports': None,
'check_creds': True,
'check_headers': True,
'check_ssl': True,
'nuclei': False,
},
}
class VulnScanner:
"""Vulnerability scanner with CVE matching and default credential checking."""
_instance = None
def __init__(self):
self.data_dir = os.path.join(str(get_data_dir()), 'vuln_scans')
os.makedirs(self.data_dir, exist_ok=True)
self.scans: Dict[str, Dict] = {}
self._lock = threading.Lock()
self._nmap_bin = find_tool('nmap')
self._nuclei_bin = find_tool('nuclei')
self._load_history()
def _load_history(self):
"""Load scan history from disk."""
try:
for fname in os.listdir(self.data_dir):
if fname.endswith('.json') and fname.startswith('scan_'):
fpath = os.path.join(self.data_dir, fname)
with open(fpath) as f:
data = json.load(f)
job_id = data.get('job_id', fname.replace('.json', '').replace('scan_', ''))
self.scans[job_id] = data
except Exception:
pass
def _save_scan(self, job_id: str):
"""Persist scan results to disk."""
try:
scan = self.scans.get(job_id)
if scan:
fpath = os.path.join(self.data_dir, f'scan_{job_id}.json')
with open(fpath, 'w') as f:
json.dump(scan, f, indent=2, default=str)
except Exception:
pass
def _gen_id(self) -> str:
"""Generate unique scan ID."""
return hashlib.md5(f"{time.time()}-{os.getpid()}".encode()).hexdigest()[:12]
# ── Main Scan Dispatcher ──────────────────────────────────────────────
def scan(self, target: str, profile: str = 'standard',
ports: Optional[str] = None, templates: Optional[List[str]] = None) -> str:
"""Start a vulnerability scan. Returns job_id."""
job_id = self._gen_id()
now = datetime.now(timezone.utc).isoformat()
self.scans[job_id] = {
'job_id': job_id,
'target': target,
'profile': profile,
'status': 'running',
'started': now,
'completed': None,
'progress': 0,
'findings': [],
'summary': {
'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'info': 0,
'total': 0,
},
'services': [],
'ports_scanned': ports or SCAN_PROFILES.get(profile, {}).get('ports', '1-1024'),
}
self._save_scan(job_id)
t = threading.Thread(target=self._run_scan, args=(job_id, target, profile, ports, templates), daemon=True)
t.start()
return job_id
def _run_scan(self, job_id: str, target: str, profile: str,
ports: Optional[str], templates: Optional[List[str]]):
"""Execute the scan in a background thread."""
try:
prof = SCAN_PROFILES.get(profile, SCAN_PROFILES['standard'])
scan_ports = ports or prof.get('ports', '1-1024')
# Phase 1: Port scan + service detection
self._update_progress(job_id, 5, 'Port scanning...')
services = self._port_scan(target, scan_ports)
with self._lock:
self.scans[job_id]['services'] = services
self._update_progress(job_id, 25, 'Port scan complete')
# Phase 2: CVE matching
self._update_progress(job_id, 30, 'Matching CVEs...')
for svc in services:
if svc.get('version'):
cves = self.match_cves(svc.get('service', ''), svc['version'])
for cve in cves:
self._add_finding(job_id, {
'type': 'cve',
'title': cve.get('id', 'CVE Match'),
'severity': cve.get('severity', 'medium'),
'service': f"{svc.get('service', '')} {svc.get('version', '')}",
'port': svc.get('port'),
'description': cve.get('description', ''),
'cvss': cve.get('cvss', ''),
'reference': cve.get('reference', ''),
})
self._update_progress(job_id, 45, 'CVE matching complete')
# Phase 3: Security headers
if prof.get('check_headers', True):
self._update_progress(job_id, 50, 'Checking security headers...')
http_ports = [s['port'] for s in services if s.get('service') in ('http', 'https', 'http-proxy', 'http-alt')]
if not http_ports:
for p in [80, 443, 8080, 8443]:
if any(s['port'] == p for s in services):
http_ports.append(p)
for port in http_ports:
scheme = 'https' if port in (443, 8443) else 'http'
url = f"{scheme}://{target}:{port}"
headers_result = self.check_headers(url)
if headers_result and headers_result.get('missing'):
for hdr in headers_result['missing']:
self._add_finding(job_id, {
'type': 'header',
'title': f'Missing Security Header: {hdr}',
'severity': 'low' if hdr == 'X-XSS-Protection' else 'medium',
'service': f'HTTP ({port})',
'port': port,
'description': f'The security header {hdr} is not set.',
})
self._update_progress(job_id, 60, 'Header checks complete')
# Phase 4: SSL/TLS
if prof.get('check_ssl', True):
self._update_progress(job_id, 62, 'Checking SSL/TLS...')
ssl_ports = [s['port'] for s in services if s.get('service') in ('https', 'ssl', 'imaps', 'pop3s', 'smtps')]
if 443 in [s['port'] for s in services] and 443 not in ssl_ports:
ssl_ports.append(443)
for port in ssl_ports:
ssl_result = self.check_ssl(target, port)
if ssl_result.get('issues'):
for issue in ssl_result['issues']:
severity = 'high' if 'weak protocol' in issue.lower() or 'expired' in issue.lower() else 'medium'
self._add_finding(job_id, {
'type': 'ssl',
'title': f'SSL/TLS Issue: {issue[:60]}',
'severity': severity,
'service': f'SSL/TLS ({port})',
'port': port,
'description': issue,
})
if ssl_result.get('weak_ciphers'):
for cipher in ssl_result['weak_ciphers']:
self._add_finding(job_id, {
'type': 'ssl',
'title': f'Weak Cipher: {cipher}',
'severity': 'medium',
'service': f'SSL/TLS ({port})',
'port': port,
'description': f'Weak cipher suite detected: {cipher}',
})
self._update_progress(job_id, 70, 'SSL checks complete')
# Phase 5: Default credentials
if prof.get('check_creds', False):
self._update_progress(job_id, 72, 'Testing default credentials...')
cred_results = self.check_default_creds(target, services)
for cred in cred_results:
self._add_finding(job_id, {
'type': 'credential',
'title': f"Default Credentials: {cred['service']}",
'severity': 'critical',
'service': cred['service'],
'port': cred.get('port'),
'description': f"Default credentials work: {cred['username']}:{cred['password']}",
})
self._update_progress(job_id, 85, 'Credential checks complete')
# Phase 6: Nuclei (if available and enabled)
if prof.get('nuclei', False) and self._nuclei_bin:
self._update_progress(job_id, 87, 'Running Nuclei templates...')
nuclei_results = self.nuclei_scan(target, templates)
for finding in nuclei_results.get('findings', []):
self._add_finding(job_id, finding)
self._update_progress(job_id, 95, 'Nuclei scan complete')
# Done
with self._lock:
self.scans[job_id]['status'] = 'complete'
self.scans[job_id]['completed'] = datetime.now(timezone.utc).isoformat()
self.scans[job_id]['progress'] = 100
self._save_scan(job_id)
except Exception as e:
with self._lock:
self.scans[job_id]['status'] = 'error'
self.scans[job_id]['error'] = str(e)
self.scans[job_id]['completed'] = datetime.now(timezone.utc).isoformat()
self._save_scan(job_id)
def _update_progress(self, job_id: str, progress: int, message: str = ''):
"""Update scan progress."""
with self._lock:
if job_id in self.scans:
self.scans[job_id]['progress'] = progress
self.scans[job_id]['progress_message'] = message
self._save_scan(job_id)
def _add_finding(self, job_id: str, finding: dict):
"""Add a finding to a scan."""
with self._lock:
if job_id in self.scans:
finding['timestamp'] = datetime.now(timezone.utc).isoformat()
self.scans[job_id]['findings'].append(finding)
sev = finding.get('severity', 'info').lower()
if sev in self.scans[job_id]['summary']:
self.scans[job_id]['summary'][sev] += 1
self.scans[job_id]['summary']['total'] += 1
# ── Port Scanning ─────────────────────────────────────────────────────
def _port_scan(self, target: str, ports: str) -> List[Dict]:
"""Run a port scan. Uses nmap if available, otherwise falls back to socket."""
if self._nmap_bin:
return self._nmap_scan(target, ports)
return self._socket_scan(target, ports)
def _nmap_scan(self, target: str, ports: str) -> List[Dict]:
"""Run nmap for port/service detection."""
services = []
try:
cmd = [self._nmap_bin, '-sV', '--version-intensity', '5',
'-p', ports, '-T4', '--open', '-oX', '-', target]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode == 0:
services = self._parse_nmap_xml(result.stdout)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
return services
def _parse_nmap_xml(self, xml_output: str) -> List[Dict]:
"""Parse nmap XML output to extract services."""
services = []
try:
import xml.etree.ElementTree as ET
root = ET.fromstring(xml_output)
for host in root.findall('.//host'):
for port_elem in host.findall('.//port'):
state = port_elem.find('state')
if state is not None and state.get('state') == 'open':
svc_elem = port_elem.find('service')
service_name = svc_elem.get('name', 'unknown') if svc_elem is not None else 'unknown'
version = ''
if svc_elem is not None:
parts = []
if svc_elem.get('product'):
parts.append(svc_elem.get('product'))
if svc_elem.get('version'):
parts.append(svc_elem.get('version'))
version = ' '.join(parts)
services.append({
'port': int(port_elem.get('portid', 0)),
'protocol': port_elem.get('protocol', 'tcp'),
'state': 'open',
'service': service_name,
'version': version,
'banner': svc_elem.get('extrainfo', '') if svc_elem is not None else '',
})
except Exception:
pass
return services
def _socket_scan(self, target: str, ports: str) -> List[Dict]:
"""Fallback socket-based port scan."""
services = []
port_list = self._parse_port_range(ports)
# Limit to prevent excessive scanning with socket fallback
if len(port_list) > 2000:
port_list = port_list[:2000]
for port in port_list:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex((target, port))
if result == 0:
service_name = self._guess_service(port)
banner = self._grab_banner(target, port)
services.append({
'port': port,
'protocol': 'tcp',
'state': 'open',
'service': service_name,
'version': banner,
'banner': banner,
})
sock.close()
except Exception:
pass
return services
def _parse_port_range(self, ports_str: str) -> List[int]:
"""Parse port range string like '1-1024,8080,8443' into list of ints."""
result = []
for part in ports_str.split(','):
part = part.strip()
if '-' in part:
try:
start, end = part.split('-', 1)
for p in range(int(start), int(end) + 1):
if 1 <= p <= 65535:
result.append(p)
except ValueError:
pass
else:
try:
p = int(part)
if 1 <= p <= 65535:
result.append(p)
except ValueError:
pass
return sorted(set(result))
def _guess_service(self, port: int) -> str:
"""Guess service name from port number."""
common = {
21: 'ftp', 22: 'ssh', 23: 'telnet', 25: 'smtp', 53: 'dns',
80: 'http', 110: 'pop3', 111: 'rpcbind', 135: 'msrpc',
139: 'netbios', 143: 'imap', 443: 'https', 445: 'smb',
993: 'imaps', 995: 'pop3s', 1433: 'mssql', 1521: 'oracle',
1723: 'pptp', 2049: 'nfs', 3306: 'mysql', 3389: 'rdp',
5432: 'postgresql', 5900: 'vnc', 5985: 'winrm',
6379: 'redis', 8080: 'http-proxy', 8443: 'https-alt',
8888: 'http-alt', 9090: 'http-alt', 9200: 'elasticsearch',
27017: 'mongodb', 1883: 'mqtt', 5672: 'amqp', 11211: 'memcached',
}
return common.get(port, 'unknown')
def _grab_banner(self, host: str, port: int) -> str:
"""Try to grab a service banner."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
sock.connect((host, port))
# Send a basic probe for HTTP
if port in (80, 8080, 8888, 8443, 443, 9090):
sock.send(b'HEAD / HTTP/1.0\r\nHost: target\r\n\r\n')
else:
sock.send(b'\r\n')
banner = sock.recv(1024).decode('utf-8', errors='ignore').strip()
sock.close()
# Extract server header from HTTP response
if banner.startswith('HTTP/'):
for line in banner.split('\r\n'):
if line.lower().startswith('server:'):
return line.split(':', 1)[1].strip()
return banner[:200] if banner else ''
except Exception:
return ''
# ── Quick / Full Scan Shortcuts ───────────────────────────────────────
def quick_scan(self, target: str) -> str:
"""Quick scan: ports + services + top CVEs. Returns job_id."""
return self.scan(target, profile='quick')
def full_scan(self, target: str) -> str:
"""Full scan: all ports + CVEs + creds + headers + SSL + nuclei. Returns job_id."""
return self.scan(target, profile='full')
# ── Nuclei Integration ────────────────────────────────────────────────
def nuclei_scan(self, target: str, templates: Optional[List[str]] = None) -> Dict:
"""Run Nuclei template scanner if available."""
result = {'ok': False, 'findings': [], 'error': ''}
if not self._nuclei_bin:
result['error'] = 'Nuclei not found in PATH'
return result
try:
cmd = [self._nuclei_bin, '-u', target, '-jsonl', '-silent', '-nc']
if templates:
for t in templates:
cmd.extend(['-t', t])
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
result['ok'] = True
for line in proc.stdout.strip().split('\n'):
if not line.strip():
continue
try:
entry = json.loads(line)
finding = {
'type': 'nuclei',
'title': entry.get('info', {}).get('name', entry.get('template-id', 'Unknown')),
'severity': entry.get('info', {}).get('severity', 'info'),
'service': entry.get('matched-at', target),
'port': None,
'description': entry.get('info', {}).get('description', ''),
'template': entry.get('template-id', ''),
'matcher': entry.get('matcher-name', ''),
'reference': ', '.join(entry.get('info', {}).get('reference', [])) if isinstance(entry.get('info', {}).get('reference'), list) else '',
}
# Try to extract port from matched-at URL
matched = entry.get('matched-at', '')
if matched:
try:
parsed = urlparse(matched)
if parsed.port:
finding['port'] = parsed.port
except Exception:
pass
result['findings'].append(finding)
except json.JSONDecodeError:
pass
except subprocess.TimeoutExpired:
result['error'] = 'Nuclei scan timed out (10 min limit)'
except Exception as e:
result['error'] = str(e)
return result
# ── Default Credential Checking ───────────────────────────────────────
def check_default_creds(self, target: str, services: List[Dict]) -> List[Dict]:
"""Test default credentials against discovered services."""
found = []
svc_map = {}
for svc in services:
name = svc.get('service', '').lower()
port = svc.get('port', 0)
svc_map[name] = port
# SSH
if 'ssh' in svc_map:
port = svc_map['ssh']
for user, pwd in DEFAULT_CREDS.get('ssh', []):
if self._try_ssh(target, port, user, pwd):
found.append({'service': f'SSH ({port})', 'port': port, 'username': user, 'password': pwd})
break
# FTP
if 'ftp' in svc_map:
port = svc_map['ftp']
for user, pwd in DEFAULT_CREDS.get('ftp', []):
if self._try_ftp(target, port, user, pwd):
found.append({'service': f'FTP ({port})', 'port': port, 'username': user, 'password': pwd})
break
# MySQL
if 'mysql' in svc_map:
port = svc_map['mysql']
for user, pwd in DEFAULT_CREDS.get('mysql', []):
if self._try_mysql(target, port, user, pwd):
found.append({'service': f'MySQL ({port})', 'port': port, 'username': user, 'password': pwd})
break
# PostgreSQL
if 'postgresql' in svc_map:
port = svc_map['postgresql']
for user, pwd in DEFAULT_CREDS.get('postgresql', []):
if self._try_postgres(target, port, user, pwd):
found.append({'service': f'PostgreSQL ({port})', 'port': port, 'username': user, 'password': pwd})
break
# Redis
if 'redis' in svc_map:
port = svc_map['redis']
for user, pwd in DEFAULT_CREDS.get('redis', []):
if self._try_redis(target, port, pwd):
found.append({'service': f'Redis ({port})', 'port': port, 'username': user or '(none)', 'password': pwd or '(none)'})
break
# MongoDB
if 'mongodb' in svc_map:
port = svc_map['mongodb']
if self._try_mongodb(target, port):
found.append({'service': f'MongoDB ({port})', 'port': port, 'username': '(none)', 'password': '(no auth)'})
# HTTP admin panels
for svc_name in ('http', 'https', 'http-proxy', 'http-alt'):
if svc_name in svc_map:
port = svc_map[svc_name]
scheme = 'https' if svc_name == 'https' or port in (443, 8443) else 'http'
for user, pwd in DEFAULT_CREDS.get('http', []):
if self._try_http_auth(f"{scheme}://{target}:{port}", user, pwd):
found.append({'service': f'HTTP Admin ({port})', 'port': port, 'username': user, 'password': pwd})
break
# SNMP
if 'snmp' in svc_map or any(s.get('port') == 161 for s in services):
port = svc_map.get('snmp', 161)
for _, community in DEFAULT_CREDS.get('snmp', []):
if self._try_snmp(target, community):
found.append({'service': f'SNMP ({port})', 'port': port, 'username': '(community)', 'password': community})
break
# Telnet
if 'telnet' in svc_map:
port = svc_map['telnet']
for user, pwd in DEFAULT_CREDS.get('telnet', []):
if self._try_telnet(target, port, user, pwd):
found.append({'service': f'Telnet ({port})', 'port': port, 'username': user, 'password': pwd})
break
return found
def _try_ssh(self, host: str, port: int, user: str, pwd: str) -> bool:
"""Try SSH login via subprocess."""
try:
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(host, port=port, username=user, password=pwd, timeout=5, allow_agent=False, look_for_keys=False)
client.close()
return True
except Exception:
return False
def _try_ftp(self, host: str, port: int, user: str, pwd: str) -> bool:
"""Try FTP login."""
try:
import ftplib
ftp = ftplib.FTP()
ftp.connect(host, port, timeout=5)
ftp.login(user, pwd)
ftp.quit()
return True
except Exception:
return False
def _try_mysql(self, host: str, port: int, user: str, pwd: str) -> bool:
"""Try MySQL login via socket."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
sock.connect((host, port))
greeting = sock.recv(1024)
sock.close()
# If we get a greeting, the port is open. Full auth test requires mysql connector.
if greeting and b'mysql' in greeting.lower():
return False # Got greeting but can't auth without connector
return False
except Exception:
return False
def _try_postgres(self, host: str, port: int, user: str, pwd: str) -> bool:
"""Try PostgreSQL login."""
try:
import psycopg2
conn = psycopg2.connect(host=host, port=port, user=user, password=pwd, connect_timeout=5)
conn.close()
return True
except Exception:
return False
def _try_redis(self, host: str, port: int, pwd: str) -> bool:
"""Try Redis with AUTH command."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
sock.connect((host, port))
if pwd:
sock.send(f"AUTH {pwd}\r\n".encode())
else:
sock.send(b"PING\r\n")
resp = sock.recv(1024).decode('utf-8', errors='ignore')
sock.close()
return '+OK' in resp or '+PONG' in resp
except Exception:
return False
def _try_mongodb(self, host: str, port: int) -> bool:
"""Check if MongoDB allows unauthenticated access."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
sock.connect((host, port))
# MongoDB wire protocol: send isMaster command
# Simplified check: just see if port is open and responds
sock.close()
return True # Port is open, but we can't fully confirm without pymongo
except Exception:
return False
def _try_http_auth(self, url: str, user: str, pwd: str) -> bool:
"""Try HTTP basic/digest auth on common admin paths."""
if not _HAS_REQUESTS:
return False
admin_paths = ['/', '/admin', '/manager/html', '/login', '/admin/login']
for path in admin_paths:
try:
resp = requests.get(url + path, auth=(user, pwd), timeout=5,
verify=False, allow_redirects=False)
if resp.status_code in (200, 301, 302) and resp.status_code != 401:
# Check if we actually got past auth (not just a public page)
unauth = requests.get(url + path, timeout=5, verify=False, allow_redirects=False)
if unauth.status_code == 401:
return True
except Exception:
pass
return False
def _try_snmp(self, host: str, community: str) -> bool:
"""Try SNMP community string via UDP."""
try:
# Build SNMPv1 GET request for sysDescr.0
community_bytes = community.encode()
pdu = (
b'\xa0\x1c'
b'\x02\x04\x00\x00\x00\x01'
b'\x02\x01\x00'
b'\x02\x01\x00'
b'\x30\x0e\x30\x0c'
b'\x06\x08\x2b\x06\x01\x02\x01\x01\x01\x00'
b'\x05\x00'
)
payload = b'\x02\x01\x00' + bytes([0x04, len(community_bytes)]) + community_bytes + pdu
snmp_get = bytes([0x30, len(payload)]) + payload
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(3)
sock.sendto(snmp_get, (host, 161))
data, _ = sock.recvfrom(4096)
sock.close()
return len(data) > 0
except Exception:
return False
def _try_telnet(self, host: str, port: int, user: str, pwd: str) -> bool:
"""Try telnet login."""
try:
import telnetlib
tn = telnetlib.Telnet(host, port, timeout=5)
tn.read_until(b'login: ', timeout=5)
tn.write(user.encode() + b'\n')
tn.read_until(b'assword: ', timeout=5)
tn.write(pwd.encode() + b'\n')
resp = tn.read_some().decode('utf-8', errors='ignore')
tn.close()
return 'incorrect' not in resp.lower() and 'failed' not in resp.lower() and 'invalid' not in resp.lower()
except Exception:
return False
# ── Security Headers Check ────────────────────────────────────────────
def check_headers(self, url: str) -> Dict:
"""Check security headers for a URL."""
result = {'url': url, 'headers': {}, 'present': [], 'missing': [], 'score': 0}
if not _HAS_REQUESTS:
result['error'] = 'requests library not available'
return result
try:
resp = requests.get(url, timeout=10, verify=False, allow_redirects=True)
resp_headers = {k.lower(): v for k, v in resp.headers.items()}
checked = 0
found = 0
for hdr in SECURITY_HEADERS:
hdr_lower = hdr.lower()
if hdr_lower in resp_headers:
result['headers'][hdr] = {
'present': True,
'value': resp_headers[hdr_lower],
'rating': 'good',
}
result['present'].append(hdr)
found += 1
else:
result['headers'][hdr] = {
'present': False,
'value': '',
'rating': 'missing',
}
result['missing'].append(hdr)
checked += 1
# Check for weak values
csp = resp_headers.get('content-security-policy', '')
if csp and ("'unsafe-inline'" in csp or "'unsafe-eval'" in csp):
result['headers']['Content-Security-Policy']['rating'] = 'weak'
hsts = resp_headers.get('strict-transport-security', '')
if hsts:
max_age_match = re.search(r'max-age=(\d+)', hsts)
if max_age_match and int(max_age_match.group(1)) < 31536000:
result['headers']['Strict-Transport-Security']['rating'] = 'weak'
xfo = resp_headers.get('x-frame-options', '')
if xfo and xfo.upper() not in ('DENY', 'SAMEORIGIN'):
result['headers']['X-Frame-Options']['rating'] = 'weak'
result['score'] = int((found / checked) * 100) if checked > 0 else 0
result['server'] = resp.headers.get('Server', '')
result['status_code'] = resp.status_code
except Exception as e:
result['error'] = str(e)
return result
# ── SSL/TLS Analysis ──────────────────────────────────────────────────
def check_ssl(self, host: str, port: int = 443) -> Dict:
"""Check SSL/TLS configuration."""
result = {
'host': host,
'port': port,
'valid': False,
'issuer': '',
'subject': '',
'expires': '',
'not_before': '',
'protocol': '',
'cipher': '',
'key_size': 0,
'issues': [],
'weak_ciphers': [],
'supported_protocols': [],
}
try:
# Test connection without verification
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with ctx.wrap_socket(socket.socket(), server_hostname=host) as s:
s.settimeout(10)
s.connect((host, port))
result['protocol'] = s.version()
cipher_info = s.cipher()
if cipher_info:
result['cipher'] = cipher_info[0]
result['key_size'] = cipher_info[2] if len(cipher_info) > 2 else 0
# Test with verification
ctx2 = ssl.create_default_context()
try:
with ctx2.wrap_socket(socket.socket(), server_hostname=host) as s2:
s2.settimeout(10)
s2.connect((host, port))
cert = s2.getpeercert()
result['valid'] = True
if cert.get('issuer'):
result['issuer'] = dict(x[0] for x in cert['issuer'])
if cert.get('subject'):
result['subject'] = dict(x[0] for x in cert['subject'])
result['expires'] = cert.get('notAfter', '')
result['not_before'] = cert.get('notBefore', '')
# Check expiry
if result['expires']:
try:
exp_date = datetime.strptime(result['expires'], '%b %d %H:%M:%S %Y %Z')
if exp_date < datetime.utcnow():
result['issues'].append('Certificate has expired')
result['valid'] = False
elif (exp_date - datetime.utcnow()).days < 30:
result['issues'].append(f'Certificate expires in {(exp_date - datetime.utcnow()).days} days')
except Exception:
pass
except ssl.SSLCertVerificationError as e:
result['issues'].append(f'Certificate verification failed: {e}')
except Exception as e:
result['issues'].append(f'SSL verification error: {e}')
# Check protocol version
if result['protocol'] in ('TLSv1', 'TLSv1.1', 'SSLv3', 'SSLv2'):
result['issues'].append(f'Weak protocol version: {result["protocol"]}')
# Check for weak ciphers
weak_patterns = ['RC4', 'DES', 'MD5', 'NULL', 'EXPORT', 'anon', 'RC2']
if result['cipher']:
for pattern in weak_patterns:
if pattern.lower() in result['cipher'].lower():
result['weak_ciphers'].append(result['cipher'])
break
# Check key size
if result['key_size'] and result['key_size'] < 128:
result['issues'].append(f'Weak key size: {result["key_size"]} bits')
# Test specific protocol versions for known vulns
protocols_to_test = [
(ssl.PROTOCOL_TLSv1 if hasattr(ssl, 'PROTOCOL_TLSv1') else None, 'TLSv1'),
]
for proto_const, proto_name in protocols_to_test:
if proto_const is not None:
try:
ctx_test = ssl.SSLContext(proto_const)
ctx_test.check_hostname = False
ctx_test.verify_mode = ssl.CERT_NONE
with ctx_test.wrap_socket(socket.socket(), server_hostname=host) as st:
st.settimeout(5)
st.connect((host, port))
result['supported_protocols'].append(proto_name)
except Exception:
pass
except Exception as e:
result['error'] = str(e)
return result
# ── CVE Matching ──────────────────────────────────────────────────────
def match_cves(self, service: str, version: str) -> List[Dict]:
"""Match service/version against known CVEs."""
matches = []
if not service or not version:
return matches
# Try local CVE database first
try:
from core.cve import CVEDatabase
cve_db = CVEDatabase()
keyword = f"{service} {version}"
results = cve_db.search(keyword)
for r in results[:20]:
matches.append({
'id': r.get('cve_id', r.get('id', '')),
'severity': r.get('severity', 'medium').lower(),
'description': r.get('description', '')[:300],
'cvss': r.get('cvss_score', r.get('cvss', '')),
'reference': r.get('reference', r.get('url', '')),
})
except Exception:
pass
# Fallback: NVD API query
if not matches and _HAS_REQUESTS:
try:
keyword = f"{service} {version}"
url = f"https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch={keyword}&resultsPerPage=10"
resp = requests.get(url, timeout=15)
if resp.status_code == 200:
data = resp.json()
for vuln in data.get('vulnerabilities', []):
cve = vuln.get('cve', {})
cve_id = cve.get('id', '')
descriptions = cve.get('descriptions', [])
desc = ''
for d in descriptions:
if d.get('lang') == 'en':
desc = d.get('value', '')
break
metrics = cve.get('metrics', {})
cvss_score = ''
severity = 'medium'
for metric_key in ('cvssMetricV31', 'cvssMetricV30', 'cvssMetricV2'):
metric_list = metrics.get(metric_key, [])
if metric_list:
cvss_data = metric_list[0].get('cvssData', {})
cvss_score = str(cvss_data.get('baseScore', ''))
sev = metric_list[0].get('baseSeverity', cvss_data.get('baseSeverity', '')).lower()
if sev:
severity = sev
break
matches.append({
'id': cve_id,
'severity': severity,
'description': desc[:300],
'cvss': cvss_score,
'reference': f'https://nvd.nist.gov/vuln/detail/{cve_id}',
})
except Exception:
pass
return matches
# ── Scan Management ───────────────────────────────────────────────────
def get_scan(self, job_id: str) -> Optional[Dict]:
"""Get scan status and results."""
return self.scans.get(job_id)
def list_scans(self) -> List[Dict]:
"""List all scans with summary info."""
result = []
for job_id, scan in sorted(self.scans.items(), key=lambda x: x[1].get('started', ''), reverse=True):
result.append({
'job_id': job_id,
'target': scan.get('target', ''),
'profile': scan.get('profile', ''),
'status': scan.get('status', ''),
'started': scan.get('started', ''),
'completed': scan.get('completed'),
'progress': scan.get('progress', 0),
'summary': scan.get('summary', {}),
'findings_count': len(scan.get('findings', [])),
})
return result
def delete_scan(self, job_id: str) -> bool:
"""Delete a scan and its data."""
if job_id in self.scans:
del self.scans[job_id]
fpath = os.path.join(self.data_dir, f'scan_{job_id}.json')
try:
if os.path.exists(fpath):
os.remove(fpath)
except Exception:
pass
return True
return False
def export_scan(self, job_id: str, fmt: str = 'json') -> Optional[Dict]:
"""Export scan results as JSON or CSV."""
scan = self.scans.get(job_id)
if not scan:
return None
if fmt == 'csv':
output = StringIO()
writer = csv.writer(output)
writer.writerow(['Type', 'Title', 'Severity', 'Service', 'Port', 'Description', 'CVSS', 'Reference'])
for f in scan.get('findings', []):
writer.writerow([
f.get('type', ''),
f.get('title', ''),
f.get('severity', ''),
f.get('service', ''),
f.get('port', ''),
f.get('description', ''),
f.get('cvss', ''),
f.get('reference', ''),
])
return {
'format': 'csv',
'filename': f'vuln_scan_{job_id}.csv',
'content': output.getvalue(),
'mime': 'text/csv',
}
else:
return {
'format': 'json',
'filename': f'vuln_scan_{job_id}.json',
'content': json.dumps(scan, indent=2, default=str),
'mime': 'application/json',
}
# ── Nuclei Templates ──────────────────────────────────────────────────
def get_templates(self) -> Dict:
"""List available Nuclei templates."""
result = {
'installed': self._nuclei_bin is not None,
'nuclei_path': self._nuclei_bin or '',
'templates': [],
'categories': [],
}
if not self._nuclei_bin:
return result
try:
# List template directories
cmd = [self._nuclei_bin, '-tl', '-silent']
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if proc.returncode == 0:
templates = [t.strip() for t in proc.stdout.strip().split('\n') if t.strip()]
result['templates'] = templates[:500]
# Extract categories from template paths
cats = set()
for t in templates:
parts = t.split('/')
if len(parts) >= 2:
cats.add(parts[0])
result['categories'] = sorted(cats)
except Exception:
pass
return result
# ── Severity Helpers ──────────────────────────────────────────────────
def _severity_score(self, severity: str) -> int:
"""Convert severity string to numeric score."""
scores = {
'critical': 5,
'high': 4,
'medium': 3,
'low': 2,
'info': 1,
}
return scores.get(severity.lower(), 0)
# ── Singleton ─────────────────────────────────────────────────────────────────
_instance = None
def get_vuln_scanner() -> VulnScanner:
global _instance
if _instance is None:
_instance = VulnScanner()
return _instance
# ── CLI Interface ─────────────────────────────────────────────────────────────
def run():
"""CLI entry point."""
print("\n Vulnerability Scanner\n")
scanner = get_vuln_scanner()
while True:
print(f"\n{'=' * 60}")
print(f" Vulnerability Scanner")
print(f"{'=' * 60}")
print()
print(" 1 — Quick Scan")
print(" 2 — Full Scan")
print(" 3 — Nuclei Scan")
print(" 4 — Check Default Credentials")
print(" 5 — Check Security Headers")
print(" 6 — Check SSL/TLS")
print(" 7 — View Scan Results")
print(" 8 — List Scans")
print(" 0 — Back")
print()
choice = input(" > ").strip()
if choice == '0':
break
elif choice == '1':
target = input(" Target (IP/hostname): ").strip()
if target:
job_id = scanner.quick_scan(target)
print(f" Scan started (job: {job_id})")
_wait_for_scan(scanner, job_id)
elif choice == '2':
target = input(" Target (IP/hostname): ").strip()
if target:
job_id = scanner.full_scan(target)
print(f" Full scan started (job: {job_id})")
_wait_for_scan(scanner, job_id)
elif choice == '3':
target = input(" Target (IP/hostname/URL): ").strip()
if target:
if not scanner._nuclei_bin:
print(" Nuclei not found in PATH")
continue
result = scanner.nuclei_scan(target)
if result['ok']:
_print_findings(result.get('findings', []))
else:
print(f" Error: {result.get('error', 'Unknown')}")
elif choice == '4':
target = input(" Target (IP/hostname): ").strip()
if target:
port_str = input(" Ports to check (comma-sep, or Enter for common): ").strip()
if port_str:
services = [{'port': int(p.strip()), 'service': scanner._guess_service(int(p.strip()))} for p in port_str.split(',') if p.strip().isdigit()]
else:
print(" Scanning common ports first...")
services = scanner._socket_scan(target, '21,22,23,80,443,1433,3306,5432,6379,8080,27017')
if services:
print(f" Found {len(services)} open port(s), checking credentials...")
found = scanner.check_default_creds(target, services)
if found:
for c in found:
print(f" {Colors.RED}[!] {c['service']}: {c['username']}:{c['password']}{Colors.RESET}")
else:
print(f" {Colors.GREEN}[+] No default credentials found{Colors.RESET}")
else:
print(" No open ports found")
elif choice == '5':
url = input(" URL: ").strip()
if url:
result = scanner.check_headers(url)
if result.get('error'):
print(f" Error: {result['error']}")
else:
print(f"\n Security Headers Score: {result['score']}%\n")
for hdr, info in result.get('headers', {}).items():
symbol = '+' if info['present'] else 'X'
color = Colors.GREEN if info['rating'] == 'good' else (Colors.YELLOW if info['rating'] == 'weak' else Colors.RED)
print(f" {color}[{symbol}] {hdr}{Colors.RESET}")
if info.get('value'):
print(f" {Colors.DIM}{info['value'][:80]}{Colors.RESET}")
elif choice == '6':
host = input(" Host: ").strip()
port_str = input(" Port (443): ").strip()
port = int(port_str) if port_str.isdigit() else 443
if host:
result = scanner.check_ssl(host, port)
if result.get('error'):
print(f" Error: {result['error']}")
else:
valid_color = Colors.GREEN if result['valid'] else Colors.RED
print(f"\n Valid: {valid_color}{result['valid']}{Colors.RESET}")
print(f" Protocol: {result['protocol']}")
print(f" Cipher: {result['cipher']}")
if result.get('expires'):
print(f" Expires: {result['expires']}")
for issue in result.get('issues', []):
print(f" {Colors.YELLOW}[!] {issue}{Colors.RESET}")
for wc in result.get('weak_ciphers', []):
print(f" {Colors.RED}[!] Weak cipher: {wc}{Colors.RESET}")
elif choice == '7':
job_id = input(" Job ID: ").strip()
if job_id:
scan = scanner.get_scan(job_id)
if scan:
_print_scan_summary(scan)
else:
print(" Scan not found")
elif choice == '8':
scans = scanner.list_scans()
if scans:
print(f"\n {'ID':<14} {'Target':<20} {'Profile':<10} {'Status':<10} {'Findings':<10}")
print(f" {'-'*14} {'-'*20} {'-'*10} {'-'*10} {'-'*10}")
for s in scans:
print(f" {s['job_id']:<14} {s['target']:<20} {s['profile']:<10} {s['status']:<10} {s['findings_count']:<10}")
else:
print(" No scans found")
def _wait_for_scan(scanner: VulnScanner, job_id: str):
"""Wait for a scan to complete and print results."""
while True:
scan = scanner.get_scan(job_id)
if not scan:
print(" Scan not found")
break
status = scan.get('status', '')
progress = scan.get('progress', 0)
message = scan.get('progress_message', '')
print(f"\r [{progress:3d}%] {message:<40}", end='', flush=True)
if status in ('complete', 'error'):
print()
if status == 'error':
print(f" Error: {scan.get('error', 'Unknown')}")
else:
_print_scan_summary(scan)
break
time.sleep(2)
def _print_scan_summary(scan: dict):
"""Print scan results summary."""
summary = scan.get('summary', {})
print(f"\n Target: {scan.get('target', '')}")
print(f" Profile: {scan.get('profile', '')}")
print(f" Status: {scan.get('status', '')}")
print(f" Findings: {summary.get('total', 0)} "
f"(C:{summary.get('critical', 0)} H:{summary.get('high', 0)} "
f"M:{summary.get('medium', 0)} L:{summary.get('low', 0)} I:{summary.get('info', 0)})")
print()
_print_findings(scan.get('findings', []))
def _print_findings(findings: list):
"""Print findings table."""
if not findings:
print(f" {Colors.GREEN}No findings{Colors.RESET}")
return
sev_colors = {
'critical': Colors.RED, 'high': Colors.RED,
'medium': Colors.YELLOW, 'low': Colors.CYAN, 'info': Colors.DIM,
}
for f in findings:
sev = f.get('severity', 'info').lower()
color = sev_colors.get(sev, Colors.WHITE)
print(f" {color}[{sev.upper():<8}]{Colors.RESET} {f.get('title', '')}")
if f.get('service'):
print(f" Service: {f['service']}")
if f.get('description'):
desc = f['description'][:120]
print(f" {Colors.DIM}{desc}{Colors.RESET}")