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
This commit is contained in:
395
.claude/skills/datto-edr/scripts/edr_client.py
Normal file
395
.claude/skills/datto-edr/scripts/edr_client.py
Normal file
@@ -0,0 +1,395 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user