File size: 15,992 Bytes
c9014d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d69f807
c9014d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d69f807
 
 
 
 
 
 
 
 
 
 
 
c9014d5
e233eaf
 
 
 
 
 
85dc44c
 
 
e233eaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9014d5
 
 
d69f807
c9014d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d69f807
c9014d5
 
 
 
d69f807
 
c9014d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d69f807
c9014d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d69f807
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85dc44c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e233eaf
c9014d5
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
"""
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")
    
@app.get("/sbom", tags=["SBOM"])
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
@app.get("/health",response_model=HealthResponse,tags=["System"])
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)

@app.get("/stats", response_model=StatsResponse, tags=["Findings"])
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)}")

@app.get("/findings", response_model=list[FindingSummary], tags=["Findings"])
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)}")

@app.get("/findings/{finding_id}", response_model=FindingDetail, tags=["Findings"])
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"]
    )
    

@app.get("/graph", response_model=GraphResponse,tags=["Graph"])
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)}")

@app.post("/scan",response_model=ScanResponse, status_code=202,tags=["Actions"])
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."
    )

@app.post("/explain",response_model=ExplainResponse,status_code=202,tags=["Actions"])
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"
    )

@app.post("/agent/fix", response_model=AgentResponse, tags=["Agent"])
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()} }")
    
@app.post("/scan/repo", tags=["Actions"], status_code=202)
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)