Files
claudetools/.claude/skills/datto-edr/scripts/edr_client.py
Howard Enos b9d4cfde98 sync: auto-sync from HOWARD-HOME at 2026-06-25 12:30:38
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-25 12:30:38
2026-06-25 12:31:14 -07:00

396 lines
17 KiB
Python

#!/usr/bin/env python3
"""Datto EDR (Infocyte HUNT) REST API client for the datto-edr skill.
Datto EDR == rebranded Infocyte HUNT. The API is a per-tenant LoopBack REST
service at https://<instance>.infocyte.com/api/<Model>. This client talks to the
live ACG tenant (azcomp4587).
Auth (IMPORTANT): a single long-lived 64-char token passed in the RAW
`Authorization` header — NOT "Bearer <token>", no prefix. (Verified live
2026-06-25.) Token + instance load from the SOPS vault; env overrides exist for
testing.
Transport: prefers httpx if installed, else stdlib urllib (no hard dependency).
Read methods are always live. Mutating methods (scan / isolate / deploy) return
the raw upstream result; the CLI caller is responsible for gating them behind
--confirm.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
try:
import httpx # type: ignore
_HAS_HTTPX = True
except ImportError: # pragma: no cover - depends on environment
_HAS_HTTPX = False
# Cap upstream error bodies surfaced in exceptions. `raw` can call arbitrary
# paths whose responses may reflect other data - bound the blast radius.
ERROR_BODY_MAX_CHARS = 500
# --- constants ----------------------------------------------------------------
DEFAULT_INSTANCE = "azcomp4587"
DATTO_EDR_BASE_URL = os.environ.get(
"DATTO_EDR_BASE_URL", f"https://{DEFAULT_INSTANCE}.infocyte.com/api"
)
TIMEOUT_SECONDS = 60.0
CONNECT_TIMEOUT_SECONDS = 10.0
VAULT_ENTRY = "msp-tools/datto-edr.sops.yaml"
VAULT_FIELD = "credentials.api_token"
SKILL_DIR = Path(__file__).resolve().parent.parent
# Mutating-path guard for `raw`: refuse these without --confirm at the call site.
# Scans, isolation responses, deletes, approvals all run through POST/DELETE.
class DattoEDRError(RuntimeError):
"""Raised for transport or API errors."""
# --- credential loading -------------------------------------------------------
def _resolve_claudetools_root() -> Path:
"""Resolve the ClaudeTools repo root: env var, then identity.json, then derived."""
derived_root = SKILL_DIR.parent.parent.parent # skills/datto-edr -> repo root
env_root = os.environ.get("CLAUDETOOLS_ROOT")
if env_root:
return Path(env_root)
identity_path = derived_root / ".claude" / "identity.json"
if identity_path.exists():
try:
data = json.loads(identity_path.read_text(encoding="utf-8"))
root = data.get("claudetools_root")
if root:
return Path(root)
except (json.JSONDecodeError, OSError):
pass
return derived_root
def load_api_token() -> str:
"""Load the Datto EDR API token: DATTO_EDR_TOKEN env override, else SOPS vault."""
env_key = os.environ.get("DATTO_EDR_TOKEN")
if env_key:
return env_key.strip()
root = _resolve_claudetools_root()
vault_script = root / ".claude" / "scripts" / "vault.sh"
if not vault_script.exists():
raise DattoEDRError(
f"Cannot load API token: vault wrapper not found at {vault_script} "
"and DATTO_EDR_TOKEN is not set."
)
try:
completed = subprocess.run(
["bash", str(vault_script), "get-field", VAULT_ENTRY, VAULT_FIELD],
capture_output=True,
text=True,
timeout=60,
)
except FileNotFoundError as exc:
raise DattoEDRError(
"Cannot load API token: 'bash' not found on PATH. Install Git Bash or "
"set DATTO_EDR_TOKEN."
) from exc
except subprocess.TimeoutExpired as exc:
raise DattoEDRError("Cannot load API token: vault call timed out.") from exc
if completed.returncode != 0:
raise DattoEDRError(
"Cannot load API token from vault "
f"(exit {completed.returncode}): {completed.stderr.strip()}"
)
token = completed.stdout.strip()
if not token:
raise DattoEDRError("Vault returned an empty API token.")
return token
# --- client -------------------------------------------------------------------
class DattoEDRClient:
def __init__(
self,
api_token: Optional[str] = None,
api_base_url: str = DATTO_EDR_BASE_URL,
timeout: float = TIMEOUT_SECONDS,
connect_timeout: float = CONNECT_TIMEOUT_SECONDS,
):
self.api_base_url = api_base_url.rstrip("/")
self._api_token = api_token
self.timeout = timeout
self.connect_timeout = connect_timeout
@property
def api_token(self) -> str:
if not self._api_token:
self._api_token = load_api_token()
return self._api_token
# -- core transport --------------------------------------------------------
def _request(self, method: str, path: str, filt: Optional[dict] = None,
body: Optional[dict] = None) -> Any:
"""One REST call. `filt` -> ?filter=<json> (LoopBack). Returns parsed JSON."""
url = f"{self.api_base_url}/{path.lstrip('/')}"
if filt is not None:
qs = urllib.parse.urlencode({"filter": json.dumps(filt)})
url = f"{url}?{qs}"
data = json.dumps(body).encode("utf-8") if body is not None else None
result = self._send(method, url, data)
if isinstance(result, dict) and result.get("error"):
err = result["error"]
msg = err.get("message") if isinstance(err, dict) else err
raise DattoEDRError(f"Datto EDR API error [{method} {path}]: {msg}")
return result
def _send(self, method: str, url: str, data: Optional[bytes]) -> Any:
# Auth is the RAW token in the Authorization header (no "Bearer").
headers = {
"Authorization": self.api_token,
"Content-Type": "application/json",
"Accept": "application/json",
}
if _HAS_HTTPX:
try:
timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
with httpx.Client(timeout=timeout) as client:
resp = client.request(method, url, content=data, headers=headers)
resp.raise_for_status()
return resp.json() if resp.content else None
except httpx.TimeoutException as exc:
raise DattoEDRError(f"Datto EDR request timed out: {exc}") from exc
except httpx.HTTPStatusError as exc:
detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS]
raise DattoEDRError(
f"Datto EDR HTTP {exc.response.status_code}: {detail}"
) from exc
except httpx.HTTPError as exc:
raise DattoEDRError(f"Datto EDR request failed: {exc}") from exc
# stdlib fallback
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read()
return json.loads(raw.decode("utf-8")) if raw else None
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
raise DattoEDRError(f"Datto EDR HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise DattoEDRError(f"Datto EDR request failed: {exc}") from exc
# convenience wrappers
def _get(self, path: str, filt: Optional[dict] = None) -> Any:
return self._request("GET", path, filt=filt)
def _count(self, model: str, where: Optional[dict] = None) -> int:
path = f"{model}/count"
if where:
qs = urllib.parse.urlencode({"where": json.dumps(where)})
path = f"{path}?{qs}"
res = self._request("GET", path)
if isinstance(res, dict):
return int(res.get("count", 0))
return 0
# ======================================================================
# READ METHODS (always live)
# ======================================================================
def get_status(self) -> dict:
"""Tenant-wide rollup: counts of agents/alerts/orgs/targets."""
return {
"instance": self.api_base_url,
"organizations": self._count("Organizations"),
"targetGroups": self._count("Targets"),
"agents": self._count("Agents"),
"agentsActive": self._count("Agents", {"active": True}),
"agentsIsolated": self._count("Agents", {"isolated": True}),
"alerts": self._count("Alerts"),
}
def list_organizations(self, limit: int = 200) -> list[dict]:
"""List Organizations (= client tenants/companies). VERIFIED LIVE."""
return self._get("Organizations", {
"limit": limit, "order": "name ASC",
"fields": {"id": True, "name": True, "agentCount": True,
"alertCount": True, "locationCount": True},
}) or []
def list_targets(self, org_id: Optional[str] = None, limit: int = 500) -> list[dict]:
"""List Targets (= target groups / sites). VERIFIED LIVE.
Each Target belongs to an Organization via organizationId."""
filt: dict = {"limit": limit, "order": "name ASC",
"fields": {"id": True, "name": True, "organizationId": True,
"agentCount": True, "activeAgentCount": True,
"alertCount": True, "lastScannedOn": True}}
if org_id:
filt["where"] = {"organizationId": org_id}
return self._get("Targets", filt) or []
def list_agents(self, org_id: Optional[str] = None,
target_group_id: Optional[str] = None,
limit: int = 500) -> list[dict]:
"""List Agents (endpoints). VERIFIED LIVE.
Filter by deviceGroupId (target group). Org filtering is done by the CLI
via the target-group->org map since Agents carry deviceGroupId, not orgId."""
filt: dict = {"limit": limit, "order": "hostname ASC"}
where: dict = {}
if target_group_id:
where["deviceGroupId"] = target_group_id
if where:
filt["where"] = where
return self._get("Agents", filt) or []
def get_agent(self, agent_id: str) -> dict:
"""Agent detail by id. VERIFIED LIVE."""
return self._get(f"Agents/{agent_id}") or {}
def list_alerts(self, org_id: Optional[str] = None,
target_group_id: Optional[str] = None,
severity: Optional[int] = None, days: Optional[int] = None,
limit: int = 200) -> list[dict]:
"""List Alerts (detections). VERIFIED LIVE.
Alerts carry organizationId/organizationName + targetGroupId/Name inline."""
where: dict = {}
if org_id:
where["organizationId"] = org_id
if target_group_id:
where["targetGroupId"] = target_group_id
if severity is not None:
where["severity"] = severity
if days is not None:
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
where["createdOn"] = {"gt": since}
filt: dict = {"limit": limit, "order": "createdOn DESC"}
if where:
filt["where"] = where
return self._get("Alerts", filt) or []
def get_alert(self, alert_id: str) -> dict:
"""Alert detail by id. VERIFIED LIVE."""
return self._get(f"Alerts/{alert_id}") or {}
def list_agent_keys(self) -> Any:
"""List agent registration keys (/agentKeys). Used to build deploy commands."""
return self._get("agentKeys") or []
def sweep(self, org_id: Optional[str] = None) -> dict:
"""Security posture rollup per client: agent health + AV + isolation +
recent detection counts. Built from live reads (never cached)."""
orgs = self.list_organizations()
if org_id:
orgs = [o for o in orgs if o.get("id") == org_id]
out = []
for o in orgs:
oid = o.get("id")
recent = self.list_alerts(org_id=oid, days=7, limit=500)
out.append({
"organizationId": oid,
"organization": o.get("name"),
"agents": o.get("agentCount", 0),
"alertsTotal": o.get("alertCount", 0),
"alertsLast7d": len(recent),
})
out.sort(key=lambda r: (-r["alertsLast7d"], (r["organization"] or "").lower()))
return {"clients": out}
# ======================================================================
# MUTATING METHODS (caller MUST gate behind --confirm)
# ----------------------------------------------------------------------
# Shapes derived from the KaseyaDEDR InfocyteHUNTAPI module (scan.ps1):
# scan a target group : POST targets/{targetGroupId}/scan {options, where}
# scan a single target: POST targets/scan {target, targetGroup, options}
# response/isolate : POST targets/scan with a response extension
# NOT executed against the live prod tenant during build (would scan/isolate
# real client machines). Treat as SHAPE-VERIFIED, RUN-UNVERIFIED until first
# deliberate use.
# ======================================================================
DEFAULT_SCAN_OPTIONS = {
"process": True, "module": True, "driver": True, "memory": True,
"account": True, "artifact": True, "autostart": True, "application": True,
"installed": True, "hook": False, "network": False, "events": True,
}
def scan_target_group(self, target_group_id: str,
options: Optional[dict] = None,
where: Optional[dict] = None) -> Any:
"""Trigger a scan across a target group (POST targets/{id}/scan).
STATE-CHANGING - gate behind --confirm at the call site."""
body: dict = {"options": options or self.DEFAULT_SCAN_OPTIONS}
if where is not None:
body["where"] = where
return self._request("POST", f"targets/{target_group_id}/scan", body=body)
def scan_single_target(self, target_group_id: str, target: str,
options: Optional[dict] = None) -> Any:
"""Scan a single on-demand target (POST targets/scan).
STATE-CHANGING - gate behind --confirm at the call site."""
body: dict = {
"target": target,
"targetGroup": {"id": target_group_id},
"options": options or self.DEFAULT_SCAN_OPTIONS,
}
return self._request("POST", "targets/scan", body=body)
def list_extensions(self, limit: int = 200) -> Any:
"""List agent extensions (response + collection). Read.
Response extensions (e.g. host isolation) are invoked via a scan body."""
return self._get("Extensions", {"limit": limit}) or []
def run_response_extension(self, target_group_id: str, target: str,
extension_id: Optional[str] = None,
extension_name: Optional[str] = None) -> Any:
"""Invoke a response extension on a target (isolate/kill/quarantine).
Per the module, a response runs as a scan with an extension in options.
UNVERIFIED shape - confirm the extension id/name from list_extensions().
STATE-CHANGING - gate behind --confirm at the call site."""
ext: dict = {}
if extension_id:
ext["id"] = extension_id
if extension_name:
ext["name"] = extension_name
body = {
"target": target,
"targetGroup": {"id": target_group_id},
"options": {"extensions": [ext]},
}
return self._request("POST", "targets/scan", body=body)
# ======================================================================
# POWER TOOL
# ======================================================================
def raw(self, method: str, path: str, filt: Optional[dict] = None,
body: Optional[dict] = None) -> Any:
"""Call any endpoint directly. Caller gates mutating methods."""
return self._request(method.upper(), path, filt=filt, body=body)
def main() -> int:
"""Minimal self-check: load token (no network call)."""
try:
client = DattoEDRClient()
_ = client.api_token # triggers vault load
print("[OK] API token loaded; transport =",
"httpx" if _HAS_HTTPX else "urllib")
print("[INFO] instance =", client.api_base_url)
return 0
except DattoEDRError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())