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).
|
- [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.
|
||||||
|
|||||||
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,
|
**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`.)*
|
||||||
|
|||||||
@@ -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 -->
|
<!-- 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]
|
||||||
|
|||||||
Reference in New Issue
Block a user