From b9d4cfde98a66063ca1bf5cd1a6736b9b67fcf08 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Thu, 25 Jun 2026 12:31:14 -0700 Subject: [PATCH] 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 --- .claude/memory/MEMORY.md | 3 + .claude/skills/datto-edr/scripts/edr.py | 339 +++++++++++++++ .../skills/datto-edr/scripts/edr_client.py | 395 ++++++++++++++++++ .../share-group-roster-proposed-2026-06-25.md | 8 +- .../offboarding-2026-06-25-alma-montt.md | 45 ++ errorlog.md | 4 + 6 files changed, 790 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/datto-edr/scripts/edr.py create mode 100644 .claude/skills/datto-edr/scripts/edr_client.py create mode 100644 clients/cascades-tucson/docs/security/offboarding-2026-06-25-alma-montt.md diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 389e3bd3..6211d353 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -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. diff --git a/.claude/skills/datto-edr/scripts/edr.py b/.claude/skills/datto-edr/scripts/edr.py new file mode 100644 index 00000000..957f1a1b --- /dev/null +++ b/.claude/skills/datto-edr/scripts/edr.py @@ -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 ] # target groups + python edr.py agents [--org ] [--site ] + python edr.py agent + python edr.py detections [--org ] [--severity 3] [--days 7] + python edr.py detection + python edr.py sweep [--org ] # per-client posture rollup + python edr.py deploy-cmd [--regkey ] # emit agent install one-liner + python edr.py extensions # list response/collection extensions + python edr.py scan --site --confirm + python edr.py scan --site --target --confirm + python edr.py isolate --site --target --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()) diff --git a/.claude/skills/datto-edr/scripts/edr_client.py b/.claude/skills/datto-edr/scripts/edr_client.py new file mode 100644 index 00000000..98a9aa1a --- /dev/null +++ b/.claude/skills/datto-edr/scripts/edr_client.py @@ -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://.infocyte.com/api/. 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 ", 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= (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()) diff --git a/clients/cascades-tucson/docs/migration/share-group-roster-proposed-2026-06-25.md b/clients/cascades-tucson/docs/migration/share-group-roster-proposed-2026-06-25.md index da83ecce..2dfd4d58 100644 --- a/clients/cascades-tucson/docs/migration/share-group-roster-proposed-2026-06-25.md +++ b/clients/cascades-tucson/docs/migration/share-group-roster-proposed-2026-06-25.md @@ -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`.)* diff --git a/clients/cascades-tucson/docs/security/offboarding-2026-06-25-alma-montt.md b/clients/cascades-tucson/docs/security/offboarding-2026-06-25-alma-montt.md new file mode 100644 index 00000000..3b9096b3 --- /dev/null +++ b/clients/cascades-tucson/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`). diff --git a/errorlog.md b/errorlog.md index 1ca9a457..146d6cde 100644 --- a/errorlog.md +++ b/errorlog.md @@ -17,6 +17,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure · +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]