Download app/api.py from MKR-24/VulnGraph: direct link, hf CLI and curl.
- Browser
- Download file 16 kB
-
https://huggingface.co/spaces/MKR-24/VulnGraph/resolve/main/app/api.py
- Command line
-
hf download hf://spaces/MKR-24/VulnGraph/app/api.py
-
curl -L -o api.py https://huggingface.co/spaces/MKR-24/VulnGraph/resolve/main/app/api.py
16 kB
| """ | |
| api.py β FastAPI backend for VulnGraph | |
| Endpoints: | |
| GET /health β service health check | |
| GET /stats β scan metrics | |
| GET /findings β all findings (optional ?severity=HIGH&source=bandit) | |
| GET /findings/{id} β single finding with LLM explanation | |
| GET /graph β attack path data as JSON for visualization | |
| POST /scan β trigger full scan + load to Neo4j (background task) | |
| POST /explain β trigger LLM explanation generation (background task) | |
| Run with: | |
| uvicorn api:app --reload --port 8000 | |
| """ | |
| from fastapi import FastAPI, HTTPException, BackgroundTasks, Query | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| from typing import Optional | |
| from neo4j import GraphDatabase | |
| from dotenv import load_dotenv | |
| import os | |
| from pathlib import Path | |
| from agent import run_agent_for_finding | |
| load_dotenv() | |
| #Config | |
| NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687") | |
| NEO4J_USER = os.getenv("NEO4J_USER", "neo4j") | |
| NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "vulngraph123") | |
| #APP | |
| app= FastAPI( | |
| title="VulnGraph API", | |
| description="REST API for the VulnGraph ASPM Platform, Exposes scan results,attack graph data, and LLM generated vulnerability explanations.", | |
| version="0.1.0", | |
| ) | |
| #Allow Streamlit frontend to call this api | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["http://localhost:8501","http://localhost:3000","*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| #Neo4j | |
| def get_driver(): | |
| return GraphDatabase.driver(NEO4J_URI,auth=(NEO4J_USER,NEO4J_PASSWORD)) | |
| #Pydantic models | |
| class HealthResponse(BaseModel): | |
| status:str | |
| neo4j:str | |
| ollama:str | |
| version:str="0.1.0" | |
| class StatsResponse(BaseModel): | |
| files:int | |
| vulnerabilities:int | |
| secrets:int | |
| high_critical: int | |
| total_findings: int | |
| explained:int | |
| class FindingSummary(BaseModel): | |
| """Lightweight finding - used in list response.""" | |
| node_id:str | |
| type:str | |
| id:str | |
| severity:str | |
| source:str | |
| file:Optional[str] = None | |
| has_explanation: bool = False | |
| class FindingDetail(BaseModel): | |
| """Full finding with LLM explanation - used in single-item responses.""" | |
| node_id:str | |
| type:str | |
| id:str | |
| severity:str | |
| source:str | |
| file:Optional[str]=None | |
| text:Optional[str] = None | |
| explanation:Optional[str]=None | |
| why_dangerous: Optional[str]=None | |
| fix:Optional[str]=None | |
| cwe:Optional[str]=None | |
| llm_model:Optional[str]=None | |
| class GraphNode(BaseModel): | |
| id:str | |
| label:str | |
| properties:dict | |
| class GraphEdge(BaseModel): | |
| source:str | |
| target:str | |
| relationship:str # Has Vulnerability / contains | |
| class GraphResponse(BaseModel): | |
| nodes:list[GraphNode] | |
| edges:list[GraphEdge] | |
| node_count:int | |
| edge_count:int | |
| class ScanResponse(BaseModel): | |
| status:str | |
| message:str | |
| class ExplainResponse(BaseModel): | |
| status:str | |
| message:str | |
| class AgentRequest(BaseModel): | |
| finding_id: str = Field(..., description="Finding ID to analyze e.g. B404") | |
| file_path: Optional[str] = Field(None, description="Optional file path hint") | |
| class AgentResponse(BaseModel): | |
| finding_id: str | |
| final_answer: str | |
| patch: dict | |
| steps_taken: int | |
| tools_called: list[str] | |
| duration_sec: float | |
| model: str | |
| timestamp: str | |
| class SBOMResponse(BaseModel): | |
| format: str | |
| path: str | |
| component_count: int | |
| sbom: dict | |
| class RepoScanRequest(BaseModel): | |
| repo_url: str = Field(..., description="Public GitHub repo URL") | |
| def get_sbom( | |
| format: str = Query("cyclonedx", description="SBOM format: cyclonedx or spdx-json"), | |
| target: Optional[str] = Query(None, description="Target path to scan") | |
| ): | |
| """ | |
| Generate and return a Software Bill of Materials. | |
| Supports CycloneDX (OWASP) and SPDX (Linux Foundation) formats. | |
| """ | |
| try: | |
| from scanner import generate_sbom | |
| result = generate_sbom(path=target, format=format) | |
| if "error" in result: | |
| raise HTTPException(status_code=500, detail=result["error"]) | |
| return result | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # Background task functions | |
| def run_scan_task(): | |
| """Run all scanners and load findings to Neo4j""" | |
| try: | |
| from scanner import scan_all | |
| from main import clear_and_load_data | |
| clear_and_load_data() | |
| print("[api] Background scan completed") | |
| except Exception as e: | |
| print(f"[api] Background scan failed: {e}") | |
| def run_explain_task(): | |
| """Generate LLM explanations for all unexplained findings.""" | |
| try: | |
| from llm import explain_all_findings | |
| results= explain_all_findings() | |
| print(f"[api] Background explain complete:{results}") | |
| except Exception as e: | |
| print(f"[api] Background explain failed: {e}") | |
| #Endpoints | |
| def health_check(): | |
| """ | |
| Check if VulnGraph services are running. | |
| """ | |
| neo4j_status="unreachable" | |
| ollama_status="unreachable" | |
| #Neo4j | |
| try: | |
| with get_driver().session() as session: | |
| session.run("RETURN 1") | |
| neo4j_status="connected" | |
| except Exception: | |
| pass | |
| #Ollama | |
| try: | |
| import requests | |
| r=requests.get(f"{os.getenv('OLLAMA_URL','http://localhost:11434')}",timeout=3) | |
| if r.status_code==200: | |
| ollama_status="running" | |
| except Exception: | |
| pass | |
| overall="healthy" if neo4j_status=="connected" else "degraded" | |
| return HealthResponse(status=overall,neo4j=neo4j_status,ollama=ollama_status) | |
| def get_status(): | |
| """ | |
| Return scan metrics: file count, vulnerability count, secret count, | |
| high/critical count, total findings and number of LLM explanations. | |
| """ | |
| try: | |
| with get_driver().session() as session: | |
| row= session.run(""" | |
| MATCH (f:File) WITH count(f) AS files | |
| OPTIONAL MATCH (v:Vulnerability) WITH files, count(v) AS vulns | |
| OPTIONAL MATCH (s:Secret) WITH files,vulns, count(s) AS secrets | |
| OPTIONAL MATCH (v2:Vulnerability) WHERE toUpper(v2.severity) IN ['CRITICAL','HIGH'] | |
| WITH files , vulns, secrets, count(v2) AS high_critical | |
| OPTIONAL MATCH (n) WHERE (n:Vulnerability OR n:Secret) AND n.explanation IS NOT NULL | |
| RETURN files, vulns,secrets,high_critical, count(n) AS explained | |
| """).single() | |
| if not row: | |
| raise HTTPException(status_code=404, detail="No data found-Scan first") | |
| return StatsResponse( | |
| files=row["files"], | |
| vulnerabilities=row["vulns"], | |
| secrets=row["secrets"], | |
| high_critical=row["high_critical"], | |
| total_findings=row["vulns"]+row["secrets"], | |
| explained=row["explained"] | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"DB error:{str(e)}") | |
| def get_findings( | |
| severity: Optional[str] = Query(None, description="Filter by severity: LOW, MEDIUM, HIGH, CRITICAL"), | |
| source: Optional[str]=Query(None,description="Filter by scanner: bandit,trivy,gitleaks"), | |
| limit:int=Query(50, ge=1, le=500, description="Max results to return") | |
| ): | |
| """ | |
| Return all findings | |
| GET/findings | |
| GET/findings?severity=HIGH | |
| GET /findings?source=bandit&severity=LOW | |
| GET /findings?limit=10 | |
| """ | |
| try: | |
| with get_driver().session() as session: | |
| where_clauses=[] | |
| params={"limit":limit} | |
| if severity: | |
| where_clauses.append("toUpper(n.severity)=toUpper($severity)") | |
| params["severity"]=severity | |
| if source: | |
| where_clauses.append("n.source=$source") | |
| params["source"]=source | |
| and_str=("AND "+" AND ".join(where_clauses)) if where_clauses else "" | |
| res=session.run(f""" | |
| MATCH (f:File)-[]->(n) | |
| WHERE n:Vulnerability OR n:Secret | |
| {and_str} | |
| RETURN elementId(n) AS node_id, | |
| labels(n)[0] AS type, | |
| CASE WHEN n:Vulnerability THEN n.id ELSE n.rule END AS id, | |
| CASE WHEN n:Vulnerability THEN coalesce(n.severity,'UNKNOWN') Else 'SECRET' END AS severity, | |
| coalesce(n.source,'unknown') AS source, | |
| f.path AS file, | |
| n.explanation IS NOT NULL AS has_explanation | |
| LIMIT $limit | |
| """, **params) | |
| return [ | |
| FindingSummary( | |
| node_id = row["node_id"], | |
| type=row["type"], | |
| id=row["id"] or "UNKNOWN", | |
| severity=row["severity"], | |
| source=row["source"], | |
| file=row["file"], | |
| has_explanation=row["has_explanation"] | |
| ) | |
| for row in res | |
| ] | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"DB error:{str(e)}") | |
| def get_finding(finding_id:str): | |
| """ | |
| Return single finding by id with full LLM explanation | |
| """ | |
| try: | |
| with get_driver().session() as session: | |
| res=session.run(""" | |
| MATCH (f:File)-[]->(n) | |
| WHERE (n:Vulnerability OR n:Secret) | |
| AND (n.id =$fid OR n.rule =$fid) | |
| RETURN elementId(n) AS node_id, | |
| labels(n)[0] AS type, | |
| CASE WHEN n:Vulnerability THEN n.id ELSE n.rule END AS id, | |
| CASE WHEN n:Vulnerability THEN coalesce(n.severity,'UNKNOWN') ELSE 'SECRET' END AS severity, | |
| coalesce(n.source,'unknown') AS source, | |
| f.path AS file, | |
| coalesce(n.text,n.title,'') AS text, | |
| n.explanation AS explanation, | |
| n.why_dangerous AS why_dangerous, | |
| n.fix as fix, | |
| n.cwe AS cwe, | |
| n.llm_model AS llm_model | |
| LIMIT 1 | |
| """, fid=finding_id).single() | |
| if res is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail=f"Finding '{finding_id} not found. Check ID or run scan" | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500,detail=f"DB error:{str(e)}") | |
| return FindingDetail( | |
| node_id=res["node_id"], | |
| type=res["type"], | |
| id=res["id"] or finding_id, | |
| severity=res['severity'], | |
| source=res["source"], | |
| file=res["file"], | |
| text=res["text"], | |
| explanation=res["explanation"], | |
| why_dangerous=res["why_dangerous"], | |
| fix=res["fix"], | |
| cwe=res["cwe"], | |
| llm_model=res["llm_model"] | |
| ) | |
| def get_graph(limit:int=Query(200,ge=1,le=1000)): | |
| """ | |
| Return attack path graph data as JSON-nodes and edges. | |
| Node labels: File, Vulnerability,Secret | |
| Edge types: HAS_Vulnerability, CONTAINS | |
| """ | |
| try: | |
| with get_driver().session() as session: | |
| edge_res=session.run(""" | |
| MATCH (n)-[r]->(m) | |
| RETURN elementId(n) AS src_id, | |
| elementId(m) AS tgt_id, | |
| labels(n)[0] AS src_label, | |
| labels(m)[0] AS tgt_label, | |
| type(r) AS rel_type, | |
| properties(n) AS src_props, | |
| properties(m) AS tgt_props | |
| LIMIT $limit | |
| """,limit=limit) | |
| nodes={} | |
| edges=[] | |
| for row in edge_res: | |
| # Source node add | |
| if row["src_id"] not in nodes: | |
| nodes[row["src_id"]] = GraphNode( | |
| id=row["src_id"], | |
| label=row["src_label"], | |
| properties=dict(row["src_props"]) | |
| ) | |
| # Add target node | |
| if row["tgt_id"] not in nodes: | |
| nodes[row["tgt_id"]] = GraphNode( | |
| id=row["tgt_id"], | |
| label=row["tgt_label"], | |
| properties=dict(row["tgt_props"]) | |
| ) | |
| # Add edge | |
| edges.append(GraphEdge( | |
| source=row["src_id"], | |
| target=row["tgt_id"], | |
| relationship=row["rel_type"] | |
| )) | |
| return GraphResponse( | |
| nodes=list(nodes.values()), | |
| edges=edges, | |
| node_count=len(nodes), | |
| edge_count=len(edges) | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500,detail=f"DB error:{str(e)}") | |
| def trigger_scan(background_task:BackgroundTasks): | |
| """ | |
| Full scan start in background | |
| Returns 202 if the server accepts | |
| """ | |
| background_task.add_task(run_scan_task) | |
| return ScanResponse( | |
| status="accepted", | |
| message="Scan started in background." | |
| ) | |
| def trigger_explain(background_tasks: BackgroundTasks): | |
| """ | |
| LLM explanation for unexplained findings | |
| """ | |
| background_tasks.add_task(run_explain_task) | |
| return ExplainResponse( | |
| status="accepted", | |
| message="Explanation generation started" | |
| ) | |
| async def agent_fix(request: AgentRequest, background_tasks: BackgroundTasks): | |
| """ | |
| Synchronous endpoint β blocks for 60-120 seconds. | |
| For production use, convert to async with a job queue. | |
| """ | |
| import asyncio | |
| loop = asyncio.get_event_loop() | |
| result = await loop.run_in_executor( | |
| None, | |
| run_agent_for_finding, | |
| request.finding_id, | |
| request.file_path | |
| ) | |
| return AgentResponse(**result) | |
| def clone_and_scan(repo_url: str): | |
| import subprocess | |
| import tempfile | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| result = subprocess.run( | |
| ["git", "clone", "--depth", "1", repo_url, tmpdir], | |
| capture_output=True, text=True, timeout=120 | |
| ) | |
| if result.returncode != 0: | |
| print(f"[api] Clone failed: {result.stderr}") | |
| return | |
| from scanner import scan_all | |
| findings = scan_all(target_dir=tmpdir) | |
| print(f"[api] Scan complete: { {k: len(v) for k, v in findings.items()} }") | |
| async def scan_repo(request: RepoScanRequest, background_tasks: BackgroundTasks): | |
| background_tasks.add_task(clone_and_scan, request.repo_url) | |
| return {"status": "accepted", "repo": request.repo_url} | |
| #Entry point | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("api:app",host="0.0.0.0",port=8000,reload=True) |