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

34
app/config.py Normal file
View File

@@ -0,0 +1,34 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "SecureLens AI"
app_version: str = "1.0.0"
debug: bool = False
host: str = "0.0.0.0"
port: int = 8000
cors_origins: str = "http://localhost:3000,http://localhost:5173"
rate_limit: str = "30/minute"
scan_timeout: int = 5
path_check_timeout: int = 3
database_url: str = "postgresql+asyncpg://securelens:securelens@localhost:5433/securelens"
jwt_secret: str = "change-me-in-production-use-a-long-random-string"
jwt_algorithm: str = "HS256"
jwt_expiry_minutes: int = 1440
openai_api_key: str | None = None
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
settings = Settings()

35
app/database.py Normal file
View File

@@ -0,0 +1,35 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=settings.debug)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def close_db():
await engine.dispose()

65
app/main.py Normal file
View File

@@ -0,0 +1,65 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from app.config import settings
from app.database import close_db, init_db
from app.middleware.rate_limiter import limiter
from app.routers import auth, health, history, scan, apikey, report
logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
import app.models # noqa: F401 — register models with Base.metadata
await init_db()
logger.info("Database initialized")
yield
await close_db()
logger.info("Database connection closed")
def create_app() -> FastAPI:
application = FastAPI(
title=settings.app_name,
version=settings.app_version,
docs_url="/docs" if settings.debug else None,
redoc_url="/redoc" if settings.debug else None,
lifespan=lifespan,
)
application.state.limiter = limiter
application.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
application.add_middleware(SlowAPIMiddleware)
application.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
application.include_router(health.router)
application.include_router(auth.router)
application.include_router(scan.router)
application.include_router(history.router)
application.include_router(apikey.router)
application.include_router(report.router)
logger.info(f"{settings.app_name} v{settings.app_version} initialized")
return application
app = create_app()

View File

71
app/middleware/auth.py Normal file
View File

@@ -0,0 +1,71 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import hashlib
from app.database import get_db
from app.models.user import User
from app.models.apikey import ApiKey
from app.utils.auth import decode_access_token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_current_user(
token: str | None = Depends(oauth2_scheme),
api_key: str | None = Depends(api_key_header),
db: AsyncSession = Depends(get_db),
) -> User:
if token:
user_id = decode_access_token(token)
if user_id:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user:
return user
if api_key:
hashed_key = hashlib.sha256(api_key.encode()).hexdigest()
result = await db.execute(
select(User)
.join(ApiKey, User.id == ApiKey.user_id)
.where(ApiKey.hashed_key == hashed_key)
)
user = result.scalar_one_or_none()
if user:
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_optional_user(
token: str | None = Depends(oauth2_scheme),
api_key: str | None = Depends(api_key_header),
db: AsyncSession = Depends(get_db),
) -> User | None:
if token:
user_id = decode_access_token(token)
if user_id:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user:
return user
if api_key:
hashed_key = hashlib.sha256(api_key.encode()).hexdigest()
result = await db.execute(
select(User)
.join(ApiKey, User.id == ApiKey.user_id)
.where(ApiKey.hashed_key == hashed_key)
)
user = result.scalar_one_or_none()
if user:
return user
return None

View File

@@ -0,0 +1,6 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.config import settings
limiter = Limiter(key_func=get_remote_address, default_limits=[settings.rate_limit])

4
app/models/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from app.models.user import User
from app.models.scan import ScanResult
__all__ = ["User", "ScanResult"]

26
app/models/apikey.py Normal file
View File

@@ -0,0 +1,26 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id"), index=True
)
name: Mapped[str] = mapped_column(String(100))
key_prefix: Mapped[str] = mapped_column(String(10))
hashed_key: Mapped[str] = mapped_column(String(255), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
user = relationship("User", back_populates="api_keys")

27
app/models/scan.py Normal file
View File

@@ -0,0 +1,27 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ScanResult(Base):
__tablename__ = "scan_results"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id"), index=True
)
url: Mapped[str] = mapped_column(String(2048))
security_score: Mapped[int] = mapped_column(Integer)
layers: Mapped[dict] = mapped_column(JSON)
issues: Mapped[list] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
user = relationship("User", back_populates="scans")

25
app/models/user.py Normal file
View File

@@ -0,0 +1,25 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
scans = relationship("ScanResult", back_populates="user", lazy="selectin")
api_keys = relationship("ApiKey", back_populates="user", lazy="selectin", cascade="all, delete")
webhooks = relationship("Webhook", back_populates="user", lazy="selectin", cascade="all, delete")

21
app/models/webhook.py Normal file
View File

@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String
from sqlalchemy.orm import relationship
from app.database import Base
class Webhook(Base):
__tablename__ = "webhooks"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
target_url = Column(String, nullable=False)
secret_key = Column(String, nullable=True) # Used for HMAC signing
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationship
user = relationship("User", back_populates="webhooks")

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"}

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

17
app/schemas/apikey.py Normal file
View File

@@ -0,0 +1,17 @@
from datetime import datetime
from pydantic import BaseModel
class ApiKeyCreate(BaseModel):
name: str
class ApiKeyResponse(BaseModel):
id: str
name: str
key_prefix: str
created_at: datetime
class ApiKeyCreateResponse(ApiKeyResponse):
key: str # The raw API key returned only once upon creation

28
app/schemas/auth.py Normal file
View File

@@ -0,0 +1,28 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
email: EmailStr
username: str = Field(..., min_length=3, max_length=100)
password: str = Field(..., min_length=8, max_length=128)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: str
email: str
username: str
created_at: datetime
model_config = {"from_attributes": True}

72
app/schemas/scan.py Normal file
View File

@@ -0,0 +1,72 @@
from datetime import datetime
from pydantic import BaseModel, Field
class ScanRequest(BaseModel):
url: str = Field(..., description="The URL of the website to scan")
class Issue(BaseModel):
issue: str
severity: str
layer: str
fix: str
contextual_severity: str | None = None
explanation: str | None = None
remediation_snippet: str | None = None
class LayerStatus(BaseModel):
issues: int = 0
status: str = "green"
class ScanResponse(BaseModel):
id: str | None = None
url: str
security_score: int
layers: dict[str, LayerStatus]
issues: list[Issue]
created_at: datetime | None = None
class ScanHistoryItem(BaseModel):
id: str
url: str
security_score: int
created_at: datetime
model_config = {"from_attributes": True}
class ScanHistoryResponse(BaseModel):
scans: list[ScanHistoryItem]
total: int
page: int
per_page: int
class DashboardTrendsResponse(BaseModel):
total_scans: int
average_score: float
recent_scans: list[ScanHistoryItem]
class ChatRequest(BaseModel):
message: str
class ChatResponse(BaseModel):
reply: str
class ThreatNarrativeResponse(BaseModel):
narrative: str
class ScanDiffResponse(BaseModel):
resolved_issues: list[Issue]
new_issues: list[Issue]
persisting_issues: list[Issue]
score_change: int

17
app/schemas/webhook.py Normal file
View File

@@ -0,0 +1,17 @@
from datetime import datetime
from pydantic import BaseModel, HttpUrl
class WebhookCreate(BaseModel):
target_url: HttpUrl
secret_key: str | None = None
class WebhookResponse(BaseModel):
id: str
target_url: str
is_active: bool
created_at: datetime
class Config:
from_attributes = True

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

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

30
app/utils/auth.py Normal file
View File

@@ -0,0 +1,30 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expiry_minutes)
payload = {"sub": user_id, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> str | None:
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return payload.get("sub")
except JWTError:
return None

46
app/utils/validators.py Normal file
View File

@@ -0,0 +1,46 @@
import ipaddress
import socket
from urllib.parse import urlparse
from fastapi import HTTPException
PRIVATE_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def validate_url(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="URL must use http or https scheme")
hostname = parsed.hostname
if not hostname:
raise HTTPException(status_code=400, detail="Invalid URL: no hostname found")
blocked_hostnames = {"localhost", "0.0.0.0"}
if hostname in blocked_hostnames:
raise HTTPException(status_code=400, detail="Scanning internal addresses is not allowed")
try:
resolved_ip = socket.gethostbyname(hostname)
ip = ipaddress.ip_address(resolved_ip)
for network in PRIVATE_NETWORKS:
if ip in network:
raise HTTPException(
status_code=400,
detail="Scanning internal/private IP addresses is not allowed",
)
except socket.gaierror:
raise HTTPException(status_code=400, detail=f"Could not resolve hostname: {hostname}")
return url