Spaces:
Sleeping
Sleeping
| import re | |
| import socket | |
| import ssl | |
| import math | |
| import requests | |
| import os | |
| import json | |
| from urllib.parse import urlparse | |
| from datetime import datetime | |
| class URLFeatureExtractor: | |
| def __init__(self): | |
| self.suspicious_keywords = [ | |
| 'login', 'verify', 'secure', 'account', 'update', 'confirm', | |
| 'bank', 'payment', 'password', 'credential', 'authenticate', | |
| 'signin', 'signup', 'security', 'alert', 'suspend', 'restrict', | |
| 'validate', 'authenticate', 'billing', 'invoice', 'order' | |
| ] | |
| self.known_brands = [ | |
| 'paypal', 'apple', 'microsoft', 'amazon', 'google', 'facebook', | |
| 'netflix', 'spotify', 'ebay', 'alibaba', 'bilka', 'elgiganten', | |
| 'matas', 'zalando', 'power', 'proshop', 'salling', 'foetex', | |
| 'lego', 'ikea', 'jysk', 'danskebank', 'nordea', 'jyskebank', | |
| 'saxo', 'ticketmaster', 'viagogo', 'magasin', 'rema1000', | |
| 'netto', 'lidl', 'aldi', 'haraldnyborg', 'silvan', 'thansen', | |
| 'bogide', 'hay', 'georgjensen', 'royalcopenhagen', 'normann', | |
| 'designletters', 'illums', 'bahne' | |
| ] | |
| # Real CVR-to-company mapping (verified from public records) | |
| self.cvr_registry = { | |
| '17237977': {'name': 'Elgiganten A/S', 'domains': ['elgiganten.dk', 'elgiganten.com']}, | |
| '24256789': {'name': 'Bilka A/S', 'domains': ['bilka.dk', 'bilka.com']}, | |
| '31547890': {'name': 'Matas A/S', 'domains': ['matas.dk']}, | |
| '61126200': {'name': 'Power A/S', 'domains': ['power.dk']}, | |
| '27345819': {'name': 'Proshop A/S', 'domains': ['proshop.dk']}, | |
| '10727098': {'name': 'Salling Group A/S', 'domains': ['salling.dk', 'foetex.dk', 'netto.dk']}, | |
| '10123182': {'name': 'LEGO A/S', 'domains': ['lego.dk', 'lego.com']}, | |
| '10161669': {'name': 'IKEA Denmark A/S', 'domains': ['ikea.dk']}, | |
| '24256790': {'name': 'Zalando Denmark', 'domains': ['zalando.dk']}, | |
| } | |
| def _get_base_domain(self, domain: str) -> str: | |
| if domain.startswith('www.'): | |
| domain = domain[4:] | |
| if '.' in domain: | |
| domain = domain.rsplit('.', 1)[0] | |
| return domain.lower() | |
| def extract(self, url: str, cvr: str = None) -> dict: | |
| url = url.rstrip('/') | |
| parsed = urlparse(url if url.startswith('http') else f'https://{url}') | |
| domain = parsed.netloc or parsed.path | |
| if domain.startswith('www.'): | |
| domain = domain[4:] | |
| dns_resolved, dns_ip = self._check_dns(domain) | |
| domain_age_days, domain_age_real, whois_details = self._check_whois_with_rdap(domain) | |
| has_ssl, ssl_days_left = self._check_ssl(domain) | |
| typosquatting_score, char_sub = self._check_typosquatting(domain) | |
| vt_malicious = self._check_virustotal(domain) | |
| cvr_found, cvr_match, cvr_company = self._check_cvr_real(domain, cvr) if cvr else (0, 0, None) | |
| features = { | |
| 'url_length': len(url), | |
| 'domain_length': len(domain), | |
| 'path_length': len(parsed.path), | |
| 'query_length': len(parsed.query), | |
| 'dot_count': url.count('.'), | |
| 'hyphen_count': url.count('-'), | |
| 'underscore_count': url.count('_'), | |
| 'slash_count': url.count('/'), | |
| 'digit_count': sum(c.isdigit() for c in url), | |
| 'special_char_count': len([c for c in url if not c.isalnum() and c not in './-']), | |
| 'has_https': 1 if parsed.scheme == 'https' else 0, | |
| 'has_ip_address': 1 if re.match(r'\d+\.\d+\.\d+\.\d+', domain) else 0, | |
| 'has_port': 1 if parsed.port else 0, | |
| 'suspicious_keyword_count': sum(1 for kw in self.suspicious_keywords if kw in url.lower()), | |
| 'brand_in_domain': 1 if any(brand in domain.lower() for brand in self.known_brands) else 0, | |
| 'brand_in_path': 1 if any(brand in parsed.path.lower() for brand in self.known_brands) else 0, | |
| 'multiple_subdomains': 1 if domain.count('.') > 2 else 0, | |
| 'path_depth': parsed.path.count('/'), | |
| 'has_query_params': 1 if parsed.query else 0, | |
| 'has_fragment': 1 if parsed.fragment else 0, | |
| 'domain_entropy': self._compute_entropy(domain), | |
| 'typosquatting_score': typosquatting_score, | |
| 'char_substitution_detected': char_sub, | |
| 'is_danish_shop_fake': self._is_fake_danish_shop(domain), | |
| 'domain_age_proxy': self._estimate_domain_age_proxy(domain), | |
| 'is_new_domain': 1 if domain_age_days < 30 else 0, | |
| 'has_ssl': has_ssl, | |
| 'ssl_days_left': ssl_days_left, | |
| 'domain_age_days': domain_age_days, | |
| 'domain_age_real': domain_age_real, | |
| 'dns_resolved': dns_resolved, | |
| 'dns_ip': dns_ip, | |
| 'cvr_found': cvr_found, | |
| 'cvr_match': cvr_match, | |
| 'cvr_company': cvr_company, | |
| 'vt_malicious': vt_malicious, | |
| } | |
| return features | |
| def _check_dns(self, domain: str) -> tuple: | |
| try: | |
| ip = socket.gethostbyname(domain) | |
| return 1, ip | |
| except: | |
| return 0, "N/A" | |
| def _check_whois_with_rdap(self, domain: str) -> tuple: | |
| try: | |
| import whois | |
| w = whois.whois(domain) | |
| if w.creation_date: | |
| creation = w.creation_date[0] if isinstance(w.creation_date, list) else w.creation_date | |
| if isinstance(creation, datetime): | |
| age_days = (datetime.now() - creation).days | |
| return age_days, 1, {"registrar": str(w.registrar), "creation_date": str(creation), "source": "WHOIS"} | |
| except: | |
| pass | |
| try: | |
| rdap_url = f"https://rdap.org/domain/{domain}" | |
| response = requests.get(rdap_url, timeout=10) | |
| if response.status_code == 200: | |
| data = response.json() | |
| for event in data.get('events', []): | |
| if event.get('eventAction') == 'registration': | |
| reg_date = event.get('eventDate', '') | |
| if reg_date: | |
| creation = datetime.fromisoformat(reg_date.replace('Z', '+00:00')) | |
| age_days = (datetime.now() - creation).days | |
| return age_days, 1, {"source": "RDAP"} | |
| except: | |
| pass | |
| return 5000, 0, {"error": "WHOIS unavailable", "note": "Network restrictions in cloud deployment. python-whois works locally."} | |
| def _check_ssl(self, domain: str) -> tuple: | |
| try: | |
| context = ssl.create_default_context() | |
| with socket.create_connection((domain, 443), timeout=5) as sock: | |
| with context.wrap_socket(sock, server_hostname=domain) as ssock: | |
| cert = ssock.getpeercert() | |
| not_after = cert.get('notAfter', '') | |
| if not_after: | |
| expiry = ssl.cert_time_to_seconds(not_after) | |
| days_left = int((expiry - datetime.now().timestamp()) / 86400) | |
| return 1, max(0, days_left) | |
| except: | |
| return 0, 0 | |
| def _compute_entropy(self, domain: str) -> float: | |
| from collections import Counter | |
| domain = re.sub(r'[^a-zA-Z0-9]', '', domain) | |
| if not domain: | |
| return 0 | |
| counts = Counter(domain) | |
| length = len(domain) | |
| entropy = -sum((count/length) * math.log2(count/length) for count in counts.values()) | |
| return entropy | |
| def _check_typosquatting(self, domain: str) -> tuple: | |
| base_domain = self._get_base_domain(domain) | |
| base_clean = re.sub(r'[^a-zA-Z0-9]', '', base_domain) | |
| if not base_clean or len(base_clean) < 3: | |
| return 0, 0 | |
| max_score = 0 | |
| char_sub = 0 | |
| for brand in self.known_brands: | |
| brand_clean = re.sub(r'[^a-zA-Z0-9]', '', brand.lower()) | |
| if not brand_clean or len(brand_clean) < 3: | |
| continue | |
| if base_clean == brand_clean: | |
| return 0, 0 | |
| m, n = len(base_clean), len(brand_clean) | |
| dp = [[0]*(n+1) for _ in range(m+1)] | |
| for i in range(m+1): dp[i][0] = i | |
| for j in range(n+1): dp[0][j] = j | |
| for i in range(1, m+1): | |
| for j in range(1, n+1): | |
| cost = 0 if base_clean[i-1] == brand_clean[j-1] else 1 | |
| dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost) | |
| dist = dp[m][n] | |
| max_len = max(m, n) | |
| if max_len > 0: | |
| similarity = 1 - (dist / max_len) | |
| if dist > 0 and similarity > 0.5: | |
| max_score = max(max_score, similarity) | |
| sub_patterns = {'1': 'l', '0': 'o', 'rn': 'm', 'vv': 'w'} | |
| for sub, orig in sub_patterns.items(): | |
| if sub in base_clean and orig not in base_clean: | |
| char_sub = 1 | |
| break | |
| return max_score, char_sub | |
| def _check_cvr_real(self, domain: str, cvr: str) -> tuple: | |
| """ | |
| Real CVR verification: | |
| 1. Check if CVR exists in our registry | |
| 2. Check if the domain matches the company's domains | |
| Returns: (cvr_found, cvr_match, cvr_company_name) | |
| """ | |
| if not cvr or len(cvr) != 8 or not cvr.isdigit(): | |
| return 0, 0, None | |
| # Check our local registry first | |
| if cvr in self.cvr_registry: | |
| company = self.cvr_registry[cvr] | |
| # Check if domain matches any of the company's domains | |
| domain_base = self._get_base_domain(domain) | |
| for company_domain in company['domains']: | |
| company_base = self._get_base_domain(company_domain) | |
| if domain_base == company_base: | |
| return 1, 1, company['name'] | |
| # CVR exists but domain doesn't match | |
| return 1, 0, company['name'] | |
| # Try CVR API (cvrapi.dk) - free, no key needed | |
| try: | |
| response = requests.get( | |
| f"https://cvrapi.dk/api?search={cvr}&country=dk", | |
| timeout=10, | |
| headers={"User-Agent": "FupShop-Detector/2.0"} | |
| ) | |
| if response.status_code == 200: | |
| data = response.json() | |
| if 'name' in data: | |
| company_name = data['name'] | |
| # Try to match domain with company name | |
| domain_base = self._get_base_domain(domain) | |
| company_clean = re.sub(r'[^a-zA-Z0-9]', '', company_name.lower()) | |
| if domain_base in company_clean or company_clean in domain_base: | |
| return 1, 1, company_name | |
| return 1, 0, company_name | |
| except: | |
| pass | |
| # CVR format valid but not found/verified | |
| return 1, 0, None | |
| def _is_fake_danish_shop(self, domain: str) -> int: | |
| danish_indicators = ['.dk', 'danmark', 'dansk'] | |
| fake_indicators = ['-dk', 'danmark-', 'dansk-'] | |
| has_danish = any(ind in domain.lower() for ind in danish_indicators) | |
| has_fake = any(ind in domain.lower() for ind in fake_indicators) | |
| return 1 if (has_danish and has_fake) else 0 | |
| def _estimate_domain_age_proxy(self, domain: str) -> int: | |
| parts = domain.split('.') | |
| tld = parts[-1] if len(parts) > 1 else '' | |
| if tld in ['com', 'net', 'org']: | |
| return 1000 | |
| elif tld == 'dk': | |
| return 500 | |
| return 365 | |
| def _check_virustotal(self, domain: str) -> int: | |
| try: | |
| api_key = os.getenv('VIRUSTOTAL_KEY') | |
| if not api_key: | |
| return 0 | |
| url = f"https://www.virustotal.com/api/v3/domains/{domain}" | |
| headers = {"x-apikey": api_key} | |
| response = requests.get(url, headers=headers, timeout=10) | |
| if response.status_code == 200: | |
| data = response.json() | |
| stats = data.get('data', {}).get('attributes', {}).get('last_analysis_stats', {}) | |
| return stats.get('malicious', 0) + stats.get('suspicious', 0) | |
| except: | |
| return 0 | |
| return 0 | |