LOT 1 - Optimizare script build -Instalare mono comanda
This commit is contained in:
parent
5380c3fc63
commit
42ff22bf85
127 changed files with 16163 additions and 532 deletions
|
|
@ -0,0 +1,297 @@
|
|||
"""
|
||||
Blacklist Checking Service - DNSBL, Spam lists, Reputation checks
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
import dns.resolver
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlacklistService:
|
||||
"""Service for checking IP/domain against various blacklists"""
|
||||
|
||||
def __init__(self):
|
||||
self.timeout = 5
|
||||
self.resolver = dns.resolver.Resolver()
|
||||
self.resolver.nameservers = ['8.8.8.8', '8.8.4.4']
|
||||
self.resolver.timeout = 3
|
||||
self.resolver.lifetime = 5
|
||||
|
||||
# DNSBL lists to check
|
||||
self.dnsbl_lists = [
|
||||
{'name': 'Spamhaus ZEN', 'zone': 'zen.spamhaus.org', 'type': 'spam'},
|
||||
{'name': 'SpamCop', 'zone': 'bl.spamcop.net', 'type': 'spam'},
|
||||
{'name': 'Barracuda', 'zone': 'b.barracudacentral.org', 'type': 'spam'},
|
||||
{'name': 'SORBS', 'zone': 'dnsbl.sorbs.net', 'type': 'spam'},
|
||||
{'name': 'URIBL', 'zone': 'multi.uribl.com', 'type': 'uri'},
|
||||
{'name': 'SURBL', 'zone': 'multi.surbl.org', 'type': 'uri'},
|
||||
{'name': 'Spamhaus DBL', 'zone': 'dbl.spamhaus.org', 'type': 'domain'},
|
||||
{'name': 'URIBL Black', 'zone': 'black.uribl.com', 'type': 'uri'}
|
||||
]
|
||||
|
||||
def check_ip(self, ip_address: str) -> Dict:
|
||||
"""
|
||||
Check IP address against multiple blacklists
|
||||
|
||||
Args:
|
||||
ip_address: IP address to check
|
||||
|
||||
Returns:
|
||||
Dictionary with blacklist check results
|
||||
"""
|
||||
result = {
|
||||
'ip': ip_address,
|
||||
'is_blacklisted': False,
|
||||
'blacklist_count': 0,
|
||||
'clean_count': 0,
|
||||
'total_checked': 0,
|
||||
'listings': [],
|
||||
'clean_lists': [],
|
||||
'check_errors': []
|
||||
}
|
||||
|
||||
# Reverse IP for DNSBL query
|
||||
reversed_ip = '.'.join(reversed(ip_address.split('.')))
|
||||
|
||||
# Check IP-based blacklists in parallel
|
||||
ip_lists = [bl for bl in self.dnsbl_lists if bl['type'] in ['spam']]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
futures = {
|
||||
executor.submit(self._check_dnsbl, reversed_ip, bl): bl
|
||||
for bl in ip_lists
|
||||
}
|
||||
|
||||
for future in as_completed(futures, timeout=15):
|
||||
bl = futures[future]
|
||||
result['total_checked'] += 1
|
||||
|
||||
try:
|
||||
is_listed, response = future.result()
|
||||
if is_listed:
|
||||
result['is_blacklisted'] = True
|
||||
result['blacklist_count'] += 1
|
||||
result['listings'].append({
|
||||
'list_name': bl['name'],
|
||||
'list_zone': bl['zone'],
|
||||
'response': response,
|
||||
'type': bl['type']
|
||||
})
|
||||
else:
|
||||
result['clean_count'] += 1
|
||||
result['clean_lists'].append(bl['name'])
|
||||
except Exception as e:
|
||||
result['check_errors'].append({
|
||||
'list': bl['name'],
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def check_domain(self, domain: str) -> Dict:
|
||||
"""
|
||||
Check domain against domain-based blacklists
|
||||
|
||||
Args:
|
||||
domain: Domain name to check
|
||||
|
||||
Returns:
|
||||
Dictionary with blacklist check results
|
||||
"""
|
||||
result = {
|
||||
'domain': domain,
|
||||
'is_blacklisted': False,
|
||||
'blacklist_count': 0,
|
||||
'clean_count': 0,
|
||||
'total_checked': 0,
|
||||
'listings': [],
|
||||
'clean_lists': [],
|
||||
'check_errors': []
|
||||
}
|
||||
|
||||
# Check domain-based blacklists
|
||||
domain_lists = [bl for bl in self.dnsbl_lists if bl['type'] in ['domain', 'uri']]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
futures = {
|
||||
executor.submit(self._check_domain_bl, domain, bl): bl
|
||||
for bl in domain_lists
|
||||
}
|
||||
|
||||
for future in as_completed(futures, timeout=15):
|
||||
bl = futures[future]
|
||||
result['total_checked'] += 1
|
||||
|
||||
try:
|
||||
is_listed, response = future.result()
|
||||
if is_listed:
|
||||
result['is_blacklisted'] = True
|
||||
result['blacklist_count'] += 1
|
||||
result['listings'].append({
|
||||
'list_name': bl['name'],
|
||||
'list_zone': bl['zone'],
|
||||
'response': response,
|
||||
'type': bl['type']
|
||||
})
|
||||
else:
|
||||
result['clean_count'] += 1
|
||||
result['clean_lists'].append(bl['name'])
|
||||
except Exception as e:
|
||||
result['check_errors'].append({
|
||||
'list': bl['name'],
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def check_all(self, domain: str, ip_address: str) -> Dict:
|
||||
"""
|
||||
Check both domain and IP against all blacklists
|
||||
|
||||
Returns:
|
||||
Combined blacklist check results
|
||||
"""
|
||||
result = {
|
||||
'domain': domain,
|
||||
'ip': ip_address,
|
||||
'is_blacklisted': False,
|
||||
'ip_blacklisted': False,
|
||||
'domain_blacklisted': False,
|
||||
'total_listings': 0,
|
||||
'ip_check': None,
|
||||
'domain_check': None,
|
||||
'reputation_score': 100, # Start with perfect score
|
||||
'risk_level': 'LOW'
|
||||
}
|
||||
|
||||
# Check IP
|
||||
if ip_address:
|
||||
ip_result = self.check_ip(ip_address)
|
||||
result['ip_check'] = ip_result
|
||||
result['ip_blacklisted'] = ip_result['is_blacklisted']
|
||||
result['total_listings'] += ip_result['blacklist_count']
|
||||
|
||||
# Check domain
|
||||
domain_result = self.check_domain(domain)
|
||||
result['domain_check'] = domain_result
|
||||
result['domain_blacklisted'] = domain_result['is_blacklisted']
|
||||
result['total_listings'] += domain_result['blacklist_count']
|
||||
|
||||
# Set overall blacklist status
|
||||
result['is_blacklisted'] = result['ip_blacklisted'] or result['domain_blacklisted']
|
||||
|
||||
# Calculate reputation score
|
||||
result['reputation_score'] = self._calculate_reputation_score(result)
|
||||
result['risk_level'] = self._get_risk_level(result['reputation_score'])
|
||||
|
||||
return result
|
||||
|
||||
def _check_dnsbl(self, reversed_ip: str, blacklist: Dict) -> tuple:
|
||||
"""Check reversed IP against a DNSBL"""
|
||||
try:
|
||||
query = f"{reversed_ip}.{blacklist['zone']}"
|
||||
answers = self.resolver.resolve(query, 'A')
|
||||
response = str(answers[0])
|
||||
|
||||
# 127.0.0.1 is an error code meaning "not authorized to query"
|
||||
# Real blacklist matches return 127.0.0.2, 127.0.0.4, etc.
|
||||
if response == '127.0.0.1':
|
||||
logger.debug(f"DNSBL {blacklist['name']} returned error code 127.0.0.1 (not authorized)")
|
||||
return False, None
|
||||
|
||||
# Valid blacklist match
|
||||
return True, response
|
||||
except dns.resolver.NXDOMAIN:
|
||||
# Not listed
|
||||
return False, None
|
||||
except dns.resolver.NoAnswer:
|
||||
return False, None
|
||||
except dns.resolver.Timeout:
|
||||
raise Exception('Timeout')
|
||||
except Exception as e:
|
||||
raise Exception(str(e))
|
||||
|
||||
def _check_domain_bl(self, domain: str, blacklist: Dict) -> tuple:
|
||||
"""Check domain against a domain blacklist"""
|
||||
try:
|
||||
query = f"{domain}.{blacklist['zone']}"
|
||||
answers = self.resolver.resolve(query, 'A')
|
||||
response = str(answers[0])
|
||||
|
||||
# 127.0.0.1 is an error code meaning "not authorized to query"
|
||||
# Real blacklist matches return 127.0.0.2, 127.0.0.4, etc.
|
||||
# This is common with URIBL when querying from unregistered resolvers
|
||||
if response == '127.0.0.1':
|
||||
logger.debug(f"Domain BL {blacklist['name']} returned error code 127.0.0.1 (not authorized)")
|
||||
return False, None
|
||||
|
||||
# Valid blacklist match
|
||||
return True, response
|
||||
except dns.resolver.NXDOMAIN:
|
||||
return False, None
|
||||
except dns.resolver.NoAnswer:
|
||||
return False, None
|
||||
except dns.resolver.Timeout:
|
||||
raise Exception('Timeout')
|
||||
except Exception as e:
|
||||
raise Exception(str(e))
|
||||
|
||||
def _calculate_reputation_score(self, result: Dict) -> int:
|
||||
"""
|
||||
Calculate reputation score (100 = perfect, 0 = worst)
|
||||
|
||||
Each blacklist listing reduces the score
|
||||
"""
|
||||
score = 100
|
||||
|
||||
# IP blacklist hits are more severe
|
||||
if result.get('ip_check'):
|
||||
ip_listings = result['ip_check'].get('blacklist_count', 0)
|
||||
score -= ip_listings * 25 # -25 per IP listing
|
||||
|
||||
# Domain blacklist hits
|
||||
if result.get('domain_check'):
|
||||
domain_listings = result['domain_check'].get('blacklist_count', 0)
|
||||
score -= domain_listings * 20 # -20 per domain listing
|
||||
|
||||
return max(0, min(100, score))
|
||||
|
||||
def _get_risk_level(self, score: int) -> str:
|
||||
"""Determine risk level from reputation score"""
|
||||
if score >= 80:
|
||||
return 'LOW'
|
||||
elif score >= 60:
|
||||
return 'MEDIUM'
|
||||
elif score >= 40:
|
||||
return 'HIGH'
|
||||
else:
|
||||
return 'CRITICAL'
|
||||
|
||||
def get_blacklist_score(self, blacklist_data: Dict) -> Dict:
|
||||
"""
|
||||
Get blacklist score for risk calculation (0 = good, 100 = bad)
|
||||
|
||||
This inverts the reputation score for consistency with other risk scores
|
||||
"""
|
||||
reputation = blacklist_data.get('reputation_score', 100)
|
||||
score = 100 - reputation # Invert: 100 reputation = 0 risk
|
||||
|
||||
reasons = []
|
||||
if blacklist_data.get('is_blacklisted'):
|
||||
if blacklist_data.get('ip_blacklisted'):
|
||||
reasons.append(f"IP on {blacklist_data['ip_check']['blacklist_count']} blacklist(s)")
|
||||
if blacklist_data.get('domain_blacklisted'):
|
||||
reasons.append(f"Domain on {blacklist_data['domain_check']['blacklist_count']} blacklist(s)")
|
||||
else:
|
||||
reasons.append('Not on any blacklists')
|
||||
|
||||
return {
|
||||
'score': score,
|
||||
'reasons': reasons,
|
||||
'is_clean': score == 0,
|
||||
'is_blacklisted': blacklist_data.get('is_blacklisted', False)
|
||||
}
|
||||
163
ai_platform/modules/domain_check/api/app/services/dns_service.py
Normal file
163
ai_platform/modules/domain_check/api/app/services/dns_service.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""
|
||||
DNS Service - handles DNS record lookups
|
||||
"""
|
||||
import logging
|
||||
import dns.resolver
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DNSService:
|
||||
"""DNS lookup service class"""
|
||||
|
||||
def __init__(self, timeout: int = 10):
|
||||
self.timeout = timeout
|
||||
self.resolver = dns.resolver.Resolver()
|
||||
self.resolver.timeout = timeout
|
||||
self.resolver.lifetime = timeout
|
||||
# Use public DNS servers (Google DNS) for reliability
|
||||
self.resolver.nameservers = ['8.8.8.8', '8.8.4.4']
|
||||
|
||||
def lookup(self, domain: str) -> Optional[Dict]:
|
||||
"""
|
||||
Perform comprehensive DNS lookup
|
||||
|
||||
Args:
|
||||
domain: Domain name to lookup
|
||||
|
||||
Returns:
|
||||
Dictionary with DNS records or None
|
||||
"""
|
||||
try:
|
||||
logger.info(f'DNS lookup for: {domain}')
|
||||
|
||||
dns_data = {
|
||||
'domain': domain,
|
||||
'a_records': self._get_a_records(domain),
|
||||
'aaaa_records': self._get_aaaa_records(domain),
|
||||
'mx_records': self._get_mx_records(domain),
|
||||
'txt_records': self._get_txt_records(domain),
|
||||
'ns_records': self._get_ns_records(domain),
|
||||
'cname_records': self._get_cname_records(domain),
|
||||
'soa_record': self._get_soa_record(domain),
|
||||
'has_a_records': False,
|
||||
'has_mx_records': False,
|
||||
'has_txt_records': False,
|
||||
'has_spf': False,
|
||||
'has_dkim': False,
|
||||
'has_dmarc': False
|
||||
}
|
||||
|
||||
# Set boolean flags
|
||||
dns_data['has_a_records'] = len(dns_data['a_records']) > 0
|
||||
dns_data['has_mx_records'] = len(dns_data['mx_records']) > 0
|
||||
dns_data['has_txt_records'] = len(dns_data['txt_records']) > 0
|
||||
|
||||
# Check for email security records
|
||||
for txt in dns_data['txt_records']:
|
||||
if txt.startswith('v=spf1'):
|
||||
dns_data['has_spf'] = True
|
||||
if 'dkim' in txt.lower():
|
||||
dns_data['has_dkim'] = True
|
||||
if 'v=DMARC1' in txt:
|
||||
dns_data['has_dmarc'] = True
|
||||
|
||||
return dns_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'DNS lookup failed for {domain}: {str(e)}')
|
||||
return None
|
||||
|
||||
def _get_a_records(self, domain: str) -> List[str]:
|
||||
"""Get A records (IPv4)"""
|
||||
try:
|
||||
answers = self.resolver.resolve(domain, 'A')
|
||||
return [str(rdata) for rdata in answers]
|
||||
except Exception as e:
|
||||
logger.debug(f'No A records for {domain}: {e}')
|
||||
return []
|
||||
|
||||
def _get_aaaa_records(self, domain: str) -> List[str]:
|
||||
"""Get AAAA records (IPv6)"""
|
||||
try:
|
||||
answers = self.resolver.resolve(domain, 'AAAA')
|
||||
return [str(rdata) for rdata in answers]
|
||||
except Exception as e:
|
||||
logger.debug(f'No AAAA records for {domain}: {e}')
|
||||
return []
|
||||
|
||||
def _get_mx_records(self, domain: str) -> List[Dict]:
|
||||
"""Get MX records (Mail servers)"""
|
||||
try:
|
||||
answers = self.resolver.resolve(domain, 'MX')
|
||||
return [
|
||||
{
|
||||
'priority': rdata.preference,
|
||||
'host': str(rdata.exchange).rstrip('.')
|
||||
}
|
||||
for rdata in answers
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug(f'No MX records for {domain}: {e}')
|
||||
return []
|
||||
|
||||
def _get_txt_records(self, domain: str) -> List[str]:
|
||||
"""Get TXT records"""
|
||||
try:
|
||||
answers = self.resolver.resolve(domain, 'TXT')
|
||||
records = []
|
||||
for rdata in answers:
|
||||
# TXT records can be split into multiple strings
|
||||
txt = ''.join([s.decode('utf-8') if isinstance(s, bytes) else str(s) for s in rdata.strings])
|
||||
records.append(txt)
|
||||
return records
|
||||
except Exception as e:
|
||||
logger.debug(f'No TXT records for {domain}: {e}')
|
||||
return []
|
||||
|
||||
def _get_ns_records(self, domain: str) -> List[str]:
|
||||
"""Get NS records (Name servers)"""
|
||||
try:
|
||||
answers = self.resolver.resolve(domain, 'NS')
|
||||
return [str(rdata).rstrip('.') for rdata in answers]
|
||||
except Exception as e:
|
||||
logger.debug(f'No NS records for {domain}: {e}')
|
||||
return []
|
||||
|
||||
def _get_cname_records(self, domain: str) -> List[str]:
|
||||
"""Get CNAME records"""
|
||||
try:
|
||||
answers = self.resolver.resolve(domain, 'CNAME')
|
||||
return [str(rdata).rstrip('.') for rdata in answers]
|
||||
except Exception as e:
|
||||
logger.debug(f'No CNAME records for {domain}: {e}')
|
||||
return []
|
||||
|
||||
def _get_soa_record(self, domain: str) -> Optional[Dict]:
|
||||
"""Get SOA record (Start of Authority)"""
|
||||
try:
|
||||
answers = self.resolver.resolve(domain, 'SOA')
|
||||
if answers:
|
||||
soa = answers[0]
|
||||
return {
|
||||
'mname': str(soa.mname).rstrip('.'),
|
||||
'rname': str(soa.rname).rstrip('.'),
|
||||
'serial': soa.serial,
|
||||
'refresh': soa.refresh,
|
||||
'retry': soa.retry,
|
||||
'expire': soa.expire,
|
||||
'minimum': soa.minimum
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f'No SOA record for {domain}: {e}')
|
||||
return None
|
||||
|
||||
def check_dnssec(self, domain: str) -> bool:
|
||||
"""Check if DNSSEC is enabled"""
|
||||
try:
|
||||
# Try to get DNSKEY records
|
||||
self.resolver.resolve(domain, 'DNSKEY')
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
"""
|
||||
HTTP Analysis Service - Headers, Technology Detection, Security Headers
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HTTPAnalysisService:
|
||||
"""Service for analyzing HTTP responses and detecting technologies"""
|
||||
|
||||
def __init__(self):
|
||||
self.timeout = 15
|
||||
self.user_agent = 'Mozilla/5.0 (compatible; DomainCheck/1.0; +https://domain-check.local)'
|
||||
|
||||
def analyze(self, domain: str) -> Dict:
|
||||
"""
|
||||
Perform comprehensive HTTP analysis
|
||||
|
||||
Args:
|
||||
domain: Domain name to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary with HTTP analysis results
|
||||
"""
|
||||
result = {
|
||||
'domain': domain,
|
||||
'http_status': None,
|
||||
'https_status': None,
|
||||
'has_https': False,
|
||||
'http_to_https_redirect': False,
|
||||
'final_url': None,
|
||||
'redirect_chain': [],
|
||||
'response_time_ms': None,
|
||||
'server': None,
|
||||
'powered_by': None,
|
||||
'security_headers': {},
|
||||
'missing_security_headers': [],
|
||||
'cookies': [],
|
||||
'technologies': [],
|
||||
'cms': None,
|
||||
'frameworks': [],
|
||||
'has_robots_txt': False,
|
||||
'has_sitemap': False,
|
||||
'has_favicon': False,
|
||||
'error': None
|
||||
}
|
||||
|
||||
try:
|
||||
# Test HTTPS first
|
||||
https_result = self._check_url(f'https://{domain}')
|
||||
if https_result.get('success'):
|
||||
result['has_https'] = True
|
||||
result['https_status'] = https_result.get('status_code')
|
||||
result['final_url'] = https_result.get('final_url')
|
||||
result['redirect_chain'] = https_result.get('redirect_chain', [])
|
||||
result['response_time_ms'] = https_result.get('response_time_ms')
|
||||
result['server'] = https_result.get('server')
|
||||
result['powered_by'] = https_result.get('powered_by')
|
||||
result['security_headers'] = https_result.get('security_headers', {})
|
||||
result['missing_security_headers'] = https_result.get('missing_security_headers', [])
|
||||
result['cookies'] = https_result.get('cookies', [])
|
||||
|
||||
# Detect technologies from response
|
||||
if https_result.get('body'):
|
||||
tech = self._detect_technologies(https_result['body'], https_result.get('headers', {}))
|
||||
result['technologies'] = tech.get('technologies', [])
|
||||
result['cms'] = tech.get('cms')
|
||||
result['frameworks'] = tech.get('frameworks', [])
|
||||
|
||||
# Test HTTP
|
||||
http_result = self._check_url(f'http://{domain}')
|
||||
if http_result.get('success'):
|
||||
result['http_status'] = http_result.get('status_code')
|
||||
# Check if HTTP redirects to HTTPS
|
||||
if http_result.get('final_url', '').startswith('https://'):
|
||||
result['http_to_https_redirect'] = True
|
||||
|
||||
# Check for common files
|
||||
result['has_robots_txt'] = self._check_file_exists(f'https://{domain}/robots.txt')
|
||||
result['has_sitemap'] = self._check_file_exists(f'https://{domain}/sitemap.xml')
|
||||
result['has_favicon'] = self._check_file_exists(f'https://{domain}/favicon.ico')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"HTTP analysis failed for {domain}: {e}")
|
||||
result['error'] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def _check_url(self, url: str) -> Dict:
|
||||
"""Check a URL and gather response data"""
|
||||
result = {
|
||||
'success': False,
|
||||
'url': url,
|
||||
'status_code': None,
|
||||
'final_url': None,
|
||||
'redirect_chain': [],
|
||||
'response_time_ms': None,
|
||||
'server': None,
|
||||
'powered_by': None,
|
||||
'security_headers': {},
|
||||
'missing_security_headers': [],
|
||||
'cookies': [],
|
||||
'headers': {},
|
||||
'body': None
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=self.timeout,
|
||||
allow_redirects=True,
|
||||
headers={'User-Agent': self.user_agent},
|
||||
verify=True
|
||||
)
|
||||
|
||||
result['success'] = True
|
||||
result['status_code'] = response.status_code
|
||||
result['final_url'] = response.url
|
||||
result['response_time_ms'] = int(response.elapsed.total_seconds() * 1000)
|
||||
result['headers'] = dict(response.headers)
|
||||
|
||||
# Get redirect chain
|
||||
if response.history:
|
||||
result['redirect_chain'] = [
|
||||
{'url': r.url, 'status': r.status_code}
|
||||
for r in response.history
|
||||
]
|
||||
|
||||
# Extract server info
|
||||
result['server'] = response.headers.get('Server')
|
||||
result['powered_by'] = response.headers.get('X-Powered-By')
|
||||
|
||||
# Analyze security headers
|
||||
result['security_headers'], result['missing_security_headers'] = \
|
||||
self._analyze_security_headers(response.headers)
|
||||
|
||||
# Analyze cookies
|
||||
result['cookies'] = self._analyze_cookies(response.cookies)
|
||||
|
||||
# Get body for technology detection
|
||||
result['body'] = response.text[:50000] # Limit body size
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
result['error'] = f'SSL Error: {str(e)}'
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
result['error'] = f'Connection Error: {str(e)}'
|
||||
except requests.exceptions.Timeout:
|
||||
result['error'] = 'Timeout'
|
||||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def _analyze_security_headers(self, headers) -> tuple:
|
||||
"""Analyze security headers"""
|
||||
security_headers = {}
|
||||
missing = []
|
||||
|
||||
# Define required security headers
|
||||
required_headers = {
|
||||
'Strict-Transport-Security': 'HSTS - Forces HTTPS',
|
||||
'X-Frame-Options': 'Prevents clickjacking',
|
||||
'X-Content-Type-Options': 'Prevents MIME sniffing',
|
||||
'X-XSS-Protection': 'XSS filtering (legacy)',
|
||||
'Content-Security-Policy': 'CSP - Controls resource loading',
|
||||
'Referrer-Policy': 'Controls referrer information',
|
||||
'Permissions-Policy': 'Controls browser features'
|
||||
}
|
||||
|
||||
for header, description in required_headers.items():
|
||||
value = headers.get(header)
|
||||
if value:
|
||||
security_headers[header] = {
|
||||
'value': value,
|
||||
'description': description,
|
||||
'present': True
|
||||
}
|
||||
else:
|
||||
missing.append({
|
||||
'header': header,
|
||||
'description': description,
|
||||
'severity': self._get_header_severity(header)
|
||||
})
|
||||
|
||||
return security_headers, missing
|
||||
|
||||
def _get_header_severity(self, header: str) -> str:
|
||||
"""Get severity level for missing header"""
|
||||
critical = ['Strict-Transport-Security', 'Content-Security-Policy']
|
||||
high = ['X-Frame-Options', 'X-Content-Type-Options']
|
||||
|
||||
if header in critical:
|
||||
return 'CRITICAL'
|
||||
elif header in high:
|
||||
return 'HIGH'
|
||||
return 'MEDIUM'
|
||||
|
||||
def _analyze_cookies(self, cookies) -> List[Dict]:
|
||||
"""Analyze cookies for security attributes"""
|
||||
analyzed = []
|
||||
|
||||
for cookie in cookies:
|
||||
cookie_info = {
|
||||
'name': cookie.name,
|
||||
'secure': cookie.secure,
|
||||
'httponly': cookie.has_nonstandard_attr('HttpOnly'),
|
||||
'samesite': cookie.get_nonstandard_attr('SameSite'),
|
||||
'issues': []
|
||||
}
|
||||
|
||||
# Check for security issues
|
||||
if not cookie.secure:
|
||||
cookie_info['issues'].append('Missing Secure flag')
|
||||
if not cookie_info['httponly']:
|
||||
cookie_info['issues'].append('Missing HttpOnly flag')
|
||||
if not cookie_info['samesite']:
|
||||
cookie_info['issues'].append('Missing SameSite attribute')
|
||||
|
||||
analyzed.append(cookie_info)
|
||||
|
||||
return analyzed
|
||||
|
||||
def _detect_technologies(self, body: str, headers: Dict) -> Dict:
|
||||
"""Detect technologies from response body and headers"""
|
||||
result = {
|
||||
'technologies': [],
|
||||
'cms': None,
|
||||
'frameworks': []
|
||||
}
|
||||
|
||||
body_lower = body.lower()
|
||||
|
||||
# CMS Detection
|
||||
cms_patterns = {
|
||||
'WordPress': [
|
||||
'wp-content', 'wp-includes', 'wordpress',
|
||||
'<meta name="generator" content="WordPress'
|
||||
],
|
||||
'Joomla': ['joomla', '/media/jui/', '/components/com_'],
|
||||
'Drupal': ['drupal', '/sites/default/files/', 'Drupal.settings'],
|
||||
'Magento': ['magento', 'mage/', '/skin/frontend/'],
|
||||
'Shopify': ['shopify', 'cdn.shopify.com'],
|
||||
'Wix': ['wix.com', '_wix_browser_sess'],
|
||||
'Squarespace': ['squarespace', 'static.squarespace.com']
|
||||
}
|
||||
|
||||
for cms, patterns in cms_patterns.items():
|
||||
for pattern in patterns:
|
||||
if pattern.lower() in body_lower:
|
||||
result['cms'] = cms
|
||||
result['technologies'].append(cms)
|
||||
break
|
||||
if result['cms']:
|
||||
break
|
||||
|
||||
# Framework detection
|
||||
framework_patterns = {
|
||||
'React': ['react', '_reactRootContainer', 'data-reactroot'],
|
||||
'Vue.js': ['vue', 'data-v-', '__vue__'],
|
||||
'Angular': ['ng-', 'angular', 'ng-app', 'ng-controller'],
|
||||
'jQuery': ['jquery', 'jQuery'],
|
||||
'Bootstrap': ['bootstrap', 'class="container', 'class="row'],
|
||||
'Tailwind CSS': ['tailwind', 'class="flex', 'class="grid'],
|
||||
'Laravel': ['laravel', 'csrf-token'],
|
||||
'Django': ['csrfmiddlewaretoken', 'django'],
|
||||
'Express': ['express'],
|
||||
'Next.js': ['next.js', '__NEXT_DATA__', '_next/'],
|
||||
'Nuxt.js': ['nuxt', '__NUXT__']
|
||||
}
|
||||
|
||||
for framework, patterns in framework_patterns.items():
|
||||
for pattern in patterns:
|
||||
if pattern.lower() in body_lower:
|
||||
if framework not in result['frameworks']:
|
||||
result['frameworks'].append(framework)
|
||||
result['technologies'].append(framework)
|
||||
break
|
||||
|
||||
# Additional technologies from meta tags
|
||||
generator_match = re.search(r'<meta[^>]+name=["\']generator["\'][^>]+content=["\']([^"\']+)["\']', body, re.I)
|
||||
if generator_match:
|
||||
generator = generator_match.group(1)
|
||||
result['technologies'].append(f'Generator: {generator}')
|
||||
|
||||
# Server-side detection from headers
|
||||
server = headers.get('Server', '').lower()
|
||||
if 'nginx' in server:
|
||||
result['technologies'].append('Nginx')
|
||||
elif 'apache' in server:
|
||||
result['technologies'].append('Apache')
|
||||
elif 'iis' in server:
|
||||
result['technologies'].append('Microsoft IIS')
|
||||
|
||||
powered_by = headers.get('X-Powered-By', '').lower()
|
||||
if 'php' in powered_by:
|
||||
result['technologies'].append('PHP')
|
||||
elif 'asp.net' in powered_by:
|
||||
result['technologies'].append('ASP.NET')
|
||||
|
||||
return result
|
||||
|
||||
def _check_file_exists(self, url: str) -> bool:
|
||||
"""Check if a file exists at URL"""
|
||||
try:
|
||||
response = requests.head(
|
||||
url,
|
||||
timeout=5,
|
||||
allow_redirects=True,
|
||||
headers={'User-Agent': self.user_agent}
|
||||
)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def get_security_score(self, http_data: Dict) -> Dict:
|
||||
"""
|
||||
Calculate HTTP security score
|
||||
|
||||
Returns:
|
||||
Dict with score (0-100) and reasons
|
||||
"""
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
# No HTTPS = very bad
|
||||
if not http_data.get('has_https'):
|
||||
score += 40
|
||||
reasons.append('No HTTPS support')
|
||||
|
||||
# No HTTP to HTTPS redirect
|
||||
if http_data.get('has_https') and not http_data.get('http_to_https_redirect'):
|
||||
score += 15
|
||||
reasons.append('No HTTP to HTTPS redirect')
|
||||
|
||||
# Missing security headers
|
||||
missing_headers = http_data.get('missing_security_headers', [])
|
||||
for header in missing_headers:
|
||||
if header.get('severity') == 'CRITICAL':
|
||||
score += 15
|
||||
reasons.append(f"Missing: {header['header']}")
|
||||
elif header.get('severity') == 'HIGH':
|
||||
score += 10
|
||||
reasons.append(f"Missing: {header['header']}")
|
||||
else:
|
||||
score += 5
|
||||
|
||||
# Cookie issues
|
||||
cookies = http_data.get('cookies', [])
|
||||
for cookie in cookies:
|
||||
if cookie.get('issues'):
|
||||
score += 5 * len(cookie['issues'])
|
||||
for issue in cookie['issues']:
|
||||
reasons.append(f"Cookie '{cookie['name']}': {issue}")
|
||||
|
||||
if score == 0:
|
||||
reasons.append('Good HTTP security configuration')
|
||||
|
||||
return {
|
||||
'score': min(100, score),
|
||||
'reasons': reasons[:10], # Limit reasons
|
||||
'is_secure': score <= 20,
|
||||
'needs_improvement': score > 20 and score <= 50,
|
||||
'is_insecure': score > 50
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
"""
|
||||
IP Intelligence Service - Geolocation, ASN, Reverse DNS, Hosting Info
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
import requests
|
||||
from typing import Dict, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IPIntelligenceService:
|
||||
"""Service for gathering IP intelligence data"""
|
||||
|
||||
def __init__(self):
|
||||
self.timeout = 10
|
||||
self.ipinfo_url = "https://ipinfo.io/{ip}/json"
|
||||
|
||||
def lookup(self, ip_address: str) -> Dict:
|
||||
"""
|
||||
Get comprehensive IP intelligence
|
||||
|
||||
Args:
|
||||
ip_address: IP address to lookup
|
||||
|
||||
Returns:
|
||||
Dictionary with IP intelligence data
|
||||
"""
|
||||
result = {
|
||||
'ip': ip_address,
|
||||
'reverse_dns': None,
|
||||
'geolocation': None,
|
||||
'asn': None,
|
||||
'isp': None,
|
||||
'organization': None,
|
||||
'is_datacenter': False,
|
||||
'is_residential': False,
|
||||
'hostname': None,
|
||||
'city': None,
|
||||
'region': None,
|
||||
'country': None,
|
||||
'country_code': None,
|
||||
'coordinates': None,
|
||||
'timezone': None,
|
||||
'data_source': 'ipinfo.io'
|
||||
}
|
||||
|
||||
try:
|
||||
# Run lookups in parallel
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = {
|
||||
executor.submit(self._get_reverse_dns, ip_address): 'reverse_dns',
|
||||
executor.submit(self._get_ipinfo, ip_address): 'ipinfo'
|
||||
}
|
||||
|
||||
for future in as_completed(futures, timeout=15):
|
||||
lookup_type = futures[future]
|
||||
try:
|
||||
data = future.result()
|
||||
if lookup_type == 'reverse_dns':
|
||||
result['reverse_dns'] = data
|
||||
result['hostname'] = data
|
||||
elif lookup_type == 'ipinfo' and data:
|
||||
result.update(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"IP lookup {lookup_type} failed: {e}")
|
||||
|
||||
# Determine if datacenter or residential
|
||||
result['is_datacenter'] = self._is_datacenter(result)
|
||||
result['is_residential'] = not result['is_datacenter']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"IP intelligence lookup failed for {ip_address}: {e}")
|
||||
result['error'] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def _get_reverse_dns(self, ip_address: str) -> Optional[str]:
|
||||
"""Get reverse DNS (PTR record)"""
|
||||
try:
|
||||
hostname, _, _ = socket.gethostbyaddr(ip_address)
|
||||
return hostname
|
||||
except (socket.herror, socket.gaierror):
|
||||
return None
|
||||
|
||||
def _get_ipinfo(self, ip_address: str) -> Optional[Dict]:
|
||||
"""Get IP info from ipinfo.io"""
|
||||
try:
|
||||
response = requests.get(
|
||||
self.ipinfo_url.format(ip=ip_address),
|
||||
timeout=self.timeout
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Parse ASN from org field (format: "AS8708 DIGI ROMANIA S.A.")
|
||||
org = data.get('org', '')
|
||||
asn = None
|
||||
isp = None
|
||||
if org:
|
||||
parts = org.split(' ', 1)
|
||||
if parts[0].startswith('AS'):
|
||||
asn = parts[0]
|
||||
isp = parts[1] if len(parts) > 1 else None
|
||||
else:
|
||||
isp = org
|
||||
|
||||
# Parse coordinates
|
||||
loc = data.get('loc', '')
|
||||
coordinates = None
|
||||
if loc:
|
||||
try:
|
||||
lat, lon = loc.split(',')
|
||||
coordinates = {
|
||||
'latitude': float(lat),
|
||||
'longitude': float(lon)
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
'hostname': data.get('hostname'),
|
||||
'city': data.get('city'),
|
||||
'region': data.get('region'),
|
||||
'country': self._get_country_name(data.get('country')),
|
||||
'country_code': data.get('country'),
|
||||
'coordinates': coordinates,
|
||||
'timezone': data.get('timezone'),
|
||||
'asn': asn,
|
||||
'isp': isp,
|
||||
'organization': isp,
|
||||
'postal': data.get('postal')
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"ipinfo.io lookup failed: {e}")
|
||||
return None
|
||||
|
||||
def _is_datacenter(self, data: Dict) -> bool:
|
||||
"""Determine if IP is likely a datacenter/hosting IP"""
|
||||
indicators = []
|
||||
|
||||
# Check ISP/organization for hosting keywords
|
||||
org = (data.get('organization') or '').lower()
|
||||
isp = (data.get('isp') or '').lower()
|
||||
hostname = (data.get('hostname') or '').lower()
|
||||
|
||||
hosting_keywords = [
|
||||
'hosting', 'server', 'cloud', 'datacenter', 'data center',
|
||||
'hetzner', 'ovh', 'digitalocean', 'linode', 'vultr', 'aws',
|
||||
'amazon', 'google', 'microsoft', 'azure', 'contabo', 'hostinger',
|
||||
'godaddy', 'bluehost', 'namecheap', 'maghost', 'simpliq', 'host365'
|
||||
]
|
||||
|
||||
for keyword in hosting_keywords:
|
||||
if keyword in org or keyword in isp:
|
||||
return True
|
||||
|
||||
# Check hostname patterns
|
||||
if hostname:
|
||||
hosting_patterns = ['static', 'vps', 'server', 'host', 'cloud', 'dedicated']
|
||||
for pattern in hosting_patterns:
|
||||
if pattern in hostname:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_country_name(self, code: str) -> str:
|
||||
"""Convert country code to name"""
|
||||
countries = {
|
||||
'RO': 'Romania',
|
||||
'US': 'United States',
|
||||
'GB': 'United Kingdom',
|
||||
'DE': 'Germany',
|
||||
'FR': 'France',
|
||||
'NL': 'Netherlands',
|
||||
'UA': 'Ukraine',
|
||||
'RU': 'Russia',
|
||||
'CN': 'China',
|
||||
'IN': 'India',
|
||||
'JP': 'Japan',
|
||||
'BR': 'Brazil',
|
||||
'CA': 'Canada',
|
||||
'AU': 'Australia'
|
||||
}
|
||||
return countries.get(code, code)
|
||||
|
||||
def get_hosting_score(self, ip_data: Dict) -> Dict:
|
||||
"""
|
||||
Calculate hosting provider reputation score
|
||||
|
||||
Returns:
|
||||
Dict with score (0-100) and reasons
|
||||
"""
|
||||
score = 50 # Neutral baseline
|
||||
reasons = []
|
||||
|
||||
isp = (ip_data.get('isp') or '').lower()
|
||||
country_code = ip_data.get('country_code', '')
|
||||
|
||||
# Trusted hosting providers (lower score = better)
|
||||
trusted_hosts = ['google', 'amazon', 'microsoft', 'cloudflare', 'akamai']
|
||||
for host in trusted_hosts:
|
||||
if host in isp:
|
||||
score = 20
|
||||
reasons.append(f'Trusted provider: {isp}')
|
||||
break
|
||||
|
||||
# Romanian ISPs (neutral)
|
||||
ro_isps = ['digi', 'rcs', 'rds', 'telekom', 'orange', 'vodafone']
|
||||
for isp_name in ro_isps:
|
||||
if isp_name in isp:
|
||||
score = 40
|
||||
reasons.append(f'Romanian ISP: {isp}')
|
||||
break
|
||||
|
||||
# Check for reverse DNS mismatch (higher risk)
|
||||
if ip_data.get('is_datacenter') and not ip_data.get('reverse_dns'):
|
||||
score += 20
|
||||
reasons.append('Datacenter IP without reverse DNS')
|
||||
|
||||
# High-risk countries
|
||||
high_risk_countries = ['RU', 'CN', 'KP', 'IR', 'NG']
|
||||
if country_code in high_risk_countries:
|
||||
score += 30
|
||||
reasons.append(f'High-risk country: {country_code}')
|
||||
|
||||
return {
|
||||
'score': min(100, max(0, score)),
|
||||
'reasons': reasons,
|
||||
'is_trusted': score <= 30,
|
||||
'is_suspicious': score >= 70
|
||||
}
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
"""
|
||||
Mail Intelligence Service - deep analysis of a domain's email infrastructure.
|
||||
|
||||
Covers what a plain MX/TXT dump does not:
|
||||
- SPF parsing (policy qualifier, include chain, DNS-lookup budget per RFC 7208)
|
||||
- DMARC policy (_dmarc) parsing (p/sp/pct/rua/aspf/adkim)
|
||||
- DKIM selector discovery (probes common selectors)
|
||||
- MX provider fingerprinting + STARTTLS reachability
|
||||
- MTA-STS (RFC 8461), TLS-RPT (RFC 8460), DANE/TLSA (RFC 7672)
|
||||
- Optional SMTP-level mailbox + catch-all probing (RCPT TO), degrades
|
||||
gracefully when outbound port 25 is blocked.
|
||||
|
||||
Everything is best-effort and never raises: a failed probe becomes a recorded
|
||||
'unknown'/False signal, so a single hiccup cannot 500 the parent request.
|
||||
"""
|
||||
import logging
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import dns.resolver
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Common DKIM selectors used by major providers / tooling.
|
||||
COMMON_DKIM_SELECTORS = [
|
||||
'default', 'google', 'selector1', 'selector2', 'k1', 'k2', 'dkim',
|
||||
'mail', 'smtp', 's1', 's2', 'mandrill', 'mailjet', 'sendgrid',
|
||||
'zoho', 'protonmail', 'protonmail2', 'fm1', 'fm2', 'fm3', 'mxvault',
|
||||
]
|
||||
|
||||
# MX hostname substrings -> human provider name.
|
||||
MX_PROVIDERS = [
|
||||
('google.com', 'Google Workspace'),
|
||||
('googlemail.com', 'Google Workspace'),
|
||||
('outlook.com', 'Microsoft 365'),
|
||||
('protection.outlook', 'Microsoft 365'),
|
||||
('zoho', 'Zoho Mail'),
|
||||
('protonmail', 'Proton Mail'),
|
||||
('proton.me', 'Proton Mail'),
|
||||
('mail.ru', 'Mail.ru'),
|
||||
('yandex', 'Yandex Mail'),
|
||||
('mimecast', 'Mimecast'),
|
||||
('pphosted', 'Proofpoint'),
|
||||
('messagelabs', 'Broadcom/Symantec'),
|
||||
('mailgun', 'Mailgun'),
|
||||
('sendgrid', 'SendGrid'),
|
||||
('amazonaws', 'Amazon SES/WorkMail'),
|
||||
('secureserver.net', 'GoDaddy'),
|
||||
('one.com', 'one.com'),
|
||||
('hostinger', 'Hostinger'),
|
||||
('gandi', 'Gandi'),
|
||||
('ovh', 'OVH'),
|
||||
('rotld', 'ROTLD'),
|
||||
]
|
||||
|
||||
|
||||
class MailIntelligenceService:
|
||||
"""Deep email-infrastructure analysis for a domain."""
|
||||
|
||||
def __init__(self, dns_timeout: int = 6, smtp_timeout: int = 8, http_timeout: int = 6):
|
||||
self.smtp_timeout = smtp_timeout
|
||||
self.http_timeout = http_timeout
|
||||
self.resolver = dns.resolver.Resolver()
|
||||
self.resolver.timeout = dns_timeout
|
||||
self.resolver.lifetime = dns_timeout
|
||||
self.resolver.nameservers = ['8.8.8.8', '1.1.1.1']
|
||||
|
||||
# -- public ------------------------------------------------------------
|
||||
|
||||
def analyze(self, domain: str, mx_records: Optional[List[Dict]] = None,
|
||||
txt_records: Optional[List[str]] = None,
|
||||
check_smtp: bool = False) -> Dict:
|
||||
domain = domain.lower().strip()
|
||||
if mx_records is None:
|
||||
mx_records = self._resolve_mx(domain)
|
||||
if txt_records is None:
|
||||
txt_records = self._resolve_txt(domain)
|
||||
|
||||
spf = self._analyze_spf(domain, txt_records)
|
||||
dmarc = self._analyze_dmarc(domain)
|
||||
dkim = self._discover_dkim(domain)
|
||||
mta_sts = self._analyze_mta_sts(domain)
|
||||
tls_rpt = self._analyze_tls_rpt(domain)
|
||||
mx = self._analyze_mx(mx_records, check_smtp=check_smtp)
|
||||
|
||||
result = {
|
||||
'domain': domain,
|
||||
'has_mail': bool(mx_records),
|
||||
'mx': mx,
|
||||
'spf': spf,
|
||||
'dmarc': dmarc,
|
||||
'dkim': dkim,
|
||||
'mta_sts': mta_sts,
|
||||
'tls_rpt': tls_rpt,
|
||||
}
|
||||
if check_smtp:
|
||||
result['deliverability'] = self._probe_deliverability(domain, mx_records)
|
||||
|
||||
result['provider'] = mx.get('provider') if mx else None
|
||||
result.update(self._grade(result))
|
||||
return result
|
||||
|
||||
# -- DNS helpers -------------------------------------------------------
|
||||
|
||||
def _resolve_mx(self, domain: str) -> List[Dict]:
|
||||
try:
|
||||
answers = self.resolver.resolve(domain, 'MX')
|
||||
return sorted(
|
||||
[{'priority': r.preference, 'host': str(r.exchange).rstrip('.')} for r in answers],
|
||||
key=lambda m: m['priority']
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _resolve_txt(self, name: str) -> List[str]:
|
||||
try:
|
||||
answers = self.resolver.resolve(name, 'TXT')
|
||||
out = []
|
||||
for r in answers:
|
||||
out.append(''.join(
|
||||
s.decode('utf-8', 'ignore') if isinstance(s, bytes) else str(s)
|
||||
for s in r.strings
|
||||
))
|
||||
return out
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# -- SPF ---------------------------------------------------------------
|
||||
|
||||
def _analyze_spf(self, domain: str, txt_records: List[str]) -> Dict:
|
||||
spf_record = next((t for t in (txt_records or []) if t.lower().startswith('v=spf1')), None)
|
||||
if not spf_record:
|
||||
return {'present': False, 'record': None, 'policy': None,
|
||||
'lookup_count': 0, 'includes': [], 'issues': ['No SPF record']}
|
||||
|
||||
tokens = spf_record.split()
|
||||
includes, lookup_terms, policy = [], 0, 'neutral'
|
||||
# Mechanisms that cost a DNS lookup (RFC 7208 §4.6.4, max 10).
|
||||
lookup_mechs = ('include:', 'a', 'mx', 'ptr', 'exists:', 'redirect=')
|
||||
for tok in tokens[1:]:
|
||||
low = tok.lower()
|
||||
if low.startswith('include:'):
|
||||
includes.append(tok.split(':', 1)[1])
|
||||
lookup_terms += 1
|
||||
elif low.startswith(lookup_mechs) or low in ('a', 'mx', 'ptr'):
|
||||
lookup_terms += 1
|
||||
if low.endswith('all'):
|
||||
qual = low[0] if low[0] in '-~?+' else '+'
|
||||
policy = {'-': 'fail (strict)', '~': 'softfail', '?': 'neutral',
|
||||
'+': 'pass (insecure +all)'}.get(qual, 'neutral')
|
||||
|
||||
issues = []
|
||||
if lookup_terms > 10:
|
||||
issues.append(f'Exceeds 10 DNS-lookup limit ({lookup_terms}) -> SPF permerror')
|
||||
if policy.startswith('pass'):
|
||||
issues.append('+all allows anyone to send as this domain')
|
||||
if policy == 'neutral':
|
||||
issues.append('?all provides no protection')
|
||||
return {'present': True, 'record': spf_record, 'policy': policy,
|
||||
'lookup_count': lookup_terms, 'includes': includes, 'issues': issues}
|
||||
|
||||
# -- DMARC -------------------------------------------------------------
|
||||
|
||||
def _analyze_dmarc(self, domain: str) -> Dict:
|
||||
records = self._resolve_txt(f'_dmarc.{domain}')
|
||||
rec = next((t for t in records if t.lower().startswith('v=dmarc1')), None)
|
||||
if not rec:
|
||||
return {'present': False, 'record': None, 'policy': None,
|
||||
'pct': None, 'rua': [], 'issues': ['No DMARC record']}
|
||||
tags = {}
|
||||
for part in rec.split(';'):
|
||||
if '=' in part:
|
||||
k, v = part.split('=', 1)
|
||||
tags[k.strip().lower()] = v.strip()
|
||||
policy = tags.get('p')
|
||||
issues = []
|
||||
if policy == 'none':
|
||||
issues.append('p=none is monitor-only, does not block spoofing')
|
||||
if not policy:
|
||||
issues.append('Missing p= tag')
|
||||
if not tags.get('rua'):
|
||||
issues.append('No aggregate reporting (rua) configured')
|
||||
return {'present': True, 'record': rec, 'policy': policy,
|
||||
'subdomain_policy': tags.get('sp'),
|
||||
'pct': tags.get('pct', '100'),
|
||||
'rua': [a.strip() for a in tags.get('rua', '').split(',') if a.strip()],
|
||||
'alignment': {'aspf': tags.get('aspf', 'r'), 'adkim': tags.get('adkim', 'r')},
|
||||
'issues': issues}
|
||||
|
||||
# -- DKIM --------------------------------------------------------------
|
||||
|
||||
def _discover_dkim(self, domain: str) -> Dict:
|
||||
found = []
|
||||
|
||||
def probe(sel):
|
||||
recs = self._resolve_txt(f'{sel}._domainkey.{domain}')
|
||||
for r in recs:
|
||||
if 'v=dkim1' in r.lower() or 'p=' in r:
|
||||
return sel
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as ex:
|
||||
futures = {ex.submit(probe, s): s for s in COMMON_DKIM_SELECTORS}
|
||||
for fut in as_completed(futures):
|
||||
try:
|
||||
sel = fut.result()
|
||||
if sel:
|
||||
found.append(sel)
|
||||
except Exception:
|
||||
pass
|
||||
return {'present': bool(found), 'selectors_found': sorted(found),
|
||||
'note': 'Probes common selectors only; absence is not proof of no DKIM'}
|
||||
|
||||
# -- MTA-STS / TLS-RPT / DANE ------------------------------------------
|
||||
|
||||
def _analyze_mta_sts(self, domain: str) -> Dict:
|
||||
txt = self._resolve_txt(f'_mta-sts.{domain}')
|
||||
has_dns = any('v=stsv1' in t.lower() for t in txt)
|
||||
policy = None
|
||||
mode = None
|
||||
if has_dns:
|
||||
try:
|
||||
resp = requests.get(
|
||||
f'https://mta-sts.{domain}/.well-known/mta-sts.txt',
|
||||
timeout=self.http_timeout, allow_redirects=False
|
||||
)
|
||||
if resp.status_code == 200 and 'version' in resp.text.lower():
|
||||
policy = resp.text[:2000]
|
||||
for line in resp.text.splitlines():
|
||||
if line.lower().startswith('mode:'):
|
||||
mode = line.split(':', 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return {'present': has_dns, 'mode': mode, 'policy_fetched': policy is not None}
|
||||
|
||||
def _analyze_tls_rpt(self, domain: str) -> Dict:
|
||||
txt = self._resolve_txt(f'_smtp._tls.{domain}')
|
||||
rec = next((t for t in txt if 'v=tlsrptv1' in t.lower()), None)
|
||||
return {'present': rec is not None, 'record': rec}
|
||||
|
||||
def _check_tlsa(self, mx_host: str) -> bool:
|
||||
try:
|
||||
self.resolver.resolve(f'_25._tcp.{mx_host}', 'TLSA')
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# -- MX analysis -------------------------------------------------------
|
||||
|
||||
def _provider_for(self, host: str) -> Optional[str]:
|
||||
h = host.lower()
|
||||
for needle, name in MX_PROVIDERS:
|
||||
if needle in h:
|
||||
return name
|
||||
return None
|
||||
|
||||
def _analyze_mx(self, mx_records: List[Dict], check_smtp: bool) -> Dict:
|
||||
if not mx_records:
|
||||
return {'count': 0, 'hosts': [], 'provider': None, 'starttls': None}
|
||||
|
||||
provider = None
|
||||
hosts_out = []
|
||||
|
||||
def inspect(mx):
|
||||
host = mx['host']
|
||||
info = {
|
||||
'host': host, 'priority': mx['priority'],
|
||||
'provider': self._provider_for(host),
|
||||
'addresses': self._resolve_addresses(host),
|
||||
'dane_tlsa': self._check_tlsa(host),
|
||||
}
|
||||
if check_smtp:
|
||||
info.update(self._smtp_starttls(host))
|
||||
return info
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(5, len(mx_records))) as ex:
|
||||
for info in ex.map(inspect, mx_records[:5]):
|
||||
hosts_out.append(info)
|
||||
if not provider and info.get('provider'):
|
||||
provider = info['provider']
|
||||
|
||||
starttls = None
|
||||
if check_smtp:
|
||||
tls_flags = [h.get('starttls') for h in hosts_out if 'starttls' in h]
|
||||
if tls_flags:
|
||||
starttls = all(tls_flags)
|
||||
return {'count': len(mx_records), 'hosts': hosts_out,
|
||||
'provider': provider, 'starttls': starttls,
|
||||
'dane': any(h.get('dane_tlsa') for h in hosts_out)}
|
||||
|
||||
def _resolve_addresses(self, host: str) -> List[str]:
|
||||
out = []
|
||||
for rtype in ('A', 'AAAA'):
|
||||
try:
|
||||
out += [str(r) for r in self.resolver.resolve(host, rtype)]
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
def _smtp_starttls(self, host: str) -> Dict:
|
||||
"""Connect on :25 and check STARTTLS. Degrades gracefully if blocked."""
|
||||
try:
|
||||
with smtplib.SMTP(host, 25, timeout=self.smtp_timeout) as smtp:
|
||||
banner = smtp.ehlo()
|
||||
supports_tls = smtp.has_extn('starttls')
|
||||
tls_ok = False
|
||||
if supports_tls:
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
smtp.starttls(context=ctx)
|
||||
tls_ok = True
|
||||
except Exception:
|
||||
tls_ok = False
|
||||
return {'reachable': True, 'starttls': supports_tls,
|
||||
'starttls_negotiated': tls_ok,
|
||||
'banner_code': banner[0] if banner else None}
|
||||
except (socket.timeout, ConnectionRefusedError, OSError) as e:
|
||||
return {'reachable': False, 'starttls': False,
|
||||
'reason': f'port 25 unreachable from server ({type(e).__name__})'}
|
||||
|
||||
# -- optional deliverability probe ------------------------------------
|
||||
|
||||
def _probe_deliverability(self, domain: str, mx_records: List[Dict]) -> Dict:
|
||||
"""SMTP RCPT probe for catch-all detection. Best-effort; many networks
|
||||
block outbound :25 and many servers grey-list, so results are advisory."""
|
||||
if not mx_records:
|
||||
return {'tested': False, 'reason': 'no MX'}
|
||||
host = mx_records[0]['host']
|
||||
try:
|
||||
with smtplib.SMTP(host, 25, timeout=self.smtp_timeout) as smtp:
|
||||
smtp.ehlo()
|
||||
smtp.mail('probe@example.com')
|
||||
# Random-looking address: if accepted, server is catch-all.
|
||||
code_random, _ = smtp.rcpt(f'zz-no-such-user-9281@{domain}')
|
||||
catch_all = code_random in (250, 251)
|
||||
return {'tested': True, 'catch_all': catch_all,
|
||||
'rcpt_code': code_random,
|
||||
'note': 'Advisory only; greylisting/anti-harvesting can skew results'}
|
||||
except (socket.timeout, ConnectionRefusedError, OSError) as e:
|
||||
return {'tested': False, 'reason': f'port 25 blocked/unreachable ({type(e).__name__})'}
|
||||
except Exception as e:
|
||||
return {'tested': False, 'reason': str(e)}
|
||||
|
||||
# -- grading -----------------------------------------------------------
|
||||
|
||||
def _grade(self, r: Dict) -> Dict:
|
||||
"""0-100 email-security score (higher = better) + letter grade + summary."""
|
||||
score = 0
|
||||
if r['spf'].get('present'):
|
||||
score += 20
|
||||
if r['spf'].get('policy', '').startswith('fail'):
|
||||
score += 10
|
||||
if r['dmarc'].get('present'):
|
||||
score += 20
|
||||
if r['dmarc'].get('policy') in ('quarantine', 'reject'):
|
||||
score += 15
|
||||
if r['dkim'].get('present'):
|
||||
score += 15
|
||||
if r['mta_sts'].get('present'):
|
||||
score += 10
|
||||
if r['mta_sts'].get('mode') == 'enforce':
|
||||
score += 5
|
||||
if r['tls_rpt'].get('present'):
|
||||
score += 5
|
||||
score = min(score, 100)
|
||||
grade = ('A' if score >= 85 else 'B' if score >= 70 else
|
||||
'C' if score >= 50 else 'D' if score >= 30 else 'F')
|
||||
return {'mail_security_score': score, 'mail_security_grade': grade}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
"""
|
||||
Port Scanning Service - Detect open ports and services
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
from typing import Dict, List, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PortScanService:
|
||||
"""Service for port scanning and service detection"""
|
||||
|
||||
def __init__(self):
|
||||
self.timeout = 3
|
||||
|
||||
# Common ports to scan with service info
|
||||
self.common_ports = {
|
||||
21: {'service': 'FTP', 'category': 'file_transfer', 'risk': 'medium'},
|
||||
22: {'service': 'SSH', 'category': 'remote_access', 'risk': 'low'},
|
||||
23: {'service': 'Telnet', 'category': 'remote_access', 'risk': 'critical'},
|
||||
25: {'service': 'SMTP', 'category': 'email', 'risk': 'low'},
|
||||
53: {'service': 'DNS', 'category': 'infrastructure', 'risk': 'low'},
|
||||
80: {'service': 'HTTP', 'category': 'web', 'risk': 'low'},
|
||||
110: {'service': 'POP3', 'category': 'email', 'risk': 'medium'},
|
||||
111: {'service': 'RPCBind', 'category': 'infrastructure', 'risk': 'high'},
|
||||
135: {'service': 'MS-RPC', 'category': 'windows', 'risk': 'high'},
|
||||
139: {'service': 'NetBIOS', 'category': 'windows', 'risk': 'high'},
|
||||
143: {'service': 'IMAP', 'category': 'email', 'risk': 'low'},
|
||||
443: {'service': 'HTTPS', 'category': 'web', 'risk': 'low'},
|
||||
445: {'service': 'SMB', 'category': 'windows', 'risk': 'critical'},
|
||||
465: {'service': 'SMTPS', 'category': 'email', 'risk': 'low'},
|
||||
587: {'service': 'SMTP Submission', 'category': 'email', 'risk': 'low'},
|
||||
993: {'service': 'IMAPS', 'category': 'email', 'risk': 'low'},
|
||||
995: {'service': 'POP3S', 'category': 'email', 'risk': 'low'},
|
||||
1433: {'service': 'MS-SQL', 'category': 'database', 'risk': 'critical'},
|
||||
1521: {'service': 'Oracle DB', 'category': 'database', 'risk': 'critical'},
|
||||
1723: {'service': 'PPTP VPN', 'category': 'vpn', 'risk': 'medium'},
|
||||
3306: {'service': 'MySQL', 'category': 'database', 'risk': 'critical'},
|
||||
3389: {'service': 'RDP', 'category': 'remote_access', 'risk': 'high'},
|
||||
5432: {'service': 'PostgreSQL', 'category': 'database', 'risk': 'critical'},
|
||||
5900: {'service': 'VNC', 'category': 'remote_access', 'risk': 'high'},
|
||||
6379: {'service': 'Redis', 'category': 'database', 'risk': 'critical'},
|
||||
8080: {'service': 'HTTP Proxy', 'category': 'web', 'risk': 'medium'},
|
||||
8443: {'service': 'HTTPS Alt', 'category': 'web', 'risk': 'low'},
|
||||
27017: {'service': 'MongoDB', 'category': 'database', 'risk': 'critical'},
|
||||
}
|
||||
|
||||
# Dangerous ports that should never be exposed
|
||||
self.dangerous_ports = [23, 135, 139, 445, 1433, 1521, 3306, 3389, 5432, 5900, 6379, 27017]
|
||||
|
||||
def scan(self, ip_address: str, ports: List[int] = None) -> Dict:
|
||||
"""
|
||||
Scan ports on IP address
|
||||
|
||||
Args:
|
||||
ip_address: IP address to scan
|
||||
ports: List of ports to scan (default: common ports)
|
||||
|
||||
Returns:
|
||||
Dictionary with scan results
|
||||
"""
|
||||
if ports is None:
|
||||
ports = list(self.common_ports.keys())
|
||||
|
||||
result = {
|
||||
'ip': ip_address,
|
||||
'total_scanned': len(ports),
|
||||
'open_ports': [],
|
||||
'closed_ports': [],
|
||||
'filtered_ports': [],
|
||||
'dangerous_open': [],
|
||||
'services_detected': [],
|
||||
'categories': {},
|
||||
'security_issues': [],
|
||||
'scan_summary': {}
|
||||
}
|
||||
|
||||
# Scan ports in parallel
|
||||
with ThreadPoolExecutor(max_workers=30) as executor:
|
||||
futures = {
|
||||
executor.submit(self._scan_port, ip_address, port): port
|
||||
for port in ports
|
||||
}
|
||||
|
||||
for future in as_completed(futures, timeout=60):
|
||||
port = futures[future]
|
||||
try:
|
||||
status, banner = future.result()
|
||||
|
||||
port_info = self.common_ports.get(port, {
|
||||
'service': 'Unknown',
|
||||
'category': 'other',
|
||||
'risk': 'unknown'
|
||||
})
|
||||
|
||||
port_data = {
|
||||
'port': port,
|
||||
'service': port_info['service'],
|
||||
'category': port_info['category'],
|
||||
'risk_level': port_info['risk'],
|
||||
'banner': banner
|
||||
}
|
||||
|
||||
if status == 'open':
|
||||
result['open_ports'].append(port_data)
|
||||
result['services_detected'].append(port_info['service'])
|
||||
|
||||
# Track by category
|
||||
category = port_info['category']
|
||||
if category not in result['categories']:
|
||||
result['categories'][category] = []
|
||||
result['categories'][category].append(port)
|
||||
|
||||
# Check if dangerous port
|
||||
if port in self.dangerous_ports:
|
||||
result['dangerous_open'].append(port_data)
|
||||
result['security_issues'].append({
|
||||
'severity': 'CRITICAL' if port_info['risk'] == 'critical' else 'HIGH',
|
||||
'port': port,
|
||||
'service': port_info['service'],
|
||||
'issue': f"Dangerous service {port_info['service']} exposed on port {port}"
|
||||
})
|
||||
|
||||
elif status == 'closed':
|
||||
result['closed_ports'].append(port)
|
||||
else:
|
||||
result['filtered_ports'].append(port)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error scanning port {port}: {e}")
|
||||
result['filtered_ports'].append(port)
|
||||
|
||||
# Generate summary
|
||||
result['scan_summary'] = {
|
||||
'open_count': len(result['open_ports']),
|
||||
'closed_count': len(result['closed_ports']),
|
||||
'filtered_count': len(result['filtered_ports']),
|
||||
'dangerous_count': len(result['dangerous_open']),
|
||||
'has_web': any(p['port'] in [80, 443, 8080, 8443] for p in result['open_ports']),
|
||||
'has_email': any(p['port'] in [25, 110, 143, 465, 587, 993, 995] for p in result['open_ports']),
|
||||
'has_database': any(p['port'] in [3306, 5432, 1433, 1521, 27017, 6379] for p in result['open_ports']),
|
||||
'has_remote_access': any(p['port'] in [22, 23, 3389, 5900] for p in result['open_ports'])
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def _scan_port(self, ip_address: str, port: int) -> tuple:
|
||||
"""
|
||||
Scan a single port
|
||||
|
||||
Returns:
|
||||
Tuple of (status, banner)
|
||||
status: 'open', 'closed', or 'filtered'
|
||||
"""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.timeout)
|
||||
|
||||
result = sock.connect_ex((ip_address, port))
|
||||
|
||||
if result == 0:
|
||||
# Port is open, try to grab banner
|
||||
banner = self._grab_banner(sock, port)
|
||||
sock.close()
|
||||
return 'open', banner
|
||||
else:
|
||||
sock.close()
|
||||
return 'closed', None
|
||||
|
||||
except socket.timeout:
|
||||
return 'filtered', None
|
||||
except socket.error:
|
||||
return 'filtered', None
|
||||
except Exception as e:
|
||||
return 'filtered', None
|
||||
|
||||
def _grab_banner(self, sock: socket.socket, port: int) -> Optional[str]:
|
||||
"""Try to grab service banner"""
|
||||
try:
|
||||
# For HTTP ports, send a HEAD request
|
||||
if port in [80, 8080]:
|
||||
sock.send(b'HEAD / HTTP/1.0\r\n\r\n')
|
||||
elif port in [443, 8443]:
|
||||
return None # Can't grab banner from SSL without proper handshake
|
||||
else:
|
||||
# For other ports, just try to receive
|
||||
pass
|
||||
|
||||
sock.settimeout(2)
|
||||
banner = sock.recv(1024).decode('utf-8', errors='ignore').strip()
|
||||
return banner[:200] if banner else None # Limit banner length
|
||||
except:
|
||||
return None
|
||||
|
||||
def quick_scan(self, ip_address: str) -> Dict:
|
||||
"""
|
||||
Quick scan of most common web ports
|
||||
|
||||
Args:
|
||||
ip_address: IP to scan
|
||||
|
||||
Returns:
|
||||
Quick scan results
|
||||
"""
|
||||
quick_ports = [21, 22, 23, 25, 80, 443, 3306, 3389, 8080]
|
||||
return self.scan(ip_address, quick_ports)
|
||||
|
||||
def get_port_score(self, scan_data: Dict) -> Dict:
|
||||
"""
|
||||
Calculate security score based on port scan results
|
||||
|
||||
Returns:
|
||||
Dict with score (0-100, higher = more risk) and reasons
|
||||
"""
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
# Dangerous ports are critical
|
||||
dangerous_count = len(scan_data.get('dangerous_open', []))
|
||||
if dangerous_count > 0:
|
||||
score += dangerous_count * 20
|
||||
reasons.append(f"{dangerous_count} dangerous port(s) exposed")
|
||||
|
||||
for port in scan_data.get('dangerous_open', []):
|
||||
reasons.append(f" - {port['service']} on port {port['port']}")
|
||||
|
||||
# Database exposed
|
||||
summary = scan_data.get('scan_summary', {})
|
||||
if summary.get('has_database'):
|
||||
score += 30
|
||||
reasons.append("Database port(s) publicly accessible")
|
||||
|
||||
# Remote access (besides SSH)
|
||||
if summary.get('has_remote_access'):
|
||||
open_ports = [p['port'] for p in scan_data.get('open_ports', [])]
|
||||
if 23 in open_ports: # Telnet
|
||||
score += 25
|
||||
reasons.append("Telnet (unencrypted) is open")
|
||||
if 3389 in open_ports: # RDP
|
||||
score += 20
|
||||
reasons.append("RDP is publicly accessible")
|
||||
if 5900 in open_ports: # VNC
|
||||
score += 20
|
||||
reasons.append("VNC is publicly accessible")
|
||||
|
||||
# Too many open ports (attack surface)
|
||||
open_count = summary.get('open_count', 0)
|
||||
if open_count > 10:
|
||||
score += 15
|
||||
reasons.append(f"Large attack surface: {open_count} open ports")
|
||||
elif open_count > 5:
|
||||
score += 5
|
||||
reasons.append(f"{open_count} open ports detected")
|
||||
|
||||
if score == 0:
|
||||
reasons.append("No dangerous services exposed")
|
||||
|
||||
return {
|
||||
'score': min(100, score),
|
||||
'reasons': reasons,
|
||||
'is_secure': score <= 10,
|
||||
'has_critical_issues': dangerous_count > 0 or summary.get('has_database')
|
||||
}
|
||||
741
ai_platform/modules/domain_check/api/app/services/risk_scorer.py
Normal file
741
ai_platform/modules/domain_check/api/app/services/risk_scorer.py
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
"""
|
||||
Risk Scoring Engine - Comprehensive Domain Risk Assessment
|
||||
|
||||
SCORING FORMULA:
|
||||
================
|
||||
Total Risk Score = Σ(Category Score × Category Weight)
|
||||
|
||||
CATEGORIES & WEIGHTS:
|
||||
- Domain Age: 20% (age_score × 0.20)
|
||||
- SSL/TLS: 15% (ssl_score × 0.15)
|
||||
- DNS Config: 10% (dns_score × 0.10)
|
||||
- Email Security: 10% (email_score × 0.10)
|
||||
- WHOIS Privacy: 5% (whois_score × 0.05)
|
||||
- IP Reputation: 10% (ip_score × 0.10)
|
||||
- HTTP Security: 10% (http_score × 0.10)
|
||||
- Blacklists: 15% (blacklist_score × 0.15)
|
||||
- Port Security: 5% (port_score × 0.05)
|
||||
----
|
||||
100%
|
||||
|
||||
INDIVIDUAL SCORE CALCULATIONS (0-100 scale, 0=safe, 100=dangerous):
|
||||
|
||||
1. DOMAIN AGE SCORE:
|
||||
- < 30 days: 100 (CRITICAL)
|
||||
- < 90 days: 80 (HIGH)
|
||||
- < 180 days: 60 (MEDIUM-HIGH)
|
||||
- < 365 days: 40 (MEDIUM)
|
||||
- < 730 days: 20 (LOW)
|
||||
- >= 730 days: 0 (TRUSTED)
|
||||
|
||||
2. SSL/TLS SCORE:
|
||||
- No SSL: 100
|
||||
- Expired: 100
|
||||
- Self-signed: 80
|
||||
- Expires < 7 days: 60
|
||||
- Expires < 30 days: 30
|
||||
- Weak cipher: 40
|
||||
- Valid SSL: 0
|
||||
|
||||
3. DNS SCORE:
|
||||
- No A records: 50
|
||||
- No MX records: 20
|
||||
- No NS at parent: 30
|
||||
- Mismatched NS: 20
|
||||
- No DNSSEC: 10
|
||||
- Complete config: 0
|
||||
|
||||
4. EMAIL SECURITY SCORE:
|
||||
- No SPF: 40
|
||||
- SPF ~all (soft): 20
|
||||
- SPF ?all (neutral): 30
|
||||
- No DKIM: 30
|
||||
- No DMARC: 20
|
||||
- DMARC p=none: 15
|
||||
- Full protection: 0
|
||||
|
||||
5. WHOIS SCORE:
|
||||
- Privacy protected: 20
|
||||
- No registrant info: 15
|
||||
- Suspicious registrar: 30
|
||||
- Free/disposable reg: 40
|
||||
- Transparent: 0
|
||||
|
||||
6. IP REPUTATION SCORE:
|
||||
- High-risk country: 40
|
||||
- Known bad ASN: 50
|
||||
- No reverse DNS: 20
|
||||
- Datacenter IP: 10
|
||||
- Residential IP: 0
|
||||
|
||||
7. HTTP SECURITY SCORE:
|
||||
- No HTTPS: 40
|
||||
- No HSTS: 20
|
||||
- No CSP: 15
|
||||
- No X-Frame-Options: 10
|
||||
- Missing headers: 5 each
|
||||
- Secure config: 0
|
||||
|
||||
8. BLACKLIST SCORE:
|
||||
- On spam blacklist: 25 each
|
||||
- On malware list: 50 each
|
||||
- On phishing list: 60 each
|
||||
- Not listed: 0
|
||||
|
||||
9. PORT SECURITY SCORE:
|
||||
- Database exposed: 30
|
||||
- RDP/VNC exposed: 25
|
||||
- Telnet open: 40
|
||||
- Too many ports (>10): 15
|
||||
- Secure config: 0
|
||||
|
||||
RISK LEVELS:
|
||||
- LOW: 0-25
|
||||
- MEDIUM: 26-50
|
||||
- HIGH: 51-75
|
||||
- CRITICAL: 76-100
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from flask import current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _coerce_datetime(value) -> Optional[datetime]:
|
||||
"""Return a naive datetime for any date-like value, else None.
|
||||
|
||||
Defense-in-depth: even though WhoisService now normalizes dates, the risk
|
||||
scorer can be fed cached/serialized data where dates are ISO strings. Never
|
||||
let ``datetime - <non-datetime>`` raise from here.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None) if value.tzinfo else value
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
from dateutil import parser as _p
|
||||
dt = _p.parse(text, fuzzy=True)
|
||||
return dt.replace(tzinfo=None) if dt.tzinfo else dt
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class RiskScorer:
|
||||
"""Calculate comprehensive risk scores for domains"""
|
||||
|
||||
def __init__(self):
|
||||
# Category weights (must sum to 1.0)
|
||||
self.weights = {
|
||||
'domain_age': 0.20,
|
||||
'ssl': 0.15,
|
||||
'dns': 0.10,
|
||||
'email_security': 0.10,
|
||||
'whois': 0.05,
|
||||
'ip_reputation': 0.10,
|
||||
'http_security': 0.10,
|
||||
'blacklist': 0.15,
|
||||
'port_security': 0.05
|
||||
}
|
||||
|
||||
# Risk level thresholds
|
||||
self.thresholds = {
|
||||
'low': 25,
|
||||
'medium': 50,
|
||||
'high': 75
|
||||
}
|
||||
|
||||
def calculate_risk(self, domain_data: Dict) -> Dict:
|
||||
"""
|
||||
Calculate comprehensive risk score
|
||||
|
||||
Args:
|
||||
domain_data: Dictionary containing all domain information
|
||||
- whois: WHOIS data
|
||||
- dns: DNS records
|
||||
- ssl: SSL certificate data
|
||||
- ip_intelligence: IP information
|
||||
- http_analysis: HTTP analysis data
|
||||
- blacklist: Blacklist check results
|
||||
- port_scan: Port scan results
|
||||
|
||||
Returns:
|
||||
Dictionary with complete risk assessment
|
||||
"""
|
||||
factors = []
|
||||
scores = {}
|
||||
formula_breakdown = []
|
||||
|
||||
# 1. Domain Age Score (20%)
|
||||
if domain_data.get('whois'):
|
||||
score, factor = self._score_domain_age(domain_data['whois'])
|
||||
scores['domain_age'] = score
|
||||
factors.append(factor)
|
||||
formula_breakdown.append({
|
||||
'category': 'Domain Age',
|
||||
'raw_score': score,
|
||||
'weight': self.weights['domain_age'],
|
||||
'weighted_score': score * self.weights['domain_age'],
|
||||
'formula': f"{score} × {self.weights['domain_age']} = {score * self.weights['domain_age']:.1f}"
|
||||
})
|
||||
|
||||
# 2. SSL Score (15%)
|
||||
if domain_data.get('ssl'):
|
||||
score, factor = self._score_ssl(domain_data['ssl'])
|
||||
scores['ssl'] = score
|
||||
factors.append(factor)
|
||||
formula_breakdown.append({
|
||||
'category': 'SSL/TLS',
|
||||
'raw_score': score,
|
||||
'weight': self.weights['ssl'],
|
||||
'weighted_score': score * self.weights['ssl'],
|
||||
'formula': f"{score} × {self.weights['ssl']} = {score * self.weights['ssl']:.1f}"
|
||||
})
|
||||
|
||||
# 3. DNS Score (10%)
|
||||
if domain_data.get('dns'):
|
||||
score, factor = self._score_dns(domain_data['dns'])
|
||||
scores['dns'] = score
|
||||
factors.append(factor)
|
||||
formula_breakdown.append({
|
||||
'category': 'DNS Configuration',
|
||||
'raw_score': score,
|
||||
'weight': self.weights['dns'],
|
||||
'weighted_score': score * self.weights['dns'],
|
||||
'formula': f"{score} × {self.weights['dns']} = {score * self.weights['dns']:.1f}"
|
||||
})
|
||||
|
||||
# 4. Email Security Score (10%)
|
||||
if domain_data.get('dns'):
|
||||
score, factor = self._score_email_security(domain_data['dns'])
|
||||
scores['email_security'] = score
|
||||
factors.append(factor)
|
||||
formula_breakdown.append({
|
||||
'category': 'Email Security',
|
||||
'raw_score': score,
|
||||
'weight': self.weights['email_security'],
|
||||
'weighted_score': score * self.weights['email_security'],
|
||||
'formula': f"{score} × {self.weights['email_security']} = {score * self.weights['email_security']:.1f}"
|
||||
})
|
||||
|
||||
# 5. WHOIS Score (5%)
|
||||
if domain_data.get('whois'):
|
||||
score, factor = self._score_whois(domain_data['whois'])
|
||||
scores['whois'] = score
|
||||
factors.append(factor)
|
||||
formula_breakdown.append({
|
||||
'category': 'WHOIS Privacy',
|
||||
'raw_score': score,
|
||||
'weight': self.weights['whois'],
|
||||
'weighted_score': score * self.weights['whois'],
|
||||
'formula': f"{score} × {self.weights['whois']} = {score * self.weights['whois']:.1f}"
|
||||
})
|
||||
|
||||
# 6. IP Reputation Score (10%)
|
||||
if domain_data.get('ip_intelligence'):
|
||||
score, factor = self._score_ip_reputation(domain_data['ip_intelligence'])
|
||||
scores['ip_reputation'] = score
|
||||
factors.append(factor)
|
||||
formula_breakdown.append({
|
||||
'category': 'IP Reputation',
|
||||
'raw_score': score,
|
||||
'weight': self.weights['ip_reputation'],
|
||||
'weighted_score': score * self.weights['ip_reputation'],
|
||||
'formula': f"{score} × {self.weights['ip_reputation']} = {score * self.weights['ip_reputation']:.1f}"
|
||||
})
|
||||
|
||||
# 7. HTTP Security Score (10%)
|
||||
if domain_data.get('http_analysis'):
|
||||
score, factor = self._score_http_security(domain_data['http_analysis'])
|
||||
scores['http_security'] = score
|
||||
factors.append(factor)
|
||||
formula_breakdown.append({
|
||||
'category': 'HTTP Security',
|
||||
'raw_score': score,
|
||||
'weight': self.weights['http_security'],
|
||||
'weighted_score': score * self.weights['http_security'],
|
||||
'formula': f"{score} × {self.weights['http_security']} = {score * self.weights['http_security']:.1f}"
|
||||
})
|
||||
|
||||
# 8. Blacklist Score (15%)
|
||||
if domain_data.get('blacklist'):
|
||||
score, factor = self._score_blacklist(domain_data['blacklist'])
|
||||
scores['blacklist'] = score
|
||||
factors.append(factor)
|
||||
formula_breakdown.append({
|
||||
'category': 'Blacklist Status',
|
||||
'raw_score': score,
|
||||
'weight': self.weights['blacklist'],
|
||||
'weighted_score': score * self.weights['blacklist'],
|
||||
'formula': f"{score} × {self.weights['blacklist']} = {score * self.weights['blacklist']:.1f}"
|
||||
})
|
||||
|
||||
# 9. Port Security Score (5%)
|
||||
if domain_data.get('port_scan'):
|
||||
score, factor = self._score_port_security(domain_data['port_scan'])
|
||||
scores['port_security'] = score
|
||||
factors.append(factor)
|
||||
formula_breakdown.append({
|
||||
'category': 'Port Security',
|
||||
'raw_score': score,
|
||||
'weight': self.weights['port_security'],
|
||||
'weighted_score': score * self.weights['port_security'],
|
||||
'formula': f"{score} × {self.weights['port_security']} = {score * self.weights['port_security']:.1f}"
|
||||
})
|
||||
|
||||
# Calculate weighted total
|
||||
total_weight_used = sum(self.weights[k] for k in scores.keys())
|
||||
if total_weight_used > 0:
|
||||
# Normalize to account for missing checks
|
||||
raw_total = sum(scores[k] * self.weights[k] for k in scores.keys())
|
||||
total_score = (raw_total / total_weight_used) if total_weight_used < 1.0 else raw_total
|
||||
else:
|
||||
total_score = 50 # Default medium risk if no data
|
||||
|
||||
total_score = min(100, max(0, int(total_score)))
|
||||
|
||||
# Determine risk level
|
||||
risk_level = self._get_risk_level(total_score)
|
||||
|
||||
# Generate final formula string
|
||||
formula_string = self._generate_formula_string(scores, total_score)
|
||||
|
||||
return {
|
||||
'total_score': total_score,
|
||||
'risk_level': risk_level,
|
||||
'individual_scores': scores,
|
||||
'factors': factors,
|
||||
'formula_breakdown': formula_breakdown,
|
||||
'formula_string': formula_string,
|
||||
'weights_used': {k: self.weights[k] for k in scores.keys()},
|
||||
'total_weight_used': total_weight_used,
|
||||
'is_new_domain': scores.get('domain_age', 0) >= 80,
|
||||
'is_suspicious': total_score >= 50,
|
||||
'is_blacklisted': scores.get('blacklist', 0) > 0,
|
||||
'requires_manual_review': total_score >= 60,
|
||||
'risk_thresholds': {
|
||||
'low': f"0-{self.thresholds['low']}",
|
||||
'medium': f"{self.thresholds['low']+1}-{self.thresholds['medium']}",
|
||||
'high': f"{self.thresholds['medium']+1}-{self.thresholds['high']}",
|
||||
'critical': f"{self.thresholds['high']+1}-100"
|
||||
}
|
||||
}
|
||||
|
||||
def _score_domain_age(self, whois_data: Dict) -> Tuple[int, Dict]:
|
||||
"""Score based on domain age"""
|
||||
creation_date = _coerce_datetime(whois_data.get('creation_date'))
|
||||
|
||||
if not creation_date:
|
||||
return 50, {
|
||||
'factor': 'domain_age',
|
||||
'score': 50,
|
||||
'weight': self.weights['domain_age'],
|
||||
'weighted_score': 50 * self.weights['domain_age'],
|
||||
'reason': 'Unable to determine domain age',
|
||||
'details': {'creation_date': None, 'age_days': None}
|
||||
}
|
||||
|
||||
age_days = (datetime.utcnow() - creation_date).days
|
||||
|
||||
# Scoring thresholds
|
||||
if age_days < 30:
|
||||
score = 100
|
||||
reason = f'CRITICAL: Very new domain ({age_days} days, <1 month)'
|
||||
elif age_days < 90:
|
||||
score = 80
|
||||
reason = f'HIGH: New domain ({age_days} days, <3 months)'
|
||||
elif age_days < 180:
|
||||
score = 60
|
||||
reason = f'MEDIUM-HIGH: Recently created ({age_days} days, <6 months)'
|
||||
elif age_days < 365:
|
||||
score = 40
|
||||
reason = f'MEDIUM: Less than 1 year old ({age_days} days)'
|
||||
elif age_days < 730:
|
||||
score = 20
|
||||
reason = f'LOW: Established domain ({age_days} days, 1-2 years)'
|
||||
else:
|
||||
score = 0
|
||||
years = age_days // 365
|
||||
reason = f'TRUSTED: Mature domain ({age_days} days, {years}+ years)'
|
||||
|
||||
return score, {
|
||||
'factor': 'domain_age',
|
||||
'score': score,
|
||||
'weight': self.weights['domain_age'],
|
||||
'weighted_score': score * self.weights['domain_age'],
|
||||
'reason': reason,
|
||||
'details': {
|
||||
'creation_date': str(creation_date) if creation_date else None,
|
||||
'age_days': age_days
|
||||
}
|
||||
}
|
||||
|
||||
def _score_ssl(self, ssl_data: Dict) -> Tuple[int, Dict]:
|
||||
"""Score based on SSL/TLS certificate"""
|
||||
if not ssl_data.get('has_ssl'):
|
||||
return 100, {
|
||||
'factor': 'ssl',
|
||||
'score': 100,
|
||||
'weight': self.weights['ssl'],
|
||||
'weighted_score': 100 * self.weights['ssl'],
|
||||
'reason': 'CRITICAL: No SSL/TLS certificate',
|
||||
'details': {'has_ssl': False}
|
||||
}
|
||||
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
if ssl_data.get('is_expired'):
|
||||
score = 100
|
||||
reasons.append('Certificate expired')
|
||||
elif ssl_data.get('is_self_signed'):
|
||||
score = 80
|
||||
reasons.append('Self-signed certificate')
|
||||
else:
|
||||
days_until_expiry = ssl_data.get('days_until_expiry', 365)
|
||||
if days_until_expiry < 7:
|
||||
score = 60
|
||||
reasons.append(f'Expires in {days_until_expiry} days')
|
||||
elif days_until_expiry < 30:
|
||||
score = 30
|
||||
reasons.append(f'Expires in {days_until_expiry} days')
|
||||
|
||||
# Check for weak algorithms
|
||||
sig_algo = (ssl_data.get('signature_algorithm') or '').lower()
|
||||
if 'sha1' in sig_algo or 'md5' in sig_algo:
|
||||
score = max(score, 40)
|
||||
reasons.append('Weak signature algorithm')
|
||||
|
||||
if score == 0:
|
||||
reasons.append('Valid SSL certificate')
|
||||
|
||||
return score, {
|
||||
'factor': 'ssl',
|
||||
'score': score,
|
||||
'weight': self.weights['ssl'],
|
||||
'weighted_score': score * self.weights['ssl'],
|
||||
'reason': '; '.join(reasons) if reasons else 'Valid SSL',
|
||||
'details': {
|
||||
'has_ssl': True,
|
||||
'is_valid': ssl_data.get('is_valid'),
|
||||
'days_until_expiry': ssl_data.get('days_until_expiry'),
|
||||
'issuer': ssl_data.get('issuer')
|
||||
}
|
||||
}
|
||||
|
||||
def _score_dns(self, dns_data: Dict) -> Tuple[int, Dict]:
|
||||
"""Score based on DNS configuration"""
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
a_records = dns_data.get('a_records', [])
|
||||
if not a_records:
|
||||
score += 50
|
||||
reasons.append('No A records')
|
||||
|
||||
ns_records = dns_data.get('ns_records', [])
|
||||
if not ns_records:
|
||||
score += 30
|
||||
reasons.append('No NS records')
|
||||
|
||||
mx_records = dns_data.get('mx_records', [])
|
||||
if not mx_records:
|
||||
score += 15
|
||||
reasons.append('No MX records')
|
||||
|
||||
# Check for DNSSEC (if available)
|
||||
if dns_data.get('dnssec') == 'inactive' or not dns_data.get('has_dnssec', True):
|
||||
score += 5
|
||||
reasons.append('DNSSEC not enabled')
|
||||
|
||||
if score == 0:
|
||||
reasons.append('Complete DNS configuration')
|
||||
|
||||
return min(100, score), {
|
||||
'factor': 'dns',
|
||||
'score': min(100, score),
|
||||
'weight': self.weights['dns'],
|
||||
'weighted_score': min(100, score) * self.weights['dns'],
|
||||
'reason': '; '.join(reasons),
|
||||
'details': {
|
||||
'a_record_count': len(a_records),
|
||||
'ns_record_count': len(ns_records),
|
||||
'mx_record_count': len(mx_records)
|
||||
}
|
||||
}
|
||||
|
||||
def _score_email_security(self, dns_data: Dict) -> Tuple[int, Dict]:
|
||||
"""Score based on email security (SPF, DKIM, DMARC)"""
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
has_spf = dns_data.get('has_spf', False)
|
||||
has_dkim = dns_data.get('has_dkim', False)
|
||||
has_dmarc = dns_data.get('has_dmarc', False)
|
||||
|
||||
if not has_spf:
|
||||
score += 40
|
||||
reasons.append('No SPF record')
|
||||
else:
|
||||
# Check SPF policy strength
|
||||
spf_record = dns_data.get('spf_record', '')
|
||||
if '~all' in spf_record:
|
||||
score += 15
|
||||
reasons.append('SPF uses soft fail (~all)')
|
||||
elif '?all' in spf_record:
|
||||
score += 25
|
||||
reasons.append('SPF uses neutral (?all)')
|
||||
|
||||
if not has_dkim:
|
||||
score += 30
|
||||
reasons.append('No DKIM record detected')
|
||||
|
||||
if not has_dmarc:
|
||||
score += 20
|
||||
reasons.append('No DMARC record')
|
||||
else:
|
||||
dmarc_record = dns_data.get('dmarc_record', '')
|
||||
if 'p=none' in dmarc_record:
|
||||
score += 15
|
||||
reasons.append('DMARC policy is none (monitoring only)')
|
||||
|
||||
if score == 0:
|
||||
reasons.append('Full email security (SPF+DKIM+DMARC)')
|
||||
|
||||
return min(100, score), {
|
||||
'factor': 'email_security',
|
||||
'score': min(100, score),
|
||||
'weight': self.weights['email_security'],
|
||||
'weighted_score': min(100, score) * self.weights['email_security'],
|
||||
'reason': '; '.join(reasons),
|
||||
'details': {
|
||||
'has_spf': has_spf,
|
||||
'has_dkim': has_dkim,
|
||||
'has_dmarc': has_dmarc
|
||||
}
|
||||
}
|
||||
|
||||
def _score_whois(self, whois_data: Dict) -> Tuple[int, Dict]:
|
||||
"""Score based on WHOIS transparency"""
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
if whois_data.get('privacy_protected'):
|
||||
score += 20
|
||||
reasons.append('WHOIS privacy protection enabled')
|
||||
|
||||
if not whois_data.get('registrant_org') and not whois_data.get('registrant'):
|
||||
score += 15
|
||||
reasons.append('No registrant information')
|
||||
|
||||
# Check for suspicious registrars
|
||||
registrar = (whois_data.get('registrar') or '').lower()
|
||||
suspicious_registrars = ['namecheap', 'namesilo', 'dynadot', 'freenom']
|
||||
for sus in suspicious_registrars:
|
||||
if sus in registrar:
|
||||
score += 20
|
||||
reasons.append(f'High-risk registrar pattern')
|
||||
break
|
||||
|
||||
if score == 0:
|
||||
reasons.append('Transparent WHOIS information')
|
||||
|
||||
return min(100, score), {
|
||||
'factor': 'whois',
|
||||
'score': min(100, score),
|
||||
'weight': self.weights['whois'],
|
||||
'weighted_score': min(100, score) * self.weights['whois'],
|
||||
'reason': '; '.join(reasons),
|
||||
'details': {
|
||||
'registrar': whois_data.get('registrar'),
|
||||
'privacy_protected': whois_data.get('privacy_protected', False)
|
||||
}
|
||||
}
|
||||
|
||||
def _score_ip_reputation(self, ip_data: Dict) -> Tuple[int, Dict]:
|
||||
"""Score based on IP intelligence"""
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
# High-risk countries
|
||||
country_code = ip_data.get('country_code', '')
|
||||
high_risk_countries = ['RU', 'CN', 'KP', 'IR', 'NG', 'VN', 'PK']
|
||||
if country_code in high_risk_countries:
|
||||
score += 40
|
||||
reasons.append(f'High-risk country: {country_code}')
|
||||
|
||||
# No reverse DNS
|
||||
if not ip_data.get('reverse_dns'):
|
||||
score += 20
|
||||
reasons.append('No reverse DNS (PTR)')
|
||||
|
||||
# Datacenter IP (slightly suspicious for some use cases)
|
||||
if ip_data.get('is_datacenter'):
|
||||
score += 10
|
||||
reasons.append('Hosted on datacenter/cloud')
|
||||
|
||||
# Hosting provider scoring
|
||||
hosting_score = ip_data.get('hosting_score', {})
|
||||
if hosting_score.get('is_suspicious'):
|
||||
score += 20
|
||||
reasons.extend(hosting_score.get('reasons', []))
|
||||
|
||||
if score == 0:
|
||||
reasons.append('Clean IP reputation')
|
||||
|
||||
return min(100, score), {
|
||||
'factor': 'ip_reputation',
|
||||
'score': min(100, score),
|
||||
'weight': self.weights['ip_reputation'],
|
||||
'weighted_score': min(100, score) * self.weights['ip_reputation'],
|
||||
'reason': '; '.join(reasons),
|
||||
'details': {
|
||||
'ip': ip_data.get('ip'),
|
||||
'country': ip_data.get('country'),
|
||||
'asn': ip_data.get('asn'),
|
||||
'isp': ip_data.get('isp')
|
||||
}
|
||||
}
|
||||
|
||||
def _score_http_security(self, http_data: Dict) -> Tuple[int, Dict]:
|
||||
"""Score based on HTTP security headers"""
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
if not http_data.get('has_https'):
|
||||
score += 40
|
||||
reasons.append('No HTTPS support')
|
||||
|
||||
if http_data.get('has_https') and not http_data.get('http_to_https_redirect'):
|
||||
score += 15
|
||||
reasons.append('No HTTP to HTTPS redirect')
|
||||
|
||||
# Check security headers
|
||||
missing_headers = http_data.get('missing_security_headers', [])
|
||||
for header in missing_headers:
|
||||
severity = header.get('severity', 'LOW')
|
||||
if severity == 'CRITICAL':
|
||||
score += 15
|
||||
elif severity == 'HIGH':
|
||||
score += 10
|
||||
else:
|
||||
score += 5
|
||||
if len(reasons) < 5: # Limit reasons
|
||||
reasons.append(f"Missing: {header.get('header', 'Unknown')}")
|
||||
|
||||
if score == 0:
|
||||
reasons.append('Good HTTP security configuration')
|
||||
|
||||
return min(100, score), {
|
||||
'factor': 'http_security',
|
||||
'score': min(100, score),
|
||||
'weight': self.weights['http_security'],
|
||||
'weighted_score': min(100, score) * self.weights['http_security'],
|
||||
'reason': '; '.join(reasons[:5]),
|
||||
'details': {
|
||||
'has_https': http_data.get('has_https'),
|
||||
'has_hsts': 'Strict-Transport-Security' in http_data.get('security_headers', {}),
|
||||
'missing_headers_count': len(missing_headers)
|
||||
}
|
||||
}
|
||||
|
||||
def _score_blacklist(self, blacklist_data: Dict) -> Tuple[int, Dict]:
|
||||
"""Score based on blacklist checks"""
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
if blacklist_data.get('is_blacklisted'):
|
||||
ip_listings = 0
|
||||
domain_listings = 0
|
||||
|
||||
if blacklist_data.get('ip_check'):
|
||||
ip_listings = blacklist_data['ip_check'].get('blacklist_count', 0)
|
||||
score += ip_listings * 25
|
||||
|
||||
if blacklist_data.get('domain_check'):
|
||||
domain_listings = blacklist_data['domain_check'].get('blacklist_count', 0)
|
||||
score += domain_listings * 25
|
||||
|
||||
if ip_listings > 0:
|
||||
reasons.append(f'IP on {ip_listings} blacklist(s)')
|
||||
if domain_listings > 0:
|
||||
reasons.append(f'Domain on {domain_listings} blacklist(s)')
|
||||
else:
|
||||
reasons.append('Not on any blacklists')
|
||||
|
||||
return min(100, score), {
|
||||
'factor': 'blacklist',
|
||||
'score': min(100, score),
|
||||
'weight': self.weights['blacklist'],
|
||||
'weighted_score': min(100, score) * self.weights['blacklist'],
|
||||
'reason': '; '.join(reasons),
|
||||
'details': {
|
||||
'is_blacklisted': blacklist_data.get('is_blacklisted', False),
|
||||
'total_listings': blacklist_data.get('total_listings', 0)
|
||||
}
|
||||
}
|
||||
|
||||
def _score_port_security(self, port_data: Dict) -> Tuple[int, Dict]:
|
||||
"""Score based on port scan results"""
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
dangerous_ports = port_data.get('dangerous_open', [])
|
||||
if dangerous_ports:
|
||||
score += len(dangerous_ports) * 20
|
||||
for port in dangerous_ports[:3]: # Limit to 3
|
||||
reasons.append(f"{port['service']} exposed (port {port['port']})")
|
||||
|
||||
summary = port_data.get('scan_summary', {})
|
||||
if summary.get('has_database'):
|
||||
score += 30
|
||||
if 'Database' not in str(reasons):
|
||||
reasons.append('Database port(s) publicly accessible')
|
||||
|
||||
open_count = summary.get('open_count', 0)
|
||||
if open_count > 10:
|
||||
score += 15
|
||||
reasons.append(f'Large attack surface ({open_count} open ports)')
|
||||
|
||||
if score == 0:
|
||||
reasons.append('No dangerous services exposed')
|
||||
|
||||
return min(100, score), {
|
||||
'factor': 'port_security',
|
||||
'score': min(100, score),
|
||||
'weight': self.weights['port_security'],
|
||||
'weighted_score': min(100, score) * self.weights['port_security'],
|
||||
'reason': '; '.join(reasons[:5]),
|
||||
'details': {
|
||||
'open_port_count': summary.get('open_count', 0),
|
||||
'dangerous_port_count': len(dangerous_ports)
|
||||
}
|
||||
}
|
||||
|
||||
def _get_risk_level(self, total_score: int) -> str:
|
||||
"""Determine risk level from total score"""
|
||||
if total_score <= self.thresholds['low']:
|
||||
return 'LOW'
|
||||
elif total_score <= self.thresholds['medium']:
|
||||
return 'MEDIUM'
|
||||
elif total_score <= self.thresholds['high']:
|
||||
return 'HIGH'
|
||||
else:
|
||||
return 'CRITICAL'
|
||||
|
||||
def _generate_formula_string(self, scores: Dict, total: int) -> str:
|
||||
"""Generate human-readable formula string"""
|
||||
parts = []
|
||||
for category, score in scores.items():
|
||||
weight = self.weights.get(category, 0)
|
||||
weighted = score * weight
|
||||
parts.append(f"({score} × {weight})")
|
||||
|
||||
formula = " + ".join(parts)
|
||||
return f"Total = {formula} = {total}"
|
||||
185
ai_platform/modules/domain_check/api/app/services/ssl_service.py
Normal file
185
ai_platform/modules/domain_check/api/app/services/ssl_service.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""
|
||||
SSL Service - handles SSL certificate validation
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
from OpenSSL import SSL, crypto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SSLService:
|
||||
"""SSL certificate checking service"""
|
||||
|
||||
def __init__(self, timeout: int = 10):
|
||||
self.timeout = timeout
|
||||
|
||||
def check(self, domain: str, port: int = 443) -> Optional[Dict]:
|
||||
"""
|
||||
Check SSL certificate for a domain
|
||||
|
||||
Args:
|
||||
domain: Domain name to check
|
||||
port: Port number (default: 443)
|
||||
|
||||
Returns:
|
||||
Dictionary with SSL certificate data or None
|
||||
"""
|
||||
try:
|
||||
logger.info(f'SSL check for: {domain}:{port}')
|
||||
|
||||
# Create SSL context
|
||||
context = ssl.create_default_context()
|
||||
|
||||
# Connect and get certificate
|
||||
with socket.create_connection((domain, port), timeout=self.timeout) as sock:
|
||||
with context.wrap_socket(sock, server_hostname=domain) as ssock:
|
||||
cert_bin = ssock.getpeercert(binary_form=True)
|
||||
cert_dict = ssock.getpeercert()
|
||||
|
||||
# Parse certificate with pyOpenSSL for more details
|
||||
x509 = crypto.load_certificate(crypto.FILETYPE_ASN1, cert_bin)
|
||||
|
||||
ssl_data = {
|
||||
'domain': domain,
|
||||
'has_ssl': True,
|
||||
'is_valid': True,
|
||||
'is_self_signed': self._is_self_signed(x509),
|
||||
'is_expired': False,
|
||||
'is_wildcard': False,
|
||||
'issuer': self._parse_issuer(x509),
|
||||
'subject': self._parse_subject(x509),
|
||||
'serial_number': str(x509.get_serial_number()),
|
||||
'signature_algorithm': x509.get_signature_algorithm().decode('utf-8'),
|
||||
'version': x509.get_version(),
|
||||
'valid_from': None,
|
||||
'valid_until': None,
|
||||
'days_until_expiry': None,
|
||||
'san': self._get_san(x509),
|
||||
'key_size': x509.get_pubkey().bits()
|
||||
}
|
||||
|
||||
# Parse dates
|
||||
not_before = datetime.strptime(
|
||||
x509.get_notBefore().decode('utf-8'),
|
||||
'%Y%m%d%H%M%SZ'
|
||||
)
|
||||
not_after = datetime.strptime(
|
||||
x509.get_notAfter().decode('utf-8'),
|
||||
'%Y%m%d%H%M%SZ'
|
||||
)
|
||||
|
||||
ssl_data['valid_from'] = not_before
|
||||
ssl_data['valid_until'] = not_after
|
||||
|
||||
# Calculate days until expiry
|
||||
days_until = (not_after - datetime.utcnow()).days
|
||||
ssl_data['days_until_expiry'] = days_until
|
||||
|
||||
# Check if expired
|
||||
if days_until < 0:
|
||||
ssl_data['is_expired'] = True
|
||||
ssl_data['is_valid'] = False
|
||||
|
||||
# Check if wildcard
|
||||
subject_cn = self._get_common_name(x509)
|
||||
if subject_cn and subject_cn.startswith('*.'):
|
||||
ssl_data['is_wildcard'] = True
|
||||
|
||||
return ssl_data
|
||||
|
||||
except ssl.SSLError as e:
|
||||
logger.warning(f'SSL error for {domain}: {str(e)}')
|
||||
return {
|
||||
'domain': domain,
|
||||
'has_ssl': True,
|
||||
'is_valid': False,
|
||||
'error': str(e)
|
||||
}
|
||||
except socket.timeout:
|
||||
logger.warning(f'SSL check timeout for {domain}')
|
||||
return {
|
||||
'domain': domain,
|
||||
'has_ssl': False,
|
||||
'error': 'Connection timeout'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'SSL check failed for {domain}: {str(e)}')
|
||||
return {
|
||||
'domain': domain,
|
||||
'has_ssl': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def _is_self_signed(self, cert: crypto.X509) -> bool:
|
||||
"""Check if certificate is self-signed"""
|
||||
try:
|
||||
issuer = cert.get_issuer()
|
||||
subject = cert.get_subject()
|
||||
return issuer.CN == subject.CN
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _parse_issuer(self, cert: crypto.X509) -> str:
|
||||
"""Parse certificate issuer"""
|
||||
try:
|
||||
issuer = cert.get_issuer()
|
||||
parts = []
|
||||
if hasattr(issuer, 'CN') and issuer.CN:
|
||||
parts.append(f"CN={issuer.CN}")
|
||||
if hasattr(issuer, 'O') and issuer.O:
|
||||
parts.append(f"O={issuer.O}")
|
||||
if hasattr(issuer, 'C') and issuer.C:
|
||||
parts.append(f"C={issuer.C}")
|
||||
return ', '.join(parts) if parts else 'Unknown'
|
||||
except Exception:
|
||||
return 'Unknown'
|
||||
|
||||
def _parse_subject(self, cert: crypto.X509) -> str:
|
||||
"""Parse certificate subject"""
|
||||
try:
|
||||
subject = cert.get_subject()
|
||||
parts = []
|
||||
if hasattr(subject, 'CN') and subject.CN:
|
||||
parts.append(f"CN={subject.CN}")
|
||||
if hasattr(subject, 'O') and subject.O:
|
||||
parts.append(f"O={subject.O}")
|
||||
if hasattr(subject, 'C') and subject.C:
|
||||
parts.append(f"C={subject.C}")
|
||||
return ', '.join(parts) if parts else 'Unknown'
|
||||
except Exception:
|
||||
return 'Unknown'
|
||||
|
||||
def _get_common_name(self, cert: crypto.X509) -> Optional[str]:
|
||||
"""Get Common Name from certificate"""
|
||||
try:
|
||||
subject = cert.get_subject()
|
||||
return subject.CN if hasattr(subject, 'CN') else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_san(self, cert: crypto.X509) -> list:
|
||||
"""Get Subject Alternative Names"""
|
||||
try:
|
||||
san_ext = None
|
||||
for i in range(cert.get_extension_count()):
|
||||
ext = cert.get_extension(i)
|
||||
if ext.get_short_name() == b'subjectAltName':
|
||||
san_ext = ext
|
||||
break
|
||||
|
||||
if san_ext:
|
||||
san_str = str(san_ext)
|
||||
# Parse SAN string (format: "DNS:example.com, DNS:www.example.com")
|
||||
sans = []
|
||||
for part in san_str.split(','):
|
||||
part = part.strip()
|
||||
if part.startswith('DNS:'):
|
||||
sans.append(part[4:])
|
||||
return sans
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not parse SAN: {e}')
|
||||
return []
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
"""
|
||||
Subdomain Enumeration Service - Certificate Transparency, DNS enumeration
|
||||
"""
|
||||
import logging
|
||||
import requests
|
||||
import dns.resolver
|
||||
from typing import Dict, List, Set
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubdomainService:
|
||||
"""Service for discovering subdomains"""
|
||||
|
||||
def __init__(self):
|
||||
self.timeout = 15
|
||||
self.resolver = dns.resolver.Resolver()
|
||||
self.resolver.nameservers = ['8.8.8.8', '8.8.4.4']
|
||||
self.resolver.timeout = 3
|
||||
self.resolver.lifetime = 5
|
||||
|
||||
# Common subdomain prefixes to check (expanded: infra, SaaS, business apps)
|
||||
self.common_subdomains = [
|
||||
'www', 'www2', 'mail', 'webmail', 'smtp', 'pop', 'pop3', 'imap', 'mx', 'mx1', 'mx2',
|
||||
'email', 'mailserver', 'relay', 'newsletter', 'mailgun', 'mailchimp', 'lists', 'list',
|
||||
'ftp', 'sftp', 'ssh', 'admin', 'administrator', 'panel', 'cpanel', 'whm', 'plesk', 'directadmin',
|
||||
'api', 'api1', 'api2', 'apis', 'rest', 'graphql', 'ws', 'sockets', 'dev', 'develop', 'development',
|
||||
'staging', 'stage', 'stg', 'test', 'testing', 'qa', 'uat', 'preprod', 'beta', 'alpha', 'demo', 'sandbox',
|
||||
'blog', 'shop', 'store', 'magento', 'woocommerce', 'checkout', 'pay', 'payment', 'payments', 'billing', 'invoice', 'invoices',
|
||||
'app', 'apps', 'application', 'mobile', 'm', 'web', 'portal', 'my', 'account', 'accounts', 'auth', 'sso', 'login', 'oauth', 'id', 'idp',
|
||||
'cdn', 'static', 'assets', 'img', 'images', 'media', 'video', 'videos', 'stream', 'live', 'download', 'downloads', 'files', 'file', 'share', 'sharepoint',
|
||||
'ns1', 'ns2', 'ns3', 'ns4', 'dns', 'dns1', 'dns2', 'resolver',
|
||||
'vpn', 'remote', 'gateway', 'gw', 'proxy', 'firewall', 'fw', 'router', 'access',
|
||||
'git', 'gitlab', 'github', 'gitea', 'bitbucket', 'svn', 'jenkins', 'ci', 'cicd', 'build', 'registry', 'docker', 'nexus', 'artifactory',
|
||||
'db', 'database', 'mysql', 'postgres', 'pg', 'mongo', 'redis', 'elastic', 'elasticsearch', 'kibana', 'grafana', 'prometheus', 'metrics', 'monitor', 'monitoring', 'status', 'health', 'uptime',
|
||||
'backup', 'backups', 'bk', 'old', 'new', 'v2', 'v3', 'legacy', 'archive',
|
||||
'intranet', 'extranet', 'internal', 'private', 'corp', 'office', 'work',
|
||||
'cloud', 'nextcloud', 'owncloud', 'drive', 'docs', 'doc', 'documents', 'wiki', 'confluence', 'jira', 'kb', 'knowledgebase',
|
||||
'crm', 'erp', 'hr', 'hrm', 'support', 'help', 'helpdesk', 'ticket', 'tickets', 'desk', 'service', 'services', 'client', 'clients', 'customer', 'customers', 'partner', 'partners', 'projects', 'project', 'pm', 'tasks', 'rpa', 'automation', 'tooling', 'tools', 'tool',
|
||||
'chat', 'talk', 'meet', 'conf', 'conference', 'videoconferinta', 'voip', 'pbx', 'sip', 'call', 'calls',
|
||||
'dashboard', 'analytics', 'stats', 'report', 'reports', 'data', 'bi', 'reporting',
|
||||
'autodiscover', 'autoconfig', 'exchange', 'owa', 'mail2', 'webdisk', 'cpcalendars', 'cpcontacts',
|
||||
'vps', 'server', 'server1', 'server2', 'host', 'node', 'node1', 'cluster', 'k8s', 'kubernetes', 'srv',
|
||||
'secure', 'ssl', 'vault', 'secret', 'kms', 'ldap', 'ad', 'radius'
|
||||
]
|
||||
|
||||
def enumerate(self, domain: str, extra_subdomains: List[str] = None) -> Dict:
|
||||
"""
|
||||
Enumerate subdomains using multiple methods.
|
||||
|
||||
Coverage note: CT logs only reveal names that had their own certificate;
|
||||
a wildcard cert (*.domain) or a custom-named record with no cert is
|
||||
invisible to CT. Brute-force only finds names in the wordlist. For a
|
||||
domain you own, the authoritative way to get 100% is the DNS provider's
|
||||
API (e.g. GoDaddy) or AXFR (usually disabled). Pass `extra_subdomains`
|
||||
to verify your own known names directly.
|
||||
|
||||
Args:
|
||||
domain: Root domain to enumerate
|
||||
extra_subdomains: owner-supplied candidate names (bare label or FQDN)
|
||||
|
||||
Returns:
|
||||
Dictionary with discovered subdomains
|
||||
"""
|
||||
result = {
|
||||
'domain': domain,
|
||||
'subdomains': [],
|
||||
'total_found': 0,
|
||||
'sources': {
|
||||
'certificate_transparency': [],
|
||||
'dns_bruteforce': [],
|
||||
'dns_records': [],
|
||||
'zone_transfer': [],
|
||||
'user_provided': []
|
||||
},
|
||||
'has_wildcard_cert': False,
|
||||
'coverage_note': None,
|
||||
'live_subdomains': [],
|
||||
'error': None
|
||||
}
|
||||
|
||||
discovered: Set[str] = set()
|
||||
|
||||
try:
|
||||
# 1. Certificate Transparency logs (crt.sh)
|
||||
ct_raw = self._get_ct_subdomains(domain)
|
||||
result['has_wildcard_cert'] = any(s.startswith('*.') for s in ct_raw)
|
||||
ct_subdomains = [s for s in ct_raw if not s.startswith('*')]
|
||||
result['sources']['certificate_transparency'] = ct_subdomains
|
||||
discovered.update(ct_subdomains)
|
||||
|
||||
# 2. DNS records enumeration (MX/NS/SOA)
|
||||
dns_subdomains = self._get_dns_subdomains(domain)
|
||||
result['sources']['dns_records'] = dns_subdomains
|
||||
discovered.update(dns_subdomains)
|
||||
|
||||
# 3. AXFR zone transfer (jackpot if the NS allows it — usually not)
|
||||
axfr_subdomains = self._try_axfr(domain)
|
||||
result['sources']['zone_transfer'] = axfr_subdomains
|
||||
discovered.update(axfr_subdomains)
|
||||
|
||||
# 4. Bruteforce common subdomains
|
||||
brute_subdomains = self._bruteforce_subdomains(domain)
|
||||
result['sources']['dns_bruteforce'] = brute_subdomains
|
||||
discovered.update(brute_subdomains)
|
||||
|
||||
# 5. Owner-supplied candidate names — verify which actually resolve
|
||||
if extra_subdomains:
|
||||
user_found = self._check_user_subdomains(domain, extra_subdomains)
|
||||
result['sources']['user_provided'] = user_found
|
||||
discovered.update(user_found)
|
||||
|
||||
# Remove wildcards and invalid entries
|
||||
discovered = {
|
||||
s for s in discovered
|
||||
if s and not s.startswith('*') and domain in s
|
||||
}
|
||||
|
||||
result['subdomains'] = sorted(list(discovered))
|
||||
result['total_found'] = len(discovered)
|
||||
|
||||
if result['has_wildcard_cert']:
|
||||
result['coverage_note'] = (
|
||||
'Domeniul are certificat wildcard (*.{0}), deci subdomeniile '
|
||||
'fara certificat propriu NU apar in CT logs. Lista poate fi '
|
||||
'incompleta — foloseste extra_subdomains sau API-ul DNS al '
|
||||
'registrarului (GoDaddy) pentru acoperire 100%.'.format(domain)
|
||||
)
|
||||
|
||||
# Check which subdomains are live
|
||||
result['live_subdomains'] = self._check_live_subdomains(list(discovered)[:60])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Subdomain enumeration failed for {domain}: {e}")
|
||||
result['error'] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def _try_axfr(self, domain: str) -> List[str]:
|
||||
"""Attempt a DNS zone transfer (AXFR) against each authoritative NS.
|
||||
|
||||
Almost always refused, but when an NS is misconfigured it dumps the
|
||||
entire zone — every subdomain at once. Cheap to try, big payoff."""
|
||||
import dns.query
|
||||
import dns.zone
|
||||
found: Set[str] = set()
|
||||
try:
|
||||
ns_records = self.resolver.resolve(domain, 'NS')
|
||||
nameservers = [str(ns.target).rstrip('.') for ns in ns_records]
|
||||
except Exception:
|
||||
return []
|
||||
for ns in nameservers[:4]:
|
||||
try:
|
||||
ns_ip = str(self.resolver.resolve(ns, 'A')[0])
|
||||
zone = dns.zone.from_xfr(dns.query.xfr(ns_ip, domain, timeout=5, lifetime=8))
|
||||
for name in zone.nodes.keys():
|
||||
label = str(name)
|
||||
if label in ('@', ''):
|
||||
continue
|
||||
fqdn = f'{label}.{domain}' if not label.endswith(domain) else label
|
||||
found.add(fqdn.rstrip('.').lower())
|
||||
logger.info(f'AXFR succeeded against {ns} for {domain} ({len(found)} names)')
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return list(found)
|
||||
|
||||
def _check_user_subdomains(self, domain: str, names: List[str]) -> List[str]:
|
||||
"""Resolve owner-supplied candidate names and keep the ones that exist."""
|
||||
candidates = []
|
||||
for n in names:
|
||||
n = str(n).strip().lower().rstrip('.')
|
||||
if not n:
|
||||
continue
|
||||
fqdn = n if n.endswith(domain) else f'{n}.{domain}'
|
||||
candidates.append(fqdn)
|
||||
found = []
|
||||
with ThreadPoolExecutor(max_workers=20) as ex:
|
||||
futures = {ex.submit(self._resolves, c): c for c in candidates}
|
||||
for fut in as_completed(futures, timeout=30):
|
||||
try:
|
||||
if fut.result():
|
||||
found.append(futures[fut])
|
||||
except Exception:
|
||||
pass
|
||||
return found
|
||||
|
||||
def _resolves(self, name: str) -> bool:
|
||||
for rtype in ('A', 'AAAA', 'CNAME'):
|
||||
try:
|
||||
self.resolver.resolve(name, rtype)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _get_ct_subdomains(self, domain: str) -> List[str]:
|
||||
"""Get subdomains from Certificate Transparency logs.
|
||||
|
||||
crt.sh is the richest source for real (incl. custom-named) subdomains but
|
||||
is frequently slow or rate-limited, so we retry, and fall back to the
|
||||
certspotter API when crt.sh keeps failing."""
|
||||
subdomains: Set[str] = set()
|
||||
|
||||
# Primary: crt.sh, with retries (it flakes often).
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = requests.get(
|
||||
f"https://crt.sh/?q=%.{domain}&output=json",
|
||||
timeout=self.timeout,
|
||||
headers={'User-Agent': 'domain-check/1.0'}
|
||||
)
|
||||
if response.status_code == 200 and response.text.strip():
|
||||
for entry in response.json():
|
||||
for name in entry.get('name_value', '').split('\n'):
|
||||
name = name.strip().lower()
|
||||
if name and domain in name:
|
||||
subdomains.add(name)
|
||||
if subdomains:
|
||||
return list(subdomains)
|
||||
except Exception as e:
|
||||
logger.warning(f"crt.sh attempt {attempt + 1} failed: {e}")
|
||||
|
||||
# Fallback: Cert Spotter (no key needed for low volume).
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://api.certspotter.com/v1/issuances?domain={domain}"
|
||||
"&include_subdomains=true&expand=dns_names",
|
||||
timeout=self.timeout, headers={'User-Agent': 'domain-check/1.0'}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
for cert in resp.json():
|
||||
for name in cert.get('dns_names', []):
|
||||
name = str(name).strip().lower()
|
||||
if name and domain in name:
|
||||
subdomains.add(name)
|
||||
except Exception as e:
|
||||
logger.warning(f"certspotter fallback failed: {e}")
|
||||
|
||||
return list(subdomains)
|
||||
|
||||
def _get_dns_subdomains(self, domain: str) -> List[str]:
|
||||
"""Get subdomains from DNS records (NS, MX, etc.)"""
|
||||
subdomains = []
|
||||
|
||||
try:
|
||||
# Check MX records
|
||||
try:
|
||||
mx_records = self.resolver.resolve(domain, 'MX')
|
||||
for mx in mx_records:
|
||||
host = str(mx.exchange).rstrip('.')
|
||||
if domain in host:
|
||||
subdomains.append(host)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check NS records
|
||||
try:
|
||||
ns_records = self.resolver.resolve(domain, 'NS')
|
||||
for ns in ns_records:
|
||||
host = str(ns.target).rstrip('.')
|
||||
if domain in host:
|
||||
subdomains.append(host)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check SOA record
|
||||
try:
|
||||
soa_records = self.resolver.resolve(domain, 'SOA')
|
||||
for soa in soa_records:
|
||||
mname = str(soa.mname).rstrip('.')
|
||||
if domain in mname:
|
||||
subdomains.append(mname)
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DNS subdomain lookup failed: {e}")
|
||||
|
||||
return list(set(subdomains))
|
||||
|
||||
def _bruteforce_subdomains(self, domain: str) -> List[str]:
|
||||
"""Bruteforce common subdomains"""
|
||||
found = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=20) as executor:
|
||||
futures = {
|
||||
executor.submit(self._check_subdomain, f"{sub}.{domain}"): sub
|
||||
for sub in self.common_subdomains
|
||||
}
|
||||
|
||||
for future in as_completed(futures, timeout=30):
|
||||
subdomain = futures[future]
|
||||
try:
|
||||
full_subdomain = f"{subdomain}.{domain}"
|
||||
if future.result():
|
||||
found.append(full_subdomain)
|
||||
except:
|
||||
pass
|
||||
|
||||
return found
|
||||
|
||||
def _check_subdomain(self, subdomain: str) -> bool:
|
||||
"""Check if subdomain resolves"""
|
||||
try:
|
||||
self.resolver.resolve(subdomain, 'A')
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def _check_live_subdomains(self, subdomains: List[str]) -> List[Dict]:
|
||||
"""Check which subdomains are live (have HTTP response)"""
|
||||
live = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
futures = {
|
||||
executor.submit(self._check_http, sub): sub
|
||||
for sub in subdomains
|
||||
}
|
||||
|
||||
for future in as_completed(futures, timeout=60):
|
||||
subdomain = futures[future]
|
||||
try:
|
||||
result = future.result()
|
||||
if result:
|
||||
live.append(result)
|
||||
except:
|
||||
pass
|
||||
|
||||
return live
|
||||
|
||||
def _check_http(self, subdomain: str) -> Dict:
|
||||
"""Check HTTP/HTTPS on subdomain"""
|
||||
result = {
|
||||
'subdomain': subdomain,
|
||||
'ip': None,
|
||||
'http': None,
|
||||
'https': None
|
||||
}
|
||||
|
||||
# Get IP
|
||||
try:
|
||||
answers = self.resolver.resolve(subdomain, 'A')
|
||||
result['ip'] = str(answers[0])
|
||||
except:
|
||||
return None
|
||||
|
||||
# Check HTTPS
|
||||
try:
|
||||
response = requests.head(
|
||||
f"https://{subdomain}",
|
||||
timeout=5,
|
||||
allow_redirects=True,
|
||||
verify=False
|
||||
)
|
||||
result['https'] = response.status_code
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check HTTP
|
||||
try:
|
||||
response = requests.head(
|
||||
f"http://{subdomain}",
|
||||
timeout=5,
|
||||
allow_redirects=False
|
||||
)
|
||||
result['http'] = response.status_code
|
||||
except:
|
||||
pass
|
||||
|
||||
if result['http'] or result['https']:
|
||||
return result
|
||||
return None
|
||||
|
||||
def check_subdomain_takeover(self, subdomains: List[str]) -> List[Dict]:
|
||||
"""
|
||||
Check for potential subdomain takeover vulnerabilities
|
||||
|
||||
Args:
|
||||
subdomains: List of subdomains to check
|
||||
|
||||
Returns:
|
||||
List of potentially vulnerable subdomains
|
||||
"""
|
||||
vulnerable = []
|
||||
|
||||
# CNAME fingerprints for takeover
|
||||
takeover_fingerprints = {
|
||||
'github.io': 'GitHub Pages',
|
||||
'herokuapp.com': 'Heroku',
|
||||
'herokudns.com': 'Heroku',
|
||||
'wordpress.com': 'WordPress',
|
||||
'pantheonsite.io': 'Pantheon',
|
||||
'domains.tumblr.com': 'Tumblr',
|
||||
'zendesk.com': 'Zendesk',
|
||||
'shopify.com': 'Shopify',
|
||||
'myshopify.com': 'Shopify',
|
||||
's3.amazonaws.com': 'AWS S3',
|
||||
's3-website': 'AWS S3',
|
||||
'cloudfront.net': 'AWS CloudFront',
|
||||
'azurewebsites.net': 'Azure',
|
||||
'cloudapp.net': 'Azure',
|
||||
'trafficmanager.net': 'Azure',
|
||||
'blob.core.windows.net': 'Azure Blob',
|
||||
'ghost.io': 'Ghost',
|
||||
'helpjuice.com': 'Helpjuice',
|
||||
'helpscoutdocs.com': 'HelpScout',
|
||||
'freshdesk.com': 'Freshdesk',
|
||||
'surge.sh': 'Surge',
|
||||
'bitbucket.io': 'Bitbucket',
|
||||
'uservoice.com': 'UserVoice',
|
||||
'simplebooklet.com': 'Simplebooklet'
|
||||
}
|
||||
|
||||
for subdomain in subdomains:
|
||||
try:
|
||||
# Check for CNAME
|
||||
cname_records = self.resolver.resolve(subdomain, 'CNAME')
|
||||
for cname in cname_records:
|
||||
cname_target = str(cname.target).rstrip('.').lower()
|
||||
|
||||
for fingerprint, service in takeover_fingerprints.items():
|
||||
if fingerprint in cname_target:
|
||||
# Check if CNAME target resolves
|
||||
try:
|
||||
self.resolver.resolve(cname_target, 'A')
|
||||
except dns.resolver.NXDOMAIN:
|
||||
vulnerable.append({
|
||||
'subdomain': subdomain,
|
||||
'cname': cname_target,
|
||||
'service': service,
|
||||
'status': 'VULNERABLE',
|
||||
'reason': 'CNAME points to non-existent resource'
|
||||
})
|
||||
except:
|
||||
pass
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
return vulnerable
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
"""
|
||||
WHOIS Service - handles WHOIS/RDAP lookups
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
from dateutil import parser as dateutil_parser
|
||||
DATEUTIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
DATEUTIL_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import whois as python_whois
|
||||
WHOIS_AVAILABLE = True
|
||||
except ImportError:
|
||||
WHOIS_AVAILABLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_whois_date(value) -> Optional[datetime]:
|
||||
"""Coerce any WHOIS date representation into a naive (tz-stripped) datetime.
|
||||
|
||||
python-whois and registry-specific WHOIS servers (notably ROTLD for .ro)
|
||||
return dates as datetime, list-of-datetime, ISO strings, or arbitrary
|
||||
free-form strings. Downstream code does ``datetime - value`` arithmetic, so
|
||||
every date MUST arrive as a ``datetime`` or ``None`` — never a raw string.
|
||||
A raw string here is the root cause of the historical 500 on ``.ro`` domains.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
# Registries often return the most recent date first; take the first set value.
|
||||
for item in value:
|
||||
parsed = normalize_whois_date(item)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None) if value.tzinfo else value
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text or text.lower() in ('none', 'null', 'n/a', '-'):
|
||||
return None
|
||||
if DATEUTIL_AVAILABLE:
|
||||
try:
|
||||
parsed = dateutil_parser.parse(text, fuzzy=True)
|
||||
return parsed.replace(tzinfo=None) if parsed.tzinfo else parsed
|
||||
except (ValueError, OverflowError, TypeError):
|
||||
return None
|
||||
# Fallback without dateutil: a few common explicit formats.
|
||||
for fmt in ('%Y-%m-%dT%H:%M:%S.%fZ', '%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%dT%H:%M:%S',
|
||||
'%Y-%m-%d %H:%M:%S', '%Y-%m-%d', '%d.%m.%Y', '%d-%b-%Y'):
|
||||
try:
|
||||
return datetime.strptime(text, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _as_list(value) -> List:
|
||||
"""Normalize a WHOIS field that may be a scalar, None, or list into a clean list."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [v for v in value if v not in (None, '')]
|
||||
return [value]
|
||||
|
||||
|
||||
class WhoisService:
|
||||
"""WHOIS/RDAP service class"""
|
||||
|
||||
def __init__(self, whoxy_api_key: str = None):
|
||||
self.whoxy_api_key = whoxy_api_key
|
||||
self.whoxy_url = 'https://api.whoxy.com/'
|
||||
|
||||
def lookup(self, domain: str, source: str = 'auto') -> Optional[Dict]:
|
||||
"""
|
||||
Perform WHOIS lookup
|
||||
|
||||
Args:
|
||||
domain: Domain name to lookup
|
||||
source: 'rdap', 'whoxy', or 'auto' (tries RDAP first, falls back to Whoxy)
|
||||
|
||||
Returns:
|
||||
Dictionary with WHOIS data or None
|
||||
"""
|
||||
if source == 'auto':
|
||||
# Try RDAP first
|
||||
result = self._lookup_rdap(domain)
|
||||
# Check if we got useful data (creation_date should exist)
|
||||
if result and result.get('creation_date'):
|
||||
logger.info(f'Using WHOIS data from python-whois for {domain}')
|
||||
return result
|
||||
|
||||
# Fallback to Whoxy if available
|
||||
if self.whoxy_api_key:
|
||||
logger.info(f'Falling back to Whoxy API for {domain}')
|
||||
whoxy_result = self._lookup_whoxy(domain)
|
||||
if whoxy_result:
|
||||
return whoxy_result
|
||||
|
||||
# If we got partial data from RDAP, return it
|
||||
if result:
|
||||
logger.warning(f'Returning incomplete WHOIS data for {domain}')
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
elif source == 'rdap':
|
||||
return self._lookup_rdap(domain)
|
||||
|
||||
elif source == 'whoxy':
|
||||
return self._lookup_whoxy(domain)
|
||||
|
||||
return None
|
||||
|
||||
def _lookup_rdap(self, domain: str) -> Optional[Dict]:
|
||||
"""Lookup using python-whois library"""
|
||||
if not WHOIS_AVAILABLE:
|
||||
logger.warning('python-whois not available')
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.info(f'WHOIS lookup for: {domain}')
|
||||
w = python_whois.whois(domain)
|
||||
|
||||
# Normalize every date to datetime|None so downstream arithmetic is safe.
|
||||
creation_date = normalize_whois_date(getattr(w, 'creation_date', None))
|
||||
expiration_date = normalize_whois_date(getattr(w, 'expiration_date', None))
|
||||
updated_date = normalize_whois_date(getattr(w, 'updated_date', None))
|
||||
|
||||
name_servers = [str(ns).lower() for ns in _as_list(getattr(w, 'name_servers', None))]
|
||||
status = [str(s) for s in _as_list(getattr(w, 'status', None))]
|
||||
emails = _as_list(getattr(w, 'emails', None))
|
||||
|
||||
result = {
|
||||
'domain': domain,
|
||||
'creation_date': creation_date,
|
||||
'expiration_date': expiration_date,
|
||||
'updated_date': updated_date,
|
||||
'registrar': getattr(w, 'registrar', None),
|
||||
'registrar_url': None,
|
||||
'registrant_org': getattr(w, 'org', None),
|
||||
'registrant_country': getattr(w, 'country', None),
|
||||
'admin_email': emails[0] if emails else None,
|
||||
'name_servers': name_servers,
|
||||
'status': status,
|
||||
'dnssec': None,
|
||||
'data_source': 'whois',
|
||||
'raw_data': {}
|
||||
}
|
||||
result['is_registered'] = self._is_registered(result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'WHOIS lookup failed for {domain}: {str(e)}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_registered(whois_result: Dict) -> Optional[bool]:
|
||||
"""Best-effort determination of whether a domain is registered.
|
||||
|
||||
Returns True/False from WHOIS signals, or None when WHOIS is too sparse
|
||||
to decide (common for ROTLD .ro) — the route then refines this using DNS.
|
||||
"""
|
||||
if whois_result.get('creation_date') or whois_result.get('registrar'):
|
||||
return True
|
||||
if whois_result.get('name_servers') or whois_result.get('status'):
|
||||
return True
|
||||
# No positive WHOIS signal at all: likely available, but let DNS confirm.
|
||||
return None
|
||||
|
||||
def _lookup_whoxy(self, domain: str) -> Optional[Dict]:
|
||||
"""Lookup using Whoxy API"""
|
||||
if not self.whoxy_api_key:
|
||||
logger.warning('Whoxy API key not configured')
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.info(f'Whoxy API lookup for: {domain}')
|
||||
|
||||
response = requests.get(
|
||||
self.whoxy_url,
|
||||
params={
|
||||
'key': self.whoxy_api_key,
|
||||
'whois': domain
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f'Whoxy API error: {response.status_code}')
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get('status') != 1:
|
||||
logger.error(f'Whoxy API returned error: {data.get("status_reason")}')
|
||||
return None
|
||||
|
||||
whois_data = data.get('whois_data', {})
|
||||
|
||||
result = {
|
||||
'domain': domain,
|
||||
'creation_date': normalize_whois_date(whois_data.get('create_date')),
|
||||
'expiration_date': normalize_whois_date(whois_data.get('expiry_date')),
|
||||
'updated_date': normalize_whois_date(whois_data.get('update_date')),
|
||||
'registrar': whois_data.get('registrar_name'),
|
||||
'registrar_url': whois_data.get('registrar_url'),
|
||||
'registrant_org': whois_data.get('registrant_organization'),
|
||||
'registrant_country': whois_data.get('registrant_country'),
|
||||
'admin_email': whois_data.get('admin_email'),
|
||||
'name_servers': [str(ns).lower() for ns in _as_list(whois_data.get('name_servers'))],
|
||||
'status': [str(s) for s in _as_list(whois_data.get('domain_status'))],
|
||||
'dnssec': whois_data.get('dnssec') == 'yes',
|
||||
'data_source': 'whoxy',
|
||||
'raw_data': whois_data
|
||||
}
|
||||
result['is_registered'] = self._is_registered(result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Whoxy lookup failed for {domain}: {str(e)}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(date_str: str) -> Optional[datetime]:
|
||||
"""Parse date string to datetime"""
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try common date formats
|
||||
for fmt in ['%Y-%m-%d', '%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%d %H:%M:%S']:
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
Loading…
Add table
Add a link
Reference in a new issue