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:
2026-06-25 13:55:22 -07:00
parent 778e12d83e
commit 1e80fb24db
4 changed files with 263 additions and 117 deletions

View File

@@ -82,61 +82,82 @@ $EDR detections [--org <orgId>] [--site <locationId>] [--severity 0-4] [--days N
$EDR detection <alertId> # full alert detail
$EDR sweep [--org <orgId>] # per-client posture rollup (headline view)
$EDR extensions # list response/collection extensions
$EDR tasks [--type "Scan - EDR"] [--limit N] # recent userTasks (scan/analysis jobs)
$EDR task <taskId> # one userTask (scan job) detail/status
$EDR deploy-cmd [--regkey <key>] # emit the agent install one-liner (see Deployment)
```
`agent`/`detection`/`status`/`sweep` support `--json` (place it before the subcommand
or after — argparse global flag): `$EDR --json sweep`.
`--json` is a global flag (place before the subcommand): `$EDR --json sweep`.
### Mutating (gated — refuse without `--confirm`)
```bash
# Scan: POST targets/{targetGroupId}/scan (whole group) or POST targets/scan (single)
$EDR scan --target-group <id> --confirm
$EDR scan --target-group <id> --target <ip/host> --confirm
Scan/isolate go through `POST Agents/scan` with a **`where` filter selecting the
agents**. The CLI REQUIRES explicit `--agent` id(s): an absent `where` scans the
ENTIRE tenant (the "156-host" footgun), so the client refuses an empty list.
# Response extension (host isolation, kill, quarantine) — runs as a scan w/ extension
$EDR isolate --target-group <id> --target <host> --extension-name "Host Isolation" --confirm
```bash
# EDR-scan specific agent(s). Repeat --agent for several. POST Agents/scan
# {"where":{"and":[{"id":[ids]}]}, "options":{}, "taskName":"Scan - EDR"}
$EDR scan --agent <agentId> [--agent <agentId2> ...] --confirm
# Response extension (Host Isolation, etc.) on agent(s) — rides Agents/scan w/ options.extensions
$EDR isolate --agent <agentId> [--extension-name "Host Isolation [Win/Linux]"] --confirm
$EDR isolate --agent <agentId> --extension-id <extId> --confirm
# Cancel a running scan/task; create a group; mint a group registration key
$EDR cancel <taskId> --confirm
$EDR create-group --name "[TEST] Foo" --org <organizationId> --confirm
$EDR mint-key --group <targetId> --key <10char> --confirm
# Power tool — any endpoint. Non-GET requires --confirm.
$EDR raw GET Agents --filter '{"limit":1}'
$EDR raw POST targets/<id>/scan --data '{"options":{...}}' --confirm
$EDR raw POST Agents/scan --data '{"where":{"and":[{"id":["<id>"]}]},"options":{},"taskName":"Scan - EDR"}' --confirm
```
**The single-agent scan body that works:** `{"where":{"and":[{"id":["<agentId>"]}]},
"options":{}, "taskName":"Scan - EDR"}`. A bare `{"id":{"inq":[...]}}` returns HTTP
412 "column reference id is ambiguous" — the `and`-wrap is required. AV scans
(`Scan - AV Quick/Full`) are policy-driven, NOT callable via this endpoint.
## Deployment
Agent install is **not a REST call** — it's the agent binary run on the endpoint.
`deploy-cmd` emits the official one-liner (pulls a live registration key from
`/agentKeys`):
`/agentKeys`). To land an agent in a SPECIFIC group: `create-group``mint-key`
(the key `id` is caller-supplied) → install with that `-RegKey`.
```
Install-EDR -InstanceName azcomp4587 -RegKey <key>
Install-EDR -URL "https://azcomp4587.infocyte.com" -RegKey <key>
```
Push it to a machine via **GuruRMM script execution** (`/rmm`) or any remote-exec
channel. `RegKey` auto-approves the agent and adds it to the default target group;
without it the agent registers but waits for manual approval in the console.
**Pass the full `-URL`, not `-InstanceName azcomp4587`** — the install script's loose
`.com` regex matches "zcom" inside the cname, leaving `--url` empty and the agent
erroring `a value is required for '--url'`. Push via **GuruRMM** (`/rmm`) or any
remote-exec channel. `RegKey` auto-approves + adds to the key's target group; without
it the agent registers but awaits manual approval.
## Safety gating
- Reads never mutate and run without confirmation.
- `scan`, `isolate`, and any non-GET `raw` print a `[DRY-RUN]` line and exit non-zero
unless `--confirm` is passed.
- **Never** run a scan or isolation casually — these hit live client production
endpoints. Confirm the target group / host first with the read commands.
- `scan`, `isolate`, `cancel`, `create-group`, `mint-key`, and any non-GET `raw` print
a `[DRY-RUN]` line and exit non-zero unless `--confirm` is passed.
- `scan`/`isolate` additionally REFUSE an empty `--agent` list (would scan the whole
tenant). **Never** scan/isolate casually — these hit live client production endpoints.
Confirm the agent id with the read commands first.
## Verified vs. unverified
**Live-verified 2026-06-25 (read fully exercised on the tenant):** `status`, `orgs`,
`sites`, `scan-targets`, `agents` (incl. per-org via Location mapping), `agent`,
`detections`, `detection`, `sweep`, `deploy-cmd` (real key returned), `raw` GET, and
the `--confirm` gate.
**Live-verified 2026-06-25 (exercised end-to-end on the tenant):** all reads
(`status`, `orgs`, `sites`, `scan-targets`, `agents` incl. per-org Location mapping,
`agent`, `detections`, `detection`, `sweep`, `extensions`, `tasks`, `task`); the full
deploy lifecycle (`create-group``mint-key` → install via `/rmm` → agent registered
into the group); `scan` (targeted single-agent — confirmed "Scanning 1 host"); `cancel`;
`deploy-cmd`; `raw`; and the `--confirm` + empty-`--agent` guards.
**SHAPE-verified, RUN-unverified (NOT executed against prod during build — would
scan/isolate real client machines):** `scan`, `isolate`/`run_response_extension`. The
payload shapes come from the KaseyaDEDR `InfocyteHUNTAPI` module (`scan.ps1`). Before
first real use, confirm the response-extension id/name via `$EDR extensions` and do a
single deliberate test on an ACG-internal machine.
**RUN-unverified (shape correct, not fired at prod):** `isolate` — it rides the same
`Agents/scan` call with the Host Isolation extension in `options.extensions`; confirm
the extension id via `$EDR extensions` and test on an ACG-internal box before relying
on it (it cuts the endpoint off the network). AV scans are policy-driven (not callable).
## Relationship to GuruRMM (Feature 6)

View File

@@ -70,30 +70,59 @@ sourceName, responseData, signed, managed, archived`.
| Alerts | `GET /Alerts` (filter `where` org/severity/createdOn) |
| Alert detail | `GET /Alerts/{id}` |
| Extensions | `GET /Extensions` |
| Agent reg keys | `GET /agentKeys` (for deploy one-liner) |
| Agent reg keys | `GET /agentKeys` (each is `{id:<key>, targetId}`) |
| Tasks (scan jobs) | `GET /userTasks` (filter `where type`, e.g. `"Scan - EDR"`) |
| Task detail | `GET /userTasks/{id}` |
## Mutating (shape-verified from the InfocyteHUNTAPI module; gate behind --confirm)
## Mutating (VERIFIED LIVE 2026-06-25; gate behind --confirm)
The Infocyte-module scan routes (`targets/{id}/scan`, `targets/scan`, `scans`) are
**DEAD (404)**. The live mechanism (read from the console JS bundle + run live) is a
single endpoint selecting agents by a LoopBack `where`:
| Op | Method/path | Body |
|---|---|---|
| Scan a target group | `POST /targets/{targetGroupId}/scan` | `{"options":{...ScanOptions}, "where":{...}?}` |
| Scan a single target | `POST /targets/scan` | `{"target":"ip/host","targetGroup":{"id":"..."},"options":{...}}` |
| Response extension (isolate/kill/quarantine) | `POST /targets/scan` | `{"target":"...","targetGroup":{"id":"..."},"options":{"extensions":[{"id"|"name":...}]}}` |
| **Scan agent(s)** | `POST /Agents/scan` | `{"where":{"and":[{"id":["<agentId>",...]}]}, "options":{}, "taskName":"Scan - EDR"}` |
| **Response ext** (isolate/kill) | `POST /Agents/scan` | `{"where":{"and":[{"id":[...]}]}, "options":{"extensions":[{"id":"<extId>"}]}, "taskName":"Response"}` |
| **Cancel a scan/task** | `POST /userTasks/{id}/cancel` | — (204) |
| **Create group** | `POST /Targets` | `{"name":"...","organizationId":"..."}` |
| **Mint reg key** | `POST /agentKeys` | `{"id":"<10char,caller-supplied>","targetId":"<group>"}` |
**ScanOptions** booleans: `process, module, driver, memory, account, artifact,
autostart, application, installed, hook, network, events`, plus
`extensions:[{id,args,order}]`.
**CRITICAL footguns:**
- The `where` is **REQUIRED**. An absent/empty `where` scans the **ENTIRE tenant**
("Scanning 156 hosts"). The CLI refuses an empty agent list.
- Use the **AND-wrapped** form `{"where":{"and":[{"id":[...]}]}}`. A bare
`{"where":{"id":{"inq":[...]}}}` returns **HTTP 412 "column reference id is
ambiguous"** (the scan query joins tables; the `and`-wrap disambiguates).
- Targeting is by **Agent.id** (not `deviceId`, which is null right after enroll).
- `scanType` is a client-side UI enum, never sent. AV scans (`Scan - AV Quick/Full`)
are **policy-driven, not callable** via this endpoint.
- Sibling endpoints exist for broader scope: `POST /organizations/scan`,
`/locations/scan`, `/locations/{id}/scan` (same `{where, options}` shape).
These were **not executed** against the prod tenant during build (they scan/isolate
real client endpoints). Confirm the response-extension id/name via `GET /Extensions`
and run one deliberate test on an ACG-internal machine before relying on them.
**options** (EDR forensic toggles, all optional; empty `{}` is valid):
`process, module, driver, memory, account, artifact, autostart, application,
installed, hook, network, events`, plus `extensions:[{id,args,order}]`.
`isolate` is shape-correct but RUN-unverified (cuts the endpoint off-network) — confirm
the extension id via `GET /Extensions` (e.g. `Host Isolation [Win/Linux]`) and test on
an ACG-internal box first.
## Deployment (not a REST call)
The agent installs by running the binary on the endpoint:
`agent.exe --key <RegKey> --url https://<instance>.infocyte.com`, or via the official
PowerShell wrapper one-liner (`Install-EDR -InstanceName <cname> -RegKey <key>`). Pull
a live key from `GET /agentKeys`. Push via GuruRMM `/rmm` script execution or any
PowerShell wrapper one-liner. **Pass the FULL `-URL`, not `-InstanceName <cname>`** —
the install script's loose `.com` regex matches "zcom" inside `azcomp4587`, leaving
`--url` empty (`a value is required for '--url'`):
```
Install-EDR -URL "https://azcomp4587.infocyte.com" -RegKey <key>
```
To land an agent in a SPECIFIC group: `POST /Targets` (create group) → `POST /agentKeys`
(mint key, caller-supplied `id`) → install with that key. Pull existing keys from
`GET /agentKeys` (each `{id:<key>, targetId}`). Push via GuruRMM `/rmm` or any
remote-exec channel. Nothing ties install to Datto RMM.
## Liftable client

View File

@@ -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.",
}

View File

@@ -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