diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc
index 896bb4f..42d4d91 100644
Binary files a/config/__pycache__/__init__.cpython-311.pyc and b/config/__pycache__/__init__.cpython-311.pyc differ
diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc
index d79d6a7..99256be 100644
Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ
diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc
index 8cf22af..03841a2 100644
Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ
diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc
index a1b4aa7..c9ec01d 100644
Binary files a/config/__pycache__/wsgi.cpython-311.pyc and b/config/__pycache__/wsgi.cpython-311.pyc differ
diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc
index 3f553f6..08021f2 100644
Binary files a/core/__pycache__/__init__.cpython-311.pyc and b/core/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 5e8987a..db43b30 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/apps.cpython-311.pyc b/core/__pycache__/apps.cpython-311.pyc
index 2fa4a49..bb6c68c 100644
Binary files a/core/__pycache__/apps.cpython-311.pyc and b/core/__pycache__/apps.cpython-311.pyc differ
diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc
index 75bf223..db533da 100644
Binary files a/core/__pycache__/context_processors.cpython-311.pyc and b/core/__pycache__/context_processors.cpython-311.pyc differ
diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc
new file mode 100644
index 0000000..0592b1f
Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ
diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index a251b5f..47b0db3 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/scanner.cpython-311.pyc b/core/__pycache__/scanner.cpython-311.pyc
new file mode 100644
index 0000000..d777069
Binary files /dev/null and b/core/__pycache__/scanner.cpython-311.pyc differ
diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc
new file mode 100644
index 0000000..4772e20
Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index f705988..313472c 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 2f0989c..05874c9 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/admin.py b/core/admin.py
index 8c38f3f..dcdf5b7 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -1,3 +1,12 @@
from django.contrib import admin
-# Register your models here.
+from .models import ThreatScan
+
+
+@admin.register(ThreatScan)
+class ThreatScanAdmin(admin.ModelAdmin):
+ list_display = ("id", "scan_type", "risk_score", "risk_level", "target_preview", "model_version", "created_at")
+ list_filter = ("scan_type", "risk_level", "model_version", "created_at")
+ search_fields = ("target_preview", "content_hash", "verdict")
+ readonly_fields = ("created_at", "content_hash")
+ ordering = ("-created_at",)
diff --git a/core/forms.py b/core/forms.py
new file mode 100644
index 0000000..2197ec4
--- /dev/null
+++ b/core/forms.py
@@ -0,0 +1,49 @@
+from django import forms
+from django.core.validators import URLValidator
+from django.core.exceptions import ValidationError
+
+from .models import ThreatScan
+
+
+class ThreatScanForm(forms.Form):
+ scan_type = forms.ChoiceField(
+ choices=ThreatScan.ScanType.choices,
+ widget=forms.RadioSelect,
+ initial=ThreatScan.ScanType.URL,
+ label="What do you want to scan?",
+ )
+ content = forms.CharField(
+ label="URL, email, or message",
+ max_length=5000,
+ widget=forms.Textarea(attrs={
+ "rows": 6,
+ "placeholder": "Paste a suspicious URL, email, SMS, or chat message. Raw text is analyzed in-memory and not stored.",
+ }),
+ )
+ store_metadata = forms.BooleanField(
+ required=False,
+ initial=True,
+ label="Save privacy-safe metadata for my dashboard",
+ help_text="Only a short sanitized preview, hash, score, and explanation are stored — not the raw submission.",
+ )
+
+ def clean_content(self):
+ content = self.cleaned_data["content"].strip()
+ if len(content) < 6:
+ raise ValidationError("Please enter enough text to analyze.")
+ return content
+
+ def clean(self):
+ cleaned = super().clean()
+ scan_type = cleaned.get("scan_type")
+ content = cleaned.get("content")
+ if scan_type == ThreatScan.ScanType.URL and content:
+ candidate = content.strip()
+ if not candidate.startswith(("http://", "https://")):
+ candidate = f"https://{candidate}"
+ try:
+ URLValidator()(candidate)
+ except ValidationError as exc:
+ raise ValidationError("Enter a valid URL, or switch the scan type to Email / Message.") from exc
+ cleaned["content"] = candidate
+ return cleaned
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..f049a2e
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 5.2.7 on 2026-06-13 09:41
+
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ThreatScan',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('scan_type', models.CharField(choices=[('url', 'URL / Website'), ('message', 'Email / Message')], max_length=20)),
+ ('target_preview', models.CharField(help_text='Sanitized preview only; raw sensitive content is not stored.', max_length=220)),
+ ('content_hash', models.CharField(db_index=True, max_length=64)),
+ ('risk_score', models.PositiveSmallIntegerField(default=0)),
+ ('risk_level', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], default='low', max_length=20)),
+ ('verdict', models.CharField(max_length=160)),
+ ('explanation', models.TextField()),
+ ('indicators', models.JSONField(blank=True, default=list)),
+ ('recommended_actions', models.JSONField(blank=True, default=list)),
+ ('model_version', models.CharField(default='heuristic-nlp-v1', max_length=40)),
+ ('store_metadata', models.BooleanField(default=True)),
+ ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ 'indexes': [models.Index(fields=['scan_type', 'risk_level'], name='core_threat_scan_ty_620347_idx'), models.Index(fields=['created_at'], name='core_threat_created_e17dbf_idx')],
+ },
+ ),
+ ]
diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc
new file mode 100644
index 0000000..539092f
Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc
index 7995815..a6c472c 100644
Binary files a/core/migrations/__pycache__/__init__.cpython-311.pyc and b/core/migrations/__pycache__/__init__.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 71a8362..eaab6f0 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,3 +1,46 @@
from django.db import models
+from django.utils import timezone
-# Create your models here.
+
+class ThreatScan(models.Model):
+ class ScanType(models.TextChoices):
+ URL = "url", "URL / Website"
+ MESSAGE = "message", "Email / Message"
+
+ class RiskLevel(models.TextChoices):
+ LOW = "low", "Low"
+ MEDIUM = "medium", "Medium"
+ HIGH = "high", "High"
+ CRITICAL = "critical", "Critical"
+
+ scan_type = models.CharField(max_length=20, choices=ScanType.choices)
+ target_preview = models.CharField(max_length=220, help_text="Sanitized preview only; raw sensitive content is not stored.")
+ content_hash = models.CharField(max_length=64, db_index=True)
+ risk_score = models.PositiveSmallIntegerField(default=0)
+ risk_level = models.CharField(max_length=20, choices=RiskLevel.choices, default=RiskLevel.LOW)
+ verdict = models.CharField(max_length=160)
+ explanation = models.TextField()
+ indicators = models.JSONField(default=list, blank=True)
+ recommended_actions = models.JSONField(default=list, blank=True)
+ model_version = models.CharField(max_length=40, default="heuristic-nlp-v1")
+ store_metadata = models.BooleanField(default=True)
+ created_at = models.DateTimeField(default=timezone.now, db_index=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["scan_type", "risk_level"]),
+ models.Index(fields=["created_at"]),
+ ]
+
+ def __str__(self):
+ return f"{self.get_scan_type_display()} · {self.risk_score}/100 · {self.target_preview[:50]}"
+
+ @property
+ def risk_badge_class(self):
+ return {
+ self.RiskLevel.LOW: "success",
+ self.RiskLevel.MEDIUM: "warning",
+ self.RiskLevel.HIGH: "danger",
+ self.RiskLevel.CRITICAL: "critical",
+ }.get(self.risk_level, "secondary")
diff --git a/core/scanner.py b/core/scanner.py
new file mode 100644
index 0000000..5cc1c39
--- /dev/null
+++ b/core/scanner.py
@@ -0,0 +1,168 @@
+import hashlib
+import math
+import re
+from dataclasses import dataclass
+from urllib.parse import urlparse
+
+from .models import ThreatScan
+
+SUSPICIOUS_TLDS = {"zip", "mov", "click", "country", "gq", "tk", "ml", "cf"}
+BRAND_TERMS = {"paypal", "microsoft", "google", "apple", "amazon", "bank", "chase", "wellsfargo", "office365"}
+URGENCY_TERMS = {"urgent", "immediately", "verify", "suspended", "locked", "limited", "expire", "password", "invoice", "wire", "gift card", "crypto"}
+CREDENTIAL_TERMS = {"login", "signin", "sign in", "password", "2fa", "otp", "account", "credentials", "ssn"}
+URL_SHORTENERS = {"bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd", "buff.ly", "cutt.ly"}
+
+
+@dataclass
+class ScanResult:
+ risk_score: int
+ risk_level: str
+ verdict: str
+ explanation: str
+ indicators: list[dict]
+ recommended_actions: list[str]
+ target_preview: str
+ content_hash: str
+
+
+def _hash_content(content: str) -> str:
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
+
+
+def _preview(content: str, limit: int = 180) -> str:
+ clean = re.sub(r"\s+", " ", content).strip()
+ clean = re.sub(r"([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*(@)", r"•••", clean)
+ return clean[:limit] + ("…" if len(clean) > limit else "")
+
+
+def _add(indicators: list[dict], label: str, weight: int, detail: str):
+ indicators.append({"label": label, "weight": weight, "detail": detail})
+
+
+def _risk_level(score: int) -> str:
+ if score >= 85:
+ return ThreatScan.RiskLevel.CRITICAL
+ if score >= 65:
+ return ThreatScan.RiskLevel.HIGH
+ if score >= 35:
+ return ThreatScan.RiskLevel.MEDIUM
+ return ThreatScan.RiskLevel.LOW
+
+
+def _verdict(score: int) -> str:
+ if score >= 85:
+ return "Likely malicious — isolate and do not interact"
+ if score >= 65:
+ return "High-risk suspicious content"
+ if score >= 35:
+ return "Needs review before trusting"
+ return "Low risk based on current signals"
+
+
+def _actions(level: str) -> list[str]:
+ if level in {ThreatScan.RiskLevel.CRITICAL, ThreatScan.RiskLevel.HIGH}:
+ return [
+ "Do not click links, download attachments, or enter credentials.",
+ "Report this item to your security team or service provider.",
+ "If you already interacted, rotate passwords and review account activity.",
+ ]
+ if level == ThreatScan.RiskLevel.MEDIUM:
+ return [
+ "Verify the sender/domain through an independent channel.",
+ "Hover or inspect links before opening them.",
+ "Avoid sharing credentials or payment details until confirmed.",
+ ]
+ return [
+ "No strong malicious signals were found, but continue to verify unexpected requests.",
+ "Keep software and browser protections enabled.",
+ ]
+
+
+def scan_content(scan_type: str, content: str) -> ScanResult:
+ indicators: list[dict] = []
+ score = 5
+ lowered = content.lower()
+
+ if scan_type == ThreatScan.ScanType.URL:
+ parsed = urlparse(content)
+ host = (parsed.netloc or parsed.path).lower().split(":")[0]
+ path = parsed.path.lower()
+ labels = [part for part in host.split(".") if part]
+ tld = labels[-1] if labels else ""
+
+ if parsed.scheme == "http":
+ score += 18
+ _add(indicators, "Unencrypted HTTP", 18, "The URL uses http:// instead of https://.")
+ if host.replace(".", "").isdigit() or re.match(r"^\d+\.\d+\.\d+\.\d+$", host):
+ score += 22
+ _add(indicators, "IP address host", 22, "Phishing links often hide behind raw IP addresses.")
+ if host in URL_SHORTENERS:
+ score += 20
+ _add(indicators, "Shortened URL", 20, "Shorteners hide the final destination until opened.")
+ if tld in SUSPICIOUS_TLDS:
+ score += 14
+ _add(indicators, "Higher-risk TLD", 14, f".{tld} domains are frequently abused in commodity phishing.")
+ if len(host) > 38 or len(content) > 120:
+ score += 10
+ _add(indicators, "Long destination", 10, "Very long hosts/URLs can hide deceptive tracking or redirect chains.")
+ if "@" in content:
+ score += 18
+ _add(indicators, "@ symbol in URL", 18, "The @ character can disguise the actual destination host.")
+ if sum(1 for ch in host if ch == "-") >= 2:
+ score += 8
+ _add(indicators, "Hyphen-heavy domain", 8, "Multiple hyphens can imitate legitimate brand domains.")
+ matched_brands = [brand for brand in BRAND_TERMS if brand in host and not host.endswith(f"{brand}.com")]
+ if matched_brands:
+ score += 16
+ _add(indicators, "Brand impersonation pattern", 16, f"The host contains sensitive brand terms: {', '.join(matched_brands[:3])}.")
+ if any(term in path for term in CREDENTIAL_TERMS):
+ score += 12
+ _add(indicators, "Credential-themed path", 12, "The path references login, password, or account actions.")
+ else:
+ urgent_hits = [term for term in URGENCY_TERMS if term in lowered]
+ credential_hits = [term for term in CREDENTIAL_TERMS if term in lowered]
+ money_hits = re.findall(r"\$\s?\d+|wire transfer|gift card|bitcoin|crypto", lowered)
+ url_count = len(re.findall(r"https?://|www\.", lowered))
+
+ if urgent_hits:
+ weight = min(25, 8 + len(urgent_hits) * 4)
+ score += weight
+ _add(indicators, "Urgency and pressure language", weight, f"Found terms such as {', '.join(urgent_hits[:5])}.")
+ if credential_hits:
+ weight = min(24, 10 + len(credential_hits) * 3)
+ score += weight
+ _add(indicators, "Credential request", weight, f"The message asks about {', '.join(credential_hits[:5])}.")
+ if money_hits:
+ score += 16
+ _add(indicators, "Payment or transfer request", 16, "The message references money movement or irreversible payments.")
+ if url_count:
+ score += min(20, url_count * 7)
+ _add(indicators, "Embedded link", min(20, url_count * 7), f"Detected {url_count} link-like item(s) in the message.")
+ if re.search(r"dear (customer|user|client)|kindly|act now|final notice", lowered):
+ score += 10
+ _add(indicators, "Common scam phrasing", 10, "The wording resembles common phishing templates.")
+ if len(content) < 40 and any(term in lowered for term in ["click", "verify", "login"]):
+ score += 8
+ _add(indicators, "Sparse context", 8, "Short messages with action links are harder to verify safely.")
+
+ # Normalize so a pile-up of weak signals does not instantly max out risk.
+ score = min(100, max(0, round(100 * (1 - math.exp(-score / 85)))))
+ if not indicators:
+ _add(indicators, "No strong threat indicators", 0, "The scanner did not find obvious phishing markers in this sample.")
+
+ level = _risk_level(score)
+ explanation = (
+ "This first MVP uses a local heuristic/NLP-style rules engine designed to be replaced or blended "
+ "with a trained Scikit-learn model. It does not store the raw submission; the dashboard saves only "
+ "a sanitized preview, SHA-256 hash, score, and explanation."
+ )
+ return ScanResult(
+ risk_score=score,
+ risk_level=level,
+ verdict=_verdict(score),
+ explanation=explanation,
+ indicators=sorted(indicators, key=lambda item: item["weight"], reverse=True),
+ recommended_actions=_actions(level),
+ target_preview=_preview(content),
+ content_hash=_hash_content(content),
+ )
diff --git a/core/templates/base.html b/core/templates/base.html
index 1e7e5fb..6875c2d 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -1,11 +1,13 @@
+{% load static %}
- {% block title %}Knowledge Base{% endblock %}
+
+ {% block title %}{{ project_name|default:"SentinelAI Cyber Assistant" }}{% endblock %}
+
{% if project_description %}
-
{% endif %}
@@ -13,13 +15,45 @@
{% endif %}
- {% load static %}
+
+
+
+
{% block head %}{% endblock %}
- {% block content %}{% endblock %}
+ Skip to content
+
+
+ {% block content %}{% endblock %}
+
+
+
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
index faec813..afcd0ad 100644
--- a/core/templates/core/index.html
+++ b/core/templates/core/index.html
@@ -1,145 +1,150 @@
{% extends "base.html" %}
-{% block title %}{{ project_name }}{% endblock %}
-
-{% block head %}
-
-
-
-
-{% endblock %}
+{% block title %}SentinelAI Cyber Assistant — Phishing & Scam Risk Scanner{% endblock %}
+{% block meta_description %}Paste a URL, email, or message to receive a privacy-safe 0–100 phishing risk score with clear explanations.{% endblock %}
{% block content %}
-
-
-
Analyzing your requirements and generating your app…
-
-
Loading…
+
+
+
+
+
+
+
Privacy-first cyber defense
+
Detect phishing URLs and scam messages before they reach people.
+
SentinelAI gives every submission a 0–100 risk score, explains the strongest signals, and stores only privacy-safe metadata for your dashboard.
+
+
+ URL phishing detection
+ Scam message NLP
+ Explainable AI
+
+
+
+
+
+
+ {{ avg_score }}
+ avg risk
+
+
+
+
{{ high_risk_count }}High risk
+
+
+
+
Local in-memory analysis
+
False-positive review dashboard
+
Threat explanations included
+
+
+
-
AppWizzy AI is collecting your requirements and applying the first changes.
-
This page will refresh automatically as the plan is implemented.
-
- Runtime: Django {{ django_version }} · Python {{ python_version }}
- — UTC {{ current_time|date:"Y-m-d H:i:s" }}
-
-
-
-{% endblock %}
\ No newline at end of file
+
+
+
+
+
+
+
+
First workflow
+
Scan a suspicious URL, email, or message
+
This MVP uses a local heuristic/NLP engine as the safe baseline before adding trained Scikit-learn models and public cybersecurity datasets.
+
+
+
+
+
+
Security posture
+
Built for sensitive submissions
+
+
+ 01
+ Raw content stays out of storage
+ Only a sanitized preview, SHA-256 hash, risk score, and explanation are persisted.
+
+
+ 02
+ Explainable decisions
+ Every result lists weighted indicators so users can understand what triggered the score.
+
+
+ 03
+ ML-ready pipeline
+ The scanner interface is ready to blend in trained models, evaluation metrics, and safe updates.
+
+
+
+
+
+
+
+
+
+
+
+
+
Threat dashboard
+
Recent detections
+
+
Open full history
+
+ {% if recent_scans %}
+
+ {% for scan in recent_scans %}
+
+ {% endfor %}
+
+ {% else %}
+
+
◇
+
No scans yet
+
Run your first URL or message scan to populate the dashboard.
+
Start scanning
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/core/templates/core/scan_detail.html b/core/templates/core/scan_detail.html
new file mode 100644
index 0000000..c39654b
--- /dev/null
+++ b/core/templates/core/scan_detail.html
@@ -0,0 +1,70 @@
+{% extends "base.html" %}
+
+{% block title %}Scan Result #{{ scan.pk }} — SentinelAI{% endblock %}
+{% block meta_description %}Explainable AI cybersecurity result with risk score, flagged indicators, and recommended next actions.{% endblock %}
+
+{% block content %}
+
+
+
+
+
Scan confirmation
+
{{ scan.verdict }}
+
Result created {{ scan.created_at|date:"M j, Y H:i" }} using {{ scan.model_version }}.
+
+
Scan another item
+
+
+
+
+
+
+
+
+
+ {{ scan.risk_score }}
+ risk / 100
+
+
{{ scan.get_risk_level_display }} risk
+
+ - Scan type
- {{ scan.get_scan_type_display }}
+ - Sanitized preview
- {{ scan.target_preview }}
+ - Content hash
{{ scan.content_hash|slice:":16" }}…
+
+
+
+
+
+
Why it was flagged
+
Explainable indicators
+
{{ scan.explanation }}
+
+ {% for indicator in scan.indicators %}
+
+
+
{{ indicator.label }}
+
{{ indicator.detail }}
+
+ +{{ indicator.weight }}
+
+ {% endfor %}
+
+
+
+
Recommended response
+
Next actions
+
+ {% for action in scan.recommended_actions %}
+ - {{ action }}
+ {% endfor %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/scan_history.html b/core/templates/core/scan_history.html
new file mode 100644
index 0000000..d42bebe
--- /dev/null
+++ b/core/templates/core/scan_history.html
@@ -0,0 +1,60 @@
+{% extends "base.html" %}
+
+{% block title %}Threat Dashboard — SentinelAI{% endblock %}
+{% block meta_description %}Review privacy-safe phishing and scam scan results by risk level, score, and explanation.{% endblock %}
+
+{% block content %}
+
+
+
Dashboard
+
Threat scan history
+
Monitor risk levels, review explanations, and identify items that need human verification.
+
+
+
+
+
+
Total scans{{ total_scans }}
+
Average risk{{ avg_score }}/100
+
High-risk items{{ high_risk_count }}
+
+
+ {% if scans %}
+
+
+
+
+ | Risk |
+ Type |
+ Preview |
+ Verdict |
+ Created |
+ |
+
+
+
+ {% for scan in scans %}
+
+ | {{ scan.risk_score }}/100 |
+ {{ scan.get_scan_type_display }} |
+ {{ scan.target_preview }} |
+ {{ scan.verdict }} |
+ {{ scan.created_at|date:"M j, Y H:i" }} |
+ Review |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
◇
+
No detections yet
+
Use the scanner to create your first privacy-safe result.
+
Run first scan
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/core/tests.py b/core/tests.py
index 7ce503c..9a14e48 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -1,3 +1,24 @@
from django.test import TestCase
+from django.urls import reverse
-# Create your tests here.
+from .models import ThreatScan
+from .scanner import scan_content
+
+
+class ThreatScanWorkflowTests(TestCase):
+ def test_scanner_flags_suspicious_url(self):
+ result = scan_content("url", "http://paypal-login-security.example.click/account/verify-password")
+ self.assertGreaterEqual(result.risk_score, 35)
+ self.assertTrue(result.indicators)
+
+ def test_post_scan_creates_privacy_safe_record_and_redirects(self):
+ response = self.client.post(reverse("create_scan"), {
+ "scan_type": "message",
+ "content": "Urgent: verify your password now or your bank account will be suspended. Click https://example.com",
+ "store_metadata": "on",
+ })
+ self.assertEqual(response.status_code, 302)
+ scan = ThreatScan.objects.get()
+ self.assertNotIn("Urgent:", scan.content_hash)
+ self.assertGreater(scan.risk_score, 0)
+ self.assertTrue(scan.explanation)
diff --git a/core/urls.py b/core/urls.py
index 6299e3d..dd28be8 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,7 +1,10 @@
from django.urls import path
-from .views import home
+from .views import create_scan, home, scan_detail, scan_history
urlpatterns = [
path("", home, name="home"),
+ path("scan/", create_scan, name="create_scan"),
+ path("scans/", scan_history, name="scan_history"),
+ path("scans/
/", scan_detail, name="scan_detail"),
]
diff --git a/core/views.py b/core/views.py
index c9aed12..fab6f6c 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,25 +1,80 @@
-import os
-import platform
+from django.db.models import Avg, Count, Max
+from django.shortcuts import get_object_or_404, redirect, render
-from django import get_version as django_version
-from django.shortcuts import render
-from django.utils import timezone
+from .forms import ThreatScanForm
+from .models import ThreatScan
+from .scanner import scan_content
+
+
+def _dashboard_context():
+ scans = ThreatScan.objects.all()
+ totals = scans.aggregate(total=Count("id"), avg_score=Avg("risk_score"), latest=Max("created_at"))
+ high_risk_count = scans.filter(risk_level__in=[ThreatScan.RiskLevel.HIGH, ThreatScan.RiskLevel.CRITICAL]).count()
+ return {
+ "total_scans": totals["total"] or 0,
+ "avg_score": round(totals["avg_score"] or 0),
+ "latest_scan_at": totals["latest"],
+ "high_risk_count": high_risk_count,
+ "recent_scans": scans[:6],
+ }
def home(request):
- """Render the landing screen with loader and environment details."""
- host_name = request.get_host().lower()
- agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
- now = timezone.now()
-
+ form = ThreatScanForm()
context = {
- "project_name": "New Style",
- "agent_brand": agent_brand,
- "django_version": django_version(),
- "python_version": platform.python_version(),
- "current_time": now,
- "host_name": host_name,
- "project_description": os.getenv("PROJECT_DESCRIPTION", ""),
- "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
+ "project_name": "SentinelAI Cyber Assistant",
+ "meta_description": "Privacy-first AI cybersecurity assistant for phishing URL and scam message risk scoring with clear explanations.",
+ "form": form,
+ **_dashboard_context(),
}
return render(request, "core/index.html", context)
+
+
+def create_scan(request):
+ if request.method != "POST":
+ return redirect("home")
+ form = ThreatScanForm(request.POST)
+ if not form.is_valid():
+ context = {
+ "project_name": "SentinelAI Cyber Assistant",
+ "meta_description": "Scan a suspicious URL, email, or message for phishing risk.",
+ "form": form,
+ **_dashboard_context(),
+ }
+ return render(request, "core/index.html", context, status=422)
+
+ result = scan_content(form.cleaned_data["scan_type"], form.cleaned_data["content"])
+ scan = ThreatScan.objects.create(
+ scan_type=form.cleaned_data["scan_type"],
+ target_preview=result.target_preview,
+ content_hash=result.content_hash,
+ risk_score=result.risk_score,
+ risk_level=result.risk_level,
+ verdict=result.verdict,
+ explanation=result.explanation,
+ indicators=result.indicators,
+ recommended_actions=result.recommended_actions,
+ store_metadata=form.cleaned_data["store_metadata"],
+ )
+ return redirect("scan_detail", pk=scan.pk)
+
+
+def scan_history(request):
+ scans = ThreatScan.objects.all()
+ context = {
+ "project_name": "Scan History",
+ "meta_description": "Review privacy-safe cybersecurity scan results and risk levels.",
+ "scans": scans,
+ **_dashboard_context(),
+ }
+ return render(request, "core/scan_history.html", context)
+
+
+def scan_detail(request, pk):
+ scan = get_object_or_404(ThreatScan, pk=pk)
+ context = {
+ "project_name": f"Scan Result #{scan.pk}",
+ "meta_description": "Detailed phishing and scam risk result with explainable AI indicators.",
+ "scan": scan,
+ }
+ return render(request, "core/scan_detail.html", context)
diff --git a/static/css/custom.css b/static/css/custom.css
index 925f6ed..1881a0c 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -1,4 +1,287 @@
-/* Custom styles for the application */
-body {
- font-family: system-ui, -apple-system, sans-serif;
+/* SentinelAI custom theme */
+:root {
+ --bg: #06111f;
+ --bg-2: #0b1f33;
+ --surface: rgba(15, 34, 54, 0.82);
+ --surface-strong: #10263d;
+ --line: rgba(163, 205, 255, 0.16);
+ --text: #eef7ff;
+ --muted: #9fb6ca;
+ --primary: #10e5b9;
+ --primary-dark: #08a98a;
+ --secondary: #28c2ff;
+ --accent: #ffb020;
+ --danger: #ff4d6d;
+ --success: #2ee59d;
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
+ --radius-xl: 28px;
+ --radius-lg: 20px;
+}
+
+* { box-sizing: border-box; }
+
+html { scroll-behavior: smooth; }
+
+body {
+ margin: 0;
+ font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background:
+ radial-gradient(circle at 10% 5%, rgba(16, 229, 185, 0.16), transparent 28rem),
+ radial-gradient(circle at 88% 20%, rgba(40, 194, 255, 0.14), transparent 24rem),
+ linear-gradient(135deg, var(--bg), #081827 54%, #03101d);
+ color: var(--text);
+ min-height: 100vh;
+}
+
+h1, h2, h3, .navbar-brand {
+ font-family: "Space Grotesk", "Inter", sans-serif;
+ letter-spacing: -0.04em;
+}
+
+a { color: inherit; }
+
+a:hover { color: var(--primary); }
+
+.skip-link {
+ position: absolute;
+ left: -999px;
+ top: 0;
+ background: var(--primary);
+ color: #03101d;
+ padding: .75rem 1rem;
+ z-index: 9999;
+}
+
+.skip-link:focus { left: 1rem; top: 1rem; }
+
+.app-nav {
+ background: rgba(6, 17, 31, 0.78);
+ border-bottom: 1px solid var(--line);
+ backdrop-filter: blur(18px);
+}
+
+.navbar-brand { color: var(--text); font-weight: 700; }
+.navbar-brand:hover, .nav-link:hover { color: var(--primary); }
+.nav-link { color: var(--muted); font-weight: 600; }
+.navbar-toggler { border-color: var(--line); }
+.navbar-toggler-icon { filter: invert(1); }
+.brand-mark { color: var(--primary); text-shadow: 0 0 24px rgba(16, 229, 185, .75); }
+
+.btn {
+ border-radius: 999px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
+.btn-primary-neo {
+ color: #02111d;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ border: 0;
+ box-shadow: 0 16px 36px rgba(16, 229, 185, 0.24);
+}
+
+.btn-primary-neo:hover, .btn-primary-neo:focus {
+ color: #02111d;
+ transform: translateY(-1px);
+ box-shadow: 0 20px 44px rgba(40, 194, 255, 0.28);
+}
+
+.btn-ghost, .btn-admin {
+ color: var(--text);
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.btn-ghost:hover, .btn-admin:hover { border-color: var(--primary); background: rgba(16, 229, 185, 0.1); }
+
+.hero-section { padding: 4rem 0 2rem; }
+.section-pad { padding: 5rem 0; }
+.section-pad-sm { padding: 4rem 0 2rem; }
+
+.display-title {
+ font-size: clamp(3rem, 8vw, 6.6rem);
+ line-height: .92;
+ max-width: 12ch;
+}
+
+.page-title { font-size: clamp(2.4rem, 5vw, 4.8rem); line-height: 1; max-width: 12ch; }
+.hero-copy { color: #c7d8e8; font-size: clamp(1.05rem, 2vw, 1.25rem); max-width: 43rem; }
+.eyebrow, .section-kicker {
+ display: inline-flex;
+ align-items: center;
+ gap: .5rem;
+ color: var(--primary);
+ text-transform: uppercase;
+ font-size: .78rem;
+ font-weight: 800;
+ letter-spacing: .14em;
+}
+
+.eyebrow::before, .section-kicker::before {
+ content: "";
+ width: .55rem;
+ height: .55rem;
+ border-radius: 50%;
+ background: var(--primary);
+ box-shadow: 0 0 24px rgba(16, 229, 185, .85);
+}
+
+.text-muted-soft { color: var(--muted); }
+.letter-spaced { letter-spacing: .16em; color: var(--muted); }
+
+.orb {
+ position: absolute;
+ border-radius: 999px;
+ filter: blur(6px);
+ opacity: .7;
+ pointer-events: none;
+}
+.orb-one { width: 18rem; height: 18rem; right: -4rem; top: 6rem; background: radial-gradient(circle, rgba(16,229,185,.28), transparent 62%); }
+.orb-two { width: 12rem; height: 12rem; left: 45%; bottom: 1rem; background: radial-gradient(circle, rgba(255,176,32,.18), transparent 62%); }
+
+.glass-card, .surface-card, .metric-tile {
+ background: linear-gradient(145deg, rgba(16, 38, 61, .9), rgba(9, 24, 40, .82));
+ border: 1px solid var(--line);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow);
+ backdrop-filter: blur(22px);
+}
+
+.hero-panel { padding: clamp(1.5rem, 4vw, 2.5rem); position: relative; overflow: hidden; }
+.hero-panel::after {
+ content: "";
+ position: absolute;
+ inset: auto -4rem -5rem auto;
+ width: 14rem;
+ height: 14rem;
+ background: linear-gradient(135deg, rgba(16,229,185,.22), rgba(40,194,255,.16));
+ transform: rotate(20deg);
+ border-radius: 2.5rem;
+}
+.status-dot { width: .85rem; height: .85rem; background: var(--primary); border-radius: 50%; box-shadow: 0 0 22px var(--primary); }
+
+.risk-meter {
+ width: 12.5rem;
+ height: 12.5rem;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ background:
+ radial-gradient(circle at center, #10263d 58%, transparent 59%),
+ conic-gradient(var(--primary), var(--secondary), var(--accent), var(--primary));
+ position: relative;
+}
+.risk-meter span { display: block; font: 700 3.5rem/1 "Space Grotesk"; }
+.risk-meter small { display: block; color: var(--muted); margin-top: .25rem; text-transform: uppercase; letter-spacing: .12em; font-weight: 800; font-size: .72rem; }
+.risk-meter.detail { width: 15rem; height: 15rem; }
+.risk-ring-warning { background: radial-gradient(circle at center, #10263d 58%, transparent 59%), conic-gradient(var(--accent), #ffd166, var(--accent)); }
+.risk-ring-danger, .risk-ring-critical { background: radial-gradient(circle at center, #10263d 58%, transparent 59%), conic-gradient(var(--danger), var(--accent), var(--danger)); }
+
+.metric-card, .metric-tile {
+ padding: 1rem;
+ background: rgba(255,255,255,.045);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+}
+.metric-card strong, .metric-tile strong { display: block; font: 700 1.8rem/1 "Space Grotesk"; }
+.metric-card span, .metric-tile span { color: var(--muted); font-size: .86rem; }
+.metric-tile { padding: 1.4rem; }
+.metric-tile strong { margin-top: .5rem; font-size: 2.2rem; }
+
+.trust-row { display: flex; flex-wrap: wrap; gap: .75rem; }
+.trust-row span { padding: .5rem .8rem; border: 1px solid var(--line); border-radius: 999px; color: #d7e8f7; background: rgba(255,255,255,.04); }
+.signal-list { display: grid; gap: .8rem; color: #d7e8f7; }
+.signal { display: inline-block; width: .65rem; height: .65rem; border-radius: 50%; margin-right: .55rem; }
+.signal.good { background: var(--success); } .signal.warn { background: var(--accent); } .signal.danger { background: var(--danger); }
+
+.scan-form textarea, .scan-form input[type="text"], .form-control {
+ width: 100%;
+ color: var(--text);
+ background: rgba(3, 16, 29, 0.72);
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ padding: 1rem;
+}
+.scan-form textarea:focus, .form-control:focus {
+ color: var(--text);
+ background: rgba(3, 16, 29, .9);
+ border-color: var(--primary);
+ box-shadow: 0 0 0 .25rem rgba(16, 229, 185, .12);
+}
+.form-label { color: #dcecff; font-weight: 800; }
+.form-text { color: var(--muted); }
+.invalid-copy { color: #ff8ba0; font-weight: 700; margin-top: .5rem; }
+
+.scan-choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
+.scan-choice {
+ display: flex;
+ align-items: center;
+ gap: .65rem;
+ padding: .9rem 1rem;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: rgba(255,255,255,.04);
+ cursor: pointer;
+ font-weight: 800;
+}
+.scan-choice:has(input:checked) { border-color: var(--primary); background: rgba(16,229,185,.12); }
+.scan-choice input, .metadata-check input { accent-color: var(--primary); }
+
+.privacy-stack { display: grid; gap: 1rem; }
+.privacy-stack article { padding: 1.2rem; border-radius: var(--radius-lg); border: 1px solid var(--line); background: rgba(255,255,255,.035); }
+.privacy-stack span { color: var(--primary); font-weight: 900; }
+.privacy-stack h3 { font-size: 1.15rem; margin: .4rem 0; }
+.privacy-stack p { color: var(--muted); margin: 0; }
+
+.scan-card {
+ display: block;
+ min-height: 13rem;
+ padding: 1.35rem;
+ text-decoration: none;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--line);
+ background: linear-gradient(145deg, rgba(16,38,61,.86), rgba(9,24,40,.72));
+ transition: transform .2s ease, border-color .2s ease;
+}
+.scan-card:hover { transform: translateY(-3px); border-color: var(--primary); color: var(--text); }
+.scan-card strong { display: block; font: 700 2.2rem/1 "Space Grotesk"; margin: 1rem 0 .75rem; }
+.scan-card p { color: #d6e7f6; }
+.scan-card small { color: var(--muted); }
+
+.badge { border-radius: 999px; padding: .45rem .7rem; }
+.risk-success { background: rgba(46,229,157,.15); color: #7dffc8; border: 1px solid rgba(46,229,157,.28); }
+.risk-warning { background: rgba(255,176,32,.15); color: #ffd68a; border: 1px solid rgba(255,176,32,.3); }
+.risk-danger, .risk-critical { background: rgba(255,77,109,.16); color: #ff9aad; border: 1px solid rgba(255,77,109,.34); }
+
+.empty-state { border: 1px dashed var(--line); border-radius: var(--radius-xl); background: rgba(255,255,255,.035); }
+.empty-icon { font-size: 3rem; color: var(--primary); }
+
+.page-hero { background: linear-gradient(180deg, rgba(16,229,185,.07), transparent); border-bottom: 1px solid var(--line); }
+.app-table { --bs-table-bg: transparent; --bs-table-border-color: var(--line); color: var(--text); }
+.app-table th { color: var(--muted); text-transform: uppercase; font-size: .78rem; letter-spacing: .08em; }
+.preview-cell { max-width: 24rem; color: #d7e8f7; }
+
+.scan-meta dt { color: var(--muted); text-transform: uppercase; letter-spacing: .08em; font-size: .75rem; margin-top: 1rem; }
+.scan-meta dd { color: var(--text); margin-bottom: 0; word-break: break-word; }
+.scan-meta code { color: var(--primary); }
+.indicator-list { display: grid; gap: .8rem; }
+.indicator-list article {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem;
+ border-radius: 18px;
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,.04);
+}
+.indicator-list p { color: var(--muted); margin: .25rem 0 0; }
+.indicator-list span { color: var(--accent); font-weight: 900; }
+.action-list { color: #d7e8f7; display: grid; gap: .7rem; }
+.site-footer { color: var(--muted); border-top: 1px solid var(--line); background: rgba(3,16,29,.72); }
+
+@media (max-width: 767px) {
+ .hero-section { padding-top: 2.5rem; }
+ .scan-choice-grid { grid-template-columns: 1fr; }
+ .risk-meter { width: 10.5rem; height: 10.5rem; }
+ .display-title { max-width: 100%; }
}
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 108056f..1881a0c 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -1,21 +1,287 @@
-
+/* SentinelAI custom theme */
:root {
- --bg-color-start: #6a11cb;
- --bg-color-end: #2575fc;
- --text-color: #ffffff;
- --card-bg-color: rgba(255, 255, 255, 0.01);
- --card-border-color: rgba(255, 255, 255, 0.1);
+ --bg: #06111f;
+ --bg-2: #0b1f33;
+ --surface: rgba(15, 34, 54, 0.82);
+ --surface-strong: #10263d;
+ --line: rgba(163, 205, 255, 0.16);
+ --text: #eef7ff;
+ --muted: #9fb6ca;
+ --primary: #10e5b9;
+ --primary-dark: #08a98a;
+ --secondary: #28c2ff;
+ --accent: #ffb020;
+ --danger: #ff4d6d;
+ --success: #2ee59d;
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
+ --radius-xl: 28px;
+ --radius-lg: 20px;
}
+
+* { box-sizing: border-box; }
+
+html { scroll-behavior: smooth; }
+
body {
margin: 0;
- font-family: 'Inter', sans-serif;
- background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
- color: var(--text-color);
- display: flex;
- justify-content: center;
- align-items: center;
+ font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background:
+ radial-gradient(circle at 10% 5%, rgba(16, 229, 185, 0.16), transparent 28rem),
+ radial-gradient(circle at 88% 20%, rgba(40, 194, 255, 0.14), transparent 24rem),
+ linear-gradient(135deg, var(--bg), #081827 54%, #03101d);
+ color: var(--text);
min-height: 100vh;
- text-align: center;
- overflow: hidden;
+}
+
+h1, h2, h3, .navbar-brand {
+ font-family: "Space Grotesk", "Inter", sans-serif;
+ letter-spacing: -0.04em;
+}
+
+a { color: inherit; }
+
+a:hover { color: var(--primary); }
+
+.skip-link {
+ position: absolute;
+ left: -999px;
+ top: 0;
+ background: var(--primary);
+ color: #03101d;
+ padding: .75rem 1rem;
+ z-index: 9999;
+}
+
+.skip-link:focus { left: 1rem; top: 1rem; }
+
+.app-nav {
+ background: rgba(6, 17, 31, 0.78);
+ border-bottom: 1px solid var(--line);
+ backdrop-filter: blur(18px);
+}
+
+.navbar-brand { color: var(--text); font-weight: 700; }
+.navbar-brand:hover, .nav-link:hover { color: var(--primary); }
+.nav-link { color: var(--muted); font-weight: 600; }
+.navbar-toggler { border-color: var(--line); }
+.navbar-toggler-icon { filter: invert(1); }
+.brand-mark { color: var(--primary); text-shadow: 0 0 24px rgba(16, 229, 185, .75); }
+
+.btn {
+ border-radius: 999px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
+.btn-primary-neo {
+ color: #02111d;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ border: 0;
+ box-shadow: 0 16px 36px rgba(16, 229, 185, 0.24);
+}
+
+.btn-primary-neo:hover, .btn-primary-neo:focus {
+ color: #02111d;
+ transform: translateY(-1px);
+ box-shadow: 0 20px 44px rgba(40, 194, 255, 0.28);
+}
+
+.btn-ghost, .btn-admin {
+ color: var(--text);
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.btn-ghost:hover, .btn-admin:hover { border-color: var(--primary); background: rgba(16, 229, 185, 0.1); }
+
+.hero-section { padding: 4rem 0 2rem; }
+.section-pad { padding: 5rem 0; }
+.section-pad-sm { padding: 4rem 0 2rem; }
+
+.display-title {
+ font-size: clamp(3rem, 8vw, 6.6rem);
+ line-height: .92;
+ max-width: 12ch;
+}
+
+.page-title { font-size: clamp(2.4rem, 5vw, 4.8rem); line-height: 1; max-width: 12ch; }
+.hero-copy { color: #c7d8e8; font-size: clamp(1.05rem, 2vw, 1.25rem); max-width: 43rem; }
+.eyebrow, .section-kicker {
+ display: inline-flex;
+ align-items: center;
+ gap: .5rem;
+ color: var(--primary);
+ text-transform: uppercase;
+ font-size: .78rem;
+ font-weight: 800;
+ letter-spacing: .14em;
+}
+
+.eyebrow::before, .section-kicker::before {
+ content: "";
+ width: .55rem;
+ height: .55rem;
+ border-radius: 50%;
+ background: var(--primary);
+ box-shadow: 0 0 24px rgba(16, 229, 185, .85);
+}
+
+.text-muted-soft { color: var(--muted); }
+.letter-spaced { letter-spacing: .16em; color: var(--muted); }
+
+.orb {
+ position: absolute;
+ border-radius: 999px;
+ filter: blur(6px);
+ opacity: .7;
+ pointer-events: none;
+}
+.orb-one { width: 18rem; height: 18rem; right: -4rem; top: 6rem; background: radial-gradient(circle, rgba(16,229,185,.28), transparent 62%); }
+.orb-two { width: 12rem; height: 12rem; left: 45%; bottom: 1rem; background: radial-gradient(circle, rgba(255,176,32,.18), transparent 62%); }
+
+.glass-card, .surface-card, .metric-tile {
+ background: linear-gradient(145deg, rgba(16, 38, 61, .9), rgba(9, 24, 40, .82));
+ border: 1px solid var(--line);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow);
+ backdrop-filter: blur(22px);
+}
+
+.hero-panel { padding: clamp(1.5rem, 4vw, 2.5rem); position: relative; overflow: hidden; }
+.hero-panel::after {
+ content: "";
+ position: absolute;
+ inset: auto -4rem -5rem auto;
+ width: 14rem;
+ height: 14rem;
+ background: linear-gradient(135deg, rgba(16,229,185,.22), rgba(40,194,255,.16));
+ transform: rotate(20deg);
+ border-radius: 2.5rem;
+}
+.status-dot { width: .85rem; height: .85rem; background: var(--primary); border-radius: 50%; box-shadow: 0 0 22px var(--primary); }
+
+.risk-meter {
+ width: 12.5rem;
+ height: 12.5rem;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ background:
+ radial-gradient(circle at center, #10263d 58%, transparent 59%),
+ conic-gradient(var(--primary), var(--secondary), var(--accent), var(--primary));
position: relative;
}
+.risk-meter span { display: block; font: 700 3.5rem/1 "Space Grotesk"; }
+.risk-meter small { display: block; color: var(--muted); margin-top: .25rem; text-transform: uppercase; letter-spacing: .12em; font-weight: 800; font-size: .72rem; }
+.risk-meter.detail { width: 15rem; height: 15rem; }
+.risk-ring-warning { background: radial-gradient(circle at center, #10263d 58%, transparent 59%), conic-gradient(var(--accent), #ffd166, var(--accent)); }
+.risk-ring-danger, .risk-ring-critical { background: radial-gradient(circle at center, #10263d 58%, transparent 59%), conic-gradient(var(--danger), var(--accent), var(--danger)); }
+
+.metric-card, .metric-tile {
+ padding: 1rem;
+ background: rgba(255,255,255,.045);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+}
+.metric-card strong, .metric-tile strong { display: block; font: 700 1.8rem/1 "Space Grotesk"; }
+.metric-card span, .metric-tile span { color: var(--muted); font-size: .86rem; }
+.metric-tile { padding: 1.4rem; }
+.metric-tile strong { margin-top: .5rem; font-size: 2.2rem; }
+
+.trust-row { display: flex; flex-wrap: wrap; gap: .75rem; }
+.trust-row span { padding: .5rem .8rem; border: 1px solid var(--line); border-radius: 999px; color: #d7e8f7; background: rgba(255,255,255,.04); }
+.signal-list { display: grid; gap: .8rem; color: #d7e8f7; }
+.signal { display: inline-block; width: .65rem; height: .65rem; border-radius: 50%; margin-right: .55rem; }
+.signal.good { background: var(--success); } .signal.warn { background: var(--accent); } .signal.danger { background: var(--danger); }
+
+.scan-form textarea, .scan-form input[type="text"], .form-control {
+ width: 100%;
+ color: var(--text);
+ background: rgba(3, 16, 29, 0.72);
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ padding: 1rem;
+}
+.scan-form textarea:focus, .form-control:focus {
+ color: var(--text);
+ background: rgba(3, 16, 29, .9);
+ border-color: var(--primary);
+ box-shadow: 0 0 0 .25rem rgba(16, 229, 185, .12);
+}
+.form-label { color: #dcecff; font-weight: 800; }
+.form-text { color: var(--muted); }
+.invalid-copy { color: #ff8ba0; font-weight: 700; margin-top: .5rem; }
+
+.scan-choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
+.scan-choice {
+ display: flex;
+ align-items: center;
+ gap: .65rem;
+ padding: .9rem 1rem;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: rgba(255,255,255,.04);
+ cursor: pointer;
+ font-weight: 800;
+}
+.scan-choice:has(input:checked) { border-color: var(--primary); background: rgba(16,229,185,.12); }
+.scan-choice input, .metadata-check input { accent-color: var(--primary); }
+
+.privacy-stack { display: grid; gap: 1rem; }
+.privacy-stack article { padding: 1.2rem; border-radius: var(--radius-lg); border: 1px solid var(--line); background: rgba(255,255,255,.035); }
+.privacy-stack span { color: var(--primary); font-weight: 900; }
+.privacy-stack h3 { font-size: 1.15rem; margin: .4rem 0; }
+.privacy-stack p { color: var(--muted); margin: 0; }
+
+.scan-card {
+ display: block;
+ min-height: 13rem;
+ padding: 1.35rem;
+ text-decoration: none;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--line);
+ background: linear-gradient(145deg, rgba(16,38,61,.86), rgba(9,24,40,.72));
+ transition: transform .2s ease, border-color .2s ease;
+}
+.scan-card:hover { transform: translateY(-3px); border-color: var(--primary); color: var(--text); }
+.scan-card strong { display: block; font: 700 2.2rem/1 "Space Grotesk"; margin: 1rem 0 .75rem; }
+.scan-card p { color: #d6e7f6; }
+.scan-card small { color: var(--muted); }
+
+.badge { border-radius: 999px; padding: .45rem .7rem; }
+.risk-success { background: rgba(46,229,157,.15); color: #7dffc8; border: 1px solid rgba(46,229,157,.28); }
+.risk-warning { background: rgba(255,176,32,.15); color: #ffd68a; border: 1px solid rgba(255,176,32,.3); }
+.risk-danger, .risk-critical { background: rgba(255,77,109,.16); color: #ff9aad; border: 1px solid rgba(255,77,109,.34); }
+
+.empty-state { border: 1px dashed var(--line); border-radius: var(--radius-xl); background: rgba(255,255,255,.035); }
+.empty-icon { font-size: 3rem; color: var(--primary); }
+
+.page-hero { background: linear-gradient(180deg, rgba(16,229,185,.07), transparent); border-bottom: 1px solid var(--line); }
+.app-table { --bs-table-bg: transparent; --bs-table-border-color: var(--line); color: var(--text); }
+.app-table th { color: var(--muted); text-transform: uppercase; font-size: .78rem; letter-spacing: .08em; }
+.preview-cell { max-width: 24rem; color: #d7e8f7; }
+
+.scan-meta dt { color: var(--muted); text-transform: uppercase; letter-spacing: .08em; font-size: .75rem; margin-top: 1rem; }
+.scan-meta dd { color: var(--text); margin-bottom: 0; word-break: break-word; }
+.scan-meta code { color: var(--primary); }
+.indicator-list { display: grid; gap: .8rem; }
+.indicator-list article {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem;
+ border-radius: 18px;
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,.04);
+}
+.indicator-list p { color: var(--muted); margin: .25rem 0 0; }
+.indicator-list span { color: var(--accent); font-weight: 900; }
+.action-list { color: #d7e8f7; display: grid; gap: .7rem; }
+.site-footer { color: var(--muted); border-top: 1px solid var(--line); background: rgba(3,16,29,.72); }
+
+@media (max-width: 767px) {
+ .hero-section { padding-top: 2.5rem; }
+ .scan-choice-grid { grid-template-columns: 1fr; }
+ .risk-meter { width: 10.5rem; height: 10.5rem; }
+ .display-title { max-width: 100%; }
+}