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 %} + + +
+ {% 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 +
+
+
+
+
+ + Live risk console +
+
+ {{ avg_score }} + avg risk +
+
+
{{ total_scans }}Scans
+
{{ high_risk_count }}High risk
+
v1Model
+
+
+
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.

+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} +
+ {{ form.scan_type.label }} +
+ {% for radio in form.scan_type %} + + {% endfor %} +
+ {% for error in form.scan_type.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.content }} + {% for error in form.content.errors %}
{{ error }}
{% endfor %} +
+ + +
+
+
+
+
+
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 %} + + {% 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 %} +
+ + + + + + + + + + + + + {% for scan in scans %} + + + + + + + + + {% endfor %} + +
RiskTypePreviewVerdictCreated
{{ scan.risk_score }}/100{{ scan.get_scan_type_display }}{{ scan.target_preview }}{{ scan.verdict }}{{ scan.created_at|date:"M j, Y H:i" }}Review
+
+ {% 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%; } +}