├── README.md ├── analysis.py ├── api.py ├── config.py ├── config ├── WhoDatBanner.png ├── config.ini ├── poc.gif └── pocimg.png ├── gui.py ├── requirements.txt ├── utils.py ├── whodat.png └── whodat.py /README.md: -------------------------------------------------------------------------------- 1 | ![Banner Image](https://github.com/calinux-py/WhoDAT/blob/main/config/WhoDatBanner.png?raw=true) 2 | # [WhoDAT Logo](https://github.com/calinux-py/WhoDAT/tree/main) WhoDAT - InfoSec Analyzer for Nerds 3 | 4 | WhoDAT is a GUI-based cybersecurity tool for nerds. 5 | 6 | Analyze emails, URLs, headers, IPs, and attachments for threats--using free APIs like VirusTotal, Google Safe Browsing, URLScan, and Hybrid Analysis. 7 | 8 | ![Windows](https://img.shields.io/badge/platform-Windows-blue) ![Python](https://img.shields.io/badge/language-Python-darkgreen) ![OpenAI](https://img.shields.io/badge/OpenAI-412991?logo=openai&logoColor=white) ![VirusTotal](https://img.shields.io/badge/VirusTotal-0078D4?logo=virustotal&logoColor=white) ![Hybrid Analysis](https://img.shields.io/badge/Hybrid%20Analysis-004080?logo=hybridanalysis&logoColor=white) ![Google Safe Browsing](https://img.shields.io/badge/Google%20Safe%20Browsing-34A853?logo=google&logoColor=white) ![URLScan](https://img.shields.io/badge/URLScan-FFA500) 9 | 10 | [WhoDAT](https://github.com/calinux-py/WhoDAT/blob/main/config/pocimg.png?raw=true) 11 | 12 | [Download the portable executable version here!](https://github.com/calinux-py/WhoDAT/releases/download/whodatv1.4/whodat.exe) 13 | 14 | ## Features 15 | 16 | ### 🌐 Domain Analyzer 17 | Analyze URLs, email addresses, and IP addresses to reveal their threat level: 18 | - **Website Analysis**: Search if a website is a known malicious site and take a secure screenshot using URLScan.io. 19 | - **Email Analysis**: Verifies if email domains are free, disposable, or associated with suspicious activity. 20 | - **URL Analysis**: Scans URLs to detect malware, phishing attempts, and suspicious redirects. 21 | - **IP Address Analysis**: Checks if an IP address has been associated with previous malicious activity. 22 | - **WHOIS Data**: Retrieves WHOIS information for domains to confirm registration dates, geographical origins, and other key details. 23 | - **DMARC Analysis**: Check if an email has been potentially spoofed. 24 | 25 | ### 📨 Header Analyzer 26 | Uncover security issues hidden in email headers: 27 | - **IP Address Analysis**: Extracts originating IPs and determines their geographic and ISP origins. IP addresses from outside the US are flagged (I'm American - edit the code to change noob). 28 | - **SPF, DKIM, and DMARC**: Validates authentication records to detect spoofing attempts. 29 | - **Intermediary Hop Analysis**: Identifies intermediate servers through header inspection. 30 | 31 | ### 🔍 Sentiment Analyzer 32 | Detect phishing and other sus language in email content: 33 | - **Content Analysis**: Scans for urgency cues, suspicious language, and embedded links. 34 | - **OpenAI Integration**: Uses AI to provide a classification score and risk assessment based on content indicators. 35 | 36 | ### 📎 Attachment Analyzer 37 | Ensure attachments are safe before opening: 38 | - **File Scanning**: Uploads files to VirusTotal and Hybrid Analysis to see if malicious or sus. 39 | - **Real-Time Reports**: Displays detailed findings from VirusTotal and Hybrid Analysis, including detection by antivirus engines and potential threat levels. 40 | - **QR Code Scanning**: Scan QR codes and automatically process the embedded link for malicious activity. 41 | 42 | --- 43 | 44 | ## Getting Started 45 | 46 | ### Prerequisites 47 | Ensure you have Python 3.6+ installed. Install dependencies via: 48 | 49 | ```powershell 50 | pip install -r requirements.txt 51 | ``` 52 | 53 | ### API Keys 54 | 55 | *API Keys are NOT required but will limit the usefulness considerably. They are free. Don't be lazy. You can skip the OpenAI API if you don't want AI analysis.* 56 | 57 | WhoDAT uses API keys from several services. All are FREE (except openai but its like a penny). Add your keys in config/config.ini under the relevant sections: 58 | 59 | - [VirusTotal](https://docs.virustotal.com/reference/overview) 60 | - [Google Safe Browsing](https://console.cloud.google.com/apis/api/safebrowsing.googleapis.com) 61 | - [URLScan](https://urlscan.io/docs/api/) 62 | - [OpenAI](https://platform.openai.com/docs/overview) 63 | - [Hybrid Analysis](https://hybrid-analysis.com/docs/api/v2) 64 | 65 | `NOTE: config/config.ini MUST be in the same directory as whodat.py/whodat.exe.` 66 | ``` 67 | WhoDAT(Python)/ 68 | ├── whodat.py 69 | ├── utils.py 70 | ├── gui.py 71 | ├── analysis.py 72 | ├── api.py 73 | ├── config.py 74 | └── config/ 75 | └── config.ini 76 | 77 | WhoDAT(Portable Executable)/ 78 | ├── whodat.exe 79 | └── config/ 80 | └── config.ini 81 | ``` 82 | 83 | --- 84 | 85 | ### Usage 86 | [Download the Python script](https://github.com/calinux-py/WhoDAT/archive/refs/heads/main.zip) or [download the portable executable version](https://github.com/calinux-py/WhoDAT/releases/download/whodatv1.4/whodat.exe). 87 | 88 | Start the .exe or run whodat.py using Python. 89 | ``` 90 | python whodat.py 91 | ``` 92 | 93 | Select Analysis Type: Choose a tab for the type of analysis you want to perform: 94 | 1) Domain Analyzer: Enter email or URL for analysis. 95 | 2) Header Analyzer: Paste email headers for validation. 96 | 3) Sentiment Analyzer: Paste email content to assess phishing risk. 97 | 4) Attachment Analyzer: Upload files for malware analysis. 98 | Interpret Results: Results are presented with color-coded risk indicators, making it easy to assess threat levels at a glance. 99 | 100 | ## File Overview 101 | 102 | ### File Description 103 | - config.py Manages API keys and retrieves credentials from a configuration file. 104 | - gui.py Implements the PyQt5-based GUI, providing a structured interface for each analysis type. 105 | - utils.py Utility functions for URL defanging, email obfuscation, and data formatting. 106 | - whodat.py Main application entry point, initializing the GUI. 107 | - analysis.py Core analysis logic, with background threads handling various tasks such as WHOIS checks, header parsing. 108 | - api.py Manages API requests to external services (VirusTotal, URLScan, Safe Browsing, OpenAI) and processes responses. 109 | 110 | [WhoDAT](https://github.com/calinux-py/WhoDAT/blob/main/config/poc.gif?raw=true) 111 | -------------------------------------------------------------------------------- /analysis.py: -------------------------------------------------------------------------------- 1 | import re 2 | import socket 3 | import os 4 | import time 5 | import json 6 | import requests 7 | import urllib.parse 8 | from datetime import datetime 9 | from PyQt5.QtCore import QThread, pyqtSignal 10 | from email.parser import Parser 11 | from email.policy import default 12 | from email.utils import parseaddr 13 | import pytz 14 | import tldextract 15 | import whois 16 | from dateutil import parser as date_parser 17 | import matplotlib.pyplot as plt 18 | import io 19 | import base64 20 | import dns.resolver 21 | from PIL import Image 22 | from pyzbar.pyzbar import decode 23 | 24 | from config import ( 25 | get_virustotal_api_key, 26 | get_safe_browsing_api_key, 27 | get_urlscan_api_key, 28 | get_openai_api_key, 29 | get_hybrid_analysis_api_key 30 | ) 31 | from utils import ( 32 | defang_url, defang_email, defang_domain, 33 | format_field 34 | ) 35 | from api import ( 36 | get_virustotal_report, get_virustotal_ip_report, 37 | get_urlscan_report, check_url_safe_browsing, 38 | get_openai_analysis 39 | ) 40 | 41 | FREE_EMAIL_DOMAINS = { 42 | 'gmail.com', 'yahoo.com', 'outlook.com', 'live.com', 'aol.com', 43 | 'protonmail.com', 'zoho.com', 'mail.com', 'gmx.com', 'yandex.com', 44 | 'mail.ru', 'inbox.com', 'fastmail.com', 'tutanota.com', 'hushmail.com', 45 | 'aim.com', 'msn.com', 'hotmail.co.uk', 'icloud.com', 'gmx.net', 46 | 'rediffmail.com', 'ymail.com', 'email.com', 'inbox.lv', 'outlook.co.uk', 47 | 'hotmail.fr', 'rambler.ru', 'seznam.cz', 'libero.it', 'laposte.net', 48 | 'virgilio.it', 'interia.pl', 'free.fr', 'freemail.nl', 'mail.bg', 49 | 'mail.ee', 'mail.hu', 'mail.it', 'email.bg', 'email.ee', 'email.hu', 50 | 'email.it', 'email.pl', 'email.nl', 'email.se', 'email.es', 'email.de', 51 | 'email.fr', 'email.ca', 'email.co.za', 'outlook.de', 'outlook.fr', 52 | 'outlook.it', 'outlook.es', 'outlook.ca', 'outlook.com.au', 'outlook.co.in', 53 | 'outlook.co.jp', 'outlook.com.br', 'mailfence.com', 'disroot.org', 54 | 'openmailbox.org', 'migadu.com', 'mail.vivaldi.net', 'outlook.com.mx' 55 | } 56 | 57 | 58 | class AnalyzerThread(QThread): 59 | output_signal = pyqtSignal(str) 60 | error_signal = pyqtSignal(str) 61 | 62 | def __init__(self, email, link, vt_api_key, sb_api_key, openai_api_key): 63 | super().__init__() 64 | self.email = email 65 | self.link = link 66 | self.vt_api_key = vt_api_key 67 | self.sb_api_key = sb_api_key 68 | self.openai_api_key = openai_api_key 69 | self.report = "" 70 | self.processed_domains = set() 71 | 72 | def get_dmarc_record(self, domain): 73 | try: 74 | answers = dns.resolver.resolve('_dmarc.' + domain, 'TXT') 75 | dmarc_records = [str(rdata).strip('"') for rdata in answers] 76 | return '; '.join(dmarc_records) 77 | except dns.resolver.NoAnswer: 78 | return "No DMARC record found.
Email could be spoofed.

" 79 | except dns.resolver.NXDOMAIN: 80 | return "No DMARC record found.
Email could be spoofed.

" 81 | except Exception as e: 82 | return f"Error fetching DMARC record: {e}" 83 | 84 | def emit_output(self, text): 85 | plain_text = re.sub('<[^<]+?>', '', text) 86 | self.report += plain_text + '\n' 87 | self.output_signal.emit(text) 88 | 89 | def run(self): 90 | try: 91 | if self.email: 92 | self.emit_output( 93 | f"

Analyzing Email Address

Target: {defang_email(self.email)}
") 94 | self.process_email_input() 95 | if self.link: 96 | self.process_link_input() 97 | if not self.email and not self.link: 98 | self.error_signal.emit("No email or link provided.") 99 | self.emit_output( 100 | "Skipping analysis because no input was provided.
") 101 | if self.openai_api_key and self.report.strip(): 102 | self.emit_output( 103 | "Sending report to AI for analysis...

") 104 | ai_response, ai_error = get_openai_analysis(self.report) 105 | if ai_error: 106 | self.error_signal.emit(ai_error) 107 | self.emit_output( 108 | "Skipping AI Analysis due to an error.
") 109 | elif ai_response: 110 | self.emit_output(f"{ai_response}
") 111 | except Exception as e: 112 | self.error_signal.emit(f"An unexpected error occurred: {e}") 113 | self.emit_output( 114 | "Skipping analysis due to an unexpected error.
") 115 | 116 | def check_website_status(self, url): 117 | full_link = url 118 | if not full_link.startswith(('http://', 'https://')): 119 | full_link = 'http://' + full_link 120 | try: 121 | response = requests.head(full_link, allow_redirects=True, timeout=10) 122 | if response.status_code < 400: 123 | return True 124 | else: 125 | return False 126 | except Exception: 127 | return False 128 | 129 | def fetch_and_process_whois(self, domain_name, domain_label): 130 | if domain_name in self.processed_domains: 131 | self.emit_output( 132 | f"WHOIS for {domain_label} ({defang_domain(domain_name)}) already processed. Skipping.
") 133 | return 134 | 135 | self.processed_domains.add(domain_name) 136 | 137 | try: 138 | domain_info = whois.whois(domain_name) 139 | output = f"WHOIS Information for {domain_label}:
" 140 | output += self.process_domain_whois(domain_name, domain_info) 141 | self.emit_output(output) 142 | if domain_label == "Email Domain" and self.email: 143 | response = requests.get(f"https://disify.com/api/email/{self.email}") 144 | if response.status_code == 200: 145 | disify_data = response.json() 146 | disposable_status = disify_data.get("disposable", "Unknown") 147 | output = "
Disposable Email: {}".format( 148 | 'YES' if disposable_status else 'No') + "
" 149 | self.emit_output(output) 150 | else: 151 | self.error_signal.emit( 152 | f"Error checking disposable status for {self.email}: {response.status_code}") 153 | self.emit_output( 154 | "Skipping Disposable Email Check due to an error.
") 155 | except Exception as e: 156 | self.error_signal.emit( 157 | f"Error fetching WHOIS data for {domain_label} {defang_domain(domain_name)}: {e}") 158 | self.emit_output( 159 | f"Skipping WHOIS analysis for {domain_label} due to an error.
") 160 | 161 | def process_email_input(self): 162 | email_pattern = r'^[^@]+@([^@]+\.[^@]+)$' 163 | match = re.match(email_pattern, self.email) 164 | 165 | if not match: 166 | self.error_signal.emit("Invalid email address.") 167 | self.emit_output( 168 | "Skipping Email Analysis due to an invalid email address.
" 169 | ) 170 | return 171 | 172 | domain = match.group(1) 173 | ext = tldextract.extract(domain) 174 | registered_domain = ext.registered_domain 175 | 176 | if not registered_domain: 177 | self.error_signal.emit("Could not extract registered domain from email.") 178 | self.emit_output( 179 | "Skipping Email Analysis due to inability to extract domain.
" 180 | ) 181 | return 182 | 183 | self.fetch_and_process_whois(registered_domain, "Email Domain") 184 | 185 | dmarc_record = self.get_dmarc_record(registered_domain) 186 | 187 | if "p=none" in dmarc_record.lower(): 188 | modified_record = re.sub( 189 | r'(p=none)', 190 | r'\1', 191 | dmarc_record, 192 | flags=re.IGNORECASE 193 | ) 194 | output = f"DMARC Record: {modified_record}.
Email could be spoofed.

" 195 | else: 196 | output = f"DMARC Record: {dmarc_record}
" 197 | 198 | self.emit_output(output) 199 | 200 | if self.email and self.is_free_email(registered_domain): 201 | self.emit_output( 202 | f"Email Domain {registered_domain} is a FREE email provider.
" 203 | ) 204 | return 205 | 206 | def is_free_email(self, domain): 207 | return domain.lower() in FREE_EMAIL_DOMAINS 208 | 209 | def process_link_input(self): 210 | full_link = self.link 211 | if not full_link.startswith(('http://', 'https://')): 212 | full_link = 'http://' + full_link 213 | try: 214 | response = requests.get(full_link, allow_redirects=True, timeout=10) 215 | except Exception as e: 216 | self.error_signal.emit(f"Could not fetch the link: {e}") 217 | self.emit_output( 218 | "

Skipping Link Analysis due to an error fetching the URL.
") 219 | return 220 | 221 | self.emit_output( 222 | f"

Analyzing Link

Target: {defang_url(self.link)}
") 223 | 224 | self.emit_output(f"
Checking if website is up or down: {defang_url(self.link)}") 225 | is_up = self.check_website_status(self.link) 226 | if is_up: 227 | self.emit_output( 228 | f"
[ + ] The website {defang_url(self.link)} is UP.

") 229 | else: 230 | self.emit_output( 231 | f"
[ - ] The website {defang_url(self.link)} is DOWN.

") 232 | 233 | parsed_start_url = urllib.parse.urlparse(full_link) 234 | start_domain = parsed_start_url.hostname 235 | ext = tldextract.extract(start_domain) 236 | start_registered_domain = ext.registered_domain 237 | 238 | if "safebrowse.io" not in full_link.lower(): 239 | if start_registered_domain: 240 | self.fetch_and_process_whois(start_registered_domain, "Starting URL Domain") 241 | self.emit_output("
Analyzing URL and Redirects...
") 242 | self.process_redirect_chain(response) 243 | else: 244 | self.emit_output( 245 | "Skipping WHOIS and URLScan scanning because URL contains safebrowse.io
") 246 | 247 | final_url = response.url 248 | parsed_final_url = urllib.parse.urlparse(final_url) 249 | final_domain = parsed_final_url.hostname 250 | ext = tldextract.extract(final_domain) 251 | final_registered_domain = ext.registered_domain 252 | 253 | if "safebrowse.io" not in final_url.lower(): 254 | if final_registered_domain and final_registered_domain != start_registered_domain: 255 | self.fetch_and_process_whois(final_registered_domain, "Final URL Domain") 256 | else: 257 | self.emit_output( 258 | "Skipping WHOIS and UrlScan for Final URL because it contains safebrowse.io.") 259 | 260 | self.emit_output("
Generating VirusTotal Report...
") 261 | 262 | if self.vt_api_key: 263 | try: 264 | vt_report = get_virustotal_report(final_url, self.vt_api_key) 265 | if vt_report: 266 | self.process_virustotal_report(vt_report) 267 | else: 268 | self.error_signal.emit( 269 | "Could not retrieve VirusTotal report. It is possible the website is taken down or blocked. Try analyzing manually on VirusTotal's website.") 270 | self.emit_output( 271 | "Skipping VirusTotal Report due to retrieval error.
") 272 | except Exception as e: 273 | self.error_signal.emit(f"An error occurred while retrieving the VirusTotal report: {e}") 274 | self.emit_output( 275 | "Skipping VirusTotal Report due to an error.
") 276 | else: 277 | self.error_signal.emit("VirusTotal API key not provided.") 278 | self.emit_output( 279 | "Skipping VirusTotal Report because API key is not provided.
") 280 | 281 | def process_virustotal_report(self, vt_report): 282 | output = "
VirusTotal Report:
" 283 | try: 284 | attributes = vt_report['data']['attributes'] 285 | stats = attributes['last_analysis_stats'] 286 | 287 | output += "" 288 | for key, value in stats.items(): 289 | color = '#A4A4A4' 290 | if key.lower() == 'malicious' and value > 0: 291 | color = '#ff6666' 292 | elif key.lower() == 'suspicious' and value > 0: 293 | color = '#ffcc00' 294 | elif key.lower() == 'harmless' and value > 0: 295 | color = '#66b266' 296 | elif key.lower() == 'undetected' and value > 0: 297 | color = '#cccccc' 298 | elif key.lower() == 'timeout' and value > 0: 299 | color = '#4d94ff' 300 | output += f"" 301 | output += f"" 302 | output += "
{key.capitalize()}{value}

" 303 | 304 | analysis_results = attributes.get('last_analysis_results', {}) 305 | malicious_vendors = [vendor for vendor, result in analysis_results.items() if 306 | result.get('category') == 'malicious'] 307 | suspicious_vendors = [vendor for vendor, result in analysis_results.items() if 308 | result.get('category') == 'suspicious'] 309 | 310 | if malicious_vendors: 311 | output += "

Malicious Detections by:
" 312 | for vendor in malicious_vendors: 313 | output += f"{vendor}
" 314 | output += "" 315 | if suspicious_vendors: 316 | output += "
Suspicious Detections by:
" 317 | for vendor in suspicious_vendors: 318 | output += f"{vendor}
" 319 | output += "" 320 | except Exception as e: 321 | self.error_signal.emit(f"Error parsing VirusTotal report: {e}") 322 | self.emit_output( 323 | "Skipping further processing of VirusTotal Report due to parsing error.
") 324 | return 325 | output += "" 326 | self.emit_output(output) 327 | 328 | def process_redirect_chain(self, response): 329 | chain = response.history + [response] 330 | output = "
Redirect Chain:
" 331 | urlscan_api_key = get_urlscan_api_key() 332 | 333 | for i, resp in enumerate(chain): 334 | url = resp.url 335 | parsed_url = urllib.parse.urlparse(url) 336 | hostname = parsed_url.hostname 337 | ip_address = 'N/A' 338 | if hostname: 339 | try: 340 | ip_address = socket.gethostbyname(hostname) 341 | except Exception: 342 | ip_address = 'N/A' 343 | 344 | detection_status = 'Unknown' 345 | ip_color = '' 346 | if ip_address != 'N/A' and self.vt_api_key: 347 | ip_report = get_virustotal_ip_report(ip_address, self.vt_api_key) 348 | if ip_report: 349 | stats = ip_report.get('data', {}).get('attributes', {}).get('last_analysis_stats', {}) 350 | malicious = stats.get('malicious', 0) 351 | suspicious = stats.get('suspicious', 0) 352 | if malicious > 0 or suspicious > 0: 353 | ip_color = '#ff4d4d' 354 | detection_status = "Malicious" 355 | else: 356 | detection_status = "Clean" 357 | 358 | if self.sb_api_key: 359 | is_safe, sb_result = check_url_safe_browsing(url, self.sb_api_key) 360 | if is_safe is True: 361 | sb_status = "No classification" 362 | elif is_safe is False: 363 | threats = ', '.join(sb_result) 364 | sb_status = f"Unsafe ({threats})" 365 | else: 366 | sb_status = f"Error ({sb_result})" 367 | else: 368 | sb_status = "Safe Browsing API Key Not Provided" 369 | 370 | defanged_url = defang_url(url) 371 | output += f"
" 372 | output += f"{i + 1}: {defanged_url} (IP: {ip_address}) - VirusTotal IP Scan: {detection_status} - Google Safe Browsing: {sb_status}" 373 | output += "
" 374 | 375 | if urlscan_api_key: 376 | try: 377 | urlscan_report = get_urlscan_report(url, urlscan_api_key) 378 | if urlscan_report: 379 | verdict = "Malicious" if urlscan_report['verdict'] == "malicious" else "No classification" 380 | output += "
URLScan Verdict: " + verdict + "
" 381 | if urlscan_report.get('screenshot'): 382 | output += f"URLScan Screenshot:

" 383 | if urlscan_report.get('screenshot_url'): 384 | output += f"
URLScan Screenshot URL: {urlscan_report['screenshot_url']}
" 385 | else: 386 | output += "URLScan Screenshot: Not available. It is possible the URL is a download or your API limit for UrlScan has exceeded.
" 387 | except Exception as e: 388 | self.error_signal.emit(f"Error fetching URLScan report for {defang_url(url)}: {e}") 389 | self.emit_output( 390 | "Skipping URLScan Verdict due to an error.
") 391 | output += "
" 392 | self.emit_output(output) 393 | 394 | def process_domain_whois(self, domain, domain_info): 395 | output = "" 396 | 397 | registration_date = domain_info.creation_date 398 | registrant_country = domain_info.country 399 | current_year = datetime.now().year 400 | 401 | def is_created_this_year(registration_date): 402 | if not registration_date: 403 | return False 404 | dates = registration_date if isinstance(registration_date, list) else [registration_date] 405 | return any(date.year == current_year for date in dates if isinstance(date, datetime)) 406 | 407 | def is_not_us_country(registrant_country): 408 | if not registrant_country: 409 | return False 410 | countries = registrant_country if isinstance(registrant_country, list) else [registrant_country] 411 | return any(country.strip().upper() != 'US' for country in countries if country) 412 | 413 | is_new_domain = is_created_this_year(registration_date) 414 | is_not_us = is_not_us_country(registrant_country) 415 | 416 | defanged_domain = defang_domain(domain) 417 | output += f""" 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 435 | 436 | 437 | 438 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 |
FieldDetails
Domain{defanged_domain}
Domain Registration Date 433 | {format_field(registration_date)} 434 |
Registrant Country 439 | {format_field(registrant_country)} 440 |
Domain Expiration Date{format_field(domain_info.expiration_date)}
Registrant Name{format_field(domain_info.name)}
Registrant Organization{format_field(domain_info.org)}
Contact Email{defang_email(format_field(domain_info.emails))}
Registrar Information{format_field(domain_info.registrar)}
Name Servers{format_field(domain_info.name_servers)}
Domain Status{format_field(domain_info.status)}
Last Updated Date{format_field(domain_info.updated_date)}
476 | """ 477 | return output 478 | 479 | 480 | class HeaderAnalyzerThread(QThread): 481 | output_signal = pyqtSignal(str) 482 | error_signal = pyqtSignal(str) 483 | 484 | def __init__(self, headers_text): 485 | super().__init__() 486 | self.headers_text = headers_text 487 | 488 | def run(self): 489 | try: 490 | parser = Parser(policy=default) 491 | msg = parser.parsestr(self.headers_text) 492 | from_header = msg['From'] 493 | authentication_results = msg.get_all('Authentication-Results', []) 494 | received_headers = msg.get_all('Received', []) 495 | return_path = msg['Return-Path'] 496 | subject = msg['Subject'] 497 | date = msg['Date'] 498 | output = "" 499 | 500 | originating_ip = self.extract_originating_ip(received_headers) 501 | country = self.get_ip_country(originating_ip) 502 | text_color = '#ff6666' if country != 'US' else '' 503 | output += f"
Originating IP Address: {originating_ip}
" 504 | output += self.get_ip_location(originating_ip, country, text_color) 505 | output += f"From Address: {from_header}
" 506 | output += f"Return-Path: {return_path}
" 507 | output += f"Subject: {subject}
" 508 | date_pacific_str = self.convert_date_to_pacific(date) 509 | output += f"Date: {date_pacific_str}
" 510 | 511 | output += "

Received Headers:
" 512 | for i, header in enumerate(received_headers, 1): 513 | output += f"{i}: {header}

" 514 | 515 | intermediaries = [] 516 | for header in received_headers: 517 | match = re.search( 518 | r'from\s+([\w\.-]+|localhost)\s+\((?:[\w\.-]+\s+)?(?:\[)?(\d{1,3}(?:\.\d{1,3}){3}|[a-fA-F0-9:]+|IPv6:::1)(?:\])?\)', 519 | header, 520 | re.IGNORECASE 521 | ) 522 | if match: 523 | hostname, ip = match.groups() 524 | intermediaries.append(f"[ + ] {hostname}: {ip}") 525 | 526 | output += "
Intermediary Addresses and IPs:
" 527 | for intermediary in intermediaries: 528 | output += f"{intermediary}
" 529 | 530 | output += self.analyze_spf(msg, authentication_results) 531 | output += self.analyze_dkim(msg, authentication_results) 532 | output += self.analyze_dmarc(authentication_results) 533 | 534 | self.output_signal.emit(output) 535 | except Exception as e: 536 | self.error_signal.emit(f"An error occurred while analyzing headers: {e}") 537 | self.output_signal.emit("Skipping Header Analysis due to an error.
") 538 | 539 | def extract_originating_ip(self, received_headers): 540 | for header in reversed(received_headers): 541 | matches = re.findall(r'\[(\d{1,3}(?:\.\d{1,3}){3})\]', header) 542 | if matches: 543 | return matches[0] 544 | return "N/A" 545 | 546 | def get_ip_country(self, ip): 547 | if ip == "N/A": 548 | return "Unknown" 549 | try: 550 | response = requests.get(f"https://ipinfo.io/{ip}/json", timeout=10) 551 | data = response.json() 552 | return data.get("country", "Unknown") 553 | except: 554 | return "Unknown" 555 | 556 | def get_ip_location(self, ip, country, text_color): 557 | if ip == "N/A": 558 | return "Originating Location: Unknown
" 559 | try: 560 | response = requests.get(f"https://ipinfo.io/{ip}/json", timeout=10) 561 | data = response.json() 562 | city = data.get("city", "Unknown") 563 | region = data.get("region", "Unknown") 564 | org = data.get("org", "Unknown") 565 | postal = data.get("postal", "Unknown") 566 | timezone = data.get("timezone", "Unknown") 567 | coordinates = data.get("loc", "Unknown") 568 | output = f"Originating Country: {country}
" 569 | output += f"City: {city}
" 570 | output += f"Region: {region}
" 571 | output += f"Organization: {org}
" 572 | output += f"Postal Code: {postal}
" 573 | output += f"Timezone: {timezone}
" 574 | output += f"Coordinates: {coordinates}
" 575 | return output 576 | except: 577 | return "Originating Location: Unknown
" 578 | 579 | def convert_date_to_pacific(self, date_str): 580 | try: 581 | date_obj = date_parser.parse(date_str) 582 | pacific_tz = pytz.timezone('America/Los_Angeles') 583 | date_pacific = date_obj.astimezone(pacific_tz) 584 | hour = date_pacific.hour 585 | time_color = "#ff6666" if (21 <= hour or hour < 5) else "" 586 | date_pacific_formatted = date_pacific.strftime('%I:%M %p, %d %b %Y') 587 | return f"{date_pacific_formatted}" 588 | except Exception: 589 | return date_str 590 | 591 | def analyze_spf(self, msg, authentication_results): 592 | spf_authenticated = "Failed" 593 | spf_alignment = "Failed" 594 | for auth_result in authentication_results: 595 | spf_match = re.search(r'spf=(\w+)', auth_result) 596 | if spf_match and spf_match.group(1).lower() == 'pass': 597 | spf_authenticated = "Passed" 598 | mailfrom_match = re.search(r'smtp\.mailfrom=([^;\s]+)', auth_result) 599 | if mailfrom_match: 600 | mailfrom_domain = mailfrom_match.group(1).split('@')[-1] 601 | from_address = parseaddr(msg['From'])[1] 602 | from_domain = from_address.split('@')[-1] 603 | spf_alignment = "Aligned" if mailfrom_domain.lower() == from_domain.lower() else "Not Aligned" 604 | spf_color = "#ff6666" if spf_authenticated != "Passed" or spf_alignment != "Aligned" else "" 605 | output = f"

SPF Authenticated: {spf_authenticated}
" 606 | output += f"SPF Alignment: {spf_alignment}
" 607 | return output 608 | 609 | def analyze_dkim(self, msg, authentication_results): 610 | dkim_authenticated = "Failed" 611 | dkim_alignment = "Failed" 612 | for auth_result in authentication_results: 613 | dkim_match = re.search(r'dkim=(\w+)', auth_result) 614 | if dkim_match and dkim_match.group(1).lower() == 'pass': 615 | dkim_authenticated = "Passed" 616 | headerd_match = re.search(r'header\.d=([^;\s]+)', auth_result) 617 | if headerd_match: 618 | headerd_domain = headerd_match.group(1) 619 | from_address = parseaddr(msg['From'])[1] 620 | from_domain = from_address.split('@')[-1] 621 | dkim_alignment = "Aligned" if headerd_domain.lower() == from_domain.lower() else "Not Aligned" 622 | dkim_color = "#ff6666" if dkim_authenticated != "Passed" or dkim_alignment != "Aligned" else "" 623 | output = f"DKIM Authenticated: {dkim_authenticated}
" 624 | output += f"DKIM Alignment: {dkim_alignment}
" 625 | return output 626 | 627 | def analyze_dmarc(self, authentication_results): 628 | dmarc_compliant = "Failed" 629 | for auth_result in authentication_results: 630 | dmarc_match = re.search(r'dmarc=(\w+)', auth_result) 631 | if dmarc_match and dmarc_match.group(1).lower() == 'pass': 632 | dmarc_compliant = "Passed" 633 | dmarc_color = "#ff6666" if dmarc_compliant != "Passed" else "" 634 | output = f"DMARC Compliance: {dmarc_compliant}
" 635 | return output 636 | 637 | 638 | class SentimentAnalyzerThread(QThread): 639 | output_signal = pyqtSignal(str) 640 | error_signal = pyqtSignal(str) 641 | 642 | def __init__(self, content, openai_api_key): 643 | super().__init__() 644 | self.content = content 645 | self.openai_api_key = openai_api_key 646 | 647 | def run(self): 648 | if not self.content.strip(): 649 | return 650 | analysis, error = get_openai_analysis(self.content) 651 | if error: 652 | self.error_signal.emit(error) 653 | elif analysis: 654 | self.output_signal.emit(analysis + "
") 655 | 656 | 657 | class AttachmentAnalyzerThread(QThread): 658 | output_signal = pyqtSignal(str) 659 | error_signal = pyqtSignal(str) 660 | 661 | def __init__(self, file_path, vt_api_key, ha_api_key): 662 | super().__init__() 663 | self.file_path = file_path 664 | self.vt_api_key = vt_api_key 665 | self.ha_api_key = ha_api_key 666 | 667 | def run(self): 668 | # --- Begin: New QR Code handling --- 669 | if self.file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')): 670 | try: 671 | image = Image.open(self.file_path) 672 | qr_codes = decode(image) 673 | if qr_codes: 674 | qr_data = qr_codes[0].data.decode('utf-8') 675 | self.output_signal.emit(f"
QR Code detected!") 676 | if qr_data.startswith("http://") or qr_data.startswith("https://"): 677 | self.output_signal.emit("Running link analyzer on QR Code URL...
") 678 | sb_api_key = get_safe_browsing_api_key() 679 | openai_api_key = get_openai_api_key() 680 | analyzer_thread = AnalyzerThread( 681 | email=None, 682 | link=qr_data, 683 | vt_api_key=self.vt_api_key, 684 | sb_api_key=sb_api_key, 685 | openai_api_key=openai_api_key 686 | ) 687 | analyzer_thread.output_signal.connect(self.output_signal) 688 | analyzer_thread.error_signal.connect(self.error_signal.emit) 689 | analyzer_thread.start() 690 | analyzer_thread.wait() 691 | else: 692 | self.output_signal.emit("The QR Code does not contain a valid URL.
") 693 | # After processing the QR code, do not continue with file upload analysis. 694 | return 695 | except Exception as e: 696 | self.error_signal.emit(f"Error decoding QR Code: {e}") 697 | # --- End: New QR Code handling --- 698 | 699 | if not self.file_path: 700 | self.error_signal.emit("No file selected.") 701 | return 702 | if not os.path.isfile(self.file_path): 703 | self.error_signal.emit("The specified file does not exist.") 704 | return 705 | 706 | self.output_signal.emit("

Attachment Analysis


Uploading file to VirusTotal and Hybrid Analysis for analysis...
") 707 | 708 | try: 709 | vt_thread = VirusTotalUploadThread(self.file_path, self.vt_api_key) 710 | ha_thread = HybridAnalysisUploadThread(self.file_path, self.ha_api_key) 711 | 712 | vt_thread.output_signal.connect(self.emit_output) 713 | vt_thread.error_signal.connect(self.error_signal.emit) 714 | ha_thread.output_signal.connect(self.emit_output) 715 | ha_thread.error_signal.connect(self.error_signal.emit) 716 | 717 | vt_thread.start() 718 | ha_thread.start() 719 | 720 | vt_thread.wait() 721 | ha_thread.wait() 722 | except Exception as e: 723 | self.error_signal.emit(f"Exception during file upload: {e}") 724 | 725 | def emit_output(self, text): 726 | self.output_signal.emit(text) 727 | 728 | 729 | class VirusTotalUploadThread(QThread): 730 | output_signal = pyqtSignal(str) 731 | error_signal = pyqtSignal(str) 732 | 733 | def __init__(self, file_path, vt_api_key): 734 | super().__init__() 735 | self.file_path = file_path 736 | self.vt_api_key = vt_api_key 737 | 738 | def run(self): 739 | try: 740 | headers = {'x-apikey': self.vt_api_key} 741 | with open(self.file_path, 'rb') as f: 742 | files = {'file': (os.path.basename(self.file_path), f)} 743 | response = requests.post('https://www.virustotal.com/api/v3/files', headers=headers, files=files) 744 | 745 | if response.status_code in (200, 201): 746 | analysis_id = response.json()['data']['id'] 747 | analysis_url = f'https://www.virustotal.com/api/v3/analyses/{analysis_id}' 748 | time.sleep(1) 749 | self.output_signal.emit("
File uploaded successfully to VirusTotal. Fetching analysis report...

") 750 | for _ in range(25): 751 | time.sleep(6) 752 | report_response = requests.get(analysis_url, headers=headers) 753 | if report_response.status_code == 200: 754 | status = report_response.json()['data']['attributes']['status'] 755 | if status == 'completed': 756 | report = self.get_file_report(analysis_id) 757 | if report: 758 | self.process_virustotal_file_report(report) 759 | else: 760 | self.error_signal.emit("Could not retrieve VirusTotal report.") 761 | return 762 | self.error_signal.emit("VirusTotal analysis timed out.") 763 | else: 764 | error_message = f"Error uploading file to VirusTotal: {response.status_code} - {response.text}" 765 | self.error_signal.emit(error_message) 766 | except Exception as e: 767 | self.error_signal.emit(f"Exception during VirusTotal file upload: {e}") 768 | 769 | def get_file_report(self, analysis_id): 770 | headers = {'x-apikey': self.vt_api_key} 771 | analysis_url = f'https://www.virustotal.com/api/v3/analyses/{analysis_id}' 772 | try: 773 | response = requests.get(analysis_url, headers=headers) 774 | if response.status_code == 200: 775 | return response.json() 776 | else: 777 | self.error_signal.emit(f"Error fetching VirusTotal analysis report: {response.status_code} - {response.text}") 778 | return None 779 | except Exception as e: 780 | self.error_signal.emit(f"Exception while fetching VirusTotal analysis report: {e}") 781 | return None 782 | 783 | def process_virustotal_file_report(self, vt_report): 784 | output = "

VirusTotal File Report:

" 785 | 786 | try: 787 | attributes = vt_report['data']['attributes'] 788 | stats = attributes['stats'] 789 | 790 | for key, value in stats.items(): 791 | color = '#A4A4A4' 792 | if key.lower() == 'malicious' and value > 0: 793 | color = '#ff6666' 794 | elif key.lower() == 'suspicious' and value > 0: 795 | color = '#ffcc00' 796 | elif key.lower() == 'harmless' and value > 0: 797 | color = '#66b266' 798 | elif key.lower() == 'undetected' and value > 0: 799 | color = '#cccccc' 800 | elif key.lower() == 'timeout' and value > 0: 801 | color = '#4d94ff' 802 | output += f"{key.capitalize()}" 803 | output += f"{value}" 804 | output += "" 805 | 806 | analysis_results = attributes.get('results', {}) 807 | malicious_vendors = [vendor for vendor, result in analysis_results.items() if result.get('category') == 'malicious'] 808 | suspicious_vendors = [vendor for vendor, result in analysis_results.items() if result.get('category') == 'suspicious'] 809 | 810 | if malicious_vendors: 811 | output += "

Malicious Detections by:
" 812 | for vendor in malicious_vendors: 813 | output += f"{vendor}
" 814 | output += "" 815 | if suspicious_vendors: 816 | output += "
Suspicious Detections by:
" 820 | except Exception as e: 821 | self.error_signal.emit(f"Error parsing VirusTotal file report: {e}") 822 | self.output_signal.emit("Skipping further processing of VirusTotal File Report due to parsing error.
") 823 | return 824 | output += "" 825 | self.output_signal.emit(output) 826 | 827 | 828 | class HybridAnalysisUploadThread(QThread): 829 | output_signal = pyqtSignal(str) 830 | error_signal = pyqtSignal(str) 831 | 832 | def __init__(self, file_path, ha_api_key): 833 | super().__init__() 834 | self.file_path = file_path 835 | self.ha_api_key = ha_api_key 836 | 837 | def run(self): 838 | try: 839 | headers = {"User-Agent": "FalconSandbox", "api-key": self.ha_api_key} 840 | upload_endpoint = "https://www.hybrid-analysis.com/api/v2/submit/file" 841 | overview_endpoint = "https://www.hybrid-analysis.com/api/v2/overview" 842 | 843 | with open(self.file_path, 'rb') as file: 844 | files = {"file": file} 845 | data = {"environment_id": 100} 846 | upload_response = requests.post(upload_endpoint, headers=headers, files=files, data=data) 847 | 848 | if upload_response.status_code in [200, 201]: 849 | upload_data = upload_response.json() 850 | hash_value = upload_data.get("sha256") 851 | self.output_signal.emit("File uploaded successfully to Hybrid Analysis. Fetching analysis report...

") 852 | 853 | if hash_value: 854 | lookup_response = requests.get(f"{overview_endpoint}/{hash_value}", headers=headers) 855 | 856 | if lookup_response.status_code == 200: 857 | data = lookup_response.json() 858 | 859 | if data: 860 | verdict = data.get("verdict", "").lower() 861 | threat_score = data.get("threat_score", 0) 862 | verdict_color = '#ff6666' if verdict == "malicious" else 'green' 863 | 864 | try: 865 | threat_score_value = float(threat_score) 866 | if threat_score_value >= 70: 867 | score_color = '#ff6666' 868 | elif threat_score_value >= 40: 869 | score_color = 'orange' 870 | else: 871 | score_color = 'green' 872 | except (ValueError, TypeError): 873 | threat_score_value = 0 874 | score_color = 'gray' 875 | 876 | output = """ 877 |
878 |

Hybrid Analysis Report:

879 | 880 | 881 | 882 | 883 | 884 | 885 | 886 | 887 | 888 | 889 | 890 | 891 | 892 | 893 | 894 | 895 | 896 | 897 | 898 | 899 | 900 | 901 | 902 | 903 | 904 |
FieldValue
File Name{file_name}
Threat Score{threat_score}
Verdict{verdict_capitalized}
Type{file_type}
Tags{tags}
905 |
906 | """.format( 907 | file_name=data.get('last_file_name', 'N/A'), 908 | threat_score=threat_score, 909 | score_color=score_color, 910 | verdict_capitalized=data.get('verdict', 'N/A').capitalize(), 911 | verdict_color=verdict_color, 912 | file_type=data.get('type', 'N/A'), 913 | tags=', '.join(data.get('tags', [])) if data.get('tags') else 'None' 914 | ) 915 | 916 | self.output_signal.emit(output) 917 | else: 918 | self.output_signal.emit("No data found for the provided hash.") 919 | else: 920 | self.error_signal.emit(f"Error during Hybrid Analysis hash lookup: {lookup_response.status_code}, {lookup_response.text}") 921 | else: 922 | self.error_signal.emit("Error: No hash returned from the Hybrid Analysis upload response.") 923 | else: 924 | self.error_signal.emit(f"Error during file upload to Hybrid Analysis: {upload_response.status_code}, {upload_response.text}") 925 | except Exception as e: 926 | self.error_signal.emit(f"Exception during Hybrid Analysis file upload: {e}") 927 | -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import time 4 | import requests 5 | from utils import normalize_url, get_url_id 6 | from config import ( 7 | get_virustotal_api_key, 8 | get_safe_browsing_api_key, 9 | get_urlscan_api_key, 10 | get_openai_api_key, 11 | get_hybrid_analysis_api_key 12 | ) 13 | 14 | def get_virustotal_report(url, api_key): 15 | if not api_key: 16 | print("Skipping VirusTotal because no API key found in config/config.ini...") 17 | return None 18 | 19 | url_id = get_url_id(url) 20 | headers = {'x-apikey': api_key} 21 | vt_url = f'https://www.virustotal.com/api/v3/urls/{url_id}' 22 | response = requests.get(vt_url, headers=headers) 23 | 24 | if response.status_code in (401, 403): 25 | print("Skipping VirusTotal because no API key found in config/config.ini...") 26 | return None 27 | 28 | if response.status_code == 200: 29 | return response.json() 30 | 31 | if response.status_code == 404: 32 | vt_submit_url = 'https://www.virustotal.com/api/v3/urls' 33 | submit_response = requests.post(vt_submit_url, headers=headers, data={'url': url}) 34 | 35 | if submit_response.status_code in (401, 403): 36 | print("Skipping VirusTotal because no API key found in config/config.ini...") 37 | return None 38 | 39 | if submit_response.status_code in (200, 202): 40 | analysis_id = submit_response.json()['data']['id'] 41 | analysis_url = f'https://www.virustotal.com/api/v3/analyses/{analysis_id}' 42 | for _ in range(10): 43 | time.sleep(2) 44 | analysis_response = requests.get(analysis_url, headers=headers) 45 | if analysis_response.status_code == 200: 46 | status = analysis_response.json()['data']['attributes']['status'] 47 | if status == 'completed': 48 | report_response = requests.get(vt_url, headers=headers) 49 | if report_response.status_code == 200: 50 | return report_response.json() 51 | else: 52 | break 53 | return None 54 | return None 55 | 56 | def get_virustotal_ip_report(ip_address, api_key): 57 | if not api_key: 58 | print("Skipping VirusTotal IP Report because no API key found in config/config.ini...") 59 | return None 60 | 61 | headers = {'x-apikey': api_key} 62 | vt_url = f'https://www.virustotal.com/api/v3/ip_addresses/{ip_address}' 63 | response = requests.get(vt_url, headers=headers) 64 | 65 | if response.status_code in (401, 403): 66 | print("Skipping VirusTotal IP Report because no API key found in config/config.ini...") 67 | return None 68 | 69 | if response.status_code == 200: 70 | return response.json() 71 | return None 72 | 73 | def get_urlscan_report(url, api_key): 74 | if not api_key: 75 | print("Skipping URLScan because no API key found in config/config.ini...") 76 | return None 77 | 78 | headers = { 79 | 'API-Key': api_key, 80 | 'Content-Type': 'application/json' 81 | } 82 | payload = { 83 | "url": url, 84 | "visibility": "unlisted" # options: Public, Private, Unlisted 85 | } 86 | response = requests.post('https://urlscan.io/api/v1/scan/', headers=headers, json=payload) 87 | 88 | if response.status_code in (401, 403): 89 | print("Skipping URLScan because no API key found in config/config.ini...") 90 | return None 91 | 92 | if response.status_code == 200: 93 | scan_id = response.json().get('uuid') 94 | result_url = f"https://urlscan.io/api/v1/result/{scan_id}/" 95 | for _ in range(10): 96 | time.sleep(2) 97 | result_response = requests.get(result_url, headers=headers) 98 | if result_response.status_code == 200: 99 | result = result_response.json() 100 | verdict = result.get("verdicts", {}).get("overall", "unknown") 101 | screenshot_url = result.get("task", {}).get("screenshotURL", None) 102 | if screenshot_url: 103 | try: 104 | screenshot_response = requests.get(screenshot_url, timeout=10) 105 | if screenshot_response.status_code == 200: 106 | screenshot_data = screenshot_response.content 107 | screenshot_base64 = base64.b64encode(screenshot_data).decode('utf-8') 108 | return { 109 | "verdict": verdict, 110 | "screenshot": screenshot_base64, 111 | "screenshot_url": screenshot_url 112 | } 113 | else: 114 | return {"verdict": verdict, "screenshot": None, "screenshot_url": screenshot_url} 115 | except Exception as e: 116 | print(f"Error fetching screenshot: {e}") 117 | return {"verdict": verdict, "screenshot": None, "screenshot_url": screenshot_url} 118 | else: 119 | return {"verdict": verdict, "screenshot": None, "screenshot_url": None} 120 | return None 121 | 122 | def check_url_safe_browsing(url, api_key): 123 | if not api_key: 124 | print("Skipping Safe Browsing because no API key found in config/config.ini...") 125 | return None, "Safe Browsing API key missing." 126 | 127 | api_endpoint = f'https://safebrowsing.googleapis.com/v4/threatMatches:find?key={api_key}' 128 | payload = { 129 | "client": { 130 | "clientId": "WhoDAT-InfoSec-Analyzer", 131 | "clientVersion": "1.0" 132 | }, 133 | "threatInfo": { 134 | "threatTypes": ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE"], 135 | "platformTypes": ["ANY_PLATFORM"], 136 | "threatEntryTypes": ["URL"], 137 | "threatEntries": [ 138 | {"url": url} 139 | ] 140 | } 141 | } 142 | try: 143 | response = requests.post(api_endpoint, json=payload, timeout=10) 144 | 145 | if response.status_code in (401, 403): 146 | print("Skipping Safe Browsing because no API key found in config/config.ini...") 147 | return None, "Safe Browsing API key invalid." 148 | 149 | if response.status_code == 200: 150 | result = response.json() 151 | if "matches" in result: 152 | threats = [match['threatType'] for match in result['matches']] 153 | return False, threats 154 | else: 155 | return True, [] 156 | else: 157 | return None, f"Safe Browsing API Error: {response.status_code}" 158 | except Exception as e: 159 | return None, f"Safe Browsing API Exception: {e}" 160 | 161 | def get_openai_analysis(report): 162 | api_key = get_openai_api_key() 163 | if not api_key: 164 | print("Skipping OpenAI because no API key found in config/config.ini...") 165 | return None, "OpenAI API key not provided." 166 | 167 | headers = { 168 | 'Content-Type': 'application/json', 169 | 'Authorization': f'Bearer {api_key}', 170 | } 171 | prompt = f"""Analyze the following security report and determine if the link or email is malicious. Do not repeat the report, simply provide a report summary and score. Classifications: Malicious (Risk score: 10-9), probably malicious (Risk score: 8-7), suspicious (Risk score: 6-4), or safe (Risk score: 3-1). Provide an overall risk score between 1-10. Bullet point your reason behind this decision and be detailed. Do NOT include hyperlinks. Note: Not all services or reports are perfect. VirusTotal IP Scans are NOT perfect since IP address owners can change. If VirusTotal IP Scan or Redirect IPs are the ONLY items listed as malicious, score this as a 6 or less. If Google Safe Browsing marks it as malicious, its malicious. If 5+ VirusTotal Report services mark it has malicious, score this higher. If the website is blocked by safebrowse.io, score this as a 10. Include Classification and Risk Score. Free Email domains should increase risk score. When analyzing email content, consider checking for indicators such as urgency, misspellings, and embedded links. These factors can contribute to a higher risk score. IMPORTANT: ALL links sent to you are DEFANGED by our system for safety! Meaning https will be hxxps, etc. ALL links both safe and malicious will be defanged. EXTREMELY IMPORTANT: You MUST format your output in HTML but do NOT use codeblocks!! 172 | 173 | {report}""" 174 | data = { 175 | "model": "chatgpt-4o-latest", 176 | "messages": [ 177 | {"role": "system", "content": "You are a security analyst. You MUST provide a Risk Score, Classification, and bullet point analysis in HTML format WITHOUT code blocks. Do NOT use Markdown"}, 178 | {"role": "user", "content": prompt} 179 | ], 180 | "max_tokens": 800, 181 | "temperature": 0.8, 182 | } 183 | try: 184 | response = requests.post('https://api.openai.com/v1/chat/completions', headers=headers, json=data) 185 | 186 | if response.status_code in (401, 403): 187 | print("Skipping OpenAI because no API key found in config/config.ini...") 188 | return None, "OpenAI API key invalid." 189 | 190 | if response.status_code == 200: 191 | return response.json()['choices'][0]['message']['content'], None 192 | else: 193 | return None, f"Error communicating with OpenAI API: {response.status_code} - {response.text}" 194 | except Exception as e: 195 | return None, f"Exception during OpenAI API call: {e}" 196 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | # config.py 2 | 3 | import configparser 4 | 5 | def get_virustotal_api_key(): 6 | config = configparser.ConfigParser() 7 | config.read('config/config.ini') 8 | return config.get('VirusTotal', 'api_key') 9 | 10 | def get_safe_browsing_api_key(): 11 | config = configparser.ConfigParser() 12 | config.read('config/config.ini') 13 | if config.has_section('GoogleSafeBrowsing'): 14 | return config.get('GoogleSafeBrowsing', 'api_key') 15 | return None 16 | 17 | def get_urlscan_api_key(): 18 | config = configparser.ConfigParser() 19 | config.read('config/config.ini') 20 | return config.get('Urlscan', 'api_key') 21 | 22 | def get_openai_api_key(): 23 | config = configparser.ConfigParser() 24 | config.read('config/config.ini') 25 | if config.has_section('OpenAI'): 26 | return config.get('OpenAI', 'api_key') 27 | return None 28 | 29 | def get_hybrid_analysis_api_key(): 30 | config = configparser.ConfigParser() 31 | config.read('config/config.ini') 32 | if config.has_section('HybridAnalysis'): 33 | return config.get('HybridAnalysis', 'api_key') 34 | return None 35 | -------------------------------------------------------------------------------- /config/WhoDatBanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calinux-py/WhoDAT/230198ea39de41a58906828b7c4c07eeee190981/config/WhoDatBanner.png -------------------------------------------------------------------------------- /config/config.ini: -------------------------------------------------------------------------------- 1 | [VirusTotal] 2 | api_key = 3 | 4 | [GoogleSafeBrowsing] 5 | api_key = 6 | 7 | [Urlscan] 8 | api_key = 9 | 10 | [OpenAI] 11 | api_key = 12 | 13 | [HybridAnalysis] 14 | api_key = -------------------------------------------------------------------------------- /config/poc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calinux-py/WhoDAT/230198ea39de41a58906828b7c4c07eeee190981/config/poc.gif -------------------------------------------------------------------------------- /config/pocimg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calinux-py/WhoDAT/230198ea39de41a58906828b7c4c07eeee190981/config/pocimg.png -------------------------------------------------------------------------------- /gui.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | import socket 4 | import os 5 | import time 6 | import json 7 | import requests 8 | import urllib.parse 9 | import webbrowser 10 | from datetime import datetime 11 | from PyQt5.QtCore import Qt 12 | from PyQt5.QtGui import QFont, QPixmap, QIcon 13 | from PyQt5.QtWidgets import ( 14 | QMainWindow, QApplication, QTabWidget, QWidget, QVBoxLayout, QTextEdit, 15 | QFormLayout, QLabel, QLineEdit, QPushButton, QTextBrowser, 16 | QHBoxLayout, QMessageBox, QFileDialog 17 | ) 18 | from config import ( 19 | get_virustotal_api_key, 20 | get_safe_browsing_api_key, 21 | get_urlscan_api_key, 22 | get_openai_api_key, 23 | get_hybrid_analysis_api_key 24 | ) 25 | from utils import ( 26 | defang_url, defang_email, defang_domain, 27 | format_field 28 | ) 29 | from analysis import ( 30 | AnalyzerThread, HeaderAnalyzerThread, SentimentAnalyzerThread, 31 | AttachmentAnalyzerThread 32 | ) 33 | 34 | 35 | class MainWindow(QMainWindow): 36 | icon_base64 = "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" 37 | 38 | def __init__(self): 39 | super().__init__() 40 | 41 | pixmap = QPixmap() 42 | if self.icon_base64: 43 | pixmap.loadFromData(base64.b64decode(self.icon_base64)) 44 | self.setWindowIcon(QIcon(pixmap)) 45 | 46 | self.setWindowTitle("WhoDAT - InfoSec Analyzer for Nerds") 47 | self.resize(1000, 630) 48 | self.initUI() 49 | 50 | def initUI(self): 51 | self.tabs = QTabWidget() 52 | self.tabs.setTabPosition(QTabWidget.West) 53 | self.tabs.setMovable(True) 54 | 55 | self.domain_tab = QWidget() 56 | self.header_tab = QWidget() 57 | self.sentiment_tab = QWidget() 58 | self.attachment_tab = QWidget() 59 | 60 | self.tabs.addTab(self.domain_tab, "Domain Analyzer") 61 | self.tabs.addTab(self.header_tab, "Header Analyzer") 62 | self.tabs.addTab(self.sentiment_tab, "Sentiment Analyzer") 63 | self.tabs.addTab(self.attachment_tab, "Attachment Analyzer") 64 | 65 | self.create_domain_tab() 66 | self.create_header_tab() 67 | self.create_sentiment_tab() 68 | self.create_attachment_tab() 69 | 70 | main_layout = QVBoxLayout() 71 | main_layout.addWidget(self.tabs) 72 | central_widget = QWidget() 73 | central_widget.setLayout(main_layout) 74 | self.setCentralWidget(central_widget) 75 | 76 | self.apply_dark_theme() 77 | 78 | def make_urlscan_links(self, text): 79 | 80 | pattern = r'(https?://urlscan\.io/screenshots/[^\s<>"]+)' 81 | return re.sub(pattern, r'\1', text) 82 | 83 | def create_domain_tab(self): 84 | self.email_label = QLabel("Email Address:") 85 | self.email_input = QLineEdit() 86 | self.email_input.setPlaceholderText("Enter email address...") 87 | font = QFont() 88 | font.setItalic(True) 89 | self.email_input.setFont(font) 90 | self.email_input.setStyleSheet("color: grey;") 91 | 92 | self.link_label = QLabel("URL:") 93 | self.link_input = QLineEdit() 94 | self.link_input.setPlaceholderText("Enter URL or link...") 95 | self.link_input.setFont(font) 96 | self.link_input.setStyleSheet("color: grey;") 97 | 98 | self.email_input.returnPressed.connect(self.analyze) 99 | self.link_input.returnPressed.connect(self.analyze) 100 | 101 | self.analyze_button = QPushButton("Analyze") 102 | self.analyze_button.clicked.connect(self.analyze) 103 | 104 | self.output_text = QTextBrowser() 105 | self.output_text.setReadOnly(True) 106 | self.output_text.setFont(QFont("Segoe UI", 11)) 107 | 108 | self.output_text.setOpenExternalLinks(True) 109 | self.output_text.setTextInteractionFlags(Qt.TextBrowserInteraction) 110 | self.output_text.setAcceptRichText(True) 111 | 112 | form_layout = QFormLayout() 113 | form_layout.addRow(self.email_label, self.email_input) 114 | form_layout.addRow(self.link_label, self.link_input) 115 | 116 | input_button_layout = QHBoxLayout() 117 | input_button_layout.addLayout(form_layout) 118 | input_button_layout.addWidget(self.analyze_button, alignment=Qt.AlignRight) 119 | 120 | layout = QVBoxLayout() 121 | layout.addLayout(input_button_layout) 122 | layout.addWidget(self.output_text) 123 | 124 | self.domain_tab.setLayout(layout) 125 | 126 | def create_header_tab(self): 127 | self.header_label = QLabel("Email Headers:") 128 | self.header_input = QTextEdit() 129 | self.header_input.setFixedHeight(150) 130 | self.header_input.setPlaceholderText("Paste email headers here...") 131 | font = QFont() 132 | font.setItalic(True) 133 | self.header_input.setFont(font) 134 | self.header_input.setStyleSheet("color: grey;") 135 | 136 | self.header_output_text = QTextBrowser() 137 | self.header_output_text.setReadOnly(True) 138 | self.header_output_text.setFont(QFont("Segoe UI", 11)) 139 | 140 | self.header_output_text.setOpenExternalLinks(True) 141 | self.header_output_text.setTextInteractionFlags(Qt.TextBrowserInteraction) 142 | self.header_output_text.setAcceptRichText(True) 143 | 144 | layout = QVBoxLayout() 145 | layout.addWidget(self.header_label) 146 | layout.addWidget(self.header_input) 147 | layout.addWidget(self.header_output_text) 148 | self.header_tab.setLayout(layout) 149 | 150 | self.header_input.textChanged.connect(self.analyze_headers) 151 | 152 | def create_sentiment_tab(self): 153 | self.sentiment_label = QLabel("Email Content:") 154 | self.sentiment_input = QTextEdit() 155 | self.sentiment_input.setFixedHeight(150) 156 | self.sentiment_input.setPlaceholderText("Paste suspicious content here...") 157 | font = QFont() 158 | font.setItalic(True) 159 | self.sentiment_input.setFont(font) 160 | self.sentiment_input.setStyleSheet("color: grey;") 161 | 162 | self.sentiment_output_text = QTextBrowser() 163 | self.sentiment_output_text.setReadOnly(True) 164 | self.sentiment_output_text.setFont(QFont("Segoe UI", 11)) 165 | 166 | self.sentiment_output_text.setOpenExternalLinks(True) 167 | self.sentiment_output_text.setTextInteractionFlags(Qt.TextBrowserInteraction) 168 | self.sentiment_output_text.setAcceptRichText(True) 169 | 170 | layout = QVBoxLayout() 171 | layout.addWidget(self.sentiment_label) 172 | layout.addWidget(self.sentiment_input) 173 | layout.addWidget(self.sentiment_output_text) 174 | self.sentiment_tab.setLayout(layout) 175 | 176 | self.sentiment_input.textChanged.connect(self.analyze_sentiment) 177 | 178 | def create_attachment_tab(self): 179 | self.attachment_label = QLabel("File Attachment:") 180 | self.attachment_path_input = QLineEdit() 181 | self.attachment_path_input.setPlaceholderText("Paste file path here...") 182 | font = QFont() 183 | font.setItalic(True) 184 | self.attachment_path_input.setFont(font) 185 | self.attachment_path_input.setStyleSheet("color: grey;") 186 | self.attachment_path_input.returnPressed.connect(self.search_file) 187 | 188 | self.browse_button = QPushButton("Browse") 189 | self.browse_button.setFixedHeight(30) 190 | self.browse_button.clicked.connect(self.browse_file) 191 | 192 | self.search_button = QPushButton("Search") 193 | self.search_button.setFixedHeight(30) 194 | self.search_button.clicked.connect(self.search_file) 195 | 196 | self.attachment_output_text = QTextBrowser() 197 | self.attachment_output_text.setReadOnly(True) 198 | self.attachment_output_text.setFont(QFont("Segoe UI", 11)) 199 | 200 | self.attachment_output_text.setOpenExternalLinks(True) 201 | self.attachment_output_text.setTextInteractionFlags(Qt.TextBrowserInteraction) 202 | self.attachment_output_text.setAcceptRichText(True) 203 | 204 | file_input_layout = QHBoxLayout() 205 | file_input_layout.addWidget(self.attachment_label) 206 | file_input_layout.addWidget(self.attachment_path_input) 207 | file_input_layout.addWidget(self.browse_button) 208 | file_input_layout.addWidget(self.search_button) 209 | 210 | layout = QVBoxLayout() 211 | layout.addLayout(file_input_layout) 212 | layout.addWidget(self.attachment_output_text) 213 | 214 | self.attachment_tab.setLayout(layout) 215 | 216 | def apply_dark_theme(self): 217 | modern_stylesheet = """ 218 | QWidget { 219 | background-color: #2b2b2b; 220 | color: #e0e0e0; 221 | font-family: 'Segoe UI', sans-serif; 222 | } 223 | QLineEdit, QTextEdit, QTextBrowser { 224 | background-color: #3c3f41; 225 | color: #ffffff; 226 | border: 1px solid #555; 227 | padding: 5px; 228 | border-radius: 4px; 229 | } 230 | QPushButton { 231 | background-color: #4a90e2; 232 | color: #ffffff; 233 | border-radius: 5px; 234 | padding: 24px; 235 | font-weight: bold; 236 | } 237 | QPushButton:hover { 238 | background-color: #3a78c2; 239 | } 240 | QLabel { 241 | color: #a9a9a9; 242 | font-weight: bold; 243 | } 244 | QTabWidget::pane { 245 | border: 1px solid #555; 246 | background: #2b2b2b; 247 | } 248 | QTabBar::tab { 249 | background: #3c3f41; 250 | color: #e0e0e0; 251 | padding: 12px; 252 | border-radius: 3px; 253 | margin: 2px; 254 | } 255 | QTabBar::tab:selected { 256 | background: #4a90e2; 257 | color: #ffffff; 258 | } 259 | QTabBar::tab:hover { 260 | background: #3a78c2; 261 | } 262 | QScrollBar:vertical, QScrollBar:horizontal { 263 | background: #2b2b2b; 264 | } 265 | QScrollBar::handle:vertical, QScrollBar::handle:horizontal { 266 | background: #4a90e2; 267 | min-height: 20px; 268 | border-radius: 4px; 269 | } 270 | QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page { 271 | background: none; 272 | } 273 | """ 274 | self.setStyleSheet(modern_stylesheet) 275 | 276 | def analyze(self): 277 | email_input = self.email_input.text().strip() 278 | link = self.link_input.text().strip() 279 | vt_api_key = get_virustotal_api_key() 280 | sb_api_key = get_safe_browsing_api_key() 281 | openai_api_key = get_openai_api_key() 282 | if not email_input and not link: 283 | QMessageBox.warning(self, "Input Error", "Please enter an email address or a link.") 284 | self.output_text.append( 285 | "Skipping analysis because no input was provided.
") 286 | return 287 | self.output_text.clear() 288 | self.output_text.append("
Processing... Please wait.") 289 | self.thread = AnalyzerThread(email_input, link, vt_api_key, sb_api_key, openai_api_key) 290 | self.thread.output_signal.connect(self.append_output) 291 | self.thread.error_signal.connect(self.show_error) 292 | self.thread.start() 293 | 294 | def analyze_headers(self): 295 | headers_text = self.header_input.toPlainText() 296 | if not headers_text.strip(): 297 | return 298 | self.header_output_text.clear() 299 | self.header_output_text.append("
Processing... Please wait.") 300 | self.header_thread = HeaderAnalyzerThread(headers_text) 301 | self.header_thread.output_signal.connect(self.append_header_output) 302 | self.header_thread.error_signal.connect(self.show_header_error) 303 | self.header_thread.start() 304 | 305 | def analyze_sentiment(self): 306 | content = self.sentiment_input.toPlainText() 307 | if not content.strip(): 308 | self.sentiment_output_text.clear() 309 | return 310 | self.sentiment_output_text.clear() 311 | self.sentiment_output_text.append("
Processing... Please wait.") 312 | openai_api_key = get_openai_api_key() 313 | if not openai_api_key: 314 | QMessageBox.warning(self, "API Key Error", "OpenAI API key not provided in config.") 315 | self.sentiment_output_text.append( 316 | "Skipping Sentiment Analysis because OpenAI API key is not provided.
") 317 | return 318 | self.sentiment_thread = SentimentAnalyzerThread(content, openai_api_key) 319 | self.sentiment_thread.output_signal.connect(self.append_sentiment_output) 320 | self.sentiment_thread.error_signal.connect(self.show_sentiment_error) 321 | self.sentiment_thread.start() 322 | 323 | def browse_file(self): 324 | options = QFileDialog.Options() 325 | options |= QFileDialog.ReadOnly 326 | file_path, _ = QFileDialog.getOpenFileName( 327 | self, 328 | "Select File for Analysis", 329 | "", 330 | "All Files (*);;Executable Files (*.exe);;PDF Files (*.pdf)", 331 | options=options 332 | ) 333 | if file_path: 334 | self.attachment_path_input.setText(file_path) 335 | self.attachment_output_text.clear() 336 | self.attachment_output_text.append("
Processing... Please wait.") 337 | vt_api_key = get_virustotal_api_key() 338 | ha_api_key = get_hybrid_analysis_api_key() 339 | if not vt_api_key: 340 | QMessageBox.warning(self, "API Key Error", "VirusTotal API key not provided in config.") 341 | self.attachment_output_text.append( 342 | "Skipping Attachment Analysis because VirusTotal API key is not provided.
") 343 | return 344 | if not ha_api_key: 345 | QMessageBox.warning(self, "API Key Error", "Hybrid Analysis API key not provided in config.") 346 | self.attachment_output_text.append( 347 | "Skipping Hybrid Analysis because Hybrid Analysis API key is not provided.
") 348 | self.attachment_thread = AttachmentAnalyzerThread(file_path, vt_api_key, ha_api_key) 349 | self.attachment_thread.output_signal.connect(self.append_attachment_output) 350 | self.attachment_thread.error_signal.connect(self.show_attachment_error) 351 | self.attachment_thread.start() 352 | 353 | def search_file(self): 354 | file_path = self.attachment_path_input.text().strip().strip("'\"") 355 | if not file_path: 356 | QMessageBox.warning(self, "Input Error", "Please enter a file path.") 357 | return 358 | if not os.path.isfile(file_path): 359 | QMessageBox.warning(self, "File Error", "The specified file does not exist.") 360 | return 361 | self.attachment_output_text.clear() 362 | self.attachment_output_text.append("
Processing... Please wait.") 363 | vt_api_key = get_virustotal_api_key() 364 | ha_api_key = get_hybrid_analysis_api_key() 365 | if not vt_api_key: 366 | QMessageBox.warning(self, "API Key Error", "VirusTotal API key not provided in config.") 367 | self.attachment_output_text.append( 368 | "Skipping Attachment Analysis because VirusTotal API key is not provided.
") 369 | return 370 | if not ha_api_key: 371 | QMessageBox.warning(self, "API Key Error", "Hybrid Analysis API key not provided in config.") 372 | self.attachment_output_text.append( 373 | "Skipping Hybrid Analysis because Hybrid Analysis API key is not provided.
") 374 | self.attachment_thread = AttachmentAnalyzerThread(file_path, vt_api_key, ha_api_key) 375 | self.attachment_thread.output_signal.connect(self.append_attachment_output) 376 | self.attachment_thread.error_signal.connect(self.show_attachment_error) 377 | self.attachment_thread.start() 378 | 379 | def append_output(self, text): 380 | if "Processing... Please wait." in self.output_text.toHtml(): 381 | self.output_text.clear() 382 | linked_text = self.make_urlscan_links(text) 383 | self.output_text.append(linked_text) 384 | 385 | def show_error(self, message): 386 | QMessageBox.warning(self, "Error", message) 387 | 388 | def append_header_output(self, text): 389 | if "Processing... Please wait." in self.header_output_text.toHtml(): 390 | self.header_output_text.clear() 391 | linked_text = self.make_urlscan_links(text) 392 | self.header_output_text.append(linked_text) 393 | 394 | def show_header_error(self, message): 395 | QMessageBox.warning(self, "Error", "Invalid email headers. Please paste valid email header metadata.") 396 | self.header_output_text.append( 397 | "Skipping Header Analysis due to an error.
") 398 | self.header_input.clear() 399 | 400 | def append_sentiment_output(self, text): 401 | if "Processing... Please wait." in self.sentiment_output_text.toHtml(): 402 | self.sentiment_output_text.clear() 403 | linked_text = self.make_urlscan_links(text) 404 | self.sentiment_output_text.append(linked_text) 405 | 406 | def show_sentiment_error(self, message): 407 | QMessageBox.warning(self, "Error", f"Sentiment Analysis Error: {message}") 408 | self.sentiment_output_text.append( 409 | "Skipping Sentiment Analysis due to an error.
") 410 | self.sentiment_input.clear() 411 | 412 | def append_attachment_output(self, text): 413 | if "Processing... Please wait." in self.attachment_output_text.toHtml(): 414 | self.attachment_output_text.clear() 415 | linked_text = self.make_urlscan_links(text) 416 | self.attachment_output_text.append(linked_text) 417 | 418 | def show_attachment_error(self, message): 419 | QMessageBox.warning(self, "Error", f"Attachment Analysis Error: {message}") 420 | self.attachment_output_text.append( 421 | "Skipping Attachment Analysis due to an error.
") 422 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt5 2 | requests 3 | urllib3 4 | configparser 5 | pytz 6 | tldextract 7 | python-whois 8 | dateutil 9 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | # utils.py 2 | 3 | import base64 4 | import re 5 | import urllib.parse 6 | from datetime import datetime 7 | 8 | def defang_url(url): 9 | return url.replace('http://', 'hxxp://').replace('https://', 'hxxps://').replace('.', '[.]') 10 | 11 | def defang_email(email): 12 | return email.replace('@', '[@]').replace('.', '[.]') 13 | 14 | def defang_domain(domain): 15 | return domain.replace('.', '[.]') 16 | 17 | def defang_text(text): 18 | defanged = re.sub(r'http(s)?://', r'hxxp\1://', text) 19 | defanged = defanged.replace('.', '[.]').replace('@', '[@]') 20 | return defanged 21 | 22 | def format_field(field): 23 | if field is None: 24 | return 'N/A' 25 | elif isinstance(field, list): 26 | return ', '.join([format_field(f) for f in field]) 27 | elif isinstance(field, datetime): 28 | return field.strftime('%Y-%m-%d') 29 | else: 30 | return str(field) 31 | 32 | def normalize_url(url): 33 | parsed = urllib.parse.urlparse(url) 34 | parsed = parsed._replace(fragment='') 35 | scheme = parsed.scheme.lower() 36 | hostname = parsed.hostname.encode('idna').decode('ascii').lower() if parsed.hostname else '' 37 | port = parsed.port 38 | if (scheme == 'http' and port == 80) or (scheme == 'https' and port == 443): 39 | netloc = hostname 40 | elif port: 41 | netloc = f"{hostname}:{port}" 42 | else: 43 | netloc = hostname 44 | path = re.sub(r'/{2,}', '/', parsed.path or '') 45 | query = parsed.query 46 | if query: 47 | query_params = urllib.parse.parse_qsl(query, keep_blank_values=True) 48 | query = urllib.parse.urlencode(sorted(query_params)) 49 | else: 50 | query = '' 51 | normalized = urllib.parse.urlunparse((scheme, netloc, path, '', query, '')) 52 | return normalized 53 | 54 | def get_url_id(url): 55 | normalized = normalize_url(url) 56 | url_bytes = normalized.encode('utf-8') 57 | url_id = base64.urlsafe_b64encode(url_bytes).decode('utf-8').strip('=') 58 | return url_id 59 | -------------------------------------------------------------------------------- /whodat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calinux-py/WhoDAT/230198ea39de41a58906828b7c4c07eeee190981/whodat.png -------------------------------------------------------------------------------- /whodat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PyQt5.QtCore import Qt 3 | from PyQt5.QtWidgets import QApplication 4 | from PyQt5.QtGui import QFont 5 | from gui import MainWindow 6 | 7 | if __name__ == "__main__": 8 | QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) 9 | QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) 10 | 11 | app = QApplication(sys.argv) 12 | window = MainWindow() 13 | window.show() 14 | font = QFont() 15 | font.setPointSize(9) 16 | app.setFont(font) 17 | sys.exit(app.exec()) 18 | --------------------------------------------------------------------------------