├── 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 | 
2 | # [
](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 |       
9 |
10 | [
](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 | [
](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"{key.capitalize()} | "
301 | output += f"{value} |
"
302 | output += "
"
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 | Field |
422 | Details |
423 |
424 |
425 |
426 |
427 | Domain |
428 | {defanged_domain} |
429 |
430 |
431 | Domain Registration Date |
432 |
433 | {format_field(registration_date)}
434 | |
435 |
436 |
437 | Registrant Country |
438 |
439 | {format_field(registrant_country)}
440 | |
441 |
442 |
443 | Domain Expiration Date |
444 | {format_field(domain_info.expiration_date)} |
445 |
446 |
447 | Registrant Name |
448 | {format_field(domain_info.name)} |
449 |
450 |
451 | Registrant Organization |
452 | {format_field(domain_info.org)} |
453 |
454 |
455 | Contact Email |
456 | {defang_email(format_field(domain_info.emails))} |
457 |
458 |
459 | Registrar Information |
460 | {format_field(domain_info.registrar)} |
461 |
462 |
463 | Name Servers |
464 | {format_field(domain_info.name_servers)} |
465 |
466 |
467 | Domain Status |
468 | {format_field(domain_info.status)} |
469 |
470 |
471 | Last Updated Date |
472 | {format_field(domain_info.updated_date)} |
473 |
474 |
475 |
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:
"
817 | for vendor in suspicious_vendors:
818 | output += f"{vendor}
"
819 | output += "
"
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 | Field |
882 | Value |
883 |
884 |
885 | File Name |
886 | {file_name} |
887 |
888 |
889 | Threat Score |
890 | {threat_score} |
891 |
892 |
893 | Verdict |
894 | {verdict_capitalized} |
895 |
896 |
897 | Type |
898 | {file_type} |
899 |
900 |
901 | Tags |
902 | {tags} |
903 |
904 |
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 |
--------------------------------------------------------------------------------