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