diff --git a/logic.py b/logic.py deleted file mode 100644 index 2bc6a2a..0000000 --- a/logic.py +++ /dev/null @@ -1,159 +0,0 @@ -import re -from datetime import datetime - -# Improved Pattern: Handles 2026-04-11, 2026/04/11, 2026.04.11 -# and separators like 'T' or space. -TIMESTAMP_PATTERN = r'(\d{4}[-/.]\d{2}[-/.]\d{2}[T ]\d{2}:\d{2}:\d{2})' - -def parse_any_date(date_str): - """Attempts to parse different date formats found in logs.""" - formats = [ - '%Y-%m-%dT%H:%M:%S', # ISO format - '%Y-%m-%d %H:%M:%S', # Standard format - '%Y/%m/%d %H:%M:%S', # Alternate slashes - '%Y.%m.%d %H:%M:%S' # Dot separators - ] - for fmt in formats: - try: - return datetime.strptime(date_str[:19], fmt) - except ValueError: - continue - return None - - -def analyze_logs(file_path, threshold_seconds=60, use_24_hour=True,progress_callback=None): - """ - Analyzes log file for temporal gaps and categorizes anomalies. - Allows toggling between 12-hour and 24-hour time formats. - """ - - # STEP 1: Decide time format based on toggle - if progress_callback: - progress_callback(10,"step1:deciding time format..") - if use_24_hour: - time_format = '%Y-%m-%d %H:%M:%S' - else: - time_format = '%Y-%m-%d %I:%M:%S %p' - - incidents = [] - last_time = None - - # Ensure threshold is an integer - try: - threshold_seconds = int(threshold_seconds) - except (ValueError, TypeError): - threshold_seconds = 60 - - with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: - for line in f: - match = re.search(TIMESTAMP_PATTERN, line) - if match: - current_time = parse_any_date(match.group(1)) - - if current_time and last_time: - gap = (current_time - last_time).total_seconds() - - if gap > threshold_seconds: - # --- DYNAMIC ANOMALY CATEGORIZATION --- - if gap > 3600: - reason = "Extended System Blackout / High Risk Log Erasure" - severity = "CRITICAL" - elif gap > 600: - reason = "Significant Data Void / Potential Tampering" - severity = "CRITICAL" - elif gap > 300: - reason = "Service Interruption Detected" - severity = "WARNING" - else: - reason = "Minor Sequential Lag" - severity = "WARNING" - - # STEP 2: Use dynamic format - if progress_callback: - progress_callback(60, "step 2 :detecting gaps in logs..") - incidents.append({ - "start": last_time.strftime(time_format), - "end": current_time.strftime(time_format), - "duration": int(gap), - "severity": severity, - "details": reason - }) - - if current_time: - last_time = current_time - if progress_callback: - progress_callback(80,"step 3 : calculating intergrity score..") - score = calculate_integrity_score(incidents) - if progress_callback: - progress_callback(100,"Analysis completed.") - return { - "total_gaps": len(incidents), - "integrity_score": round(score, 2), - "incidents": incidents - } - - -def calculate_integrity_score(incidents): - """ - Weighted integrity scoring model. - - Replaces the naive `100 - (gap_count * 5)` formula with a hybrid metric - that accounts for: - - Total missing time (penalises long blackouts) - - Longest single gap (catches single catastrophic voids) - - Gap frequency relative to log span (distribution awareness) - - Severity-tiered per-gap penalties (threshold-based) - - Score is clamped to [0, 100]. - """ - if not incidents: - return 100.0 - - durations = [inc["duration"] for inc in incidents] - gap_count = len(durations) - total_gap_time = sum(durations) # seconds - longest_gap = max(durations) - avg_gap = total_gap_time / gap_count - - # ── 1. FREQUENCY PENALTY (replaces flat -5 per gap) ───────────────────── - # Short gaps hurt less; many gaps still accumulate pressure. - # Penalty per gap scales with average gap size. - if avg_gap < 120: # < 2 min → minor - per_gap_penalty = 1.0 - elif avg_gap < 600: # < 10 min → moderate - per_gap_penalty = 3.0 - elif avg_gap < 3600: # < 1 hr → significant - per_gap_penalty = 6.0 - else: # ≥ 1 hr → critical - per_gap_penalty = 10.0 - - frequency_penalty = min(40.0, gap_count * per_gap_penalty) - - # ── 2. TOTAL DURATION PENALTY ──────────────────────────────────────────── - # Every hour of missing data costs ~10 points, capped at 35. - hours_missing = total_gap_time / 3600 - duration_penalty = min(35.0, hours_missing * 10.0) - - # ── 3. LONGEST GAP PENALTY ─────────────────────────────────────────────── - # A single massive void is a strong tampering signal. - if longest_gap >= 86400: # ≥ 24 hrs - longest_penalty = 25.0 - elif longest_gap >= 3600: # ≥ 1 hr - longest_penalty = 15.0 - elif longest_gap >= 600: # ≥ 10 min - longest_penalty = 8.0 - elif longest_gap >= 300: # ≥ 5 min - longest_penalty = 4.0 - else: - longest_penalty = 1.0 - - # ── 4. CLUSTERING PENALTY ──────────────────────────────────────────────── - # Many gaps in a short span suggests systematic deletion. - # Proxy: if gap_count > 10 and avg_gap < 5 min, add extra pressure. - clustering_penalty = 0.0 - if gap_count > 10 and avg_gap < 300: - clustering_penalty = min(10.0, (gap_count - 10) * 0.5) - - total_penalty = frequency_penalty + duration_penalty + longest_penalty + clustering_penalty - score = max(0.0, 100.0 - total_penalty) - return round(score, 2) \ No newline at end of file diff --git a/logic_simplified.py b/logic_simplified.py new file mode 100644 index 0000000..e819930 --- /dev/null +++ b/logic_simplified.py @@ -0,0 +1,129 @@ +import re +from datetime import datetime + +TIMESTAMP_PATTERN = r'(\d{4}[-/.]\d{2}[-/.]\d{2}[T ]\d{2}:\d{2}:\d{2})' +DATE_FORMATS = [ + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%d %H:%M:%S', + '%Y/%m/%d %H:%M:%S', + '%Y.%m.%d %H:%M:%S', +] + + +def parse_any_date(date_str): + for fmt in DATE_FORMATS: + try: + return datetime.strptime(date_str[:19], fmt) + except ValueError: + continue + return None + + +def extract_timestamp(line: str): + match = re.search(TIMESTAMP_PATTERN, line) + return parse_any_date(match.group(1)) if match else None + + + +def normalize_threshold(value): + try: + + return max(1, int(value)) + except (ValueError, TypeError): + return 60 + + +def classify_gap(gap: float): + + if gap > 3600: + return "CRITICAL", "Extended System Blackout / High Risk Log Erasure" + if gap > 600: + return "CRITICAL", "Significant Data Void / Potential Tampering" + if gap > 300: + return "WARNING", "Service Interruption Detected" + return "WARNING", "Minor Sequential Lag" + + +def clamp_score(value: float) -> float: + return max(0.0, min(100.0, value)) + + +def analyze_logs(file_path, threshold_seconds=60, use_24_hour=True, progress_callback=None): + if progress_callback: + progress_callback(0, "Starting analysis") + + threshold_seconds = normalize_threshold(threshold_seconds) + time_format = '%Y-%m-%d %H:%M:%S' if use_24_hour else '%Y-%m-%d %I:%M:%S %p' + incidents = [] + last_time = None + + with open(file_path, 'r', encoding='utf-8', errors='ignore') as handle: + for line in handle: + current_time = extract_timestamp(line) + if not current_time: + continue + + if last_time is not None: + gap = (current_time - last_time).total_seconds() + if gap > threshold_seconds: + severity, details = classify_gap(gap) + incidents.append( + { + "start": last_time.strftime(time_format), + "end": current_time.strftime(time_format), + "duration": int(gap), + "severity": severity, + "details": details, + } + ) + + last_time = current_time + + score = calculate_integrity_score(incidents) + if progress_callback: + progress_callback(100, "Analysis completed.") + + return { + "total_gaps": len(incidents), + "integrity_score": score, + "incidents": incidents, + } + + +def calculate_integrity_score(incidents): + if not incidents: + return 100.0 + + durations = [inc["duration"] for inc in incidents] + total_gap_time = sum(durations) + longest_gap = max(durations) + average_gap = total_gap_time / len(durations) + + if average_gap < 120: + per_gap_penalty = 1.0 + elif average_gap < 600: + per_gap_penalty = 3.0 + elif average_gap < 3600: + per_gap_penalty = 6.0 + else: + per_gap_penalty = 10.0 + + frequency_penalty = min(40.0, len(durations) * per_gap_penalty) + duration_penalty = min(35.0, total_gap_time / 3600 * 10.0) + + if longest_gap >= 86400: + longest_penalty = 25.0 + elif longest_gap >= 3600: + longest_penalty = 15.0 + elif longest_gap >= 600: + longest_penalty = 8.0 + elif longest_gap >= 300: + longest_penalty = 4.0 + else: + longest_penalty = 1.0 + + clustering_penalty = 0.0 + if len(durations) > 10 and average_gap < 300: + clustering_penalty = min(10.0, (len(durations) - 10) * 0.5) + + return clamp_score(100.0 - (frequency_penalty + duration_penalty + longest_penalty + clustering_penalty)) diff --git a/main.py b/main_simplified.py similarity index 52% rename from main.py rename to main_simplified.py index bb24c61..a2cf15b 100644 --- a/main.py +++ b/main_simplified.py @@ -1,27 +1,23 @@ -from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends, Request -from fastapi.responses import HTMLResponse,StreamingResponse +from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile, Depends +from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address from jose import JWTError, jwt import bcrypt from datetime import datetime, timedelta from dotenv import load_dotenv -import shutil -import os -import asyncio -import sys +import io import json import logging -import uuid import magic # python-magic for MIME sniffing -import io +import os +import uuid import zipfile -load_dotenv() -# ─── LOGGING ───────────────────────────────────────────────────────────────── +load_dotenv() logging.basicConfig( level=logging.INFO, @@ -29,17 +25,12 @@ ) logger = logging.getLogger("evidence_protector") -# ─── STARTUP SECRET VALIDATION ─────────────────────────────────────────────── -# Refuse to start if SECRET_KEY is missing or is a known insecure default. -# This prevents silent JWT forgery when the env var is not configured. - _SECRET_KEY = os.getenv("SECRET_KEY", "") _INSECURE_DEFAULTS = { "", "fallback-secret-change-me", "fallback-secret-change-me-in-production", "change-this-to-a-long-random-secret-in-production", - "change-this-to-a-long-random-secret-in-production", "REPLACE_WITH_A_STRONG_RANDOM_SECRET", } if _SECRET_KEY in _INSECURE_DEFAULTS: @@ -53,8 +44,6 @@ ) sys.exit(1) -# ─── FILE VALIDATION CONSTANTS ─────────────────────────────────────────────── - ALLOWED_EXTENSIONS = {".log", ".txt", ".csv", ".json", ".xml", ".syslog", ".evtx"} ALLOWED_MIME_TYPES = { "text/plain", @@ -63,24 +52,19 @@ "application/xml", "text/xml", "text/x-log", - "application/octet-stream", # fallback for .log/.evtx on some systems + "application/octet-stream", } -MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50 MB - -# ─── AUTH CONFIGURATION ────────────────────────────────────────────────────── - -# Reuse the already-validated value — no insecure fallback +MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 SECRET_KEY = _SECRET_KEY ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 60)) USERS_FILE = "users.json" - -# ─── RATE LIMITER ──────────────────────────────────────────────────────────── +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +UPLOAD_DIR = os.path.join(BASE_DIR, "uploads") +HTML_DIR = os.path.join(BASE_DIR, "html") limiter = Limiter(key_func=get_remote_address) -# ─── APP ───────────────────────────────────────────────────────────────────── - try: from logic import analyze_logs except ImportError: @@ -89,7 +73,6 @@ app = FastAPI(title="Evidence Protector Pro") app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -98,81 +81,126 @@ allow_headers=["*"], ) -# ─── EXCEPTION HANDLERS ────────────────────────────────────────────────────── +os.makedirs(UPLOAD_DIR, exist_ok=True) + -@app.exception_handler(404) -async def custom_404_handler(request: Request, __): +def load_html_template(name: str) -> str: + path = os.path.join(HTML_DIR, name) + with open(path, "r", encoding="utf-8") as file: + return file.read() + + +def html_error_response(status_code: int, fallback: str) -> HTMLResponse: try: - base_path = os.path.dirname(os.path.abspath(__file__)) - file_path = os.path.join(base_path, "html", "404.html") - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - return HTMLResponse(content=content, status_code=404) - except Exception: - return HTMLResponse(content="

404 | Data Void Detected

", status_code=404) + return HTMLResponse(load_html_template("404.html"), status_code=status_code) + except OSError: + return HTMLResponse(content=fallback, status_code=status_code) -@app.exception_handler(Exception) -async def custom_exception_handler(request: Request, exc: Exception): - logger.error("CRITICAL SYSTEM ERROR: %s", exc) + +def read_json(path: str, default=None): try: - base_path = os.path.dirname(os.path.abspath(__file__)) - file_path = os.path.join(base_path, "html", "404.html") - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - return HTMLResponse(content=content, status_code=500) - except Exception: - return HTMLResponse(content="

500 | System Breach Detected

", status_code=500) + with open(path, "r", encoding="utf-8") as file: + return json.load(file) + except (OSError, ValueError): + return default -# ─── UPLOAD DIR ────────────────────────────────────────────────────────────── -UPLOAD_DIR = "uploads" -os.makedirs(UPLOAD_DIR, exist_ok=True) +def write_json(path: str, data): + with open(path, "w", encoding="utf-8") as file: + json.dump(data, file, indent=4) -# ─── USER STORE ────────────────────────────────────────────────────────────── def load_users() -> dict: - if not os.path.exists(USERS_FILE): - default_hash = bcrypt.hashpw("admin123".encode(), bcrypt.gensalt()).decode() - default_users = {"admin": default_hash} - with open(USERS_FILE, "w") as f: - json.dump(default_users, f, indent=4) - return default_users - try: - with open(USERS_FILE, "r") as f: - return json.load(f) - except (json.JSONDecodeError, IOError): - return {} + users = read_json(USERS_FILE, default={}) or {} + if users: + return users + + default_user = "admin" + default_password = bcrypt.hashpw("admin123".encode(), bcrypt.gensalt()).decode() + users = {default_user: default_password} + write_json(USERS_FILE, users) + return users + def save_user(username: str, hashed_password: str): users = load_users() users[username] = hashed_password - with open(USERS_FILE, "w") as f: - json.dump(users, f, indent=4) + write_json(USERS_FILE, users) -# ─── JWT HELPERS ───────────────────────────────────────────────────────────── - -bearer_scheme = HTTPBearer() def create_access_token(data: dict) -> str: payload = data.copy() payload["exp"] = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), ) -> str: - """JWT dependency — validates token and returns username. Raises 401 on failure.""" token = credentials.credentials try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - username: str = payload.get("sub") + username = payload.get("sub") if not username: raise HTTPException(status_code=401, detail="Invalid token payload") return username except JWTError: raise HTTPException(status_code=401, detail="Token expired or invalid") -# ─── ENDPOINTS ─────────────────────────────────────────────────────────────── + +def validate_upload(file: UploadFile, contents: bytes) -> str: + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided.") + + filename = os.path.basename(file.filename) + extension = os.path.splitext(filename.lower())[1] + if extension not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"File type '{extension}' is not allowed. Accepted types: {', '.join(sorted(ALLOWED_EXTENSIONS))}" + ) + + if not contents: + raise HTTPException(status_code=400, detail="Uploaded file is empty.") + + if len(contents) > MAX_FILE_SIZE_BYTES: + raise HTTPException( + status_code=413, + detail=f"File exceeds maximum allowed size of {MAX_FILE_SIZE_BYTES // (1024 * 1024)} MB." + ) + + try: + detected_mime = magic.from_buffer(contents, mime=True) + except Exception: + detected_mime = "unknown" + + if detected_mime not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=400, + detail=f"File content type '{detected_mime}' is not permitted. Only log/text files are accepted." + ) + + safe_name = filename.replace("..", "").replace("/", "").replace("\\", "") + return f"{uuid.uuid4().hex}_{safe_name}" + + +def save_temp_file(contents: bytes, filename: str) -> str: + path = os.path.join(UPLOAD_DIR, filename) + with open(path, "wb") as buffer: + buffer.write(contents) + return path + + +@app.exception_handler(404) +async def custom_404_handler(request: Request, __): + return html_error_response(404, "

404 | Data Void Detected

") + + +@app.exception_handler(Exception) +async def custom_exception_handler(request: Request, exc: Exception): + logger.error("CRITICAL SYSTEM ERROR: %s", exc) + return html_error_response(500, "

500 | System Breach Detected

") + @app.get("/") async def root(): @@ -182,12 +210,12 @@ async def root(): @app.post("/login") @limiter.limit("10/minute") async def login(request: Request, username: str = Form(...), password: str = Form(...)): - """Authenticate operator and return a signed JWT.""" users = load_users() hashed = users.get(username) if not hashed or not bcrypt.checkpw(password.encode(), hashed.encode()): logger.warning("Failed login attempt for user: %s from %s", username, request.client.host) raise HTTPException(status_code=401, detail="Invalid Credentials") + token = create_access_token({"sub": username}) logger.info("Successful login: %s from %s", username, request.client.host) return {"access_token": token, "token_type": "bearer"} @@ -196,12 +224,11 @@ async def login(request: Request, username: str = Form(...), password: str = For @app.post("/register") @limiter.limit("5/minute") async def register(request: Request, username: str = Form(...), password: str = Form(...)): - """Register a new operator account.""" users = load_users() if username in users: raise HTTPException(status_code=400, detail="Operator ID already registered in the system.") - hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() - save_user(username, hashed) + + save_user(username, bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()) token = create_access_token({"sub": username}) logger.info("New operator registered: %s from %s", username, request.client.host) return { @@ -217,60 +244,14 @@ async def upload_log( request: Request, file: UploadFile = File(...), threshold: str = Form("60"), - current_user: str = Depends(get_current_user), # 🔒 JWT required + current_user: str = Depends(get_current_user), ): - """ - Protected forensic analysis endpoint. - Requires a valid Bearer token issued by /login or /register. - Rate-limited to 30 requests/minute per IP. - """ logger.info("Analyze request from user '%s' | file: %s | ip: %s", current_user, file.filename, request.client.host) - # ── 1. FILENAME / EXTENSION VALIDATION ────────────────────────────────── - if not file.filename: - raise HTTPException(status_code=400, detail="No filename provided.") - - _, ext = os.path.splitext(file.filename.lower()) - if ext not in ALLOWED_EXTENSIONS: - raise HTTPException( - status_code=400, - detail=f"File type '{ext}' is not allowed. Accepted types: {', '.join(sorted(ALLOWED_EXTENSIONS))}" - ) - - # ── 2. FILE SIZE VALIDATION ────────────────────────────────────────────── contents = await file.read() - if len(contents) == 0: - raise HTTPException(status_code=400, detail="Uploaded file is empty.") - if len(contents) > MAX_FILE_SIZE_BYTES: - raise HTTPException( - status_code=413, - detail=f"File exceeds maximum allowed size of {MAX_FILE_SIZE_BYTES // (1024 * 1024)} MB." - ) - - # ── 3. MIME TYPE VALIDATION (magic-byte sniffing) ──────────────────────── - try: - detected_mime = magic.from_buffer(contents, mime=True) - except Exception: - detected_mime = "unknown" - - if detected_mime not in ALLOWED_MIME_TYPES: - raise HTTPException( - status_code=400, - detail=f"File content type '{detected_mime}' is not permitted. Only log/text files are accepted." - ) - - # ── 4. SAFE + UNIQUE FILENAME (prevent path traversal and collisions) ───────── - safe_name = os.path.basename(file.filename).replace("..", "").replace("/", "").replace("\\", "") - unique_name = f"{uuid.uuid4().hex}_{safe_name}" - temp_path = os.path.join(UPLOAD_DIR, unique_name) - - # ── 5. WRITE & ANALYZE ─────────────────────────────────────────────────── - try: - with open(temp_path, "wb") as buffer: - buffer.write(contents) - except Exception as e: - raise HTTPException(status_code=500, detail=f"File save failed: {str(e)}") + temp_name = validate_upload(file, contents) + temp_path = save_temp_file(contents, temp_name) try: numeric_threshold = int(threshold) @@ -278,9 +259,9 @@ async def upload_log( logger.info("Analysis complete for user '%s' | gaps: %s | score: %s", current_user, results.get("total_gaps"), results.get("integrity_score")) return results - except Exception as e: - logger.error("Analysis error for user '%s': %s", current_user, e) - raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") + except Exception as exc: + logger.error("Analysis error for user '%s': %s", current_user, exc) + raise HTTPException(status_code=500, detail=f"Analysis failed: {str(exc)}") finally: if os.path.exists(temp_path): os.remove(temp_path) @@ -292,10 +273,6 @@ async def analyze_process( threshold: int = 60, current_user: str = Depends(get_current_user), ): - """ - SSE endpoint streaming real analysis progress from analyze_logs(). - Fixes hardcoded progress messages by wiring progress_callback to the stream. - """ queue: asyncio.Queue = asyncio.Queue() def progress_callback(percent: int, message: str): @@ -325,9 +302,9 @@ async def event_generator(): try: result = await analysis_task yield f"data: {json.dumps({'percent': 100, 'message': 'Analysis completed.', 'result': result})}\n\n" - except Exception as e: - logger.error("SSE analysis error: %s", e) - yield f"data: {json.dumps({'percent': 100, 'message': f'Error: {str(e)}'})}\n\n" + except Exception as exc: + logger.error("SSE analysis error: %s", exc) + yield f"data: {json.dumps({'percent': 100, 'message': f'Error: {str(exc)}'})}\n\n" return StreamingResponse( event_generator(), @@ -337,30 +314,22 @@ async def event_generator(): @app.post("/verify-chain") -#@limiter.limit("30/minute") async def verify_chain_manifest( request: Request, - manifest: list = None, + manifest: list = Body(...), current_user: str = Depends(get_current_user), ): - """ - Optional backend verification endpoint for chain of custody manifest. - Accepts a chain manifest (array of session entries) from frontend and validates integrity. - """ try: - from chain_of_custody import verify_chain_manifest + from chain_of_custody import verify_chain_manifest as verify_manifest except ImportError: raise HTTPException( status_code=501, detail="Chain of custody module not available. Ensure chain_of_custody.py exists." ) - - if manifest is None or not isinstance(manifest, list): - raise HTTPException( - status_code=400, - detail="Manifest must be a non-empty list of session entries." - ) - + + if not isinstance(manifest, list): + raise HTTPException(status_code=400, detail="Manifest must be a non-empty list of session entries.") + if len(manifest) == 0: return { "is_intact": True, @@ -369,55 +338,50 @@ async def verify_chain_manifest( "verification_details": [], "message": "Empty manifest — no entries to verify." } - + try: - is_intact, broken_index = verify_chain_manifest(manifest) - + is_intact, broken_index = verify_manifest(manifest) + broken_index = None if is_intact else broken_index + verification_details = [] for i, entry in enumerate(manifest): - entry_detail = { - "entry_index": i, - "session_id": entry.get("session_id", "unknown"), - "timestamp": entry.get("timestamp", "unknown"), - "valid": i < broken_index if broken_index is not None else True, - } - + valid = broken_index is None or i < broken_index if i == 0: - entry_detail["notes"] = "Genesis entry (first session)" + notes = "Genesis entry (first session)" elif broken_index is not None and i == broken_index: - entry_detail["notes"] = "❌ Chain broken at this entry — previous link failed" + notes = "❌ Chain broken at this entry — previous link failed" elif broken_index is not None and i > broken_index: - entry_detail["notes"] = "❌ Invalid (downstream of broken link)" + notes = "❌ Invalid (downstream of broken link)" else: - entry_detail["notes"] = "✓ Valid — linked to previous session" - - verification_details.append(entry_detail) - + notes = "✓ Valid — linked to previous session" + + verification_details.append( + { + "entry_index": i, + "session_id": entry.get("session_id", "unknown"), + "timestamp": entry.get("timestamp", "unknown"), + "valid": valid, + "notes": notes, + } + ) + logger.info( "Chain verification for user '%s': is_intact=%s, broken_index=%s", - current_user, is_intact, broken_index + current_user, + is_intact, + broken_index, ) - + return { "is_intact": is_intact, "broken_index": broken_index, "verified_entries": len(manifest), "verification_details": verification_details, - "message": ( - "✓ Chain integrity verified" if is_intact - else f"❌ Chain broken at entry #{broken_index}" - ) + "message": ("✓ Chain integrity verified" if is_intact else f"❌ Chain broken at entry #{broken_index}"), } - - except Exception as e: - logger.error( - "Chain verification error for user '%s': %s", - current_user, e - ) - raise HTTPException( - status_code=500, - detail=f"Verification failed: {str(e)}" - ) + except Exception as exc: + logger.error("Chain verification error for user '%s': %s", current_user, exc) + raise HTTPException(status_code=500, detail=f"Verification failed: {str(exc)}") @app.get("/chain-status") @@ -426,10 +390,6 @@ async def get_chain_status( request: Request, current_user: str = Depends(get_current_user), ): - """ - Optional endpoint to retrieve chain manifest storage instructions. - Useful for documentation and debugging chain setup. - """ return { "storage_location": "Browser localStorage['analysis_chain_manifest']", "chain_type": "SHA-256 hash chaining", @@ -438,13 +398,10 @@ async def get_chain_status( "Findings integrity (SHA-256 of analysis output)", "Session linking (cryptographic chain)", "Tampering detection (broken chain indicates modification)", - "Export integration (PDF/JSON include manifest)" + "Export integration (PDF/JSON include manifest)", ], - "endpoints": { - "verify": "POST /verify-chain", - "info": "GET /chain-status" - }, - "documentation": "See CHAIN_OF_CUSTODY.md for full details" + "endpoints": {"verify": "POST /verify-chain", "info": "GET /chain-status"}, + "documentation": "See CHAIN_OF_CUSTODY.md for full details", } @@ -456,31 +413,25 @@ async def export_zip( json_file: UploadFile = File(...), current_user: str = Depends(get_current_user), ): - """ - Creates an in-memory ZIP archive containing the generated PDF, CSV, and JSON reports. - Streams the ZIP file back to the client. - """ try: zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: - zip_file.writestr("report.pdf", await pdf_file.read()) - zip_file.writestr("registry.csv", await csv_file.read()) - zip_file.writestr("report.json", await json_file.read()) - + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("report.pdf", await pdf_file.read()) + archive.writestr("registry.csv", await csv_file.read()) + archive.writestr("report.json", await json_file.read()) + zip_buffer.seek(0) - return StreamingResponse( iter([zip_buffer.getvalue()]), media_type="application/zip", - headers={ - "Content-Disposition": "attachment; filename=Evidence_Package.zip" - } + headers={"Content-Disposition": "attachment; filename=Evidence_Package.zip"}, ) - except Exception as e: - logger.error("ZIP export failed for user '%s': %s", current_user, str(e)) + except Exception as exc: + logger.error("ZIP export failed for user '%s': %s", current_user, str(exc)) raise HTTPException(status_code=500, detail="Failed to generate ZIP archive.") if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="127.0.0.1", port=8000) \ No newline at end of file + uvicorn.run(app, host="127.0.0.1", port=8000) +''')" \ No newline at end of file