Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
1a0d620188 AI 2026-06-13 09:48:20 +00:00
30 changed files with 1285 additions and 182 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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
View 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

View 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')],
},
),
]

View File

@ -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
View 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),
)

View File

@ -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>

View File

@ -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 0100 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 0100 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 %}

View 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 %}

View 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 %}

View File

@ -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)

View File

@ -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"),
] ]

View File

@ -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)

View File

@ -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%; }
} }

View File

@ -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%; }
}