Major RCS/SMS exploitation rewrite (v2.0): - bugle_db direct extraction (plaintext messages, no decryption needed) - CVE-2024-0044 run-as privilege escalation (Android 12-13) - AOSP RCS provider queries (content://rcs/) - Archon app relay for Shizuku-elevated bugle_db access - 7-tab web UI: Extract, Database, Forge, Modify, Exploit, Backup, Monitor - SQL query interface for extracted databases - Full backup/restore/clone with SMS Backup & Restore XML support - Known CVE database (CVE-2023-24033, CVE-2024-49415, CVE-2025-48593) - IMS/RCS diagnostics, Phenotype verbose logging, Pixel tools New modules: Starlink hack, SMS forge, SDR drone detection Archon Android app: RCS messaging module with Shizuku integration Updated manuals to v2.3, 60 web blueprints confirmed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1378 lines
58 KiB
Python
1378 lines
58 KiB
Python
"""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}")
|