#!/usr/bin/env python3 """CLI for the datto-edr skill - Datto EDR (Infocyte HUNT) REST API. Read subcommands run freely. Mutating subcommands (scan, isolate, run-extension) refuse to run unless --confirm is passed; without it they print what they WOULD do and exit non-zero. Output: --json emits raw JSON; otherwise a readable table/summary. Usage examples: python edr.py status python edr.py orgs # clients (Organizations) python edr.py sites [--org ] # sites (Locations) python edr.py agents [--org ] [--site ] python edr.py agent python edr.py detections [--org ] [--severity 3] [--days 7] python edr.py detection python edr.py sweep [--org ] # per-client posture rollup python edr.py deploy-cmd --regkey | --group # install one-liner python edr.py extensions # list response/collection extensions python edr.py tasks [--type "Scan - EDR"] # scan/analysis jobs python edr.py scan --agent [--agent ...] --confirm python edr.py isolate --agent --confirm python edr.py cancel --confirm python edr.py create-group --name "[TEST] X" --org --confirm python edr.py mint-key --group --key <10char> --confirm python edr.py raw GET Agents --filter '{"limit":1}' """ from __future__ import annotations import argparse import json import os import subprocess import sys from edr_client import DattoEDRClient, DattoEDRError, DEFAULT_INSTANCE def _log_skill_error(skill, msg, context=""): """Soft-fail: append a functional-error entry to errorlog.md (never throws).""" try: root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") ) h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh") if not os.path.exists(h): return a = ["bash", h, skill, msg] if context: a += ["--context", context] subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) except Exception: pass # Only GENUINELY expected/transient API responses are suppressed. Deliberately # NARROW: a real auth failure (HTTP 401, e.g. the ~1yr token lapsing) MUST reach # errorlog.md per CLAUDE.md - so we do NOT match broad words like "required", # "unauthorized", "invalid value" that would swallow it. _EXPECTED_ERROR_MARKERS = ( "http 404", "not found", "no method to handle", "http 429", "too many requests", ) def _is_expected_error(msg: str) -> bool: m = (msg or "").lower() return any(marker in m for marker in _EXPECTED_ERROR_MARKERS) def _should_log_error(command: str, msg: str) -> bool: # raw is exploratory but a genuine failure through it (401/500) is still a # real API failure worth logging - filter only on expectedness, not command. if os.environ.get("EDR_SUPPRESS_ERRORLOG"): return False return not _is_expected_error(msg) def _emit(obj, as_json: bool, table_fn=None) -> None: if as_json or table_fn is None: print(json.dumps(obj, indent=2, default=str)) else: table_fn(obj) def _trunc(s, n): s = "" if s is None else str(s) return s if len(s) <= n else s[: n - 1] + "…" # --- table renderers ---------------------------------------------------------- def _t_status(s): print(f"Datto EDR tenant: {s.get('instance')}") print(f" Organizations (clients): {s.get('organizations')}") print(f" Scan target groups : {s.get('targetGroups')}") print(f" Agents : {s.get('agents')} " f"({s.get('agentsActive')} active, {s.get('agentsIsolated')} isolated)") print(f" Alerts (all time) : {s.get('alerts')}") def _t_orgs(rows): print(f"{'ORGANIZATION':<34} {'AGENTS':>7} {'ALERTS':>7} {'SITES':>6} ID") for o in rows: print(f"{_trunc(o.get('name'),34):<34} {o.get('agentCount',0):>7} " f"{o.get('alertCount',0):>7} {o.get('locationCount',0):>6} {o.get('id')}") print(f"\n{len(rows)} organizations") def _t_sites(rows): print(f"{'SITE (LOCATION)':<34} {'AGENTS':>7} {'ACTIVE':>7} {'ALERTS':>7} ID") for t in rows: print(f"{_trunc(t.get('name'),34):<34} {t.get('agentCount',0):>7} " f"{t.get('activeAgentCount',0):>7} {t.get('alertCount',0):>7} {t.get('id')}") print(f"\n{len(rows)} locations") def _t_scan_targets(rows): print(f"{'SCAN TARGET GROUP':<34} {'AGENTS':>7} {'ACTIVE':>7} {'LAST SCAN':<20} ID") for t in rows: print(f"{_trunc(t.get('name'),34):<34} {t.get('agentCount',0):>7} " f"{t.get('activeAgentCount',0):>7} {_trunc(t.get('lastScannedOn'),20):<20} {t.get('id')}") print(f"\n{len(rows)} scan target groups") def _agent_os(a): for k, label in (("osWindows", "Windows"), ("osLinux", "Linux"), ("osOsx", "macOS"), ("osOther", "Other")): if a.get(k): return label return a.get("os") or "?" def _t_agents(rows): print(f"{'HOSTNAME':<26} {'OS':<8} {'ONLINE':<7} {'ISO':<4} {'AV':<4} " f"{'VERSION':<13} ID") for a in rows: print(f"{_trunc(a.get('hostname') or a.get('name'),26):<26} " f"{_agent_os(a):<8} {('yes' if a.get('active') else 'no'):<7} " f"{('ISO' if a.get('isolated') else '-'):<4} " f"{('on' if a.get('dattoAvEnabled') else '-'):<4} " f"{_trunc(a.get('version'),13):<13} {a.get('id')}") print(f"\n{len(rows)} agents") _SEV = {0: "info", 1: "low", 2: "medium", 3: "high", 4: "critical"} def _t_detections(rows): print(f"{'WHEN':<20} {'SEV':<8} {'HOST':<22} {'ORG':<20} NAME") for a in rows: sev = _SEV.get(a.get("severity"), str(a.get("severity"))) print(f"{_trunc(a.get('createdOn'),20):<20} {sev:<8} " f"{_trunc(a.get('hostname'),22):<22} " f"{_trunc(a.get('organizationName'),20):<20} " f"{_trunc(a.get('name') or a.get('description'),40)}") print(f"\n{len(rows)} detections") def _t_sweep(s): rows = s.get("clients", []) print(f"{'CLIENT':<34} {'AGENTS':>7} {'ALERTS(7d)':>11} {'ALERTS(all)':>12}") for r in rows: print(f"{_trunc(r.get('organization'),34):<34} {r.get('agents',0):>7} " f"{r.get('alertsLast7d',0):>11} {r.get('alertsTotal',0):>12}") print(f"\n{len(rows)} clients (sorted by 7-day detection volume)") def _t_tasks(rows): print(f"{'WHEN':<20} {'TYPE':<16} {'STATUS':<11} {'P%':>4} ID") for t in rows: print(f"{_trunc(t.get('createdOn'),20):<20} {_trunc(t.get('type'),16):<16} " f"{_trunc(t.get('status'),11):<11} {str(t.get('progress','')):>4} {t.get('id')}") print(f"\n{len(rows)} tasks") # --- org -> target group resolution ------------------------------------------- def _agents_for_org(client, org_id, limit): """Agents across all sites (Locations) in an org. Agent.locationId -> Location.""" loc_ids = [l.get("id") for l in client.list_locations(org_id=org_id) if l.get("id")] if not loc_ids: return [] return client.list_agents(location_ids=loc_ids, limit=limit) def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="edr.py", description="Datto EDR (Infocyte HUNT) CLI") p.add_argument("--json", action="store_true", help="emit raw JSON") sub = p.add_subparsers(dest="command", required=True) sub.add_parser("status", help="tenant rollup") sub.add_parser("orgs", help="list organizations (clients)") sub.add_parser("clients", help="alias for orgs") sp = sub.add_parser("sites", help="list sites (Locations)") sp.add_argument("--org", help="filter by organizationId") sp = sub.add_parser("scan-targets", help="list scan target groups") sp.add_argument("--org", help="filter by organizationId") sp = sub.add_parser("agents", help="list agents") sp.add_argument("--org") sp.add_argument("--site", help="location id (a single site)") sp.add_argument("--limit", type=int, default=500) sp = sub.add_parser("agent", help="agent detail") sp.add_argument("id") sp = sub.add_parser("detections", help="list alerts") sp.add_argument("--org") sp.add_argument("--target-group", dest="target_group", help="filter alerts by targetGroupId (Alert.targetGroupId is a " "scan-target id, NOT a Location id - distinct from `agents --site`)") sp.add_argument("--severity", type=int, help="0 info..4 critical") sp.add_argument("--days", type=int) sp.add_argument("--limit", type=int, default=200) sp = sub.add_parser("detection", help="alert detail") sp.add_argument("id") sp = sub.add_parser("sweep", help="per-client posture rollup") sp.add_argument("--org") sub.add_parser("extensions", help="list agent extensions") sp = sub.add_parser("deploy-cmd", help="emit the agent install one-liner") sp.add_argument("--regkey", help="registration key string (used verbatim)") sp.add_argument("--group", help="target group id; looks up THAT group's key from " "/agentKeys (never auto-picks an arbitrary cross-client key)") sp = sub.add_parser("scan", help="trigger an EDR scan on specific agent(s) (gated)") sp.add_argument("--agent", action="append", dest="agents", metavar="AGENT_ID", help="agent id to scan (repeatable). REQUIRED - omitting scans the whole tenant.") sp.add_argument("--task-name", default="Scan - EDR") sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("isolate", help="run a response extension (host isolation) on agent(s) (gated)") sp.add_argument("--agent", action="append", dest="agents", metavar="AGENT_ID", help="agent id (repeatable)") sp.add_argument("--extension-id", help="extension id (else resolved from --extension-name)") sp.add_argument("--extension-name", default="Host Isolation [Win/Linux]") sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("tasks", help="list recent userTasks (scan/analysis jobs)") sp.add_argument("--type", dest="task_type", help="filter by type e.g. 'Scan - EDR'") sp.add_argument("--limit", type=int, default=20) sp = sub.add_parser("task", help="userTask (scan job) detail") sp.add_argument("id") sp = sub.add_parser("cancel", help="cancel a running userTask e.g. a scan (gated)") sp.add_argument("id") sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("create-group", help="create an EDR target group (gated)") sp.add_argument("--name", required=True) sp.add_argument("--org", required=True, help="organizationId") sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("mint-key", help="mint a registration key for a group (gated)") sp.add_argument("--group", required=True, help="target group id") sp.add_argument("--key", required=True, help="10-char key string (caller-supplied)") sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("raw", help="call any endpoint") sp.add_argument("method", help="GET/POST/PUT/DELETE") sp.add_argument("path", help="e.g. Agents or Agents/scan") sp.add_argument("--filter", help="LoopBack filter JSON (GET)") sp.add_argument("--data", help="request body JSON") sp.add_argument("--confirm", action="store_true", help="required for non-GET methods") return p def _gate(args, what): """Print the would-do and return False if --confirm missing.""" if getattr(args, "confirm", False): return True print(f"[DRY-RUN] would {what}\n[BLOCKED] re-run with --confirm to execute.", file=sys.stderr) return False def main(argv=None) -> int: args = build_parser().parse_args(argv) j = args.json client = DattoEDRClient() cmd = args.command try: if cmd == "status": _emit(client.get_status(), j, _t_status) elif cmd in ("orgs", "clients"): _emit(client.list_organizations(), j, _t_orgs) elif cmd == "sites": _emit(client.list_locations(org_id=args.org), j, _t_sites) elif cmd == "scan-targets": _emit(client.list_targets(org_id=args.org), j, _t_scan_targets) elif cmd == "agents": if args.site: if args.org: print("[INFO] --site given; --org ignored (a site is already " "org-scoped).", file=sys.stderr) rows = client.list_agents(location_id=args.site, limit=args.limit) elif args.org: rows = _agents_for_org(client, args.org, args.limit) else: rows = client.list_agents(limit=args.limit) _emit(rows, j, _t_agents) elif cmd == "agent": _emit(client.get_agent(args.id), j) elif cmd == "detections": _emit(client.list_alerts(org_id=args.org, target_group_id=args.target_group, severity=args.severity, days=args.days, limit=args.limit), j, _t_detections) elif cmd == "detection": _emit(client.get_alert(args.id), j) elif cmd == "sweep": _emit(client.sweep(org_id=args.org), j, _t_sweep) elif cmd == "extensions": _emit(client.list_extensions(), j) elif cmd == "deploy-cmd": regkey = args.regkey if not regkey and args.group: # Only the requested group's key - never auto-pick keys[0], which # is an arbitrary CROSS-CLIENT key (agents would land in the wrong org). keys = client.list_agent_keys() match = [k for k in (keys or []) if k.get("targetId") == args.group] if not match: print(f"[ERROR] no registration key found for group {args.group}. " f"Mint one with: mint-key --group {args.group} --key <10char>", file=sys.stderr) return 1 regkey = match[0].get("id") if not regkey: print("[ERROR] deploy-cmd needs --regkey or --group " "(refusing to auto-pick an arbitrary cross-client key).", file=sys.stderr) return 2 _emit(_deploy_payload(client, regkey), j, _t_deploy) elif cmd == "scan": ids = args.agents or [] if not ids: print("[BLOCKED] scan requires at least one --agent . Omitting " "agents would scan the ENTIRE tenant (156-host footgun).", file=sys.stderr) return 2 if not _gate(args, f"EDR-scan {len(ids)} agent(s): {', '.join(ids)}"): return 2 _emit(client.scan_agents(ids, task_name=args.task_name), j) elif cmd == "isolate": ids = args.agents or [] if not ids: print("[BLOCKED] isolate requires at least one --agent .", file=sys.stderr) return 2 ext_id = args.extension_id if not ext_id: # resolve name -> id exts = client.list_extensions() or [] want = args.extension_name.lower() # Exact first; then forgiving substring (tolerates console renames # like "Host Isolation [Windows]"). Exclude the paired "Restore" # extension so we never isolate when asked to isolate-undo, etc. exact = [e for e in exts if (e.get("name") or "").lower() == want] if exact: match = exact else: want_key = want.replace("[win/linux]", "").strip() match = [e for e in exts if want_key in (e.get("name") or "").lower() and "restore" not in (e.get("name") or "").lower()] if not match: print(f"[ERROR] extension '{args.extension_name}' not found. " f"Run `extensions` to list, or pass --extension-id.", file=sys.stderr) return 1 if len(match) > 1: names = ", ".join(f"{e.get('name')} ({e.get('id')})" for e in match) print(f"[ERROR] '{args.extension_name}' matched {len(match)} " f"extensions: {names}. Pass --extension-id to disambiguate.", file=sys.stderr) return 1 ext_id = match[0].get("id") if not _gate(args, f"run extension {ext_id} ('{args.extension_name}') " f"on {len(ids)} agent(s): {', '.join(ids)}"): return 2 _emit(client.run_extension_on_agents(ids, ext_id), j) elif cmd == "tasks": _emit(client.list_tasks(type_=args.task_type, limit=args.limit), j, _t_tasks) elif cmd == "task": _emit(client.get_task(args.id), j) elif cmd == "cancel": if not _gate(args, f"cancel userTask {args.id}"): return 2 _emit(client.cancel_task(args.id), j) elif cmd == "create-group": if not _gate(args, f"create group '{args.name}' in org {args.org}"): return 2 _emit(client.create_group(args.name, args.org), j) elif cmd == "mint-key": if not _gate(args, f"mint key '{args.key}' for group {args.group}"): return 2 _emit(client.mint_registration_key(args.group, args.key), j) elif cmd == "raw": method = args.method.upper() if method != "GET" and not args.confirm: print(f"[BLOCKED] {method} requires --confirm.", file=sys.stderr) return 2 filt = json.loads(args.filter) if args.filter else None body = json.loads(args.data) if args.data else None # Same tenant-wide footgun guard the scan command has: a POST to any # */scan endpoint with no non-empty `where` scans the ENTIRE tenant. if method == "POST" and args.path.rstrip("/").lower().endswith("scan"): where = (body or {}).get("where") if isinstance(body, dict) else None if not where: print("[BLOCKED] POST to a */scan endpoint with no non-empty " "`where` would scan the ENTIRE tenant. Add a where filter, " "e.g. {\"where\":{\"and\":[{\"id\":[\"\"]}]}}.", file=sys.stderr) return 2 _emit(client.raw(method, args.path, filt=filt, body=body), j) else: print(f"[ERROR] unknown command {cmd}", file=sys.stderr) return 1 except DattoEDRError as exc: msg = str(exc) print(f"[ERROR] {msg}", file=sys.stderr) if _should_log_error(cmd, msg): _log_skill_error("datto-edr", msg, context=f"cmd={cmd}") return 1 except KeyboardInterrupt: print("\n[ERROR] interrupted", file=sys.stderr) return 130 except Exception as exc: # unexpected - always surfaced AND logged msg = f"{type(exc).__name__}: {exc}" print(f"[ERROR] unexpected: {msg}", file=sys.stderr) _log_skill_error("datto-edr", msg, context=f"cmd={cmd} unexpected") return 1 return 0 def _deploy_payload(client, regkey): instance = client.api_base_url.replace("https://", "").replace("/api", "").rstrip("/") cname = instance.split(".")[0] full_url = f"https://{instance}" keypart = f" -RegKey {regkey}" if regkey else "" # Pass the FULL -URL, never -InstanceName : the install script's loose # `.com` regex matches "zcom" inside azcomp4587 and leaves --url empty. oneliner = ( "[System.Net.ServicePointManager]::SecurityProtocol = " "[Enum]::ToObject([System.Net.SecurityProtocolType], 3072); " "(new-object Net.WebClient).DownloadString(" "\"https://raw.githubusercontent.com/Infocyte/PowershellTools/master/" "AgentDeployment/install_huntagent.ps1\") | iex; " f"Install-EDR -URL \"{full_url}\"{keypart}" ) return { "instance": cname, "url": full_url, "regkey": regkey or "(none found - agent will register but await approval)", "powershell_oneliner": oneliner, "note": "Run on the target via GuruRMM script-execution or any remote-exec " "channel. RegKey auto-approves + adds to the key's target group.", } def _t_deploy(p): print(f"Instance : {p['instance']}") print(f"RegKey : {p['regkey']}") print(f"\nPowerShell one-liner (run on target, admin):\n\n{p['powershell_oneliner']}\n") print(f"[note] {p['note']}") if __name__ == "__main__": raise SystemExit(main())