# Datto EDR (Infocyte HUNT) API reference

Datto EDR == rebranded Infocyte HUNT. The API is a per-tenant **LoopBack** REST
service. Everything below was verified live against `azcomp4587.infocyte.com` on
2026-06-25 unless marked otherwise.

## Base URL & auth

- Base: `https://<instance>.infocyte.com/api` (this tenant: `azcomp4587`).
- Self-documenting LoopBack explorer: `https://<instance>.infocyte.com/explorer`.
- **Auth: raw 64-char token in the `Authorization` header.** NO `Bearer` prefix, no
  Basic, no OAuth. `Authorization: <token>`. (Source: KaseyaDEDR PowershellTools
  `requestHelpers.ps1`; confirmed live — `Bearer` prefix would fail.)
- Token created in console: username menu → **Admin → Users & Tokens → API Tokens →
  Create new token**. Shown once. **Expires 1 year after creation.**
- Vault: `msp-tools/datto-edr.sops.yaml` field `credentials.api_token`.

## LoopBack conventions

- Models are PascalCase collections: `Organizations`, `Locations`, `Targets`,
  `Agents`, `Alerts`, `Boxes`, `Reports`, `deviceGroups` (lowercase), `agentKeys`.
- List with a filter: `GET /<Model>?filter=<urlencoded JSON>` where the JSON supports
  `{"where":{...},"limit":N,"order":"field DIR","fields":{"x":true}}`.
- `where` operators: `{"field":{"gt":"..."}}`, `{"field":{"inq":[...]}}`, equality
  `{"field":val}`.
- Count: `GET /<Model>/count?where=<urlencoded JSON>` → `{"count":N}`.
- Detail: `GET /<Model>/{id}`.
- A model whose route doesn't exist returns `{"error":{"statusCode":404,"message":
  "There is no method to handle GET /..."}}`.

## Data hierarchy

```
Organization (client)            GET /Organizations   [id,name,agentCount,alertCount,locationCount,tenantId]
  └─ Location (site)             GET /Locations       [id,name,organizationId,agentCount,activeAgentCount,alertCount,lastScannedOn]
       └─ Agent (endpoint)       GET /Agents          (Agent.locationId -> Location.id)
Target (SCAN group)              GET /Targets         [id,name,organizationId,agentCount,activeAgentCount,lastScannedOn]  (scannable unit)
deviceGroup (global category)    GET /deviceGroups    [id,name,deviceType]  ("Servers"/"Workstations" — spans all orgs)
Alert (detection)                GET /Alerts          (carries organizationId/Name, targetGroupId/Name, severity, mitre*)
```

To list **agents for a client**: resolve org → its Locations (`where organizationId`)
→ `GET /Agents?filter={"where":{"locationId":{"inq":[<locIds>]}}}`. (Agents do NOT
carry organizationId directly; `deviceGroupId` is a global category, not the client.)

## Agent object (key fields)

`id, hostname, name, os, osWindows/osLinux/osOsx/osOther, version, ip/ipPub,
active (online), heartbeat, isolated (containment state), dattoAvEnabled,
markedForUninstall, markedForUpdate, locationId, deviceGroupId, deviceId, authorized,
eppData, rwdInfo`.

## Alert object (key fields)

`id, name, description, severity (0 info,1 low,2 medium,3 high,4 critical),
mitreTactic, mitreId, hostname, organizationId, organizationName, targetGroupId,
targetGroupName, deviceId, agentId, hostId, eventTime, createdOn, sourceType,
sourceName, responseData, signed, managed, archived`.

## Reads (verified)

| Op | Method/path |
|---|---|
| Tenant counts | `GET /Organizations/count`, `/Targets/count`, `/Agents/count`, `/Alerts/count` |
| Organizations | `GET /Organizations` |
| Locations (sites) | `GET /Locations` (filter `where organizationId`) |
| Scan target groups | `GET /Targets` |
| Agents | `GET /Agents` (filter `where locationId` / `inq`) |
| Agent detail | `GET /Agents/{id}` |
| Alerts | `GET /Alerts` (filter `where` org/severity/createdOn) |
| Alert detail | `GET /Alerts/{id}` |
| Extensions | `GET /Extensions` |
| 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 (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 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>"}` |

**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).

**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.

## Alert Suppression Rules (verified live 2026-07-07)

Suppress a false-positive detection so it stops firing (and stops triggering any
attached auto-response like `isolate-host`). LoopBack models are API-reachable:

| Op | Method/path |
|---|---|
| List rules | `GET /SuppressionRules` (`[id, alertId, name, versionCount, description, organizationId, locationId, active, deleted]`) |
| Count | `GET /SuppressionRules/count` |
| Match criteria (per rule) | `GET /SuppressionRules/{id}/versions` → `[{id, suppressionRuleId, name, description, metadata:{...}}]` |

**`metadata`** is the full match-field map; each key is `{value, active, display, dataType,
operator}`. A field participates in the match ONLY when `active:true`; all active fields are
**AND**-ed. Available fields (`display`): Alert Type, Item Type, Organization Name, Location
Name, Hostname, IP Address, Name, File Path, File SHA1, File SHA256, File Signature Issuer,
**Process Command Line**, AV Threat Name, Operating System, Threat Status, Severity, Rule
Name, EPP Type, Threat Category, Process Owner, Process Owner UID, AV Hits, Parent Process
Name, Grand Parent Process Name.

**Scoping guidance (learned from the vwp-qbs RMM false-positive incident):** the tightest
safe suppression matches on **Process Command Line** (a unique fingerprint of the exact
script) plus **Grand Parent Process Name** (the launching agent, e.g. `gururmm-agent.exe`).
That trusts one exact known-good automation without blinding a whole binary. NEVER suppress
on `Name`/`File Path`/`File SHA*` of a LOLBin (powershell/rundll32/etc.) alone — it disables
the rule for that binary everywhere. Add `Rule Name` to limit to one rule; add `Hostname`/
`Organization Name`/`Location Name` to limit scope (empty `organizationId`/`locationId` on the
rule = fleet-wide, gated by the metadata match). **Do NOT blanket-whitelist an RMM agent as
grandparent by itself** — the RMM is a SYSTEM-level RCE channel and the top MSP attack path;
whitelisting all of it blinds EDR exactly where it matters.

**[DANGER] API create footgun (verified live 2026-07-07).** `POST /SuppressionRules` on this
tenant (a) **ignores `active:false` and forces the rule LIVE immediately**, and (b) auto-creates
an **EMPTY-metadata version that matches EVERYTHING**. So the two-step "create rule → then add
version" path leaves a live match-all suppression window (observed ~2 min → deleted). Also note
`GET`/`PATCH /SuppressionRules/{id}` (single-resource route) **HTTP 400** "undefined is not valid
JSON" on this tenant — read via `GET /SuppressionRules?filter={"where":{"id":"..."}}` instead;
**`DELETE /SuppressionRules/{id}` DOES work** (returns null, sets `deleted:true`). Until an
**atomic** create (metadata embedded in the POST body) is verified, **create suppressions in the
CONSOLE**, not via `raw POST`. If you must POST, verify the version's active fields IMMEDIATELY
and `DELETE` on any doubt.

**Create — CONSOLE-observed, API create RUN-unverified.** Console flow: open the alert →
**Create Suppression Rule** → check the desired Match fields → Save. This POSTs a
`SuppressionRule` (carrying `alertId`, `name`, `description`, `organizationId`, `locationId`,
`active`) plus a version whose `metadata` has the chosen fields flipped to `active:true`. To
replicate via API, POST `/SuppressionRules` then the version to `/SuppressionRules/{id}/
versions` with that metadata shape — verify the exact envelope on the next real create (build
→ read back via the GET above → delete if wrong) before relying on it. Example on this tenant:
rule `e4dd55bf-…` (name "Exfiltration Over HTTP Protocol", alertId `5d5f39b1-…`) matches
Process Command Line + Grand Parent = `gururmm-agent.exe`.

## 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. **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

`github.com/KaseyaDEDR/PowershellTools` — the `InfocyteHUNTAPI` module (Apache-2.0) is
a complete REST wrapper and the de-facto API spec. `extension-docs` (Lua 5.3)
documents the agent response actions (host isolation `isolator:isolate()`, process
kill, quarantine).

## Webhooks (for the GuruRMM add-on)

Admin → Webhooks → Add Webhook: EDR POSTs full alert JSON over HTTPS on each detection,
with custom auth headers supported. This is the recommended near-real-time feed for a
GuruRMM "EDR add-on" dashboard, complemented by REST polling here. (Not wired in this
skill yet — Feature 6 work.)
