desant-ai-security commited on
Commit
e993487
·
verified ·
1 Parent(s): 8fd0a18

Deploy minimal smoke test app for startup diagnostics

Browse files
Files changed (1) hide show
  1. app.py +8 -1916
app.py CHANGED
@@ -1,1922 +1,14 @@
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 can add significant cold-start time on Spaces.
453
- # Keep it opt-in; on-demand launch still works in _take_screenshot().
454
- if os.environ.get("PREWARM_BROWSER", "0") == "1":
455
- _pw_executor.submit(_launch_browser)
456
-
457
-
458
- def _capture_screenshot_from_url(url: str) -> Image.Image:
459
- """Navigate to *url* in a headless browser and return a PIL screenshot."""
460
- url = _validate_url(url)
461
- _screenshot_limiter.check()
462
-
463
- try:
464
- future = _pw_executor.submit(_take_screenshot, url)
465
- return future.result(timeout=45)
466
- except gr.Error:
467
- raise
468
- except TimeoutError:
469
- raise gr.Error(
470
- "Screenshot capture timed out. "
471
- "Please try again or upload a screenshot manually."
472
- )
473
- except Exception:
474
- logger.error("Screenshot capture failed for URL", exc_info=True)
475
- raise gr.Error(
476
- "Could not capture a screenshot of the provided URL. "
477
- "Please check the URL and try again, or upload a screenshot manually."
478
- )
479
-
480
-
481
- # ---------------------------------------------------------------------------
482
- # Prediction helper
483
- # ---------------------------------------------------------------------------
484
- def _call_predict(image: Image.Image, url: str | None = None) -> dict:
485
- """Send an image to the /predict endpoint and return the JSON response."""
486
- _predict_limiter.check()
487
- token = _get_jwt_token()
488
-
489
- buf = io.BytesIO()
490
- image.save(buf, format="PNG")
491
- buf.seek(0)
492
-
493
- if buf.getbuffer().nbytes > MAX_IMAGE_BYTES:
494
- raise gr.Error(
495
- f"Encoded image exceeds {MAX_IMAGE_BYTES // (1024 * 1024)}MB limit."
496
- )
497
-
498
- headers = {
499
- "Authorization": f"Bearer {token}",
500
- "X-CSRF-Token": secrets.token_hex(16),
501
- "X-Extension-ID": DEMO_EXTENSION_ID,
502
- "X-Installation-ID": DEMO_INSTALLATION_ID,
503
- "X-Extension-Version": "hf-demo-1.0",
504
- }
505
- if url:
506
- headers["X-Source-URL"] = url
507
-
508
- files = {"image": ("screenshot.png", buf, "image/png")}
509
- form_data = {}
510
- if url:
511
- form_data["url"] = url
512
-
513
- resp = requests.post(
514
- f"{API_BASE_URL}/predict",
515
- headers=headers,
516
- files=files,
517
- data=form_data,
518
- timeout=30,
519
- verify=True,
520
- )
521
-
522
- if resp.status_code == 401:
523
- with _token_lock:
524
- _token_cache["access_token"] = None
525
- token = _get_jwt_token()
526
- headers["Authorization"] = f"Bearer {token}"
527
- buf.seek(0)
528
- files = {"image": ("screenshot.png", buf, "image/png")}
529
- resp = requests.post(
530
- f"{API_BASE_URL}/predict",
531
- headers=headers,
532
- files=files,
533
- data=form_data,
534
- timeout=30,
535
- verify=True,
536
- )
537
-
538
- resp.raise_for_status()
539
- if len(resp.content) > MAX_API_RESPONSE_BYTES:
540
- raise gr.Error("API response exceeds the allowed size limit.")
541
- return resp.json()
542
-
543
-
544
- # ---------------------------------------------------------------------------
545
- # Safe numeric extraction from API response
546
- # ---------------------------------------------------------------------------
547
- def _safe_float(value: object, default: float = 0.0) -> float:
548
- """Coerce a value to float, returning *default* on failure."""
549
- try:
550
- result = float(value)
551
- except (TypeError, ValueError):
552
- return default
553
- if not (result == result): # NaN check
554
- return default
555
- return result
556
-
557
-
558
- def _safe_int(value: object, default: int = -1) -> int:
559
- try:
560
- return int(value)
561
- except (TypeError, ValueError):
562
- return default
563
-
564
-
565
- # ---------------------------------------------------------------------------
566
- # SVG Icons — static, safe, no user input
567
- # ---------------------------------------------------------------------------
568
- _SHIELD_CHECK_SVG = (
569
- '<svg width="52" height="52" viewBox="0 0 24 24" fill="none" '
570
- 'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
571
- '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
572
- 'fill="currentColor" opacity="0.12"/>'
573
- '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
574
- 'stroke="currentColor" stroke-width="1.5" fill="none"/>'
575
- '<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" '
576
- 'stroke-linecap="round" stroke-linejoin="round"/>'
577
- '</svg>'
578
- )
579
-
580
- _SHIELD_ALERT_SVG = (
581
- '<svg width="52" height="52" viewBox="0 0 24 24" fill="none" '
582
- 'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
583
- '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
584
- 'fill="currentColor" opacity="0.12"/>'
585
- '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
586
- 'stroke="currentColor" stroke-width="1.5" fill="none"/>'
587
- '<path d="M12 8v4" stroke="currentColor" stroke-width="2" '
588
- 'stroke-linecap="round"/>'
589
- '<circle cx="12" cy="16" r="1" fill="currentColor"/>'
590
- '</svg>'
591
- )
592
-
593
- _LINK_SVG = (
594
- '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" '
595
- 'stroke="currentColor" stroke-width="2" aria-hidden="true">'
596
- '<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/>'
597
- '<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>'
598
- '</svg>'
599
- )
600
-
601
- _BOLT_SVG = (
602
- '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" '
603
- 'stroke="currentColor" stroke-width="2" aria-hidden="true">'
604
- '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>'
605
- '</svg>'
606
- )
607
-
608
- _PLACEHOLDER_SVG = (
609
- '<svg width="64" height="64" viewBox="0 0 24 24" fill="none" '
610
- 'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
611
- '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" '
612
- 'fill="none" stroke="currentColor" stroke-width="1" opacity="0.3"/>'
613
- '<path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="1.5" '
614
- 'stroke-linecap="round" opacity="0.3"/>'
615
- '</svg>'
616
- )
617
-
618
-
619
- # ---------------------------------------------------------------------------
620
- # HTML Templates — static content, no user input
621
- # ---------------------------------------------------------------------------
622
- _HERO_HTML = f"""
623
- <div class="erna-hero">
624
- <div class="erna-hero__scanline" aria-hidden="true"></div>
625
- <div class="erna-hero__content">
626
- <div class="erna-hero__badge">DESANT.AI</div>
627
- <h1 class="erna-hero__title">Phishing Detection Engine</h1>
628
- <p class="erna-hero__subtitle">
629
- CLIP-based deep learning model for real-time phishing screenshot
630
- classification. Upload a screenshot or paste a URL and our AI will
631
- analyze it for phishing indicators.
632
- </p>
633
- <div class="erna-hero__tags">
634
- <span class="erna-tag">GPU Inference</span>
635
- <span class="erna-tag">OpenCLIP RN50x64</span>
636
- <span class="erna-tag">Real-time Analysis</span>
637
- <span class="erna-tag">Production API</span>
638
- </div>
639
- </div>
640
- </div>
641
- """
642
-
643
- _PLACEHOLDER_HTML = f"""
644
- <div class="erna-placeholder" role="status">
645
- <div class="erna-placeholder__icon">{_PLACEHOLDER_SVG}</div>
646
- <p class="erna-placeholder__text">Upload a screenshot or enter a URL to begin security analysis</p>
647
- <p class="erna-placeholder__hint">Results will appear here after analysis</p>
648
- </div>
649
- """
650
-
651
-
652
- def _build_error_html(message: str) -> str:
653
- safe_msg = (
654
- message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
655
- )
656
- return (
657
- '<div class="erna-placeholder" role="alert">'
658
- '<div class="erna-placeholder__icon">'
659
- '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" '
660
- 'stroke="#f87171" stroke-width="2" stroke-linecap="round" '
661
- 'stroke-linejoin="round">'
662
- '<circle cx="12" cy="12" r="10"/>'
663
- '<line x1="15" y1="9" x2="9" y2="15"/>'
664
- '<line x1="9" y1="9" x2="15" y2="15"/>'
665
- '</svg></div>'
666
- f'<p class="erna-placeholder__text" style="color:#f87171">{safe_msg}</p>'
667
- '</div>'
668
- )
669
-
670
-
671
- _HOW_IT_WORKS_HTML = """
672
- <div class="erna-how">
673
- <p class="erna-how__title">How it works</p>
674
- <div class="erna-how__steps">
675
- <div class="erna-how__step">
676
- <span class="erna-how__num">1</span>
677
- <span class="erna-how__label">Upload screenshot or paste URL</span>
678
- </div>
679
- <div class="erna-how__step">
680
- <span class="erna-how__num">2</span>
681
- <span class="erna-how__label">AI scans visual patterns</span>
682
- </div>
683
- <div class="erna-how__step">
684
- <span class="erna-how__num">3</span>
685
- <span class="erna-how__label">Instant threat verdict</span>
686
- </div>
687
- </div>
688
- </div>
689
- """
690
-
691
- _FOOTER_HTML = """
692
- <div class="erna-footer">
693
- <div class="erna-footer__row">
694
- <span class="erna-footer__item">
695
- <strong>Model:</strong> CLIP RN50x64 + custom classifier head
696
- </span>
697
- <span class="erna-footer__sep" aria-hidden="true"></span>
698
- <span class="erna-footer__item">
699
- <strong>Backend:</strong> GPU inference at
700
- <code>api.ernacyberops.com</code>
701
- </span>
702
- <span class="erna-footer__sep" aria-hidden="true"></span>
703
- <span class="erna-footer__item">
704
- <strong>Built by:</strong>
705
- <a href="https://desant.ai" target="_blank" rel="noopener noreferrer">
706
- Desant.ai
707
- </a>
708
- </span>
709
- </div>
710
- <p class="erna-footer__note">
711
- This demo sends your screenshot to our secure API for analysis.
712
- No images are stored permanently. Rate-limited to prevent abuse.
713
- Also powering the
714
- <a href="https://chromewebstore.google.com/detail/desant-phishing-detector/alfnmlahonkioonhdghhdnflnoeeegdp"
715
- target="_blank" rel="noopener noreferrer">
716
- Desant Anti-Phishing Chrome Extension
717
- </a>.
718
- </p>
719
- </div>
720
- """
721
-
722
-
723
- # ---------------------------------------------------------------------------
724
- # Rich HTML result builder
725
- # ---------------------------------------------------------------------------
726
- def _build_results_html(
727
- prediction: int,
728
- malicious_prob: float,
729
- safe_prob: float,
730
- confidence: float,
731
- threshold: float,
732
- perf: dict,
733
- url: str | None,
734
- ) -> str:
735
- """
736
- Build the rich HTML results card.
737
-
738
- Security: All dynamic values are either validated floats (from
739
- _safe_float/_safe_int) or html.escape()'d strings. No user-controlled
740
- content is rendered unescaped. (ANTI_PATTERNS Pattern 3: XSS)
741
- """
742
- is_malicious = prediction == 1
743
-
744
- verdict_class = "danger" if is_malicious else "safe"
745
- icon_svg = _SHIELD_ALERT_SVG if is_malicious else _SHIELD_CHECK_SVG
746
- title = "PHISHING DETECTED" if is_malicious else "LEGITIMATE PAGE"
747
- subtitle = (
748
- "This page exhibits high-confidence phishing indicators"
749
- if is_malicious
750
- else "No phishing indicators detected in this page"
751
- )
752
-
753
- mal_pct = f"{malicious_prob * 100:.1f}"
754
- safe_pct = f"{safe_prob * 100:.1f}"
755
- conf_pct = f"{confidence * 100:.1f}"
756
- thr_pct = f"{threshold * 100:.1f}"
757
-
758
- prep_ms = html.escape(str(perf.get("preprocessing_time_ms", "\u2014")))
759
- infer_ms = html.escape(str(perf.get("inference_time_ms", "\u2014")))
760
- total_ms = html.escape(str(perf.get("total_request_time_ms", "\u2014")))
761
-
762
- url_section = ""
763
- if url:
764
- escaped_url = html.escape(url, quote=True)
765
- url_section = (
766
- f'<div class="erna-result__url">'
767
- f'{_LINK_SVG}'
768
- f'<span>Source: {escaped_url}</span>'
769
- f'</div>'
770
- )
771
-
772
- return f"""
773
- <div class="erna-result erna-result--{verdict_class}" role="alert">
774
- <div class="erna-result__header">
775
- <div class="erna-result__icon">{icon_svg}</div>
776
- <div class="erna-result__titles">
777
- <h2 class="erna-result__title">{title}</h2>
778
- <p class="erna-result__subtitle">{subtitle}</p>
779
- </div>
780
- </div>
781
-
782
- <div class="erna-gauge" aria-label="Threat level gauge">
783
- <div class="erna-gauge__header">
784
- <span class="erna-gauge__label">Threat Level</span>
785
- <span class="erna-gauge__value erna-gauge__value--{verdict_class}">{mal_pct}%</span>
786
- </div>
787
- <div class="erna-gauge__track">
788
- <div class="erna-gauge__fill erna-gauge__fill--{verdict_class}"
789
- style="width:{mal_pct}%"
790
- role="progressbar"
791
- aria-valuenow="{mal_pct}"
792
- aria-valuemin="0"
793
- aria-valuemax="100"></div>
794
- <div class="erna-gauge__threshold" style="left:{thr_pct}%"
795
- aria-label="Decision threshold at {thr_pct}%"></div>
796
- </div>
797
- <div class="erna-gauge__scale">
798
- <span>0% Safe</span>
799
- <span>Threshold {thr_pct}%</span>
800
- <span>100% Malicious</span>
801
- </div>
802
- </div>
803
-
804
- <div class="erna-metrics">
805
- <div class="erna-metric erna-metric--{verdict_class}">
806
- <span class="erna-metric__value">{mal_pct}%</span>
807
- <span class="erna-metric__label">Phishing Score</span>
808
- </div>
809
- <div class="erna-metric">
810
- <span class="erna-metric__value">{safe_pct}%</span>
811
- <span class="erna-metric__label">Safe Score</span>
812
- </div>
813
- <div class="erna-metric">
814
- <span class="erna-metric__value">{conf_pct}%</span>
815
- <span class="erna-metric__label">Confidence</span>
816
- </div>
817
- </div>
818
-
819
- <div class="erna-perf">
820
- <div class="erna-perf__header">
821
- {_BOLT_SVG}
822
- <span>Performance</span>
823
- </div>
824
- <div class="erna-perf__grid">
825
- <div class="erna-perf__item">
826
- <span class="erna-perf__val">{prep_ms}<small>ms</small></span>
827
- <span class="erna-perf__lbl">Preprocessing</span>
828
- </div>
829
- <div class="erna-perf__item">
830
- <span class="erna-perf__val">{infer_ms}<small>ms</small></span>
831
- <span class="erna-perf__lbl">Inference</span>
832
- </div>
833
- <div class="erna-perf__item">
834
- <span class="erna-perf__val">{total_ms}<small>ms</small></span>
835
- <span class="erna-perf__lbl">Total</span>
836
- </div>
837
- </div>
838
- </div>
839
-
840
- {url_section}
841
- </div>
842
- """
843
-
844
-
845
- # ---------------------------------------------------------------------------
846
- # Gradio inference function
847
- # ---------------------------------------------------------------------------
848
- def analyze_screenshot(
849
- image: Image.Image | None, url: str,
850
- ) -> tuple[Image.Image | None, str, str]:
851
- """
852
- Main Gradio handler — takes an uploaded screenshot and/or a URL,
853
- returns (thumbnail, results_html, raw_json). The thumbnail is fed
854
- back into the Image component so the user always sees the image that
855
- was analysed (especially useful when a URL was the only input).
856
- """
857
- url = url.strip() if url else None
858
-
859
- if url:
860
- try:
861
- image = _capture_screenshot_from_url(url)
862
- except gr.Error:
863
- return None, _build_error_html(
864
- "Could not capture a screenshot of the provided URL. "
865
- "Please check the URL and try again, or upload a "
866
- "screenshot manually."
867
- ), ""
868
- elif image is None:
869
- raise gr.Error("Please upload a screenshot or provide a URL.")
870
-
871
- image = _validate_image(image)
872
-
873
- try:
874
- result = _call_predict(image, url)
875
- except requests.exceptions.HTTPError as exc:
876
- status = exc.response.status_code if exc.response is not None else "unknown"
877
- raise gr.Error(f"Backend returned an error (HTTP {status}).") from exc
878
- except requests.exceptions.ConnectionError:
879
- raise gr.Error(
880
- "Cannot reach the Erna backend. The server may be temporarily offline."
881
- )
882
- except gr.Error:
883
- raise
884
- except Exception:
885
- logger.error("Prediction failed", exc_info=True)
886
- raise gr.Error("An unexpected error occurred. Please try again later.")
887
-
888
- res = result.get("results", {})
889
- perf = result.get("performance", {})
890
-
891
- prediction = _safe_int(res.get("prediction"), default=-1)
892
- malicious_prob = _safe_float(res.get("malicious_probability"))
893
- safe_prob = _safe_float(res.get("safe_probability"))
894
- confidence = _safe_float(res.get("confidence"))
895
- threshold = _safe_float(res.get("threshold"), default=0.5)
896
-
897
- results_html = _build_results_html(
898
- prediction=prediction,
899
- malicious_prob=malicious_prob,
900
- safe_prob=safe_prob,
901
- confidence=confidence,
902
- threshold=threshold,
903
- perf=perf,
904
- url=url,
905
- )
906
-
907
- raw_json = json.dumps(result, indent=2, default=str)
908
-
909
- return image, results_html, raw_json
910
-
911
-
912
- # ---------------------------------------------------------------------------
913
- # Custom CSS — CyberShield Dark Theme
914
- # ---------------------------------------------------------------------------
915
- _FONT_HEAD = (
916
- '<link rel="preconnect" href="https://fonts.googleapis.com">'
917
- '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
918
- '<link href="https://fonts.googleapis.com/css2?'
919
- 'family=Inter:wght@300;400;500;600;700;800&'
920
- 'family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">'
921
- )
922
-
923
- _CUSTOM_CSS = """
924
- :root {
925
- --erna-bg-deep: #050816;
926
- --erna-bg-primary: #0a0e1a;
927
- --erna-bg-secondary: #111827;
928
- --erna-bg-card: #141b2d;
929
- --erna-border: #1e293b;
930
- --erna-border-glow: rgba(0, 212, 255, 0.2);
931
- --erna-text-primary: #f0f4f8;
932
- --erna-text-secondary: #94a3b8;
933
- --erna-text-muted: #64748b;
934
- --erna-cyan: #00d4ff;
935
- --erna-cyan-soft: rgba(0, 212, 255, 0.08);
936
- --erna-safe: #00e68a;
937
- --erna-safe-soft: rgba(0, 230, 138, 0.08);
938
- --erna-danger: #ff4757;
939
- --erna-danger-soft: rgba(255, 71, 87, 0.08);
940
- --erna-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
941
- }
942
-
943
- /* === Global Overrides === */
944
- body, .gradio-container, .main, .app,
945
- body.dark, .dark .gradio-container {
946
- background: var(--erna-bg-deep) !important;
947
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
948
- }
949
-
950
- .gradio-container {
951
- max-width: 1120px !important;
952
- }
953
-
954
- footer {
955
- display: none !important;
956
- }
957
-
958
- /* Gradio component overrides */
959
- .block {
960
- background: transparent !important;
961
- border: none !important;
962
- box-shadow: none !important;
963
- }
964
-
965
- .block.padded {
966
- padding: 0 !important;
967
- }
968
-
969
- .panel {
970
- background: var(--erna-bg-primary) !important;
971
- border: 1px solid var(--erna-border) !important;
972
- border-radius: 12px !important;
973
- }
974
-
975
- /* Input styling */
976
- .wrap.svelte-1qxcj04,
977
- input[type="text"],
978
- textarea,
979
- .input-text input,
980
- .textbox textarea {
981
- background: var(--erna-bg-primary) !important;
982
- border: 1px solid var(--erna-border) !important;
983
- color: var(--erna-text-primary) !important;
984
- border-radius: 10px !important;
985
- font-family: var(--erna-mono) !important;
986
- font-size: 14px !important;
987
- transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
988
- }
989
-
990
- input[type="text"]:focus,
991
- textarea:focus {
992
- border-color: var(--erna-cyan) !important;
993
- box-shadow: 0 0 0 3px var(--erna-cyan-soft) !important;
994
- outline: none !important;
995
- }
996
-
997
- /* Label styling */
998
- label span, .label-text, .block-label,
999
- span[data-testid="block-info"],
1000
- .block .label-wrap span {
1001
- color: var(--erna-text-secondary) !important;
1002
- font-weight: 500 !important;
1003
- font-size: 13px !important;
1004
- letter-spacing: 0.02em !important;
1005
- text-transform: uppercase !important;
1006
- }
1007
-
1008
- /* Image upload area */
1009
- .image-container, .upload-area, .image-frame,
1010
- [data-testid="image"] .upload-area,
1011
- [data-testid="image"] {
1012
- background: var(--erna-bg-primary) !important;
1013
- border: 2px dashed var(--erna-border) !important;
1014
- border-radius: 12px !important;
1015
- transition: border-color 0.2s ease !important;
1016
- }
1017
-
1018
- [data-testid="image"]:hover .upload-area,
1019
- .image-container:hover {
1020
- border-color: var(--erna-cyan) !important;
1021
- }
1022
-
1023
- /* Primary button */
1024
- .primary {
1025
- background: linear-gradient(135deg, #00bcd4, #00d4ff) !important;
1026
- color: #050816 !important;
1027
- font-weight: 700 !important;
1028
- font-size: 15px !important;
1029
- letter-spacing: 0.04em !important;
1030
- text-transform: uppercase !important;
1031
- border: none !important;
1032
- border-radius: 10px !important;
1033
- padding: 14px 28px !important;
1034
- box-shadow: 0 4px 20px rgba(0, 212, 255, 0.25) !important;
1035
- transition: all 0.3s ease !important;
1036
- }
1037
-
1038
- .primary:hover {
1039
- box-shadow: 0 6px 30px rgba(0, 212, 255, 0.4) !important;
1040
- transform: translateY(-1px) !important;
1041
- }
1042
-
1043
- .primary:active {
1044
- transform: translateY(0) !important;
1045
- }
1046
-
1047
- /* Accordion */
1048
- .accordion {
1049
- background: var(--erna-bg-primary) !important;
1050
- border: 1px solid var(--erna-border) !important;
1051
- border-radius: 12px !important;
1052
- overflow: hidden !important;
1053
- }
1054
-
1055
- .accordion .label-wrap {
1056
- background: var(--erna-bg-primary) !important;
1057
- padding: 14px 20px !important;
1058
- }
1059
-
1060
- .accordion .label-wrap span {
1061
- text-transform: none !important;
1062
- font-size: 14px !important;
1063
- font-weight: 500 !important;
1064
- }
1065
-
1066
- /* Code block in accordion */
1067
- .code-block, .cm-editor, pre, code {
1068
- font-family: var(--erna-mono) !important;
1069
- background: var(--erna-bg-deep) !important;
1070
- color: var(--erna-text-primary) !important;
1071
- border-radius: 8px !important;
1072
- font-size: 12.5px !important;
1073
- }
1074
-
1075
- /* === Hero Section === */
1076
- .erna-hero {
1077
- position: relative;
1078
- padding: 48px 40px 40px;
1079
- margin-bottom: 4px;
1080
- border-radius: 16px;
1081
- overflow: hidden;
1082
- background:
1083
- linear-gradient(rgba(0, 212, 255, 0.025) 1px, transparent 1px),
1084
- linear-gradient(90deg, rgba(0, 212, 255, 0.025) 1px, transparent 1px),
1085
- linear-gradient(180deg, var(--erna-bg-primary), var(--erna-bg-deep));
1086
- background-size: 48px 48px, 48px 48px, 100% 100%;
1087
- border: 1px solid var(--erna-border);
1088
- }
1089
-
1090
- .erna-hero__scanline {
1091
- position: absolute;
1092
- top: 0;
1093
- left: 0;
1094
- right: 0;
1095
- height: 2px;
1096
- background: linear-gradient(90deg, transparent, var(--erna-cyan), transparent);
1097
- animation: scanline 4s ease-in-out infinite;
1098
- opacity: 0.6;
1099
- }
1100
-
1101
- @keyframes scanline {
1102
- 0% { top: 0; opacity: 0; }
1103
- 10% { opacity: 0.6; }
1104
- 90% { opacity: 0.6; }
1105
- 100% { top: 100%; opacity: 0; }
1106
- }
1107
-
1108
- .erna-hero__content {
1109
- position: relative;
1110
- z-index: 1;
1111
- }
1112
-
1113
- .erna-hero__badge {
1114
- display: inline-block;
1115
- font-family: var(--erna-mono);
1116
- font-size: 11px;
1117
- font-weight: 600;
1118
- letter-spacing: 0.15em;
1119
- color: var(--erna-cyan);
1120
- background: var(--erna-cyan-soft);
1121
- border: 1px solid rgba(0, 212, 255, 0.15);
1122
- padding: 5px 14px;
1123
- border-radius: 6px;
1124
- margin-bottom: 16px;
1125
- }
1126
-
1127
- .erna-hero__title {
1128
- font-size: 32px;
1129
- font-weight: 800;
1130
- color: var(--erna-text-primary);
1131
- margin: 0 0 12px;
1132
- letter-spacing: -0.02em;
1133
- line-height: 1.2;
1134
- }
1135
-
1136
- .erna-hero__subtitle {
1137
- font-size: 15px;
1138
- color: var(--erna-text-secondary);
1139
- line-height: 1.7;
1140
- margin: 0 0 20px;
1141
- max-width: 640px;
1142
- }
1143
-
1144
- .erna-hero__tags {
1145
- display: flex;
1146
- flex-wrap: wrap;
1147
- gap: 8px;
1148
- }
1149
-
1150
- .erna-tag {
1151
- font-family: var(--erna-mono);
1152
- font-size: 11px;
1153
- font-weight: 500;
1154
- color: var(--erna-text-secondary);
1155
- background: var(--erna-bg-secondary);
1156
- border: 1px solid var(--erna-border);
1157
- padding: 4px 12px;
1158
- border-radius: 20px;
1159
- letter-spacing: 0.02em;
1160
- }
1161
-
1162
- /* === Placeholder State === */
1163
- .erna-placeholder {
1164
- display: flex;
1165
- flex-direction: column;
1166
- align-items: center;
1167
- justify-content: center;
1168
- min-height: 380px;
1169
- color: var(--erna-text-muted);
1170
- text-align: center;
1171
- padding: 40px 20px;
1172
- }
1173
-
1174
- .erna-placeholder__icon {
1175
- margin-bottom: 20px;
1176
- opacity: 0.4;
1177
- color: var(--erna-text-muted);
1178
- }
1179
-
1180
- .erna-placeholder__text {
1181
- font-size: 15px;
1182
- font-weight: 500;
1183
- color: var(--erna-text-secondary);
1184
- margin: 0 0 8px;
1185
- }
1186
-
1187
- .erna-placeholder__hint {
1188
- font-size: 13px;
1189
- color: var(--erna-text-muted);
1190
- margin: 0;
1191
- }
1192
-
1193
- /* === Results Card === */
1194
- .erna-result {
1195
- animation: fadeInUp 0.5s ease-out;
1196
- border-radius: 14px;
1197
- padding: 28px;
1198
- border: 1px solid var(--erna-border);
1199
- background: var(--erna-bg-card);
1200
- }
1201
-
1202
- .erna-result--safe {
1203
- border-color: rgba(0, 230, 138, 0.2);
1204
- background: linear-gradient(180deg, rgba(0, 230, 138, 0.04), var(--erna-bg-card));
1205
- }
1206
-
1207
- .erna-result--danger {
1208
- border-color: rgba(255, 71, 87, 0.2);
1209
- background: linear-gradient(180deg, rgba(255, 71, 87, 0.04), var(--erna-bg-card));
1210
- }
1211
-
1212
- @keyframes fadeInUp {
1213
- from {
1214
- opacity: 0;
1215
- transform: translateY(16px);
1216
- }
1217
- to {
1218
- opacity: 1;
1219
- transform: translateY(0);
1220
- }
1221
- }
1222
-
1223
- .erna-result__header {
1224
- display: flex;
1225
- align-items: center;
1226
- gap: 16px;
1227
- margin-bottom: 24px;
1228
- padding-bottom: 20px;
1229
- border-bottom: 1px solid var(--erna-border);
1230
- }
1231
-
1232
- .erna-result--safe .erna-result__icon { color: var(--erna-safe); }
1233
- .erna-result--danger .erna-result__icon { color: var(--erna-danger); }
1234
-
1235
- .erna-result__titles {
1236
- flex: 1;
1237
- }
1238
-
1239
- .erna-result__title {
1240
- font-size: 22px;
1241
- font-weight: 800;
1242
- margin: 0 0 4px;
1243
- letter-spacing: 0.03em;
1244
- }
1245
-
1246
- .erna-result--safe .erna-result__title { color: var(--erna-safe); }
1247
- .erna-result--danger .erna-result__title { color: var(--erna-danger); }
1248
-
1249
- .erna-result__subtitle {
1250
- font-size: 13px;
1251
- color: var(--erna-text-secondary);
1252
- margin: 0;
1253
- font-weight: 400;
1254
- }
1255
-
1256
- /* === Threat Gauge === */
1257
- .erna-gauge {
1258
- margin-bottom: 24px;
1259
- }
1260
-
1261
- .erna-gauge__header {
1262
- display: flex;
1263
- justify-content: space-between;
1264
- align-items: center;
1265
- margin-bottom: 10px;
1266
- }
1267
-
1268
- .erna-gauge__label {
1269
- font-size: 12px;
1270
- font-weight: 600;
1271
- text-transform: uppercase;
1272
- letter-spacing: 0.08em;
1273
- color: var(--erna-text-secondary);
1274
- }
1275
-
1276
- .erna-gauge__value {
1277
- font-family: var(--erna-mono);
1278
- font-size: 20px;
1279
- font-weight: 700;
1280
- }
1281
-
1282
- .erna-gauge__value--safe { color: var(--erna-safe); }
1283
- .erna-gauge__value--danger { color: var(--erna-danger); }
1284
-
1285
- .erna-gauge__track {
1286
- position: relative;
1287
- height: 10px;
1288
- background: var(--erna-bg-deep);
1289
- border-radius: 5px;
1290
- overflow: visible;
1291
- }
1292
-
1293
- .erna-gauge__fill {
1294
- height: 100%;
1295
- border-radius: 5px;
1296
- transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1);
1297
- animation: gaugeGrow 0.8s cubic-bezier(0.22, 1, 0.36, 1);
1298
- }
1299
-
1300
- .erna-gauge__fill--safe {
1301
- background: linear-gradient(90deg, #00e68a, #00cc7a);
1302
- box-shadow: 0 0 12px rgba(0, 230, 138, 0.3);
1303
- }
1304
-
1305
- .erna-gauge__fill--danger {
1306
- background: linear-gradient(90deg, #ff8a47, #ff4757);
1307
- box-shadow: 0 0 12px rgba(255, 71, 87, 0.3);
1308
- }
1309
-
1310
- @keyframes gaugeGrow {
1311
- from { width: 0 !important; }
1312
- }
1313
-
1314
- .erna-gauge__threshold {
1315
- position: absolute;
1316
- top: -4px;
1317
- width: 2px;
1318
- height: 18px;
1319
- background: var(--erna-text-secondary);
1320
- border-radius: 1px;
1321
- transform: translateX(-1px);
1322
- opacity: 0.6;
1323
- }
1324
-
1325
- .erna-gauge__threshold::after {
1326
- content: '';
1327
- position: absolute;
1328
- top: -3px;
1329
- left: -3px;
1330
- width: 8px;
1331
- height: 8px;
1332
- background: var(--erna-text-secondary);
1333
- border-radius: 50%;
1334
- opacity: 0.5;
1335
- }
1336
-
1337
- .erna-gauge__scale {
1338
- display: flex;
1339
- justify-content: space-between;
1340
- margin-top: 8px;
1341
- font-size: 10px;
1342
- font-family: var(--erna-mono);
1343
- color: var(--erna-text-muted);
1344
- letter-spacing: 0.03em;
1345
- }
1346
-
1347
- /* === Score Metrics === */
1348
- .erna-metrics {
1349
- display: grid;
1350
- grid-template-columns: repeat(3, 1fr);
1351
- gap: 12px;
1352
- margin-bottom: 20px;
1353
- }
1354
-
1355
- .erna-metric {
1356
- text-align: center;
1357
- padding: 16px 12px;
1358
- background: var(--erna-bg-deep);
1359
- border-radius: 10px;
1360
- border: 1px solid var(--erna-border);
1361
- }
1362
-
1363
- .erna-metric--safe {
1364
- border-color: rgba(0, 230, 138, 0.15);
1365
- background: var(--erna-safe-soft);
1366
- }
1367
-
1368
- .erna-metric--danger {
1369
- border-color: rgba(255, 71, 87, 0.15);
1370
- background: var(--erna-danger-soft);
1371
- }
1372
-
1373
- .erna-metric__value {
1374
- display: block;
1375
- font-family: var(--erna-mono);
1376
- font-size: 22px;
1377
- font-weight: 700;
1378
- color: var(--erna-text-primary);
1379
- line-height: 1.2;
1380
- }
1381
-
1382
- .erna-metric--safe .erna-metric__value { color: var(--erna-safe); }
1383
- .erna-metric--danger .erna-metric__value { color: var(--erna-danger); }
1384
-
1385
- .erna-metric__label {
1386
- display: block;
1387
- font-size: 11px;
1388
- font-weight: 500;
1389
- color: var(--erna-text-muted);
1390
- text-transform: uppercase;
1391
- letter-spacing: 0.06em;
1392
- margin-top: 6px;
1393
- }
1394
-
1395
- /* === Performance Section === */
1396
- .erna-perf {
1397
- border-top: 1px solid var(--erna-border);
1398
- padding-top: 16px;
1399
- }
1400
-
1401
- .erna-perf__header {
1402
- display: flex;
1403
- align-items: center;
1404
- gap: 6px;
1405
- margin-bottom: 12px;
1406
- font-size: 12px;
1407
- font-weight: 600;
1408
- text-transform: uppercase;
1409
- letter-spacing: 0.08em;
1410
- color: var(--erna-text-secondary);
1411
- }
1412
-
1413
- .erna-perf__header svg {
1414
- color: var(--erna-cyan);
1415
- }
1416
-
1417
- .erna-perf__grid {
1418
- display: grid;
1419
- grid-template-columns: repeat(3, 1fr);
1420
- gap: 10px;
1421
- }
1422
-
1423
- .erna-perf__item {
1424
- text-align: center;
1425
- padding: 10px 8px;
1426
- background: var(--erna-bg-deep);
1427
- border-radius: 8px;
1428
- }
1429
-
1430
- .erna-perf__val {
1431
- display: block;
1432
- font-family: var(--erna-mono);
1433
- font-size: 16px;
1434
- font-weight: 600;
1435
- color: var(--erna-cyan);
1436
- }
1437
-
1438
- .erna-perf__val small {
1439
- font-size: 11px;
1440
- font-weight: 400;
1441
- opacity: 0.7;
1442
- margin-left: 1px;
1443
- }
1444
-
1445
- .erna-perf__lbl {
1446
- display: block;
1447
- font-size: 10px;
1448
- color: var(--erna-text-muted);
1449
- text-transform: uppercase;
1450
- letter-spacing: 0.05em;
1451
- margin-top: 4px;
1452
- }
1453
-
1454
- /* === Source URL === */
1455
- .erna-result__url {
1456
- display: flex;
1457
- align-items: center;
1458
- gap: 8px;
1459
- margin-top: 16px;
1460
- padding-top: 16px;
1461
- border-top: 1px solid var(--erna-border);
1462
- font-family: var(--erna-mono);
1463
- font-size: 12px;
1464
- color: var(--erna-text-muted);
1465
- word-break: break-all;
1466
- }
1467
-
1468
- .erna-result__url svg {
1469
- flex-shrink: 0;
1470
- color: var(--erna-text-muted);
1471
- }
1472
-
1473
- /* === How It Works === */
1474
- .erna-how {
1475
- margin-top: 20px;
1476
- padding: 16px 20px;
1477
- background: var(--erna-bg-primary);
1478
- border: 1px solid var(--erna-border);
1479
- border-radius: 10px;
1480
- }
1481
-
1482
- .erna-how__title {
1483
- font-size: 11px;
1484
- font-weight: 600;
1485
- text-transform: uppercase;
1486
- letter-spacing: 0.1em;
1487
- color: var(--erna-text-muted);
1488
- margin: 0 0 14px;
1489
- }
1490
-
1491
- .erna-how__steps {
1492
- display: flex;
1493
- flex-direction: column;
1494
- gap: 10px;
1495
- }
1496
-
1497
- .erna-how__step {
1498
- display: flex;
1499
- align-items: center;
1500
- gap: 12px;
1501
- }
1502
-
1503
- .erna-how__num {
1504
- display: flex;
1505
- align-items: center;
1506
- justify-content: center;
1507
- width: 24px;
1508
- height: 24px;
1509
- font-family: var(--erna-mono);
1510
- font-size: 11px;
1511
- font-weight: 600;
1512
- color: var(--erna-cyan);
1513
- background: var(--erna-cyan-soft);
1514
- border: 1px solid rgba(0, 212, 255, 0.12);
1515
- border-radius: 6px;
1516
- flex-shrink: 0;
1517
- }
1518
-
1519
- .erna-how__label {
1520
- font-size: 13px;
1521
- color: var(--erna-text-secondary);
1522
- }
1523
-
1524
- /* === Footer === */
1525
- .erna-footer {
1526
- text-align: center;
1527
- padding: 28px 20px 16px;
1528
- border-top: 1px solid var(--erna-border);
1529
- margin-top: 8px;
1530
- }
1531
-
1532
- .erna-footer__row {
1533
- display: flex;
1534
- flex-wrap: wrap;
1535
- justify-content: center;
1536
- align-items: center;
1537
- gap: 8px 20px;
1538
- margin-bottom: 12px;
1539
- }
1540
-
1541
- .erna-footer__item {
1542
- font-size: 12px;
1543
- color: var(--erna-text-secondary);
1544
- }
1545
-
1546
- .erna-footer__item strong {
1547
- color: var(--erna-text-primary);
1548
- font-weight: 600;
1549
- }
1550
-
1551
- .erna-footer__item code {
1552
- font-family: var(--erna-mono);
1553
- font-size: 11px;
1554
- padding: 2px 6px;
1555
- background: var(--erna-bg-secondary);
1556
- border-radius: 4px;
1557
- color: var(--erna-cyan);
1558
- }
1559
-
1560
- .erna-footer__item a {
1561
- color: var(--erna-cyan);
1562
- text-decoration: none;
1563
- font-weight: 500;
1564
- }
1565
-
1566
- .erna-footer__item a:hover {
1567
- text-decoration: underline;
1568
- }
1569
-
1570
- .erna-footer__sep {
1571
- width: 4px;
1572
- height: 4px;
1573
- background: var(--erna-border);
1574
- border-radius: 50%;
1575
- }
1576
-
1577
- .erna-footer__note {
1578
- font-size: 11px;
1579
- color: var(--erna-text-muted);
1580
- line-height: 1.6;
1581
- margin: 0;
1582
- }
1583
-
1584
- .erna-footer__note a {
1585
- color: var(--erna-cyan);
1586
- text-decoration: none;
1587
- }
1588
-
1589
- .erna-footer__note a:hover {
1590
- text-decoration: underline;
1591
- }
1592
-
1593
- /* === Responsive === */
1594
- @media (max-width: 768px) {
1595
- .erna-hero {
1596
- padding: 32px 24px;
1597
- }
1598
-
1599
- .erna-hero__title {
1600
- font-size: 24px;
1601
- }
1602
-
1603
- .erna-result {
1604
- padding: 20px;
1605
- }
1606
-
1607
- .erna-result__header {
1608
- flex-direction: column;
1609
- text-align: center;
1610
- }
1611
-
1612
- .erna-result__title {
1613
- font-size: 18px;
1614
- }
1615
-
1616
- .erna-metrics {
1617
- grid-template-columns: repeat(3, 1fr);
1618
- gap: 8px;
1619
- }
1620
-
1621
- .erna-metric__value {
1622
- font-size: 18px;
1623
- }
1624
-
1625
- .erna-perf__grid {
1626
- grid-template-columns: repeat(3, 1fr);
1627
- gap: 8px;
1628
- }
1629
-
1630
- .erna-footer__row {
1631
- flex-direction: column;
1632
- gap: 8px;
1633
- }
1634
-
1635
- .erna-footer__sep {
1636
- display: none;
1637
- }
1638
- }
1639
-
1640
- /* === Loading State Enhancement === */
1641
- .wrap.generating {
1642
- border-color: var(--erna-cyan) !important;
1643
- }
1644
-
1645
- .progress-bar {
1646
- background: linear-gradient(90deg, var(--erna-cyan), #00e68a) !important;
1647
- }
1648
-
1649
- /* Scrollbar styling */
1650
- ::-webkit-scrollbar {
1651
- width: 8px;
1652
- height: 8px;
1653
- }
1654
-
1655
- ::-webkit-scrollbar-track {
1656
- background: var(--erna-bg-deep);
1657
- }
1658
-
1659
- ::-webkit-scrollbar-thumb {
1660
- background: var(--erna-border);
1661
- border-radius: 4px;
1662
- }
1663
-
1664
- ::-webkit-scrollbar-thumb:hover {
1665
- background: var(--erna-text-muted);
1666
- }
1667
-
1668
- /* Hide the "Paste from clipboard" source-tab button if present */
1669
- #screenshot-input button[aria-label="Paste from clipboard"],
1670
- [data-testid="image"] button[aria-label="Paste from clipboard"],
1671
- .gradio-image button[aria-label="Paste from clipboard"],
1672
- button.icon[aria-label="Paste from clipboard"] {
1673
- display: none !important;
1674
- }
1675
- """
1676
-
1677
-
1678
- # ---------------------------------------------------------------------------
1679
- # Gradio Theme
1680
- # ---------------------------------------------------------------------------
1681
- _erna_theme = gr.themes.Base(
1682
- font=gr.themes.GoogleFont("Inter"),
1683
- font_mono=gr.themes.GoogleFont("JetBrains Mono"),
1684
- primary_hue=gr.themes.Color(
1685
- c50="#ecfeff", c100="#cffafe", c200="#a5f3fc",
1686
- c300="#67e8f9", c400="#22d3ee", c500="#00d4ff",
1687
- c600="#0891b2", c700="#0e7490", c800="#155e75",
1688
- c900="#164e63", c950="#083344",
1689
- ),
1690
- secondary_hue="emerald",
1691
- neutral_hue="slate",
1692
- ).set(
1693
- body_background_fill="#050816",
1694
- body_background_fill_dark="#050816",
1695
- body_text_color="#e2e8f0",
1696
- body_text_color_dark="#e2e8f0",
1697
- body_text_color_subdued="#94a3b8",
1698
- body_text_color_subdued_dark="#94a3b8",
1699
- background_fill_primary="#0a0e1a",
1700
- background_fill_primary_dark="#0a0e1a",
1701
- background_fill_secondary="#111827",
1702
- background_fill_secondary_dark="#111827",
1703
- border_color_primary="#1e293b",
1704
- border_color_primary_dark="#1e293b",
1705
- border_color_accent="#00d4ff",
1706
- border_color_accent_dark="#00d4ff",
1707
- color_accent="#00d4ff",
1708
- color_accent_soft="rgba(0, 212, 255, 0.08)",
1709
- color_accent_soft_dark="rgba(0, 212, 255, 0.08)",
1710
- block_background_fill="#0f1629",
1711
- block_background_fill_dark="#0f1629",
1712
- block_border_color="#1e293b",
1713
- block_border_color_dark="#1e293b",
1714
- block_label_background_fill="#111827",
1715
- block_label_background_fill_dark="#111827",
1716
- block_label_text_color="#94a3b8",
1717
- block_label_text_color_dark="#94a3b8",
1718
- input_background_fill="#0a0e1a",
1719
- input_background_fill_dark="#0a0e1a",
1720
- input_border_color="#1e293b",
1721
- input_border_color_dark="#1e293b",
1722
- input_border_color_focus="#00d4ff",
1723
- input_border_color_focus_dark="#00d4ff",
1724
- input_placeholder_color="#4a5568",
1725
- input_placeholder_color_dark="#4a5568",
1726
- button_primary_background_fill="#00d4ff",
1727
- button_primary_background_fill_dark="#00d4ff",
1728
- button_primary_background_fill_hover="#00bce6",
1729
- button_primary_background_fill_hover_dark="#00bce6",
1730
- button_primary_text_color="#050816",
1731
- button_primary_text_color_dark="#050816",
1732
- button_secondary_background_fill="#111827",
1733
- button_secondary_background_fill_dark="#111827",
1734
- button_secondary_text_color="#e2e8f0",
1735
- button_secondary_text_color_dark="#e2e8f0",
1736
- shadow_drop="0 2px 8px rgba(0, 0, 0, 0.3)",
1737
- shadow_drop_lg="0 4px 16px rgba(0, 0, 0, 0.4)",
1738
- shadow_spread="0 0 0 3px rgba(0, 212, 255, 0.08)",
1739
- panel_background_fill="#0a0e1a",
1740
- panel_background_fill_dark="#0a0e1a",
1741
- panel_border_color="#1e293b",
1742
- panel_border_color_dark="#1e293b",
1743
- )
1744
-
1745
-
1746
- # ---------------------------------------------------------------------------
1747
- # JavaScript — clipboard paste handler + auto-analyze
1748
- # ---------------------------------------------------------------------------
1749
- _PASTE_JS = """
1750
- function() {
1751
- function imgRoot() {
1752
- return document.getElementById('screenshot-input')
1753
- || document.querySelector('[data-testid="image"]')
1754
- || document.querySelector('.gradio-image')
1755
- || document.body;
1756
- }
1757
-
1758
- /* ---- helper: push an image File into the Gradio Image component ---- */
1759
- function pushFile(file) {
1760
- var root = imgRoot();
1761
- var fi = root.querySelector('input[type="file"]')
1762
- || document.querySelector('input[type="file"][accept*="image"]');
1763
- if (!fi) return false;
1764
- var dt = new DataTransfer();
1765
- dt.items.add(file);
1766
- fi.files = dt.files;
1767
- fi.dispatchEvent(new Event('change', {bubbles: true}));
1768
- return true;
1769
- }
1770
-
1771
- function autoAnalyze() {
1772
- setTimeout(function() {
1773
- var btn = document.getElementById('analyze-btn');
1774
- if (btn) btn.click();
1775
- }, 800);
1776
- }
1777
-
1778
- /* ---- 1. Global Ctrl-V / Cmd-V paste handler ---- */
1779
- document.addEventListener('paste', function(event) {
1780
- var items = event.clipboardData && event.clipboardData.items;
1781
- if (!items) return;
1782
- for (var i = 0; i < items.length; i++) {
1783
- if (items[i].type.indexOf('image') === -1) continue;
1784
- var file = items[i].getAsFile();
1785
- if (!file) continue;
1786
- if (pushFile(file)) {
1787
- event.preventDefault();
1788
- autoAnalyze();
1789
- }
1790
- break;
1791
- }
1792
- });
1793
-
1794
- /* ---- 2. Patch the Gradio "Paste from clipboard" button ---- */
1795
- function patchPasteBtn() {
1796
- var container = imgRoot();
1797
- container.querySelectorAll('button').forEach(function(btn) {
1798
- if (btn._pastePatch) return;
1799
- var txt = (btn.textContent || '') + (btn.getAttribute('aria-label') || '');
1800
- if (txt.toLowerCase().indexOf('paste') === -1 &&
1801
- txt.toLowerCase().indexOf('clipboard') === -1) return;
1802
- btn._pastePatch = true;
1803
- btn.addEventListener('click', function(e) {
1804
- e.stopImmediatePropagation();
1805
- e.preventDefault();
1806
- if (!navigator.clipboard || !navigator.clipboard.read) {
1807
- alert('Your browser does not support direct clipboard access.\\n'
1808
- + 'Please press Ctrl+V (Cmd+V on Mac) to paste a screenshot.');
1809
- return;
1810
- }
1811
- navigator.clipboard.read().then(function(clipItems) {
1812
- for (var ci = 0; ci < clipItems.length; ci++) {
1813
- var imgType = null;
1814
- for (var t = 0; t < clipItems[ci].types.length; t++) {
1815
- if (clipItems[ci].types[t].startsWith('image/')) {
1816
- imgType = clipItems[ci].types[t]; break;
1817
- }
1818
- }
1819
- if (!imgType) continue;
1820
- clipItems[ci].getType(imgType).then(function(blob) {
1821
- var f = new File([blob], 'clipboard.png', {type: blob.type});
1822
- if (pushFile(f)) autoAnalyze();
1823
- });
1824
- return;
1825
- }
1826
- alert('No image found in your clipboard.\\n'
1827
- + 'Copy a screenshot first, then click Paste or press Ctrl+V.');
1828
- }).catch(function() {
1829
- alert('Clipboard access was blocked by your browser.\\n'
1830
- + 'Please press Ctrl+V (Cmd+V on Mac) to paste a screenshot instead.');
1831
- });
1832
- }, true);
1833
- });
1834
- }
1835
- /* ---- 3. Rewrite drop-zone text to "Upload image" ---- */
1836
- var WANTED = 'Upload image';
1837
- function tweakImageUI() {
1838
- var root = imgRoot();
1839
- var walker = document.createTreeWalker(
1840
- root, NodeFilter.SHOW_TEXT, null, false);
1841
- var node;
1842
- while (node = walker.nextNode()) {
1843
- var t = node.nodeValue.trim();
1844
- if (!t || t === WANTED) continue;
1845
- if (/drop.*image/i.test(t) || /paste from clipboard/i.test(t)
1846
- || /upload.*file/i.test(t)) {
1847
- node.nodeValue = WANTED;
1848
- } else if (/click to upload/i.test(t)
1849
- || t === '- or -' || t === 'or'
1850
- || t === '-' || t === '--') {
1851
- node.nodeValue = '';
1852
- }
1853
- }
1854
- }
1855
-
1856
- function applyAll() { patchPasteBtn(); tweakImageUI(); }
1857
- setTimeout(applyAll, 1500);
1858
- setInterval(tweakImageUI, 1000);
1859
- }
1860
- """
1861
-
1862
-
1863
- # ---------------------------------------------------------------------------
1864
- # Gradio UI — CyberShield Layout
1865
- # ---------------------------------------------------------------------------
1866
- with gr.Blocks(
1867
- theme=_erna_theme,
1868
- title="Desant Phishing Detection \u2014 Desant.ai",
1869
- css=_CUSTOM_CSS,
1870
- head=_FONT_HEAD,
1871
- js=_PASTE_JS,
1872
- ) as demo:
1873
-
1874
- gr.HTML(_HERO_HTML)
1875
-
1876
- with gr.Row():
1877
- with gr.Column(scale=5):
1878
- img_input = gr.Image(
1879
- type="pil",
1880
- label="Screenshot",
1881
- height=380,
1882
- show_label=True,
1883
- sources=["upload"],
1884
- elem_id="screenshot-input",
1885
- )
1886
- url_input = gr.Textbox(
1887
- label="Target URL",
1888
- placeholder="https://example.com/login",
1889
- max_lines=1,
1890
- info="Paste a URL to auto-capture a screenshot via headless browser",
1891
- )
1892
- analyze_btn = gr.Button(
1893
- "\u25b6 ANALYZE THREAT",
1894
- variant="primary",
1895
- size="lg",
1896
- elem_id="analyze-btn",
1897
- )
1898
- gr.HTML(_HOW_IT_WORKS_HTML)
1899
-
1900
- with gr.Column(scale=7):
1901
- results_output = gr.HTML(
1902
- value=_PLACEHOLDER_HTML,
1903
- label="Analysis Results",
1904
- )
1905
-
1906
- with gr.Accordion("Raw API Response", open=False):
1907
- json_output = gr.Code(language="json", label="JSON Response")
1908
-
1909
- analyze_btn.click(
1910
- fn=analyze_screenshot,
1911
- inputs=[img_input, url_input],
1912
- outputs=[img_input, results_output, json_output],
1913
- )
1914
 
1915
- gr.HTML(_FOOTER_HTML)
 
1916
 
 
 
 
 
 
 
1917
 
1918
- # ---------------------------------------------------------------------------
1919
- # Launch
1920
- # ---------------------------------------------------------------------------
1921
  if __name__ == "__main__":
1922
  demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ def ping(name: str):
4
+ return f"Smoke app is running, {name or 'friend'}"
5
 
6
+ with gr.Blocks(title="Smoke Test") as demo:
7
+ gr.Markdown("# Smoke test app")
8
+ inp = gr.Textbox(label="Name")
9
+ out = gr.Textbox(label="Output")
10
+ btn = gr.Button("Run")
11
+ btn.click(fn=ping, inputs=inp, outputs=out)
12
 
 
 
 
13
  if __name__ == "__main__":
14
  demo.launch(server_name="0.0.0.0", server_port=7860)