desant-ai-security commited on
Commit
c868edc
·
verified ·
1 Parent(s): fe4153c

Publish Space copy from original source

Browse files
Files changed (4) hide show
  1. README.md +87 -7
  2. app.py +1920 -0
  3. packages.txt +23 -0
  4. requirements.txt +4 -0
README.md CHANGED
@@ -1,12 +1,92 @@
1
  ---
2
- title: Desant Anti Phishing Inferencing Copy 20260308 222846
3
- emoji: 🦀
4
- colorFrom: green
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.9.0
8
  app_file: app.py
9
- pinned: false
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Desant Phishing Detection
3
+ emoji: 🛡️
4
+ colorFrom: red
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: "5.50.0"
8
  app_file: app.py
9
+ pinned: true
10
+ license: mit
11
+ short_description: CLIP-based phishing screenshot classifier by Desant.ai
12
+ tags:
13
+ - image-classification
14
+ - clip
15
+ - openclip
16
+ - phishing-detection
17
+ - cybersecurity
18
+ - security
19
  ---
20
 
21
+ # Erna Phishing Detection.
22
+
23
+ **AI-powered phishing detection using CLIP vision models — by [Desant.ai](https://desant.ai)**
24
+
25
+ ## What it does
26
+
27
+ Upload a screenshot of any web page and our deep learning model will classify
28
+ it as **safe** or **malicious (phishing)**. The model was trained on thousands
29
+ of real phishing and legitimate login page screenshots.
30
+
31
+ ## How it works
32
+
33
+ 1. **You upload** a screenshot (PNG, JPEG) of a web page.
34
+ 2. **Our CLIP-based model** extracts visual features from the screenshot using a
35
+ frozen OpenCLIP vision encoder (ViT-B/32).
36
+ 3. **A custom classifier head** (3-layer MLP with dropout) produces a phishing
37
+ probability score.
38
+ 4. **The verdict** is returned: SAFE or MALICIOUS, with a confidence score and
39
+ detailed timing metrics.
40
+
41
+ ## Model Architecture
42
+
43
+ ```
44
+ Input Image (screenshot)
45
+
46
+
47
+ ┌─────────────────────────┐
48
+ │ OpenCLIP ViT-B/32 │ ← Frozen pre-trained encoder
49
+ │ Vision Encoder │
50
+ └────────┬────────────────┘
51
+ │ 512-dim features
52
+
53
+ ┌─────────────────────────┐
54
+ │ Classifier Head │
55
+ │ Dropout(0.5) │
56
+ │ Linear(512 → 512) │
57
+ │ ReLU │
58
+ │ Dropout(0.3) │
59
+ │ Linear(512 → 128) │
60
+ │ ReLU │
61
+ │ Dropout(0.2) │
62
+ │ Linear(128 → 2) │ ← [safe, malicious]
63
+ └────────┬────────────────┘
64
+
65
+
66
+ Softmax → Probability
67
+ ```
68
+
69
+ ## Training Data
70
+
71
+ - **Malicious class**: Real phishing login form screenshots collected from
72
+ PhishTank, OpenPhish, URLhaus, and AlienVault OTX
73
+ - **Safe class**: Legitimate login pages, search engines, and normal web pages
74
+ - **Resolution**: 1920×941 screenshots, preprocessed to 224×224 for CLIP
75
+ - **Augmentation**: Horizontal flip + color jitter
76
+
77
+ ## Performance
78
+
79
+ | Metric | Score |
80
+ |---|---|
81
+ | Accuracy | 92–96% |
82
+ | Malicious Recall | 90–95% |
83
+ | False Positive Rate | 3–8% |
84
+
85
+ ## API
86
+
87
+ This Space connects to the Erna inference API at `api.ernacyberops.com` running
88
+ on GPU. No local model loading is required — inference happens on our servers.
89
+
90
+ ## Built by
91
+
92
+ **[Desant.ai](https://desant.ai)** — Advanced cybersecurity through AI.
app.py ADDED
@@ -0,0 +1,1920 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Desant Phishing Detection — Hugging Face Space Demo
3
+ ====================================================
4
+ Interactive demo for the CLIP-based phishing screenshot classifier
5
+ by Desant.ai. Sends screenshots to the production inference backend
6
+ at api.ernacyberops.com and displays results.
7
+ """
8
+
9
+ import html
10
+ import io
11
+ import ipaddress
12
+ import json
13
+ import logging
14
+ import os
15
+ import re
16
+ import secrets
17
+ import socket
18
+ import subprocess
19
+ import threading
20
+ import time
21
+ import uuid
22
+ from collections import defaultdict
23
+ from urllib.parse import urlparse
24
+
25
+ import gradio as gr
26
+ import requests
27
+ from PIL import Image
28
+
29
+ MAX_IMAGE_PIXELS = 25_000_000
30
+ Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
31
+ MAX_IMAGE_DIMENSION = 4096
32
+ MAX_IMAGE_BYTES = 10 * 1024 * 1024
33
+ MAX_API_RESPONSE_BYTES = 5 * 1024 * 1024
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Configuration — sensitive values come from HF Space Secrets
37
+ # ---------------------------------------------------------------------------
38
+ API_BASE_URL = os.environ.get("API_BASE_URL", "https://api.ernacyberops.com")
39
+ DEMO_EXTENSION_ID = os.environ.get("DEMO_EXTENSION_ID", "huggingface-demo-desantai")
40
+ DEMO_INSTALLATION_ID = os.environ.get("DEMO_INSTALLATION_ID", uuid.uuid4().hex)
41
+
42
+ logging.basicConfig(level=logging.INFO)
43
+ logger = logging.getLogger("erna-demo")
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # SSRF protection — URL validation
47
+ # ---------------------------------------------------------------------------
48
+ _BLOCKED_NETWORKS = [
49
+ ipaddress.ip_network("0.0.0.0/8"),
50
+ ipaddress.ip_network("10.0.0.0/8"),
51
+ ipaddress.ip_network("100.64.0.0/10"),
52
+ ipaddress.ip_network("127.0.0.0/8"),
53
+ ipaddress.ip_network("169.254.0.0/16"),
54
+ ipaddress.ip_network("172.16.0.0/12"),
55
+ ipaddress.ip_network("192.0.0.0/24"),
56
+ ipaddress.ip_network("192.0.2.0/24"),
57
+ ipaddress.ip_network("192.88.99.0/24"),
58
+ ipaddress.ip_network("192.168.0.0/16"),
59
+ ipaddress.ip_network("198.18.0.0/15"),
60
+ ipaddress.ip_network("198.51.100.0/24"),
61
+ ipaddress.ip_network("203.0.113.0/24"),
62
+ ipaddress.ip_network("224.0.0.0/4"),
63
+ ipaddress.ip_network("240.0.0.0/4"),
64
+ ipaddress.ip_network("255.255.255.255/32"),
65
+ ipaddress.ip_network("::1/128"),
66
+ ipaddress.ip_network("fc00::/7"),
67
+ ipaddress.ip_network("fe80::/10"),
68
+ ipaddress.ip_network("::ffff:127.0.0.0/104"),
69
+ ipaddress.ip_network("::ffff:10.0.0.0/104"),
70
+ ipaddress.ip_network("::ffff:172.16.0.0/108"),
71
+ ipaddress.ip_network("::ffff:192.168.0.0/112"),
72
+ ipaddress.ip_network("::ffff:169.254.0.0/112"),
73
+ ]
74
+
75
+ _BLOCKED_HOSTNAMES = {
76
+ "localhost",
77
+ "metadata.google.internal",
78
+ "metadata.google.com",
79
+ "metadata",
80
+ }
81
+
82
+
83
+ def _validate_url(raw_url: str) -> str:
84
+ """Validate and sanitize a URL, blocking SSRF targets. Returns the safe URL."""
85
+ raw_url = raw_url.strip()
86
+ if not raw_url:
87
+ raise gr.Error("URL cannot be empty.")
88
+
89
+ if not re.match(r"^https?://", raw_url, re.IGNORECASE):
90
+ raw_url = "https://" + raw_url
91
+
92
+ parsed = urlparse(raw_url)
93
+
94
+ if parsed.scheme not in ("http", "https"):
95
+ raise gr.Error("Only http and https URLs are allowed.")
96
+
97
+ hostname = parsed.hostname
98
+ if not hostname:
99
+ raise gr.Error("Invalid URL: no hostname found.")
100
+
101
+ if hostname in _BLOCKED_HOSTNAMES:
102
+ raise gr.Error("This hostname is not allowed.")
103
+
104
+ if parsed.username or parsed.password:
105
+ raise gr.Error("URLs with embedded credentials are not allowed.")
106
+
107
+ if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", hostname) or ":" in hostname:
108
+ try:
109
+ addr = ipaddress.ip_address(hostname)
110
+ except ValueError:
111
+ raise gr.Error("Invalid IP address in URL.")
112
+ for network in _BLOCKED_NETWORKS:
113
+ if addr in network:
114
+ raise gr.Error("URLs pointing to internal/private networks are not allowed.")
115
+ else:
116
+ try:
117
+ resolved = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
118
+ except socket.gaierror:
119
+ raise gr.Error("Could not resolve hostname. Please check the URL.")
120
+ for family, _, _, _, sockaddr in resolved:
121
+ ip_str = sockaddr[0]
122
+ try:
123
+ addr = ipaddress.ip_address(ip_str)
124
+ except ValueError:
125
+ continue
126
+ for network in _BLOCKED_NETWORKS:
127
+ if addr in network:
128
+ raise gr.Error("This URL resolves to an internal/private address and is not allowed.")
129
+
130
+ if parsed.port and parsed.port not in (80, 443, 8080, 8443):
131
+ raise gr.Error("Non-standard ports are not allowed for security reasons.")
132
+
133
+ return raw_url
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # SSRF route guard — real-time DNS check for Playwright sub-requests
138
+ # ---------------------------------------------------------------------------
139
+ _ALLOWED_BROWSER_SCHEMES = frozenset({"http", "https"})
140
+
141
+
142
+ def _is_safe_request_target(url: str, dns_cache: dict | None = None) -> bool:
143
+ """Return True if *url* does not target internal/private networks.
144
+
145
+ Used by the Playwright route handler to perform DNS validation at
146
+ actual request time, closing the TOCTOU window between the initial
147
+ ``_validate_url`` check and the browser's own DNS resolution.
148
+
149
+ *dns_cache* is an optional ``{hostname: bool}`` dict scoped to a
150
+ single page-load. It avoids redundant ``getaddrinfo`` calls for the
151
+ dozens of sub-resources a typical page fetches, which otherwise push
152
+ the page-load past the navigation timeout.
153
+ """
154
+ try:
155
+ parsed = urlparse(url)
156
+ except Exception:
157
+ return False
158
+ if parsed.scheme not in _ALLOWED_BROWSER_SCHEMES:
159
+ return False
160
+ hostname = parsed.hostname
161
+ if not hostname:
162
+ return False
163
+ if hostname in _BLOCKED_HOSTNAMES:
164
+ return False
165
+
166
+ if dns_cache is not None and hostname in dns_cache:
167
+ return dns_cache[hostname]
168
+
169
+ safe = True
170
+ if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", hostname) or ":" in hostname:
171
+ try:
172
+ addr = ipaddress.ip_address(hostname)
173
+ except ValueError:
174
+ safe = False
175
+ else:
176
+ for network in _BLOCKED_NETWORKS:
177
+ if addr in network:
178
+ safe = False
179
+ break
180
+ else:
181
+ try:
182
+ resolved = socket.getaddrinfo(
183
+ hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
184
+ )
185
+ except socket.gaierror:
186
+ safe = False
187
+ else:
188
+ for _family, _, _, _, sockaddr in resolved:
189
+ try:
190
+ addr = ipaddress.ip_address(sockaddr[0])
191
+ except ValueError:
192
+ continue
193
+ for network in _BLOCKED_NETWORKS:
194
+ if addr in network:
195
+ safe = False
196
+ break
197
+ if not safe:
198
+ break
199
+
200
+ if dns_cache is not None:
201
+ dns_cache[hostname] = safe
202
+ return safe
203
+
204
+
205
+ def _make_ssrf_route_guard(dns_cache: dict):
206
+ """Create a Playwright route handler with a shared DNS cache."""
207
+ def _guard(route) -> None:
208
+ if _is_safe_request_target(route.request.url, dns_cache):
209
+ route.continue_()
210
+ else:
211
+ logger.warning(
212
+ "Blocked browser request to restricted target: %s",
213
+ urlparse(route.request.url).hostname,
214
+ )
215
+ route.abort("blockedbyclient")
216
+ return _guard
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Image validation
221
+ # ---------------------------------------------------------------------------
222
+ def _validate_image(image: Image.Image) -> Image.Image:
223
+ """Validate image dimensions and apply safety limits."""
224
+ width, height = image.size
225
+ if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
226
+ raise gr.Error(
227
+ f"Image too large ({width}x{height}). "
228
+ f"Maximum allowed dimension is {MAX_IMAGE_DIMENSION}px."
229
+ )
230
+ if width * height > MAX_IMAGE_PIXELS:
231
+ raise gr.Error("Image has too many pixels. Please use a smaller image.")
232
+
233
+ if image.mode not in ("RGB", "RGBA", "L"):
234
+ image = image.convert("RGB")
235
+
236
+ return image
237
+
238
+
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Rate limiting — per-feature, in-memory
243
+ # ---------------------------------------------------------------------------
244
+ class _RateLimiter:
245
+ """Simple sliding-window rate limiter keyed by client identifier."""
246
+
247
+ def __init__(self, max_calls: int, window_seconds: float):
248
+ self._max_calls = max_calls
249
+ self._window = window_seconds
250
+ self._calls: dict[str, list[float]] = defaultdict(list)
251
+ self._lock = threading.Lock()
252
+
253
+ def check(self, key: str = "global") -> None:
254
+ now = time.monotonic()
255
+ with self._lock:
256
+ timestamps = self._calls[key]
257
+ cutoff = now - self._window
258
+ self._calls[key] = [t for t in timestamps if t > cutoff]
259
+ if len(self._calls[key]) >= self._max_calls:
260
+ raise gr.Error(
261
+ "Rate limit exceeded. Please wait a moment before trying again."
262
+ )
263
+ self._calls[key].append(now)
264
+
265
+
266
+ _screenshot_limiter = _RateLimiter(max_calls=5, window_seconds=60)
267
+ _predict_limiter = _RateLimiter(max_calls=20, window_seconds=60)
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # JWT token management — thread-safe
272
+ # ---------------------------------------------------------------------------
273
+ _token_cache: dict = {"access_token": None, "refresh_token": None, "expires_at": 0}
274
+ _token_lock = threading.Lock()
275
+
276
+
277
+ def _get_jwt_token() -> str:
278
+ """Obtain a valid JWT access token, refreshing or generating as needed."""
279
+ with _token_lock:
280
+ now = time.time()
281
+
282
+ if _token_cache["access_token"] and now < _token_cache["expires_at"] - 60:
283
+ return _token_cache["access_token"]
284
+
285
+ if _token_cache["refresh_token"]:
286
+ try:
287
+ resp = requests.post(
288
+ f"{API_BASE_URL}/auth/refresh",
289
+ json={
290
+ "refresh_token": _token_cache["refresh_token"],
291
+ "extension_id": DEMO_EXTENSION_ID,
292
+ },
293
+ timeout=10,
294
+ verify=True,
295
+ )
296
+ if resp.status_code == 200:
297
+ data = resp.json()
298
+ tokens = data.get("tokens", data)
299
+ _token_cache["access_token"] = tokens["access_token"]
300
+ _token_cache["refresh_token"] = tokens.get(
301
+ "refresh_token", _token_cache["refresh_token"]
302
+ )
303
+ _token_cache["expires_at"] = now + tokens.get("expires_in", 3600)
304
+ logger.info("JWT token refreshed successfully")
305
+ return _token_cache["access_token"]
306
+ except Exception as exc:
307
+ logger.warning("Token refresh failed: %s", type(exc).__name__)
308
+
309
+ try:
310
+ resp = requests.post(
311
+ f"{API_BASE_URL}/auth/token",
312
+ json={
313
+ "extension_id": DEMO_EXTENSION_ID,
314
+ "installation_id": DEMO_INSTALLATION_ID,
315
+ "grant_type": "client_credentials",
316
+ },
317
+ timeout=10,
318
+ verify=True,
319
+ )
320
+ resp.raise_for_status()
321
+ data = resp.json()
322
+ tokens = data.get("tokens", data)
323
+ _token_cache["access_token"] = tokens["access_token"]
324
+ _token_cache["refresh_token"] = tokens.get("refresh_token")
325
+ _token_cache["expires_at"] = now + tokens.get("expires_in", 3600)
326
+ logger.info("New JWT tokens generated successfully")
327
+ return _token_cache["access_token"]
328
+ except Exception as exc:
329
+ logger.error("Token generation failed: %s", type(exc).__name__)
330
+ raise gr.Error(
331
+ "Could not authenticate with the Erna backend. "
332
+ "Please try again later or contact support."
333
+ )
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Playwright browser management — persistent instance for fast screenshots
338
+ #
339
+ # Playwright's sync API uses greenlets that are bound to the OS thread that
340
+ # created them. Gradio dispatches requests across a thread-pool, so we
341
+ # funnel *all* Playwright work through a single dedicated worker thread via
342
+ # a ThreadPoolExecutor(max_workers=1). The Chromium process stays alive
343
+ # between requests, eliminating the ~10 s cold-start overhead.
344
+ # ---------------------------------------------------------------------------
345
+ from concurrent.futures import ThreadPoolExecutor
346
+
347
+ _browser_installed: bool = False
348
+ _pw_instance = None
349
+ _pw_browser = None
350
+
351
+ _CHROMIUM_ARGS = [
352
+ "--no-sandbox",
353
+ "--disable-dev-shm-usage",
354
+ "--disable-background-networking",
355
+ "--disable-default-apps",
356
+ "--disable-extensions",
357
+ "--disable-sync",
358
+ "--disable-translate",
359
+ "--no-first-run",
360
+ ]
361
+
362
+ _pw_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="playwright")
363
+
364
+
365
+ def _ensure_playwright_browser() -> None:
366
+ """Install the Playwright Chromium browser if it hasn't been installed yet."""
367
+ global _browser_installed
368
+ if _browser_installed:
369
+ return
370
+ try:
371
+ subprocess.run(
372
+ ["playwright", "install", "chromium"],
373
+ check=True,
374
+ capture_output=True,
375
+ timeout=180,
376
+ )
377
+ _browser_installed = True
378
+ logger.info("Playwright Chromium installed successfully")
379
+ except FileNotFoundError:
380
+ raise gr.Error(
381
+ "Playwright is not installed. Cannot capture screenshots from URLs. "
382
+ "Please upload a screenshot manually."
383
+ )
384
+ except subprocess.TimeoutExpired:
385
+ raise gr.Error(
386
+ "Browser installation timed out. Please try again or upload a screenshot manually."
387
+ )
388
+ except subprocess.CalledProcessError:
389
+ logger.error("Playwright install failed", exc_info=True)
390
+ raise gr.Error(
391
+ "Could not install the browser engine. Please upload a screenshot manually."
392
+ )
393
+
394
+
395
+ def _launch_browser() -> None:
396
+ """(Re)launch the persistent Chromium browser. Runs inside _pw_executor."""
397
+ global _pw_instance, _pw_browser
398
+
399
+ if _pw_browser:
400
+ try:
401
+ _pw_browser.close()
402
+ except Exception:
403
+ pass
404
+ if _pw_instance:
405
+ try:
406
+ _pw_instance.stop()
407
+ except Exception:
408
+ pass
409
+
410
+ _ensure_playwright_browser()
411
+
412
+ from playwright.sync_api import sync_playwright
413
+
414
+ _pw_instance = sync_playwright().start()
415
+ _pw_browser = _pw_instance.chromium.launch(
416
+ headless=True, args=_CHROMIUM_ARGS,
417
+ )
418
+ logger.info("Persistent Chromium browser launched")
419
+
420
+
421
+ def _take_screenshot(url: str) -> Image.Image:
422
+ """Navigate to *url* and return a PIL screenshot. Runs inside _pw_executor."""
423
+ global _pw_browser
424
+
425
+ if not _pw_browser or not _pw_browser.is_connected():
426
+ _launch_browser()
427
+
428
+ context = _pw_browser.new_context(
429
+ viewport={"width": 1280, "height": 720},
430
+ java_script_enabled=True,
431
+ bypass_csp=False,
432
+ )
433
+ try:
434
+ page = context.new_page()
435
+ page.route("**/*", _make_ssrf_route_guard(dns_cache={}))
436
+
437
+ page.goto(url, wait_until="networkidle", timeout=30_000)
438
+ page.wait_for_timeout(1_000)
439
+
440
+ final_url = page.url
441
+ _validate_url(final_url)
442
+
443
+ png_bytes = page.screenshot(type="png", full_page=False)
444
+ return Image.open(io.BytesIO(png_bytes))
445
+ finally:
446
+ try:
447
+ context.close()
448
+ except Exception:
449
+ pass
450
+
451
+
452
+ # Preload: install binary + launch Chromium so the first request is fast.
453
+ _pw_executor.submit(_launch_browser)
454
+
455
+
456
+ def _capture_screenshot_from_url(url: str) -> Image.Image:
457
+ """Navigate to *url* in a headless browser and return a PIL screenshot."""
458
+ url = _validate_url(url)
459
+ _screenshot_limiter.check()
460
+
461
+ try:
462
+ future = _pw_executor.submit(_take_screenshot, url)
463
+ return future.result(timeout=45)
464
+ except gr.Error:
465
+ raise
466
+ except TimeoutError:
467
+ raise gr.Error(
468
+ "Screenshot capture timed out. "
469
+ "Please try again or upload a screenshot manually."
470
+ )
471
+ except Exception:
472
+ logger.error("Screenshot capture failed for URL", exc_info=True)
473
+ raise gr.Error(
474
+ "Could not capture a screenshot of the provided URL. "
475
+ "Please check the URL and try again, or upload a screenshot manually."
476
+ )
477
+
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # Prediction helper
481
+ # ---------------------------------------------------------------------------
482
+ def _call_predict(image: Image.Image, url: str | None = None) -> dict:
483
+ """Send an image to the /predict endpoint and return the JSON response."""
484
+ _predict_limiter.check()
485
+ token = _get_jwt_token()
486
+
487
+ buf = io.BytesIO()
488
+ image.save(buf, format="PNG")
489
+ buf.seek(0)
490
+
491
+ if buf.getbuffer().nbytes > MAX_IMAGE_BYTES:
492
+ raise gr.Error(
493
+ f"Encoded image exceeds {MAX_IMAGE_BYTES // (1024 * 1024)}MB limit."
494
+ )
495
+
496
+ headers = {
497
+ "Authorization": f"Bearer {token}",
498
+ "X-CSRF-Token": secrets.token_hex(16),
499
+ "X-Extension-ID": DEMO_EXTENSION_ID,
500
+ "X-Installation-ID": DEMO_INSTALLATION_ID,
501
+ "X-Extension-Version": "hf-demo-1.0",
502
+ }
503
+ if url:
504
+ headers["X-Source-URL"] = url
505
+
506
+ files = {"image": ("screenshot.png", buf, "image/png")}
507
+ form_data = {}
508
+ if url:
509
+ form_data["url"] = url
510
+
511
+ resp = requests.post(
512
+ f"{API_BASE_URL}/predict",
513
+ headers=headers,
514
+ files=files,
515
+ data=form_data,
516
+ timeout=30,
517
+ verify=True,
518
+ )
519
+
520
+ if resp.status_code == 401:
521
+ with _token_lock:
522
+ _token_cache["access_token"] = None
523
+ token = _get_jwt_token()
524
+ headers["Authorization"] = f"Bearer {token}"
525
+ buf.seek(0)
526
+ files = {"image": ("screenshot.png", buf, "image/png")}
527
+ resp = requests.post(
528
+ f"{API_BASE_URL}/predict",
529
+ headers=headers,
530
+ files=files,
531
+ data=form_data,
532
+ timeout=30,
533
+ verify=True,
534
+ )
535
+
536
+ resp.raise_for_status()
537
+ if len(resp.content) > MAX_API_RESPONSE_BYTES:
538
+ raise gr.Error("API response exceeds the allowed size limit.")
539
+ return resp.json()
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # Safe numeric extraction from API response
544
+ # ---------------------------------------------------------------------------
545
+ def _safe_float(value: object, default: float = 0.0) -> float:
546
+ """Coerce a value to float, returning *default* on failure."""
547
+ try:
548
+ result = float(value)
549
+ except (TypeError, ValueError):
550
+ return default
551
+ if not (result == result): # NaN check
552
+ return default
553
+ return result
554
+
555
+
556
+ def _safe_int(value: object, default: int = -1) -> int:
557
+ try:
558
+ return int(value)
559
+ except (TypeError, ValueError):
560
+ return default
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # SVG Icons — static, safe, no user input
565
+ # ---------------------------------------------------------------------------
566
+ _SHIELD_CHECK_SVG = (
567
+ '<svg width="52" height="52" viewBox="0 0 24 24" fill="none" '
568
+ 'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
569
+ '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
570
+ 'fill="currentColor" opacity="0.12"/>'
571
+ '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
572
+ 'stroke="currentColor" stroke-width="1.5" fill="none"/>'
573
+ '<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" '
574
+ 'stroke-linecap="round" stroke-linejoin="round"/>'
575
+ '</svg>'
576
+ )
577
+
578
+ _SHIELD_ALERT_SVG = (
579
+ '<svg width="52" height="52" viewBox="0 0 24 24" fill="none" '
580
+ 'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
581
+ '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
582
+ 'fill="currentColor" opacity="0.12"/>'
583
+ '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
584
+ 'stroke="currentColor" stroke-width="1.5" fill="none"/>'
585
+ '<path d="M12 8v4" stroke="currentColor" stroke-width="2" '
586
+ 'stroke-linecap="round"/>'
587
+ '<circle cx="12" cy="16" r="1" fill="currentColor"/>'
588
+ '</svg>'
589
+ )
590
+
591
+ _LINK_SVG = (
592
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" '
593
+ 'stroke="currentColor" stroke-width="2" aria-hidden="true">'
594
+ '<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/>'
595
+ '<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>'
596
+ '</svg>'
597
+ )
598
+
599
+ _BOLT_SVG = (
600
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" '
601
+ 'stroke="currentColor" stroke-width="2" aria-hidden="true">'
602
+ '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>'
603
+ '</svg>'
604
+ )
605
+
606
+ _PLACEHOLDER_SVG = (
607
+ '<svg width="64" height="64" viewBox="0 0 24 24" fill="none" '
608
+ 'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
609
+ '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
610
+ 'fill="none" stroke="currentColor" stroke-width="1" opacity="0.3"/>'
611
+ '<path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="1.5" '
612
+ 'stroke-linecap="round" opacity="0.3"/>'
613
+ '</svg>'
614
+ )
615
+
616
+
617
+ # ---------------------------------------------------------------------------
618
+ # HTML Templates — static content, no user input
619
+ # ---------------------------------------------------------------------------
620
+ _HERO_HTML = f"""
621
+ <div class="erna-hero">
622
+ <div class="erna-hero__scanline" aria-hidden="true"></div>
623
+ <div class="erna-hero__content">
624
+ <div class="erna-hero__badge">DESANT.AI</div>
625
+ <h1 class="erna-hero__title">Phishing Detection Engine</h1>
626
+ <p class="erna-hero__subtitle">
627
+ CLIP-based deep learning model for real-time phishing screenshot
628
+ classification. Upload a screenshot or paste a URL and our AI will
629
+ analyze it for phishing indicators.
630
+ </p>
631
+ <div class="erna-hero__tags">
632
+ <span class="erna-tag">GPU Inference</span>
633
+ <span class="erna-tag">OpenCLIP RN50x64</span>
634
+ <span class="erna-tag">Real-time Analysis</span>
635
+ <span class="erna-tag">Production API</span>
636
+ </div>
637
+ </div>
638
+ </div>
639
+ """
640
+
641
+ _PLACEHOLDER_HTML = f"""
642
+ <div class="erna-placeholder" role="status">
643
+ <div class="erna-placeholder__icon">{_PLACEHOLDER_SVG}</div>
644
+ <p class="erna-placeholder__text">Upload a screenshot or enter a URL to begin security analysis</p>
645
+ <p class="erna-placeholder__hint">Results will appear here after analysis</p>
646
+ </div>
647
+ """
648
+
649
+
650
+ def _build_error_html(message: str) -> str:
651
+ safe_msg = (
652
+ message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
653
+ )
654
+ return (
655
+ '<div class="erna-placeholder" role="alert">'
656
+ '<div class="erna-placeholder__icon">'
657
+ '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" '
658
+ 'stroke="#f87171" stroke-width="2" stroke-linecap="round" '
659
+ 'stroke-linejoin="round">'
660
+ '<circle cx="12" cy="12" r="10"/>'
661
+ '<line x1="15" y1="9" x2="9" y2="15"/>'
662
+ '<line x1="9" y1="9" x2="15" y2="15"/>'
663
+ '</svg></div>'
664
+ f'<p class="erna-placeholder__text" style="color:#f87171">{safe_msg}</p>'
665
+ '</div>'
666
+ )
667
+
668
+
669
+ _HOW_IT_WORKS_HTML = """
670
+ <div class="erna-how">
671
+ <p class="erna-how__title">How it works</p>
672
+ <div class="erna-how__steps">
673
+ <div class="erna-how__step">
674
+ <span class="erna-how__num">1</span>
675
+ <span class="erna-how__label">Upload screenshot or paste URL</span>
676
+ </div>
677
+ <div class="erna-how__step">
678
+ <span class="erna-how__num">2</span>
679
+ <span class="erna-how__label">AI scans visual patterns</span>
680
+ </div>
681
+ <div class="erna-how__step">
682
+ <span class="erna-how__num">3</span>
683
+ <span class="erna-how__label">Instant threat verdict</span>
684
+ </div>
685
+ </div>
686
+ </div>
687
+ """
688
+
689
+ _FOOTER_HTML = """
690
+ <div class="erna-footer">
691
+ <div class="erna-footer__row">
692
+ <span class="erna-footer__item">
693
+ <strong>Model:</strong> CLIP RN50x64 + custom classifier head
694
+ </span>
695
+ <span class="erna-footer__sep" aria-hidden="true"></span>
696
+ <span class="erna-footer__item">
697
+ <strong>Backend:</strong> GPU inference at
698
+ <code>api.ernacyberops.com</code>
699
+ </span>
700
+ <span class="erna-footer__sep" aria-hidden="true"></span>
701
+ <span class="erna-footer__item">
702
+ <strong>Built by:</strong>
703
+ <a href="https://desant.ai" target="_blank" rel="noopener noreferrer">
704
+ Desant.ai
705
+ </a>
706
+ </span>
707
+ </div>
708
+ <p class="erna-footer__note">
709
+ This demo sends your screenshot to our secure API for analysis.
710
+ No images are stored permanently. Rate-limited to prevent abuse.
711
+ Also powering the
712
+ <a href="https://chromewebstore.google.com/detail/desant-phishing-detector/alfnmlahonkioonhdghhdnflnoeeegdp"
713
+ target="_blank" rel="noopener noreferrer">
714
+ Desant Anti-Phishing Chrome Extension
715
+ </a>.
716
+ </p>
717
+ </div>
718
+ """
719
+
720
+
721
+ # ---------------------------------------------------------------------------
722
+ # Rich HTML result builder
723
+ # ---------------------------------------------------------------------------
724
+ def _build_results_html(
725
+ prediction: int,
726
+ malicious_prob: float,
727
+ safe_prob: float,
728
+ confidence: float,
729
+ threshold: float,
730
+ perf: dict,
731
+ url: str | None,
732
+ ) -> str:
733
+ """
734
+ Build the rich HTML results card.
735
+
736
+ Security: All dynamic values are either validated floats (from
737
+ _safe_float/_safe_int) or html.escape()'d strings. No user-controlled
738
+ content is rendered unescaped. (ANTI_PATTERNS Pattern 3: XSS)
739
+ """
740
+ is_malicious = prediction == 1
741
+
742
+ verdict_class = "danger" if is_malicious else "safe"
743
+ icon_svg = _SHIELD_ALERT_SVG if is_malicious else _SHIELD_CHECK_SVG
744
+ title = "PHISHING DETECTED" if is_malicious else "LEGITIMATE PAGE"
745
+ subtitle = (
746
+ "This page exhibits high-confidence phishing indicators"
747
+ if is_malicious
748
+ else "No phishing indicators detected in this page"
749
+ )
750
+
751
+ mal_pct = f"{malicious_prob * 100:.1f}"
752
+ safe_pct = f"{safe_prob * 100:.1f}"
753
+ conf_pct = f"{confidence * 100:.1f}"
754
+ thr_pct = f"{threshold * 100:.1f}"
755
+
756
+ prep_ms = html.escape(str(perf.get("preprocessing_time_ms", "\u2014")))
757
+ infer_ms = html.escape(str(perf.get("inference_time_ms", "\u2014")))
758
+ total_ms = html.escape(str(perf.get("total_request_time_ms", "\u2014")))
759
+
760
+ url_section = ""
761
+ if url:
762
+ escaped_url = html.escape(url, quote=True)
763
+ url_section = (
764
+ f'<div class="erna-result__url">'
765
+ f'{_LINK_SVG}'
766
+ f'<span>Source: {escaped_url}</span>'
767
+ f'</div>'
768
+ )
769
+
770
+ return f"""
771
+ <div class="erna-result erna-result--{verdict_class}" role="alert">
772
+ <div class="erna-result__header">
773
+ <div class="erna-result__icon">{icon_svg}</div>
774
+ <div class="erna-result__titles">
775
+ <h2 class="erna-result__title">{title}</h2>
776
+ <p class="erna-result__subtitle">{subtitle}</p>
777
+ </div>
778
+ </div>
779
+
780
+ <div class="erna-gauge" aria-label="Threat level gauge">
781
+ <div class="erna-gauge__header">
782
+ <span class="erna-gauge__label">Threat Level</span>
783
+ <span class="erna-gauge__value erna-gauge__value--{verdict_class}">{mal_pct}%</span>
784
+ </div>
785
+ <div class="erna-gauge__track">
786
+ <div class="erna-gauge__fill erna-gauge__fill--{verdict_class}"
787
+ style="width:{mal_pct}%"
788
+ role="progressbar"
789
+ aria-valuenow="{mal_pct}"
790
+ aria-valuemin="0"
791
+ aria-valuemax="100"></div>
792
+ <div class="erna-gauge__threshold" style="left:{thr_pct}%"
793
+ aria-label="Decision threshold at {thr_pct}%"></div>
794
+ </div>
795
+ <div class="erna-gauge__scale">
796
+ <span>0% Safe</span>
797
+ <span>Threshold {thr_pct}%</span>
798
+ <span>100% Malicious</span>
799
+ </div>
800
+ </div>
801
+
802
+ <div class="erna-metrics">
803
+ <div class="erna-metric erna-metric--{verdict_class}">
804
+ <span class="erna-metric__value">{mal_pct}%</span>
805
+ <span class="erna-metric__label">Phishing Score</span>
806
+ </div>
807
+ <div class="erna-metric">
808
+ <span class="erna-metric__value">{safe_pct}%</span>
809
+ <span class="erna-metric__label">Safe Score</span>
810
+ </div>
811
+ <div class="erna-metric">
812
+ <span class="erna-metric__value">{conf_pct}%</span>
813
+ <span class="erna-metric__label">Confidence</span>
814
+ </div>
815
+ </div>
816
+
817
+ <div class="erna-perf">
818
+ <div class="erna-perf__header">
819
+ {_BOLT_SVG}
820
+ <span>Performance</span>
821
+ </div>
822
+ <div class="erna-perf__grid">
823
+ <div class="erna-perf__item">
824
+ <span class="erna-perf__val">{prep_ms}<small>ms</small></span>
825
+ <span class="erna-perf__lbl">Preprocessing</span>
826
+ </div>
827
+ <div class="erna-perf__item">
828
+ <span class="erna-perf__val">{infer_ms}<small>ms</small></span>
829
+ <span class="erna-perf__lbl">Inference</span>
830
+ </div>
831
+ <div class="erna-perf__item">
832
+ <span class="erna-perf__val">{total_ms}<small>ms</small></span>
833
+ <span class="erna-perf__lbl">Total</span>
834
+ </div>
835
+ </div>
836
+ </div>
837
+
838
+ {url_section}
839
+ </div>
840
+ """
841
+
842
+
843
+ # ---------------------------------------------------------------------------
844
+ # Gradio inference function
845
+ # ---------------------------------------------------------------------------
846
+ def analyze_screenshot(
847
+ image: Image.Image | None, url: str,
848
+ ) -> tuple[Image.Image | None, str, str]:
849
+ """
850
+ Main Gradio handler — takes an uploaded screenshot and/or a URL,
851
+ returns (thumbnail, results_html, raw_json). The thumbnail is fed
852
+ back into the Image component so the user always sees the image that
853
+ was analysed (especially useful when a URL was the only input).
854
+ """
855
+ url = url.strip() if url else None
856
+
857
+ if url:
858
+ try:
859
+ image = _capture_screenshot_from_url(url)
860
+ except gr.Error:
861
+ return None, _build_error_html(
862
+ "Could not capture a screenshot of the provided URL. "
863
+ "Please check the URL and try again, or upload a "
864
+ "screenshot manually."
865
+ ), ""
866
+ elif image is None:
867
+ raise gr.Error("Please upload a screenshot or provide a URL.")
868
+
869
+ image = _validate_image(image)
870
+
871
+ try:
872
+ result = _call_predict(image, url)
873
+ except requests.exceptions.HTTPError as exc:
874
+ status = exc.response.status_code if exc.response is not None else "unknown"
875
+ raise gr.Error(f"Backend returned an error (HTTP {status}).") from exc
876
+ except requests.exceptions.ConnectionError:
877
+ raise gr.Error(
878
+ "Cannot reach the Erna backend. The server may be temporarily offline."
879
+ )
880
+ except gr.Error:
881
+ raise
882
+ except Exception:
883
+ logger.error("Prediction failed", exc_info=True)
884
+ raise gr.Error("An unexpected error occurred. Please try again later.")
885
+
886
+ res = result.get("results", {})
887
+ perf = result.get("performance", {})
888
+
889
+ prediction = _safe_int(res.get("prediction"), default=-1)
890
+ malicious_prob = _safe_float(res.get("malicious_probability"))
891
+ safe_prob = _safe_float(res.get("safe_probability"))
892
+ confidence = _safe_float(res.get("confidence"))
893
+ threshold = _safe_float(res.get("threshold"), default=0.5)
894
+
895
+ results_html = _build_results_html(
896
+ prediction=prediction,
897
+ malicious_prob=malicious_prob,
898
+ safe_prob=safe_prob,
899
+ confidence=confidence,
900
+ threshold=threshold,
901
+ perf=perf,
902
+ url=url,
903
+ )
904
+
905
+ raw_json = json.dumps(result, indent=2, default=str)
906
+
907
+ return image, results_html, raw_json
908
+
909
+
910
+ # ---------------------------------------------------------------------------
911
+ # Custom CSS — CyberShield Dark Theme
912
+ # ---------------------------------------------------------------------------
913
+ _FONT_HEAD = (
914
+ '<link rel="preconnect" href="https://fonts.googleapis.com">'
915
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
916
+ '<link href="https://fonts.googleapis.com/css2?'
917
+ 'family=Inter:wght@300;400;500;600;700;800&'
918
+ 'family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">'
919
+ )
920
+
921
+ _CUSTOM_CSS = """
922
+ :root {
923
+ --erna-bg-deep: #050816;
924
+ --erna-bg-primary: #0a0e1a;
925
+ --erna-bg-secondary: #111827;
926
+ --erna-bg-card: #141b2d;
927
+ --erna-border: #1e293b;
928
+ --erna-border-glow: rgba(0, 212, 255, 0.2);
929
+ --erna-text-primary: #f0f4f8;
930
+ --erna-text-secondary: #94a3b8;
931
+ --erna-text-muted: #64748b;
932
+ --erna-cyan: #00d4ff;
933
+ --erna-cyan-soft: rgba(0, 212, 255, 0.08);
934
+ --erna-safe: #00e68a;
935
+ --erna-safe-soft: rgba(0, 230, 138, 0.08);
936
+ --erna-danger: #ff4757;
937
+ --erna-danger-soft: rgba(255, 71, 87, 0.08);
938
+ --erna-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
939
+ }
940
+
941
+ /* === Global Overrides === */
942
+ body, .gradio-container, .main, .app,
943
+ body.dark, .dark .gradio-container {
944
+ background: var(--erna-bg-deep) !important;
945
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
946
+ }
947
+
948
+ .gradio-container {
949
+ max-width: 1120px !important;
950
+ }
951
+
952
+ footer {
953
+ display: none !important;
954
+ }
955
+
956
+ /* Gradio component overrides */
957
+ .block {
958
+ background: transparent !important;
959
+ border: none !important;
960
+ box-shadow: none !important;
961
+ }
962
+
963
+ .block.padded {
964
+ padding: 0 !important;
965
+ }
966
+
967
+ .panel {
968
+ background: var(--erna-bg-primary) !important;
969
+ border: 1px solid var(--erna-border) !important;
970
+ border-radius: 12px !important;
971
+ }
972
+
973
+ /* Input styling */
974
+ .wrap.svelte-1qxcj04,
975
+ input[type="text"],
976
+ textarea,
977
+ .input-text input,
978
+ .textbox textarea {
979
+ background: var(--erna-bg-primary) !important;
980
+ border: 1px solid var(--erna-border) !important;
981
+ color: var(--erna-text-primary) !important;
982
+ border-radius: 10px !important;
983
+ font-family: var(--erna-mono) !important;
984
+ font-size: 14px !important;
985
+ transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
986
+ }
987
+
988
+ input[type="text"]:focus,
989
+ textarea:focus {
990
+ border-color: var(--erna-cyan) !important;
991
+ box-shadow: 0 0 0 3px var(--erna-cyan-soft) !important;
992
+ outline: none !important;
993
+ }
994
+
995
+ /* Label styling */
996
+ label span, .label-text, .block-label,
997
+ span[data-testid="block-info"],
998
+ .block .label-wrap span {
999
+ color: var(--erna-text-secondary) !important;
1000
+ font-weight: 500 !important;
1001
+ font-size: 13px !important;
1002
+ letter-spacing: 0.02em !important;
1003
+ text-transform: uppercase !important;
1004
+ }
1005
+
1006
+ /* Image upload area */
1007
+ .image-container, .upload-area, .image-frame,
1008
+ [data-testid="image"] .upload-area,
1009
+ [data-testid="image"] {
1010
+ background: var(--erna-bg-primary) !important;
1011
+ border: 2px dashed var(--erna-border) !important;
1012
+ border-radius: 12px !important;
1013
+ transition: border-color 0.2s ease !important;
1014
+ }
1015
+
1016
+ [data-testid="image"]:hover .upload-area,
1017
+ .image-container:hover {
1018
+ border-color: var(--erna-cyan) !important;
1019
+ }
1020
+
1021
+ /* Primary button */
1022
+ .primary {
1023
+ background: linear-gradient(135deg, #00bcd4, #00d4ff) !important;
1024
+ color: #050816 !important;
1025
+ font-weight: 700 !important;
1026
+ font-size: 15px !important;
1027
+ letter-spacing: 0.04em !important;
1028
+ text-transform: uppercase !important;
1029
+ border: none !important;
1030
+ border-radius: 10px !important;
1031
+ padding: 14px 28px !important;
1032
+ box-shadow: 0 4px 20px rgba(0, 212, 255, 0.25) !important;
1033
+ transition: all 0.3s ease !important;
1034
+ }
1035
+
1036
+ .primary:hover {
1037
+ box-shadow: 0 6px 30px rgba(0, 212, 255, 0.4) !important;
1038
+ transform: translateY(-1px) !important;
1039
+ }
1040
+
1041
+ .primary:active {
1042
+ transform: translateY(0) !important;
1043
+ }
1044
+
1045
+ /* Accordion */
1046
+ .accordion {
1047
+ background: var(--erna-bg-primary) !important;
1048
+ border: 1px solid var(--erna-border) !important;
1049
+ border-radius: 12px !important;
1050
+ overflow: hidden !important;
1051
+ }
1052
+
1053
+ .accordion .label-wrap {
1054
+ background: var(--erna-bg-primary) !important;
1055
+ padding: 14px 20px !important;
1056
+ }
1057
+
1058
+ .accordion .label-wrap span {
1059
+ text-transform: none !important;
1060
+ font-size: 14px !important;
1061
+ font-weight: 500 !important;
1062
+ }
1063
+
1064
+ /* Code block in accordion */
1065
+ .code-block, .cm-editor, pre, code {
1066
+ font-family: var(--erna-mono) !important;
1067
+ background: var(--erna-bg-deep) !important;
1068
+ color: var(--erna-text-primary) !important;
1069
+ border-radius: 8px !important;
1070
+ font-size: 12.5px !important;
1071
+ }
1072
+
1073
+ /* === Hero Section === */
1074
+ .erna-hero {
1075
+ position: relative;
1076
+ padding: 48px 40px 40px;
1077
+ margin-bottom: 4px;
1078
+ border-radius: 16px;
1079
+ overflow: hidden;
1080
+ background:
1081
+ linear-gradient(rgba(0, 212, 255, 0.025) 1px, transparent 1px),
1082
+ linear-gradient(90deg, rgba(0, 212, 255, 0.025) 1px, transparent 1px),
1083
+ linear-gradient(180deg, var(--erna-bg-primary), var(--erna-bg-deep));
1084
+ background-size: 48px 48px, 48px 48px, 100% 100%;
1085
+ border: 1px solid var(--erna-border);
1086
+ }
1087
+
1088
+ .erna-hero__scanline {
1089
+ position: absolute;
1090
+ top: 0;
1091
+ left: 0;
1092
+ right: 0;
1093
+ height: 2px;
1094
+ background: linear-gradient(90deg, transparent, var(--erna-cyan), transparent);
1095
+ animation: scanline 4s ease-in-out infinite;
1096
+ opacity: 0.6;
1097
+ }
1098
+
1099
+ @keyframes scanline {
1100
+ 0% { top: 0; opacity: 0; }
1101
+ 10% { opacity: 0.6; }
1102
+ 90% { opacity: 0.6; }
1103
+ 100% { top: 100%; opacity: 0; }
1104
+ }
1105
+
1106
+ .erna-hero__content {
1107
+ position: relative;
1108
+ z-index: 1;
1109
+ }
1110
+
1111
+ .erna-hero__badge {
1112
+ display: inline-block;
1113
+ font-family: var(--erna-mono);
1114
+ font-size: 11px;
1115
+ font-weight: 600;
1116
+ letter-spacing: 0.15em;
1117
+ color: var(--erna-cyan);
1118
+ background: var(--erna-cyan-soft);
1119
+ border: 1px solid rgba(0, 212, 255, 0.15);
1120
+ padding: 5px 14px;
1121
+ border-radius: 6px;
1122
+ margin-bottom: 16px;
1123
+ }
1124
+
1125
+ .erna-hero__title {
1126
+ font-size: 32px;
1127
+ font-weight: 800;
1128
+ color: var(--erna-text-primary);
1129
+ margin: 0 0 12px;
1130
+ letter-spacing: -0.02em;
1131
+ line-height: 1.2;
1132
+ }
1133
+
1134
+ .erna-hero__subtitle {
1135
+ font-size: 15px;
1136
+ color: var(--erna-text-secondary);
1137
+ line-height: 1.7;
1138
+ margin: 0 0 20px;
1139
+ max-width: 640px;
1140
+ }
1141
+
1142
+ .erna-hero__tags {
1143
+ display: flex;
1144
+ flex-wrap: wrap;
1145
+ gap: 8px;
1146
+ }
1147
+
1148
+ .erna-tag {
1149
+ font-family: var(--erna-mono);
1150
+ font-size: 11px;
1151
+ font-weight: 500;
1152
+ color: var(--erna-text-secondary);
1153
+ background: var(--erna-bg-secondary);
1154
+ border: 1px solid var(--erna-border);
1155
+ padding: 4px 12px;
1156
+ border-radius: 20px;
1157
+ letter-spacing: 0.02em;
1158
+ }
1159
+
1160
+ /* === Placeholder State === */
1161
+ .erna-placeholder {
1162
+ display: flex;
1163
+ flex-direction: column;
1164
+ align-items: center;
1165
+ justify-content: center;
1166
+ min-height: 380px;
1167
+ color: var(--erna-text-muted);
1168
+ text-align: center;
1169
+ padding: 40px 20px;
1170
+ }
1171
+
1172
+ .erna-placeholder__icon {
1173
+ margin-bottom: 20px;
1174
+ opacity: 0.4;
1175
+ color: var(--erna-text-muted);
1176
+ }
1177
+
1178
+ .erna-placeholder__text {
1179
+ font-size: 15px;
1180
+ font-weight: 500;
1181
+ color: var(--erna-text-secondary);
1182
+ margin: 0 0 8px;
1183
+ }
1184
+
1185
+ .erna-placeholder__hint {
1186
+ font-size: 13px;
1187
+ color: var(--erna-text-muted);
1188
+ margin: 0;
1189
+ }
1190
+
1191
+ /* === Results Card === */
1192
+ .erna-result {
1193
+ animation: fadeInUp 0.5s ease-out;
1194
+ border-radius: 14px;
1195
+ padding: 28px;
1196
+ border: 1px solid var(--erna-border);
1197
+ background: var(--erna-bg-card);
1198
+ }
1199
+
1200
+ .erna-result--safe {
1201
+ border-color: rgba(0, 230, 138, 0.2);
1202
+ background: linear-gradient(180deg, rgba(0, 230, 138, 0.04), var(--erna-bg-card));
1203
+ }
1204
+
1205
+ .erna-result--danger {
1206
+ border-color: rgba(255, 71, 87, 0.2);
1207
+ background: linear-gradient(180deg, rgba(255, 71, 87, 0.04), var(--erna-bg-card));
1208
+ }
1209
+
1210
+ @keyframes fadeInUp {
1211
+ from {
1212
+ opacity: 0;
1213
+ transform: translateY(16px);
1214
+ }
1215
+ to {
1216
+ opacity: 1;
1217
+ transform: translateY(0);
1218
+ }
1219
+ }
1220
+
1221
+ .erna-result__header {
1222
+ display: flex;
1223
+ align-items: center;
1224
+ gap: 16px;
1225
+ margin-bottom: 24px;
1226
+ padding-bottom: 20px;
1227
+ border-bottom: 1px solid var(--erna-border);
1228
+ }
1229
+
1230
+ .erna-result--safe .erna-result__icon { color: var(--erna-safe); }
1231
+ .erna-result--danger .erna-result__icon { color: var(--erna-danger); }
1232
+
1233
+ .erna-result__titles {
1234
+ flex: 1;
1235
+ }
1236
+
1237
+ .erna-result__title {
1238
+ font-size: 22px;
1239
+ font-weight: 800;
1240
+ margin: 0 0 4px;
1241
+ letter-spacing: 0.03em;
1242
+ }
1243
+
1244
+ .erna-result--safe .erna-result__title { color: var(--erna-safe); }
1245
+ .erna-result--danger .erna-result__title { color: var(--erna-danger); }
1246
+
1247
+ .erna-result__subtitle {
1248
+ font-size: 13px;
1249
+ color: var(--erna-text-secondary);
1250
+ margin: 0;
1251
+ font-weight: 400;
1252
+ }
1253
+
1254
+ /* === Threat Gauge === */
1255
+ .erna-gauge {
1256
+ margin-bottom: 24px;
1257
+ }
1258
+
1259
+ .erna-gauge__header {
1260
+ display: flex;
1261
+ justify-content: space-between;
1262
+ align-items: center;
1263
+ margin-bottom: 10px;
1264
+ }
1265
+
1266
+ .erna-gauge__label {
1267
+ font-size: 12px;
1268
+ font-weight: 600;
1269
+ text-transform: uppercase;
1270
+ letter-spacing: 0.08em;
1271
+ color: var(--erna-text-secondary);
1272
+ }
1273
+
1274
+ .erna-gauge__value {
1275
+ font-family: var(--erna-mono);
1276
+ font-size: 20px;
1277
+ font-weight: 700;
1278
+ }
1279
+
1280
+ .erna-gauge__value--safe { color: var(--erna-safe); }
1281
+ .erna-gauge__value--danger { color: var(--erna-danger); }
1282
+
1283
+ .erna-gauge__track {
1284
+ position: relative;
1285
+ height: 10px;
1286
+ background: var(--erna-bg-deep);
1287
+ border-radius: 5px;
1288
+ overflow: visible;
1289
+ }
1290
+
1291
+ .erna-gauge__fill {
1292
+ height: 100%;
1293
+ border-radius: 5px;
1294
+ transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1);
1295
+ animation: gaugeGrow 0.8s cubic-bezier(0.22, 1, 0.36, 1);
1296
+ }
1297
+
1298
+ .erna-gauge__fill--safe {
1299
+ background: linear-gradient(90deg, #00e68a, #00cc7a);
1300
+ box-shadow: 0 0 12px rgba(0, 230, 138, 0.3);
1301
+ }
1302
+
1303
+ .erna-gauge__fill--danger {
1304
+ background: linear-gradient(90deg, #ff8a47, #ff4757);
1305
+ box-shadow: 0 0 12px rgba(255, 71, 87, 0.3);
1306
+ }
1307
+
1308
+ @keyframes gaugeGrow {
1309
+ from { width: 0 !important; }
1310
+ }
1311
+
1312
+ .erna-gauge__threshold {
1313
+ position: absolute;
1314
+ top: -4px;
1315
+ width: 2px;
1316
+ height: 18px;
1317
+ background: var(--erna-text-secondary);
1318
+ border-radius: 1px;
1319
+ transform: translateX(-1px);
1320
+ opacity: 0.6;
1321
+ }
1322
+
1323
+ .erna-gauge__threshold::after {
1324
+ content: '';
1325
+ position: absolute;
1326
+ top: -3px;
1327
+ left: -3px;
1328
+ width: 8px;
1329
+ height: 8px;
1330
+ background: var(--erna-text-secondary);
1331
+ border-radius: 50%;
1332
+ opacity: 0.5;
1333
+ }
1334
+
1335
+ .erna-gauge__scale {
1336
+ display: flex;
1337
+ justify-content: space-between;
1338
+ margin-top: 8px;
1339
+ font-size: 10px;
1340
+ font-family: var(--erna-mono);
1341
+ color: var(--erna-text-muted);
1342
+ letter-spacing: 0.03em;
1343
+ }
1344
+
1345
+ /* === Score Metrics === */
1346
+ .erna-metrics {
1347
+ display: grid;
1348
+ grid-template-columns: repeat(3, 1fr);
1349
+ gap: 12px;
1350
+ margin-bottom: 20px;
1351
+ }
1352
+
1353
+ .erna-metric {
1354
+ text-align: center;
1355
+ padding: 16px 12px;
1356
+ background: var(--erna-bg-deep);
1357
+ border-radius: 10px;
1358
+ border: 1px solid var(--erna-border);
1359
+ }
1360
+
1361
+ .erna-metric--safe {
1362
+ border-color: rgba(0, 230, 138, 0.15);
1363
+ background: var(--erna-safe-soft);
1364
+ }
1365
+
1366
+ .erna-metric--danger {
1367
+ border-color: rgba(255, 71, 87, 0.15);
1368
+ background: var(--erna-danger-soft);
1369
+ }
1370
+
1371
+ .erna-metric__value {
1372
+ display: block;
1373
+ font-family: var(--erna-mono);
1374
+ font-size: 22px;
1375
+ font-weight: 700;
1376
+ color: var(--erna-text-primary);
1377
+ line-height: 1.2;
1378
+ }
1379
+
1380
+ .erna-metric--safe .erna-metric__value { color: var(--erna-safe); }
1381
+ .erna-metric--danger .erna-metric__value { color: var(--erna-danger); }
1382
+
1383
+ .erna-metric__label {
1384
+ display: block;
1385
+ font-size: 11px;
1386
+ font-weight: 500;
1387
+ color: var(--erna-text-muted);
1388
+ text-transform: uppercase;
1389
+ letter-spacing: 0.06em;
1390
+ margin-top: 6px;
1391
+ }
1392
+
1393
+ /* === Performance Section === */
1394
+ .erna-perf {
1395
+ border-top: 1px solid var(--erna-border);
1396
+ padding-top: 16px;
1397
+ }
1398
+
1399
+ .erna-perf__header {
1400
+ display: flex;
1401
+ align-items: center;
1402
+ gap: 6px;
1403
+ margin-bottom: 12px;
1404
+ font-size: 12px;
1405
+ font-weight: 600;
1406
+ text-transform: uppercase;
1407
+ letter-spacing: 0.08em;
1408
+ color: var(--erna-text-secondary);
1409
+ }
1410
+
1411
+ .erna-perf__header svg {
1412
+ color: var(--erna-cyan);
1413
+ }
1414
+
1415
+ .erna-perf__grid {
1416
+ display: grid;
1417
+ grid-template-columns: repeat(3, 1fr);
1418
+ gap: 10px;
1419
+ }
1420
+
1421
+ .erna-perf__item {
1422
+ text-align: center;
1423
+ padding: 10px 8px;
1424
+ background: var(--erna-bg-deep);
1425
+ border-radius: 8px;
1426
+ }
1427
+
1428
+ .erna-perf__val {
1429
+ display: block;
1430
+ font-family: var(--erna-mono);
1431
+ font-size: 16px;
1432
+ font-weight: 600;
1433
+ color: var(--erna-cyan);
1434
+ }
1435
+
1436
+ .erna-perf__val small {
1437
+ font-size: 11px;
1438
+ font-weight: 400;
1439
+ opacity: 0.7;
1440
+ margin-left: 1px;
1441
+ }
1442
+
1443
+ .erna-perf__lbl {
1444
+ display: block;
1445
+ font-size: 10px;
1446
+ color: var(--erna-text-muted);
1447
+ text-transform: uppercase;
1448
+ letter-spacing: 0.05em;
1449
+ margin-top: 4px;
1450
+ }
1451
+
1452
+ /* === Source URL === */
1453
+ .erna-result__url {
1454
+ display: flex;
1455
+ align-items: center;
1456
+ gap: 8px;
1457
+ margin-top: 16px;
1458
+ padding-top: 16px;
1459
+ border-top: 1px solid var(--erna-border);
1460
+ font-family: var(--erna-mono);
1461
+ font-size: 12px;
1462
+ color: var(--erna-text-muted);
1463
+ word-break: break-all;
1464
+ }
1465
+
1466
+ .erna-result__url svg {
1467
+ flex-shrink: 0;
1468
+ color: var(--erna-text-muted);
1469
+ }
1470
+
1471
+ /* === How It Works === */
1472
+ .erna-how {
1473
+ margin-top: 20px;
1474
+ padding: 16px 20px;
1475
+ background: var(--erna-bg-primary);
1476
+ border: 1px solid var(--erna-border);
1477
+ border-radius: 10px;
1478
+ }
1479
+
1480
+ .erna-how__title {
1481
+ font-size: 11px;
1482
+ font-weight: 600;
1483
+ text-transform: uppercase;
1484
+ letter-spacing: 0.1em;
1485
+ color: var(--erna-text-muted);
1486
+ margin: 0 0 14px;
1487
+ }
1488
+
1489
+ .erna-how__steps {
1490
+ display: flex;
1491
+ flex-direction: column;
1492
+ gap: 10px;
1493
+ }
1494
+
1495
+ .erna-how__step {
1496
+ display: flex;
1497
+ align-items: center;
1498
+ gap: 12px;
1499
+ }
1500
+
1501
+ .erna-how__num {
1502
+ display: flex;
1503
+ align-items: center;
1504
+ justify-content: center;
1505
+ width: 24px;
1506
+ height: 24px;
1507
+ font-family: var(--erna-mono);
1508
+ font-size: 11px;
1509
+ font-weight: 600;
1510
+ color: var(--erna-cyan);
1511
+ background: var(--erna-cyan-soft);
1512
+ border: 1px solid rgba(0, 212, 255, 0.12);
1513
+ border-radius: 6px;
1514
+ flex-shrink: 0;
1515
+ }
1516
+
1517
+ .erna-how__label {
1518
+ font-size: 13px;
1519
+ color: var(--erna-text-secondary);
1520
+ }
1521
+
1522
+ /* === Footer === */
1523
+ .erna-footer {
1524
+ text-align: center;
1525
+ padding: 28px 20px 16px;
1526
+ border-top: 1px solid var(--erna-border);
1527
+ margin-top: 8px;
1528
+ }
1529
+
1530
+ .erna-footer__row {
1531
+ display: flex;
1532
+ flex-wrap: wrap;
1533
+ justify-content: center;
1534
+ align-items: center;
1535
+ gap: 8px 20px;
1536
+ margin-bottom: 12px;
1537
+ }
1538
+
1539
+ .erna-footer__item {
1540
+ font-size: 12px;
1541
+ color: var(--erna-text-secondary);
1542
+ }
1543
+
1544
+ .erna-footer__item strong {
1545
+ color: var(--erna-text-primary);
1546
+ font-weight: 600;
1547
+ }
1548
+
1549
+ .erna-footer__item code {
1550
+ font-family: var(--erna-mono);
1551
+ font-size: 11px;
1552
+ padding: 2px 6px;
1553
+ background: var(--erna-bg-secondary);
1554
+ border-radius: 4px;
1555
+ color: var(--erna-cyan);
1556
+ }
1557
+
1558
+ .erna-footer__item a {
1559
+ color: var(--erna-cyan);
1560
+ text-decoration: none;
1561
+ font-weight: 500;
1562
+ }
1563
+
1564
+ .erna-footer__item a:hover {
1565
+ text-decoration: underline;
1566
+ }
1567
+
1568
+ .erna-footer__sep {
1569
+ width: 4px;
1570
+ height: 4px;
1571
+ background: var(--erna-border);
1572
+ border-radius: 50%;
1573
+ }
1574
+
1575
+ .erna-footer__note {
1576
+ font-size: 11px;
1577
+ color: var(--erna-text-muted);
1578
+ line-height: 1.6;
1579
+ margin: 0;
1580
+ }
1581
+
1582
+ .erna-footer__note a {
1583
+ color: var(--erna-cyan);
1584
+ text-decoration: none;
1585
+ }
1586
+
1587
+ .erna-footer__note a:hover {
1588
+ text-decoration: underline;
1589
+ }
1590
+
1591
+ /* === Responsive === */
1592
+ @media (max-width: 768px) {
1593
+ .erna-hero {
1594
+ padding: 32px 24px;
1595
+ }
1596
+
1597
+ .erna-hero__title {
1598
+ font-size: 24px;
1599
+ }
1600
+
1601
+ .erna-result {
1602
+ padding: 20px;
1603
+ }
1604
+
1605
+ .erna-result__header {
1606
+ flex-direction: column;
1607
+ text-align: center;
1608
+ }
1609
+
1610
+ .erna-result__title {
1611
+ font-size: 18px;
1612
+ }
1613
+
1614
+ .erna-metrics {
1615
+ grid-template-columns: repeat(3, 1fr);
1616
+ gap: 8px;
1617
+ }
1618
+
1619
+ .erna-metric__value {
1620
+ font-size: 18px;
1621
+ }
1622
+
1623
+ .erna-perf__grid {
1624
+ grid-template-columns: repeat(3, 1fr);
1625
+ gap: 8px;
1626
+ }
1627
+
1628
+ .erna-footer__row {
1629
+ flex-direction: column;
1630
+ gap: 8px;
1631
+ }
1632
+
1633
+ .erna-footer__sep {
1634
+ display: none;
1635
+ }
1636
+ }
1637
+
1638
+ /* === Loading State Enhancement === */
1639
+ .wrap.generating {
1640
+ border-color: var(--erna-cyan) !important;
1641
+ }
1642
+
1643
+ .progress-bar {
1644
+ background: linear-gradient(90deg, var(--erna-cyan), #00e68a) !important;
1645
+ }
1646
+
1647
+ /* Scrollbar styling */
1648
+ ::-webkit-scrollbar {
1649
+ width: 8px;
1650
+ height: 8px;
1651
+ }
1652
+
1653
+ ::-webkit-scrollbar-track {
1654
+ background: var(--erna-bg-deep);
1655
+ }
1656
+
1657
+ ::-webkit-scrollbar-thumb {
1658
+ background: var(--erna-border);
1659
+ border-radius: 4px;
1660
+ }
1661
+
1662
+ ::-webkit-scrollbar-thumb:hover {
1663
+ background: var(--erna-text-muted);
1664
+ }
1665
+
1666
+ /* Hide the "Paste from clipboard" source-tab button if present */
1667
+ #screenshot-input button[aria-label="Paste from clipboard"],
1668
+ [data-testid="image"] button[aria-label="Paste from clipboard"],
1669
+ .gradio-image button[aria-label="Paste from clipboard"],
1670
+ button.icon[aria-label="Paste from clipboard"] {
1671
+ display: none !important;
1672
+ }
1673
+ """
1674
+
1675
+
1676
+ # ---------------------------------------------------------------------------
1677
+ # Gradio Theme
1678
+ # ---------------------------------------------------------------------------
1679
+ _erna_theme = gr.themes.Base(
1680
+ font=gr.themes.GoogleFont("Inter"),
1681
+ font_mono=gr.themes.GoogleFont("JetBrains Mono"),
1682
+ primary_hue=gr.themes.Color(
1683
+ c50="#ecfeff", c100="#cffafe", c200="#a5f3fc",
1684
+ c300="#67e8f9", c400="#22d3ee", c500="#00d4ff",
1685
+ c600="#0891b2", c700="#0e7490", c800="#155e75",
1686
+ c900="#164e63", c950="#083344",
1687
+ ),
1688
+ secondary_hue="emerald",
1689
+ neutral_hue="slate",
1690
+ ).set(
1691
+ body_background_fill="#050816",
1692
+ body_background_fill_dark="#050816",
1693
+ body_text_color="#e2e8f0",
1694
+ body_text_color_dark="#e2e8f0",
1695
+ body_text_color_subdued="#94a3b8",
1696
+ body_text_color_subdued_dark="#94a3b8",
1697
+ background_fill_primary="#0a0e1a",
1698
+ background_fill_primary_dark="#0a0e1a",
1699
+ background_fill_secondary="#111827",
1700
+ background_fill_secondary_dark="#111827",
1701
+ border_color_primary="#1e293b",
1702
+ border_color_primary_dark="#1e293b",
1703
+ border_color_accent="#00d4ff",
1704
+ border_color_accent_dark="#00d4ff",
1705
+ color_accent="#00d4ff",
1706
+ color_accent_soft="rgba(0, 212, 255, 0.08)",
1707
+ color_accent_soft_dark="rgba(0, 212, 255, 0.08)",
1708
+ block_background_fill="#0f1629",
1709
+ block_background_fill_dark="#0f1629",
1710
+ block_border_color="#1e293b",
1711
+ block_border_color_dark="#1e293b",
1712
+ block_label_background_fill="#111827",
1713
+ block_label_background_fill_dark="#111827",
1714
+ block_label_text_color="#94a3b8",
1715
+ block_label_text_color_dark="#94a3b8",
1716
+ input_background_fill="#0a0e1a",
1717
+ input_background_fill_dark="#0a0e1a",
1718
+ input_border_color="#1e293b",
1719
+ input_border_color_dark="#1e293b",
1720
+ input_border_color_focus="#00d4ff",
1721
+ input_border_color_focus_dark="#00d4ff",
1722
+ input_placeholder_color="#4a5568",
1723
+ input_placeholder_color_dark="#4a5568",
1724
+ button_primary_background_fill="#00d4ff",
1725
+ button_primary_background_fill_dark="#00d4ff",
1726
+ button_primary_background_fill_hover="#00bce6",
1727
+ button_primary_background_fill_hover_dark="#00bce6",
1728
+ button_primary_text_color="#050816",
1729
+ button_primary_text_color_dark="#050816",
1730
+ button_secondary_background_fill="#111827",
1731
+ button_secondary_background_fill_dark="#111827",
1732
+ button_secondary_text_color="#e2e8f0",
1733
+ button_secondary_text_color_dark="#e2e8f0",
1734
+ shadow_drop="0 2px 8px rgba(0, 0, 0, 0.3)",
1735
+ shadow_drop_lg="0 4px 16px rgba(0, 0, 0, 0.4)",
1736
+ shadow_spread="0 0 0 3px rgba(0, 212, 255, 0.08)",
1737
+ panel_background_fill="#0a0e1a",
1738
+ panel_background_fill_dark="#0a0e1a",
1739
+ panel_border_color="#1e293b",
1740
+ panel_border_color_dark="#1e293b",
1741
+ )
1742
+
1743
+
1744
+ # ---------------------------------------------------------------------------
1745
+ # JavaScript — clipboard paste handler + auto-analyze
1746
+ # ---------------------------------------------------------------------------
1747
+ _PASTE_JS = """
1748
+ function() {
1749
+ function imgRoot() {
1750
+ return document.getElementById('screenshot-input')
1751
+ || document.querySelector('[data-testid="image"]')
1752
+ || document.querySelector('.gradio-image')
1753
+ || document.body;
1754
+ }
1755
+
1756
+ /* ---- helper: push an image File into the Gradio Image component ---- */
1757
+ function pushFile(file) {
1758
+ var root = imgRoot();
1759
+ var fi = root.querySelector('input[type="file"]')
1760
+ || document.querySelector('input[type="file"][accept*="image"]');
1761
+ if (!fi) return false;
1762
+ var dt = new DataTransfer();
1763
+ dt.items.add(file);
1764
+ fi.files = dt.files;
1765
+ fi.dispatchEvent(new Event('change', {bubbles: true}));
1766
+ return true;
1767
+ }
1768
+
1769
+ function autoAnalyze() {
1770
+ setTimeout(function() {
1771
+ var btn = document.getElementById('analyze-btn');
1772
+ if (btn) btn.click();
1773
+ }, 800);
1774
+ }
1775
+
1776
+ /* ---- 1. Global Ctrl-V / Cmd-V paste handler ---- */
1777
+ document.addEventListener('paste', function(event) {
1778
+ var items = event.clipboardData && event.clipboardData.items;
1779
+ if (!items) return;
1780
+ for (var i = 0; i < items.length; i++) {
1781
+ if (items[i].type.indexOf('image') === -1) continue;
1782
+ var file = items[i].getAsFile();
1783
+ if (!file) continue;
1784
+ if (pushFile(file)) {
1785
+ event.preventDefault();
1786
+ autoAnalyze();
1787
+ }
1788
+ break;
1789
+ }
1790
+ });
1791
+
1792
+ /* ---- 2. Patch the Gradio "Paste from clipboard" button ---- */
1793
+ function patchPasteBtn() {
1794
+ var container = imgRoot();
1795
+ container.querySelectorAll('button').forEach(function(btn) {
1796
+ if (btn._pastePatch) return;
1797
+ var txt = (btn.textContent || '') + (btn.getAttribute('aria-label') || '');
1798
+ if (txt.toLowerCase().indexOf('paste') === -1 &&
1799
+ txt.toLowerCase().indexOf('clipboard') === -1) return;
1800
+ btn._pastePatch = true;
1801
+ btn.addEventListener('click', function(e) {
1802
+ e.stopImmediatePropagation();
1803
+ e.preventDefault();
1804
+ if (!navigator.clipboard || !navigator.clipboard.read) {
1805
+ alert('Your browser does not support direct clipboard access.\\n'
1806
+ + 'Please press Ctrl+V (Cmd+V on Mac) to paste a screenshot.');
1807
+ return;
1808
+ }
1809
+ navigator.clipboard.read().then(function(clipItems) {
1810
+ for (var ci = 0; ci < clipItems.length; ci++) {
1811
+ var imgType = null;
1812
+ for (var t = 0; t < clipItems[ci].types.length; t++) {
1813
+ if (clipItems[ci].types[t].startsWith('image/')) {
1814
+ imgType = clipItems[ci].types[t]; break;
1815
+ }
1816
+ }
1817
+ if (!imgType) continue;
1818
+ clipItems[ci].getType(imgType).then(function(blob) {
1819
+ var f = new File([blob], 'clipboard.png', {type: blob.type});
1820
+ if (pushFile(f)) autoAnalyze();
1821
+ });
1822
+ return;
1823
+ }
1824
+ alert('No image found in your clipboard.\\n'
1825
+ + 'Copy a screenshot first, then click Paste or press Ctrl+V.');
1826
+ }).catch(function() {
1827
+ alert('Clipboard access was blocked by your browser.\\n'
1828
+ + 'Please press Ctrl+V (Cmd+V on Mac) to paste a screenshot instead.');
1829
+ });
1830
+ }, true);
1831
+ });
1832
+ }
1833
+ /* ---- 3. Rewrite drop-zone text to "Upload image" ---- */
1834
+ var WANTED = 'Upload image';
1835
+ function tweakImageUI() {
1836
+ var root = imgRoot();
1837
+ var walker = document.createTreeWalker(
1838
+ root, NodeFilter.SHOW_TEXT, null, false);
1839
+ var node;
1840
+ while (node = walker.nextNode()) {
1841
+ var t = node.nodeValue.trim();
1842
+ if (!t || t === WANTED) continue;
1843
+ if (/drop.*image/i.test(t) || /paste from clipboard/i.test(t)
1844
+ || /upload.*file/i.test(t)) {
1845
+ node.nodeValue = WANTED;
1846
+ } else if (/click to upload/i.test(t)
1847
+ || t === '- or -' || t === 'or'
1848
+ || t === '-' || t === '--') {
1849
+ node.nodeValue = '';
1850
+ }
1851
+ }
1852
+ }
1853
+
1854
+ function applyAll() { patchPasteBtn(); tweakImageUI(); }
1855
+ setTimeout(applyAll, 1500);
1856
+ setInterval(tweakImageUI, 1000);
1857
+ }
1858
+ """
1859
+
1860
+
1861
+ # ---------------------------------------------------------------------------
1862
+ # Gradio UI — CyberShield Layout
1863
+ # ---------------------------------------------------------------------------
1864
+ with gr.Blocks(
1865
+ theme=_erna_theme,
1866
+ title="Desant Phishing Detection \u2014 Desant.ai",
1867
+ css=_CUSTOM_CSS,
1868
+ head=_FONT_HEAD,
1869
+ js=_PASTE_JS,
1870
+ ) as demo:
1871
+
1872
+ gr.HTML(_HERO_HTML)
1873
+
1874
+ with gr.Row():
1875
+ with gr.Column(scale=5):
1876
+ img_input = gr.Image(
1877
+ type="pil",
1878
+ label="Screenshot",
1879
+ height=380,
1880
+ show_label=True,
1881
+ sources=["upload"],
1882
+ elem_id="screenshot-input",
1883
+ )
1884
+ url_input = gr.Textbox(
1885
+ label="Target URL",
1886
+ placeholder="https://example.com/login",
1887
+ max_lines=1,
1888
+ info="Paste a URL to auto-capture a screenshot via headless browser",
1889
+ )
1890
+ analyze_btn = gr.Button(
1891
+ "\u25b6 ANALYZE THREAT",
1892
+ variant="primary",
1893
+ size="lg",
1894
+ elem_id="analyze-btn",
1895
+ )
1896
+ gr.HTML(_HOW_IT_WORKS_HTML)
1897
+
1898
+ with gr.Column(scale=7):
1899
+ results_output = gr.HTML(
1900
+ value=_PLACEHOLDER_HTML,
1901
+ label="Analysis Results",
1902
+ )
1903
+
1904
+ with gr.Accordion("Raw API Response", open=False):
1905
+ json_output = gr.Code(language="json", label="JSON Response")
1906
+
1907
+ analyze_btn.click(
1908
+ fn=analyze_screenshot,
1909
+ inputs=[img_input, url_input],
1910
+ outputs=[img_input, results_output, json_output],
1911
+ )
1912
+
1913
+ gr.HTML(_FOOTER_HTML)
1914
+
1915
+
1916
+ # ---------------------------------------------------------------------------
1917
+ # Launch
1918
+ # ---------------------------------------------------------------------------
1919
+ if __name__ == "__main__":
1920
+ demo.launch(server_name="0.0.0.0", server_port=7860)
packages.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ libnss3
2
+ libnspr4
3
+ libatk1.0-0
4
+ libatk-bridge2.0-0
5
+ libcups2
6
+ libdrm2
7
+ libdbus-1-3
8
+ libxkbcommon0
9
+ libatspi2.0-0
10
+ libx11-6
11
+ libxcomposite1
12
+ libxdamage1
13
+ libxext6
14
+ libxfixes3
15
+ libxrandr2
16
+ libgbm1
17
+ libpango-1.0-0
18
+ libcairo2
19
+ libasound2
20
+ libxshmfence1
21
+ libx11-xcb1
22
+ fonts-liberation
23
+ fonts-noto-color-emoji
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio==5.50.0
2
+ requests==2.32.5
3
+ Pillow==11.2.1
4
+ playwright==1.50.0