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/routers/__init__.py Normal file
View File

76
app/routers/apikey.py Normal file
View File

@@ -0,0 +1,76 @@
import hashlib
import secrets
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.models.apikey import ApiKey
from app.models.user import User
from app.schemas.apikey import ApiKeyCreate, ApiKeyCreateResponse, ApiKeyResponse
router = APIRouter(prefix="/api-keys", tags=["apikeys"])
@router.post("", response_model=ApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_api_key(
data: ApiKeyCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
raw_key = f"sl_{secrets.token_urlsafe(32)}"
key_prefix = raw_key[:10]
hashed_key = hashlib.sha256(raw_key.encode()).hexdigest()
api_key = ApiKey(
user_id=current_user.id,
name=data.name,
key_prefix=key_prefix,
hashed_key=hashed_key,
)
db.add(api_key)
await db.commit()
await db.refresh(api_key)
return ApiKeyCreateResponse(
id=api_key.id,
name=api_key.name,
key_prefix=api_key.key_prefix,
created_at=api_key.created_at,
key=raw_key,
)
@router.get("", response_model=list[ApiKeyResponse])
async def list_api_keys(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ApiKey)
.where(ApiKey.user_id == current_user.id)
.order_by(ApiKey.created_at.desc())
)
return result.scalars().all()
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_api_key(
key_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == current_user.id)
)
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="API Key not found"
)
await db.delete(api_key)
await db.commit()

61
app/routers/auth.py Normal file
View File

@@ -0,0 +1,61 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.models.user import User
from app.schemas.auth import (
LoginRequest,
RegisterRequest,
TokenResponse,
UserResponse,
)
from app.utils.auth import create_access_token, hash_password, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(User).where((User.email == data.email) | (User.username == data.username))
)
existing = result.scalar_one_or_none()
if existing:
field = "email" if existing.email == data.email else "username"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A user with this {field} already exists",
)
user = User(
email=data.email,
username=data.username,
hashed_password=hash_password(data.password),
)
db.add(user)
await db.flush()
token = create_access_token(user.id)
return TokenResponse(access_token=token)
@router.post("/login", response_model=TokenResponse)
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == data.email))
user = result.scalar_one_or_none()
if user is None or not verify_password(data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
token = create_access_token(user.id)
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user

19
app/routers/health.py Normal file
View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
from app.config import settings
router = APIRouter(tags=["health"])
@router.get("/")
async def root():
return {"message": f"{settings.app_name} backend running 🚀"}
@router.get("/health")
async def health_check():
return {
"status": "healthy",
"app": settings.app_name,
"version": settings.app_version,
}

226
app/routers/history.py Normal file
View File

@@ -0,0 +1,226 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.models.scan import ScanResult
from app.models.user import User
from app.schemas.scan import (
Issue,
LayerStatus,
ScanHistoryItem,
ScanHistoryResponse,
ScanResponse,
DashboardTrendsResponse,
ChatRequest,
ChatResponse,
ThreatNarrativeResponse,
ScanDiffResponse,
)
from app.services.ai import chat_with_scan_context, generate_threat_narrative
router = APIRouter(prefix="/scans", tags=["history"])
@router.get("", response_model=ScanHistoryResponse)
async def list_scans(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
offset = (page - 1) * per_page
count_result = await db.execute(
select(func.count()).select_from(ScanResult).where(ScanResult.user_id == current_user.id)
)
total = count_result.scalar_one()
result = await db.execute(
select(ScanResult)
.where(ScanResult.user_id == current_user.id)
.order_by(ScanResult.created_at.desc())
.offset(offset)
.limit(per_page)
)
scans = result.scalars().all()
return ScanHistoryResponse(
scans=[ScanHistoryItem.model_validate(s) for s in scans],
total=total,
page=page,
per_page=per_page,
)
@router.get("/trends", response_model=DashboardTrendsResponse)
async def get_trends(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
count_result = await db.execute(
select(func.count()).select_from(ScanResult).where(ScanResult.user_id == current_user.id)
)
total_scans = count_result.scalar_one()
avg_result = await db.execute(
select(func.avg(ScanResult.security_score)).where(ScanResult.user_id == current_user.id)
)
avg_score = avg_result.scalar_one() or 0.0
recent_result = await db.execute(
select(ScanResult)
.where(ScanResult.user_id == current_user.id)
.order_by(ScanResult.created_at.desc())
.limit(5)
)
recent_scans = recent_result.scalars().all()
return DashboardTrendsResponse(
total_scans=total_scans,
average_score=float(avg_score),
recent_scans=[ScanHistoryItem.model_validate(s) for s in recent_scans]
)
@router.get("/{scan_id}", response_model=ScanResponse)
async def get_scan(
scan_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScanResult).where(
ScanResult.id == scan_id,
ScanResult.user_id == current_user.id,
)
)
scan = result.scalar_one_or_none()
if scan is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scan not found")
return ScanResponse(
id=scan.id,
url=scan.url,
security_score=scan.security_score,
layers={k: LayerStatus(**v) for k, v in scan.layers.items()},
issues=[Issue(**i) for i in scan.issues],
created_at=scan.created_at,
)
@router.delete("/{scan_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_scan(
scan_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScanResult).where(
ScanResult.id == scan_id,
ScanResult.user_id == current_user.id,
)
)
scan = result.scalar_one_or_none()
if scan is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scan not found")
await db.delete(scan)
@router.post("/{scan_id}/chat", response_model=ChatResponse)
async def chat_about_scan(
scan_id: str,
data: ChatRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScanResult).where(
ScanResult.id == scan_id,
ScanResult.user_id == current_user.id,
)
)
scan = result.scalar_one_or_none()
if not scan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scan not found")
context_data = {
"url": scan.url,
"score": scan.security_score,
"layers": scan.layers,
"issues": scan.issues,
}
reply = await chat_with_scan_context(scan_id, context_data, data.message)
return ChatResponse(reply=reply)
@router.get("/{scan_id}/threat-narrative", response_model=ThreatNarrativeResponse)
async def get_threat_narrative(
scan_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScanResult).where(
ScanResult.id == scan_id,
ScanResult.user_id == current_user.id,
)
)
scan = result.scalar_one_or_none()
if not scan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scan not found")
context_data = {
"url": scan.url,
"score": scan.security_score,
"layers": scan.layers,
"issues": scan.issues,
}
narrative = await generate_threat_narrative(context_data)
return ThreatNarrativeResponse(narrative=narrative)
@router.get("/{old_id}/diff/{new_id}", response_model=ScanDiffResponse)
async def diff_scans(
old_id: str,
new_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScanResult).where(
ScanResult.id.in_([old_id, new_id]),
ScanResult.user_id == current_user.id
)
)
scans = result.scalars().all()
if len(scans) != 2:
raise HTTPException(status_code=404, detail="One or both scans not found, or access denied.")
s_old = scans[0] if scans[0].id == old_id else scans[1]
s_new = scans[1] if scans[1].id == new_id else scans[0]
# Convert to set-like structures using issue names
old_map = {i.get("issue"): i for i in s_old.issues}
new_map = {i.get("issue"): i for i in s_new.issues}
resolved = [v for k, v in old_map.items() if k not in new_map]
new_issues = [v for k, v in new_map.items() if k not in old_map]
persisting = [v for k, v in new_map.items() if k in old_map]
return ScanDiffResponse(
resolved_issues=resolved,
new_issues=new_issues,
persisting_issues=persisting,
score_change=s_new.security_score - s_old.security_score
)

116
app/routers/report.py Normal file
View File

@@ -0,0 +1,116 @@
import csv
import io
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fpdf import FPDF
from app.database import get_db
from app.middleware.auth import get_current_user
from app.models.scan import ScanResult
from app.models.user import User
router = APIRouter(prefix="/scans", tags=["report"])
def _generate_csv(scan: ScanResult) -> io.StringIO:
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["SecureLens AI Scan Report"])
writer.writerow(["URL", scan.url])
writer.writerow(["Date", scan.created_at.strftime("%Y-%m-%d %H:%M:%S")])
writer.writerow(["Security Score", scan.security_score])
writer.writerow([])
writer.writerow(["Issue", "Severity", "Layer", "Fix", "Contextual Severity", "Explanation"])
for i in scan.issues:
writer.writerow([
i.get("issue"),
i.get("severity"),
i.get("layer"),
i.get("fix"),
i.get("contextual_severity", ""),
i.get("explanation", ""),
])
output.seek(0)
return output
def _generate_pdf(scan: ScanResult) -> io.BytesIO:
pdf = FPDF()
pdf.add_page()
pdf.set_font("helvetica", "B", 16)
pdf.cell(0, 10, "SecureLens AI Scan Report", new_x="LMARGIN", new_y="NEXT", align="C")
pdf.set_font("helvetica", "", 12)
pdf.cell(0, 10, f"URL: {scan.url}", new_x="LMARGIN", new_y="NEXT")
pdf.cell(0, 10, f"Date: {scan.created_at.strftime('%Y-%m-%d %H:%M:%S')}", new_x="LMARGIN", new_y="NEXT")
pdf.cell(0, 10, f"Security Score: {scan.security_score}/100", new_x="LMARGIN", new_y="NEXT")
pdf.ln(5)
pdf.set_font("helvetica", "B", 14)
pdf.cell(0, 10, "Discovered Issues", new_x="LMARGIN", new_y="NEXT")
for i in scan.issues:
pdf.set_font("helvetica", "B", 12)
pdf.cell(0, 8, f"Issue: {i.get('issue')} [{i.get('severity')}]", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("helvetica", "", 10)
pdf.multi_cell(0, 6, f"Layer: {i.get('layer')}")
pdf.multi_cell(0, 6, f"Fix: {i.get('fix')}")
if i.get("explanation"):
pdf.multi_cell(0, 6, f"AI Context: {i.get('explanation')}")
pdf.ln(4)
pdf_bytes = pdf.output()
return io.BytesIO(pdf_bytes)
@router.get("/{scan_id}/export/csv")
async def export_csv(
scan_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScanResult).where(ScanResult.id == scan_id, ScanResult.user_id == current_user.id)
)
scan = result.scalar_one_or_none()
if not scan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scan not found")
csv_data = _generate_csv(scan)
response = StreamingResponse(iter([csv_data.getvalue()]), media_type="text/csv")
response.headers["Content-Disposition"] = f"attachment; filename=scan_{scan_id}.csv"
return response
@router.get("/{scan_id}/export/pdf")
async def export_pdf(
scan_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScanResult).where(ScanResult.id == scan_id, ScanResult.user_id == current_user.id)
)
scan = result.scalar_one_or_none()
if not scan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scan not found")
try:
pdf_data = _generate_pdf(scan)
response = StreamingResponse(pdf_data, media_type="application/pdf")
response.headers["Content-Disposition"] = f"attachment; filename=scan_{scan_id}.pdf"
return response
except Exception as e:
raise HTTPException(status_code=500, detail=f"PDF Generation failed: {str(e)}")

154
app/routers/scan.py Normal file
View File

@@ -0,0 +1,154 @@
import logging
import httpx
from fastapi import APIRouter, Depends, Request, BackgroundTasks
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.middleware.auth import get_optional_user
from app.middleware.rate_limiter import limiter
from app.models.scan import ScanResult
from app.models.user import User
from app.models.webhook import Webhook
from app.schemas.scan import ScanRequest, ScanResponse
from app.services.scanner.cookies import CookieScanner
from app.services.scanner.exposure import ExposureScanner
from app.services.scanner.headers import HeaderScanner
from app.services.scanner.ssl_checker import SSLScanner
from app.services.scanner.transport import TransportScanner
from app.services.scanner.dns import DNSScanner
from app.services.scanner.ports import PortScanner
from app.services.scoring import calculate_layer_statuses, calculate_score
from app.services.ai import enhance_security_issues
from app.utils.validators import validate_url
logger = logging.getLogger(__name__)
router = APIRouter(tags=["scan"])
transport_scanner = TransportScanner()
ssl_scanner = SSLScanner()
header_scanner = HeaderScanner()
cookie_scanner = CookieScanner()
exposure_scanner = ExposureScanner()
dns_scanner = DNSScanner()
port_scanner = PortScanner()
async def dispatch_webhooks(user_id: str, scan_data: dict, db_session):
import hmac, hashlib, json
from sqlalchemy import select
result = await db_session.execute(
select(Webhook).where(Webhook.user_id == user_id, Webhook.is_active == True)
)
hooks = result.scalars().all()
if not hooks:
return
async with httpx.AsyncClient() as client:
payload = json.dumps(scan_data).encode("utf-8")
for hook in hooks:
headers = {"Content-Type": "application/json"}
if hook.secret_key:
sig = hmac.new(hook.secret_key.encode(), payload, hashlib.sha256).hexdigest()
headers["X-SecureLens-Signature"] = sig
try:
await client.post(hook.target_url, content=payload, headers=headers, timeout=5.0)
except Exception as e:
logger.warning(f"Webhook {hook.target_url} failed: {e}")
@router.post("/scan", response_model=ScanResponse)
@limiter.limit(settings.rate_limit)
async def scan_website(
data: ScanRequest,
request: Request,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_optional_user),
):
url = validate_url(data.url)
try:
import asyncio
dns_task = asyncio.create_task(dns_scanner.scan(url))
port_task = asyncio.create_task(port_scanner.scan(url))
async with httpx.AsyncClient(
timeout=httpx.Timeout(settings.scan_timeout),
follow_redirects=True,
) as client:
response = await client.get(url)
all_issues = []
all_issues.extend(await transport_scanner.scan(url, response))
all_issues.extend(await ssl_scanner.scan(url, response))
all_issues.extend(await header_scanner.scan(url, response))
all_issues.extend(await cookie_scanner.scan(url, response))
all_issues.extend(await exposure_scanner.scan(url, response))
# Await infrastructure scans
all_issues.extend(await dns_task)
all_issues.extend(await port_task)
score = calculate_score(all_issues)
layers = calculate_layer_statuses(all_issues)
if settings.openai_api_key and all_issues:
issues_dict_list = [i.model_dump() for i in all_issues]
ai_data = await enhance_security_issues(issues_dict_list)
enhanced_list = ai_data.get("enhanced_issues", [])
enhancement_map = {e.get("issue"): e for e in enhanced_list}
for original in all_issues:
enh = enhancement_map.get(original.issue)
if enh:
original.contextual_severity = enh.get("contextual_severity")
original.explanation = enh.get("explanation")
original.remediation_snippet = enh.get("remediation_snippet")
scan_id = None
created_at = None
if current_user is not None:
layers_dict = {k: v.model_dump() for k, v in layers.items()}
issues_list = [i.model_dump() for i in all_issues]
scan_record = ScanResult(
user_id=current_user.id,
url=url,
security_score=score,
layers=layers_dict,
issues=issues_list,
)
db.add(scan_record)
await db.flush()
scan_id = scan_record.id
created_at = scan_record.created_at
scan_summary = {
"scan_id": scan_id,
"url": url,
"score": score
}
background_tasks.add_task(dispatch_webhooks, current_user.id, scan_summary, db)
return ScanResponse(
id=scan_id,
url=url,
security_score=score,
layers=layers,
issues=all_issues,
created_at=created_at,
)
except httpx.HTTPError as e:
logger.error(f"Scan failed for {url}: {e}")
return JSONResponse(
status_code=502,
content={"error": f"Could not reach {url}: {str(e)}"},
)

56
app/routers/webhook.py Normal file
View File

@@ -0,0 +1,56 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.models.user import User
from app.models.webhook import Webhook
from app.schemas.webhook import WebhookCreate, WebhookResponse
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
@router.post("", response_model=WebhookResponse)
async def create_webhook(
hook_in: WebhookCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
secret = hook_in.secret_key or secrets.token_hex(16)
db_hook = Webhook(
user_id=current_user.id,
target_url=str(hook_in.target_url),
secret_key=secret
)
db.add(db_hook)
await db.commit()
await db.refresh(db_hook)
return db_hook
@router.get("", response_model=list[WebhookResponse])
async def list_webhooks(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Webhook).where(Webhook.user_id == current_user.id))
return result.scalars().all()
@router.delete("/{hook_id}")
async def delete_webhook(
hook_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Webhook).where(Webhook.id == hook_id, Webhook.user_id == current_user.id))
hook = result.scalar_one_or_none()
if not hook:
raise HTTPException(status_code=404, detail="Webhook not found")
await db.delete(hook)
await db.commit()
return {"status": "success", "message": "Webhook deleted"}