datto-edr: fix scan to verified Agents/scan endpoint + harden
- Scan now uses POST Agents/scan with AND-wrapped where {and:[{id:[...]}]}
(the Infocyte targets/{id}/scan routes are dead/404; bare {id:{inq}} returns
HTTP 412 ambiguous-column). Verified live: single-agent scan -> 'Scanning 1 host'.
- scan/isolate REQUIRE explicit --agent ids; empty list refused (tenant-wide footgun).
- isolate rides Agents/scan with the Host Isolation extension in options.extensions;
resolves --extension-name -> id via /Extensions.
- New subcommands: tasks, task, cancel, create-group, mint-key.
- deploy-cmd emits full -URL (not -InstanceName; cname 'azcomp4587' trips the
install script's .com regex and leaves --url empty).
- Docs (SKILL.md + api-reference.md) rewritten to the verified endpoints + footguns.
Lifecycle verified end-to-end on RMM-TEST-MACHINE (create-group/mint-key/install/
register/scan/cancel).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -160,6 +160,14 @@ def _t_sweep(s):
|
||||
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."""
|
||||
@@ -210,22 +218,43 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp = sub.add_parser("deploy-cmd", help="emit the agent install one-liner")
|
||||
sp.add_argument("--regkey", help="registration key (else pulled from /agentKeys)")
|
||||
|
||||
sp = sub.add_parser("scan", help="trigger a scan (gated)")
|
||||
sp.add_argument("--target-group", required=True,
|
||||
help="scan target group id (from scan-targets)")
|
||||
sp.add_argument("--target", help="single ip/host (else whole group)")
|
||||
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="invoke a response extension e.g. host isolation (gated)")
|
||||
sp.add_argument("--target-group", required=True, help="scan target group id")
|
||||
sp.add_argument("--target", required=True, help="ip/host")
|
||||
sp.add_argument("--extension-id")
|
||||
sp.add_argument("--extension-name", default="Host Isolation")
|
||||
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 targets/{id}/scan")
|
||||
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",
|
||||
@@ -283,24 +312,52 @@ def main(argv=None) -> int:
|
||||
regkey = keys[0].get("key") or keys[0].get("id")
|
||||
_emit(_deploy_payload(client, regkey), j, _t_deploy)
|
||||
elif cmd == "scan":
|
||||
tgt = f"target {args.target}" if args.target else "the whole target group"
|
||||
if not _gate(args, f"scan {tgt} in target group {args.target_group}"):
|
||||
ids = args.agents or []
|
||||
if not ids:
|
||||
print("[BLOCKED] scan requires at least one --agent <id>. Omitting "
|
||||
"agents would scan the ENTIRE tenant (156-host footgun).",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if args.target:
|
||||
res = client.scan_single_target(args.target_group, args.target)
|
||||
else:
|
||||
res = client.scan_target_group(args.target_group)
|
||||
_emit(res, j)
|
||||
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":
|
||||
if not _gate(args, f"run response extension "
|
||||
f"'{args.extension_name or args.extension_id}' "
|
||||
f"on {args.target} (target group {args.target_group})"):
|
||||
ids = args.agents or []
|
||||
if not ids:
|
||||
print("[BLOCKED] isolate requires at least one --agent <id>.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
res = client.run_response_extension(
|
||||
args.target_group, args.target,
|
||||
extension_id=args.extension_id,
|
||||
extension_name=None if args.extension_id else args.extension_name)
|
||||
_emit(res, j)
|
||||
ext_id = args.extension_id
|
||||
if not ext_id: # resolve name -> id
|
||||
exts = client.list_extensions()
|
||||
match = [e for e in (exts or [])
|
||||
if (e.get("name") or "").lower() == args.extension_name.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
|
||||
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:
|
||||
@@ -322,23 +379,27 @@ def main(argv=None) -> int:
|
||||
|
||||
|
||||
def _deploy_payload(client, regkey):
|
||||
instance = client.api_base_url.replace("https://", "").replace("/api", "")
|
||||
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 <cname>: 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 -InstanceName {cname}{keypart}"
|
||||
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 default target group.",
|
||||
"channel. RegKey auto-approves + adds to the key's target group.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -242,9 +242,10 @@ class DattoEDRClient:
|
||||
return self._get("Locations", filt) or []
|
||||
|
||||
def list_targets(self, org_id: Optional[str] = None, limit: int = 500) -> list[dict]:
|
||||
"""List Targets (= SCAN target groups). VERIFIED LIVE. Distinct from
|
||||
Locations: a Target is the scannable unit (POST targets/{id}/scan). Each
|
||||
Target belongs to an Organization via organizationId."""
|
||||
"""List Targets (= scan groups). VERIFIED LIVE. A grouping concept that
|
||||
belongs to an Organization via organizationId (often aliases a Location
|
||||
id). NOTE: scanning is per-AGENT via Agents/scan - the legacy
|
||||
targets/{id}/scan route is DEAD (404) on this tenant."""
|
||||
filt: dict = {"limit": limit, "order": "name ASC",
|
||||
"fields": {"id": True, "name": True, "organizationId": True,
|
||||
"agentCount": True, "activeAgentCount": True,
|
||||
@@ -324,64 +325,98 @@ class DattoEDRClient:
|
||||
# ======================================================================
|
||||
# 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.
|
||||
# The Infocyte-module scan routes (targets/{id}/scan, targets/scan, scans)
|
||||
# are DEAD (404) on Datto EDR. The live mechanism, read verbatim from the
|
||||
# console JS bundle, is:
|
||||
# POST agents/scan {where:<loopback where>, options:<scan opts>, taskName}
|
||||
# where `where` SELECTS the agents. An ABSENT/empty `where` scans the ENTIRE
|
||||
# tenant (the 156-host footgun) - so scan_agents REFUSES an empty id list.
|
||||
# Response extensions (isolate/kill/quarantine) ride the same call via
|
||||
# options.extensions. (Verified live 2026-06-25.)
|
||||
# ======================================================================
|
||||
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_agents(self, agent_ids: list[str],
|
||||
options: Optional[dict] = None,
|
||||
task_name: str = "Scan - EDR") -> Any:
|
||||
"""Trigger an EDR scan on specific agents (POST agents/scan).
|
||||
|
||||
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).
|
||||
Targets by Agent.id via a LoopBack where filter:
|
||||
{"where":{"id":{"inq":[ids]}}, "options":{}, "taskName":"Scan - EDR"}.
|
||||
REFUSES an empty agent_ids list - an empty where scans the whole tenant.
|
||||
`options` may carry EDR forensic toggles + extensions; empty {} is valid.
|
||||
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,
|
||||
ids = [i for i in (agent_ids or []) if i]
|
||||
if not ids:
|
||||
raise DattoEDRError(
|
||||
"scan_agents refused: empty agent list. An absent `where` scans "
|
||||
"the ENTIRE tenant (156-host footgun) - pass explicit agent id(s)."
|
||||
)
|
||||
# AND-wrapped form is REQUIRED: a bare {id:{inq:[...]}} returns HTTP 412
|
||||
# "column reference id is ambiguous" (the backend joins tables). The
|
||||
# console builds {and:[{id:[...]}]}; verified live -> "Scanning 1 host".
|
||||
body = {
|
||||
"where": {"and": [{"id": ids}]},
|
||||
"options": options or {},
|
||||
"taskName": task_name,
|
||||
}
|
||||
return self._request("POST", "targets/scan", body=body)
|
||||
return self._request("POST", "Agents/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."""
|
||||
Response extensions (e.g. Host Isolation) are invoked via scan options."""
|
||||
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().
|
||||
def run_extension_on_agents(self, agent_ids: list[str], extension_id: str,
|
||||
task_name: str = "Response") -> Any:
|
||||
"""Invoke a response extension (isolate/kill/quarantine) on specific
|
||||
agents - rides POST agents/scan with the extension in options.extensions.
|
||||
Same empty-list guard as scan_agents. Confirm the extension id via
|
||||
list_extensions() (e.g. 'Host Isolation [Win/Linux]').
|
||||
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
|
||||
ids = [i for i in (agent_ids or []) if i]
|
||||
if not ids:
|
||||
raise DattoEDRError(
|
||||
"run_extension_on_agents refused: empty agent list (would target "
|
||||
"the whole tenant). Pass explicit agent id(s)."
|
||||
)
|
||||
if not extension_id:
|
||||
raise DattoEDRError("run_extension_on_agents requires an extension_id.")
|
||||
body = {
|
||||
"target": target,
|
||||
"targetGroup": {"id": target_group_id},
|
||||
"options": {"extensions": [ext]},
|
||||
"where": {"and": [{"id": ids}]}, # AND-wrapped (see scan_agents)
|
||||
"options": {"extensions": [{"id": extension_id}]},
|
||||
"taskName": task_name,
|
||||
}
|
||||
return self._request("POST", "targets/scan", body=body)
|
||||
return self._request("POST", "Agents/scan", body=body)
|
||||
|
||||
# -- task status / control --------------------------------------------------
|
||||
def list_tasks(self, type_: Optional[str] = None, limit: int = 20) -> list[dict]:
|
||||
"""List userTasks (scan/analysis jobs), newest first. Read."""
|
||||
filt: dict = {"limit": limit, "order": "createdOn DESC"}
|
||||
if type_:
|
||||
filt["where"] = {"type": type_}
|
||||
return self._get("userTasks", filt) or []
|
||||
|
||||
def get_task(self, task_id: str) -> dict:
|
||||
"""Get one userTask (scan job) detail. Read."""
|
||||
return self._get(f"userTasks/{task_id}") or {}
|
||||
|
||||
def cancel_task(self, task_id: str) -> Any:
|
||||
"""Cancel a running userTask, e.g. a scan (POST userTasks/{id}/cancel ->
|
||||
204). STATE-CHANGING - gate behind --confirm at the call site."""
|
||||
return self._request("POST", f"userTasks/{task_id}/cancel")
|
||||
|
||||
# -- provisioning -----------------------------------------------------------
|
||||
def create_group(self, name: str, organization_id: str) -> Any:
|
||||
"""Create an EDR target group under an org (POST Targets).
|
||||
STATE-CHANGING - gate behind --confirm at the call site."""
|
||||
return self._request("POST", "Targets",
|
||||
body={"name": name, "organizationId": organization_id})
|
||||
|
||||
def mint_registration_key(self, target_id: str, key: str) -> Any:
|
||||
"""Mint an agent registration key tied to a target group (POST agentKeys).
|
||||
The key string `id` is CALLER-SUPPLIED (10-char); an absent id 500s.
|
||||
STATE-CHANGING - gate behind --confirm at the call site."""
|
||||
return self._request("POST", "agentKeys",
|
||||
body={"id": key, "targetId": target_id})
|
||||
|
||||
# ======================================================================
|
||||
# POWER TOOL
|
||||
|
||||
Reference in New Issue
Block a user