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