diff --git a/README.md b/README.md index 9bfade4..b3dfcbf 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,34 @@ Supported file types: **📚 For detailed setup instructions, see [QUICKSTART.md](QUICKSTART.md)** +--- + +## ✅ Runtime Validation & Demo Artifacts + +The FEAS project has been executed locally and validated with both backend and frontend checks. + +### Validation Commands + +```bash +# Backend tests +cd backend +python -m pytest tests/ -v + +# Frontend production build +cd ../frontend +npm run build +``` + +### Captured Runtime Media + +The following artifacts were captured from a live FEAS run: + +- **Home Screen Screenshot:** [`docs/media/feas-home.png`](docs/media/feas-home.png) +- **Submission Screen Screenshot:** [`docs/media/feas-submission.png`](docs/media/feas-submission.png) +- **Short Demo Video:** [`docs/media/feas-demo.webm`](docs/media/feas-demo.webm) + +These media files provide visual confirmation of application startup, routing, and UI availability. + ### Option 1: Quick Start (Docker) 🐳 1. **Clone the repository** diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 5031894..ad1c09f 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -215,3 +215,15 @@ async def get_me(current_user: User = Depends(get_current_user), db: Session = D async def logout(): """Logout (client-side token removal)""" return {"message": "Successfully logged out"} + + +def get_user_role(db: Session, user: User) -> str: + profile = db.query(UserProfile).filter(UserProfile.user_id == user.id).first() + return (profile.role if profile and profile.role else "Analyst").strip() + + +def user_has_any_role(db: Session, user: User, allowed_roles: list[str]) -> bool: + if user.is_admin: + return True + role = get_user_role(db, user).lower() + return role in {r.lower() for r in allowed_roles} diff --git a/backend/app/api/v1/endpoints/jobs.py b/backend/app/api/v1/endpoints/jobs.py index 5a5a2d5..3825cbe 100644 --- a/backend/app/api/v1/endpoints/jobs.py +++ b/backend/app/api/v1/endpoints/jobs.py @@ -1,9 +1,8 @@ -from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks, Depends +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks, Depends, status from fastapi.responses import FileResponse -from typing import Optional, List +from typing import Optional, List, Dict, Any import uuid import os -import shutil import tempfile from datetime import datetime, timedelta import logging @@ -12,16 +11,34 @@ from kombu.exceptions import OperationalError as KombuOperationalError from app.db.session import get_db -from app.models.sql_models import Job, ChainOfCustody +from app.models.sql_models import ( + Job, + ChainOfCustody, + NetworkScan, + ScanPort, + VulnerabilityFinding, + IntegrityAlert, +) from app.models.schemas import ( - URLJobCreate, JobStatusResponse, JobDetailsResponse, VerificationResponse + URLJobCreate, + JobStatusResponse, + JobDetailsResponse, + VerificationResponse, + NetworkScanRequest, + NetworkScanResponse, ) from app.pipelines.url_pipeline import URLPipeline from app.pipelines.upload_pipeline import UploadPipeline from app.pipelines.unified_pipeline import UnifiedForensicPipeline from app.services.validator import FileValidator +from app.services.storage import StorageService +from app.services.audit import AuditLogService +from app.services.network_scanner import NetworkScannerService +from app.services.vulnerability_mapper import VulnerabilityMapperService +from app.services.correlation import CorrelationService from app.core.logger import ForensicLogger from app.core.config import settings +from app.api.v1.endpoints.auth import get_current_user, user_has_any_role logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1", tags=["jobs"]) @@ -31,7 +48,7 @@ from app.workers.tasks import process_url_job, process_upload_job # --- Background Helpers for non-Celery mode --- -# Note: FastAPI BackgroundTasks runs these in a ThreadPoolExecutor, +# Note: FastAPI BackgroundTasks runs these in a ThreadPoolExecutor, # so asyncio.run() is safe here - each thread gets its own event loop. def run_url_pipeline_sync(job_id: str, url: str, investigator_id: str, case_number: str = None): @@ -42,6 +59,7 @@ def run_url_pipeline_sync(job_id: str, url: str, investigator_id: str, case_numb except Exception as e: logger.error(f"URL pipeline failed for job {job_id}: {str(e)}") + def run_upload_pipeline_sync(job_id: str, file_path: str, filename: str, investigator_id: str, case_number: str = None): """Synchronous wrapper for upload pipeline (runs in background thread)""" try: @@ -50,9 +68,91 @@ def run_upload_pipeline_sync(job_id: str, file_path: str, filename: str, investi except Exception as e: logger.error(f"Upload pipeline failed for job {job_id}: {str(e)}") + # Additional enforcement -ALLOWED_TYPES = {"application/pdf", "image/png", "image/jpeg", "text/plain", "application/zip", "video/mp4", "audio/mpeg", "audio/wav"} -MAX_UPLOAD_MB = 500 +ALLOWED_TYPES = { + "application/pdf", + "image/png", + "image/jpeg", + "text/plain", + "application/zip", + "video/mp4", + "audio/mpeg", + "audio/wav", +} +MAX_UPLOAD_MB = 500 + + +# --- Helpers --- + +def _ensure_role(db: Session, current_user, allowed_roles: List[str]): + if not user_has_any_role(db, current_user, allowed_roles): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access restricted to roles: {', '.join(allowed_roles)}", + ) + + +def _trigger_integrity_alert( + db: Session, + job: Job, + expected_hash: str, + current_hash: str, + user_id: str, + action: str, +): + alert = IntegrityAlert( + job_id=job.id, + expected_hash=expected_hash or "", + current_hash=current_hash or "", + message=f"Integrity mismatch detected during {action}", + resolved=False, + ) + db.add(alert) + + db.add( + ChainOfCustody( + job_id=job.id, + event="HASH_MISMATCH_ALERT", + investigator_id=user_id, + details={ + "action": action, + "expected_hash": expected_hash, + "current_hash": current_hash, + }, + hash_verification=current_hash, + ) + ) + db.commit() + + +def _verify_integrity_on_access(db: Session, job: Job, user_id: str, action: str) -> Dict[str, Any]: + if not job.storage_path or not os.path.exists(job.storage_path): + return {"checked": False, "matches": None, "current_hash": None} + + current_hash = StorageService.compute_stored_evidence_hash(job.storage_path) + if not current_hash: + return {"checked": True, "matches": False, "current_hash": None} + + matches = current_hash == (job.sha256_hash or "") + if not matches: + _trigger_integrity_alert(db, job, job.sha256_hash or "", current_hash, user_id, action) + + AuditLogService.append( + db=db, + action="view", + user_id=user_id, + job_id=job.id, + details={ + "context": action, + "integrity_checked": True, + "integrity_match": matches, + "current_hash": current_hash, + }, + ) + + return {"checked": True, "matches": matches, "current_hash": current_hash} + # --- Endpoints --- @@ -60,49 +160,67 @@ def run_upload_pipeline_sync(job_id: str, file_path: str, filename: str, investi async def submit_url_job( job_data: URLJobCreate, background_tasks: BackgroundTasks, - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user=Depends(get_current_user), ): try: job_id = str(uuid.uuid4()) job = Job( - id=job_id, status="pending", source="url", original_url=str(job_data.url), - investigator_id=job_data.investigator_id, case_number=job_data.case_number, - notes=job_data.notes, stage="Initialization" + id=job_id, + status="pending", + source="url", + original_url=str(job_data.url), + investigator_id=job_data.investigator_id, + case_number=job_data.case_number, + notes=job_data.notes, + stage="Initialization", ) db.add(job) db.commit() db.refresh(job) - - ForensicLogger.log_acquisition(job_id=job_id, source='url', investigator_id=job_data.investigator_id, url=str(job_data.url)) - + + ForensicLogger.log_acquisition( + job_id=job_id, + source="url", + investigator_id=job_data.investigator_id, + url=str(job_data.url), + ) + AuditLogService.append( + db=db, + action="upload", + user_id=str(current_user.id), + job_id=job_id, + details={"source": "url", "target": str(job_data.url)}, + ) + # Use Celery if enabled, otherwise fall back to FastAPI BackgroundTasks if settings.USE_CELERY: try: process_url_job.delay( - job_id=job_id, + job_id=job_id, url=str(job_data.url), - investigator_id=job_data.investigator_id, - case_number=job_data.case_number + investigator_id=job_data.investigator_id, + case_number=job_data.case_number, ) except (KombuOperationalError, ConnectionError, OSError) as celery_error: # Fallback to BackgroundTasks if Celery/Redis is not available logger.warning(f"Celery unavailable, falling back to BackgroundTasks: {str(celery_error)}") background_tasks.add_task( - run_url_pipeline_sync, - job_id, - str(job_data.url), - job_data.investigator_id, - job_data.case_number + run_url_pipeline_sync, + job_id, + str(job_data.url), + job_data.investigator_id, + job_data.case_number, ) else: # Use FastAPI BackgroundTasks when USE_CELERY is disabled logger.info(f"Processing URL job {job_id} with BackgroundTasks (USE_CELERY=false)") background_tasks.add_task( - run_url_pipeline_sync, - job_id, - str(job_data.url), - job_data.investigator_id, - job_data.case_number + run_url_pipeline_sync, + job_id, + str(job_data.url), + job_data.investigator_id, + job_data.case_number, ) return job except HTTPException: @@ -111,6 +229,7 @@ async def submit_url_job( logger.error(f"URL job submission failed: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + @router.post("/jobs/upload", response_model=JobStatusResponse) async def submit_local_file( background_tasks: BackgroundTasks, @@ -118,7 +237,8 @@ async def submit_local_file( investigator_id: str = Form(...), case_number: Optional[str] = Form(None), notes: Optional[str] = Form(None), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user=Depends(get_current_user), ): try: # Validate File @@ -128,26 +248,24 @@ async def submit_local_file( raise HTTPException(status_code=400, detail=validation_result["error"]) # Enforce MIME type - if file.content_type not in ALLOWED_TYPES and not any(file.content_type.startswith(p) for p in ['image/', 'video/', 'audio/']): - # Simplified check, allowing main types - pass - + if file.content_type not in ALLOWED_TYPES and not any(file.content_type.startswith(p) for p in ["image/", "video/", "audio/"]): + raise HTTPException(status_code=400, detail=f"Unsupported file type: {file.content_type}") + job_id = str(uuid.uuid4()) # Save to temp file - # Use the LOCAL_STORAGE_PATH from settings for better portability storage_base = os.path.abspath(settings.LOCAL_STORAGE_PATH) temp_dir = os.path.join(storage_base, "temp_uploads") os.makedirs(temp_dir, exist_ok=True) - + temp_file = tempfile.NamedTemporaryFile( - delete=False, - dir=temp_dir, # <--- Force save to shared volume - suffix=f"_{file.filename}" + delete=False, + dir=temp_dir, + suffix=f"_{file.filename}", ) try: written = 0 - with open(temp_file.name, 'wb') as f: + with open(temp_file.name, "wb") as f: while True: chunk = file.file.read(1024 * 1024) if not chunk: @@ -159,118 +277,253 @@ async def submit_local_file( f.write(chunk) except HTTPException: raise - except Exception as e: + except Exception: if os.path.exists(temp_file.name): os.unlink(temp_file.name) raise HTTPException(status_code=500, detail="Failed to save uploaded file") job = Job( - id=job_id, status="pending", source="local_upload", filename=file.filename, - investigator_id=investigator_id, case_number=case_number, notes=notes, - stage="Initialization" + id=job_id, + status="pending", + source="local_upload", + filename=file.filename, + investigator_id=investigator_id, + case_number=case_number, + notes=notes, + stage="Initialization", ) db.add(job) db.commit() db.refresh(job) - - ForensicLogger.log_acquisition(job_id=job_id, source='local_upload', investigator_id=investigator_id, filename=file.filename) - + + ForensicLogger.log_acquisition( + job_id=job_id, + source="local_upload", + investigator_id=investigator_id, + filename=file.filename, + ) + AuditLogService.append( + db=db, + action="upload", + user_id=str(current_user.id), + job_id=job_id, + details={"source": "local_upload", "filename": file.filename}, + ) + # Use Celery if enabled, otherwise fall back to FastAPI BackgroundTasks if settings.USE_CELERY: try: process_upload_job.delay( - job_id=job_id, - file_path=temp_file.name, - filename=file.filename, - investigator_id=investigator_id, - case_number=case_number + job_id=job_id, + file_path=temp_file.name, + filename=file.filename, + investigator_id=investigator_id, + case_number=case_number, ) except (KombuOperationalError, ConnectionError, OSError) as celery_error: # Fallback to BackgroundTasks if Celery/Redis is not available logger.warning(f"Celery unavailable, falling back to BackgroundTasks: {str(celery_error)}") background_tasks.add_task( - run_upload_pipeline_sync, - job_id, - temp_file.name, - file.filename, - investigator_id, - case_number + run_upload_pipeline_sync, + job_id, + temp_file.name, + file.filename, + investigator_id, + case_number, ) else: # Use FastAPI BackgroundTasks when USE_CELERY is disabled logger.info(f"Processing upload job {job_id} with BackgroundTasks (USE_CELERY=false)") background_tasks.add_task( - run_upload_pipeline_sync, - job_id, - temp_file.name, - file.filename, - investigator_id, - case_number + run_upload_pipeline_sync, + job_id, + temp_file.name, + file.filename, + investigator_id, + case_number, ) return job - except HTTPException: + except HTTPException: raise except Exception as e: logger.error(f"Upload job submission failed: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + @router.get("/jobs", response_model=List[JobStatusResponse]) -async def list_jobs(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): +async def list_jobs( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + _ensure_role(db, current_user, ["Analyst", "Senior Analyst", "Admin", "Investigator"]) return db.query(Job).order_by(Job.created_at.desc()).offset(skip).limit(limit).all() + @router.get("/jobs/{job_id}/status", response_model=JobStatusResponse) -async def get_job_status(job_id: str, db: Session = Depends(get_db)): +async def get_job_status(job_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + _ensure_role(db, current_user, ["Analyst", "Senior Analyst", "Admin", "Investigator"]) job = db.query(Job).filter(Job.id == job_id).first() - if not job: raise HTTPException(status_code=404, detail="Job not found") + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + AuditLogService.append( + db=db, + action="view", + user_id=str(current_user.id), + job_id=job.id, + details={"context": "status"}, + ) return job + @router.get("/jobs/{job_id}/details", response_model=JobDetailsResponse) -async def get_job_details(job_id: str, db: Session = Depends(get_db)): +async def get_job_details(job_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + _ensure_role(db, current_user, ["Analyst", "Senior Analyst", "Admin", "Investigator"]) job = db.query(Job).filter(Job.id == job_id).first() - if not job: raise HTTPException(status_code=404, detail="Job not found") - - logs = db.query(ChainOfCustody).filter(ChainOfCustody.job_id == job_id).order_by(ChainOfCustody.timestamp).all() - + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + integrity = _verify_integrity_on_access(db, job, str(current_user.id), "details") + + logs = ( + db.query(ChainOfCustody) + .filter(ChainOfCustody.job_id == job_id) + .order_by(ChainOfCustody.timestamp) + .all() + ) + + scans = [] + vulnerabilities = [] + risk_assessment = {"counts": {"High": 0, "Medium": 0, "Low": 0}, "overall_risk": "Low"} + + if job.case_number: + case_scans = db.query(NetworkScan).filter(NetworkScan.case_number == job.case_number).all() + for scan in case_scans: + scan_ports = db.query(ScanPort).filter(ScanPort.scan_id == scan.id).all() + scans.append( + { + "scan_id": scan.id, + "target": scan.target, + "status": scan.status, + "ports": [ + { + "port": p.port, + "protocol": p.protocol, + "state": p.state, + "service": p.service, + "version": p.version, + } + for p in scan_ports + ], + } + ) + + scan_vulns = db.query(VulnerabilityFinding).filter(VulnerabilityFinding.scan_id == scan.id).all() + for v in scan_vulns: + vulnerabilities.append( + { + "id": v.id, + "cve_id": v.cve_id, + "service": v.service, + "version": v.version, + "risk_level": v.risk_level, + "description": v.description, + } + ) + + risk_assessment = VulnerabilityMapperService.summarize_risk(vulnerabilities) + metadata = { - "file_name": job.filename, "file_size": job.file_size, "mime_type": job.mime_type, - "sha256_hash": job.sha256_hash, "extraction_timestamp": job.updated_at, - "exif_data": {}, "media_metadata": {} + "file_name": job.filename, + "file_size": job.file_size, + "mime_type": job.mime_type, + "sha256_hash": job.sha256_hash, + "extraction_timestamp": job.updated_at, + "exif_data": {}, + "media_metadata": {}, } - # Populate metadata from latest relevant log or stored JSON if available (simplified here) - # Ideally, we should fetch metadata from the stored metadata.json, but for now we construct basic structure - + if integrity.get("checked") and integrity.get("matches") is False: + metadata["integrity_alert"] = { + "message": "Integrity mismatch detected", + "current_hash": integrity.get("current_hash"), + "expected_hash": job.sha256_hash, + } + return JobDetailsResponse( - job_id=job.id, status=job.status, source=job.source, platform=None, + job_id=job.id, + status=job.status, + source=job.source, + platform=None, metadata=metadata, chain_of_custody=[ - {"timestamp": l.timestamp, "event": l.event, "details": l.details, "investigator_id": l.investigator_id, "hash_verification": l.hash_verification} for l in logs + { + "timestamp": l.timestamp, + "event": l.event, + "details": l.details, + "investigator_id": l.investigator_id, + "hash_verification": l.hash_verification, + } + for l in logs ], - original_url=job.original_url, file_path=job.storage_path or "", - storage_location=job.storage_path or "", created_at=job.created_at, completed_at=job.completed_at + original_url=job.original_url, + file_path=job.storage_path or "", + storage_location=job.storage_path or "", + created_at=job.created_at, + completed_at=job.completed_at, + scan_results=scans, + vulnerabilities=vulnerabilities, + risk_assessment=risk_assessment, ) + @router.get("/jobs/{job_id}/report") -async def download_report(job_id: str, db: Session = Depends(get_db)): +async def download_report(job_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + _ensure_role(db, current_user, ["Analyst", "Senior Analyst", "Admin", "Investigator"]) job = db.query(Job).filter(Job.id == job_id).first() - if not job: raise HTTPException(status_code=404, detail="Job not found") - - report_log = db.query(ChainOfCustody).filter(ChainOfCustody.job_id == job_id, ChainOfCustody.event == "REPORT_GENERATED").first() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + integrity = _verify_integrity_on_access(db, job, str(current_user.id), "report") + if integrity.get("checked") and integrity.get("matches") is False: + AuditLogService.append( + db=db, + action="view", + user_id=str(current_user.id), + job_id=job.id, + details={ + "context": "report_blocked_integrity", + "expected_hash": job.sha256_hash, + "current_hash": integrity.get("current_hash"), + }, + ) + raise HTTPException(status_code=409, detail="Evidence integrity mismatch detected; report download blocked") + + report_log = ( + db.query(ChainOfCustody) + .filter(ChainOfCustody.job_id == job_id, ChainOfCustody.event == "REPORT_GENERATED") + .first() + ) pdf_path = report_log.details.get("report_path") if report_log and report_log.details else None - + if not pdf_path or not os.path.exists(pdf_path): - raise HTTPException(status_code=404, detail="Report not available") + raise HTTPException(status_code=404, detail="Report not available") + + return FileResponse(pdf_path, media_type="application/pdf", filename=f"Forensic_Report_{job_id}.pdf") - return FileResponse(pdf_path, media_type='application/pdf', filename=f"Forensic_Report_{job_id}.pdf") @router.get("/jobs/{job_id}/pdf") -async def generate_pdf_endpoint(job_id: str, db: Session = Depends(get_db)): - return await download_report(job_id, db) +async def generate_pdf_endpoint(job_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + return await download_report(job_id, db, current_user) + @router.get("/analytics") -async def get_analytics(period: str = "7d", db: Session = Depends(get_db)): +async def get_analytics(period: str = "7d", db: Session = Depends(get_db), current_user=Depends(get_current_user)): + _ensure_role(db, current_user, ["Analyst", "Senior Analyst", "Admin", "Investigator"]) now = datetime.utcnow() - + # Parse period parameter if period == "24h": start_date = now - timedelta(hours=24) @@ -280,24 +533,209 @@ async def get_analytics(period: str = "7d", db: Session = Depends(get_db)): start_date = now - timedelta(days=90) else: # default to 7d start_date = now - timedelta(days=7) - + total = db.query(Job).filter(Job.created_at >= start_date).count() - completed = db.query(Job).filter(Job.created_at >= start_date, Job.status == 'completed').count() - failed = db.query(Job).filter(Job.created_at >= start_date, Job.status == 'failed').count() - pending = db.query(Job).filter(Job.created_at >= start_date, Job.status == 'pending').count() - + completed = db.query(Job).filter(Job.created_at >= start_date, Job.status == "completed").count() + failed = db.query(Job).filter(Job.created_at >= start_date, Job.status == "failed").count() + pending = db.query(Job).filter(Job.created_at >= start_date, Job.status == "pending").count() + return {"total_jobs": total, "completed_jobs": completed, "failed_jobs": failed, "pending_jobs": pending} + @router.post("/jobs/{job_id}/verify", response_model=VerificationResponse) -async def verify_integrity(job_id: str, db: Session = Depends(get_db)): +async def verify_integrity(job_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + _ensure_role(db, current_user, ["Analyst", "Senior Analyst", "Admin", "Investigator"]) job = db.query(Job).filter(Job.id == job_id).first() - if not job or not job.storage_path: raise HTTPException(status_code=404, detail="Evidence not found") - + if not job or not job.storage_path: + raise HTTPException(status_code=404, detail="Evidence not found") + + if not os.path.exists(job.storage_path): + raise HTTPException(status_code=404, detail="Stored evidence file not found") + + current_hash = StorageService.compute_stored_evidence_hash(job.storage_path) + if not current_hash: + raise HTTPException(status_code=500, detail="Failed to compute evidence hash") + + matches = current_hash == (job.sha256_hash or "") + + db.add( + ChainOfCustody( + job_id=job.id, + event="INTEGRITY_VERIFICATION", + investigator_id=str(current_user.id), + details={"verified_via": "verify_endpoint"}, + hash_verification=current_hash, + ) + ) + db.commit() + + if not matches: + _trigger_integrity_alert(db, job, job.sha256_hash or "", current_hash, str(current_user.id), "verify") + + AuditLogService.append( + db=db, + action="view", + user_id=str(current_user.id), + job_id=job.id, + details={"context": "verify", "matches": matches}, + ) + pipeline = UnifiedForensicPipeline() - result = pipeline.verify_integrity(job.storage_path, job.sha256_hash, job.id, job.investigator_id) - + result = pipeline.verify_integrity(job.storage_path, job.sha256_hash, job.id, str(current_user.id)) + result["current_hash"] = current_hash + result["matches"] = matches + return VerificationResponse( - job_id=job.id, verification_timestamp=datetime.utcnow(), - original_hash=result['original_hash'], current_hash=result['current_hash'], - matches=result['matches'], verification_details=result['verification_details'] + job_id=job.id, + verification_timestamp=datetime.utcnow(), + original_hash=result["original_hash"], + current_hash=result["current_hash"], + matches=result["matches"], + verification_details=result["verification_details"], + ) + + +@router.delete("/jobs/{job_id}") +async def delete_evidence_job(job_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + _ensure_role(db, current_user, ["Admin"]) + + job = db.query(Job).filter(Job.id == job_id).first() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if job.storage_path and os.path.exists(job.storage_path): + try: + os.remove(job.storage_path) + except OSError as exc: + logger.error(f"Failed to delete evidence file for job {job_id}: {str(exc)}") + raise HTTPException(status_code=500, detail="Failed to delete stored evidence file") + + job.storage_path = None + job.stage = "Deleted" + job.status = "failed" + job.notes = "Evidence deleted by authorized user" + + db.add( + ChainOfCustody( + job_id=job.id, + event="EVIDENCE_DELETED", + investigator_id=str(current_user.id), + details={"reason": "authorized_delete"}, + ) ) + db.commit() + + AuditLogService.append( + db=db, + action="delete", + user_id=str(current_user.id), + job_id=job.id, + details={"result": "deleted"}, + ) + + return {"ok": True, "job_id": job_id, "deleted": True} + + +@router.post("/scans/network", response_model=NetworkScanResponse) +async def run_network_scan( + payload: NetworkScanRequest, + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + _ensure_role(db, current_user, ["Analyst", "Senior Analyst", "Admin", "Investigator"]) + + scan = NetworkScan( + case_number=payload.case_number, + target=payload.target, + initiated_by=str(current_user.id), + command="nmap -sV -Pn -oX -", + status="running", + started_at=datetime.utcnow(), + ) + db.add(scan) + db.commit() + db.refresh(scan) + + try: + scan_result = NetworkScannerService.run_scan(payload.target) + parsed = scan_result["parsed"] + all_ports: List[Dict[str, Any]] = [] + + for host in parsed.get("hosts", []): + all_ports.extend(host.get("ports", [])) + + port_row_by_number = {} + for p in all_ports: + port_row = ScanPort( + scan_id=scan.id, + port=p.get("port", 0), + protocol=p.get("protocol", "tcp"), + state=p.get("state", "open"), + service=p.get("service"), + version=p.get("version"), + ) + db.add(port_row) + db.flush() + port_row_by_number.setdefault(p.get("port", 0), []).append(port_row) + + findings = VulnerabilityMapperService.map_ports(all_ports) + for finding in findings: + matching_port = port_row_by_number.get(finding.get("port"), [None])[0] + vuln = VulnerabilityFinding( + scan_id=scan.id, + scan_port_id=matching_port.id if matching_port else None, + cve_id=finding["cve_id"], + service=finding.get("service"), + version=finding.get("version"), + risk_level=finding["risk_level"], + description=finding.get("description"), + ) + db.add(vuln) + db.flush() + + scan.raw_output = scan_result["raw_output"] + scan.command = scan_result["command"] + scan.status = "completed" + scan.completed_at = datetime.utcnow() + db.commit() + db.refresh(scan) + + created_correlations = CorrelationService.create_case_correlations(db, scan) + risk_assessment = VulnerabilityMapperService.summarize_risk(findings) + + AuditLogService.append( + db=db, + action="scan", + user_id=str(current_user.id), + details={ + "scan_id": scan.id, + "target": payload.target, + "case_number": payload.case_number, + "ports_found": len(all_ports), + "vulnerabilities_found": len(findings), + }, + ) + + return NetworkScanResponse( + scan_id=scan.id, + case_number=scan.case_number, + target=scan.target, + status=scan.status, + command=scan.command, + ports=all_ports, + vulnerabilities=findings, + risk_assessment=risk_assessment, + correlations_created=len(created_correlations), + ) + + except FileNotFoundError: + scan.status = "failed" + scan.completed_at = datetime.utcnow() + db.commit() + raise HTTPException(status_code=500, detail="Nmap binary is not available on the server") + except Exception as e: + scan.status = "failed" + scan.completed_at = datetime.utcnow() + db.commit() + logger.error(f"Network scan failed: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2169703..d78813e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -24,9 +24,11 @@ def assemble_db_connection(cls, v: Optional[str], values: dict) -> str: return f"postgresql://{values.get('POSTGRES_USER')}:{values.get('POSTGRES_PASSWORD')}@{values.get('POSTGRES_SERVER')}:{values.get('POSTGRES_PORT')}/{values.get('POSTGRES_DB')}" # --- Storage Settings --- - STORAGE_TYPE: str = "local" - LOCAL_STORAGE_PATH: str = "./evidence_storage" - MAX_FILE_SIZE: int = 500 * 1024 * 1024 + STORAGE_TYPE: str = "local" + LOCAL_STORAGE_PATH: str = "./evidence_storage" + MAX_FILE_SIZE: int = 500 * 1024 * 1024 + EVIDENCE_ENCRYPTION_KEY: Optional[str] = None + EVIDENCE_ENCRYPTION_KEY_FILE: str = "./evidence_encryption.key" # --- S3 Settings (Optional) --- S3_ENDPOINT: Optional[str] = None @@ -107,4 +109,4 @@ def assemble_celery_backend(cls, v: Optional[str], values: dict) -> str: "extra": "ignore" # This prevents crashes if .env has extra keys } -settings = Settings() \ No newline at end of file +settings = Settings() diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 4eeb251..4ec4d62 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -72,7 +72,7 @@ class ChainOfCustodyEntry(BaseModel): investigator_id: str hash_verification: Optional[str] = None -class JobDetailsResponse(BaseModel): +class JobDetailsResponse(BaseModel): job_id: str status: str source: str @@ -80,10 +80,13 @@ class JobDetailsResponse(BaseModel): metadata: Metadata chain_of_custody: List[ChainOfCustodyEntry] original_url: Optional[str] = None - file_path: str - storage_location: str - created_at: datetime - completed_at: Optional[datetime] = None + file_path: str + storage_location: str + created_at: datetime + completed_at: Optional[datetime] = None + scan_results: Optional[List[Dict[str, Any]]] = None + vulnerabilities: Optional[List[Dict[str, Any]]] = None + risk_assessment: Optional[Dict[str, Any]] = None # Allow mapping if we ever use from_attributes here too model_config = ConfigDict(from_attributes=True) @@ -101,8 +104,25 @@ class ErrorResponse(BaseModel): error_code: str timestamp: datetime -class HealthResponse(BaseModel): - status: str - version: str - timestamp: datetime - services: Dict[str, str] \ No newline at end of file +class HealthResponse(BaseModel): + status: str + version: str + timestamp: datetime + services: Dict[str, str] + + +class NetworkScanRequest(BaseModel): + target: str = Field(..., min_length=1, max_length=255) + case_number: str = Field(..., min_length=1, max_length=50) + + +class NetworkScanResponse(BaseModel): + scan_id: str + case_number: str + target: str + status: str + command: str + ports: List[Dict[str, Any]] + vulnerabilities: List[Dict[str, Any]] + risk_assessment: Dict[str, Any] + correlations_created: int diff --git a/backend/app/models/sql_models.py b/backend/app/models/sql_models.py index 9daea64..6289ccf 100644 --- a/backend/app/models/sql_models.py +++ b/backend/app/models/sql_models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, Float, DateTime, JSON, ForeignKey, Integer, Boolean +from sqlalchemy import Column, String, Float, DateTime, JSON, ForeignKey, Integer, Boolean, Text from sqlalchemy.orm import relationship from datetime import datetime import uuid @@ -36,6 +36,9 @@ class Job(Base): # Relationships custody_logs = relationship("ChainOfCustody", back_populates="job", cascade="all, delete-orphan") + audit_logs = relationship("AuditLog", back_populates="job", cascade="all, delete-orphan") + integrity_alerts = relationship("IntegrityAlert", back_populates="job", cascade="all, delete-orphan") + correlations = relationship("EvidenceCorrelation", back_populates="job", cascade="all, delete-orphan") class ChainOfCustody(Base): __tablename__ = "chain_of_custody" @@ -50,6 +53,102 @@ class ChainOfCustody(Base): job = relationship("Job", back_populates="custody_logs") + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + job_id = Column(String, ForeignKey("jobs.id"), nullable=True, index=True) + action = Column(String, nullable=False, index=True) # upload, view, delete, scan + user_id = Column(String, nullable=False, index=True) + timestamp = Column(DateTime, default=datetime.utcnow, index=True) + details = Column(JSON, nullable=True) + + job = relationship("Job", back_populates="audit_logs") + + +class NetworkScan(Base): + __tablename__ = "network_scans" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + case_number = Column(String, nullable=False, index=True) + target = Column(String, nullable=False, index=True) + initiated_by = Column(String, nullable=False, index=True) + command = Column(String, nullable=False) + status = Column(String, nullable=False, default="pending", index=True) + raw_output = Column(Text, nullable=True) + started_at = Column(DateTime, default=datetime.utcnow) + completed_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + ports = relationship("ScanPort", back_populates="scan", cascade="all, delete-orphan") + vulnerabilities = relationship("VulnerabilityFinding", back_populates="scan", cascade="all, delete-orphan") + correlations = relationship("EvidenceCorrelation", back_populates="scan", cascade="all, delete-orphan") + + +class ScanPort(Base): + __tablename__ = "scan_ports" + + id = Column(Integer, primary_key=True, index=True) + scan_id = Column(String, ForeignKey("network_scans.id"), nullable=False, index=True) + port = Column(Integer, nullable=False, index=True) + protocol = Column(String, nullable=False, default="tcp") + state = Column(String, nullable=False, default="open") + service = Column(String, nullable=True) + version = Column(String, nullable=True) + + scan = relationship("NetworkScan", back_populates="ports") + vulnerabilities = relationship("VulnerabilityFinding", back_populates="port_ref", cascade="all, delete-orphan") + + +class VulnerabilityFinding(Base): + __tablename__ = "vulnerability_findings" + + id = Column(Integer, primary_key=True, index=True) + scan_id = Column(String, ForeignKey("network_scans.id"), nullable=False, index=True) + scan_port_id = Column(Integer, ForeignKey("scan_ports.id"), nullable=True) + cve_id = Column(String, nullable=False, index=True) + service = Column(String, nullable=True, index=True) + version = Column(String, nullable=True) + risk_level = Column(String, nullable=False, index=True) # Low, Medium, High + description = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + scan = relationship("NetworkScan", back_populates="vulnerabilities") + port_ref = relationship("ScanPort", back_populates="vulnerabilities") + correlations = relationship("EvidenceCorrelation", back_populates="vulnerability", cascade="all, delete-orphan") + + +class IntegrityAlert(Base): + __tablename__ = "integrity_alerts" + + id = Column(Integer, primary_key=True, index=True) + job_id = Column(String, ForeignKey("jobs.id"), nullable=False, index=True) + expected_hash = Column(String, nullable=False) + current_hash = Column(String, nullable=False) + message = Column(String, nullable=False) + detected_at = Column(DateTime, default=datetime.utcnow, index=True) + resolved = Column(Boolean, default=False) + + job = relationship("Job", back_populates="integrity_alerts") + + +class EvidenceCorrelation(Base): + __tablename__ = "evidence_correlations" + + id = Column(Integer, primary_key=True, index=True) + job_id = Column(String, ForeignKey("jobs.id"), nullable=False, index=True) + scan_id = Column(String, ForeignKey("network_scans.id"), nullable=False, index=True) + vulnerability_id = Column(Integer, ForeignKey("vulnerability_findings.id"), nullable=True) + correlation_type = Column(String, nullable=False) + confidence = Column(Float, default=0.5) + details = Column(JSON, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + job = relationship("Job", back_populates="correlations") + scan = relationship("NetworkScan", back_populates="correlations") + vulnerability = relationship("VulnerabilityFinding", back_populates="correlations") + class User(Base): __tablename__ = "users" diff --git a/backend/app/pipelines/unified_pipeline.py b/backend/app/pipelines/unified_pipeline.py index 4fed34e..d119f6c 100644 --- a/backend/app/pipelines/unified_pipeline.py +++ b/backend/app/pipelines/unified_pipeline.py @@ -205,10 +205,10 @@ async def process(self, finally: db.close() - def verify_integrity(self, file_path: str, original_hash: str, job_id: str, investigator_id: str): - """Verifies if the current file hash matches the original chain of custody hash.""" - current_hash = self.hash_service.compute_file_hash(file_path) - matches = (current_hash == original_hash) + def verify_integrity(self, file_path: str, original_hash: str, job_id: str, investigator_id: str): + """Verifies if the current file hash matches the original chain of custody hash.""" + current_hash = self.storage_service.compute_stored_evidence_hash(file_path) + matches = (current_hash == original_hash) return { "success": True, @@ -220,4 +220,4 @@ def verify_integrity(self, file_path: str, original_hash: str, job_id: str, inve "verified_by": investigator_id, "timestamp": datetime.utcnow().isoformat() } - } \ No newline at end of file + } diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..fd0d301 --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Optional, Dict, Any +from sqlalchemy.orm import Session + +from app.models.sql_models import AuditLog + + +class AuditLogService: + """Append-only audit log writer for forensic actions.""" + + @staticmethod + def append( + db: Session, + action: str, + user_id: str, + job_id: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ) -> AuditLog: + entry = AuditLog( + job_id=job_id, + action=action, + user_id=user_id, + timestamp=datetime.utcnow(), + details=details or {} + ) + db.add(entry) + db.commit() + db.refresh(entry) + return entry diff --git a/backend/app/services/correlation.py b/backend/app/services/correlation.py new file mode 100644 index 0000000..e2d0a38 --- /dev/null +++ b/backend/app/services/correlation.py @@ -0,0 +1,59 @@ +from typing import List +from sqlalchemy.orm import Session + +from app.models.sql_models import ( + Job, + NetworkScan, + VulnerabilityFinding, + EvidenceCorrelation, + AuditLog +) + + +class CorrelationService: + """Correlates scan findings with evidence and related logs by case number.""" + + @staticmethod + def create_case_correlations(db: Session, scan: NetworkScan) -> List[EvidenceCorrelation]: + evidence_jobs = db.query(Job).filter(Job.case_number == scan.case_number).all() + vulnerabilities = db.query(VulnerabilityFinding).filter(VulnerabilityFinding.scan_id == scan.id).all() + created: List[EvidenceCorrelation] = [] + + for job in evidence_jobs: + job_logs_count = db.query(AuditLog).filter(AuditLog.job_id == job.id).count() + + if vulnerabilities: + for vuln in vulnerabilities: + corr = EvidenceCorrelation( + job_id=job.id, + scan_id=scan.id, + vulnerability_id=vuln.id, + correlation_type="case_vulnerability_link", + confidence=0.85 if vuln.risk_level == "High" else 0.7, + details={ + "reason": "Matched by case number and active vulnerability finding", + "risk_level": vuln.risk_level, + "audit_log_count": job_logs_count + } + ) + db.add(corr) + created.append(corr) + else: + corr = EvidenceCorrelation( + job_id=job.id, + scan_id=scan.id, + vulnerability_id=None, + correlation_type="case_scan_link", + confidence=0.6, + details={ + "reason": "Matched by case number", + "audit_log_count": job_logs_count + } + ) + db.add(corr) + created.append(corr) + + db.commit() + for item in created: + db.refresh(item) + return created diff --git a/backend/app/services/network_scanner.py b/backend/app/services/network_scanner.py new file mode 100644 index 0000000..0f8d558 --- /dev/null +++ b/backend/app/services/network_scanner.py @@ -0,0 +1,92 @@ +import re +import subprocess +import xml.etree.ElementTree as ET +import ipaddress +import socket +from typing import Dict, Any, List + + +class NetworkScannerService: + """Executes and parses nmap scan results.""" + + SAFE_TARGET_RE = re.compile(r"^[a-zA-Z0-9\.\-:]+$") + + @classmethod + def validate_target(cls, target: str) -> bool: + return bool(target and cls.SAFE_TARGET_RE.match(target)) + + @classmethod + def normalize_target(cls, target: str) -> str: + if not cls.validate_target(target): + raise ValueError("Invalid scan target.") + + try: + return str(ipaddress.ip_address(target)) + except ValueError: + resolved_ip = socket.gethostbyname(target) + return str(ipaddress.ip_address(resolved_ip)) + + @classmethod + def run_scan(cls, target: str) -> Dict[str, Any]: + safe_target_ip = cls.normalize_target(target) + # shell=False + IP-normalized target keeps command execution constrained. + command = ["nmap", "-sV", "-Pn", "-oX", "-", "--", safe_target_ip] + process = subprocess.run( + command, + capture_output=True, + text=True, + timeout=120, + shell=False, + check=False, + ) + + if process.returncode != 0: + raise RuntimeError(process.stderr.strip() or "Nmap scan failed.") + + xml_output = process.stdout + parsed = cls.parse_nmap_xml(xml_output) + return { + "command": " ".join(command), + "raw_output": xml_output, + "parsed": parsed + } + + @staticmethod + def parse_nmap_xml(xml_output: str) -> Dict[str, Any]: + root = ET.fromstring(xml_output) + hosts: List[Dict[str, Any]] = [] + + for host in root.findall("host"): + addresses = [a.attrib.get("addr") for a in host.findall("address") if a.attrib.get("addr")] + ports: List[Dict[str, Any]] = [] + ports_node = host.find("ports") + + if ports_node is not None: + for port in ports_node.findall("port"): + state_node = port.find("state") + service_node = port.find("service") + state = state_node.attrib.get("state") if state_node is not None else "unknown" + service_name = service_node.attrib.get("name") if service_node is not None else "" + product = service_node.attrib.get("product", "") if service_node is not None else "" + version = service_node.attrib.get("version", "") if service_node is not None else "" + version_text = " ".join([product, version]).strip() + + if state == "open": + ports.append( + { + "port": int(port.attrib.get("portid", 0)), + "protocol": port.attrib.get("protocol", "tcp"), + "state": state, + "service": service_name, + "version": version_text + } + ) + + hosts.append( + { + "addresses": addresses, + "ports": ports + } + ) + + return {"hosts": hosts} diff --git a/backend/app/services/pdf_generator.py b/backend/app/services/pdf_generator.py index b70eea7..a288455 100644 --- a/backend/app/services/pdf_generator.py +++ b/backend/app/services/pdf_generator.py @@ -373,11 +373,11 @@ def truncate_url(url, max_length=60): else: story.append(Paragraph("No chain of custody entries recorded.", styles['ForensicBodyText'])) - # ==================== METADATA SECTION ==================== - if job_details.metadata.exif_data: - story.append(Spacer(1, 25)) - story.append(PDFReportGenerator._create_section_header_table("EXIF METADATA", "📷")) - story.append(Spacer(1, 10)) + # ==================== METADATA SECTION ==================== + if job_details.metadata.exif_data: + story.append(Spacer(1, 25)) + story.append(PDFReportGenerator._create_section_header_table("EXIF METADATA", "📷")) + story.append(Spacer(1, 10)) exif_data = [] for key, value in job_details.metadata.exif_data.items(): @@ -386,8 +386,86 @@ def truncate_url(url, max_length=60): value_str = value_str[:100] + "..." exif_data.append([key, value_str]) - if exif_data: - story.append(PDFReportGenerator._create_info_table(exif_data)) + if exif_data: + story.append(PDFReportGenerator._create_info_table(exif_data)) + + # ==================== NETWORK SCAN RESULTS ==================== + if job_details.scan_results: + story.append(Spacer(1, 25)) + story.append(PDFReportGenerator._create_section_header_table("NETWORK SCAN RESULTS", "🌐")) + story.append(Spacer(1, 10)) + + scan_rows = [["Scan ID", "Target", "Status", "Open Ports"]] + for scan in job_details.scan_results: + open_ports = scan.get("ports", []) + ports_brief = ", ".join(str(p.get("port")) for p in open_ports[:6]) or "None" + if len(open_ports) > 6: + ports_brief += " ..." + scan_rows.append([ + scan.get("scan_id", "N/A"), + scan.get("target", "N/A"), + scan.get("status", "N/A"), + ports_brief + ]) + story.append(PDFReportGenerator._create_info_table(scan_rows, col_widths=[1.6*inch, 2*inch, 1*inch, 1.9*inch])) + + # ==================== VULNERABILITY FINDINGS ==================== + if job_details.vulnerabilities: + story.append(Spacer(1, 25)) + story.append(PDFReportGenerator._create_section_header_table("VULNERABILITY FINDINGS", "🛡️")) + story.append(Spacer(1, 10)) + + vuln_rows = [["CVE", "Service", "Version", "Risk", "Description"]] + for vuln in job_details.vulnerabilities[:25]: + desc = vuln.get("description", "") or "" + if len(desc) > 55: + desc = desc[:55] + "..." + vuln_rows.append([ + vuln.get("cve_id", "N/A"), + vuln.get("service", "N/A"), + vuln.get("version", "N/A"), + vuln.get("risk_level", "Low"), + desc + ]) + + vuln_table = Table(vuln_rows, colWidths=[1.3*inch, 1.1*inch, 1.2*inch, 0.7*inch, 2.2*inch]) + style_commands = [ + ('BACKGROUND', (0, 0), (-1, 0), ForensicColors.SECONDARY), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 9), + ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'), + ('FONTSIZE', (0, 1), (-1, -1), 8), + ('GRID', (0, 0), (-1, -1), 0.5, ForensicColors.BORDER), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('PADDING', (0, 0), (-1, -1), 5), + ] + for i in range(1, len(vuln_rows)): + risk = (vuln_rows[i][3] or "").lower() + if risk == "high": + style_commands.append(('BACKGROUND', (3, i), (3, i), colors.HexColor('#fed7d7'))) + elif risk == "medium": + style_commands.append(('BACKGROUND', (3, i), (3, i), colors.HexColor('#feebc8'))) + else: + style_commands.append(('BACKGROUND', (3, i), (3, i), colors.HexColor('#c6f6d5'))) + + vuln_table.setStyle(TableStyle(style_commands)) + story.append(vuln_table) + + # ==================== RISK ASSESSMENT ==================== + if job_details.risk_assessment: + story.append(Spacer(1, 25)) + story.append(PDFReportGenerator._create_section_header_table("RISK ASSESSMENT", "⚠️")) + story.append(Spacer(1, 10)) + + counts = job_details.risk_assessment.get("counts", {}) + risk_data = [ + ["Overall Risk:", job_details.risk_assessment.get("overall_risk", "Low")], + ["High Findings:", str(counts.get("High", 0))], + ["Medium Findings:", str(counts.get("Medium", 0))], + ["Low Findings:", str(counts.get("Low", 0))], + ] + story.append(PDFReportGenerator._create_info_table(risk_data)) # ==================== CERTIFICATION SECTION ==================== story.append(Spacer(1, 30)) @@ -573,4 +651,4 @@ def create_verification_report(job_id: str, except Exception as e: logger.error(f"Verification report generation failed: {str(e)}") - raise \ No newline at end of file + raise diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index ccfae7c..a39608a 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -1,16 +1,18 @@ import logging -import shutil -import json -from pathlib import Path -from datetime import datetime -import uuid -from typing import Dict, Any - -from app.core.config import settings +import json +from pathlib import Path +from datetime import datetime +import uuid +import os +import hashlib +from typing import Dict, Any, Optional +from cryptography.fernet import Fernet + +from app.core.config import settings logger = logging.getLogger(__name__) -class StorageService: +class StorageService: """Storage service for handling evidence files""" storage_type = settings.STORAGE_TYPE @@ -30,10 +32,10 @@ async def store_evidence(cls, file_path: str, job_id: str, metadata: Dict[str, A else: raise ValueError(f"Unsupported storage type: {cls.storage_type}") - @classmethod - async def _store_local(cls, file_path: str, job_id: str, metadata: Dict[str, Any]) -> Dict[str, Any]: - """Store file in local filesystem""" - source_path = Path(file_path) + @classmethod + async def _store_local(cls, file_path: str, job_id: str, metadata: Dict[str, Any]) -> Dict[str, Any]: + """Store file in local filesystem""" + source_path = Path(file_path) # Create job directory job_dir = Path(settings.LOCAL_STORAGE_PATH) / job_id @@ -45,21 +47,77 @@ async def _store_local(cls, file_path: str, job_id: str, metadata: Dict[str, Any if not file_ext: file_ext = source_path.suffix - storage_name = f"{uuid.uuid4().hex}{file_ext}" - dest_path = job_dir / storage_name - - # Copy file - shutil.copy2(source_path, dest_path) + storage_name = f"{uuid.uuid4().hex}.enc" + dest_path = job_dir / storage_name + + # Encrypt file before storage + fernet = cls._get_fernet() + with open(source_path, "rb") as source_file: + plaintext = source_file.read() + encrypted = fernet.encrypt(plaintext) + with open(dest_path, "wb") as dest_file: + dest_file.write(encrypted) # Create metadata file - metadata_file = job_dir / "metadata.json" - with open(metadata_file, 'w') as f: - json.dump(metadata, f, indent=2, default=str) + metadata_file = job_dir / "metadata.json" + metadata = { + **metadata, + "security": { + "encrypted_at_rest": True, + "algorithm": "Fernet", + "stored_extension": ".enc", + "original_extension": file_ext + } + } + with open(metadata_file, 'w') as f: + json.dump(metadata, f, indent=2, default=str) return { 'success': True, 'path': str(dest_path), 'location': f"local://{dest_path}", 'size': dest_path.stat().st_size, - 'stored_at': datetime.utcnow().isoformat() - } \ No newline at end of file + 'stored_at': datetime.utcnow().isoformat() + } + + @classmethod + def _get_fernet(cls) -> Fernet: + configured_key = getattr(settings, "EVIDENCE_ENCRYPTION_KEY", None) + if configured_key: + key = configured_key.encode() + else: + configured_key_path = Path(getattr(settings, "EVIDENCE_ENCRYPTION_KEY_FILE", "./evidence_encryption.key")) + if configured_key_path.is_absolute(): + key_file = configured_key_path + else: + key_file = Path(settings.LOCAL_STORAGE_PATH) / configured_key_path + key_file.parent.mkdir(parents=True, exist_ok=True) + if key_file.exists(): + key = key_file.read_bytes().strip() + else: + key = Fernet.generate_key() + key_file.write_bytes(key) + os.chmod(key_file, 0o600) + return Fernet(key) + + @classmethod + def read_decrypted_bytes(cls, encrypted_path: str) -> bytes: + fernet = cls._get_fernet() + with open(encrypted_path, "rb") as encrypted_file: + encrypted = encrypted_file.read() + return fernet.decrypt(encrypted) + + @classmethod + def compute_stored_evidence_hash(cls, storage_path: str) -> Optional[str]: + try: + if storage_path.endswith(".enc"): + content = cls.read_decrypted_bytes(storage_path) + return hashlib.sha256(content).hexdigest() + + sha256_hash = hashlib.sha256() + with open(storage_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + return sha256_hash.hexdigest() + except Exception: + return None diff --git a/backend/app/services/vulnerability_mapper.py b/backend/app/services/vulnerability_mapper.py new file mode 100644 index 0000000..41fb480 --- /dev/null +++ b/backend/app/services/vulnerability_mapper.py @@ -0,0 +1,93 @@ +from typing import List, Dict, Any +import re + + +class VulnerabilityMapperService: + """Maps discovered services to known CVEs using a simple internal dataset.""" + + CVE_DATASET = [ + { + "service_contains": "openssh", + "version_contains": "7.", + "cve_id": "CVE-2018-15473", + "risk_level": "Medium", + "description": "OpenSSH user enumeration vulnerability." + }, + { + "service_contains": "apache", + "version_contains": "2.4.49", + "cve_id": "CVE-2021-41773", + "risk_level": "High", + "description": "Apache path traversal and RCE risk." + }, + { + "service_contains": "samba", + "version_contains": "3.", + "cve_id": "CVE-2017-7494", + "risk_level": "High", + "description": "Samba remote code execution vulnerability." + }, + { + "service_contains": "nginx", + "version_contains": "1.14", + "cve_id": "CVE-2019-20372", + "risk_level": "Low", + "description": "Nginx error_page request smuggling condition." + }, + ] + + @staticmethod + def _service_matches(service: str, needle: str) -> bool: + if not service: + return False + tokens = [t for t in re.split(r"[^a-z0-9]+", service.lower()) if t] + return needle.lower() in tokens + + @staticmethod + def _version_matches(version: str, needle: str) -> bool: + if not version or not needle: + return False + pattern = rf"\b{re.escape(needle)}" + return re.search(pattern, version.lower()) is not None + + @classmethod + def map_ports(cls, ports: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + + for port in ports: + service = (port.get("service") or "").lower() + version = (port.get("version") or "").lower() + + for item in cls.CVE_DATASET: + if cls._service_matches(service, item["service_contains"]) and cls._version_matches(version, item["version_contains"]): + findings.append( + { + "port": port.get("port"), + "service": port.get("service"), + "version": port.get("version"), + "cve_id": item["cve_id"], + "risk_level": item["risk_level"], + "description": item["description"] + } + ) + + return findings + + @staticmethod + def summarize_risk(findings: List[Dict[str, Any]]) -> Dict[str, Any]: + counts = {"High": 0, "Medium": 0, "Low": 0} + for finding in findings: + level = finding.get("risk_level", "Low") + if level in counts: + counts[level] += 1 + + overall = "Low" + if counts["High"] > 0: + overall = "High" + elif counts["Medium"] > 0: + overall = "Medium" + + return { + "counts": counts, + "overall_risk": overall + } diff --git a/docs/media/feas-demo.webm b/docs/media/feas-demo.webm new file mode 100644 index 0000000..3ec0756 Binary files /dev/null and b/docs/media/feas-demo.webm differ diff --git a/docs/media/feas-home.png b/docs/media/feas-home.png new file mode 100644 index 0000000..e64453a Binary files /dev/null and b/docs/media/feas-home.png differ diff --git a/docs/media/feas-submission.png b/docs/media/feas-submission.png new file mode 100644 index 0000000..e64453a Binary files /dev/null and b/docs/media/feas-submission.png differ diff --git a/frontend/src/components/submission/NetworkScanInput.jsx b/frontend/src/components/submission/NetworkScanInput.jsx new file mode 100644 index 0000000..6f2f023 --- /dev/null +++ b/frontend/src/components/submission/NetworkScanInput.jsx @@ -0,0 +1,149 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { FaNetworkWired, FaSpinner, FaShieldAlt } from 'react-icons/fa'; +import { toast } from 'react-toastify'; +import { forensicAPI } from '../../services/api'; + +const Container = styled.div` + background: ${({ theme }) => theme.cardBackground}; + border: 1px solid ${({ theme }) => theme.cardBorder}; + border-radius: 8px; + padding: 2rem; +`; + +const Title = styled.h3` + font-size: 1.5rem; + color: ${({ theme }) => theme.text}; + margin-bottom: 0.5rem; +`; + +const Subtitle = styled.p` + color: ${({ theme }) => theme.textSecondary}; + margin-bottom: 1.5rem; + font-size: 0.875rem; +`; + +const Form = styled.form` + display: grid; + gap: 1rem; +`; + +const Input = styled.input` + background: ${({ theme }) => theme.background}; + border: 1px solid ${({ theme }) => theme.cardBorder}; + border-radius: 4px; + padding: 0.75rem 1rem; + color: ${({ theme }) => theme.text}; + font-family: var(--font-mono); +`; + +const SubmitButton = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + background: ${({ theme }) => theme.primary}; + color: ${({ theme }) => theme.cardBackground}; + border: none; + border-radius: 4px; + padding: 0.9rem 1.2rem; + cursor: pointer; + font-weight: 600; + opacity: ${({ disabled }) => (disabled ? 0.6 : 1)}; +`; + +const ResultBox = styled.pre` + margin-top: 1rem; + background: ${({ theme }) => theme.background}; + border: 1px solid ${({ theme }) => theme.cardBorder}; + border-radius: 6px; + padding: 1rem; + font-size: 0.75rem; + color: ${({ theme }) => theme.textSecondary}; + overflow-x: auto; +`; + +const SecurityNotice = styled.div` + margin-top: 1rem; + padding: 1rem; + background: ${({ theme }) => theme.success}10; + border: 1px solid ${({ theme }) => theme.success}20; + border-radius: 4px; + font-size: 0.75rem; + color: ${({ theme }) => theme.success}; + display: flex; + align-items: center; + gap: 0.5rem; +`; + +const Spinner = styled.div` + @keyframes spin { to { transform: rotate(360deg); } } + width: 14px; + height: 14px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 1s linear infinite; +`; + +const NetworkScanInput = ({ onSubmit }) => { + const [target, setTarget] = useState(''); + const [caseNumber, setCaseNumber] = useState(''); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + try { + const response = await forensicAPI.runNetworkScan({ + target, + case_number: caseNumber + }); + setResult(response); + if (onSubmit) onSubmit(response); + toast.success('Network scan completed'); + } catch (err) { + toast.error(`Scan failed: ${err.message}`); + } finally { + setLoading(false); + } + }; + + return ( + + <FaNetworkWired /> Network Scan + Scan a target IP/domain and map findings to case evidence. +
+ setTarget(e.target.value)} + required + /> + setCaseNumber(e.target.value)} + required + /> + + {loading ? <> Scanning... : 'Run Nmap Scan'} + +
+ + {result && ( + {JSON.stringify(result, null, 2)} + )} + + + + Scan actions are logged in append-only forensic audit records. + +
+ ); +}; + +export default NetworkScanInput; diff --git a/frontend/src/components/submission/SubmissionTabs.jsx b/frontend/src/components/submission/SubmissionTabs.jsx index 4e8b88b..445cf27 100644 --- a/frontend/src/components/submission/SubmissionTabs.jsx +++ b/frontend/src/components/submission/SubmissionTabs.jsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; import styled from 'styled-components'; -import { FaGlobe, FaUpload, FaCheckCircle, FaShieldAlt } from 'react-icons/fa'; -import { useNavigate } from 'react-router-dom'; -import URLInput from './URLInput'; -import FileUpload from './FileUpload'; +import { FaGlobe, FaUpload, FaCheckCircle, FaShieldAlt, FaNetworkWired } from 'react-icons/fa'; +import { useNavigate } from 'react-router-dom'; +import URLInput from './URLInput'; +import FileUpload from './FileUpload'; +import NetworkScanInput from './NetworkScanInput'; const Container = styled.div` background: ${({ theme }) => theme.cardBackground}; border: 1px solid ${({ theme }) => theme.cardBorder}; border-radius: 12px; overflow: hidden; `; const TabsHeader = styled.div` display: flex; border-bottom: 1px solid ${({ theme }) => theme.cardBorder}; background: ${({ theme }) => theme.background}; `; @@ -34,14 +35,17 @@ const SubmissionTabs = () => { <> setActiveTab('url')}> URL Acquisition - setActiveTab('upload')}> Local Upload - - - {activeTab === 'url' ? ( - - ) : ( - - )} + setActiveTab('upload')}> Local Upload + setActiveTab('scan')}> Network Scan + + + {activeTab === 'url' ? ( + + ) : activeTab === 'scan' ? ( + + ) : ( + + )} ) : ( diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 4a0594f..999d84c 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -71,8 +71,9 @@ export const forensicAPI = { getAllJobs: () => api.get('/jobs'), getJobStatus: (id) => api.get(`/jobs/${id}/status`), getJobDetails: (id) => api.get(`/jobs/${id}/details`), - verifyIntegrity: (id) => api.post(`/jobs/${id}/verify`), - getAnalytics: (period) => api.get(`/analytics?period=${period}`), + verifyIntegrity: (id) => api.post(`/jobs/${id}/verify`), + runNetworkScan: (data) => api.post('/scans/network', data), + getAnalytics: (period) => api.get(`/analytics?period=${period}`), downloadReport: async (jobId) => { const response = await axios.get(`${API_BASE_URL}/jobs/${jobId}/report`, { @@ -88,4 +89,4 @@ export const forensicAPI = { } }; -export default api; \ No newline at end of file +export default api;