mirror of
https://github.com/Rarebuffalo/securelens-backend.git
synced 2026-06-19 07:00:30 +00:00
updated the architecture
This commit is contained in:
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
105
app/services/ai.py
Normal file
105
app/services/ai.py
Normal 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."
|
||||
0
app/services/scanner/__init__.py
Normal file
0
app/services/scanner/__init__.py
Normal file
11
app/services/scanner/base.py
Normal file
11
app/services/scanner/base.py
Normal 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
|
||||
70
app/services/scanner/cookies.py
Normal file
70
app/services/scanner/cookies.py
Normal 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
148
app/services/scanner/dns.py
Normal 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
|
||||
135
app/services/scanner/exposure.py
Normal file
135
app/services/scanner/exposure.py
Normal 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
|
||||
140
app/services/scanner/headers.py
Normal file
140
app/services/scanner/headers.py
Normal 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
|
||||
76
app/services/scanner/ports.py
Normal file
76
app/services/scanner/ports.py
Normal 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
|
||||
136
app/services/scanner/ssl_checker.py
Normal file
136
app/services/scanner/ssl_checker.py
Normal 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
|
||||
76
app/services/scanner/transport.py
Normal file
76
app/services/scanner/transport.py
Normal 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
42
app/services/scoring.py
Normal 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
|
||||
Reference in New Issue
Block a user