datto-edr: apply code-review fixes (gating + footgun hardening)

- deploy-cmd: require explicit --regkey or --group; never auto-pick an
  arbitrary cross-client registration key (would enroll into wrong org).
- raw: block POST to any */scan endpoint with no non-empty `where`
  (same tenant-wide footgun the scan command guards against).
- main(): catch-all for unexpected exceptions -> [ERROR] + errorlog,
  plus clean KeyboardInterrupt (130).
- isolate: forgiving extension-name match (exact, then substring),
  excludes the paired "Restore" ext; errors on ambiguous match.
- detections: --site -> --target-group; Alert.targetGroupId is a
  scan-target id, not a Location id (distinct from `agents --site`).
- status: relabel "Target groups (sites)" -> "Scan target groups".
- SKILL.md + docstrings updated to match.

Verified: py_compile clean, selftest green (216 agents), guards fire
on no-key/empty-where/no-agent, deploy-cmd --group picks the group's key.
This commit is contained in:
2026-06-25 15:14:18 -07:00
parent d87aa9dfd8
commit 79bda6fab9
3 changed files with 113 additions and 36 deletions

View File

@@ -308,16 +308,20 @@ class DattoEDRClient:
orgs = self.list_organizations()
if org_id:
orgs = [o for o in orgs if o.get("id") == org_id]
since = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
out = []
for o in orgs:
oid = o.get("id")
recent = self.list_alerts(org_id=oid, days=7, limit=500)
# _count (not list+len) so a >500-alert client isn't silently capped
# and mis-sorted, and we don't transfer 500 full records per org.
recent = self._count("Alerts", {"organizationId": oid,
"createdOn": {"gt": since}})
out.append({
"organizationId": oid,
"organization": o.get("name"),
"agents": o.get("agentCount", 0),
"alertsTotal": o.get("alertCount", 0),
"alertsLast7d": len(recent),
"alertsLast7d": recent,
})
out.sort(key=lambda r: (-r["alertsLast7d"], (r["organization"] or "").lower()))
return {"clients": out}
@@ -334,15 +338,26 @@ class DattoEDRClient:
# Response extensions (isolate/kill/quarantine) ride the same call via
# options.extensions. (Verified live 2026-06-25.)
# ======================================================================
# Full EDR forensic collection - the default so a bare `scan` is a real sweep
# (an empty options body risks a thin scan that returns a false all-clear on a
# compromised host). Pass options={} explicitly to override with a lean scan.
DEFAULT_SCAN_OPTIONS = {
"process": True, "module": True, "driver": True, "memory": True,
"account": True, "artifact": True, "autostart": True, "application": True,
"installed": True, "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).
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.
Targets by Agent.id via the AND-wrapped LoopBack where the console uses:
{"where":{"and":[{"id":[ids]}]}, "options":{...}, "taskName":"Scan - EDR"}.
A bare {"id":{"inq":[...]}} returns HTTP 412 "column reference id is
ambiguous" (the scan query joins tables) - the and-wrap disambiguates.
REFUSES an empty agent_ids list (an absent where scans the whole tenant).
`options` defaults to DEFAULT_SCAN_OPTIONS (full forensic); pass {} for lean.
STATE-CHANGING - gate behind --confirm at the call site."""
ids = [i for i in (agent_ids or []) if i]
if not ids:
@@ -350,12 +365,9 @@ class DattoEDRClient:
"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 {},
"options": self.DEFAULT_SCAN_OPTIONS if options is None else options,
"taskName": task_name,
}
return self._request("POST", "Agents/scan", body=body)