From 8330060e860e0532e948ff870f593d144a45ac88 Mon Sep 17 00:00:00 2001 From: rarebuffalo Date: Tue, 7 Apr 2026 18:13:43 +0530 Subject: [PATCH] updated the architecture --- .dockerignore | 9 + .env.example | 27 +++ .gitignore | 16 ++ Dockerfile | 15 ++ alembic.ini | 149 ++++++++++++ app/__init__.py | 0 app/config.py | 34 +++ app/database.py | 35 +++ app/main.py | 65 +++++ app/middleware/__init__.py | 0 app/middleware/auth.py | 71 ++++++ app/middleware/rate_limiter.py | 6 + app/models/__init__.py | 4 + app/models/apikey.py | 26 ++ app/models/scan.py | 27 +++ app/models/user.py | 25 ++ app/models/webhook.py | 21 ++ app/routers/__init__.py | 0 app/routers/apikey.py | 76 ++++++ app/routers/auth.py | 61 +++++ app/routers/health.py | 19 ++ app/routers/history.py | 226 ++++++++++++++++++ app/routers/report.py | 116 +++++++++ app/routers/scan.py | 154 ++++++++++++ app/routers/webhook.py | 56 +++++ app/schemas/__init__.py | 0 app/schemas/apikey.py | 17 ++ app/schemas/auth.py | 28 +++ app/schemas/scan.py | 72 ++++++ app/schemas/webhook.py | 17 ++ app/services/__init__.py | 0 app/services/ai.py | 105 ++++++++ app/services/scanner/__init__.py | 0 app/services/scanner/base.py | 11 + app/services/scanner/cookies.py | 70 ++++++ app/services/scanner/dns.py | 148 ++++++++++++ app/services/scanner/exposure.py | 135 +++++++++++ app/services/scanner/headers.py | 140 +++++++++++ app/services/scanner/ports.py | 76 ++++++ app/services/scanner/ssl_checker.py | 136 +++++++++++ app/services/scanner/transport.py | 76 ++++++ app/services/scoring.py | 42 ++++ app/utils/__init__.py | 0 app/utils/auth.py | 30 +++ app/utils/validators.py | 46 ++++ ci/securelens-scan.yml | 46 ++++ docker-compose.yml | 34 +++ main.py | 131 +--------- migrations/README | 1 + migrations/env.py | 96 ++++++++ migrations/script.py.mako | 28 +++ .../a2ca840d767c_initial_migration.py | 57 +++++ .../a8253e561192_add_api_key_model.py | 45 ++++ requirements.txt | 17 +- tests/__init__.py | 0 tests/conftest.py | 70 ++++++ tests/test_auth.py | 106 ++++++++ tests/test_cookies.py | 65 +++++ tests/test_headers.py | 94 ++++++++ tests/test_health.py | 19 ++ tests/test_history.py | 90 +++++++ tests/test_scan.py | 54 +++++ tests/test_scoring.py | 63 +++++ tests/test_ssl_checker.py | 86 +++++++ tests/test_transport.py | 75 ++++++ tests/test_validators.py | 50 ++++ 66 files changed, 3484 insertions(+), 130 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/middleware/__init__.py create mode 100644 app/middleware/auth.py create mode 100644 app/middleware/rate_limiter.py create mode 100644 app/models/__init__.py create mode 100644 app/models/apikey.py create mode 100644 app/models/scan.py create mode 100644 app/models/user.py create mode 100644 app/models/webhook.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/apikey.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/health.py create mode 100644 app/routers/history.py create mode 100644 app/routers/report.py create mode 100644 app/routers/scan.py create mode 100644 app/routers/webhook.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/apikey.py create mode 100644 app/schemas/auth.py create mode 100644 app/schemas/scan.py create mode 100644 app/schemas/webhook.py create mode 100644 app/services/__init__.py create mode 100644 app/services/ai.py create mode 100644 app/services/scanner/__init__.py create mode 100644 app/services/scanner/base.py create mode 100644 app/services/scanner/cookies.py create mode 100644 app/services/scanner/dns.py create mode 100644 app/services/scanner/exposure.py create mode 100644 app/services/scanner/headers.py create mode 100644 app/services/scanner/ports.py create mode 100644 app/services/scanner/ssl_checker.py create mode 100644 app/services/scanner/transport.py create mode 100644 app/services/scoring.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/auth.py create mode 100644 app/utils/validators.py create mode 100644 ci/securelens-scan.yml create mode 100644 docker-compose.yml create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/a2ca840d767c_initial_migration.py create mode 100644 migrations/versions/a8253e561192_add_api_key_model.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_cookies.py create mode 100644 tests/test_headers.py create mode 100644 tests/test_health.py create mode 100644 tests/test_history.py create mode 100644 tests/test_scan.py create mode 100644 tests/test_scoring.py create mode 100644 tests/test_ssl_checker.py create mode 100644 tests/test_transport.py create mode 100644 tests/test_validators.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6a9ce69 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__ +*.pyc +.env +.git +.gitignore +venv +.venv +*.egg-info +.pytest_cache diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cad8b80 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# SecureLens AI Configuration + +# Application +APP_NAME=SecureLens AI +APP_VERSION=1.0.0 +DEBUG=true + +# Server +HOST=0.0.0.0 +PORT=8000 + +# CORS - comma-separated list of allowed origins +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# Rate Limiting +RATE_LIMIT=30/minute + +# Scanner +SCAN_TIMEOUT=5 +PATH_CHECK_TIMEOUT=3 + +# Database configuration +DATABASE_URL=postgresql+asyncpg://securelens:securelens@localhost:5433/securelens + +# AI Integration +OPENAI_API_KEY= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23837c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.eggs/ +venv/ +.venv/ +.env +*.log +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ +*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..014cd78 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim AS base + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..6a15082 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..ac17e7c --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..e3858e6 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..135c557 --- /dev/null +++ b/app/main.py @@ -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() diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/middleware/auth.py b/app/middleware/auth.py new file mode 100644 index 0000000..71b59bc --- /dev/null +++ b/app/middleware/auth.py @@ -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 diff --git a/app/middleware/rate_limiter.py b/app/middleware/rate_limiter.py new file mode 100644 index 0000000..f6fdc30 --- /dev/null +++ b/app/middleware/rate_limiter.py @@ -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]) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..a8b37b7 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,4 @@ +from app.models.user import User +from app.models.scan import ScanResult + +__all__ = ["User", "ScanResult"] diff --git a/app/models/apikey.py b/app/models/apikey.py new file mode 100644 index 0000000..9f1df3b --- /dev/null +++ b/app/models/apikey.py @@ -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") diff --git a/app/models/scan.py b/app/models/scan.py new file mode 100644 index 0000000..4631088 --- /dev/null +++ b/app/models/scan.py @@ -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") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..713d51d --- /dev/null +++ b/app/models/user.py @@ -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") diff --git a/app/models/webhook.py b/app/models/webhook.py new file mode 100644 index 0000000..62e51c7 --- /dev/null +++ b/app/models/webhook.py @@ -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") diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/apikey.py b/app/routers/apikey.py new file mode 100644 index 0000000..de361dd --- /dev/null +++ b/app/routers/apikey.py @@ -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() diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..1a1ccbf --- /dev/null +++ b/app/routers/auth.py @@ -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 diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..6452f15 --- /dev/null +++ b/app/routers/health.py @@ -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, + } diff --git a/app/routers/history.py b/app/routers/history.py new file mode 100644 index 0000000..4043ba6 --- /dev/null +++ b/app/routers/history.py @@ -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 + ) diff --git a/app/routers/report.py b/app/routers/report.py new file mode 100644 index 0000000..8dfc55c --- /dev/null +++ b/app/routers/report.py @@ -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)}") diff --git a/app/routers/scan.py b/app/routers/scan.py new file mode 100644 index 0000000..a461eef --- /dev/null +++ b/app/routers/scan.py @@ -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)}"}, + ) diff --git a/app/routers/webhook.py b/app/routers/webhook.py new file mode 100644 index 0000000..9c86c6f --- /dev/null +++ b/app/routers/webhook.py @@ -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"} diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/apikey.py b/app/schemas/apikey.py new file mode 100644 index 0000000..6a63da1 --- /dev/null +++ b/app/schemas/apikey.py @@ -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 diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..92640bc --- /dev/null +++ b/app/schemas/auth.py @@ -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} diff --git a/app/schemas/scan.py b/app/schemas/scan.py new file mode 100644 index 0000000..5007900 --- /dev/null +++ b/app/schemas/scan.py @@ -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 diff --git a/app/schemas/webhook.py b/app/schemas/webhook.py new file mode 100644 index 0000000..cca9178 --- /dev/null +++ b/app/schemas/webhook.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/ai.py b/app/services/ai.py new file mode 100644 index 0000000..6b93b78 --- /dev/null +++ b/app/services/ai.py @@ -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." diff --git a/app/services/scanner/__init__.py b/app/services/scanner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/scanner/base.py b/app/services/scanner/base.py new file mode 100644 index 0000000..a82a1c1 --- /dev/null +++ b/app/services/scanner/base.py @@ -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 diff --git a/app/services/scanner/cookies.py b/app/services/scanner/cookies.py new file mode 100644 index 0000000..9c3203c --- /dev/null +++ b/app/services/scanner/cookies.py @@ -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 diff --git a/app/services/scanner/dns.py b/app/services/scanner/dns.py new file mode 100644 index 0000000..9f6e723 --- /dev/null +++ b/app/services/scanner/dns.py @@ -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 diff --git a/app/services/scanner/exposure.py b/app/services/scanner/exposure.py new file mode 100644 index 0000000..e9c4a96 --- /dev/null +++ b/app/services/scanner/exposure.py @@ -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 ("
" 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
diff --git a/app/services/scanner/headers.py b/app/services/scanner/headers.py
new file mode 100644
index 0000000..7f70122
--- /dev/null
+++ b/app/services/scanner/headers.py
@@ -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
diff --git a/app/services/scanner/ports.py b/app/services/scanner/ports.py
new file mode 100644
index 0000000..b5b35fb
--- /dev/null
+++ b/app/services/scanner/ports.py
@@ -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
diff --git a/app/services/scanner/ssl_checker.py b/app/services/scanner/ssl_checker.py
new file mode 100644
index 0000000..68563de
--- /dev/null
+++ b/app/services/scanner/ssl_checker.py
@@ -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
diff --git a/app/services/scanner/transport.py b/app/services/scanner/transport.py
new file mode 100644
index 0000000..23768ea
--- /dev/null
+++ b/app/services/scanner/transport.py
@@ -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
diff --git a/app/services/scoring.py b/app/services/scoring.py
new file mode 100644
index 0000000..681773a
--- /dev/null
+++ b/app/services/scoring.py
@@ -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
diff --git a/app/utils/__init__.py b/app/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/utils/auth.py b/app/utils/auth.py
new file mode 100644
index 0000000..6026d31
--- /dev/null
+++ b/app/utils/auth.py
@@ -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
diff --git a/app/utils/validators.py b/app/utils/validators.py
new file mode 100644
index 0000000..ff7381d
--- /dev/null
+++ b/app/utils/validators.py
@@ -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
diff --git a/ci/securelens-scan.yml b/ci/securelens-scan.yml
new file mode 100644
index 0000000..857e0c8
--- /dev/null
+++ b/ci/securelens-scan.yml
@@ -0,0 +1,46 @@
+name: SecureLens CI/CD Scan
+
+on:
+  push:
+    branches: [ "main" ]
+  pull_request:
+    branches: [ "main" ]
+
+jobs:
+  security-scan:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v3
+
+      # Example: Wait for deployment/staging URL to be available
+      - name: Run SecureLens Scan
+        env:
+          SECURELENS_API_URL: "https://your-securelens-instance.com"
+          SECURELENS_API_KEY: ${{ secrets.SECURELENS_API_KEY }}
+          TARGET_URL: "https://staging.your-app.com"
+          MINIMUM_SCORE: 80
+        run: |
+          echo "Initiating SecureLens Scan against $TARGET_URL"
+          
+          # Trigger Scan
+          RESPONSE=$(curl -s -X POST "$SECURELENS_API_URL/scans/scan" \
+            -H "X-API-Key: $SECURELENS_API_KEY" \
+            -H "Content-Type: application/json" \
+            -d "{\"url\": \"$TARGET_URL\"}")
+            
+          # Extract score using jq
+          SCORE=$(echo $RESPONSE | jq -r '.security_score')
+          SCAN_ID=$(echo $RESPONSE | jq -r '.id')
+          
+          echo "Scan completed (ID: $SCAN_ID)"
+          echo "Security Score: $SCORE"
+          
+          # Check Threshold
+          if (( $(echo "$SCORE < $MINIMUM_SCORE" | bc -l) )); then
+            echo "::error::Security score ($SCORE) is below the minimum threshold ($MINIMUM_SCORE)"
+            exit 1
+          else
+            echo "Security check passed!"
+            exit 0
+          fi
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..93b92c1
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,34 @@
+services:
+  db:
+    image: postgres:16-alpine
+    environment:
+      POSTGRES_USER: securelens
+      POSTGRES_PASSWORD: securelens
+      POSTGRES_DB: securelens
+    ports:
+      - "5433:5432"
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U securelens"]
+      interval: 5s
+      timeout: 5s
+      retries: 5
+
+  backend:
+    build: .
+    ports:
+      - "8000:8000"
+    env_file:
+      - .env
+    environment:
+      DATABASE_URL: postgresql+asyncpg://securelens:securelens@db:5432/securelens
+    volumes:
+      - .:/app
+    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+    depends_on:
+      db:
+        condition: service_healthy
+
+volumes:
+  pgdata:
diff --git a/main.py b/main.py
index dc88054..4535d67 100644
--- a/main.py
+++ b/main.py
@@ -1,130 +1,3 @@
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi import FastAPI
-from pydantic import BaseModel
-import requests
+from app.main import app
 
-app = FastAPI()
-
-app.add_middleware(
-    CORSMiddleware,
-    allow_origins=["*"],
-    allow_credentials=True,
-    allow_methods=["*"],
-    allow_headers=["*"],
-)
-
-class ScanRequest(BaseModel):
-    url: str
-
-
-@app.get("/")
-def read_root():
-    return {"message": "SecureLens AI backend running 🚀"}
-
-
-@app.post("/scan")
-def scan_website(data: ScanRequest):
-    url = data.url
-    issues = []
-    score = 100
-
-    layers = {
-        "Transport Layer": {"issues": 0, "status": "green"},
-        "Server Config Layer": {"issues": 0, "status": "green"},
-        "Exposure Layer": {"issues": 0, "status": "green"}
-    }
-
-    try:
-        response = requests.get(url, timeout=5)
-        headers = response.headers
-
-        # Transport Layer
-        if not url.startswith("https"):
-            issues.append({
-                "issue": "Website is not using HTTPS",
-                "severity": "Critical",
-                "layer": "Transport Layer",
-                "fix": "Install SSL certificate and redirect HTTP to HTTPS"
-            })
-            score -= 15
-            layers["Transport Layer"]["issues"] += 1
-
-        # Server Config
-        if "Content-Security-Policy" not in headers:
-            issues.append({
-                "issue": "Missing Content-Security-Policy header",
-                "severity": "Warning",
-                "layer": "Server Config Layer",
-                "fix": "Add header: Content-Security-Policy: default-src 'self';"
-            })
-            score -= 5
-            layers["Server Config Layer"]["issues"] += 1
-
-        if "X-Frame-Options" not in headers:
-            issues.append({
-                "issue": "Missing X-Frame-Options header",
-                "severity": "Warning",
-                "layer": "Server Config Layer",
-                "fix": "Add header: X-Frame-Options: SAMEORIGIN"
-            })
-            score -= 5
-            layers["Server Config Layer"]["issues"] += 1
-
-        if "Strict-Transport-Security" not in headers:
-            issues.append({
-                "issue": "Missing HSTS header",
-                "severity": "Warning",
-                "layer": "Server Config Layer",
-                "fix": "Add header: Strict-Transport-Security: max-age=31536000; includeSubDomains"
-            })
-            score -= 5
-            layers["Server Config Layer"]["issues"] += 1
-
-        if headers.get("Access-Control-Allow-Origin") == "*":
-            issues.append({
-                "issue": "CORS allows all origins (*)",
-                "severity": "Warning",
-                "layer": "Server Config Layer",
-                "fix": "Restrict Access-Control-Allow-Origin to trusted domains"
-            })
-            score -= 5
-            layers["Server Config Layer"]["issues"] += 1
-
-        # Exposure
-        sensitive_paths = ["/admin", "/.env", "/backup", "/debug"]
-
-        for path in sensitive_paths:
-            try:
-                test_url = url.rstrip("/") + path
-                r = requests.get(test_url, timeout=3)
-                if r.status_code == 200:
-                    issues.append({
-                        "issue": f"Sensitive path exposed: {path}",
-                        "severity": "Critical",
-                        "layer": "Exposure Layer",
-                        "fix": f"Restrict access to {path} using authentication or firewall rules"
-                    })
-                    score -= 15
-                    layers["Exposure Layer"]["issues"] += 1
-            except:
-                pass
-
-    except Exception as e:
-        return {"error": str(e)}
-
-    # Set layer status
-    for layer in layers:
-        count = layers[layer]["issues"]
-        if count == 0:
-            layers[layer]["status"] = "green"
-        elif count < 3:
-            layers[layer]["status"] = "yellow"
-        else:
-            layers[layer]["status"] = "red"
-
-    return {
-        "url": url,
-        "security_score": max(score, 0),
-        "layers": layers,
-        "issues": issues
-    }
+__all__ = ["app"]
diff --git a/migrations/README b/migrations/README
new file mode 100644
index 0000000..e0d0858
--- /dev/null
+++ b/migrations/README
@@ -0,0 +1 @@
+Generic single-database configuration with an async dbapi.
\ No newline at end of file
diff --git a/migrations/env.py b/migrations/env.py
new file mode 100644
index 0000000..01cda79
--- /dev/null
+++ b/migrations/env.py
@@ -0,0 +1,96 @@
+import asyncio
+from logging.config import fileConfig
+
+from sqlalchemy import pool
+from sqlalchemy.engine import Connection
+from sqlalchemy.ext.asyncio import async_engine_from_config
+
+from alembic import context
+
+from app.config import settings
+from app.database import Base
+from app.models.user import User
+from app.models.scan import ScanResult
+from app.models.apikey import ApiKey
+from app.models.webhook import Webhook
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+config.set_main_option("sqlalchemy.url", settings.database_url)
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+target_metadata = Base.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def do_run_migrations(connection: Connection) -> None:
+    context.configure(connection=connection, target_metadata=target_metadata)
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+async def run_async_migrations() -> None:
+    """In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+
+    connectable = async_engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+
+    async with connectable.connect() as connection:
+        await connection.run_sync(do_run_migrations)
+
+    await connectable.dispose()
+
+
+def run_migrations_online() -> None:
+    """Run migrations in 'online' mode."""
+
+    asyncio.run(run_async_migrations())
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()
diff --git a/migrations/script.py.mako b/migrations/script.py.mako
new file mode 100644
index 0000000..1101630
--- /dev/null
+++ b/migrations/script.py.mako
@@ -0,0 +1,28 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    ${downgrades if downgrades else "pass"}
diff --git a/migrations/versions/a2ca840d767c_initial_migration.py b/migrations/versions/a2ca840d767c_initial_migration.py
new file mode 100644
index 0000000..275d938
--- /dev/null
+++ b/migrations/versions/a2ca840d767c_initial_migration.py
@@ -0,0 +1,57 @@
+"""Initial migration
+
+Revision ID: a2ca840d767c
+Revises: 
+Create Date: 2026-03-24 18:29:43.533353
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'a2ca840d767c'
+down_revision: Union[str, Sequence[str], None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('users',
+    sa.Column('id', sa.String(length=36), nullable=False),
+    sa.Column('email', sa.String(length=255), nullable=False),
+    sa.Column('username', sa.String(length=100), nullable=False),
+    sa.Column('hashed_password', sa.String(length=255), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
+    op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
+    op.create_table('scan_results',
+    sa.Column('id', sa.String(length=36), nullable=False),
+    sa.Column('user_id', sa.String(length=36), nullable=False),
+    sa.Column('url', sa.String(length=2048), nullable=False),
+    sa.Column('security_score', sa.Integer(), nullable=False),
+    sa.Column('layers', sa.JSON(), nullable=False),
+    sa.Column('issues', sa.JSON(), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+    sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_scan_results_user_id'), 'scan_results', ['user_id'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_scan_results_user_id'), table_name='scan_results')
+    op.drop_table('scan_results')
+    op.drop_index(op.f('ix_users_username'), table_name='users')
+    op.drop_index(op.f('ix_users_email'), table_name='users')
+    op.drop_table('users')
+    # ### end Alembic commands ###
diff --git a/migrations/versions/a8253e561192_add_api_key_model.py b/migrations/versions/a8253e561192_add_api_key_model.py
new file mode 100644
index 0000000..5e99784
--- /dev/null
+++ b/migrations/versions/a8253e561192_add_api_key_model.py
@@ -0,0 +1,45 @@
+"""Add API Key model
+
+Revision ID: a8253e561192
+Revises: a2ca840d767c
+Create Date: 2026-03-24 18:31:38.229135
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'a8253e561192'
+down_revision: Union[str, Sequence[str], None] = 'a2ca840d767c'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('api_keys',
+    sa.Column('id', sa.String(length=36), nullable=False),
+    sa.Column('user_id', sa.String(length=36), nullable=False),
+    sa.Column('name', sa.String(length=100), nullable=False),
+    sa.Column('key_prefix', sa.String(length=10), nullable=False),
+    sa.Column('hashed_key', sa.String(length=255), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+    sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_api_keys_hashed_key'), 'api_keys', ['hashed_key'], unique=True)
+    op.create_index(op.f('ix_api_keys_user_id'), 'api_keys', ['user_id'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_api_keys_user_id'), table_name='api_keys')
+    op.drop_index(op.f('ix_api_keys_hashed_key'), table_name='api_keys')
+    op.drop_table('api_keys')
+    # ### end Alembic commands ###
diff --git a/requirements.txt b/requirements.txt
index 9288a59..afa5f6d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,19 @@
 fastapi
 uvicorn
-requests
+httpx
 pydantic
+pydantic-settings
+python-dotenv
+slowapi
+sqlalchemy[asyncio]
+aiosqlite
+asyncpg
+python-jose[cryptography]
+passlib[bcrypt]
+pydantic[email]
+pytest
+pytest-asyncio
+alembic
+openai
+aiodns
+fpdf2
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..aeb63fd
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,70 @@
+import pytest
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from app.database import Base, get_db
+from app.main import app
+from app.models.user import User
+from app.utils.auth import create_access_token, hash_password
+
+TEST_DB_URL = "sqlite+aiosqlite://"
+
+test_engine = create_async_engine(TEST_DB_URL, echo=False)
+TestSessionLocal = async_sessionmaker(
+    bind=test_engine, class_=AsyncSession, expire_on_commit=False
+)
+
+
+async def override_get_db():
+    async with TestSessionLocal() as session:
+        try:
+            yield session
+            await session.commit()
+        except Exception:
+            await session.rollback()
+            raise
+
+
+app.dependency_overrides[get_db] = override_get_db
+
+
+@pytest.fixture(autouse=True)
+async def setup_db():
+    async with test_engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield
+    async with test_engine.begin() as conn:
+        await conn.run_sync(Base.metadata.drop_all)
+
+
+@pytest.fixture
+def client():
+    from fastapi.testclient import TestClient
+    return TestClient(app)
+
+
+@pytest.fixture
+async def async_client():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as ac:
+        yield ac
+
+
+@pytest.fixture
+async def test_user():
+    async with TestSessionLocal() as session:
+        user = User(
+            email="test@example.com",
+            username="testuser",
+            hashed_password=hash_password("testpassword123"),
+        )
+        session.add(user)
+        await session.commit()
+        await session.refresh(user)
+        return user
+
+
+@pytest.fixture
+async def auth_headers(test_user):
+    token = create_access_token(test_user.id)
+    return {"Authorization": f"Bearer {token}"}
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000..cddb710
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,106 @@
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_register(async_client):
+    response = await async_client.post("/auth/register", json={
+        "email": "new@example.com",
+        "username": "newuser",
+        "password": "securepass123",
+    })
+    assert response.status_code == 201
+    data = response.json()
+    assert "access_token" in data
+    assert data["token_type"] == "bearer"
+
+
+@pytest.mark.asyncio
+async def test_register_duplicate_email(async_client, test_user):
+    response = await async_client.post("/auth/register", json={
+        "email": "test@example.com",
+        "username": "different",
+        "password": "securepass123",
+    })
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+async def test_register_duplicate_username(async_client, test_user):
+    response = await async_client.post("/auth/register", json={
+        "email": "different@example.com",
+        "username": "testuser",
+        "password": "securepass123",
+    })
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+async def test_register_short_password(async_client):
+    response = await async_client.post("/auth/register", json={
+        "email": "new@example.com",
+        "username": "newuser",
+        "password": "short",
+    })
+    assert response.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_register_invalid_email(async_client):
+    response = await async_client.post("/auth/register", json={
+        "email": "not-an-email",
+        "username": "newuser",
+        "password": "securepass123",
+    })
+    assert response.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_login(async_client, test_user):
+    response = await async_client.post("/auth/login", json={
+        "email": "test@example.com",
+        "password": "testpassword123",
+    })
+    assert response.status_code == 200
+    data = response.json()
+    assert "access_token" in data
+
+
+@pytest.mark.asyncio
+async def test_login_wrong_password(async_client, test_user):
+    response = await async_client.post("/auth/login", json={
+        "email": "test@example.com",
+        "password": "wrongpassword",
+    })
+    assert response.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_login_nonexistent_email(async_client):
+    response = await async_client.post("/auth/login", json={
+        "email": "nobody@example.com",
+        "password": "testpassword123",
+    })
+    assert response.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_get_me(async_client, test_user, auth_headers):
+    response = await async_client.get("/auth/me", headers=auth_headers)
+    assert response.status_code == 200
+    data = response.json()
+    assert data["email"] == "test@example.com"
+    assert data["username"] == "testuser"
+    assert "id" in data
+    assert "created_at" in data
+
+
+@pytest.mark.asyncio
+async def test_get_me_unauthorized(async_client):
+    response = await async_client.get("/auth/me")
+    assert response.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_get_me_invalid_token(async_client):
+    response = await async_client.get("/auth/me", headers={"Authorization": "Bearer invalid"})
+    assert response.status_code == 401
diff --git a/tests/test_cookies.py b/tests/test_cookies.py
new file mode 100644
index 0000000..0616be1
--- /dev/null
+++ b/tests/test_cookies.py
@@ -0,0 +1,65 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from app.services.scanner.cookies import CookieScanner
+
+scanner = CookieScanner()
+
+
+def _make_response(set_cookie_headers: list[str]) -> MagicMock:
+    items = [("content-type", "text/html")]
+    for cookie in set_cookie_headers:
+        items.append(("set-cookie", cookie))
+    response = MagicMock()
+    response.headers.multi_items.return_value = items
+    return response
+
+
+@pytest.mark.asyncio
+async def test_no_cookies_returns_empty():
+    response = _make_response([])
+    issues = await scanner.scan("https://example.com", response)
+    assert issues == []
+
+
+@pytest.mark.asyncio
+async def test_detects_missing_httponly():
+    response = _make_response(["session=abc123; Path=/; Secure; SameSite=Lax"])
+    issues = await scanner.scan("https://example.com", response)
+    assert any("HttpOnly" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_missing_secure():
+    response = _make_response(["session=abc123; Path=/; HttpOnly; SameSite=Lax"])
+    issues = await scanner.scan("https://example.com", response)
+    assert any("Secure" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_missing_samesite():
+    response = _make_response(["session=abc123; Path=/; HttpOnly; Secure"])
+    issues = await scanner.scan("https://example.com", response)
+    assert any("SameSite" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_samesite_none_without_secure():
+    response = _make_response(["session=abc123; Path=/; HttpOnly; SameSite=None"])
+    issues = await scanner.scan("https://example.com", response)
+    assert any("SameSite=None" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_secure_cookie_passes():
+    response = _make_response(["session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax"])
+    issues = await scanner.scan("https://example.com", response)
+    assert len(issues) == 0
+
+
+@pytest.mark.asyncio
+async def test_skips_secure_check_for_http():
+    response = _make_response(["session=abc123; Path=/; HttpOnly; SameSite=Lax"])
+    issues = await scanner.scan("http://example.com", response)
+    assert not any("Secure flag" in i.issue for i in issues)
diff --git a/tests/test_headers.py b/tests/test_headers.py
new file mode 100644
index 0000000..e27a965
--- /dev/null
+++ b/tests/test_headers.py
@@ -0,0 +1,94 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from app.services.scanner.headers import HeaderScanner
+
+scanner = HeaderScanner()
+
+
+def _make_response(headers: dict) -> MagicMock:
+    response = MagicMock()
+    response.headers = headers
+    return response
+
+
+@pytest.mark.asyncio
+async def test_detects_all_missing_headers():
+    response = _make_response({})
+    issues = await scanner.scan("https://example.com", response)
+    issue_texts = [i.issue for i in issues]
+    assert any("Content-Security-Policy" in t for t in issue_texts)
+    assert any("X-Frame-Options" in t for t in issue_texts)
+    assert any("X-Content-Type-Options" in t for t in issue_texts)
+    assert any("Referrer-Policy" in t for t in issue_texts)
+    assert any("Permissions-Policy" in t for t in issue_texts)
+    assert any("Cache-Control" in t for t in issue_texts)
+    assert any("COOP" in t for t in issue_texts)
+    assert any("CORP" in t for t in issue_texts)
+    assert any("COEP" in t for t in issue_texts)
+
+
+@pytest.mark.asyncio
+async def test_detects_unsafe_inline_csp():
+    response = _make_response({
+        "Content-Security-Policy": "default-src 'self' 'unsafe-inline'",
+        "X-Frame-Options": "DENY",
+        "X-Content-Type-Options": "nosniff",
+        "Referrer-Policy": "strict-origin",
+        "Permissions-Policy": "camera=()",
+        "Cache-Control": "no-store",
+        "Cross-Origin-Opener-Policy": "same-origin",
+        "Cross-Origin-Resource-Policy": "same-origin",
+        "Cross-Origin-Embedder-Policy": "require-corp",
+    })
+    issues = await scanner.scan("https://example.com", response)
+    assert any("unsafe-inline" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_unsafe_eval_csp():
+    response = _make_response({
+        "Content-Security-Policy": "default-src 'self' 'unsafe-eval'",
+        "X-Frame-Options": "DENY",
+        "X-Content-Type-Options": "nosniff",
+        "Referrer-Policy": "strict-origin",
+        "Permissions-Policy": "camera=()",
+        "Cache-Control": "no-store",
+        "Cross-Origin-Opener-Policy": "same-origin",
+        "Cross-Origin-Resource-Policy": "same-origin",
+        "Cross-Origin-Embedder-Policy": "require-corp",
+    })
+    issues = await scanner.scan("https://example.com", response)
+    assert any("unsafe-eval" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_server_disclosure():
+    response = _make_response({"Server": "Apache/2.4.41"})
+    issues = await scanner.scan("https://example.com", response)
+    assert any("Server header" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_x_powered_by():
+    response = _make_response({"X-Powered-By": "Express"})
+    issues = await scanner.scan("https://example.com", response)
+    assert any("X-Powered-By" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_no_issues_with_all_headers():
+    response = _make_response({
+        "Content-Security-Policy": "default-src 'self'",
+        "X-Frame-Options": "SAMEORIGIN",
+        "X-Content-Type-Options": "nosniff",
+        "Referrer-Policy": "strict-origin-when-cross-origin",
+        "Permissions-Policy": "geolocation=()",
+        "Cache-Control": "no-store",
+        "Cross-Origin-Opener-Policy": "same-origin",
+        "Cross-Origin-Resource-Policy": "same-origin",
+        "Cross-Origin-Embedder-Policy": "require-corp",
+    })
+    issues = await scanner.scan("https://example.com", response)
+    assert len(issues) == 0
diff --git a/tests/test_health.py b/tests/test_health.py
new file mode 100644
index 0000000..be648d7
--- /dev/null
+++ b/tests/test_health.py
@@ -0,0 +1,19 @@
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_root(async_client):
+    response = await async_client.get("/")
+    assert response.status_code == 200
+    data = response.json()
+    assert "running" in data["message"]
+
+
+@pytest.mark.asyncio
+async def test_health(async_client):
+    response = await async_client.get("/health")
+    assert response.status_code == 200
+    data = response.json()
+    assert data["status"] == "healthy"
+    assert "app" in data
+    assert "version" in data
diff --git a/tests/test_history.py b/tests/test_history.py
new file mode 100644
index 0000000..f98a8e0
--- /dev/null
+++ b/tests/test_history.py
@@ -0,0 +1,90 @@
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.scan import ScanResult
+
+
+async def _create_scan(user_id: str) -> None:
+    from tests.conftest import TestSessionLocal
+    async with TestSessionLocal() as session:
+        scan = ScanResult(
+            user_id=user_id,
+            url="https://example.com",
+            security_score=85,
+            layers={"Transport Layer": {"issues": 1, "status": "yellow"}},
+            issues=[{"issue": "Missing HSTS", "severity": "Warning", "layer": "Transport Layer", "fix": "Add HSTS"}],
+        )
+        session.add(scan)
+        await session.commit()
+        await session.refresh(scan)
+        return scan
+
+
+@pytest.mark.asyncio
+async def test_list_scans_empty(async_client, test_user, auth_headers):
+    response = await async_client.get("/scans", headers=auth_headers)
+    assert response.status_code == 200
+    data = response.json()
+    assert data["scans"] == []
+    assert data["total"] == 0
+
+
+@pytest.mark.asyncio
+async def test_list_scans_with_results(async_client, test_user, auth_headers):
+    scan = await _create_scan(test_user.id)
+
+    response = await async_client.get("/scans", headers=auth_headers)
+    assert response.status_code == 200
+    data = response.json()
+    assert data["total"] == 1
+    assert len(data["scans"]) == 1
+    assert data["scans"][0]["url"] == "https://example.com"
+    assert data["scans"][0]["security_score"] == 85
+
+
+@pytest.mark.asyncio
+async def test_list_scans_pagination(async_client, test_user, auth_headers):
+    for _ in range(5):
+        await _create_scan(test_user.id)
+
+    response = await async_client.get("/scans?page=1&per_page=2", headers=auth_headers)
+    data = response.json()
+    assert data["total"] == 5
+    assert len(data["scans"]) == 2
+    assert data["page"] == 1
+    assert data["per_page"] == 2
+
+
+@pytest.mark.asyncio
+async def test_get_scan_by_id(async_client, test_user, auth_headers):
+    scan = await _create_scan(test_user.id)
+
+    response = await async_client.get(f"/scans/{scan.id}", headers=auth_headers)
+    assert response.status_code == 200
+    data = response.json()
+    assert data["url"] == "https://example.com"
+    assert data["security_score"] == 85
+    assert len(data["issues"]) == 1
+
+
+@pytest.mark.asyncio
+async def test_get_scan_not_found(async_client, test_user, auth_headers):
+    response = await async_client.get("/scans/nonexistent", headers=auth_headers)
+    assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_delete_scan(async_client, test_user, auth_headers):
+    scan = await _create_scan(test_user.id)
+
+    response = await async_client.delete(f"/scans/{scan.id}", headers=auth_headers)
+    assert response.status_code == 204
+
+    response = await async_client.get(f"/scans/{scan.id}", headers=auth_headers)
+    assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_list_scans_unauthorized(async_client):
+    response = await async_client.get("/scans")
+    assert response.status_code == 401
diff --git a/tests/test_scan.py b/tests/test_scan.py
new file mode 100644
index 0000000..ad5e2d7
--- /dev/null
+++ b/tests/test_scan.py
@@ -0,0 +1,54 @@
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_scan_rejects_invalid_url(async_client):
+    response = await async_client.post("/scan", json={"url": "not-a-url"})
+    assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_scan_rejects_localhost(async_client):
+    response = await async_client.post("/scan", json={"url": "http://localhost:8000"})
+    assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_scan_rejects_private_ip(async_client):
+    response = await async_client.post("/scan", json={"url": "http://192.168.1.1"})
+    assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_scan_valid_url(async_client):
+    response = await async_client.post("/scan", json={"url": "https://example.com"})
+    assert response.status_code in (200, 502)
+    data = response.json()
+    assert "security_score" in data or "error" in data
+
+
+@pytest.mark.asyncio
+async def test_scan_missing_url(async_client):
+    response = await async_client.post("/scan", json={})
+    assert response.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_scan_saves_when_authenticated(async_client, test_user, auth_headers):
+    response = await async_client.post(
+        "/scan",
+        json={"url": "https://example.com"},
+        headers=auth_headers,
+    )
+    if response.status_code == 200:
+        data = response.json()
+        assert data["id"] is not None
+        assert data["created_at"] is not None
+
+
+@pytest.mark.asyncio
+async def test_scan_no_save_when_anonymous(async_client):
+    response = await async_client.post("/scan", json={"url": "https://example.com"})
+    if response.status_code == 200:
+        data = response.json()
+        assert data["id"] is None
diff --git a/tests/test_scoring.py b/tests/test_scoring.py
new file mode 100644
index 0000000..10b1538
--- /dev/null
+++ b/tests/test_scoring.py
@@ -0,0 +1,63 @@
+from app.schemas.scan import Issue
+from app.services.scoring import calculate_layer_statuses, calculate_score
+
+
+def test_perfect_score_no_issues():
+    assert calculate_score([]) == 100
+
+
+def test_critical_deduction():
+    issues = [Issue(issue="Test", severity="Critical", layer="Transport Layer", fix="Fix")]
+    assert calculate_score(issues) == 85
+
+
+def test_warning_deduction():
+    issues = [Issue(issue="Test", severity="Warning", layer="Transport Layer", fix="Fix")]
+    assert calculate_score(issues) == 95
+
+
+def test_info_deduction():
+    issues = [Issue(issue="Test", severity="Info", layer="Transport Layer", fix="Fix")]
+    assert calculate_score(issues) == 98
+
+
+def test_score_cannot_go_below_zero():
+    issues = [Issue(issue=f"Test {i}", severity="Critical", layer="Transport Layer", fix="Fix") for i in range(10)]
+    assert calculate_score(issues) == 0
+
+
+def test_all_layers_present():
+    statuses = calculate_layer_statuses([])
+    assert "Transport Layer" in statuses
+    assert "SSL/TLS Layer" in statuses
+    assert "Server Config Layer" in statuses
+    assert "Cookie Security" in statuses
+    assert "Exposure Layer" in statuses
+
+
+def test_layer_status_green_when_no_issues():
+    statuses = calculate_layer_statuses([])
+    for layer in statuses.values():
+        assert layer.status == "green"
+        assert layer.issues == 0
+
+
+def test_layer_status_yellow_for_few_issues():
+    issues = [
+        Issue(issue="Test 1", severity="Warning", layer="SSL/TLS Layer", fix="Fix"),
+        Issue(issue="Test 2", severity="Warning", layer="SSL/TLS Layer", fix="Fix"),
+    ]
+    statuses = calculate_layer_statuses(issues)
+    assert statuses["SSL/TLS Layer"].status == "yellow"
+    assert statuses["SSL/TLS Layer"].issues == 2
+
+
+def test_layer_status_red_for_many_issues():
+    issues = [
+        Issue(issue="Test 1", severity="Warning", layer="Cookie Security", fix="Fix"),
+        Issue(issue="Test 2", severity="Warning", layer="Cookie Security", fix="Fix"),
+        Issue(issue="Test 3", severity="Critical", layer="Cookie Security", fix="Fix"),
+    ]
+    statuses = calculate_layer_statuses(issues)
+    assert statuses["Cookie Security"].status == "red"
+    assert statuses["Cookie Security"].issues == 3
diff --git a/tests/test_ssl_checker.py b/tests/test_ssl_checker.py
new file mode 100644
index 0000000..e89e2dc
--- /dev/null
+++ b/tests/test_ssl_checker.py
@@ -0,0 +1,86 @@
+import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from app.services.scanner.ssl_checker import SSLScanner, _check_ssl
+
+scanner = SSLScanner()
+
+
+@pytest.mark.asyncio
+async def test_skips_http_urls():
+    response = MagicMock()
+    issues = await scanner.scan("http://example.com", response)
+    assert issues == []
+
+
+@pytest.mark.asyncio
+async def test_detects_self_signed():
+    response = MagicMock()
+    mock_result = {
+        "error": "self-signed certificate",
+        "cert": None,
+        "tls_version": "TLSv1.3",
+        "self_signed": True,
+    }
+    with patch("app.services.scanner.ssl_checker.asyncio.to_thread", return_value=mock_result):
+        issues = await scanner.scan("https://self-signed.example.com", response)
+    assert any("self-signed" in i.issue.lower() for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_weak_tls():
+    response = MagicMock()
+    future_date = (datetime.datetime.utcnow() + datetime.timedelta(days=365)).strftime("%b %d %H:%M:%S %Y GMT")
+    mock_result = {
+        "error": None,
+        "cert": {
+            "notAfter": future_date,
+            "subject": ((('commonName', 'example.com'),),),
+            "issuer": ((('commonName', 'CA'),),),
+        },
+        "tls_version": "TLSv1.1",
+        "self_signed": False,
+    }
+    with patch("app.services.scanner.ssl_checker.asyncio.to_thread", return_value=mock_result):
+        issues = await scanner.scan("https://example.com", response)
+    assert any("weak TLS" in i.issue.lower() or "tls" in i.issue.lower() for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_expiring_soon():
+    response = MagicMock()
+    soon_date = (datetime.datetime.utcnow() + datetime.timedelta(days=15)).strftime("%b %d %H:%M:%S %Y GMT")
+    mock_result = {
+        "error": None,
+        "cert": {
+            "notAfter": soon_date,
+            "subject": ((('commonName', 'example.com'),),),
+            "issuer": ((('commonName', 'CA'),),),
+        },
+        "tls_version": "TLSv1.3",
+        "self_signed": False,
+    }
+    with patch("app.services.scanner.ssl_checker.asyncio.to_thread", return_value=mock_result):
+        issues = await scanner.scan("https://example.com", response)
+    assert any("expires in" in i.issue.lower() for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_no_issues_for_valid_cert():
+    response = MagicMock()
+    future_date = (datetime.datetime.utcnow() + datetime.timedelta(days=365)).strftime("%b %d %H:%M:%S %Y GMT")
+    mock_result = {
+        "error": None,
+        "cert": {
+            "notAfter": future_date,
+            "subject": ((('commonName', 'example.com'),),),
+            "issuer": ((('commonName', 'Let\'s Encrypt'),),),
+        },
+        "tls_version": "TLSv1.3",
+        "self_signed": False,
+    }
+    with patch("app.services.scanner.ssl_checker.asyncio.to_thread", return_value=mock_result):
+        issues = await scanner.scan("https://example.com", response)
+    assert len(issues) == 0
diff --git a/tests/test_transport.py b/tests/test_transport.py
new file mode 100644
index 0000000..9a1fe0a
--- /dev/null
+++ b/tests/test_transport.py
@@ -0,0 +1,75 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from app.services.scanner.transport import TransportScanner
+
+scanner = TransportScanner()
+
+
+def _make_response(headers: dict) -> MagicMock:
+    response = MagicMock()
+    response.headers = headers
+    return response
+
+
+@pytest.mark.asyncio
+async def test_detects_no_https():
+    response = _make_response({})
+    issues = await scanner.scan("http://example.com", response)
+    assert any("HTTPS" in i.issue for i in issues)
+    assert len(issues) == 1
+
+
+@pytest.mark.asyncio
+async def test_detects_missing_hsts():
+    response = _make_response({})
+    issues = await scanner.scan("https://example.com", response)
+    assert any("HSTS" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_short_hsts_max_age():
+    response = _make_response({
+        "Strict-Transport-Security": "max-age=3600; includeSubDomains; preload"
+    })
+    issues = await scanner.scan("https://example.com", response)
+    assert any("max-age" in i.issue.lower() for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_missing_includesubdomains():
+    response = _make_response({
+        "Strict-Transport-Security": "max-age=31536000; preload"
+    })
+    issues = await scanner.scan("https://example.com", response)
+    assert any("includeSubDomains" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_missing_preload():
+    response = _make_response({
+        "Strict-Transport-Security": "max-age=31536000; includeSubDomains"
+    })
+    issues = await scanner.scan("https://example.com", response)
+    assert any("preload" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_detects_missing_upgrade_insecure_requests():
+    response = _make_response({
+        "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
+        "Content-Security-Policy": "default-src 'self'",
+    })
+    issues = await scanner.scan("https://example.com", response)
+    assert any("upgrade-insecure-requests" in i.issue for i in issues)
+
+
+@pytest.mark.asyncio
+async def test_good_hsts_no_transport_issues():
+    response = _make_response({
+        "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
+        "Content-Security-Policy": "default-src 'self'; upgrade-insecure-requests",
+    })
+    issues = await scanner.scan("https://example.com", response)
+    assert len(issues) == 0
diff --git a/tests/test_validators.py b/tests/test_validators.py
new file mode 100644
index 0000000..a5814dc
--- /dev/null
+++ b/tests/test_validators.py
@@ -0,0 +1,50 @@
+import pytest
+from fastapi import HTTPException
+
+from app.utils.validators import validate_url
+
+
+def test_valid_https_url():
+    result = validate_url("https://example.com")
+    assert result == "https://example.com"
+
+
+def test_valid_http_url():
+    result = validate_url("http://example.com")
+    assert result == "http://example.com"
+
+
+def test_rejects_ftp_scheme():
+    with pytest.raises(HTTPException) as exc_info:
+        validate_url("ftp://example.com")
+    assert exc_info.value.status_code == 400
+
+
+def test_rejects_no_scheme():
+    with pytest.raises(HTTPException) as exc_info:
+        validate_url("example.com")
+    assert exc_info.value.status_code == 400
+
+
+def test_rejects_localhost():
+    with pytest.raises(HTTPException) as exc_info:
+        validate_url("http://localhost")
+    assert exc_info.value.status_code == 400
+
+
+def test_rejects_private_ip():
+    with pytest.raises(HTTPException) as exc_info:
+        validate_url("http://192.168.1.1")
+    assert exc_info.value.status_code == 400
+
+
+def test_rejects_loopback():
+    with pytest.raises(HTTPException) as exc_info:
+        validate_url("http://127.0.0.1")
+    assert exc_info.value.status_code == 400
+
+
+def test_rejects_unresolvable_host():
+    with pytest.raises(HTTPException) as exc_info:
+        validate_url("http://this-domain-does-not-exist-xyz123.com")
+    assert exc_info.value.status_code == 400