Yog965/grc-ai-command-deck
0
1from __future__ import annotations2 3import base644import hashlib5import hmac6import io7import json8import os9import secrets10import threading11import time12import uuid13import zipfile14from datetime import datetime, timezone15from pathlib import Path16from typing import Any, Dict, List, Optional17 18import pandas as pd19from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request20from fastapi.responses import HTMLResponse, StreamingResponse21from fastapi.staticfiles import StaticFiles22from fastapi.templating import Jinja2Templates23from pydantic import BaseModel, Field24from reportlab.lib.pagesizes import letter25from reportlab.pdfgen import canvas26 27DEFAULT_NIST_JSON_URL = (28 "https://raw.githubusercontent.com/usnistgov/oscal-content/main/"29 "nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json"30)31 32app = FastAPI(title="GRC AI Dashboard", version="1.0.0")33app.mount("/static", StaticFiles(directory="static"), name="static")34templates = Jinja2Templates(directory="templates")35 36HISTORY_PATH = Path("data") / "run_history.json"37USERS_PATH = Path("data") / "users.json"38TOKENS: Dict[str, Dict[str, str]] = {}39JOBS: Dict[str, Dict[str, Any]] = {}40APP_ENV = os.getenv("APP_ENV", "development").strip().lower()41TOKEN_SECRET = os.getenv("GRC_TOKEN_SECRET", "grc-ai-demo-token-secret-change-in-prod")42TOKEN_TTL_SECONDS = int(os.getenv("GRC_TOKEN_TTL_SECONDS", "43200"))43FORCE_DEMO_USERS = os.getenv("GRC_FORCE_DEMO_USERS", "false").strip().lower() in {"1", "true", "yes"}44DEFAULT_DEMO_ADMIN_USER = os.getenv("GRC_DEMO_ADMIN_USER", "demo_admin").strip() or "demo_admin"45DEFAULT_DEMO_ADMIN_PASSWORD = os.getenv("GRC_DEMO_ADMIN_PASSWORD", "GrcAI_Demo@2026").strip() or "GrcAI_Demo@2026"46DEFAULT_DEMO_REVIEWER_USER = os.getenv("GRC_DEMO_REVIEWER_USER", "demo_reviewer").strip() or "demo_reviewer"47DEFAULT_DEMO_REVIEWER_PASSWORD = os.getenv("GRC_DEMO_REVIEWER_PASSWORD", "GrcAI_Review@2026").strip() or "GrcAI_Review@2026"48 49 50OUTPUT_SETS = {51 "outputs": Path("outputs"),52 "outputs_sample": Path("outputs_sample"),53 "demo_phase1": Path("demo_outputs") / "phase1",54 "demo_phase2": Path("demo_outputs") / "phase2",55 "demo_phase3": Path("demo_outputs") / "phase3",56 "demo_phase4": Path("demo_outputs") / "phase4",57}58 59 60class PipelineRunRequest(BaseModel):61 logs_count: int = Field(default=1000, ge=100, le=100000)62 similarity_threshold: float = Field(default=0.7, ge=0.0, le=1.0)63 output_set: str = Field(default="outputs")64 nist_source_url: str = Field(default=DEFAULT_NIST_JSON_URL)65 66 67class PipelineRunResponse(BaseModel):68 message: str69 artifacts: Dict[str, str]70 71 72class PipelineRunQueuedResponse(BaseModel):73 message: str74 job_id: str75 run_id: str76 77 78class LoginRequest(BaseModel):79 username: str80 password: str81 82 83class LoginResponse(BaseModel):84 access_token: str85 token_type: str = "bearer"86 username: str87 role: str88 89 90def _hash_password(password: str, salt: bytes | None = None) -> Dict[str, str]:91 salt_bytes = salt if salt is not None else os.urandom(16)92 digest = __import__("hashlib").pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, 120000)93 return {94 "salt": base64.b64encode(salt_bytes).decode("ascii"),95 "hash": base64.b64encode(digest).decode("ascii"),96 }97 98 99def _verify_password(password: str, salt_b64: str, hash_b64: str) -> bool:100 salt = base64.b64decode(salt_b64.encode("ascii"))101 expected = base64.b64decode(hash_b64.encode("ascii"))102 candidate = __import__("hashlib").pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 120000)103 return secrets.compare_digest(candidate, expected)104 105 106def _save_users(users: Dict[str, Dict[str, str]]) -> None:107 USERS_PATH.parent.mkdir(parents=True, exist_ok=True)108 USERS_PATH.write_text(json.dumps(users, indent=2), encoding="utf-8")109 110 111def _load_users() -> Dict[str, Dict[str, str]]:112 if not USERS_PATH.exists():113 return {}114 payload = _read_json(USERS_PATH, default={})115 if not isinstance(payload, dict):116 return {}117 return payload118 119 120def _ensure_default_users() -> None:121 users = _load_users()122 if users and not FORCE_DEMO_USERS:123 return124 125 env_admin_user = os.getenv("GRC_ADMIN_USER", "").strip()126 env_admin_pass = os.getenv("GRC_ADMIN_PASSWORD", "").strip()127 env_admin_role = os.getenv("GRC_ADMIN_ROLE", "admin").strip() or "admin"128 129 if env_admin_user and env_admin_pass:130 seeded = {131 env_admin_user: {132 **_hash_password(env_admin_pass),133 "role": env_admin_role,134 }135 }136 _save_users(seeded)137 return138 139 seeded = users.copy() if users else {}140 seeded[DEFAULT_DEMO_ADMIN_USER] = {141 **_hash_password(DEFAULT_DEMO_ADMIN_PASSWORD),142 "role": "admin",143 }144 seeded[DEFAULT_DEMO_REVIEWER_USER] = {145 **_hash_password(DEFAULT_DEMO_REVIEWER_PASSWORD),146 "role": "reviewer",147 }148 seeded["demo_analyst"] = {149 **_hash_password("GrcAI_Analyst@2026"),150 "role": "analyst",151 }152 _save_users(seeded)153 154 155def _safe_output_dir(output_set: str) -> Path:156 if output_set not in OUTPUT_SETS:157 raise HTTPException(status_code=400, detail=f"Unknown output_set: {output_set}")158 return OUTPUT_SETS[output_set]159 160 161def _read_json(path: Path, default: Any) -> Any:162 if not path.exists():163 return default164 with path.open("r", encoding="utf-8") as handle:165 return json.load(handle)166 167 168def _read_markdown(path: Path) -> str:169 if not path.exists():170 return "Report not generated yet. Run the pipeline from the dashboard."171 return path.read_text(encoding="utf-8")172 173 174def _collect_artifacts(base_dir: Path) -> Dict[str, Path]:175 return {176 "controls_json": base_dir / "nist_controls_rev5.json",177 "controls_csv": base_dir / "nist_controls_rev5.csv",178 "synthetic_logs_csv": base_dir / "synthetic_cloudtrail_logs.csv",179 "anomaly_csv": base_dir / "anomaly_scored_logs.csv",180 "mapping_json": base_dir / "sbert_mapping_results.json",181 "report_md": base_dir / "compliance_drift_report.md",182 }183 184 185def _summary_for_output_set(output_set: str) -> Dict[str, Any]:186 base_dir = _safe_output_dir(output_set)187 artifacts = _collect_artifacts(base_dir)188 189 summary: Dict[str, Any] = {190 "output_set": output_set,191 "base_dir": str(base_dir),192 "exists": base_dir.exists(),193 "controls_count": 0,194 "logs_count": 0,195 "drift_count": 0,196 "drift_rate": 0.0,197 "mapped_count": 0,198 "hitl_required_count": 0,199 "mitre_hitl_required_count": 0,200 }201 202 controls_path = artifacts["controls_csv"]203 if controls_path.exists():204 controls_df = pd.read_csv(controls_path)205 summary["controls_count"] = len(controls_df)206 207 anomaly_path = artifacts["anomaly_csv"]208 if anomaly_path.exists():209 anomaly_df = pd.read_csv(anomaly_path)210 summary["logs_count"] = len(anomaly_df)211 drift_mask = anomaly_df.get("DriftFlag", pd.Series([], dtype=object)) == "Potential Compliance Drift"212 drift_count = int(drift_mask.sum()) if len(anomaly_df) else 0213 summary["drift_count"] = drift_count214 summary["drift_rate"] = (drift_count / len(anomaly_df)) if len(anomaly_df) else 0.0215 216 mapping_path = artifacts["mapping_json"]217 mappings = _read_json(mapping_path, default=[])218 if isinstance(mappings, list):219 summary["mapped_count"] = len(mappings)220 summary["hitl_required_count"] = sum(1 for m in mappings if bool(m.get("hitl_required")))221 summary["mitre_hitl_required_count"] = sum(1 for m in mappings if bool(m.get("mitre_hitl_required")))222 223 return summary224 225 226def _load_history() -> List[Dict[str, Any]]:227 if not HISTORY_PATH.exists():228 return []229 with HISTORY_PATH.open("r", encoding="utf-8") as handle:230 payload = json.load(handle)231 if not isinstance(payload, list):232 return []233 return payload234 235 236def _save_history(items: List[Dict[str, Any]]) -> None:237 HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)238 HISTORY_PATH.write_text(json.dumps(items, indent=2), encoding="utf-8")239 240 241def _append_history(item: Dict[str, Any]) -> None:242 history = _load_history()243 history.append(item)244 _save_history(history)245 246 247def _get_history_item(run_id: str) -> Dict[str, Any] | None:248 for item in _load_history():249 if item.get("run_id") == run_id:250 return item251 return None252 253 254def _extract_token(authorization: str | None) -> str:255 if not authorization:256 raise HTTPException(status_code=401, detail="Missing Authorization header")257 if not authorization.lower().startswith("bearer "):258 raise HTTPException(status_code=401, detail="Authorization must be Bearer token")259 return authorization.split(" ", 1)[1].strip()260 261 262def _b64url_encode(data: bytes) -> str:263 return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")264 265 266def _b64url_decode(data: str) -> bytes:267 padding = "=" * (-len(data) % 4)268 return base64.urlsafe_b64decode((data + padding).encode("ascii"))269 270 271def _issue_token(username: str, role: str) -> str:272 payload = {273 "u": username,274 "r": role,275 "exp": int(time.time()) + TOKEN_TTL_SECONDS,276 }277 payload_str = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))278 sig = hmac.new(TOKEN_SECRET.encode("utf-8"), payload_str.encode("ascii"), hashlib.sha256).digest()279 sig_str = _b64url_encode(sig)280 return f"{payload_str}.{sig_str}"281 282 283def _verify_token(token: str) -> Dict[str, str] | None:284 try:285 payload_part, sig_part = token.split(".", 1)286 except ValueError:287 return None288 289 expected_sig = hmac.new(TOKEN_SECRET.encode("utf-8"), payload_part.encode("ascii"), hashlib.sha256).digest()290 provided_sig = _b64url_decode(sig_part)291 if not hmac.compare_digest(expected_sig, provided_sig):292 return None293 294 payload_raw = _b64url_decode(payload_part)295 payload = json.loads(payload_raw.decode("utf-8"))296 if int(payload.get("exp", 0)) < int(time.time()):297 return None298 299 username = str(payload.get("u", "")).strip()300 role = str(payload.get("r", "")).strip()301 if not username or not role:302 return None303 return {"username": username, "role": role}304 305 306def get_current_user(authorization: str | None = Header(default=None)) -> Dict[str, str]:307 token = _extract_token(authorization)308 user = _verify_token(token)309 if not user:310 raise HTTPException(status_code=401, detail="Invalid or expired token")311 return user312 313 314def require_roles(*allowed_roles: str):315 def _role_dependency(user: Dict[str, str] = Depends(get_current_user)) -> Dict[str, str]:316 if user.get("role") not in allowed_roles:317 raise HTTPException(status_code=403, detail="Insufficient role permissions")318 return user319 320 return _role_dependency321 322 323def _render_report_pdf(report_markdown: str) -> bytes:324 buffer = io.BytesIO()325 doc = canvas.Canvas(buffer, pagesize=letter)326 width, height = letter327 x = 40328 y = height - 40329 330 doc.setFont("Helvetica-Bold", 13)331 doc.drawString(x, y, "GRC AI Compliance Drift Report")332 y -= 24333 doc.setFont("Helvetica", 9)334 335 for raw_line in report_markdown.splitlines():336 line = raw_line if raw_line.strip() else " "337 for chunk_start in range(0, len(line), 110):338 chunk = line[chunk_start : chunk_start + 110]339 if y < 40:340 doc.showPage()341 doc.setFont("Helvetica", 9)342 y = height - 40343 doc.drawString(x, y, chunk)344 y -= 12345 346 doc.save()347 return buffer.getvalue()348 349 350def _run_pipeline_job(job_id: str, run_id: str, payload: PipelineRunRequest, user: Dict[str, str]) -> None:351 output_dir = _safe_output_dir(payload.output_set)352 output_dir.mkdir(parents=True, exist_ok=True)353 started_at = datetime.now(timezone.utc)354 355 JOBS[job_id].update(356 {357 "status": "running",358 "progress": 10,359 "stage": "Preparing output directory",360 "started_at": started_at.isoformat(),361 }362 )363 364 try:365 JOBS[job_id].update({"progress": 25, "stage": "Running GRC pipeline"})366 # Import lazily to avoid loading heavy ML dependencies during web app cold start.367 from grc_ai_pipeline import run_pipeline368 369 outputs = run_pipeline(370 nist_source_url=payload.nist_source_url,371 logs_count=payload.logs_count,372 output_dir=output_dir,373 similarity_threshold=payload.similarity_threshold,374 interactive_hitl=False,375 )376 JOBS[job_id].update({"progress": 85, "stage": "Computing summary and saving run history"})377 378 summary = _summary_for_output_set(payload.output_set)379 status = "completed"380 error_detail = ""381 except Exception as exc:382 outputs = {}383 summary = {"output_set": payload.output_set}384 status = "failed"385 error_detail = str(exc)386 387 finished_at = datetime.now(timezone.utc)388 history_item = {389 "run_id": run_id,390 "job_id": job_id,391 "started_at": started_at.isoformat(),392 "finished_at": finished_at.isoformat(),393 "duration_seconds": round((finished_at - started_at).total_seconds(), 3),394 "status": status,395 "triggered_by": user["username"],396 "role": user["role"],397 "params": payload.model_dump(),398 "summary": summary,399 "artifacts": {name: str(path) for name, path in outputs.items()},400 "error": error_detail,401 }402 _append_history(history_item)403 404 JOBS[job_id].update(405 {406 "status": status,407 "progress": 100,408 "stage": "Completed" if status == "completed" else "Failed",409 "finished_at": finished_at.isoformat(),410 "error": error_detail,411 "summary": summary,412 "artifacts": history_item["artifacts"],413 "run_id": run_id,414 }415 )416 417 418_ensure_default_users()419 420 421@app.on_event("startup")422def startup_event():423 if APP_ENV == "production":424 print("Production environment detected. Skipping startup cache warming to conserve memory.")425 return426 427 # Warm up GRC AI pipeline cache in a background thread to prevent blocking FastAPI startup428 def warm_cache():429 try:430 print("Warming up GRC AI pipeline model and controls cache...")431 from grc_ai_pipeline import fetch_nist_controls, NISTControlMapper, DEFAULT_NIST_JSON_URL432 from grc_ai_pipeline import load_mitre_attack_techniques, MITREAttackMapper433 434 # Fetch controls (will read from cache if it exists, or fetch once)435 controls_df = fetch_nist_controls(DEFAULT_NIST_JSON_URL)436 # Initialize mapper (will load model and cache embeddings)437 _ = NISTControlMapper(controls_df)438 439 # Fetch and warm MITRE techniques440 mitre_df = load_mitre_attack_techniques()441 _ = MITREAttackMapper(mitre_df)442 443 print("GRC AI pipeline cache warmed up successfully.")444 except Exception as e:445 print(f"Failed to warm up cache: {e}")446 447 threading.Thread(target=warm_cache, daemon=True).start()448 449 450@app.get("/", response_class=HTMLResponse)451def index(request: Request) -> HTMLResponse:452 return templates.TemplateResponse(request=request, name="index.html", context={})453 454 455@app.post("/api/login", response_model=LoginResponse)456def login(payload: LoginRequest) -> LoginResponse:457 users = _load_users()458 record = users.get(payload.username)459 if not record:460 raise HTTPException(status_code=401, detail="Invalid username or password")461 462 salt_b64 = record.get("salt", "")463 hash_b64 = record.get("hash", "")464 if not salt_b64 or not hash_b64 or not _verify_password(payload.password, salt_b64, hash_b64):465 raise HTTPException(status_code=401, detail="Invalid username or password")466 467 token = _issue_token(payload.username, record["role"])468 return LoginResponse(access_token=token, username=payload.username, role=record["role"])469 470 471@app.get("/api/me")472def me(user: Dict[str, str] = Depends(get_current_user)) -> Dict[str, str]:473 return user474 475 476@app.get("/api/output-sets")477def list_output_sets(user: Dict[str, str] = Depends(get_current_user)) -> Dict[str, List[str]]:478 return {"output_sets": list(OUTPUT_SETS.keys())}479 480 481@app.get("/api/summary")482def get_summary(483 output_set: str = Query(default="outputs"),484 user: Dict[str, str] = Depends(get_current_user),485) -> Dict[str, Any]:486 return _summary_for_output_set(output_set)487 488 489@app.get("/api/artifacts")490def get_artifacts(491 output_set: str = Query(default="outputs"),492 user: Dict[str, str] = Depends(get_current_user),493) -> Dict[str, Any]:494 base_dir = _safe_output_dir(output_set)495 artifacts = _collect_artifacts(base_dir)496 497 rows = []498 for name, path in artifacts.items():499 rows.append(500 {501 "name": name,502 "path": str(path),503 "exists": path.exists(),504 "size_bytes": path.stat().st_size if path.exists() else 0,505 }506 )507 508 return {"output_set": output_set, "artifacts": rows}509 510 511@app.get("/api/mappings")512def get_mappings(513 output_set: str = Query(default="outputs"),514 limit: int = Query(default=20, ge=1, le=500),515 user: Dict[str, str] = Depends(get_current_user),516) -> Dict[str, Any]:517 base_dir = _safe_output_dir(output_set)518 mapping_path = base_dir / "sbert_mapping_results.json"519 mappings = _read_json(mapping_path, default=[])520 521 if not isinstance(mappings, list):522 raise HTTPException(status_code=500, detail="Mapping JSON format is invalid")523 524 return {525 "output_set": output_set,526 "total": len(mappings),527 "items": mappings[:limit],528 }529 530 531@app.get("/api/report")532def get_report(533 output_set: str = Query(default="outputs"),534 user: Dict[str, str] = Depends(get_current_user),535) -> Dict[str, str]:536 base_dir = _safe_output_dir(output_set)537 return {"output_set": output_set, "report_markdown": _read_markdown(base_dir / "compliance_drift_report.md")}538 539 540@app.get("/api/run-history")541def get_run_history(542 limit: int = Query(default=25, ge=1, le=500),543 user: Dict[str, str] = Depends(get_current_user),544) -> Dict[str, Any]:545 history = _load_history()546 history = sorted(history, key=lambda item: item.get("started_at", ""), reverse=True)547 return {"total": len(history), "items": history[:limit]}548 549 550@app.get("/api/run-history/{run_id}")551def get_run_detail(552 run_id: str,553 user: Dict[str, str] = Depends(get_current_user),554) -> Dict[str, Any]:555 item = _get_history_item(run_id)556 if not item:557 raise HTTPException(status_code=404, detail=f"Run not found: {run_id}")558 559 output_set = item.get("params", {}).get("output_set", "outputs")560 base_dir = _safe_output_dir(output_set)561 artifacts = _collect_artifacts(base_dir)562 artifact_rows = []563 for name, path in artifacts.items():564 artifact_rows.append(565 {566 "name": name,567 "path": str(path),568 "exists": path.exists(),569 "size_bytes": path.stat().st_size if path.exists() else 0,570 }571 )572 573 summary = item.get("summary", {})574 chart = {575 "labels": ["Drift", "Normal"],576 "values": [577 int(summary.get("drift_count", 0)),578 max(int(summary.get("logs_count", 0)) - int(summary.get("drift_count", 0)), 0),579 ],580 }581 582 return {583 "run": item,584 "artifacts": artifact_rows,585 "chart": chart,586 }587 588 589@app.get("/api/jobs/{job_id}")590def get_job_status(591 job_id: str,592 user: Dict[str, str] = Depends(get_current_user),593) -> Dict[str, Any]:594 job = JOBS.get(job_id)595 if not job:596 raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")597 return job598 599 600@app.get("/api/export/report.pdf")601def export_report_pdf(602 output_set: str = Query(default="outputs"),603 user: Dict[str, str] = Depends(get_current_user),604) -> StreamingResponse:605 base_dir = _safe_output_dir(output_set)606 report = _read_markdown(base_dir / "compliance_drift_report.md")607 pdf_bytes = _render_report_pdf(report)608 609 return StreamingResponse(610 io.BytesIO(pdf_bytes),611 media_type="application/pdf",612 headers={"Content-Disposition": f'attachment; filename="{output_set}_compliance_report.pdf"'},613 )614 615 616@app.get("/api/export/csv-bundle")617def export_csv_bundle(618 output_set: str = Query(default="outputs"),619 user: Dict[str, str] = Depends(get_current_user),620) -> StreamingResponse:621 base_dir = _safe_output_dir(output_set)622 artifacts = _collect_artifacts(base_dir)623 624 csv_targets = {625 "nist_controls_rev5.csv": artifacts["controls_csv"],626 "synthetic_cloudtrail_logs.csv": artifacts["synthetic_logs_csv"],627 "anomaly_scored_logs.csv": artifacts["anomaly_csv"],628 }629 630 zip_buffer = io.BytesIO()631 with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:632 for archive_name, source_path in csv_targets.items():633 if source_path.exists():634 archive.write(source_path, arcname=archive_name)635 report_path = artifacts["report_md"]636 if report_path.exists():637 archive.write(report_path, arcname="compliance_drift_report.md")638 639 zip_buffer.seek(0)640 return StreamingResponse(641 zip_buffer,642 media_type="application/zip",643 headers={"Content-Disposition": f'attachment; filename="{output_set}_grc_bundle.zip"'},644 )645 646 647@app.post("/api/run-pipeline", response_model=PipelineRunQueuedResponse)648def run_full_pipeline(649 payload: PipelineRunRequest,650 user: Dict[str, str] = Depends(require_roles("analyst", "admin")),651) -> PipelineRunQueuedResponse:652 run_id = str(uuid.uuid4())653 job_id = str(uuid.uuid4())654 JOBS[job_id] = {655 "job_id": job_id,656 "run_id": run_id,657 "status": "queued",658 "progress": 0,659 "stage": "Queued",660 "submitted_at": datetime.now(timezone.utc).isoformat(),661 "submitted_by": user["username"],662 "role": user["role"],663 "params": payload.model_dump(),664 }665 666 worker = threading.Thread(target=_run_pipeline_job, args=(job_id, run_id, payload, user), daemon=True)667 worker.start()668 669 return PipelineRunQueuedResponse(670 message="Pipeline job queued successfully.",671 job_id=job_id,672 run_id=run_id,673 )674 675 676class ResolveMappingRequest(BaseModel):677 output_set: str = Field(default="outputs")678 raw_log: str679 selected_control_id: Optional[str] = None680 selected_mitre_id: Optional[str] = None681 hitl_decision: str682 683 684@app.post("/api/mappings/resolve")685def resolve_mapping(686 payload: ResolveMappingRequest,687 user: Dict[str, str] = Depends(require_roles("admin", "analyst", "reviewer")),688):689 base_dir = _safe_output_dir(payload.output_set)690 mapping_path = base_dir / "sbert_mapping_results.json"691 692 if not mapping_path.exists():693 raise HTTPException(status_code=404, detail="Mapping file not found. Run pipeline first.")694 695 mappings = _read_json(mapping_path, default=[])696 if not isinstance(mappings, list):697 raise HTTPException(status_code=500, detail="Invalid mapping file format.")698 699 found = False700 for m in mappings:701 if m.get("RawLog") == payload.raw_log:702 if payload.selected_control_id is not None:703 m["selected_control_id"] = payload.selected_control_id704 m["hitl_decision"] = f"Human verified: {payload.hitl_decision} (by {user['username']})"705 m["hitl_required"] = False706 if payload.selected_mitre_id is not None:707 m["selected_mitre_id"] = payload.selected_mitre_id708 m["mitre_hitl_decision"] = f"Human verified: {payload.hitl_decision} (by {user['username']})"709 m["mitre_hitl_required"] = False710 found = True711 break712 713 if not found:714 raise HTTPException(status_code=404, detail="Mapping item not found")715 716 # Save back to file717 mapping_path.write_text(json.dumps(mappings, indent=2), encoding="utf-8")718 719 # Regenerate markdown report!720 anomaly_path = base_dir / "anomaly_scored_logs.csv"721 report_md = base_dir / "compliance_drift_report.md"722 if anomaly_path.exists():723 scored_df = pd.read_csv(anomaly_path)724 flagged_df = scored_df[scored_df["DriftFlag"] == "Potential Compliance Drift"].copy().reset_index(drop=True)725 from grc_ai_pipeline import generate_markdown_report726 generate_markdown_report(flagged_df, mappings, report_md)727 728 return {"message": "Mapping decision resolved successfully."}729 