Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a0d620188 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/scanner.cpython-311.pyc
Normal file
BIN
core/__pycache__/scanner.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,12 @@
|
|||||||
from django.contrib import admin
|
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",)
|
||||||
|
|||||||
49
core/forms.py
Normal file
49
core/forms.py
Normal file
@ -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
|
||||||
37
core/migrations/0001_initial.py
Normal file
37
core/migrations/0001_initial.py
Normal file
@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -1,3 +1,46 @@
|
|||||||
from django.db import models
|
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")
|
||||||
|
|||||||
168
core/scanner.py
Normal file
168
core/scanner.py
Normal file
@ -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),
|
||||||
|
)
|
||||||
@ -1,11 +1,13 @@
|
|||||||
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}{{ project_name|default:"SentinelAI Cyber Assistant" }}{% endblock %}</title>
|
||||||
|
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'Privacy-first AI cybersecurity assistant for phishing and scam risk scoring.' }}{% endblock %}">
|
||||||
{% if project_description %}
|
{% if project_description %}
|
||||||
<meta name="description" content="{{ project_description }}">
|
|
||||||
<meta property="og:description" content="{{ project_description }}">
|
<meta property="og:description" content="{{ project_description }}">
|
||||||
<meta property="twitter:description" content="{{ project_description }}">
|
<meta property="twitter:description" content="{{ project_description }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -13,13 +15,45 @@
|
|||||||
<meta property="og:image" content="{{ project_image_url }}">
|
<meta property="og:image" content="{{ project_image_url }}">
|
||||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% load static %}
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
{% block content %}{% endblock %}
|
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||||
|
<nav class="navbar navbar-expand-lg app-nav sticky-top" aria-label="Primary navigation">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand d-flex align-items-center gap-2" href="{% url 'home' %}">
|
||||||
|
<span class="brand-mark" aria-hidden="true">◆</span>
|
||||||
|
<span>SentinelAI</span>
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}#scanner">Scan</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'scan_history' %}">Dashboard</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}#privacy">Privacy</a></li>
|
||||||
|
<li class="nav-item"><a class="btn btn-sm btn-admin" href="/admin/">Admin</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main id="main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<footer class="site-footer py-4">
|
||||||
|
<div class="container d-flex flex-column flex-md-row justify-content-between gap-2">
|
||||||
|
<span>SentinelAI Cyber Assistant · Explainable risk scoring</span>
|
||||||
|
<span>Privacy-first MVP: raw submissions are analyzed in-memory only.</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,145 +1,150 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ project_name }}{% 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 head %}
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
: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);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1.2rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime code {
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
<section class="hero-section position-relative overflow-hidden">
|
||||||
<div class="card">
|
<div class="orb orb-one" aria-hidden="true"></div>
|
||||||
<h1>Analyzing your requirements and generating your app…</h1>
|
<div class="orb orb-two" aria-hidden="true"></div>
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<div class="container position-relative">
|
||||||
<span class="sr-only">Loading…</span>
|
<div class="row align-items-center g-5 py-5">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<span class="eyebrow">Privacy-first cyber defense</span>
|
||||||
|
<h1 class="display-title mt-3">Detect phishing URLs and scam messages before they reach people.</h1>
|
||||||
|
<p class="hero-copy mt-3">SentinelAI gives every submission a 0–100 risk score, explains the strongest signals, and stores only privacy-safe metadata for your dashboard.</p>
|
||||||
|
<div class="d-flex flex-wrap gap-3 mt-4">
|
||||||
|
<a href="#scanner" class="btn btn-primary-neo btn-lg">Run a scan</a>
|
||||||
|
<a href="{% url 'scan_history' %}" class="btn btn-outline-light btn-lg">View dashboard</a>
|
||||||
|
</div>
|
||||||
|
<div class="trust-row mt-4" aria-label="MVP capabilities">
|
||||||
|
<span>URL phishing detection</span>
|
||||||
|
<span>Scam message NLP</span>
|
||||||
|
<span>Explainable AI</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="hero-panel glass-card">
|
||||||
|
<div class="panel-header d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span class="small text-uppercase letter-spaced">Live risk console</span>
|
||||||
|
</div>
|
||||||
|
<div class="risk-meter mx-auto" aria-label="Average risk score">
|
||||||
|
<span>{{ avg_score }}</span>
|
||||||
|
<small>avg risk</small>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mt-4">
|
||||||
|
<div class="col-4"><div class="metric-card"><strong>{{ total_scans }}</strong><span>Scans</span></div></div>
|
||||||
|
<div class="col-4"><div class="metric-card"><strong>{{ high_risk_count }}</strong><span>High risk</span></div></div>
|
||||||
|
<div class="col-4"><div class="metric-card"><strong>v1</strong><span>Model</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-list mt-4">
|
||||||
|
<div><span class="signal good"></span>Local in-memory analysis</div>
|
||||||
|
<div><span class="signal warn"></span>False-positive review dashboard</div>
|
||||||
|
<div><span class="signal danger"></span>Threat explanations included</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
|
||||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
|
||||||
<p class="runtime">
|
|
||||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
|
||||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</section>
|
||||||
<footer>
|
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
<section id="scanner" class="section-pad scanner-section">
|
||||||
</footer>
|
<div class="container">
|
||||||
{% endblock %}
|
<div class="row g-4 align-items-stretch">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="surface-card p-4 p-md-5 h-100">
|
||||||
|
<div class="section-kicker">First workflow</div>
|
||||||
|
<h2>Scan a suspicious URL, email, or message</h2>
|
||||||
|
<p class="text-muted-soft">This MVP uses a local heuristic/NLP engine as the safe baseline before adding trained Scikit-learn models and public cybersecurity datasets.</p>
|
||||||
|
<form method="post" action="{% url 'create_scan' %}" class="scan-form mt-4" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<fieldset class="mb-4">
|
||||||
|
<legend class="form-label">{{ form.scan_type.label }}</legend>
|
||||||
|
<div class="scan-choice-grid">
|
||||||
|
{% for radio in form.scan_type %}
|
||||||
|
<label class="scan-choice" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<span>{{ radio.choice_label }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% for error in form.scan_type.errors %}<div class="invalid-copy">{{ error }}</div>{% endfor %}
|
||||||
|
</fieldset>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.content.id_for_label }}">{{ form.content.label }}</label>
|
||||||
|
{{ form.content }}
|
||||||
|
{% for error in form.content.errors %}<div class="invalid-copy">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="form-check metadata-check mb-4">
|
||||||
|
{{ form.store_metadata }}
|
||||||
|
<label class="form-check-label" for="{{ form.store_metadata.id_for_label }}">{{ form.store_metadata.label }}</label>
|
||||||
|
<div class="form-text">{{ form.store_metadata.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary-neo btn-lg w-100">Analyze risk now</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="surface-card p-4 p-md-5 h-100" id="privacy">
|
||||||
|
<div class="section-kicker">Security posture</div>
|
||||||
|
<h2>Built for sensitive submissions</h2>
|
||||||
|
<div class="privacy-stack mt-4">
|
||||||
|
<article>
|
||||||
|
<span>01</span>
|
||||||
|
<h3>Raw content stays out of storage</h3>
|
||||||
|
<p>Only a sanitized preview, SHA-256 hash, risk score, and explanation are persisted.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>02</span>
|
||||||
|
<h3>Explainable decisions</h3>
|
||||||
|
<p>Every result lists weighted indicators so users can understand what triggered the score.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>03</span>
|
||||||
|
<h3>ML-ready pipeline</h3>
|
||||||
|
<p>The scanner interface is ready to blend in trained models, evaluation metrics, and safe updates.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-pad pt-0">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-end gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="section-kicker">Threat dashboard</div>
|
||||||
|
<h2 class="mb-0">Recent detections</h2>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'scan_history' %}" class="btn btn-ghost">Open full history</a>
|
||||||
|
</div>
|
||||||
|
{% if recent_scans %}
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for scan in recent_scans %}
|
||||||
|
<div class="col-md-6 col-xl-4">
|
||||||
|
<a class="scan-card" href="{% url 'scan_detail' scan.pk %}">
|
||||||
|
<span class="badge risk-{{ scan.risk_badge_class }}">{{ scan.get_risk_level_display }}</span>
|
||||||
|
<strong>{{ scan.risk_score }}/100</strong>
|
||||||
|
<p>{{ scan.target_preview }}</p>
|
||||||
|
<small>{{ scan.get_scan_type_display }} · {{ scan.created_at|date:"M j, Y H:i" }}</small>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state text-center p-5">
|
||||||
|
<div class="empty-icon" aria-hidden="true">◇</div>
|
||||||
|
<h3>No scans yet</h3>
|
||||||
|
<p>Run your first URL or message scan to populate the dashboard.</p>
|
||||||
|
<a href="#scanner" class="btn btn-primary-neo">Start scanning</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
70
core/templates/core/scan_detail.html
Normal file
70
core/templates/core/scan_detail.html
Normal file
@ -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 %}
|
||||||
|
<section class="page-hero section-pad-sm">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-lg-end">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Scan confirmation</span>
|
||||||
|
<h1 class="page-title mt-3">{{ scan.verdict }}</h1>
|
||||||
|
<p class="hero-copy">Result created {{ scan.created_at|date:"M j, Y H:i" }} using {{ scan.model_version }}.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-primary-neo" href="{% url 'home' %}#scanner">Scan another item</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section-pad">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="surface-card p-4 p-md-5 text-center h-100">
|
||||||
|
<div class="risk-meter detail mx-auto risk-ring-{{ scan.risk_badge_class }}">
|
||||||
|
<span>{{ scan.risk_score }}</span>
|
||||||
|
<small>risk / 100</small>
|
||||||
|
</div>
|
||||||
|
<span class="badge risk-{{ scan.risk_badge_class }} mt-4">{{ scan.get_risk_level_display }} risk</span>
|
||||||
|
<dl class="scan-meta mt-4 text-start">
|
||||||
|
<dt>Scan type</dt><dd>{{ scan.get_scan_type_display }}</dd>
|
||||||
|
<dt>Sanitized preview</dt><dd>{{ scan.target_preview }}</dd>
|
||||||
|
<dt>Content hash</dt><dd><code>{{ scan.content_hash|slice:":16" }}…</code></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="surface-card p-4 p-md-5 mb-4">
|
||||||
|
<div class="section-kicker">Why it was flagged</div>
|
||||||
|
<h2>Explainable indicators</h2>
|
||||||
|
<p class="text-muted-soft">{{ scan.explanation }}</p>
|
||||||
|
<div class="indicator-list mt-4">
|
||||||
|
{% for indicator in scan.indicators %}
|
||||||
|
<article>
|
||||||
|
<div>
|
||||||
|
<strong>{{ indicator.label }}</strong>
|
||||||
|
<p>{{ indicator.detail }}</p>
|
||||||
|
</div>
|
||||||
|
<span>+{{ indicator.weight }}</span>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="surface-card p-4 p-md-5">
|
||||||
|
<div class="section-kicker">Recommended response</div>
|
||||||
|
<h2>Next actions</h2>
|
||||||
|
<ul class="action-list mt-3">
|
||||||
|
{% for action in scan.recommended_actions %}
|
||||||
|
<li>{{ action }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex flex-wrap gap-3 mt-4">
|
||||||
|
<a class="btn btn-ghost" href="{% url 'scan_history' %}">Back to dashboard</a>
|
||||||
|
<a class="btn btn-primary-neo" href="{% url 'home' %}#scanner">New scan</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
60
core/templates/core/scan_history.html
Normal file
60
core/templates/core/scan_history.html
Normal file
@ -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 %}
|
||||||
|
<section class="page-hero section-pad-sm">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow">Dashboard</span>
|
||||||
|
<h1 class="page-title mt-3">Threat scan history</h1>
|
||||||
|
<p class="hero-copy">Monitor risk levels, review explanations, and identify items that need human verification.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section-pad">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4"><div class="metric-tile"><span>Total scans</span><strong>{{ total_scans }}</strong></div></div>
|
||||||
|
<div class="col-md-4"><div class="metric-tile"><span>Average risk</span><strong>{{ avg_score }}/100</strong></div></div>
|
||||||
|
<div class="col-md-4"><div class="metric-tile"><span>High-risk items</span><strong>{{ high_risk_count }}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="surface-card p-0 overflow-hidden">
|
||||||
|
{% if scans %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-hover align-middle mb-0 app-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Risk</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Preview</th>
|
||||||
|
<th>Verdict</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scan in scans %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge risk-{{ scan.risk_badge_class }}">{{ scan.risk_score }}/100</span></td>
|
||||||
|
<td>{{ scan.get_scan_type_display }}</td>
|
||||||
|
<td class="preview-cell">{{ scan.target_preview }}</td>
|
||||||
|
<td>{{ scan.verdict }}</td>
|
||||||
|
<td>{{ scan.created_at|date:"M j, Y H:i" }}</td>
|
||||||
|
<td><a class="btn btn-sm btn-ghost" href="{% url 'scan_detail' scan.pk %}">Review</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state text-center p-5">
|
||||||
|
<div class="empty-icon" aria-hidden="true">◇</div>
|
||||||
|
<h2>No detections yet</h2>
|
||||||
|
<p>Use the scanner to create your first privacy-safe result.</p>
|
||||||
|
<a class="btn btn-primary-neo" href="{% url 'home' %}#scanner">Run first scan</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@ -1,3 +1,24 @@
|
|||||||
from django.test import TestCase
|
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)
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import home
|
from .views import create_scan, home, scan_detail, scan_history
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
|
path("scan/", create_scan, name="create_scan"),
|
||||||
|
path("scans/", scan_history, name="scan_history"),
|
||||||
|
path("scans/<int:pk>/", scan_detail, name="scan_detail"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,25 +1,80 @@
|
|||||||
import os
|
from django.db.models import Avg, Count, Max
|
||||||
import platform
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
|
||||||
from django import get_version as django_version
|
from .forms import ThreatScanForm
|
||||||
from django.shortcuts import render
|
from .models import ThreatScan
|
||||||
from django.utils import timezone
|
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):
|
def home(request):
|
||||||
"""Render the landing screen with loader and environment details."""
|
form = ThreatScanForm()
|
||||||
host_name = request.get_host().lower()
|
|
||||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
|
||||||
now = timezone.now()
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"project_name": "New Style",
|
"project_name": "SentinelAI Cyber Assistant",
|
||||||
"agent_brand": agent_brand,
|
"meta_description": "Privacy-first AI cybersecurity assistant for phishing URL and scam message risk scoring with clear explanations.",
|
||||||
"django_version": django_version(),
|
"form": form,
|
||||||
"python_version": platform.python_version(),
|
**_dashboard_context(),
|
||||||
"current_time": now,
|
|
||||||
"host_name": host_name,
|
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
|
||||||
}
|
}
|
||||||
return render(request, "core/index.html", 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)
|
||||||
|
|||||||
@ -1,4 +1,287 @@
|
|||||||
/* Custom styles for the application */
|
/* SentinelAI custom theme */
|
||||||
body {
|
:root {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
--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%; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,287 @@
|
|||||||
|
/* SentinelAI custom theme */
|
||||||
:root {
|
:root {
|
||||||
--bg-color-start: #6a11cb;
|
--bg: #06111f;
|
||||||
--bg-color-end: #2575fc;
|
--bg-2: #0b1f33;
|
||||||
--text-color: #ffffff;
|
--surface: rgba(15, 34, 54, 0.82);
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
--surface-strong: #10263d;
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
--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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
background:
|
||||||
color: var(--text-color);
|
radial-gradient(circle at 10% 5%, rgba(16, 229, 185, 0.16), transparent 28rem),
|
||||||
display: flex;
|
radial-gradient(circle at 88% 20%, rgba(40, 194, 255, 0.14), transparent 24rem),
|
||||||
justify-content: center;
|
linear-gradient(135deg, var(--bg), #081827 54%, #03101d);
|
||||||
align-items: center;
|
color: var(--text);
|
||||||
min-height: 100vh;
|
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;
|
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%; }
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user