mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-06-11 05:34:55 +03:00
922 lines
34 KiB
Python
922 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""Agent for implementing external attack surface management (EASM).
|
|
|
|
Combines Shodan, Censys, ProjectDiscovery tools (subfinder, httpx, nuclei),
|
|
and a custom exposure scoring algorithm for comprehensive ASM.
|
|
|
|
DISCLAIMER: This tool is intended for authorized security testing and attack
|
|
surface management only. Ensure you have written authorization before scanning
|
|
any targets. Unauthorized scanning of systems you do not own or have explicit
|
|
permission to test is illegal and unethical.
|
|
"""
|
|
|
|
import json
|
|
import subprocess
|
|
import argparse
|
|
import math
|
|
from datetime import datetime
|
|
from collections import defaultdict
|
|
|
|
try:
|
|
import shodan
|
|
except ImportError:
|
|
shodan = None
|
|
|
|
try:
|
|
from censys.search import CensysHosts, CensysCerts
|
|
except ImportError:
|
|
CensysHosts = None
|
|
CensysCerts = None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Port risk weights based on OWASP attack surface analysis methodology
|
|
# --------------------------------------------------------------------------- #
|
|
PORT_RISK_WEIGHTS = {
|
|
# Management / remote access (highest risk)
|
|
22: 8.0, # SSH
|
|
23: 9.5, # Telnet (unencrypted)
|
|
3389: 8.5, # RDP
|
|
5900: 8.0, # VNC
|
|
5985: 7.5, # WinRM HTTP
|
|
5986: 7.0, # WinRM HTTPS
|
|
# Web services
|
|
80: 3.0, # HTTP
|
|
443: 2.5, # HTTPS
|
|
8080: 5.0, # Alt HTTP (often dev/admin)
|
|
8443: 4.5, # Alt HTTPS
|
|
8888: 6.0, # Often dev panels
|
|
# Databases (high risk if exposed)
|
|
3306: 9.0, # MySQL
|
|
5432: 9.0, # PostgreSQL
|
|
1433: 9.0, # MSSQL
|
|
1521: 9.0, # Oracle
|
|
27017: 9.5, # MongoDB
|
|
6379: 9.5, # Redis
|
|
9200: 8.5, # Elasticsearch
|
|
5601: 8.0, # Kibana
|
|
# Message queues
|
|
5672: 7.5, # RabbitMQ
|
|
9092: 7.5, # Kafka
|
|
# File sharing
|
|
21: 8.0, # FTP
|
|
445: 9.0, # SMB
|
|
139: 8.5, # NetBIOS
|
|
# Email
|
|
25: 6.0, # SMTP
|
|
110: 6.5, # POP3
|
|
143: 6.0, # IMAP
|
|
# DNS
|
|
53: 5.0, # DNS
|
|
# SNMP
|
|
161: 8.0, # SNMP
|
|
162: 7.5, # SNMP Trap
|
|
}
|
|
|
|
# Services that indicate sensitive data handling
|
|
SENSITIVE_SERVICE_INDICATORS = {
|
|
"mysql", "postgresql", "mongodb", "redis", "elasticsearch",
|
|
"oracle", "mssql", "couchdb", "cassandra", "memcached",
|
|
"rabbitmq", "kafka", "activemq",
|
|
}
|
|
|
|
# Technologies known to have frequent vulnerabilities
|
|
HIGH_RISK_TECHNOLOGIES = {
|
|
"apache": 3.0,
|
|
"nginx": 2.0,
|
|
"iis": 4.0,
|
|
"tomcat": 5.0,
|
|
"jboss": 6.0,
|
|
"weblogic": 7.0,
|
|
"wordpress": 6.0,
|
|
"drupal": 5.0,
|
|
"joomla": 5.5,
|
|
"phpmyadmin": 8.0,
|
|
"jenkins": 7.0,
|
|
"gitlab": 5.0,
|
|
"grafana": 4.0,
|
|
"kibana": 5.0,
|
|
"solr": 6.0,
|
|
"struts": 8.0,
|
|
"coldfusion": 7.0,
|
|
"exchange": 7.5,
|
|
"sharepoint": 6.0,
|
|
}
|
|
|
|
|
|
class SubdomainEnumerator:
|
|
"""Discovers subdomains using subfinder and amass."""
|
|
|
|
def __init__(self, domain):
|
|
self.domain = domain
|
|
self.subdomains = set()
|
|
|
|
def run_subfinder(self):
|
|
"""Run subfinder for passive subdomain enumeration."""
|
|
print(f"[+] Running subfinder against {self.domain}")
|
|
try:
|
|
result = subprocess.run(
|
|
["subfinder", "-d", self.domain, "-all", "-silent"],
|
|
capture_output=True, text=True, timeout=300,
|
|
)
|
|
found = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
|
|
self.subdomains.update(found)
|
|
print(f"[+] subfinder found {len(found)} subdomains")
|
|
except FileNotFoundError:
|
|
print("[-] subfinder not installed. Install: go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest")
|
|
except subprocess.TimeoutExpired:
|
|
print("[-] subfinder timed out after 300s")
|
|
return self.subdomains
|
|
|
|
def run_amass(self):
|
|
"""Run amass for deeper passive enumeration."""
|
|
print(f"[+] Running amass passive enum against {self.domain}")
|
|
try:
|
|
result = subprocess.run(
|
|
["amass", "enum", "-d", self.domain, "-passive"],
|
|
capture_output=True, text=True, timeout=600,
|
|
)
|
|
found = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
|
|
self.subdomains.update(found)
|
|
print(f"[+] amass found {len(found)} subdomains")
|
|
except FileNotFoundError:
|
|
print("[-] amass not installed. Install: go install -v github.com/owasp-amass/amass/v4/...@master")
|
|
except subprocess.TimeoutExpired:
|
|
print("[-] amass timed out after 600s")
|
|
return self.subdomains
|
|
|
|
def enumerate_all(self):
|
|
"""Run all enumeration tools and merge results."""
|
|
self.run_subfinder()
|
|
self.run_amass()
|
|
self.subdomains.discard("")
|
|
print(f"[+] Total unique subdomains: {len(self.subdomains)}")
|
|
return sorted(self.subdomains)
|
|
|
|
|
|
class ServiceFingerprinter:
|
|
"""Probes live hosts and fingerprints services using httpx."""
|
|
|
|
def __init__(self, subdomains):
|
|
self.subdomains = subdomains
|
|
self.results = []
|
|
|
|
def run_httpx(self):
|
|
"""Run httpx for HTTP probing and technology detection."""
|
|
if not self.subdomains:
|
|
print("[-] No subdomains to probe")
|
|
return []
|
|
|
|
print(f"[+] Running httpx against {len(self.subdomains)} subdomains")
|
|
input_data = "\n".join(self.subdomains)
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"httpx", "-sc", "-cl", "-ct", "-title", "-tech-detect",
|
|
"-favicon", "-cdn", "-cname", "-follow-redirects",
|
|
"-json", "-silent",
|
|
],
|
|
input=input_data,
|
|
capture_output=True, text=True, timeout=600,
|
|
)
|
|
for line in result.stdout.strip().split("\n"):
|
|
if line.strip():
|
|
try:
|
|
self.results.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
print(f"[+] httpx found {len(self.results)} live hosts")
|
|
except FileNotFoundError:
|
|
print("[-] httpx not installed. Install: go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest")
|
|
except subprocess.TimeoutExpired:
|
|
print("[-] httpx timed out after 600s")
|
|
return self.results
|
|
|
|
|
|
class ShodanScanner:
|
|
"""Discovers exposed services and vulnerabilities via Shodan API."""
|
|
|
|
def __init__(self, api_key):
|
|
if shodan is None:
|
|
raise ImportError("pip install shodan")
|
|
self.api = shodan.Shodan(api_key)
|
|
self.results = []
|
|
|
|
def search_domain(self, domain):
|
|
"""Search Shodan for all hosts associated with a domain."""
|
|
print(f"[+] Searching Shodan for hostname:{domain}")
|
|
try:
|
|
results = self.api.search(f"hostname:{domain}", limit=500)
|
|
self.results.extend(results.get("matches", []))
|
|
print(f"[+] Shodan returned {results['total']} results")
|
|
except shodan.APIError as e:
|
|
print(f"[-] Shodan API error: {e}")
|
|
return self.results
|
|
|
|
def search_org(self, org_name):
|
|
"""Search Shodan for all hosts in an organization."""
|
|
print(f'[+] Searching Shodan for org:"{org_name}"')
|
|
try:
|
|
results = self.api.search(f'org:"{org_name}"', limit=500)
|
|
self.results.extend(results.get("matches", []))
|
|
print(f"[+] Shodan returned {results['total']} results")
|
|
except shodan.APIError as e:
|
|
print(f"[-] Shodan API error: {e}")
|
|
return self.results
|
|
|
|
def search_ssl_cert(self, domain):
|
|
"""Search Shodan for hosts with SSL certificates matching domain."""
|
|
print(f"[+] Searching Shodan for ssl.cert.subject.cn:{domain}")
|
|
try:
|
|
results = self.api.search(f"ssl.cert.subject.cn:{domain}", limit=500)
|
|
self.results.extend(results.get("matches", []))
|
|
print(f"[+] Shodan SSL cert search returned {results['total']} results")
|
|
except shodan.APIError as e:
|
|
print(f"[-] Shodan API error: {e}")
|
|
return self.results
|
|
|
|
def get_host_details(self, ip):
|
|
"""Get detailed information for a specific IP."""
|
|
try:
|
|
return self.api.host(ip)
|
|
except shodan.APIError as e:
|
|
print(f"[-] Shodan host lookup failed for {ip}: {e}")
|
|
return None
|
|
|
|
def get_all_results(self):
|
|
"""Return deduplicated results."""
|
|
seen_ips = set()
|
|
deduped = []
|
|
for result in self.results:
|
|
ip = result.get("ip_str", "")
|
|
port = result.get("port", 0)
|
|
key = f"{ip}:{port}"
|
|
if key not in seen_ips:
|
|
seen_ips.add(key)
|
|
deduped.append(result)
|
|
return deduped
|
|
|
|
|
|
class CensysScanner:
|
|
"""Discovers internet-facing assets through Censys host and cert search."""
|
|
|
|
def __init__(self, api_id, api_secret):
|
|
if CensysHosts is None:
|
|
raise ImportError("pip install censys")
|
|
self.hosts_api = CensysHosts(api_id=api_id, api_secret=api_secret)
|
|
self.certs_api = CensysCerts(api_id=api_id, api_secret=api_secret)
|
|
self.results = []
|
|
|
|
def search_hosts(self, domain, max_pages=5):
|
|
"""Search Censys for hosts matching domain."""
|
|
print(f"[+] Searching Censys hosts for {domain}")
|
|
query = f"services.tls.certificates.leaf.subject.common_name: {domain}"
|
|
try:
|
|
count = 0
|
|
for page in self.hosts_api.search(query, per_page=100, pages=max_pages):
|
|
for host in page:
|
|
self.results.append({
|
|
"ip": host.get("ip"),
|
|
"services": host.get("services", []),
|
|
"location": host.get("location", {}),
|
|
"autonomous_system": host.get("autonomous_system", {}),
|
|
"source": "censys",
|
|
})
|
|
count += 1
|
|
print(f"[+] Censys returned {count} hosts")
|
|
except Exception as e:
|
|
print(f"[-] Censys search error: {e}")
|
|
return self.results
|
|
|
|
def search_certificates(self, domain, max_pages=3):
|
|
"""Search Censys certificate transparency logs."""
|
|
print(f"[+] Searching Censys certificates for {domain}")
|
|
subdomains = set()
|
|
try:
|
|
for page in self.certs_api.search(
|
|
f"parsed.names: {domain}", per_page=100, pages=max_pages
|
|
):
|
|
for cert in page:
|
|
names = cert.get("parsed", {}).get("names", [])
|
|
for name in names:
|
|
if name.endswith(domain):
|
|
subdomains.add(name)
|
|
print(f"[+] Censys certs revealed {len(subdomains)} subdomains")
|
|
except Exception as e:
|
|
print(f"[-] Censys cert search error: {e}")
|
|
return subdomains
|
|
|
|
|
|
class VulnerabilityScanner:
|
|
"""Runs vulnerability scans using Nuclei."""
|
|
|
|
def __init__(self, targets):
|
|
self.targets = targets
|
|
self.findings = []
|
|
|
|
def run_nuclei(self, severity="critical,high", tags=None):
|
|
"""Run nuclei against targets with specified severity/tags."""
|
|
if not self.targets:
|
|
print("[-] No targets for nuclei scan")
|
|
return []
|
|
|
|
print(f"[+] Running nuclei against {len(self.targets)} targets")
|
|
input_data = "\n".join(self.targets)
|
|
cmd = ["nuclei", "-json", "-silent", "-severity", severity]
|
|
if tags:
|
|
cmd.extend(["-tags", tags])
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd, input=input_data,
|
|
capture_output=True, text=True, timeout=1800,
|
|
)
|
|
for line in result.stdout.strip().split("\n"):
|
|
if line.strip():
|
|
try:
|
|
finding = json.loads(line)
|
|
self.findings.append({
|
|
"template_id": finding.get("template-id", ""),
|
|
"name": finding.get("info", {}).get("name", ""),
|
|
"severity": finding.get("info", {}).get("severity", ""),
|
|
"host": finding.get("host", ""),
|
|
"matched_at": finding.get("matched-at", ""),
|
|
"type": finding.get("type", ""),
|
|
"description": finding.get("info", {}).get("description", ""),
|
|
"tags": finding.get("info", {}).get("tags", []),
|
|
"reference": finding.get("info", {}).get("reference", []),
|
|
"cvss_score": finding.get("info", {}).get(
|
|
"classification", {}
|
|
).get("cvss-score", 0),
|
|
"cve_id": finding.get("info", {}).get(
|
|
"classification", {}
|
|
).get("cve-id", ""),
|
|
})
|
|
except json.JSONDecodeError:
|
|
continue
|
|
print(f"[+] nuclei found {len(self.findings)} vulnerabilities")
|
|
except FileNotFoundError:
|
|
print("[-] nuclei not installed. Install: go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest")
|
|
except subprocess.TimeoutExpired:
|
|
print("[-] nuclei timed out after 1800s")
|
|
return self.findings
|
|
|
|
|
|
class ExposureScorer:
|
|
"""Calculates exposure scores using OWASP attack surface analysis principles.
|
|
|
|
The scoring algorithm implements a weighted formula derived from:
|
|
- OWASP Relative Attack Surface Quotient (RSQ)
|
|
- Carnegie Mellon damage-potential-to-effort ratio
|
|
- CVSS-based vulnerability weighting
|
|
|
|
Final score is normalized to 0-100 range.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.weights = {
|
|
"open_ports": 0.25,
|
|
"vulnerabilities": 0.30,
|
|
"technology_risk": 0.15,
|
|
"exposure_level": 0.15,
|
|
"data_sensitivity": 0.15,
|
|
}
|
|
|
|
def score_open_ports(self, ports):
|
|
"""Score based on open ports and their associated risk.
|
|
|
|
Uses PORT_RISK_WEIGHTS to assign higher scores to management ports,
|
|
databases, and legacy protocols.
|
|
"""
|
|
if not ports:
|
|
return 0.0
|
|
|
|
total_risk = 0.0
|
|
for port in ports:
|
|
weight = PORT_RISK_WEIGHTS.get(port, 4.0)
|
|
total_risk += weight
|
|
|
|
# Normalize: more ports = higher risk, but with diminishing returns
|
|
# Using log scale to prevent linear explosion with many ports
|
|
normalized = min(100.0, (total_risk / len(ports)) * 10 * math.log2(len(ports) + 1))
|
|
return round(normalized, 2)
|
|
|
|
def score_vulnerabilities(self, vulns):
|
|
"""Score based on discovered vulnerabilities weighted by CVSS.
|
|
|
|
Critical (9.0-10.0): weight 10
|
|
High (7.0-8.9): weight 7
|
|
Medium (4.0-6.9): weight 4
|
|
Low (0.1-3.9): weight 2
|
|
"""
|
|
if not vulns:
|
|
return 0.0
|
|
|
|
total_weight = 0.0
|
|
for vuln in vulns:
|
|
cvss = vuln.get("cvss_score", 0)
|
|
if isinstance(cvss, str):
|
|
try:
|
|
cvss = float(cvss)
|
|
except ValueError:
|
|
cvss = 5.0
|
|
|
|
if cvss >= 9.0:
|
|
total_weight += 10.0
|
|
elif cvss >= 7.0:
|
|
total_weight += 7.0
|
|
elif cvss >= 4.0:
|
|
total_weight += 4.0
|
|
else:
|
|
total_weight += 2.0
|
|
|
|
# Normalize with diminishing returns
|
|
normalized = min(100.0, total_weight * math.log2(len(vulns) + 1))
|
|
return round(normalized, 2)
|
|
|
|
def score_technology_risk(self, technologies):
|
|
"""Score based on technology stack risk assessment."""
|
|
if not technologies:
|
|
return 0.0
|
|
|
|
total_risk = 0.0
|
|
matched = 0
|
|
for tech in technologies:
|
|
tech_lower = tech.lower()
|
|
for known_tech, risk in HIGH_RISK_TECHNOLOGIES.items():
|
|
if known_tech in tech_lower:
|
|
total_risk += risk
|
|
matched += 1
|
|
break
|
|
|
|
if matched == 0:
|
|
return 10.0 # Unknown tech gets baseline risk
|
|
|
|
normalized = min(100.0, (total_risk / matched) * 12 * math.log2(matched + 1))
|
|
return round(normalized, 2)
|
|
|
|
def score_exposure_level(self, asset):
|
|
"""Score based on how exposed the asset is.
|
|
|
|
Factors: internet-reachable, authentication required, CDN protection.
|
|
"""
|
|
score = 50.0 # Base score for internet-facing asset
|
|
|
|
# No HTTPS = higher risk
|
|
if asset.get("scheme") == "http":
|
|
score += 15.0
|
|
|
|
# CDN protection reduces exposure
|
|
if asset.get("cdn"):
|
|
score -= 20.0
|
|
|
|
# Authentication indicators reduce exposure
|
|
status_code = asset.get("status_code", 200)
|
|
if status_code in (401, 403):
|
|
score -= 25.0
|
|
|
|
# Default/login pages increase risk
|
|
title = (asset.get("title") or "").lower()
|
|
if any(kw in title for kw in ["login", "admin", "dashboard", "panel", "console"]):
|
|
score += 20.0
|
|
|
|
return round(max(0.0, min(100.0, score)), 2)
|
|
|
|
def score_data_sensitivity(self, services, ports):
|
|
"""Score based on potential data sensitivity.
|
|
|
|
Database ports, email services, and file shares indicate sensitive data handling.
|
|
"""
|
|
score = 0.0
|
|
service_set = set()
|
|
for svc in (services or []):
|
|
service_set.add(svc.lower() if isinstance(svc, str) else "")
|
|
|
|
# Check for sensitive service indicators
|
|
for indicator in SENSITIVE_SERVICE_INDICATORS:
|
|
if indicator in service_set:
|
|
score += 15.0
|
|
|
|
# Check for database ports
|
|
db_ports = {3306, 5432, 1433, 1521, 27017, 6379, 9200}
|
|
exposed_db_ports = set(ports or []) & db_ports
|
|
score += len(exposed_db_ports) * 20.0
|
|
|
|
# File sharing ports
|
|
file_ports = {21, 445, 139, 2049}
|
|
exposed_file_ports = set(ports or []) & file_ports
|
|
score += len(exposed_file_ports) * 15.0
|
|
|
|
return round(min(100.0, score), 2)
|
|
|
|
def calculate_asset_score(self, asset):
|
|
"""Calculate the overall exposure score for an asset.
|
|
|
|
Returns a dict with component scores and weighted total (0-100).
|
|
"""
|
|
ports = asset.get("ports", [])
|
|
vulns = asset.get("vulnerabilities", [])
|
|
technologies = asset.get("technologies", [])
|
|
services = asset.get("services", [])
|
|
|
|
component_scores = {
|
|
"open_ports": self.score_open_ports(ports),
|
|
"vulnerabilities": self.score_vulnerabilities(vulns),
|
|
"technology_risk": self.score_technology_risk(technologies),
|
|
"exposure_level": self.score_exposure_level(asset),
|
|
"data_sensitivity": self.score_data_sensitivity(services, ports),
|
|
}
|
|
|
|
weighted_total = sum(
|
|
component_scores[key] * self.weights[key]
|
|
for key in self.weights
|
|
)
|
|
|
|
return {
|
|
"host": asset.get("host", asset.get("ip", "unknown")),
|
|
"total_score": round(weighted_total, 2),
|
|
"risk_level": self._risk_level(weighted_total),
|
|
"component_scores": component_scores,
|
|
"weights": self.weights,
|
|
}
|
|
|
|
def _risk_level(self, score):
|
|
if score >= 80:
|
|
return "CRITICAL"
|
|
elif score >= 60:
|
|
return "HIGH"
|
|
elif score >= 40:
|
|
return "MEDIUM"
|
|
elif score >= 20:
|
|
return "LOW"
|
|
return "INFORMATIONAL"
|
|
|
|
def score_all_assets(self, assets):
|
|
"""Score all assets and return sorted by risk."""
|
|
scored = [self.calculate_asset_score(a) for a in assets]
|
|
scored.sort(key=lambda x: x["total_score"], reverse=True)
|
|
return scored
|
|
|
|
|
|
class ASMPipeline:
|
|
"""Orchestrates the full attack surface management pipeline."""
|
|
|
|
def __init__(self, domain, shodan_key=None, censys_id=None, censys_secret=None):
|
|
self.domain = domain
|
|
self.shodan_key = shodan_key
|
|
self.censys_id = censys_id
|
|
self.censys_secret = censys_secret
|
|
self.subdomains = []
|
|
self.live_hosts = []
|
|
self.shodan_results = []
|
|
self.censys_results = []
|
|
self.nuclei_findings = []
|
|
self.assets = []
|
|
|
|
def enumerate_subdomains(self):
|
|
"""Phase 1: Discover subdomains."""
|
|
enumerator = SubdomainEnumerator(self.domain)
|
|
self.subdomains = enumerator.enumerate_all()
|
|
|
|
# Enrich with Censys certificate transparency
|
|
if self.censys_id and self.censys_secret:
|
|
try:
|
|
censys = CensysScanner(self.censys_id, self.censys_secret)
|
|
ct_subdomains = censys.search_certificates(self.domain)
|
|
combined = set(self.subdomains) | ct_subdomains
|
|
self.subdomains = sorted(combined)
|
|
print(f"[+] After CT enrichment: {len(self.subdomains)} subdomains")
|
|
except Exception as e:
|
|
print(f"[-] Censys CT search failed: {e}")
|
|
|
|
return self.subdomains
|
|
|
|
def fingerprint_services(self):
|
|
"""Phase 2: Probe live hosts and fingerprint technologies."""
|
|
fingerprinter = ServiceFingerprinter(self.subdomains)
|
|
self.live_hosts = fingerprinter.run_httpx()
|
|
return self.live_hosts
|
|
|
|
def discover_shodan(self):
|
|
"""Phase 3: Enrich with Shodan data."""
|
|
if not self.shodan_key:
|
|
print("[!] Shodan API key not provided, skipping")
|
|
return []
|
|
|
|
try:
|
|
scanner = ShodanScanner(self.shodan_key)
|
|
scanner.search_domain(self.domain)
|
|
scanner.search_ssl_cert(self.domain)
|
|
self.shodan_results = scanner.get_all_results()
|
|
except Exception as e:
|
|
print(f"[-] Shodan scanning failed: {e}")
|
|
return self.shodan_results
|
|
|
|
def discover_censys(self):
|
|
"""Phase 4: Enrich with Censys data."""
|
|
if not self.censys_id or not self.censys_secret:
|
|
print("[!] Censys API credentials not provided, skipping")
|
|
return []
|
|
|
|
try:
|
|
scanner = CensysScanner(self.censys_id, self.censys_secret)
|
|
self.censys_results = scanner.search_hosts(self.domain)
|
|
except Exception as e:
|
|
print(f"[-] Censys scanning failed: {e}")
|
|
return self.censys_results
|
|
|
|
def scan_vulnerabilities(self):
|
|
"""Phase 5: Run vulnerability scans."""
|
|
targets = []
|
|
for host in self.live_hosts:
|
|
url = host.get("url", "")
|
|
if url:
|
|
targets.append(url)
|
|
if not targets:
|
|
targets = [f"https://{sub}" for sub in self.subdomains[:100]]
|
|
|
|
scanner = VulnerabilityScanner(targets)
|
|
self.nuclei_findings = scanner.run_nuclei()
|
|
return self.nuclei_findings
|
|
|
|
def _build_asset_inventory(self):
|
|
"""Merge all data sources into a unified asset inventory."""
|
|
asset_map = defaultdict(lambda: {
|
|
"host": "",
|
|
"ip": "",
|
|
"ports": [],
|
|
"services": [],
|
|
"technologies": [],
|
|
"vulnerabilities": [],
|
|
"status_code": 200,
|
|
"title": "",
|
|
"cdn": False,
|
|
"scheme": "https",
|
|
})
|
|
|
|
# Merge httpx results
|
|
for host in self.live_hosts:
|
|
key = host.get("host", host.get("input", ""))
|
|
asset = asset_map[key]
|
|
asset["host"] = key
|
|
asset["status_code"] = host.get("status_code", 200)
|
|
asset["title"] = host.get("title", "")
|
|
asset["cdn"] = host.get("cdn", False)
|
|
asset["scheme"] = host.get("scheme", "https")
|
|
techs = host.get("tech", [])
|
|
if isinstance(techs, list):
|
|
asset["technologies"].extend(techs)
|
|
port = host.get("port", 0)
|
|
if port:
|
|
asset["ports"].append(port)
|
|
|
|
# Merge Shodan results
|
|
for result in self.shodan_results:
|
|
ip = result.get("ip_str", "")
|
|
hostnames = result.get("hostnames", [])
|
|
key = hostnames[0] if hostnames else ip
|
|
asset = asset_map[key]
|
|
asset["ip"] = ip
|
|
asset["host"] = asset["host"] or key
|
|
port = result.get("port", 0)
|
|
if port and port not in asset["ports"]:
|
|
asset["ports"].append(port)
|
|
product = result.get("product", "")
|
|
if product and product not in asset["services"]:
|
|
asset["services"].append(product)
|
|
for cve in result.get("vulns", []):
|
|
asset["vulnerabilities"].append({
|
|
"cve_id": cve,
|
|
"cvss_score": result.get("vulns", {}).get(cve, {}).get(
|
|
"cvss", 5.0
|
|
) if isinstance(result.get("vulns"), dict) else 5.0,
|
|
"source": "shodan",
|
|
})
|
|
|
|
# Merge Censys results
|
|
for result in self.censys_results:
|
|
ip = result.get("ip", "")
|
|
key = ip
|
|
asset = asset_map[key]
|
|
asset["ip"] = ip
|
|
asset["host"] = asset["host"] or ip
|
|
for svc in result.get("services", []):
|
|
port = svc.get("port", 0)
|
|
if port and port not in asset["ports"]:
|
|
asset["ports"].append(port)
|
|
svc_name = svc.get("service_name", "")
|
|
if svc_name and svc_name not in asset["services"]:
|
|
asset["services"].append(svc_name)
|
|
|
|
# Merge Nuclei findings
|
|
for finding in self.nuclei_findings:
|
|
host = finding.get("host", "")
|
|
# Match to existing asset or create new entry
|
|
matched_key = None
|
|
for key in asset_map:
|
|
if key in host or host in key:
|
|
matched_key = key
|
|
break
|
|
if matched_key is None:
|
|
matched_key = host
|
|
asset_map[matched_key]["vulnerabilities"].append({
|
|
"cve_id": finding.get("cve_id", ""),
|
|
"name": finding.get("name", ""),
|
|
"severity": finding.get("severity", ""),
|
|
"cvss_score": finding.get("cvss_score", 5.0),
|
|
"template_id": finding.get("template_id", ""),
|
|
"source": "nuclei",
|
|
})
|
|
|
|
# Deduplicate technologies and ports
|
|
for asset in asset_map.values():
|
|
asset["ports"] = sorted(set(asset["ports"]))
|
|
asset["technologies"] = list(set(asset["technologies"]))
|
|
asset["services"] = list(set(asset["services"]))
|
|
|
|
self.assets = list(asset_map.values())
|
|
return self.assets
|
|
|
|
def score_assets(self):
|
|
"""Phase 6: Calculate exposure scores for all assets."""
|
|
if not self.assets:
|
|
self._build_asset_inventory()
|
|
|
|
scorer = ExposureScorer()
|
|
return scorer.score_all_assets(self.assets)
|
|
|
|
def run_full_scan(self):
|
|
"""Execute the complete ASM pipeline."""
|
|
print(f"\n{'='*60}")
|
|
print(f" ATTACK SURFACE MANAGEMENT SCAN: {self.domain}")
|
|
print(f"{'='*60}\n")
|
|
|
|
# Phase 1: Subdomain enumeration
|
|
print("[*] Phase 1: Subdomain Enumeration")
|
|
self.enumerate_subdomains()
|
|
|
|
# Phase 2: Service fingerprinting
|
|
print("\n[*] Phase 2: Service Fingerprinting")
|
|
self.fingerprint_services()
|
|
|
|
# Phase 3: Shodan enrichment
|
|
print("\n[*] Phase 3: Shodan Asset Discovery")
|
|
self.discover_shodan()
|
|
|
|
# Phase 4: Censys enrichment
|
|
print("\n[*] Phase 4: Censys Asset Discovery")
|
|
self.discover_censys()
|
|
|
|
# Phase 5: Vulnerability scanning
|
|
print("\n[*] Phase 5: Vulnerability Scanning")
|
|
self.scan_vulnerabilities()
|
|
|
|
# Phase 6: Build inventory and score
|
|
print("\n[*] Phase 6: Asset Inventory and Exposure Scoring")
|
|
self._build_asset_inventory()
|
|
scored_assets = self.score_assets()
|
|
|
|
# Build final report
|
|
report = {
|
|
"scan_id": f"asm-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}",
|
|
"domain": self.domain,
|
|
"generated_at": datetime.utcnow().isoformat(),
|
|
"summary": {
|
|
"total_subdomains": len(self.subdomains),
|
|
"live_hosts": len(self.live_hosts),
|
|
"shodan_services": len(self.shodan_results),
|
|
"censys_hosts": len(self.censys_results),
|
|
"total_vulnerabilities": len(self.nuclei_findings),
|
|
"total_assets": len(self.assets),
|
|
"critical_assets": sum(
|
|
1 for a in scored_assets if a["risk_level"] == "CRITICAL"
|
|
),
|
|
"high_risk_assets": sum(
|
|
1 for a in scored_assets if a["risk_level"] == "HIGH"
|
|
),
|
|
"medium_risk_assets": sum(
|
|
1 for a in scored_assets if a["risk_level"] == "MEDIUM"
|
|
),
|
|
"low_risk_assets": sum(
|
|
1 for a in scored_assets if a["risk_level"] in ("LOW", "INFORMATIONAL")
|
|
),
|
|
"average_score": round(
|
|
sum(a["total_score"] for a in scored_assets) / max(len(scored_assets), 1), 2
|
|
),
|
|
},
|
|
"scored_assets": scored_assets,
|
|
"subdomains": self.subdomains,
|
|
"vulnerabilities": self.nuclei_findings,
|
|
"raw_data": {
|
|
"httpx_hosts": len(self.live_hosts),
|
|
"shodan_matches": len(self.shodan_results),
|
|
"censys_matches": len(self.censys_results),
|
|
},
|
|
}
|
|
|
|
return report
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Attack Surface Management Agent"
|
|
)
|
|
parser.add_argument("--domain", help="Target domain")
|
|
parser.add_argument("--domain-list", help="File with list of target domains")
|
|
parser.add_argument(
|
|
"--action",
|
|
required=True,
|
|
choices=["enumerate", "fingerprint", "shodan", "censys", "vuln_scan", "score", "full_scan"],
|
|
)
|
|
parser.add_argument("--shodan-key", help="Shodan API key")
|
|
parser.add_argument("--censys-id", help="Censys API ID")
|
|
parser.add_argument("--censys-secret", help="Censys API secret")
|
|
parser.add_argument("--input", help="Input file from previous scan (JSON)")
|
|
parser.add_argument("--output", default="asm_report.json")
|
|
args = parser.parse_args()
|
|
|
|
domains = []
|
|
if args.domain:
|
|
domains.append(args.domain)
|
|
elif args.domain_list:
|
|
with open(args.domain_list) as f:
|
|
domains = [line.strip() for line in f if line.strip()]
|
|
else:
|
|
print("[-] Provide --domain or --domain-list")
|
|
return
|
|
|
|
all_reports = []
|
|
for domain in domains:
|
|
pipeline = ASMPipeline(
|
|
domain=domain,
|
|
shodan_key=args.shodan_key,
|
|
censys_id=args.censys_id,
|
|
censys_secret=args.censys_secret,
|
|
)
|
|
|
|
if args.action == "enumerate":
|
|
subdomains = pipeline.enumerate_subdomains()
|
|
report = {
|
|
"domain": domain,
|
|
"subdomains": subdomains,
|
|
"count": len(subdomains),
|
|
}
|
|
elif args.action == "fingerprint":
|
|
pipeline.enumerate_subdomains()
|
|
hosts = pipeline.fingerprint_services()
|
|
report = {"domain": domain, "live_hosts": hosts, "count": len(hosts)}
|
|
elif args.action == "shodan":
|
|
results = pipeline.discover_shodan()
|
|
report = {"domain": domain, "shodan_results": results, "count": len(results)}
|
|
elif args.action == "censys":
|
|
results = pipeline.discover_censys()
|
|
report = {"domain": domain, "censys_results": results, "count": len(results)}
|
|
elif args.action == "vuln_scan":
|
|
pipeline.enumerate_subdomains()
|
|
pipeline.fingerprint_services()
|
|
findings = pipeline.scan_vulnerabilities()
|
|
report = {"domain": domain, "vulnerabilities": findings, "count": len(findings)}
|
|
elif args.action == "score":
|
|
if args.input:
|
|
with open(args.input) as f:
|
|
prev_data = json.load(f)
|
|
assets = prev_data.get("scored_assets", prev_data.get("assets", []))
|
|
scorer = ExposureScorer()
|
|
scored = scorer.score_all_assets(assets)
|
|
report = {"domain": domain, "scored_assets": scored}
|
|
else:
|
|
report = pipeline.run_full_scan()
|
|
elif args.action == "full_scan":
|
|
report = pipeline.run_full_scan()
|
|
else:
|
|
print(f"[-] Unknown action: {args.action}")
|
|
continue
|
|
|
|
all_reports.append(report)
|
|
|
|
output = all_reports[0] if len(all_reports) == 1 else {"domains": all_reports}
|
|
with open(args.output, "w") as f:
|
|
json.dump(output, f, indent=2, default=str)
|
|
print(f"\n[+] Report saved to {args.output}")
|
|
|
|
# Print summary
|
|
for report in all_reports:
|
|
if "summary" in report:
|
|
s = report["summary"]
|
|
print(f"\n{'='*60}")
|
|
print(f" ASM SUMMARY: {report.get('domain', 'N/A')}")
|
|
print(f"{'='*60}")
|
|
print(f" Subdomains discovered: {s.get('total_subdomains', 0)}")
|
|
print(f" Live hosts: {s.get('live_hosts', 0)}")
|
|
print(f" Total vulnerabilities: {s.get('total_vulnerabilities', 0)}")
|
|
print(f" Assets scored: {s.get('total_assets', 0)}")
|
|
print(f" Average exposure score: {s.get('average_score', 0)}")
|
|
print(f" CRITICAL: {s.get('critical_assets', 0)}")
|
|
print(f" HIGH: {s.get('high_risk_assets', 0)}")
|
|
print(f" MEDIUM: {s.get('medium_risk_assets', 0)}")
|
|
print(f" LOW: {s.get('low_risk_assets', 0)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|