updated the architecture

This commit is contained in:
rarebuffalo
2026-04-07 18:13:43 +05:30
parent 087d8ffaee
commit 8330060e86
66 changed files with 3484 additions and 130 deletions

0
app/services/__init__.py Normal file
View File

105
app/services/ai.py Normal file
View File

@@ -0,0 +1,105 @@
import json
import logging
from openai import AsyncOpenAI
from app.config import settings
logger = logging.getLogger(__name__)
api_key = settings.openai_api_key or "mock-key-for-testing"
client = AsyncOpenAI(api_key=api_key)
async def enhance_security_issues(issues: list[dict]) -> dict:
"""
Takes a list of basic security issues and uses an LLM to provide:
- Contextual severity
- Natural language explanations
- Auto-generated remediation code snippets
"""
if not settings.openai_api_key:
logger.warning("OPENAI_API_KEY is not set. AI enhancements are skipped.")
return {"enhanced_issues": issues}
prompt = (
"Analyze the following security vulnerabilities:\n"
f"{json.dumps(issues, indent=2)}\n\n"
"Return a JSON object with a single key 'enhanced_issues' containing a list of objects. "
"Each object MUST correspond to one of the original issues and have the following keys: "
"'issue' (exact string of the original issue), "
"'contextual_severity' (Low, Medium, High, Critical), "
"'explanation' (a 1-2 sentence non-technical explanation), "
"'remediation_snippet' (Actionable code snippet, e.g. Nginx config, or 'N/A')."
)
try:
response = await client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": "You are a senior cybersecurity automation agent. Always respond with valid JSON."},
{"role": "user", "content": prompt}
],
response_format={"type": "json_object"},
temperature=0.2,
)
content = response.choices[0].message.content
if not content:
return {"enhanced_issues": issues, "ai_error": "Empty response"}
return json.loads(content)
except Exception as e:
logger.error(f"AI Generation Error: {str(e)}")
return {"enhanced_issues": issues, "ai_error": str(e)}
async def chat_with_scan_context(scan_id: str, context_data: dict, user_message: str) -> str:
"""
Allows a user to ask a question about a specific scan's results.
"""
if not settings.openai_api_key:
return "AI Chat is disabled because OPENAI_API_KEY is not configured."
system_prompt = (
"You are SecureLens AI, an expert cybersecurity assistant. "
"You are helping a developer understand a security scan report for their website. "
f"Here is the context of the scan: {json.dumps(context_data)}"
)
try:
response = await client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
],
temperature=0.5,
)
return response.choices[0].message.content or "No response from AI."
except Exception as e:
logger.error(f"AI Chat Error: {str(e)}")
return "I encountered an error trying to process your request."
async def generate_threat_narrative(context_data: dict) -> str:
"""
Weaves multiple scan issues into a cohesive attack sequence.
"""
if not settings.openai_api_key:
return "AI Threat Narrative is disabled because OPENAI_API_KEY is not configured."
system_prompt = (
"You are a senior cybersecurity red-teamer. Analyze the following security scan results "
"and weave them into a single, cohesive 'Threat Narrative'. Explain how an attacker might "
"chain these specific vulnerabilities together to compromise the system. "
"Keep it professional, concise (2-3 paragraphs), and actionable."
)
try:
response = await client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(context_data)}
],
temperature=0.7,
)
return response.choices[0].message.content or "Could not generate threat narrative."
except Exception as e:
logger.error(f"AI Narrative Error: {str(e)}")
return "I encountered an error trying to generate the threat narrative."

View File

View File

@@ -0,0 +1,11 @@
from abc import ABC, abstractmethod
import httpx
from app.schemas.scan import Issue
class BaseScanner(ABC):
@abstractmethod
async def scan(self, url: str, response: httpx.Response) -> list[Issue]:
pass

View File

@@ -0,0 +1,70 @@
import logging
from http.cookies import SimpleCookie
import httpx
from app.schemas.scan import Issue
from app.services.scanner.base import BaseScanner
logger = logging.getLogger(__name__)
class CookieScanner(BaseScanner):
async def scan(self, url: str, response: httpx.Response) -> list[Issue]:
issues: list[Issue] = []
is_https = url.startswith("https")
raw_cookies = response.headers.multi_items()
set_cookie_headers = [
value for key, value in raw_cookies if key.lower() == "set-cookie"
]
if not set_cookie_headers:
return issues
for cookie_str in set_cookie_headers:
cookie_lower = cookie_str.lower()
cookie = SimpleCookie()
try:
cookie.load(cookie_str)
except Exception:
logger.debug(f"Could not parse cookie: {cookie_str[:80]}")
continue
for name, morsel in cookie.items():
if "httponly" not in cookie_lower:
issues.append(Issue(
issue=f"Cookie '{name}' missing HttpOnly flag",
severity="Warning",
layer="Cookie Security",
fix=f"Set the HttpOnly flag on cookie '{name}' to prevent JavaScript access",
))
if "; secure" not in cookie_lower:
if is_https:
issues.append(Issue(
issue=f"Cookie '{name}' missing Secure flag",
severity="Warning",
layer="Cookie Security",
fix=f"Set the Secure flag on cookie '{name}' to ensure it is only sent over HTTPS",
))
samesite_value = morsel.get("samesite", "").lower()
if not samesite_value:
issues.append(Issue(
issue=f"Cookie '{name}' missing SameSite attribute",
severity="Warning",
layer="Cookie Security",
fix=f"Set SameSite=Lax or SameSite=Strict on cookie '{name}' to prevent CSRF attacks",
))
elif samesite_value == "none":
if "; secure" not in cookie_lower:
issues.append(Issue(
issue=f"Cookie '{name}' has SameSite=None without Secure flag",
severity="Critical",
layer="Cookie Security",
fix=f"Cookies with SameSite=None must also have the Secure flag set",
))
return issues

148
app/services/scanner/dns.py Normal file
View File

@@ -0,0 +1,148 @@
import asyncio
import logging
from urllib.parse import urlparse
import aiodns
import httpx
from app.schemas.scan import Issue
logger = logging.getLogger(__name__)
class DNSScanner:
def __init__(self):
self.resolver = aiodns.DNSResolver(timeout=3.0)
async def scan(self, url: str) -> list[Issue]:
issues = []
domain = self._extract_domain(url)
if not domain:
return issues
tasks = [
self._check_spf(domain),
self._check_dmarc(domain),
self._enumerate_subdomains(domain),
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, list):
issues.extend(result)
elif isinstance(result, Exception):
logger.error(f"DNS scan error: {result}")
return issues
def _extract_domain(self, url: str) -> str | None:
try:
parsed = urlparse(url)
domain = parsed.netloc.split(":")[0]
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return None
async def _check_spf(self, domain: str) -> list[Issue]:
issues = []
try:
records = await self.resolver.query(domain, "TXT")
has_spf = any("v=spf1" in r.text for r in records if r.text)
if not has_spf:
issues.append(
Issue(
issue="Missing SPF Record",
severity="Medium",
layer="DNS",
fix="Add a TXT record with SPF rules (e.g., v=spf1 mx -all) to prevent email spoofing.",
)
)
except aiodns.error.DNSError as e:
# Code 4 usually means no record of that type, or Code 1 is domain not found
if e.args[0] in [1, 4]:
issues.append(
Issue(
issue="Missing SPF Record",
severity="Medium",
layer="DNS",
fix="Add a TXT record with SPF rules (e.g., v=spf1 mx -all) to prevent email spoofing.",
)
)
else:
logger.debug(f"SPF DNS error for {domain}: {e}")
return issues
async def _check_dmarc(self, domain: str) -> list[Issue]:
issues = []
dmarc_domain = f"_dmarc.{domain}"
try:
records = await self.resolver.query(dmarc_domain, "TXT")
has_dmarc = any("v=DMARC1" in r.text for r in records if r.text)
if not has_dmarc:
issues.append(
Issue(
issue="Missing DMARC Record",
severity="Low",
layer="DNS",
fix="Add a DMARC TXT record at _dmarc to policy control email spoofing failures.",
)
)
except aiodns.error.DNSError as e:
if e.args[0] in [1, 4]:
issues.append(
Issue(
issue="Missing DMARC Record",
severity="Low",
layer="DNS",
fix="Add a DMARC TXT record at _dmarc to policy control email spoofing failures.",
)
)
else:
logger.debug(f"DMARC DNS error for {domain}: {e}")
return issues
async def _enumerate_subdomains(self, domain: str) -> list[Issue]:
issues = []
# Query Certificate Transparency logs via crt.sh
url = f"https://crt.sh/?q=%.{domain}&output=json"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
if response.status_code == 200:
data = response.json()
unique_subs = set()
# Extract subdomains
for entry in data:
name = entry.get("name_value", "")
# Handle multiple names separated by newlines
for sub in name.split("\n"):
sub = sub.strip()
if "*" not in sub and sub != domain and sub != f"www.{domain}":
unique_subs.add(sub)
# Look for risky subdomains
keywords = ["dev", "test", "staging", "qa", "admin", "internal", "api", "dashboard"]
dev_envs = [sub for sub in unique_subs if any(kw in sub.lower() for kw in keywords)]
if dev_envs:
env_str = ", ".join(list(dev_envs)[:3])
more = len(dev_envs) - 3
if more > 0:
env_str += f", and {more} more"
issues.append(
Issue(
issue="Exposed Subdomains Detected",
severity="Info",
layer="DNS",
fix=f"Subdomains such as {env_str} are exposed in CT logs. Ensure they are protected and not publicly accessible if they afford sensitive access.",
)
)
except Exception as e:
logger.debug(f"Subdomain enumeration failed for {domain}: {str(e)}")
return issues

View File

@@ -0,0 +1,135 @@
import logging
import httpx
from app.config import settings
from app.schemas.scan import Issue
from app.services.scanner.base import BaseScanner
logger = logging.getLogger(__name__)
SENSITIVE_PATHS = [
"/admin",
"/.env",
"/.git",
"/.git/config",
"/.git/HEAD",
"/backup",
"/debug",
"/wp-admin",
"/wp-login.php",
"/phpmyadmin",
"/.DS_Store",
"/server-status",
"/server-info",
"/swagger.json",
"/openapi.json",
"/api/docs",
"/.htaccess",
"/.htpasswd",
"/web.config",
"/elmah.axd",
"/trace.axd",
"/phpinfo.php",
"/config.php",
"/wp-config.php.bak",
"/.well-known/security.txt",
]
ROBOTS_SENSITIVE_KEYWORDS = [
"/admin",
"/login",
"/dashboard",
"/secret",
"/private",
"/backup",
"/config",
"/database",
"/staging",
"/internal",
"/api/v",
]
class ExposureScanner(BaseScanner):
async def scan(self, url: str, response: httpx.Response) -> list[Issue]:
issues: list[Issue] = []
base_url = url.rstrip("/")
async with httpx.AsyncClient(
timeout=httpx.Timeout(settings.path_check_timeout),
follow_redirects=True,
) as client:
for path in SENSITIVE_PATHS:
try:
r = await client.get(base_url + path)
if r.status_code == 200:
issues.append(Issue(
issue=f"Sensitive path exposed: {path}",
severity="Critical",
layer="Exposure Layer",
fix=f"Restrict access to {path} using authentication or firewall rules",
))
except httpx.HTTPError:
logger.debug(f"Could not reach {base_url}{path}")
issues.extend(await self._check_robots_txt(client, base_url))
issues.extend(await self._check_directory_listing(client, base_url))
return issues
async def _check_robots_txt(
self, client: httpx.AsyncClient, base_url: str
) -> list[Issue]:
issues: list[Issue] = []
try:
r = await client.get(base_url + "/robots.txt")
if r.status_code == 200:
content = r.text.lower()
exposed_paths = []
for line in content.splitlines():
line = line.strip()
if line.startswith("disallow:"):
path = line.split(":", 1)[1].strip()
if path:
for keyword in ROBOTS_SENSITIVE_KEYWORDS:
if keyword in path.lower():
exposed_paths.append(path)
break
if exposed_paths:
paths_str = ", ".join(exposed_paths[:5])
issues.append(Issue(
issue=f"robots.txt reveals sensitive paths: {paths_str}",
severity="Warning",
layer="Exposure Layer",
fix="Avoid listing sensitive paths in robots.txt; use authentication instead",
))
except httpx.HTTPError:
pass
return issues
async def _check_directory_listing(
self, client: httpx.AsyncClient, base_url: str
) -> list[Issue]:
issues: list[Issue] = []
test_paths = ["/images/", "/assets/", "/static/", "/uploads/"]
for path in test_paths:
try:
r = await client.get(base_url + path)
if r.status_code == 200:
body = r.text.lower()
if "index of" in body or "directory listing" in body or ("<pre>" in body and "parent directory" in body):
issues.append(Issue(
issue=f"Directory listing enabled at {path}",
severity="Warning",
layer="Exposure Layer",
fix=f"Disable directory listing for {path} in your web server configuration",
))
break
except httpx.HTTPError:
pass
return issues

View File

@@ -0,0 +1,140 @@
import httpx
from app.schemas.scan import Issue
from app.services.scanner.base import BaseScanner
class HeaderScanner(BaseScanner):
async def scan(self, url: str, response: httpx.Response) -> list[Issue]:
issues: list[Issue] = []
headers = response.headers
if "Content-Security-Policy" not in headers:
issues.append(Issue(
issue="Missing Content-Security-Policy header",
severity="Warning",
layer="Server Config Layer",
fix="Add header: Content-Security-Policy: default-src 'self';",
))
else:
csp = headers["Content-Security-Policy"]
if "'unsafe-inline'" in csp:
issues.append(Issue(
issue="Content-Security-Policy allows 'unsafe-inline'",
severity="Warning",
layer="Server Config Layer",
fix="Remove 'unsafe-inline' from CSP and use nonces or hashes for inline scripts/styles",
))
if "'unsafe-eval'" in csp:
issues.append(Issue(
issue="Content-Security-Policy allows 'unsafe-eval'",
severity="Warning",
layer="Server Config Layer",
fix="Remove 'unsafe-eval' from CSP to prevent dynamic code execution via eval()",
))
if "*" in csp.split():
issues.append(Issue(
issue="Content-Security-Policy uses wildcard (*) source",
severity="Warning",
layer="Server Config Layer",
fix="Replace wildcard (*) in CSP with specific trusted domains",
))
if "X-Frame-Options" not in headers:
issues.append(Issue(
issue="Missing X-Frame-Options header",
severity="Warning",
layer="Server Config Layer",
fix="Add header: X-Frame-Options: SAMEORIGIN",
))
if "X-Content-Type-Options" not in headers:
issues.append(Issue(
issue="Missing X-Content-Type-Options header",
severity="Warning",
layer="Server Config Layer",
fix="Add header: X-Content-Type-Options: nosniff",
))
if "Referrer-Policy" not in headers:
issues.append(Issue(
issue="Missing Referrer-Policy header",
severity="Info",
layer="Server Config Layer",
fix="Add header: Referrer-Policy: strict-origin-when-cross-origin",
))
if "Permissions-Policy" not in headers:
issues.append(Issue(
issue="Missing Permissions-Policy header",
severity="Info",
layer="Server Config Layer",
fix="Add header: Permissions-Policy: geolocation=(), camera=(), microphone=()",
))
if headers.get("Access-Control-Allow-Origin") == "*":
issues.append(Issue(
issue="CORS allows all origins (*)",
severity="Warning",
layer="Server Config Layer",
fix="Restrict Access-Control-Allow-Origin to trusted domains",
))
server = headers.get("Server", "")
if server:
issues.append(Issue(
issue=f"Server header discloses technology: {server}",
severity="Info",
layer="Server Config Layer",
fix="Remove or obfuscate the Server header to prevent information disclosure",
))
if "X-Powered-By" in headers:
issues.append(Issue(
issue=f"X-Powered-By header discloses technology: {headers['X-Powered-By']}",
severity="Info",
layer="Server Config Layer",
fix="Remove the X-Powered-By header to prevent information disclosure",
))
cache_control = headers.get("Cache-Control", "")
if not cache_control:
issues.append(Issue(
issue="Missing Cache-Control header",
severity="Info",
layer="Server Config Layer",
fix="Add Cache-Control header with appropriate directives (e.g., no-store for sensitive pages)",
))
elif "no-store" not in cache_control.lower() and "private" not in cache_control.lower():
issues.append(Issue(
issue="Cache-Control does not prevent caching of potentially sensitive content",
severity="Info",
layer="Server Config Layer",
fix="Add 'no-store' or 'private' to Cache-Control for pages with sensitive data",
))
if "Cross-Origin-Opener-Policy" not in headers:
issues.append(Issue(
issue="Missing Cross-Origin-Opener-Policy (COOP) header",
severity="Info",
layer="Server Config Layer",
fix="Add header: Cross-Origin-Opener-Policy: same-origin",
))
if "Cross-Origin-Resource-Policy" not in headers:
issues.append(Issue(
issue="Missing Cross-Origin-Resource-Policy (CORP) header",
severity="Info",
layer="Server Config Layer",
fix="Add header: Cross-Origin-Resource-Policy: same-origin",
))
if "Cross-Origin-Embedder-Policy" not in headers:
issues.append(Issue(
issue="Missing Cross-Origin-Embedder-Policy (COEP) header",
severity="Info",
layer="Server Config Layer",
fix="Add header: Cross-Origin-Embedder-Policy: require-corp",
))
return issues

View File

@@ -0,0 +1,76 @@
import asyncio
import logging
from urllib.parse import urlparse
from app.schemas.scan import Issue
logger = logging.getLogger(__name__)
# High-risk ports that generally shouldn't be publicly exposed
HIGH_RISK_PORTS = {
22: "SSH",
1433: "MSSQL",
3306: "MySQL",
5432: "PostgreSQL",
27017: "MongoDB",
6379: "Redis",
11211: "Memcached",
9200: "Elasticsearch",
}
class PortScanner:
def __init__(self, timeout: float = 2.0):
self.timeout = timeout
async def scan(self, url: str) -> list[Issue]:
issues = []
domain = self._extract_domain(url)
if not domain:
return issues
tasks = [
self._check_port(domain, port, service)
for port, service in HIGH_RISK_PORTS.items()
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Issue):
issues.append(result)
elif isinstance(result, Exception):
logger.debug(f"Port scanning exception: {result}")
return issues
def _extract_domain(self, url: str) -> str | None:
try:
parsed = urlparse(url)
domain = parsed.netloc.split(":")[0]
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return None
async def _check_port(self, domain: str, port: int, service: str) -> Issue | None:
try:
# Short timeout ensuring minimal scanning latency overhead
reader, writer = await asyncio.wait_for(
asyncio.open_connection(domain, port), timeout=self.timeout
)
writer.close()
await writer.wait_closed()
return Issue(
issue=f"Exposed Database/Service Port: {port} ({service})",
severity="Critical",
layer="Network",
fix=f"Close port {port} to the public internet. Use a VPN, VPC peering, or strict IP whitelisting to access {service}.",
)
except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
# Normal: port is either closed, filtered, or timing out.
return None
except Exception as e:
logger.debug(f"Unexpected error validating port {port} on {domain}: {e}")
return None

View File

@@ -0,0 +1,136 @@
import asyncio
import datetime
import logging
import socket
import ssl
from urllib.parse import urlparse
import httpx
from app.schemas.scan import Issue
from app.services.scanner.base import BaseScanner
logger = logging.getLogger(__name__)
WEAK_TLS_VERSIONS = {"TLSv1", "TLSv1.1"}
def _check_ssl(hostname: str, port: int) -> dict:
result: dict = {
"error": None,
"cert": None,
"tls_version": None,
"self_signed": False,
}
context = ssl.create_default_context()
try:
with socket.create_connection((hostname, port), timeout=5) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
result["cert"] = ssock.getpeercert()
result["tls_version"] = ssock.version()
except ssl.SSLCertVerificationError as e:
error_msg = str(e)
result["error"] = error_msg
if "self-signed" in error_msg.lower() or "self signed" in error_msg.lower():
result["self_signed"] = True
try:
ctx_no_verify = ssl.create_default_context()
ctx_no_verify.check_hostname = False
ctx_no_verify.verify_mode = ssl.CERT_NONE
with socket.create_connection((hostname, port), timeout=5) as sock:
with ctx_no_verify.wrap_socket(sock, server_hostname=hostname) as ssock:
result["tls_version"] = ssock.version()
except Exception:
pass
except (socket.timeout, socket.gaierror, OSError) as e:
result["error"] = str(e)
return result
class SSLScanner(BaseScanner):
async def scan(self, url: str, response: httpx.Response) -> list[Issue]:
issues: list[Issue] = []
parsed = urlparse(url)
if parsed.scheme != "https":
return issues
hostname = parsed.hostname
port = parsed.port or 443
if not hostname:
return issues
try:
result = await asyncio.to_thread(_check_ssl, hostname, port)
except Exception as e:
logger.warning(f"SSL check failed for {hostname}: {e}")
return issues
if result["self_signed"]:
issues.append(Issue(
issue="SSL certificate is self-signed",
severity="Critical",
layer="SSL/TLS Layer",
fix="Obtain a valid SSL certificate from a trusted Certificate Authority (e.g., Let's Encrypt)",
))
if result["error"] and not result["self_signed"]:
issues.append(Issue(
issue=f"SSL certificate verification failed: {result['error'][:120]}",
severity="Critical",
layer="SSL/TLS Layer",
fix="Ensure the SSL certificate is valid, not expired, and issued by a trusted CA",
))
cert = result.get("cert")
if cert:
not_after = cert.get("notAfter")
if not_after:
try:
expiry = datetime.datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
now = datetime.datetime.utcnow()
if expiry < now:
issues.append(Issue(
issue="SSL certificate has expired",
severity="Critical",
layer="SSL/TLS Layer",
fix="Renew the SSL certificate immediately",
))
elif (expiry - now).days < 30:
issues.append(Issue(
issue=f"SSL certificate expires in {(expiry - now).days} days",
severity="Warning",
layer="SSL/TLS Layer",
fix="Renew the SSL certificate before it expires",
))
except ValueError:
logger.debug(f"Could not parse cert expiry: {not_after}")
subject = cert.get("subject", ())
issuer = cert.get("issuer", ())
if subject and issuer and subject == issuer:
if not result["self_signed"]:
issues.append(Issue(
issue="SSL certificate is self-signed",
severity="Critical",
layer="SSL/TLS Layer",
fix="Obtain a valid SSL certificate from a trusted Certificate Authority",
))
tls_version = result.get("tls_version")
if tls_version and tls_version in WEAK_TLS_VERSIONS:
issues.append(Issue(
issue=f"Server supports weak TLS version: {tls_version}",
severity="Critical",
layer="SSL/TLS Layer",
fix="Disable TLS 1.0 and TLS 1.1; enforce TLS 1.2 or higher",
))
return issues

View File

@@ -0,0 +1,76 @@
import httpx
from app.schemas.scan import Issue
from app.services.scanner.base import BaseScanner
MIN_HSTS_MAX_AGE = 15768000 # 6 months in seconds
class TransportScanner(BaseScanner):
async def scan(self, url: str, response: httpx.Response) -> list[Issue]:
issues: list[Issue] = []
headers = response.headers
if not url.startswith("https"):
issues.append(Issue(
issue="Website is not using HTTPS",
severity="Critical",
layer="Transport Layer",
fix="Install SSL certificate and redirect HTTP to HTTPS",
))
return issues
hsts = headers.get("Strict-Transport-Security", "")
if not hsts:
issues.append(Issue(
issue="Missing HSTS header",
severity="Warning",
layer="Transport Layer",
fix="Add header: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload",
))
else:
hsts_lower = hsts.lower()
max_age = 0
for directive in hsts_lower.split(";"):
directive = directive.strip()
if directive.startswith("max-age="):
try:
max_age = int(directive.split("=", 1)[1])
except ValueError:
pass
if max_age < MIN_HSTS_MAX_AGE:
issues.append(Issue(
issue=f"HSTS max-age is too short ({max_age}s, minimum recommended: {MIN_HSTS_MAX_AGE}s)",
severity="Warning",
layer="Transport Layer",
fix="Set HSTS max-age to at least 15768000 (6 months), ideally 31536000 (1 year)",
))
if "includesubdomains" not in hsts_lower:
issues.append(Issue(
issue="HSTS header missing includeSubDomains directive",
severity="Info",
layer="Transport Layer",
fix="Add includeSubDomains to HSTS header to protect all subdomains",
))
if "preload" not in hsts_lower:
issues.append(Issue(
issue="HSTS header missing preload directive",
severity="Info",
layer="Transport Layer",
fix="Add preload to HSTS header and submit to hstspreload.org for browser preload list",
))
csp = headers.get("Content-Security-Policy", "")
if url.startswith("https") and "upgrade-insecure-requests" not in csp.lower():
issues.append(Issue(
issue="CSP does not include upgrade-insecure-requests directive",
severity="Info",
layer="Transport Layer",
fix="Add 'upgrade-insecure-requests' to Content-Security-Policy to auto-upgrade HTTP resources",
))
return issues

42
app/services/scoring.py Normal file
View File

@@ -0,0 +1,42 @@
from app.schemas.scan import Issue, LayerStatus
SEVERITY_WEIGHTS: dict[str, int] = {
"Critical": 15,
"Warning": 5,
"Info": 2,
}
LAYER_NAMES = [
"Transport Layer",
"SSL/TLS Layer",
"Server Config Layer",
"Cookie Security",
"Exposure Layer",
]
def calculate_score(issues: list[Issue]) -> int:
score = 100
for issue in issues:
score -= SEVERITY_WEIGHTS.get(issue.severity, 0)
return max(score, 0)
def calculate_layer_statuses(issues: list[Issue]) -> dict[str, LayerStatus]:
layers: dict[str, LayerStatus] = {
name: LayerStatus(issues=0, status="green") for name in LAYER_NAMES
}
for issue in issues:
if issue.layer in layers:
layers[issue.layer].issues += 1
for layer in layers.values():
if layer.issues == 0:
layer.status = "green"
elif layer.issues < 3:
layer.status = "yellow"
else:
layer.status = "red"
return layers