diff --git a/backend/app/main.py b/backend/app/main.py index 66168de..b29c631 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ from __future__ import annotations +from contextlib import asynccontextmanager import asyncio import functools @@ -6,6 +7,7 @@ import logging import os import random +import secrets import re import shutil import tempfile @@ -19,16 +21,19 @@ import httpx from fastapi import ( BackgroundTasks, + Depends, FastAPI, File, Form, HTTPException, Query, Request, + Security, UploadFile, ) from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.responses import FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field @@ -76,7 +81,9 @@ from .utils.fs import ensure_dir, safe_job_dir, safe_rmtree, unzip_to_dir _MAX_UPLOAD_MB_RAW = os.environ.get("MAX_UPLOAD_MB") -RANKER = load_ranker() + +# 1. Initialize RANKER as a global placeholder variable +RANKER = None try: MAX_UPLOAD_MB = int(_MAX_UPLOAD_MB_RAW) if _MAX_UPLOAD_MB_RAW else 100 @@ -87,7 +94,53 @@ MAX_UPLOAD_SIZE = MAX_UPLOAD_MB * 1024 * 1024 logger = logging.getLogger(__name__) -app = FastAPI(title="PatchPilot API", version="0.1.0") + +# 2. Define the modern Lifespan Context Manager +@asynccontextmanager +async def lifespan(app: FastAPI): + global RANKER + logger.info("Initializing database...") + await init_db() + + logger.info("Loading ML ranker model into memory...") + RANKER = load_ranker() + + yield # The server runs and processes requests while here + + # Clean up resources on shutdown + logger.info("Shutting down application. Clearing ML models from memory...") + RANKER = None + +# 3. Initialize FastAPI and attach the lifespan manager +app = FastAPI(title="PatchPilot API", version="0.1.0", lifespan=lifespan) + +security_scheme = HTTPBearer() + +API_KEY = os.environ.get("PATCHPILOT_API_KEY", "") + +async def verify_api_key( + credentials: HTTPAuthorizationCredentials = Security(security_scheme), +): + """ + Validates incoming Bearer token. + """ + + if not API_KEY: + logger.warning( + "PATCHPILOT_API_KEY environment variable is not configured." + ) + raise HTTPException( + status_code=500, + detail="Internal server error: Security layer not initialized.", + ) + + if credentials.credentials != API_KEY: + raise HTTPException( + status_code=401, + detail="Unauthorized: Invalid API token.", + ) + + return credentials.credentials ALLOWED_ORIGINS = [ "http://localhost:5173", @@ -120,12 +173,6 @@ ) ensure_dir(WORK_ROOT) - -@app.on_event("startup") -async def startup(): - await init_db() - - @app.get("/health") def health(): scanners = { @@ -639,7 +686,7 @@ async def scan( detail=f"Header indicates file is too large. Maximum upload size is {MAX_UPLOAD_MB}MB.", ) - job_id = next(tempfile._get_candidate_names()) + job_id = secrets.token_urlsafe(16) job_dir = WORK_ROOT / job_id ensure_dir(job_dir) archive_path = job_dir / project.filename @@ -718,7 +765,7 @@ async def scan_url( is returned immediately for tracking scan progress and retrieving results. """ - job_id = next(tempfile._get_candidate_names()) + job_id = secrets.token_urlsafe(16) job_dir = WORK_ROOT / job_id ensure_dir(job_dir) archive_path = job_dir / "repo.zip" @@ -744,7 +791,10 @@ async def scan_url( @app.get("/api/scans/{job_id}/stream") -async def stream_single_scan_status(job_id: str): +async def stream_single_scan_status( + job_id: str, + token: str = Depends(verify_api_key), +): async def event_generator(): while True: if job_id not in ACTIVE_SCANS: @@ -1013,6 +1063,7 @@ def evidence_pack( False, description="If true, refreshes the raw scan results before generating the evidence package.", ), + token: str = Depends(verify_api_key), ): """ Generate a downloadable evidence package for a completed scan. @@ -1106,7 +1157,10 @@ async def download_audit_pdf(job_id: str): @app.get("/jobs/{job_id}/findings") -async def get_findings(job_id: str): +async def get_findings( + job_id: str, + token: str = Depends(verify_api_key), +): db = await get_db() try: job_row = await get_job(db, job_id) @@ -1234,7 +1288,10 @@ async def get_verify(job_id: str): @app.delete("/jobs/{job_id}") -async def delete_job_endpoint(job_id: str): +async def delete_job_endpoint( + job_id: str, + token: str = Depends(verify_api_key), +): try: job_dir = safe_job_dir(WORK_ROOT, job_id) except ValueError as e: @@ -1509,7 +1566,7 @@ async def _run_org_batch(org_job_id: str, repos: List[dict]): repo_url = r["html_url"] ref = r["default_branch"] project_name = r["name"] - job_id = next(tempfile._get_candidate_names()) + job_id = secrets.token_urlsafe(16) db = await get_db() try: diff --git a/frontend/src/app/lib/api.ts b/frontend/src/app/lib/api.ts index b2ef971..ca69f4e 100644 --- a/frontend/src/app/lib/api.ts +++ b/frontend/src/app/lib/api.ts @@ -2,6 +2,18 @@ export const API_BASE = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, "") || "http://localhost:8000"; +// 1. Pull the token from Vite's env variables +export const API_KEY = import.meta.env.VITE_PATCHPILOT_API_KEY || ""; + +// 2. Utility to cleanly inject the Authorization header +function getAuthHeaders(customHeaders: Record = {}): Record { + const headers = { ...customHeaders }; + if (API_KEY) { + headers["Authorization"] = `Bearer ${API_KEY}`; + } + return headers; +} + export type HealthResponse = { ok: boolean; status: "healthy" | "degraded"; @@ -9,7 +21,7 @@ export type HealthResponse = { }; export async function getHealth() { - const res = await fetch(`${API_BASE}/health`); + const res = await fetch(`${API_BASE}/health`, { headers: getAuthHeaders() }); if (!res.ok) { throw new Error(await res.text()); @@ -45,11 +57,11 @@ export type BackendFinding = { }; reachability?: { - reachable?: boolean; - reason?: string; -}; + reachable?: boolean; + reason?: string; + }; -features?: Record; + features?: Record; confidence?: number; code?: string; suggested_fix?: string; @@ -72,6 +84,7 @@ export async function scanZip(file: File, projectName = "project") { const res = await fetch(`${API_BASE}/scan`, { method: "POST", + headers: getAuthHeaders(), body: form, }); @@ -91,6 +104,7 @@ export async function scanRepoUrl( const res = await fetch(`${API_BASE}/scan-url`, { method: "POST", + headers: getAuthHeaders(), body: form, }); @@ -101,8 +115,11 @@ export async function scanRepoUrl( return (await res.json()) as ScanInitResponse; } + export async function getJobFindings(jobId: string): Promise { - const res = await fetch(`${API_BASE}/jobs/${jobId}/findings`); + const res = await fetch(`${API_BASE}/jobs/${jobId}/findings`, { + headers: getAuthHeaders(), + }); if (!res.ok) throw new Error(await res.text()); return (await res.json()) as BackendFinding[]; } @@ -115,7 +132,7 @@ export async function labelFinding( ) { const res = await fetch(`${API_BASE}/findings/${findingId}/label`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: getAuthHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ false_positive: falsePositive, expected_version: expectedVersion }), signal, }); @@ -131,7 +148,7 @@ export async function labelFinding( export async function updateFindingStatus(findingId: string, status: "open" | "accepted" | "ignored") { const res = await fetch(`${API_BASE}/findings/${findingId}/status`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: getAuthHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ status }), }); @@ -142,7 +159,7 @@ export async function updateFindingStatus(findingId: string, status: "open" | "a export async function fix(jobId: string, findingIds: string[]) { const res = await fetch(`${API_BASE}/fix`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: getAuthHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ job_id: jobId, finding_ids: findingIds }), }); @@ -154,15 +171,15 @@ export async function verify(jobId: string) { const form = new FormData(); form.append("job_id", jobId); - const res = await fetch(`${API_BASE}/verify`, { method: "POST", body: form }); + const res = await fetch(`${API_BASE}/verify`, { + method: "POST", + headers: getAuthHeaders(), + body: form + }); if (!res.ok) throw new Error(await res.text()); return res.json(); } -/** - * POST /evidence-pack -> returns a ZIP (FileResponse) - * This fetches it as a Blob and tries to infer a filename from Content-Disposition. - */ export async function downloadEvidencePack( jobId: string, projectName = "project", @@ -173,6 +190,7 @@ export async function downloadEvidencePack( const res = await fetch(`${API_BASE}/evidence-pack`, { method: "POST", + headers: getAuthHeaders(), body: form, }); @@ -181,7 +199,6 @@ export async function downloadEvidencePack( } const blob = await res.blob(); - const cd = res.headers.get("content-disposition") || ""; const match = cd.match(/filename="?([^"]+)"?/i); const filename = match?.[1] || `evidence-pack-${jobId}.zip`; @@ -195,7 +212,9 @@ export type TrendData = { }; export async function getTrends(limit = 6) { - const res = await fetch(`${API_BASE}/trends?limit=${limit}`); + const res = await fetch(`${API_BASE}/trends?limit=${limit}`, { + headers: getAuthHeaders(), + }); if (!res.ok) throw new Error(await res.text()); return (await res.json()) as TrendData[]; } @@ -206,7 +225,9 @@ export type CweData = { }; export async function getCweDistribution() { - const res = await fetch(`${API_BASE}/cwe-distribution`); + const res = await fetch(`${API_BASE}/cwe-distribution`, { + headers: getAuthHeaders(), + }); if (!res.ok) throw new Error(await res.text()); return (await res.json()) as CweData[]; } @@ -227,7 +248,9 @@ export interface DependencyDiffResult { } export const getDependencyDiff = async (): Promise => { - const response = await fetch(`${API_BASE}/dependency-diff`); + const response = await fetch(`${API_BASE}/dependency-diff`, { + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error("Failed to fetch dependency diff"); } @@ -251,7 +274,9 @@ export interface LeaderboardUpdateRequest { } export async function getLeaderboard(): Promise { - const res = await fetch(`${API_BASE}/leaderboard`); + const res = await fetch(`${API_BASE}/leaderboard`, { + headers: getAuthHeaders(), + }); if (!res.ok) throw new Error(await res.text()); return res.json(); } @@ -259,7 +284,7 @@ export async function getLeaderboard(): Promise { export async function updateLeaderboard(data: LeaderboardUpdateRequest) { const res = await fetch(`${API_BASE}/leaderboard/update`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: getAuthHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(data), }); if (!res.ok) throw new Error(await res.text()); @@ -267,7 +292,9 @@ export async function updateLeaderboard(data: LeaderboardUpdateRequest) { } export async function downloadAuditReport(jobId: string) { - const res = await fetch(`${API_BASE}/api/scans/${jobId}/report/pdf`); + const res = await fetch(`${API_BASE}/api/scans/${jobId}/report/pdf`, { + headers: getAuthHeaders(), + }); if (!res.ok) { throw new Error(await res.text()); @@ -292,7 +319,7 @@ export type OrgJobStatusResponse = { export async function scanOrganization(orgUrl: string) { const res = await fetch(`${API_BASE}/api/scans/org`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: getAuthHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ org_url: orgUrl }), }); @@ -304,7 +331,9 @@ export async function scanOrganization(orgUrl: string) { } export async function getOrgJobStatus(orgJobId: string) { - const res = await fetch(`${API_BASE}/api/scans/org/${orgJobId}/status`); + const res = await fetch(`${API_BASE}/api/scans/org/${orgJobId}/status`, { + headers: getAuthHeaders(), + }); if (!res.ok) { throw new Error(await res.text()); @@ -316,32 +345,38 @@ export async function getOrgJobStatus(orgJobId: string) { export const abortOrganizationScan = async (orgJobId: string, mode: "pending" | "force" = "pending") => { const response = await fetch(`${API_BASE}/api/scans/org/${orgJobId}/abort?mode=${mode}`, { method: "POST", + headers: getAuthHeaders(), }); if (!response.ok) throw new Error("Failed to abort scan"); return response.json(); }; export async function getOrgSummary(orgJobId: string) { - const res = await fetch(`${API_BASE}/api/scans/org/${orgJobId}/summary`); + const res = await fetch(`${API_BASE}/api/scans/org/${orgJobId}/summary`, { + headers: getAuthHeaders(), + }); if (!res.ok) throw new Error("Failed to fetch organization summary"); return res.json(); } export async function getOrgFindings(orgJobId: string) { - const res = await fetch(`${API_BASE}/api/scans/org/${orgJobId}/findings`); + const res = await fetch(`${API_BASE}/api/scans/org/${orgJobId}/findings`, { + headers: getAuthHeaders(), + }); if (!res.ok) throw new Error("Failed to fetch organization findings"); return res.json(); } export async function downloadOrgAuditReport(orgJobId: string) { - const res = await fetch(`${API_BASE}/api/scans/org/${orgJobId}/report/pdf`); + const res = await fetch(`${API_BASE}/api/scans/org/${orgJobId}/report/pdf`, { + headers: getAuthHeaders(), + }); if (!res.ok) { throw new Error(await res.text()); } const blob = await res.blob(); - const cd = res.headers.get("content-disposition") || ""; const match = cd.match(/filename="?([^"]+)"?/i); const filename = match?.[1] || `PatchPilot-Org-Audit-${orgJobId}.pdf`; @@ -351,7 +386,9 @@ export async function downloadOrgAuditReport(orgJobId: string) { export const getOrgBlastRadius = async (orgJobId: string) => { const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; - const response = await fetch(`${baseUrl}/api/scans/org/${orgJobId}/blast-radius`); + const response = await fetch(`${baseUrl}/api/scans/org/${orgJobId}/blast-radius`, { + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error('Failed to fetch blast radius data'); @@ -366,7 +403,9 @@ export interface OllamaHealthResponse { } export async function getOllamaHealth(): Promise { - const res = await fetch(`${API_BASE}/api/health/ollama`); + const res = await fetch(`${API_BASE}/api/health/ollama`, { + headers: getAuthHeaders(), + }); if (!res.ok) { throw new Error(await res.text()); }