Açıklama Yok

main.py 30KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832
  1. import asyncio
  2. import logging
  3. from datetime import datetime, timezone
  4. from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
  5. from app.adapters.abuseipdb import AbuseIpdbAdapter
  6. from app.adapters.iris import IrisAdapter
  7. from app.adapters.pagerduty import PagerDutyAdapter
  8. from app.adapters.shuffle import ShuffleAdapter
  9. from app.adapters.virustotal import VirusTotalAdapter
  10. from app.adapters.wazuh import WazuhAdapter
  11. from app.config import settings
  12. from app.db import init_schema
  13. from app.models import (
  14. ActionCreateIncidentRequest,
  15. ApiResponse,
  16. IocEnrichRequest,
  17. IocEvaluateRequest,
  18. IrisTicketCreateRequest,
  19. ShuffleLoginRequest,
  20. ShuffleProxyRequest,
  21. TriggerShuffleRequest,
  22. WazuhIngestRequest,
  23. )
  24. from app.repositories.mvp_repo import MvpRepository
  25. from app.routes.mvp import build_mvp_router
  26. from app.security import require_internal_api_key
  27. from app.services.mvp_service import MvpService
  28. app = FastAPI(title=settings.app_name, version="0.1.0")
  29. logger = logging.getLogger(__name__)
  30. wazuh_adapter = WazuhAdapter(
  31. base_url=settings.wazuh_base_url,
  32. username=settings.wazuh_username,
  33. password=settings.wazuh_password,
  34. indexer_url=settings.wazuh_indexer_url,
  35. indexer_username=settings.wazuh_indexer_username,
  36. indexer_password=settings.wazuh_indexer_password,
  37. )
  38. shuffle_adapter = ShuffleAdapter(
  39. base_url=settings.shuffle_base_url,
  40. api_key=settings.shuffle_api_key,
  41. )
  42. pagerduty_adapter = PagerDutyAdapter(
  43. base_url=settings.pagerduty_base_url,
  44. api_key=settings.pagerduty_api_key,
  45. )
  46. iris_adapter = IrisAdapter(
  47. base_url=settings.iris_base_url,
  48. api_key=settings.iris_api_key,
  49. )
  50. virustotal_adapter = VirusTotalAdapter(
  51. base_url=settings.virustotal_base_url,
  52. api_key=settings.virustotal_api_key,
  53. )
  54. abuseipdb_adapter = AbuseIpdbAdapter(
  55. base_url=settings.abuseipdb_base_url,
  56. api_key=settings.abuseipdb_api_key,
  57. )
  58. repo = MvpRepository()
  59. mvp_service = MvpService(
  60. repo=repo,
  61. wazuh_adapter=wazuh_adapter,
  62. shuffle_adapter=shuffle_adapter,
  63. iris_adapter=iris_adapter,
  64. pagerduty_adapter=pagerduty_adapter,
  65. )
  66. app.include_router(build_mvp_router(mvp_service, require_internal_api_key))
  67. async def _wazuh_auto_sync_loop() -> None:
  68. interval = max(5, int(settings.wazuh_auto_sync_interval_seconds))
  69. while True:
  70. started_at = datetime.now(timezone.utc).isoformat()
  71. try:
  72. app.state.wazuh_auto_sync_state["running"] = True
  73. app.state.wazuh_auto_sync_state["last_started_at"] = started_at
  74. result = await mvp_service.sync_wazuh_alerts(
  75. query=settings.wazuh_auto_sync_query,
  76. limit=settings.wazuh_auto_sync_limit,
  77. minutes=settings.wazuh_auto_sync_minutes,
  78. )
  79. app.state.wazuh_auto_sync_state["last_status"] = "ok"
  80. app.state.wazuh_auto_sync_state["last_result"] = result
  81. app.state.wazuh_auto_sync_state["last_finished_at"] = datetime.now(timezone.utc).isoformat()
  82. logger.info(
  83. "wazuh auto-sync processed=%s ingested=%s skipped=%s failed=%s ioc_evaluated=%s ioc_matched=%s ioc_rejected=%s",
  84. result.get("processed", 0),
  85. result.get("ingested", 0),
  86. result.get("skipped_existing", 0),
  87. result.get("failed", 0),
  88. result.get("ioc_evaluated", 0),
  89. result.get("ioc_matched", 0),
  90. result.get("ioc_rejected", 0),
  91. )
  92. except Exception as exc:
  93. app.state.wazuh_auto_sync_state["last_status"] = "error"
  94. app.state.wazuh_auto_sync_state["last_error"] = str(exc)
  95. app.state.wazuh_auto_sync_state["last_finished_at"] = datetime.now(timezone.utc).isoformat()
  96. logger.exception("wazuh auto-sync failed: %s", exc)
  97. finally:
  98. app.state.wazuh_auto_sync_state["running"] = False
  99. await asyncio.sleep(interval)
  100. @app.on_event("startup")
  101. async def startup() -> None:
  102. init_schema()
  103. repo.ensure_policy()
  104. app.state.wazuh_auto_sync_state = {
  105. "running": False,
  106. "last_status": None,
  107. "last_started_at": None,
  108. "last_finished_at": None,
  109. "last_error": None,
  110. "last_result": None,
  111. }
  112. if settings.wazuh_auto_sync_enabled:
  113. app.state.wazuh_auto_sync_task = asyncio.create_task(_wazuh_auto_sync_loop())
  114. logger.info(
  115. "wazuh auto-sync enabled interval=%ss limit=%s minutes=%s query=%s",
  116. settings.wazuh_auto_sync_interval_seconds,
  117. settings.wazuh_auto_sync_limit,
  118. settings.wazuh_auto_sync_minutes,
  119. settings.wazuh_auto_sync_query,
  120. )
  121. @app.on_event("shutdown")
  122. async def shutdown() -> None:
  123. task = getattr(app.state, "wazuh_auto_sync_task", None)
  124. if task:
  125. task.cancel()
  126. try:
  127. await task
  128. except asyncio.CancelledError:
  129. pass
  130. @app.get("/health", response_model=ApiResponse)
  131. async def health() -> ApiResponse:
  132. return ApiResponse(
  133. data={
  134. "service": settings.app_name,
  135. "env": settings.app_env,
  136. "targets": {
  137. "wazuh": settings.wazuh_base_url,
  138. "shuffle": settings.shuffle_base_url,
  139. "pagerduty": settings.pagerduty_base_url,
  140. "iris": settings.iris_base_url,
  141. },
  142. }
  143. )
  144. @app.post("/ingest/wazuh-alert", response_model=ApiResponse)
  145. async def ingest_wazuh_alert(payload: WazuhIngestRequest) -> ApiResponse:
  146. normalized = {
  147. "source": payload.source,
  148. "alert_id": payload.alert_id,
  149. "rule_id": payload.rule_id,
  150. "severity": payload.severity,
  151. "title": payload.title,
  152. "payload": payload.payload,
  153. }
  154. return ApiResponse(data={"normalized": normalized})
  155. @app.post("/action/create-incident", response_model=ApiResponse)
  156. async def create_incident(payload: ActionCreateIncidentRequest) -> ApiResponse:
  157. incident_payload = {
  158. "title": payload.title,
  159. "urgency": payload.severity,
  160. "incident_key": payload.dedupe_key,
  161. "body": payload.payload,
  162. "source": payload.source,
  163. }
  164. try:
  165. pd_result = await pagerduty_adapter.create_incident(incident_payload)
  166. except Exception as exc:
  167. raise HTTPException(status_code=502, detail=f"PagerDuty call failed: {exc}") from exc
  168. return ApiResponse(data={"pagerduty": pd_result})
  169. @app.post("/action/trigger-shuffle", response_model=ApiResponse)
  170. async def trigger_shuffle(payload: TriggerShuffleRequest) -> ApiResponse:
  171. try:
  172. shuffle_result = await shuffle_adapter.trigger_workflow(
  173. workflow_id=payload.workflow_id,
  174. payload=payload.execution_argument,
  175. )
  176. except Exception as exc:
  177. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  178. return ApiResponse(data={"shuffle": shuffle_result})
  179. @app.get("/shuffle/health", response_model=ApiResponse)
  180. async def shuffle_health() -> ApiResponse:
  181. try:
  182. result = await shuffle_adapter.health()
  183. except Exception as exc:
  184. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  185. return ApiResponse(data={"shuffle": result})
  186. @app.get("/shuffle/auth-test", response_model=ApiResponse)
  187. async def shuffle_auth_test() -> ApiResponse:
  188. try:
  189. result = await shuffle_adapter.auth_test()
  190. except Exception as exc:
  191. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  192. return ApiResponse(data={"shuffle": result})
  193. @app.post("/shuffle/login", response_model=ApiResponse)
  194. async def shuffle_login(payload: ShuffleLoginRequest) -> ApiResponse:
  195. try:
  196. result = await shuffle_adapter.login(payload.username, payload.password)
  197. except Exception as exc:
  198. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  199. return ApiResponse(data={"shuffle": result})
  200. @app.post("/shuffle/generate-apikey", response_model=ApiResponse)
  201. async def shuffle_generate_apikey(payload: ShuffleLoginRequest | None = None) -> ApiResponse:
  202. username = payload.username if payload else settings.shuffle_username
  203. password = payload.password if payload else settings.shuffle_password
  204. if not username or not password:
  205. raise HTTPException(
  206. status_code=400,
  207. detail="Missing shuffle credentials. Provide username/password in body or set SHUFFLE_USERNAME and SHUFFLE_PASSWORD.",
  208. )
  209. try:
  210. result = await shuffle_adapter.generate_apikey_from_login(username, password)
  211. except Exception as exc:
  212. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  213. return ApiResponse(data={"shuffle": result})
  214. @app.get("/shuffle/workflows", response_model=ApiResponse)
  215. async def shuffle_workflows() -> ApiResponse:
  216. try:
  217. result = await shuffle_adapter.list_workflows()
  218. except Exception as exc:
  219. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  220. return ApiResponse(data={"shuffle": result})
  221. @app.get("/shuffle/workflows/{workflow_id}", response_model=ApiResponse)
  222. async def shuffle_workflow(workflow_id: str) -> ApiResponse:
  223. try:
  224. result = await shuffle_adapter.get_workflow(workflow_id)
  225. except Exception as exc:
  226. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  227. return ApiResponse(data={"shuffle": result})
  228. @app.post("/shuffle/workflows/{workflow_id}/execute", response_model=ApiResponse)
  229. async def shuffle_workflow_execute(
  230. workflow_id: str, payload: dict[str, object]
  231. ) -> ApiResponse:
  232. try:
  233. result = await shuffle_adapter.trigger_workflow(workflow_id=workflow_id, payload=payload)
  234. except Exception as exc:
  235. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  236. return ApiResponse(data={"shuffle": result})
  237. @app.get("/shuffle/apps", response_model=ApiResponse)
  238. async def shuffle_apps() -> ApiResponse:
  239. try:
  240. result = await shuffle_adapter.list_apps()
  241. except Exception as exc:
  242. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  243. return ApiResponse(data={"shuffle": result})
  244. @app.post("/shuffle/proxy", response_model=ApiResponse)
  245. async def shuffle_proxy(payload: ShuffleProxyRequest) -> ApiResponse:
  246. path = payload.path if payload.path.startswith("/api/") else f"/api/v1/{payload.path.lstrip('/')}"
  247. try:
  248. result = await shuffle_adapter.proxy(
  249. method=payload.method,
  250. path=path,
  251. params=payload.params,
  252. payload=payload.payload,
  253. )
  254. except Exception as exc:
  255. raise HTTPException(status_code=502, detail=f"Shuffle call failed: {exc}") from exc
  256. return ApiResponse(data={"shuffle": result})
  257. @app.post("/action/create-iris-case", response_model=ApiResponse)
  258. async def create_iris_case(payload: ActionCreateIncidentRequest) -> ApiResponse:
  259. # IRIS v2 expects case_name, case_description, case_customer, case_soc_id.
  260. case_payload = {
  261. "case_name": payload.title,
  262. "case_description": payload.payload.get("description", "Created by soc-integrator"),
  263. "case_customer": payload.payload.get("case_customer", settings.iris_default_customer_id),
  264. "case_soc_id": payload.payload.get("case_soc_id", settings.iris_default_soc_id),
  265. }
  266. try:
  267. iris_result = await iris_adapter.create_case(case_payload)
  268. except Exception as exc:
  269. raise HTTPException(status_code=502, detail=f"IRIS call failed: {exc}") from exc
  270. return ApiResponse(data={"iris": iris_result})
  271. @app.post("/iris/tickets", response_model=ApiResponse)
  272. async def iris_create_ticket(payload: IrisTicketCreateRequest) -> ApiResponse:
  273. case_payload = {
  274. "case_name": payload.title,
  275. "case_description": payload.description,
  276. "case_customer": payload.case_customer or settings.iris_default_customer_id,
  277. "case_soc_id": payload.case_soc_id or settings.iris_default_soc_id,
  278. }
  279. if payload.payload:
  280. case_payload.update(payload.payload)
  281. try:
  282. iris_result = await iris_adapter.create_case(case_payload)
  283. except Exception as exc:
  284. raise HTTPException(status_code=502, detail=f"IRIS call failed: {exc}") from exc
  285. return ApiResponse(data={"iris": iris_result})
  286. @app.get("/iris/tickets", response_model=ApiResponse)
  287. async def iris_list_tickets(limit: int = 50, offset: int = 0) -> ApiResponse:
  288. try:
  289. iris_result = await iris_adapter.list_cases(limit=limit, offset=offset)
  290. except Exception as exc:
  291. raise HTTPException(status_code=502, detail=f"IRIS call failed: {exc}") from exc
  292. return ApiResponse(data={"iris": iris_result})
  293. def _build_vt_ioc_result(
  294. vt: dict[str, object],
  295. ioc_type: str,
  296. ioc_value: str,
  297. malicious_threshold: int,
  298. suspicious_threshold: int,
  299. ) -> tuple[dict[str, object], bool, str, float]:
  300. stats = (
  301. (((vt.get("data") or {}).get("attributes") or {}).get("last_analysis_stats"))
  302. if isinstance(vt, dict)
  303. else None
  304. ) or {}
  305. malicious = int(stats.get("malicious", 0) or 0)
  306. suspicious = int(stats.get("suspicious", 0) or 0)
  307. harmless = int(stats.get("harmless", 0) or 0)
  308. undetected = int(stats.get("undetected", 0) or 0)
  309. total = malicious + suspicious + harmless + undetected
  310. confidence = 0.0 if total == 0 else round(((malicious + (0.5 * suspicious)) / total), 4)
  311. matched = (malicious >= malicious_threshold) or (suspicious >= suspicious_threshold)
  312. severity = "low"
  313. if malicious >= 5 or suspicious >= 10:
  314. severity = "critical"
  315. elif malicious >= 2 or suspicious >= 5:
  316. severity = "high"
  317. elif malicious >= 1 or suspicious >= 1:
  318. severity = "medium"
  319. reason = (
  320. f"virustotal_stats malicious={malicious} suspicious={suspicious} "
  321. f"thresholds(malicious>={malicious_threshold}, suspicious>={suspicious_threshold})"
  322. )
  323. result: dict[str, object] = {
  324. "ioc_type": ioc_type,
  325. "ioc_value": ioc_value,
  326. "matched": matched,
  327. "severity": severity,
  328. "confidence": confidence,
  329. "reason": reason,
  330. "providers": {
  331. "virustotal": {
  332. "stats": stats,
  333. }
  334. },
  335. "raw": {
  336. "virustotal": vt,
  337. },
  338. }
  339. return result, matched, severity, confidence
  340. def _build_abuseipdb_ioc_result(
  341. abuse: dict[str, object],
  342. ioc_value: str,
  343. confidence_threshold: int = 50,
  344. ) -> tuple[dict[str, object], bool, str, float]:
  345. data = ((abuse.get("data") if isinstance(abuse, dict) else None) or {}) if isinstance(abuse, dict) else {}
  346. score = int(data.get("abuseConfidenceScore", 0) or 0)
  347. total_reports = int(data.get("totalReports", 0) or 0)
  348. matched = score >= confidence_threshold
  349. severity = "low"
  350. if score >= 90:
  351. severity = "critical"
  352. elif score >= 70:
  353. severity = "high"
  354. elif score >= 30:
  355. severity = "medium"
  356. confidence = round(score / 100.0, 4)
  357. reason = f"abuseipdb score={score} totalReports={total_reports} threshold>={confidence_threshold}"
  358. result: dict[str, object] = {
  359. "ioc_type": "ip",
  360. "ioc_value": ioc_value,
  361. "matched": matched,
  362. "severity": severity,
  363. "confidence": confidence,
  364. "reason": reason,
  365. "providers": {"abuseipdb": {"score": score, "totalReports": total_reports, "raw": abuse}},
  366. }
  367. return result, matched, severity, confidence
  368. @app.post("/ioc/enrich", response_model=ApiResponse)
  369. async def ioc_enrich(payload: IocEnrichRequest) -> ApiResponse:
  370. providers = [p.lower().strip() for p in payload.providers]
  371. result: dict[str, object] = {
  372. "ioc_type": payload.ioc_type,
  373. "ioc_value": payload.ioc_value,
  374. "providers_requested": providers,
  375. "providers": {},
  376. }
  377. if "virustotal" in providers:
  378. try:
  379. vt = await virustotal_adapter.enrich_ioc(payload.ioc_type, payload.ioc_value)
  380. result["providers"] = {**(result.get("providers") or {}), "virustotal": vt}
  381. except Exception as exc:
  382. repo.add_ioc_trace(
  383. action="enrich",
  384. ioc_type=payload.ioc_type,
  385. ioc_value=payload.ioc_value,
  386. providers=providers,
  387. request_payload=payload.model_dump(mode="json"),
  388. response_payload={},
  389. error=str(exc),
  390. )
  391. raise HTTPException(status_code=502, detail=f"VirusTotal call failed: {exc}") from exc
  392. if "abuseipdb" in providers:
  393. if payload.ioc_type != "ip":
  394. result["providers"] = {
  395. **(result.get("providers") or {}),
  396. "abuseipdb": {"skipped": "AbuseIPDB currently supports ioc_type='ip' only"},
  397. }
  398. else:
  399. try:
  400. abuse = await abuseipdb_adapter.check_ip(payload.ioc_value)
  401. result["providers"] = {**(result.get("providers") or {}), "abuseipdb": abuse}
  402. except Exception as exc:
  403. repo.add_ioc_trace(
  404. action="enrich",
  405. ioc_type=payload.ioc_type,
  406. ioc_value=payload.ioc_value,
  407. providers=providers,
  408. request_payload=payload.model_dump(mode="json"),
  409. response_payload={},
  410. error=str(exc),
  411. )
  412. raise HTTPException(status_code=502, detail=f"AbuseIPDB call failed: {exc}") from exc
  413. repo.add_ioc_trace(
  414. action="enrich",
  415. ioc_type=payload.ioc_type,
  416. ioc_value=payload.ioc_value,
  417. providers=providers,
  418. request_payload=payload.model_dump(mode="json"),
  419. response_payload=result,
  420. )
  421. return ApiResponse(data={"ioc": result})
  422. @app.post("/ioc/evaluate", response_model=ApiResponse)
  423. async def ioc_evaluate(payload: IocEvaluateRequest) -> ApiResponse:
  424. providers = [p.lower().strip() for p in payload.providers]
  425. supported = {"virustotal", "abuseipdb"}
  426. requested = [p for p in providers if p in supported]
  427. if not requested:
  428. raise HTTPException(status_code=400, detail="No supported provider requested. Use ['virustotal'] or ['abuseipdb'].")
  429. per_provider: dict[str, dict[str, object]] = {}
  430. errors: dict[str, str] = {}
  431. if "virustotal" in requested:
  432. try:
  433. vt = await virustotal_adapter.enrich_ioc(payload.ioc_type, payload.ioc_value)
  434. vt_result, _, _, _ = _build_vt_ioc_result(
  435. vt=vt,
  436. ioc_type=payload.ioc_type,
  437. ioc_value=payload.ioc_value,
  438. malicious_threshold=payload.malicious_threshold,
  439. suspicious_threshold=payload.suspicious_threshold,
  440. )
  441. per_provider["virustotal"] = vt_result
  442. except Exception as exc:
  443. errors["virustotal"] = str(exc)
  444. if "abuseipdb" in requested:
  445. if payload.ioc_type != "ip":
  446. errors["abuseipdb"] = "AbuseIPDB supports ioc_type='ip' only"
  447. else:
  448. try:
  449. abuse = await abuseipdb_adapter.check_ip(payload.ioc_value)
  450. abuse_result, _, _, _ = _build_abuseipdb_ioc_result(
  451. abuse=abuse,
  452. ioc_value=payload.ioc_value,
  453. confidence_threshold=50,
  454. )
  455. per_provider["abuseipdb"] = abuse_result
  456. except Exception as exc:
  457. errors["abuseipdb"] = str(exc)
  458. if not per_provider:
  459. repo.add_ioc_trace(
  460. action="evaluate",
  461. ioc_type=payload.ioc_type,
  462. ioc_value=payload.ioc_value,
  463. providers=requested,
  464. request_payload=payload.model_dump(mode="json"),
  465. response_payload={},
  466. error=str(errors),
  467. )
  468. raise HTTPException(status_code=502, detail=f"Provider evaluation failed: {errors}")
  469. # aggregate decision (max confidence/severity, matched if any provider matched)
  470. order = {"low": 1, "medium": 2, "high": 3, "critical": 4}
  471. matched = any(bool(r.get("matched")) for r in per_provider.values())
  472. confidence = max(float(r.get("confidence", 0.0) or 0.0) for r in per_provider.values())
  473. severity = max((str(r.get("severity", "low")) for r in per_provider.values()), key=lambda x: order.get(x, 1))
  474. reason_parts = [f"{name}:{res.get('reason','')}" for name, res in per_provider.items()]
  475. if errors:
  476. reason_parts.append(f"errors={errors}")
  477. ioc_result = {
  478. "ioc_type": payload.ioc_type,
  479. "ioc_value": payload.ioc_value,
  480. "matched": matched,
  481. "severity": severity,
  482. "confidence": round(confidence, 4),
  483. "reason": " | ".join(reason_parts),
  484. "providers": per_provider,
  485. }
  486. repo.add_ioc_trace(
  487. action="evaluate",
  488. ioc_type=payload.ioc_type,
  489. ioc_value=payload.ioc_value,
  490. providers=providers,
  491. request_payload=payload.model_dump(mode="json"),
  492. response_payload=ioc_result,
  493. matched=matched,
  494. severity=severity,
  495. confidence=float(ioc_result["confidence"]),
  496. )
  497. return ApiResponse(data={"ioc": ioc_result})
  498. @app.post("/ioc/upload-file", response_model=ApiResponse)
  499. async def ioc_upload_file(file: UploadFile = File(...)) -> ApiResponse:
  500. content = await file.read()
  501. if not content:
  502. raise HTTPException(status_code=400, detail="Uploaded file is empty")
  503. try:
  504. vt_upload = await virustotal_adapter.upload_file(file.filename or "upload.bin", content)
  505. except Exception as exc:
  506. repo.add_ioc_trace(
  507. action="upload_file",
  508. ioc_type="hash",
  509. ioc_value=file.filename or "<unknown>",
  510. providers=["virustotal"],
  511. request_payload={"filename": file.filename, "size": len(content)},
  512. response_payload={},
  513. error=str(exc),
  514. )
  515. raise HTTPException(status_code=502, detail=f"VirusTotal upload failed: {exc}") from exc
  516. repo.add_ioc_trace(
  517. action="upload_file",
  518. ioc_type="hash",
  519. ioc_value=file.filename or "<unknown>",
  520. providers=["virustotal"],
  521. request_payload={"filename": file.filename, "size": len(content)},
  522. response_payload=vt_upload if isinstance(vt_upload, dict) else {"raw": str(vt_upload)},
  523. )
  524. return ApiResponse(data={"virustotal": vt_upload})
  525. @app.get("/ioc/analysis/{analysis_id}", response_model=ApiResponse)
  526. async def ioc_get_analysis(analysis_id: str) -> ApiResponse:
  527. try:
  528. vt_analysis = await virustotal_adapter.get_analysis(analysis_id)
  529. except Exception as exc:
  530. repo.add_ioc_trace(
  531. action="analysis",
  532. ioc_type="hash",
  533. ioc_value=analysis_id,
  534. providers=["virustotal"],
  535. request_payload={"analysis_id": analysis_id},
  536. response_payload={},
  537. error=str(exc),
  538. )
  539. raise HTTPException(status_code=502, detail=f"VirusTotal analysis fetch failed: {exc}") from exc
  540. repo.add_ioc_trace(
  541. action="analysis",
  542. ioc_type="hash",
  543. ioc_value=analysis_id,
  544. providers=["virustotal"],
  545. request_payload={"analysis_id": analysis_id},
  546. response_payload=vt_analysis if isinstance(vt_analysis, dict) else {"raw": str(vt_analysis)},
  547. )
  548. return ApiResponse(data={"virustotal": vt_analysis})
  549. @app.post("/ioc/evaluate-file", response_model=ApiResponse)
  550. async def ioc_evaluate_file(
  551. file: UploadFile = File(...),
  552. malicious_threshold: int = 1,
  553. suspicious_threshold: int = 3,
  554. poll_timeout_seconds: int = 30,
  555. poll_interval_seconds: int = 2,
  556. ) -> ApiResponse:
  557. content = await file.read()
  558. if not content:
  559. raise HTTPException(status_code=400, detail="Uploaded file is empty")
  560. try:
  561. vt_upload = await virustotal_adapter.upload_file(file.filename or "upload.bin", content)
  562. except Exception as exc:
  563. repo.add_ioc_trace(
  564. action="evaluate_file",
  565. ioc_type="hash",
  566. ioc_value=file.filename or "<unknown>",
  567. providers=["virustotal"],
  568. request_payload={"filename": file.filename, "size": len(content)},
  569. response_payload={},
  570. error=str(exc),
  571. )
  572. raise HTTPException(status_code=502, detail=f"VirusTotal upload failed: {exc}") from exc
  573. analysis_id = (
  574. (((vt_upload.get("data") or {}).get("id")) if isinstance(vt_upload, dict) else None)
  575. or ""
  576. )
  577. if not analysis_id:
  578. raise HTTPException(status_code=502, detail="VirusTotal upload response missing analysis ID")
  579. timeout = max(1, poll_timeout_seconds)
  580. interval = max(1, poll_interval_seconds)
  581. elapsed = 0
  582. analysis: dict[str, object] = {}
  583. while elapsed <= timeout:
  584. analysis = await virustotal_adapter.get_analysis(analysis_id)
  585. status = (
  586. (((analysis.get("data") or {}).get("attributes") or {}).get("status"))
  587. if isinstance(analysis, dict)
  588. else None
  589. )
  590. if status == "completed":
  591. break
  592. await asyncio.sleep(interval)
  593. elapsed += interval
  594. sha256 = (
  595. (((analysis.get("meta") or {}).get("file_info") or {}).get("sha256"))
  596. if isinstance(analysis, dict)
  597. else None
  598. )
  599. if not sha256:
  600. raise HTTPException(status_code=502, detail="VirusTotal analysis did not return file hash yet")
  601. try:
  602. vt_file = await virustotal_adapter.enrich_ioc("hash", str(sha256))
  603. except Exception as exc:
  604. repo.add_ioc_trace(
  605. action="evaluate_file",
  606. ioc_type="hash",
  607. ioc_value=str(sha256),
  608. providers=["virustotal"],
  609. request_payload={"filename": file.filename, "analysis_id": analysis_id},
  610. response_payload={"upload": vt_upload, "analysis": analysis},
  611. error=str(exc),
  612. )
  613. raise HTTPException(status_code=502, detail=f"VirusTotal report fetch failed: {exc}") from exc
  614. ioc_result, matched, severity, confidence = _build_vt_ioc_result(
  615. vt=vt_file,
  616. ioc_type="hash",
  617. ioc_value=str(sha256),
  618. malicious_threshold=malicious_threshold,
  619. suspicious_threshold=suspicious_threshold,
  620. )
  621. ioc_result["analysis_id"] = analysis_id
  622. ioc_result["filename"] = file.filename
  623. repo.add_ioc_trace(
  624. action="evaluate_file",
  625. ioc_type="hash",
  626. ioc_value=str(sha256),
  627. providers=["virustotal"],
  628. request_payload={"filename": file.filename, "analysis_id": analysis_id},
  629. response_payload={
  630. "upload": vt_upload,
  631. "analysis": analysis,
  632. "ioc": ioc_result,
  633. },
  634. matched=matched,
  635. severity=severity,
  636. confidence=confidence,
  637. )
  638. return ApiResponse(data={"ioc": ioc_result, "analysis": analysis, "upload": vt_upload})
  639. @app.get("/ioc/history", response_model=ApiResponse)
  640. async def ioc_history(limit: int = 50, offset: int = 0) -> ApiResponse:
  641. return ApiResponse(data={"items": repo.list_ioc_trace(limit=limit, offset=offset)})
  642. @app.get("/sync/wazuh-version", response_model=ApiResponse)
  643. async def sync_wazuh_version() -> ApiResponse:
  644. try:
  645. wazuh_result = await wazuh_adapter.get_version()
  646. except Exception as exc:
  647. raise HTTPException(status_code=502, detail=f"Wazuh call failed: {exc}") from exc
  648. return ApiResponse(data={"wazuh": wazuh_result})
  649. @app.get("/wazuh/auth-test", response_model=ApiResponse)
  650. async def wazuh_auth_test() -> ApiResponse:
  651. try:
  652. result = await wazuh_adapter.auth_test()
  653. except Exception as exc:
  654. raise HTTPException(status_code=502, detail=f"Wazuh auth failed: {exc}") from exc
  655. return ApiResponse(data={"wazuh": result})
  656. @app.get("/wazuh/manager-info", response_model=ApiResponse)
  657. async def wazuh_manager_info() -> ApiResponse:
  658. try:
  659. result = await wazuh_adapter.get_manager_info()
  660. except Exception as exc:
  661. raise HTTPException(status_code=502, detail=f"Wazuh call failed: {exc}") from exc
  662. return ApiResponse(data={"wazuh": result})
  663. @app.get("/wazuh/agents", response_model=ApiResponse)
  664. async def wazuh_agents(
  665. limit: int = 50,
  666. offset: int = 0,
  667. select: str | None = None,
  668. ) -> ApiResponse:
  669. try:
  670. result = await wazuh_adapter.list_agents(limit=limit, offset=offset, select=select)
  671. except Exception as exc:
  672. raise HTTPException(status_code=502, detail=f"Wazuh call failed: {exc}") from exc
  673. return ApiResponse(data={"wazuh": result})
  674. @app.get("/wazuh/alerts", response_model=ApiResponse)
  675. async def wazuh_alerts(
  676. limit: int = 50,
  677. offset: int = 0,
  678. q: str | None = None,
  679. sort: str | None = None,
  680. ) -> ApiResponse:
  681. try:
  682. # In this Wazuh build, API alerts are exposed via manager logs.
  683. result = await wazuh_adapter.list_manager_logs(
  684. limit=limit, offset=offset, q=q, sort=sort
  685. )
  686. except Exception as exc:
  687. raise HTTPException(status_code=502, detail=f"Wazuh call failed: {exc}") from exc
  688. return ApiResponse(data={"wazuh": result})
  689. @app.get("/wazuh/manager-logs", response_model=ApiResponse)
  690. async def wazuh_manager_logs(
  691. limit: int = 50,
  692. offset: int = 0,
  693. q: str | None = None,
  694. sort: str | None = None,
  695. ) -> ApiResponse:
  696. try:
  697. result = await wazuh_adapter.list_manager_logs(
  698. limit=limit, offset=offset, q=q, sort=sort
  699. )
  700. except Exception as exc:
  701. raise HTTPException(status_code=502, detail=f"Wazuh call failed: {exc}") from exc
  702. return ApiResponse(data={"wazuh": result})
  703. @app.post("/wazuh/sync-to-mvp", response_model=ApiResponse, dependencies=[Depends(require_internal_api_key)])
  704. async def wazuh_sync_to_mvp(
  705. limit: int = 50,
  706. minutes: int = 120,
  707. q: str = "soc_mvp_test=true OR event_type:*",
  708. ) -> ApiResponse:
  709. try:
  710. result = await mvp_service.sync_wazuh_alerts(query=q, limit=limit, minutes=minutes)
  711. except Exception as exc:
  712. raise HTTPException(status_code=502, detail=f"Wazuh sync failed: {exc}") from exc
  713. return ApiResponse(data={"sync": result})
  714. @app.get("/wazuh/auto-sync/status", response_model=ApiResponse)
  715. async def wazuh_auto_sync_status() -> ApiResponse:
  716. state = getattr(app.state, "wazuh_auto_sync_state", {})
  717. task = getattr(app.state, "wazuh_auto_sync_task", None)
  718. return ApiResponse(
  719. data={
  720. "enabled": settings.wazuh_auto_sync_enabled,
  721. "task_running": bool(task and not task.done()),
  722. "settings": {
  723. "interval_seconds": settings.wazuh_auto_sync_interval_seconds,
  724. "limit": settings.wazuh_auto_sync_limit,
  725. "minutes": settings.wazuh_auto_sync_minutes,
  726. "query": settings.wazuh_auto_sync_query,
  727. },
  728. "state": state,
  729. }
  730. )