From e2b8fcee21ee046bab76dd0b2dd60168ec9f55ec Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Thu, 23 Apr 2026 09:23:38 -0700 Subject: [PATCH] feat: add Bitdefender GravityZone integration module Adds full GravityZone API integration to ClaudeTools. Key additions: - api/services/gravityzone_service.py: JSON-RPC client with Basic auth, methods for company/endpoint/quarantine/licensing data, and security_sweep which paginates all endpoints, enriches with malware/agent status, and sorts infected > outdated > clean - api/schemas/gravityzone.py: Pydantic response models for all endpoints - api/routers/gravityzone.py: 7 REST endpoints at /api/gravityzone/*, JWT-protected, returns 502 on downstream GZ errors - api/config.py: GRAVITYZONE_API_KEY + GRAVITYZONE_API_BASE_URL settings - api/main.py: router registered under /api/gravityzone Vault entry: msp-tools/gravityzone.sops.yaml (partner-level key, 14 modules) Server .env updated, ticktick router synced, service restarted and verified. Co-Authored-By: Claude Sonnet 4.6 --- api/config.py | 4 + api/main.py | 2 + api/routers/gravityzone.py | 251 ++++++++++++++++++++++++++++ api/schemas/gravityzone.py | 52 ++++++ api/services/gravityzone_service.py | 230 +++++++++++++++++++++++++ 5 files changed, 539 insertions(+) create mode 100644 api/routers/gravityzone.py create mode 100644 api/schemas/gravityzone.py create mode 100644 api/services/gravityzone_service.py diff --git a/api/config.py b/api/config.py index b722501..31d7377 100644 --- a/api/config.py +++ b/api/config.py @@ -53,6 +53,10 @@ class Settings(BaseSettings): GRAPH_SENDER_EMAIL: str = "noreply@azcomputerguru.com" ADMIN_NOTIFICATION_EMAIL: str = "mike@azcomputerguru.com" + # Bitdefender GravityZone + GRAVITYZONE_API_KEY: str = "" + GRAVITYZONE_API_BASE_URL: str = "https://cloud.gravityzone.bitdefender.com/api/v1.0/jsonrpc" + class Config: """Pydantic configuration.""" diff --git a/api/main.py b/api/main.py index a10a644..cd194e2 100644 --- a/api/main.py +++ b/api/main.py @@ -36,6 +36,7 @@ from api.routers import ( quotes, admin_quotes, ticktick, + gravityzone, ) # Import middleware @@ -133,6 +134,7 @@ app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin # External integrations app.include_router(ticktick.router, prefix="/api/ticktick", tags=["TickTick"]) +app.include_router(gravityzone.router, prefix="/api/gravityzone", tags=["GravityZone"]) if __name__ == "__main__": diff --git a/api/routers/gravityzone.py b/api/routers/gravityzone.py new file mode 100644 index 0000000..564b6a9 --- /dev/null +++ b/api/routers/gravityzone.py @@ -0,0 +1,251 @@ +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from api.middleware.auth import get_current_user +from api.schemas.gravityzone import ( + GZCompanyItem, + GZEndpointDetail, + GZEndpointItem, + GZStatusResponse, + GZSweepResult, +) +from api.services.gravityzone_service import ( + ACG_COMPANIES_CONTAINER_ID, + get_gravityzone_service, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _raise_on_failure(result, detail_prefix: str = "GravityZone error") -> dict: + if not result.success: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"{detail_prefix}: {result.error or 'unknown error'}", + ) + return result.data or {} + + +# -------------------------------------------------------------------------- +# Status +# -------------------------------------------------------------------------- + + +@router.get( + "/status", + response_model=GZStatusResponse, + summary="GravityZone API key status and license info", + status_code=status.HTTP_200_OK, +) +async def get_status(current_user: dict = Depends(get_current_user)): + service = get_gravityzone_service() + result = await service.get_api_status() + data = _raise_on_failure(result, "GravityZone status") + + return GZStatusResponse( + enabled_apis=data.get("enabledApis", []), + key_created_at=data.get("createdAt"), + used_slots=data.get("usedSlots"), + total_slots=data.get("totalSlots"), + expiry_date=data.get("expiryDate"), + ) + + +# -------------------------------------------------------------------------- +# Companies +# -------------------------------------------------------------------------- + + +@router.get( + "/companies", + summary="List GravityZone client companies", + status_code=status.HTTP_200_OK, +) +async def list_companies( + page: int = Query(1, ge=1), + per_page: int = Query(100, ge=1, le=500), + current_user: dict = Depends(get_current_user), +): + service = get_gravityzone_service() + result = await service.list_client_companies(page=page, per_page=per_page) + data = _raise_on_failure(result, "GravityZone companies") + + companies = [ + GZCompanyItem( + id=item.get("id", ""), + name=item.get("name", ""), + type=item.get("type", 1), + ) + for item in data.get("items", []) + ] + return {"total": data.get("total", len(companies)), "companies": companies} + + +# -------------------------------------------------------------------------- +# Endpoints +# -------------------------------------------------------------------------- + + +@router.get( + "/companies/{company_id}/endpoints", + summary="List endpoints for a GravityZone company", + status_code=status.HTTP_200_OK, +) +async def list_endpoints( + company_id: str, + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=200), + current_user: dict = Depends(get_current_user), +): + service = get_gravityzone_service() + result = await service.list_endpoints(company_id, page=page, per_page=per_page) + data = _raise_on_failure(result, "GravityZone endpoints") + + endpoints = [ + GZEndpointItem( + id=item.get("id", ""), + name=item.get("name", ""), + fqdn=item.get("fqdn"), + ip=item.get("ip"), + os_version=item.get("operatingSystemVersion"), + is_managed=bool(item.get("isManaged", False)), + policy_name=(item.get("policy") or {}).get("name"), + ) + for item in data.get("items", []) + ] + return {"total": data.get("total", len(endpoints)), "endpoints": endpoints} + + +@router.get( + "/endpoints/{endpoint_id}", + response_model=GZEndpointDetail, + summary="Get detailed info for a single GravityZone endpoint", + status_code=status.HTTP_200_OK, +) +async def get_endpoint( + endpoint_id: str, + current_user: dict = Depends(get_current_user), +): + service = get_gravityzone_service() + result = await service.get_endpoint_details(endpoint_id) + data = _raise_on_failure(result, "GravityZone endpoint detail") + + malware = data.get("malwareStatus", {}) + agent = data.get("agent", {}) + + return GZEndpointDetail( + id=data.get("id", endpoint_id), + name=data.get("name", ""), + company_id=data.get("companyId"), + infected=bool(malware.get("infected", False)), + detection_active=bool(malware.get("detection", False)), + signature_outdated=bool(agent.get("signatureOutdated", False)), + product_outdated=bool(agent.get("productOutdated", False)), + agent_version=agent.get("productVersion"), + engine_version=agent.get("engineVersion"), + last_seen=data.get("lastSeen"), + last_update=agent.get("lastUpdate"), + state=data.get("state", 0), + modules=data.get("modules"), + ) + + +# -------------------------------------------------------------------------- +# Quarantine +# -------------------------------------------------------------------------- + + +@router.get( + "/companies/{company_id}/quarantine", + summary="List quarantine items for a GravityZone company", + status_code=status.HTTP_200_OK, +) +async def list_quarantine( + company_id: str, + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=200), + current_user: dict = Depends(get_current_user), +): + service = get_gravityzone_service() + result = await service.list_quarantine_items(company_id, page=page, per_page=per_page) + data = _raise_on_failure(result, "GravityZone quarantine") + + return {"total": data.get("total", 0), "items": data.get("items", [])} + + +# -------------------------------------------------------------------------- +# Security sweep +# -------------------------------------------------------------------------- + + +def _build_sweep_result(summaries) -> GZSweepResult: + stale_cutoff = datetime.now(timezone.utc) - timedelta(days=7) + not_seen_recently = 0 + + for s in summaries: + if s.last_seen: + try: + last_seen_dt = datetime.fromisoformat( + s.last_seen.replace("Z", "+00:00") + ) + if last_seen_dt < stale_cutoff: + not_seen_recently += 1 + except (ValueError, AttributeError): + pass + + return GZSweepResult( + total=len(summaries), + infected=sum(1 for s in summaries if s.infected), + signature_outdated=sum(1 for s in summaries if s.signature_outdated), + product_outdated=sum(1 for s in summaries if s.product_outdated), + not_seen_recently=not_seen_recently, + endpoints=[ + { + "endpoint_id": s.endpoint_id, + "name": s.name, + "company_id": s.company_id, + "infected": s.infected, + "detection_active": s.detection_active, + "signature_outdated": s.signature_outdated, + "product_outdated": s.product_outdated, + "last_seen": s.last_seen, + "agent_version": s.agent_version, + "state": s.state, + } + for s in summaries + ], + ) + + +@router.get( + "/sweep/{parent_id}", + response_model=GZSweepResult, + summary="Security sweep for all endpoints under a parent ID", + status_code=status.HTTP_200_OK, +) +async def sweep_parent( + parent_id: str, + current_user: dict = Depends(get_current_user), +): + service = get_gravityzone_service() + summaries = await service.security_sweep(parent_id) + return _build_sweep_result(summaries) + + +@router.get( + "/sweep", + response_model=GZSweepResult, + summary="Security sweep across all ACG client companies", + status_code=status.HTTP_200_OK, +) +async def sweep_all_clients( + current_user: dict = Depends(get_current_user), +): + service = get_gravityzone_service() + summaries = await service.security_sweep(ACG_COMPANIES_CONTAINER_ID) + return _build_sweep_result(summaries) diff --git a/api/schemas/gravityzone.py b/api/schemas/gravityzone.py new file mode 100644 index 0000000..ebc8671 --- /dev/null +++ b/api/schemas/gravityzone.py @@ -0,0 +1,52 @@ +from typing import Optional + +from pydantic import BaseModel + + +class GZEndpointItem(BaseModel): + id: str + name: str + fqdn: Optional[str] = None + ip: Optional[str] = None + os_version: Optional[str] = None + is_managed: bool + policy_name: Optional[str] = None + + +class GZEndpointDetail(BaseModel): + id: str + name: str + company_id: Optional[str] = None + infected: bool + detection_active: bool + signature_outdated: bool + product_outdated: bool + agent_version: Optional[str] = None + engine_version: Optional[str] = None + last_seen: Optional[str] = None + last_update: Optional[str] = None + state: int + modules: Optional[dict] = None + + +class GZCompanyItem(BaseModel): + id: str + name: str + type: int + + +class GZSweepResult(BaseModel): + total: int + infected: int + signature_outdated: int + product_outdated: int + not_seen_recently: int + endpoints: list[dict] + + +class GZStatusResponse(BaseModel): + enabled_apis: list[str] + key_created_at: Optional[str] = None + used_slots: Optional[int] = None + total_slots: Optional[int] = None + expiry_date: Optional[str] = None diff --git a/api/services/gravityzone_service.py b/api/services/gravityzone_service.py new file mode 100644 index 0000000..bb209fd --- /dev/null +++ b/api/services/gravityzone_service.py @@ -0,0 +1,230 @@ +import logging +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +GRAVITYZONE_API_BASE_URL = os.environ.get( + "GRAVITYZONE_API_BASE_URL", + "https://cloud.gravityzone.bitdefender.com/api/v1.0/jsonrpc", +) +GRAVITYZONE_API_KEY = os.environ.get("GRAVITYZONE_API_KEY", "") + +GRAVITYZONE_TIMEOUT_SECONDS = 30.0 +GRAVITYZONE_CONNECT_TIMEOUT_SECONDS = 10.0 + +ACG_ROOT_COMPANY_ID = "5c4280716c0318f3478b456a" +ACG_COMPANIES_CONTAINER_ID = "5c4280716c0318f3478b456e" + + +@dataclass +class GZResult: + success: bool + data: Optional[dict] = None + error: Optional[str] = None + + +@dataclass +class GZEndpointSummary: + endpoint_id: str + name: str + company_id: str + infected: bool + detection_active: bool + signature_outdated: bool + product_outdated: bool + last_seen: Optional[str] + agent_version: Optional[str] + state: int + + +class GravityZoneService: + def __init__( + self, + api_base_url: str = GRAVITYZONE_API_BASE_URL, + api_key: str = GRAVITYZONE_API_KEY, + timeout: float = GRAVITYZONE_TIMEOUT_SECONDS, + connect_timeout: float = GRAVITYZONE_CONNECT_TIMEOUT_SECONDS, + ): + self.api_base_url = api_base_url.rstrip("/") + self.api_key = api_key + self.timeout = httpx.Timeout(timeout, connect=connect_timeout) + + async def _jsonrpc_request( + self, module: str, method: str, params: dict + ) -> GZResult: + url = f"{self.api_base_url}/{module}" + payload = {"id": "1", "jsonrpc": "2.0", "method": method, "params": params} + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post(url, json=payload, auth=(self.api_key, "")) + response.raise_for_status() + body = response.json() + except httpx.TimeoutException as exc: + return GZResult(success=False, error=f"GravityZone API timeout: {exc}") + except httpx.HTTPStatusError as exc: + return GZResult( + success=False, + error=f"GravityZone HTTP error {exc.response.status_code}: {exc.response.text}", + ) + except Exception as exc: + return GZResult(success=False, error=f"GravityZone request failed: {exc}") + + if "error" in body: + err = body["error"] + detail = err.get("data", {}).get("details") or err.get("message") + return GZResult(success=False, error=detail) + return GZResult(success=True, data=body.get("result")) + + async def get_api_status(self) -> GZResult: + key_result = await self._jsonrpc_request("general", "getApiKeyDetails", {}) + license_result = await self._jsonrpc_request("licensing", "getLicenseInfo", {}) + + if not key_result.success: + return key_result + + combined = {**(key_result.data or {})} + if license_result.success: + combined.update(license_result.data or {}) + else: + logger.warning(f"GravityZone getLicenseInfo failed: {license_result.error}") + + return GZResult(success=True, data=combined) + + async def get_own_company(self) -> GZResult: + return await self._jsonrpc_request("companies", "getCompanyDetails", {}) + + async def list_client_companies(self, page: int = 1, per_page: int = 100) -> GZResult: + result = await self._jsonrpc_request( + "network", + "getNetworkInventoryItems", + { + "parentId": ACG_COMPANIES_CONTAINER_ID, + "page": page, + "perPage": per_page, + }, + ) + if not result.success: + return result + + data = result.data or {} + items = data.get("items", []) + companies = [item for item in items if item.get("type") == 1] + return GZResult( + success=True, + data={"total": len(companies), "items": companies}, + ) + + async def list_endpoints( + self, parent_id: str, page: int = 1, per_page: int = 50 + ) -> GZResult: + return await self._jsonrpc_request( + "network", + "getEndpointsList", + {"parentId": parent_id, "page": page, "perPage": per_page}, + ) + + async def get_endpoint_details(self, endpoint_id: str) -> GZResult: + return await self._jsonrpc_request( + "network", + "getManagedEndpointDetails", + {"endpointId": endpoint_id}, + ) + + async def list_quarantine_items( + self, parent_id: str, page: int = 1, per_page: int = 50 + ) -> GZResult: + return await self._jsonrpc_request( + "quarantine", + "getQuarantineItemsList", + {"parentId": parent_id, "page": page, "perPage": per_page}, + ) + + async def security_sweep(self, parent_id: str) -> list[GZEndpointSummary]: + summaries: list[GZEndpointSummary] = [] + page = 1 + per_page = 100 + + while True: + result = await self.list_endpoints(parent_id, page=page, per_page=per_page) + if not result.success: + logger.warning( + f"GravityZone security_sweep list_endpoints failed for " + f"{parent_id} page {page}: {result.error}" + ) + break + + data = result.data or {} + items = data.get("items", []) + if not items: + break + + for item in items: + endpoint_id = item.get("id", "") + detail_result = await self.get_endpoint_details(endpoint_id) + if not detail_result.success: + logger.warning( + f"GravityZone getManagedEndpointDetails failed for " + f"{endpoint_id}: {detail_result.error}" + ) + continue + + detail = detail_result.data or {} + malware = detail.get("malwareStatus", {}) + agent = detail.get("agent", {}) + + infected = bool(malware.get("infected", False)) + detection_active = bool(malware.get("detection", False)) + signature_outdated = bool(agent.get("signatureOutdated", False)) + product_outdated = bool(agent.get("productOutdated", False)) + + if infected: + logger.warning( + f"GravityZone: infected endpoint detected — " + f"id={endpoint_id} name={detail.get('name', '')}" + ) + + summaries.append( + GZEndpointSummary( + endpoint_id=endpoint_id, + name=detail.get("name") or item.get("name", ""), + company_id=item.get("companyId", ""), + infected=infected, + detection_active=detection_active, + signature_outdated=signature_outdated, + product_outdated=product_outdated, + last_seen=detail.get("lastSeen"), + agent_version=agent.get("productVersion"), + state=detail.get("state", 0), + ) + ) + + total = data.get("total", 0) + if page * per_page >= total: + break + page += 1 + + def _sort_key(s: GZEndpointSummary) -> tuple: + return ( + not s.infected, + not s.signature_outdated, + not s.product_outdated, + s.name.lower(), + ) + + summaries.sort(key=_sort_key) + return summaries + + +_gravityzone_service: Optional[GravityZoneService] = None + + +def get_gravityzone_service() -> GravityZoneService: + global _gravityzone_service + if _gravityzone_service is None: + _gravityzone_service = GravityZoneService() + return _gravityzone_service