diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md index dd81278..2c11349 100644 --- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md @@ -32,7 +32,7 @@ This phase protects the analysis service from abuse with per-IP rate limiting an - [x] T041 [US6] Create `backend/app/middleware/rate_limiter.py` — per-IP sliding window rate limiter (async-safe in-memory). Configurable limit via `config.py`. Returns 429 with Retry-After header and CAPTCHA challenge token (NFR-11, NFR-12). Note: per-instance counters — acceptable for initial release; shared store upgradeable later. Verify `test_rate_limiter.py` passes (TDD Green) - [x] T042 [US6] Create `backend/app/routers/captcha.py` — `POST /api/captcha/verify` endpoint. Server-generated visual noise CAPTCHA (randomly distorted text). Returns HMAC-signed bypass token (5-minute expiry) on success. Token exempts IP from rate limiting. Response schema in `backend/app/schemas/captcha.py`. Verify `test_captcha.py` passes (TDD Green) - [x] T043 [P] [US6] Create `frontend/src/components/CaptchaChallenge.tsx` — modal on 429 response. Displays CAPTCHA image, on verification stores bypass token and retries original request. FontAwesome lock/unlock icons. Keyboard accessible (NFR-02). Verify `CaptchaChallenge.test.tsx` passes (TDD Green) -- [ ] T044 [US6] Create `backend/app/schemas/health.py` and `backend/app/routers/health.py` — `GET /api/health` returning status (up/degraded/down), version, uptime, scanner count (NFR-15). Verify `test_health.py` passes (TDD Green) +- [x] T044 [US6] Create `backend/app/schemas/health.py` and `backend/app/routers/health.py` — `GET /api/health` returning status (up/degraded/down), version, uptime, scanner count (NFR-15). Verify `test_health.py` passes (TDD Green) - [ ] T045 [US6] Register all routers and middleware in `backend/app/main.py` — CORS middleware (frontend origin), rate limiter, routers (analysis, tests, health, captcha). Verify stateless operation (NFR-16). Note: rate limiter per-instance state is accepted trade-off (see T041) ## Completion diff --git a/backend/app/main.py b/backend/app/main.py index 85b73be..84785e9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from app.core.config import get_settings from app.middleware.rate_limiter import RateLimiterMiddleware, SlidingWindowRateLimiter from app.routers.analysis import router as analysis_router from app.routers.captcha import router as captcha_router +from app.routers.health import router as health_router from app.routers.tests import router as tests_router app = FastAPI(title="Web Header Analyzer API") @@ -16,6 +17,7 @@ app.add_middleware( ) app.include_router(analysis_router) app.include_router(captcha_router) +app.include_router(health_router) app.include_router(tests_router) diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..b092ead --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os +import time +import tomllib +from importlib import metadata +from pathlib import Path + +from fastapi import APIRouter + +from app.engine.scanner_registry import ScannerRegistry +from app.schemas.health import HealthResponse + +router = APIRouter(prefix="/api", tags=["health"]) + +_START_TIME = time.monotonic() +_PROJECT_NAME = "web-header-analyzer-backend" + + +@router.get("/health", response_model=HealthResponse) +def health_check() -> HealthResponse: + status = "up" + scanner_count = 0 + + try: + registry = ScannerRegistry() + scanner_count = len(registry.list_tests()) + if scanner_count == 0: + status = "degraded" + except Exception: + status = "down" + scanner_count = 0 + + return HealthResponse( + status=status, + version=_resolve_version(), + uptime=_uptime_seconds(), + scanner_count=scanner_count, + ) + + +def _uptime_seconds() -> float: + return max(0.0, time.monotonic() - _START_TIME) + + +def _resolve_version() -> str: + env_version = os.getenv("WHA_VERSION", "").strip() + if env_version: + return env_version + + try: + return metadata.version(_PROJECT_NAME) + except metadata.PackageNotFoundError: + pass + + pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml" + if pyproject_path.exists(): + try: + data = pyproject_path.read_text(encoding="utf-8") + except OSError: + data = "" + if data: + try: + parsed = tomllib.loads(data) + except ValueError: + parsed = {} + version = ( + parsed.get("project", {}).get("version") + if isinstance(parsed, dict) + else None + ) + if isinstance(version, str) and version.strip(): + return version.strip() + + return "unknown" diff --git a/backend/app/schemas/health.py b/backend/app/schemas/health.py new file mode 100644 index 0000000..9308a26 --- /dev/null +++ b/backend/app/schemas/health.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class HealthResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + status: Literal["up", "degraded", "down"] + version: str + uptime: float + scanner_count: int = Field(alias="scannerCount") + + +__all__ = ["HealthResponse"]