sync: auto-sync from HOWARD-HOME at 2026-06-25 12:30:38
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-25 12:30:38
This commit is contained in:
@@ -120,6 +120,9 @@
|
||||
- [Cascades FR GPO fix](reference_cascades_fr_gpo_fix.md) — Native Folder Redirection was DOA on every machine: redirect targets were in a misnamed `fdeploy1.ini` (Windows reads `fdeploy.ini`) → empty target path → silent no-op → per-user registry workaround every time. Fixed 2026-06-08 (correct fdeploy.ini + version bump). Also: CS-SERVER live RMM agent is `c39f1de7...` (old `6766e973` stale).
|
||||
- [pfSense 25.07 ops quirks](reference_pfsense_25_07_ops.md) — Cascades pfSense Plus 25.07: logs are PLAIN TEXT (use tail/grep, NOT clog → clog returns empty); clean dhcpd restart = `services_dhcpd_configure()` via slow pfSsh.php (needs 50s+ timeout); dirty boot can leave 2 dhcpd → DISCOVER/OFFER but no ACK; reboot the Cox modem after a config restore; ZFS survives power loss. From the 2026-06-17 power-outage incident.
|
||||
- [feedback_ascii_only_api_payloads](feedback_ascii_only_api_payloads.md) -- On Windows/Git-bash, non-ASCII chars (em-dash, arrow, smart quotes) in JSON payload TEXT passed to curl get mangled and rejected — Discord bot-alert returns 400, the coord API returns "error parsing the body". Use ASCII-only in API payload text, or a single-quoted heredoc.
|
||||
- [feedback_bitdefender_unattended_install](feedback_bitdefender_unattended_install.md) -- Bitdefender unattended RMM install must use the FULL KIT as SYSTEM (silent, no UAC) — the downloader stub fails headless and triggers UAC
|
||||
- [Broken [[backlinks]] are write-me-later markers — flesh out from session history, don't delete](feedback_broken_backlinks_are_writeme_markers.md) -- A [[name]] link in a memory body whose target file doesn't exist is NOT an error to clean up — it's an intentional marker that that memory is worth writing. When you hit one (or memory-dream lists them), flesh the missing memory out from the session logs / session history, don't strip the link.
|
||||
- [feedback_rmm_longops_fire_and_forget](feedback_rmm_longops_fire_and_forget.md) -- Long-running RMM endpoint ops (software installs, big downloads) must be fire-and-forget, not live-monitored
|
||||
|
||||
## Machine
|
||||
- [GURU-5070 Workstation Setup](reference_workstation_setup.md) — Mike's primary (owner confirmed 2026-05-26). Windows 11 Pro. Renamed from OC-5070 → ACG-5070/acg-guru-5070 → GURU-5070; all the same box, all Mike's.
|
||||
|
||||
339
.claude/skills/datto-edr/scripts/edr.py
Normal file
339
.claude/skills/datto-edr/scripts/edr.py
Normal file
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for the datto-edr skill - Datto EDR (Infocyte HUNT) REST API.
|
||||
|
||||
Read subcommands run freely. Mutating subcommands (scan, isolate, run-extension)
|
||||
refuse to run unless --confirm is passed; without it they print what they WOULD
|
||||
do and exit non-zero.
|
||||
|
||||
Output: --json emits raw JSON; otherwise a readable table/summary.
|
||||
|
||||
Usage examples:
|
||||
python edr.py status
|
||||
python edr.py orgs # clients (Organizations)
|
||||
python edr.py sites [--org <orgId>] # target groups
|
||||
python edr.py agents [--org <orgId>] [--site <targetGroupId>]
|
||||
python edr.py agent <agentId>
|
||||
python edr.py detections [--org <orgId>] [--severity 3] [--days 7]
|
||||
python edr.py detection <alertId>
|
||||
python edr.py sweep [--org <orgId>] # per-client posture rollup
|
||||
python edr.py deploy-cmd [--regkey <key>] # emit agent install one-liner
|
||||
python edr.py extensions # list response/collection extensions
|
||||
python edr.py scan --site <targetGroupId> --confirm
|
||||
python edr.py scan --site <targetGroupId> --target <ip/host> --confirm
|
||||
python edr.py isolate --site <tgId> --target <host> --extension-name "Host Isolation" --confirm
|
||||
python edr.py raw GET Agents --filter '{"limit":1}'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from edr_client import DattoEDRClient, DattoEDRError, DEFAULT_INSTANCE
|
||||
|
||||
|
||||
def _log_skill_error(skill, msg, context=""):
|
||||
"""Soft-fail: append a functional-error entry to errorlog.md (never throws)."""
|
||||
try:
|
||||
root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
|
||||
)
|
||||
h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh")
|
||||
if not os.path.exists(h):
|
||||
return
|
||||
a = ["bash", h, skill, msg]
|
||||
if context:
|
||||
a += ["--context", context]
|
||||
subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_EXPECTED_ERROR_MARKERS = (
|
||||
"404", "not found", "no method to handle", "invalid value",
|
||||
"required", "must be", "429", "too many requests", "unauthorized",
|
||||
)
|
||||
|
||||
|
||||
def _is_expected_error(msg: str) -> bool:
|
||||
m = (msg or "").lower()
|
||||
return any(marker in m for marker in _EXPECTED_ERROR_MARKERS)
|
||||
|
||||
|
||||
def _should_log_error(command: str, msg: str) -> bool:
|
||||
if os.environ.get("EDR_SUPPRESS_ERRORLOG"):
|
||||
return False
|
||||
if command == "raw":
|
||||
return False
|
||||
return not _is_expected_error(msg)
|
||||
|
||||
|
||||
def _emit(obj, as_json: bool, table_fn=None) -> None:
|
||||
if as_json or table_fn is None:
|
||||
print(json.dumps(obj, indent=2, default=str))
|
||||
else:
|
||||
table_fn(obj)
|
||||
|
||||
|
||||
def _trunc(s, n):
|
||||
s = "" if s is None else str(s)
|
||||
return s if len(s) <= n else s[: n - 1] + "…"
|
||||
|
||||
|
||||
# --- table renderers ----------------------------------------------------------
|
||||
def _t_status(s):
|
||||
print(f"Datto EDR tenant: {s.get('instance')}")
|
||||
print(f" Organizations (clients): {s.get('organizations')}")
|
||||
print(f" Target groups (sites) : {s.get('targetGroups')}")
|
||||
print(f" Agents : {s.get('agents')} "
|
||||
f"({s.get('agentsActive')} active, {s.get('agentsIsolated')} isolated)")
|
||||
print(f" Alerts (all time) : {s.get('alerts')}")
|
||||
|
||||
|
||||
def _t_orgs(rows):
|
||||
print(f"{'ORGANIZATION':<34} {'AGENTS':>7} {'ALERTS':>7} {'SITES':>6} ID")
|
||||
for o in rows:
|
||||
print(f"{_trunc(o.get('name'),34):<34} {o.get('agentCount',0):>7} "
|
||||
f"{o.get('alertCount',0):>7} {o.get('locationCount',0):>6} {o.get('id')}")
|
||||
print(f"\n{len(rows)} organizations")
|
||||
|
||||
|
||||
def _t_sites(rows):
|
||||
print(f"{'SITE (TARGET GROUP)':<34} {'AGENTS':>7} {'ACTIVE':>7} {'ALERTS':>7} ID")
|
||||
for t in rows:
|
||||
print(f"{_trunc(t.get('name'),34):<34} {t.get('agentCount',0):>7} "
|
||||
f"{t.get('activeAgentCount',0):>7} {t.get('alertCount',0):>7} {t.get('id')}")
|
||||
print(f"\n{len(rows)} target groups")
|
||||
|
||||
|
||||
def _agent_os(a):
|
||||
for k, label in (("osWindows", "Windows"), ("osLinux", "Linux"),
|
||||
("osOsx", "macOS"), ("osOther", "Other")):
|
||||
if a.get(k):
|
||||
return label
|
||||
return a.get("os") or "?"
|
||||
|
||||
|
||||
def _t_agents(rows):
|
||||
print(f"{'HOSTNAME':<26} {'OS':<8} {'ONLINE':<7} {'ISO':<4} {'AV':<4} "
|
||||
f"{'VERSION':<10} ID")
|
||||
for a in rows:
|
||||
print(f"{_trunc(a.get('hostname') or a.get('name'),26):<26} "
|
||||
f"{_agent_os(a):<8} {('yes' if a.get('active') else 'no'):<7} "
|
||||
f"{('ISO' if a.get('isolated') else '-'):<4} "
|
||||
f"{('on' if a.get('dattoAvEnabled') else '-'):<4} "
|
||||
f"{_trunc(a.get('version'),10):<10} {a.get('id')}")
|
||||
print(f"\n{len(rows)} agents")
|
||||
|
||||
|
||||
_SEV = {0: "info", 1: "low", 2: "medium", 3: "high", 4: "critical"}
|
||||
|
||||
|
||||
def _t_detections(rows):
|
||||
print(f"{'WHEN':<20} {'SEV':<8} {'HOST':<22} {'ORG':<20} NAME")
|
||||
for a in rows:
|
||||
sev = _SEV.get(a.get("severity"), str(a.get("severity")))
|
||||
print(f"{_trunc(a.get('createdOn'),20):<20} {sev:<8} "
|
||||
f"{_trunc(a.get('hostname'),22):<22} "
|
||||
f"{_trunc(a.get('organizationName'),20):<20} "
|
||||
f"{_trunc(a.get('name') or a.get('description'),40)}")
|
||||
print(f"\n{len(rows)} detections")
|
||||
|
||||
|
||||
def _t_sweep(s):
|
||||
rows = s.get("clients", [])
|
||||
print(f"{'CLIENT':<34} {'AGENTS':>7} {'ALERTS(7d)':>11} {'ALERTS(all)':>12}")
|
||||
for r in rows:
|
||||
print(f"{_trunc(r.get('organization'),34):<34} {r.get('agents',0):>7} "
|
||||
f"{r.get('alertsLast7d',0):>11} {r.get('alertsTotal',0):>12}")
|
||||
print(f"\n{len(rows)} clients (sorted by 7-day detection volume)")
|
||||
|
||||
|
||||
# --- org -> target group resolution -------------------------------------------
|
||||
def _agents_for_org(client, org_id, limit):
|
||||
"""Agents across all target groups in an org (Agents carry deviceGroupId)."""
|
||||
out = []
|
||||
for tg in client.list_targets(org_id=org_id):
|
||||
out.extend(client.list_agents(target_group_id=tg.get("id"), limit=limit))
|
||||
return out
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="edr.py", description="Datto EDR (Infocyte HUNT) CLI")
|
||||
p.add_argument("--json", action="store_true", help="emit raw JSON")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("status", help="tenant rollup")
|
||||
sub.add_parser("orgs", help="list organizations (clients)")
|
||||
sub.add_parser("clients", help="alias for orgs")
|
||||
|
||||
sp = sub.add_parser("sites", help="list target groups")
|
||||
sp.add_argument("--org", help="filter by organizationId")
|
||||
|
||||
sp = sub.add_parser("agents", help="list agents")
|
||||
sp.add_argument("--org")
|
||||
sp.add_argument("--site", help="target group id")
|
||||
sp.add_argument("--limit", type=int, default=500)
|
||||
|
||||
sp = sub.add_parser("agent", help="agent detail")
|
||||
sp.add_argument("id")
|
||||
|
||||
sp = sub.add_parser("detections", help="list alerts")
|
||||
sp.add_argument("--org")
|
||||
sp.add_argument("--site")
|
||||
sp.add_argument("--severity", type=int, help="0 info..4 critical")
|
||||
sp.add_argument("--days", type=int)
|
||||
sp.add_argument("--limit", type=int, default=200)
|
||||
|
||||
sp = sub.add_parser("detection", help="alert detail")
|
||||
sp.add_argument("id")
|
||||
|
||||
sp = sub.add_parser("sweep", help="per-client posture rollup")
|
||||
sp.add_argument("--org")
|
||||
|
||||
sub.add_parser("extensions", help="list agent extensions")
|
||||
|
||||
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("--site", required=True, help="target group id")
|
||||
sp.add_argument("--target", help="single ip/host (else whole group)")
|
||||
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("--site", required=True, help="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.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("--filter", help="LoopBack filter JSON (GET)")
|
||||
sp.add_argument("--data", help="request body JSON")
|
||||
sp.add_argument("--confirm", action="store_true",
|
||||
help="required for non-GET methods")
|
||||
return p
|
||||
|
||||
|
||||
def _gate(args, what):
|
||||
"""Print the would-do and return False if --confirm missing."""
|
||||
if getattr(args, "confirm", False):
|
||||
return True
|
||||
print(f"[DRY-RUN] would {what}\n[BLOCKED] re-run with --confirm to execute.",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
j = args.json
|
||||
client = DattoEDRClient()
|
||||
cmd = args.command
|
||||
|
||||
try:
|
||||
if cmd == "status":
|
||||
_emit(client.get_status(), j, _t_status)
|
||||
elif cmd in ("orgs", "clients"):
|
||||
_emit(client.list_organizations(), j, _t_orgs)
|
||||
elif cmd == "sites":
|
||||
_emit(client.list_targets(org_id=args.org), j, _t_sites)
|
||||
elif cmd == "agents":
|
||||
if args.org and not args.site:
|
||||
rows = _agents_for_org(client, args.org, args.limit)
|
||||
else:
|
||||
rows = client.list_agents(target_group_id=args.site, limit=args.limit)
|
||||
_emit(rows, j, _t_agents)
|
||||
elif cmd == "agent":
|
||||
_emit(client.get_agent(args.id), j)
|
||||
elif cmd == "detections":
|
||||
_emit(client.list_alerts(org_id=args.org, target_group_id=args.site,
|
||||
severity=args.severity, days=args.days,
|
||||
limit=args.limit), j, _t_detections)
|
||||
elif cmd == "detection":
|
||||
_emit(client.get_alert(args.id), j)
|
||||
elif cmd == "sweep":
|
||||
_emit(client.sweep(org_id=args.org), j, _t_sweep)
|
||||
elif cmd == "extensions":
|
||||
_emit(client.list_extensions(), j)
|
||||
elif cmd == "deploy-cmd":
|
||||
regkey = args.regkey
|
||||
if not regkey:
|
||||
keys = client.list_agent_keys()
|
||||
if isinstance(keys, list) and keys:
|
||||
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 site {args.site}"):
|
||||
return 2
|
||||
if args.target:
|
||||
res = client.scan_single_target(args.site, args.target)
|
||||
else:
|
||||
res = client.scan_target_group(args.site)
|
||||
_emit(res, j)
|
||||
elif cmd == "isolate":
|
||||
if not _gate(args, f"run response extension "
|
||||
f"'{args.extension_name or args.extension_id}' "
|
||||
f"on {args.target} (site {args.site})"):
|
||||
return 2
|
||||
res = client.run_response_extension(
|
||||
args.site, args.target,
|
||||
extension_id=args.extension_id,
|
||||
extension_name=None if args.extension_id else args.extension_name)
|
||||
_emit(res, j)
|
||||
elif cmd == "raw":
|
||||
method = args.method.upper()
|
||||
if method != "GET" and not args.confirm:
|
||||
print(f"[BLOCKED] {method} requires --confirm.", file=sys.stderr)
|
||||
return 2
|
||||
filt = json.loads(args.filter) if args.filter else None
|
||||
body = json.loads(args.data) if args.data else None
|
||||
_emit(client.raw(method, args.path, filt=filt, body=body), j)
|
||||
else:
|
||||
print(f"[ERROR] unknown command {cmd}", file=sys.stderr)
|
||||
return 1
|
||||
except DattoEDRError as exc:
|
||||
msg = str(exc)
|
||||
print(f"[ERROR] {msg}", file=sys.stderr)
|
||||
if _should_log_error(cmd, msg):
|
||||
_log_skill_error("datto-edr", msg, context=f"cmd={cmd}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _deploy_payload(client, regkey):
|
||||
instance = client.api_base_url.replace("https://", "").replace("/api", "")
|
||||
cname = instance.split(".")[0]
|
||||
keypart = f" -RegKey {regkey}" if regkey else ""
|
||||
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}"
|
||||
)
|
||||
return {
|
||||
"instance": cname,
|
||||
"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.",
|
||||
}
|
||||
|
||||
|
||||
def _t_deploy(p):
|
||||
print(f"Instance : {p['instance']}")
|
||||
print(f"RegKey : {p['regkey']}")
|
||||
print(f"\nPowerShell one-liner (run on target, admin):\n\n{p['powershell_oneliner']}\n")
|
||||
print(f"[note] {p['note']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
395
.claude/skills/datto-edr/scripts/edr_client.py
Normal file
395
.claude/skills/datto-edr/scripts/edr_client.py
Normal file
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Datto EDR (Infocyte HUNT) REST API client for the datto-edr skill.
|
||||
|
||||
Datto EDR == rebranded Infocyte HUNT. The API is a per-tenant LoopBack REST
|
||||
service at https://<instance>.infocyte.com/api/<Model>. This client talks to the
|
||||
live ACG tenant (azcomp4587).
|
||||
|
||||
Auth (IMPORTANT): a single long-lived 64-char token passed in the RAW
|
||||
`Authorization` header — NOT "Bearer <token>", no prefix. (Verified live
|
||||
2026-06-25.) Token + instance load from the SOPS vault; env overrides exist for
|
||||
testing.
|
||||
|
||||
Transport: prefers httpx if installed, else stdlib urllib (no hard dependency).
|
||||
|
||||
Read methods are always live. Mutating methods (scan / isolate / deploy) return
|
||||
the raw upstream result; the CLI caller is responsible for gating them behind
|
||||
--confirm.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
try:
|
||||
import httpx # type: ignore
|
||||
|
||||
_HAS_HTTPX = True
|
||||
except ImportError: # pragma: no cover - depends on environment
|
||||
_HAS_HTTPX = False
|
||||
|
||||
# Cap upstream error bodies surfaced in exceptions. `raw` can call arbitrary
|
||||
# paths whose responses may reflect other data - bound the blast radius.
|
||||
ERROR_BODY_MAX_CHARS = 500
|
||||
|
||||
# --- constants ----------------------------------------------------------------
|
||||
DEFAULT_INSTANCE = "azcomp4587"
|
||||
DATTO_EDR_BASE_URL = os.environ.get(
|
||||
"DATTO_EDR_BASE_URL", f"https://{DEFAULT_INSTANCE}.infocyte.com/api"
|
||||
)
|
||||
TIMEOUT_SECONDS = 60.0
|
||||
CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
VAULT_ENTRY = "msp-tools/datto-edr.sops.yaml"
|
||||
VAULT_FIELD = "credentials.api_token"
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Mutating-path guard for `raw`: refuse these without --confirm at the call site.
|
||||
# Scans, isolation responses, deletes, approvals all run through POST/DELETE.
|
||||
|
||||
|
||||
class DattoEDRError(RuntimeError):
|
||||
"""Raised for transport or API errors."""
|
||||
|
||||
|
||||
# --- credential loading -------------------------------------------------------
|
||||
def _resolve_claudetools_root() -> Path:
|
||||
"""Resolve the ClaudeTools repo root: env var, then identity.json, then derived."""
|
||||
derived_root = SKILL_DIR.parent.parent.parent # skills/datto-edr -> repo root
|
||||
env_root = os.environ.get("CLAUDETOOLS_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root)
|
||||
identity_path = derived_root / ".claude" / "identity.json"
|
||||
if identity_path.exists():
|
||||
try:
|
||||
data = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||
root = data.get("claudetools_root")
|
||||
if root:
|
||||
return Path(root)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return derived_root
|
||||
|
||||
|
||||
def load_api_token() -> str:
|
||||
"""Load the Datto EDR API token: DATTO_EDR_TOKEN env override, else SOPS vault."""
|
||||
env_key = os.environ.get("DATTO_EDR_TOKEN")
|
||||
if env_key:
|
||||
return env_key.strip()
|
||||
|
||||
root = _resolve_claudetools_root()
|
||||
vault_script = root / ".claude" / "scripts" / "vault.sh"
|
||||
if not vault_script.exists():
|
||||
raise DattoEDRError(
|
||||
f"Cannot load API token: vault wrapper not found at {vault_script} "
|
||||
"and DATTO_EDR_TOKEN is not set."
|
||||
)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["bash", str(vault_script), "get-field", VAULT_ENTRY, VAULT_FIELD],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise DattoEDRError(
|
||||
"Cannot load API token: 'bash' not found on PATH. Install Git Bash or "
|
||||
"set DATTO_EDR_TOKEN."
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise DattoEDRError("Cannot load API token: vault call timed out.") from exc
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise DattoEDRError(
|
||||
"Cannot load API token from vault "
|
||||
f"(exit {completed.returncode}): {completed.stderr.strip()}"
|
||||
)
|
||||
token = completed.stdout.strip()
|
||||
if not token:
|
||||
raise DattoEDRError("Vault returned an empty API token.")
|
||||
return token
|
||||
|
||||
|
||||
# --- client -------------------------------------------------------------------
|
||||
class DattoEDRClient:
|
||||
def __init__(
|
||||
self,
|
||||
api_token: Optional[str] = None,
|
||||
api_base_url: str = DATTO_EDR_BASE_URL,
|
||||
timeout: float = TIMEOUT_SECONDS,
|
||||
connect_timeout: float = CONNECT_TIMEOUT_SECONDS,
|
||||
):
|
||||
self.api_base_url = api_base_url.rstrip("/")
|
||||
self._api_token = api_token
|
||||
self.timeout = timeout
|
||||
self.connect_timeout = connect_timeout
|
||||
|
||||
@property
|
||||
def api_token(self) -> str:
|
||||
if not self._api_token:
|
||||
self._api_token = load_api_token()
|
||||
return self._api_token
|
||||
|
||||
# -- core transport --------------------------------------------------------
|
||||
def _request(self, method: str, path: str, filt: Optional[dict] = None,
|
||||
body: Optional[dict] = None) -> Any:
|
||||
"""One REST call. `filt` -> ?filter=<json> (LoopBack). Returns parsed JSON."""
|
||||
url = f"{self.api_base_url}/{path.lstrip('/')}"
|
||||
if filt is not None:
|
||||
qs = urllib.parse.urlencode({"filter": json.dumps(filt)})
|
||||
url = f"{url}?{qs}"
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
result = self._send(method, url, data)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
err = result["error"]
|
||||
msg = err.get("message") if isinstance(err, dict) else err
|
||||
raise DattoEDRError(f"Datto EDR API error [{method} {path}]: {msg}")
|
||||
return result
|
||||
|
||||
def _send(self, method: str, url: str, data: Optional[bytes]) -> Any:
|
||||
# Auth is the RAW token in the Authorization header (no "Bearer").
|
||||
headers = {
|
||||
"Authorization": self.api_token,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if _HAS_HTTPX:
|
||||
try:
|
||||
timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.request(method, url, content=data, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else None
|
||||
except httpx.TimeoutException as exc:
|
||||
raise DattoEDRError(f"Datto EDR request timed out: {exc}") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS]
|
||||
raise DattoEDRError(
|
||||
f"Datto EDR HTTP {exc.response.status_code}: {detail}"
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise DattoEDRError(f"Datto EDR request failed: {exc}") from exc
|
||||
|
||||
# stdlib fallback
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw.decode("utf-8")) if raw else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
|
||||
raise DattoEDRError(f"Datto EDR HTTP {exc.code}: {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise DattoEDRError(f"Datto EDR request failed: {exc}") from exc
|
||||
|
||||
# convenience wrappers
|
||||
def _get(self, path: str, filt: Optional[dict] = None) -> Any:
|
||||
return self._request("GET", path, filt=filt)
|
||||
|
||||
def _count(self, model: str, where: Optional[dict] = None) -> int:
|
||||
path = f"{model}/count"
|
||||
if where:
|
||||
qs = urllib.parse.urlencode({"where": json.dumps(where)})
|
||||
path = f"{path}?{qs}"
|
||||
res = self._request("GET", path)
|
||||
if isinstance(res, dict):
|
||||
return int(res.get("count", 0))
|
||||
return 0
|
||||
|
||||
# ======================================================================
|
||||
# READ METHODS (always live)
|
||||
# ======================================================================
|
||||
def get_status(self) -> dict:
|
||||
"""Tenant-wide rollup: counts of agents/alerts/orgs/targets."""
|
||||
return {
|
||||
"instance": self.api_base_url,
|
||||
"organizations": self._count("Organizations"),
|
||||
"targetGroups": self._count("Targets"),
|
||||
"agents": self._count("Agents"),
|
||||
"agentsActive": self._count("Agents", {"active": True}),
|
||||
"agentsIsolated": self._count("Agents", {"isolated": True}),
|
||||
"alerts": self._count("Alerts"),
|
||||
}
|
||||
|
||||
def list_organizations(self, limit: int = 200) -> list[dict]:
|
||||
"""List Organizations (= client tenants/companies). VERIFIED LIVE."""
|
||||
return self._get("Organizations", {
|
||||
"limit": limit, "order": "name ASC",
|
||||
"fields": {"id": True, "name": True, "agentCount": True,
|
||||
"alertCount": True, "locationCount": True},
|
||||
}) or []
|
||||
|
||||
def list_targets(self, org_id: Optional[str] = None, limit: int = 500) -> list[dict]:
|
||||
"""List Targets (= target groups / sites). VERIFIED LIVE.
|
||||
Each Target belongs to an Organization via organizationId."""
|
||||
filt: dict = {"limit": limit, "order": "name ASC",
|
||||
"fields": {"id": True, "name": True, "organizationId": True,
|
||||
"agentCount": True, "activeAgentCount": True,
|
||||
"alertCount": True, "lastScannedOn": True}}
|
||||
if org_id:
|
||||
filt["where"] = {"organizationId": org_id}
|
||||
return self._get("Targets", filt) or []
|
||||
|
||||
def list_agents(self, org_id: Optional[str] = None,
|
||||
target_group_id: Optional[str] = None,
|
||||
limit: int = 500) -> list[dict]:
|
||||
"""List Agents (endpoints). VERIFIED LIVE.
|
||||
Filter by deviceGroupId (target group). Org filtering is done by the CLI
|
||||
via the target-group->org map since Agents carry deviceGroupId, not orgId."""
|
||||
filt: dict = {"limit": limit, "order": "hostname ASC"}
|
||||
where: dict = {}
|
||||
if target_group_id:
|
||||
where["deviceGroupId"] = target_group_id
|
||||
if where:
|
||||
filt["where"] = where
|
||||
return self._get("Agents", filt) or []
|
||||
|
||||
def get_agent(self, agent_id: str) -> dict:
|
||||
"""Agent detail by id. VERIFIED LIVE."""
|
||||
return self._get(f"Agents/{agent_id}") or {}
|
||||
|
||||
def list_alerts(self, org_id: Optional[str] = None,
|
||||
target_group_id: Optional[str] = None,
|
||||
severity: Optional[int] = None, days: Optional[int] = None,
|
||||
limit: int = 200) -> list[dict]:
|
||||
"""List Alerts (detections). VERIFIED LIVE.
|
||||
Alerts carry organizationId/organizationName + targetGroupId/Name inline."""
|
||||
where: dict = {}
|
||||
if org_id:
|
||||
where["organizationId"] = org_id
|
||||
if target_group_id:
|
||||
where["targetGroupId"] = target_group_id
|
||||
if severity is not None:
|
||||
where["severity"] = severity
|
||||
if days is not None:
|
||||
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||
where["createdOn"] = {"gt": since}
|
||||
filt: dict = {"limit": limit, "order": "createdOn DESC"}
|
||||
if where:
|
||||
filt["where"] = where
|
||||
return self._get("Alerts", filt) or []
|
||||
|
||||
def get_alert(self, alert_id: str) -> dict:
|
||||
"""Alert detail by id. VERIFIED LIVE."""
|
||||
return self._get(f"Alerts/{alert_id}") or {}
|
||||
|
||||
def list_agent_keys(self) -> Any:
|
||||
"""List agent registration keys (/agentKeys). Used to build deploy commands."""
|
||||
return self._get("agentKeys") or []
|
||||
|
||||
def sweep(self, org_id: Optional[str] = None) -> dict:
|
||||
"""Security posture rollup per client: agent health + AV + isolation +
|
||||
recent detection counts. Built from live reads (never cached)."""
|
||||
orgs = self.list_organizations()
|
||||
if org_id:
|
||||
orgs = [o for o in orgs if o.get("id") == org_id]
|
||||
out = []
|
||||
for o in orgs:
|
||||
oid = o.get("id")
|
||||
recent = self.list_alerts(org_id=oid, days=7, limit=500)
|
||||
out.append({
|
||||
"organizationId": oid,
|
||||
"organization": o.get("name"),
|
||||
"agents": o.get("agentCount", 0),
|
||||
"alertsTotal": o.get("alertCount", 0),
|
||||
"alertsLast7d": len(recent),
|
||||
})
|
||||
out.sort(key=lambda r: (-r["alertsLast7d"], (r["organization"] or "").lower()))
|
||||
return {"clients": out}
|
||||
|
||||
# ======================================================================
|
||||
# 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.
|
||||
# ======================================================================
|
||||
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_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).
|
||||
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,
|
||||
}
|
||||
return self._request("POST", "targets/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."""
|
||||
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().
|
||||
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
|
||||
body = {
|
||||
"target": target,
|
||||
"targetGroup": {"id": target_group_id},
|
||||
"options": {"extensions": [ext]},
|
||||
}
|
||||
return self._request("POST", "targets/scan", body=body)
|
||||
|
||||
# ======================================================================
|
||||
# POWER TOOL
|
||||
# ======================================================================
|
||||
def raw(self, method: str, path: str, filt: Optional[dict] = None,
|
||||
body: Optional[dict] = None) -> Any:
|
||||
"""Call any endpoint directly. Caller gates mutating methods."""
|
||||
return self._request(method.upper(), path, filt=filt, body=body)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Minimal self-check: load token (no network call)."""
|
||||
try:
|
||||
client = DattoEDRClient()
|
||||
_ = client.api_token # triggers vault load
|
||||
print("[OK] API token loaded; transport =",
|
||||
"httpx" if _HAS_HTTPX else "urllib")
|
||||
print("[INFO] instance =", client.api_base_url)
|
||||
return 0
|
||||
except DattoEDRError as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -54,7 +54,7 @@ Crystal Rodriguez, Veronica Feller, Shelby Trozzi, Christina DuPras · ~~Tamra
|
||||
**RW (per matrix "Access: Directory"):** Meredith, Ashley, Lauren, Allison, Megan, Crystal,
|
||||
Lois, Karen, Veronica, Shelby, Christine, Christina DuPras, Cathy Kingston, Shontiel Nunn,
|
||||
Kyla Quick Tiffany *(no AD acct yet)*, Michelle Shestko, Sebastian Leon, Sheldon Gardfrey,
|
||||
Ray Rai, Susan Hicks, Sharon Edwards, Alma R Montt *(no AD acct yet)*, John Trozzi, Matt Brooks, Lupe Sanchez
|
||||
Ray Rai, Susan Hicks, Sharon Edwards, John Trozzi, Matt Brooks, Lupe Sanchez · ~~Alma R Montt~~ *(OFFBOARDED 2026-06-25)*
|
||||
**Excluded:** kitchen staff (JD, Ramon, Alyssa), drivers, caregivers
|
||||
> **Big question:** matrix intro says "most staff need **read**" but each person's line reads
|
||||
> "Access" (= RW). Does everyone really need WRITE to the resident directory, or **read for most +
|
||||
@@ -75,8 +75,7 @@ Sheldon Gardfrey, Ray Rai, Christina DuPras, Meredith Kuhn, Ashley Jensen
|
||||
> Kitchen staff get Culinary ONLY (no Directory, no other shares). No `SG-Culinary-RO` group — RO trio needs one or direct NTFS read.
|
||||
|
||||
### `SG-Activities-RW` → `\\CS-SERVER\Activities` (= Life Enrichment)
|
||||
**RW:** Susan Hicks **[OPEN]**, Sharon Edwards, Alma R Montt *(no AD acct yet)*, Veronica Feller,
|
||||
Meredith Kuhn, Ashley Jensen
|
||||
**RW:** Susan Hicks **[OPEN]**, Sharon Edwards, Veronica Feller, Meredith Kuhn, Ashley Jensen · ~~Alma R Montt~~ *(OFFBOARDED 2026-06-25)*
|
||||
**RO:** Shelby Trozzi, Christina DuPras
|
||||
> Confirm `Activities` share == the Life Enrichment data share (matrix called it `LifeEnrichment`).
|
||||
> LE workstations have no mapped drives today — this is their first map.
|
||||
@@ -127,4 +126,5 @@ Veronica Feller, Shelby Trozzi, Christine Nyanzunda
|
||||
## AD-account verification needed before assignment
|
||||
Confirm a domain account exists for: Cathy Kingston, Shontiel Nunn, Michelle Shestko,
|
||||
Sebastian Leon, Sheldon Gardfrey, Ray Rai, Sharon Edwards, Allison Reibschied.
|
||||
**Create first:** Kyla Quick Tiffany, Alma R Montt (matrix: not yet created).
|
||||
**Create first:** Kyla Quick Tiffany (matrix: not yet created).
|
||||
*(Alma R Montt — OFFBOARDED 2026-06-25, see `docs/security/offboarding-2026-06-25-alma-montt.md`.)*
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Offboarding Record — Alma Montt
|
||||
|
||||
**Date:** 2026-06-25 · **Performed by:** Howard Enos (ClaudeTools session) · **Authorized by:** Howard Enos
|
||||
**Separation type:** Termination (no longer with Cascades) · **Role:** Memory Care Life Enrichment / MC Reception
|
||||
**Runbook:** `docs/security/termination-procedures.md`
|
||||
|
||||
## Identities handled
|
||||
- **M365 (cloud-only):** `Alma.Montt@cascadestucson.com` — id `b2fb546e-687a-4647-b286-9c8edd3d989f`
|
||||
- **On-prem AD:** `Alma.Montt` (was OU=Administrative,OU=Departments,DC=cascades,DC=local — separate object, NOT Entra-synced)
|
||||
- **ALIS:** N/A — Alma had no ALIS access (Life Enrichment role, not clinical/caregiver; confirmed Howard 2026-06-25)
|
||||
|
||||
## Actions completed (M365)
|
||||
| # | Action | Result |
|
||||
|---|---|---|
|
||||
| 1 | Revoke active sign-in sessions | HTTP 200 |
|
||||
| 2 | Block sign-in (`accountEnabled=false`) | confirmed false |
|
||||
| 3 | Reset password (random, vaulted) | OK (via JIT PAA — see follow-up) |
|
||||
| 4 | Grant `Shelby.Trozzi` **FullAccess + AutoMapping** to mailbox | confirmed (auto-attaches in Shelby's Outlook) |
|
||||
| 5 | Convert mailbox → **SharedMailbox** | confirmed (78 MB / 198 items) |
|
||||
| 6 | Remove **Business Premium (SPB)** license | confirmed 0 licenses — **frees 1 SPB seat** |
|
||||
| 7 | Hide from GAL | confirmed |
|
||||
| 8 | Remove from `SG-SSPR-Eligible` | HTTP 204 |
|
||||
|
||||
## Actions completed (on-prem AD, CS-SERVER)
|
||||
- `Disable-ADAccount Alma.Montt` → Enabled=False
|
||||
- Group memberships stripped → groupCount=0
|
||||
- Moved to `OU=Excluded-From-Sync,DC=cascades,DC=local`
|
||||
|
||||
## Retention / compliance
|
||||
- **No Litigation Hold applied.** Decision (Howard, 2026-06-25): Alma had **no PHI / medical-data access**
|
||||
in her role, so the 7-yr litigation hold is not required. Mailbox is preserved via shared-mailbox
|
||||
conversion + zero-deletion posture (no mailbox deleted). Revisit only if her PHI-access
|
||||
determination changes.
|
||||
- Password stored for emergency recovery/audit only: vault `clients/cascades-tucson/alma-montt`.
|
||||
**Do NOT re-enable without authorization.**
|
||||
|
||||
## Open follow-ups
|
||||
- [x] ~~ALIS staff profile~~ — N/A, no ALIS access (Howard 2026-06-25).
|
||||
- [ ] **SECURITY — needs Global Admin / portal:** the password reset required a JIT elevation of the
|
||||
**ComputerGuru – Tenant Admin** service principal to **Privileged Authentication Administrator**, and
|
||||
the automatic role removal was blocked by Graph ("removing self from built-in role is not allowed").
|
||||
**The PAA role is still assigned to the SP and must be removed manually** in Entra
|
||||
(Roles & admins → Privileged Authentication Administrator → remove `ComputerGuru - Tenant Admin`).
|
||||
Its standing **Conditional Access Administrator** role is intentional — leave that.
|
||||
- [ ] Reconcile: Alma removed from the proposed share rosters (`docs/migration/share-group-roster-proposed-2026-06-25.md`).
|
||||
@@ -17,6 +17,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
|
||||
|
||||
<!-- Append entries below this line -->
|
||||
|
||||
2026-06-25 | Howard-Home | remediation-tool/reset-password.sh | JIT cleanup cannot self-remove: after elevating the Tenant Admin SP to Privileged Authentication Administrator to reset a password, the DELETE of that role assignment is performed BY the same SP and Graph blocks it (HTTP 400 'Removing self from built-in role is not allowed'), leaving a STANDING PAA role on the SP - needs a Global Admin/portal removal; script should detect this and surface portal steps instead of a bare WARNING [ctx: tenant=cascadestucson SP=ComputerGuru-Tenant-Admin role=PrivilegedAuthAdmin]
|
||||
|
||||
2026-06-25 | Howard-Home | rmm/dispatch | [friction] embedded escaped quotes " , " in a PowerShell -join inside the jq/heredoc dispatch chain caused a parse error (script failed pre-exec, wasted one dispatch); fix: build strings with + concatenation or [char]44, never escaped quotes in RMM PowerShell payloads [ctx: ref=feedback_windows_quote_stripping]
|
||||
|
||||
2026-06-25 | Howard-Home | wiki-compile/gururmm | [correction] characterized SPEC-030 software uninstall as SHIPPED/working capability; correct is BETA, merged+deployed but NOT guaranteed to work (many uninstallers fail: AV, Launchy/AIMP, drivers)
|
||||
|
||||
2026-06-25 | Howard-Home | remediation-tool | reset-password: failed to remove JIT Privileged Auth Admin role - standing privilege left behind, REMOVE MANUALLY [ctx: tenant=207fa277-e9d8-4eb7-ada1-1064d2221498 assignment=ikzke6-tKk6E1qsmSeCKE6mJ-qU1txBOtmTwQuJl0Tc-1 http=400]
|
||||
|
||||
Reference in New Issue
Block a user