CoolFace
Apppublic

Yog965/grc-ai-command-deck

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
app.py729 linesDownload Raw Back to root
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