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:
2026-06-25 12:31:14 -07:00
parent ca44a005cc
commit b9d4cfde98
6 changed files with 790 additions and 4 deletions

View File

@@ -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). - [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. - [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_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 ## 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. - [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.

View 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())

View 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())

View File

@@ -54,7 +54,7 @@ Crystal Rodriguez, Veronica Feller, Shelby Trozzi, Christina DuPras · ~~Tamra
**RW (per matrix "Access: Directory"):** Meredith, Ashley, Lauren, Allison, Megan, Crystal, **RW (per matrix "Access: Directory"):** Meredith, Ashley, Lauren, Allison, Megan, Crystal,
Lois, Karen, Veronica, Shelby, Christine, Christina DuPras, Cathy Kingston, Shontiel Nunn, Lois, Karen, Veronica, Shelby, Christine, Christina DuPras, Cathy Kingston, Shontiel Nunn,
Kyla Quick Tiffany *(no AD acct yet)*, Michelle Shestko, Sebastian Leon, Sheldon Gardfrey, 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 **Excluded:** kitchen staff (JD, Ramon, Alyssa), drivers, caregivers
> **Big question:** matrix intro says "most staff need **read**" but each person's line reads > **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 + > "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. > 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) ### `SG-Activities-RW` → `\\CS-SERVER\Activities` (= Life Enrichment)
**RW:** Susan Hicks **[OPEN]**, Sharon Edwards, Alma R Montt *(no AD acct yet)*, Veronica Feller, **RW:** Susan Hicks **[OPEN]**, Sharon Edwards, Veronica Feller, Meredith Kuhn, Ashley Jensen · ~~Alma R Montt~~ *(OFFBOARDED 2026-06-25)*
Meredith Kuhn, Ashley Jensen
**RO:** Shelby Trozzi, Christina DuPras **RO:** Shelby Trozzi, Christina DuPras
> Confirm `Activities` share == the Life Enrichment data share (matrix called it `LifeEnrichment`). > Confirm `Activities` share == the Life Enrichment data share (matrix called it `LifeEnrichment`).
> LE workstations have no mapped drives today — this is their first map. > 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 ## AD-account verification needed before assignment
Confirm a domain account exists for: Cathy Kingston, Shontiel Nunn, Michelle Shestko, Confirm a domain account exists for: Cathy Kingston, Shontiel Nunn, Michelle Shestko,
Sebastian Leon, Sheldon Gardfrey, Ray Rai, Sharon Edwards, Allison Reibschied. 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`.)*

View File

@@ -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`).

View File

@@ -17,6 +17,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
<!-- Append entries below this line --> <!-- 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 | 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] 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]