sync: auto-sync from HOWARD-HOME at 2026-06-25 13:43:47
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-25 13:43:47
This commit is contained in:
@@ -271,6 +271,8 @@ def cmd_companies(client, args):
|
||||
|
||||
|
||||
def cmd_company(client, args):
|
||||
if args.company_id and not _require_oid(args.company_id, "company"):
|
||||
return 2
|
||||
_emit(client.get_company_details(args.company_id), args.json, _print_kv)
|
||||
|
||||
|
||||
@@ -292,6 +294,8 @@ def cmd_company_create(client, args):
|
||||
|
||||
|
||||
def cmd_company_suspend(client, args):
|
||||
if not _require_oid(args.id, "company"):
|
||||
return 2
|
||||
if not _gated(f"suspend company {args.id}", args.confirm):
|
||||
return 3
|
||||
_emit({"suspended": args.id, "result": client.suspend_company(args.id)},
|
||||
@@ -300,6 +304,8 @@ def cmd_company_suspend(client, args):
|
||||
|
||||
|
||||
def cmd_company_activate(client, args):
|
||||
if not _require_oid(args.id, "company"):
|
||||
return 2
|
||||
if not _gated(f"activate company {args.id}", args.confirm):
|
||||
return 3
|
||||
_emit({"activated": args.id, "result": client.activate_company(args.id)},
|
||||
@@ -308,6 +314,8 @@ def cmd_company_activate(client, args):
|
||||
|
||||
|
||||
def cmd_company_delete(client, args):
|
||||
if not _require_oid(args.id, "company"):
|
||||
return 2
|
||||
if not _gated(f"delete company {args.id}", args.confirm):
|
||||
return 3
|
||||
_emit({"deletedCompany": args.id, "result": client.delete_company(args.id)},
|
||||
@@ -369,6 +377,8 @@ def cmd_notif_settings(client, args):
|
||||
|
||||
|
||||
def cmd_account(client, args):
|
||||
if args.account_id and not _require_oid(args.account_id, "account"):
|
||||
return 2
|
||||
_emit(client.get_account_details(args.account_id), args.json, _print_kv)
|
||||
|
||||
|
||||
@@ -420,6 +430,8 @@ def cmd_account_update(client, args):
|
||||
print("[ERROR] --set-json (object of fields to change) is required.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if not _require_oid(args.id, "account"):
|
||||
return 2
|
||||
if not _gated(f"update account {args.id} fields={list(fields)}", args.confirm):
|
||||
return 3
|
||||
result = client.update_account(args.id, fields)
|
||||
@@ -428,6 +440,8 @@ def cmd_account_update(client, args):
|
||||
|
||||
|
||||
def cmd_account_delete(client, args):
|
||||
if not _require_oid(args.id, "account"):
|
||||
return 2
|
||||
if not _gated(f"delete account {args.id}", args.confirm):
|
||||
return 3
|
||||
result = client.delete_account(args.id)
|
||||
@@ -456,6 +470,11 @@ def cmd_scan_tasks(client, args):
|
||||
|
||||
|
||||
def cmd_assign_policy(client, args):
|
||||
if not _require_oid(args.policy, "policy"):
|
||||
return 2
|
||||
for t in args.targets:
|
||||
if not _require_oid(t, "target"):
|
||||
return 2
|
||||
desc = (f"assign policy {args.policy} to {len(args.targets)} target(s): "
|
||||
f"{','.join(args.targets)}")
|
||||
if not _gated(desc, args.confirm):
|
||||
@@ -632,15 +651,21 @@ def cmd_packages(client, args):
|
||||
|
||||
|
||||
def cmd_quarantine(client, args):
|
||||
if not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
_emit(client.list_quarantine(args.company), args.json, _print_quarantine_table)
|
||||
|
||||
|
||||
def cmd_blocklist(client, args):
|
||||
if args.company and not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
_emit(client.list_blocklist(args.company, page=args.page, per_page=args.per_page),
|
||||
args.json, _print_blocklist_table)
|
||||
|
||||
|
||||
def cmd_incidents(client, args):
|
||||
if not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
_emit(client.list_incidents(args.company, page=args.page,
|
||||
per_page=args.per_page),
|
||||
args.json, _print_incidents_table)
|
||||
@@ -715,6 +740,8 @@ def cmd_endpoint_tags(client, args):
|
||||
|
||||
|
||||
def cmd_set_label(client, args):
|
||||
if not _require_oid(args.endpoint, "endpoint"):
|
||||
return 2
|
||||
if not _gated(f"label endpoint {args.endpoint} = '{args.label}'", args.confirm):
|
||||
return 3
|
||||
result = client.set_endpoint_label(args.endpoint, args.label)
|
||||
@@ -727,6 +754,9 @@ def cmd_reconfigure(client, args):
|
||||
extra, rc = _load_json_arg(args.extra_json, "extra-json")
|
||||
if rc:
|
||||
return rc
|
||||
for t in args.targets:
|
||||
if not _require_oid(t, "target"):
|
||||
return 2
|
||||
if not _gated(f"reconfigure {len(args.targets)} agent(s): {','.join(args.targets)}",
|
||||
args.confirm):
|
||||
return 3
|
||||
@@ -747,10 +777,13 @@ def cmd_make_group(client, args):
|
||||
return 0
|
||||
|
||||
|
||||
# Substrings that mark a JSON-RPC method as state-destroying. `raw` can reach
|
||||
# any method (incl. UNVERIFIED ones), so gate these behind --confirm too.
|
||||
# isolate / blocklist add+remove are NEW destructive verbs from the incidents
|
||||
# (EDR) module - gate them in `raw` as well as via the dedicated subcommands.
|
||||
# Substrings that mark a JSON-RPC method as state-changing. `raw` can reach ANY
|
||||
# method (incl. UNVERIFIED ones), so gate these behind --confirm too. This is a
|
||||
# BEST-EFFORT denylist, not a guarantee: a state-changing method whose name
|
||||
# matches none of these substrings can still run via `raw` - the operator must
|
||||
# verify any `raw` method themselves before passing --confirm.
|
||||
# isolate / blocklist add+remove are destructive verbs from the incidents (EDR)
|
||||
# module - gated in `raw` as well as via the dedicated subcommands.
|
||||
DESTRUCTIVE_RAW_PATTERNS = ("delete", "createuninstall", "createremove",
|
||||
"createreconfigure", "isolat", "addtoblocklist",
|
||||
"removefromblocklist", "assignpolicy",
|
||||
@@ -762,7 +795,9 @@ DESTRUCTIVE_RAW_PATTERNS = ("delete", "createuninstall", "createremove",
|
||||
# state-changing methods also exposed as gated
|
||||
# subcommands - keep them gated via `raw` too.
|
||||
"moveendpoints", "movecustomgroup", "createscan",
|
||||
"createpackage", "createcustomgroup")
|
||||
"createpackage", "createcustomgroup",
|
||||
# agent-deploy task reachable via raw (state-changing)
|
||||
"createinstall")
|
||||
|
||||
|
||||
def _is_destructive_method(method: str) -> bool:
|
||||
@@ -818,6 +853,8 @@ def _gated(action_desc: str, confirm: bool) -> bool:
|
||||
|
||||
|
||||
def cmd_delete_endpoint(client, args):
|
||||
if not _require_oid(args.endpoint_id, "endpoint"):
|
||||
return 2
|
||||
if not _gated(f"delete endpoint {args.endpoint_id}", args.confirm):
|
||||
return 3
|
||||
result = client.delete_endpoint(args.endpoint_id)
|
||||
@@ -835,6 +872,8 @@ def cmd_delete_package(client, args):
|
||||
|
||||
|
||||
def cmd_delete_group(client, args):
|
||||
if not _require_oid(args.group, "group"):
|
||||
return 2
|
||||
if not _gated(f"delete custom group {args.group}", args.confirm):
|
||||
return 3
|
||||
result = client.delete_custom_group(args.group)
|
||||
@@ -844,6 +883,9 @@ def cmd_delete_group(client, args):
|
||||
|
||||
# --- EDR / incident response (gated) ------------------------------------------
|
||||
def cmd_isolate(client, args):
|
||||
for e in args.endpoints:
|
||||
if not _require_oid(e, "endpoint"):
|
||||
return 2
|
||||
targets = ",".join(args.endpoints)
|
||||
if not _gated(f"isolate endpoints {targets}", args.confirm):
|
||||
return 3
|
||||
@@ -853,6 +895,9 @@ def cmd_isolate(client, args):
|
||||
|
||||
|
||||
def cmd_unisolate(client, args):
|
||||
for e in args.endpoints:
|
||||
if not _require_oid(e, "endpoint"):
|
||||
return 2
|
||||
targets = ",".join(args.endpoints)
|
||||
if not _gated(f"restore endpoints from isolation {targets}", args.confirm):
|
||||
return 3
|
||||
@@ -862,6 +907,8 @@ def cmd_unisolate(client, args):
|
||||
|
||||
|
||||
def cmd_blocklist_add(client, args):
|
||||
if not _require_oid(args.company, "company"):
|
||||
return 2
|
||||
desc = (f"add {len(args.hashes)} hash(es) to blocklist for company "
|
||||
f"{args.company}: {','.join(args.hashes)}")
|
||||
if not _gated(desc, args.confirm):
|
||||
@@ -1075,8 +1122,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.add_argument("--method", required=True)
|
||||
sp.add_argument("--params", default="{}", help="JSON object of params.")
|
||||
sp.add_argument("--confirm", action="store_true",
|
||||
help="Required for destructive methods (delete/uninstall/"
|
||||
"remove/reconfigure).")
|
||||
help="Required for methods matching the best-effort destructive "
|
||||
"denylist (delete/uninstall/remove/reconfigure/isolate/"
|
||||
"blocklist/assign/create*/move/...); verify any raw method "
|
||||
"yourself - the denylist is not exhaustive.")
|
||||
|
||||
# destructive (gated)
|
||||
sp = sub.add_parser("delete-endpoint", help="Delete an endpoint (gated).",
|
||||
|
||||
@@ -105,6 +105,10 @@ GRAVITYZONE_API_BASE_URL = os.environ.get(
|
||||
GRAVITYZONE_TIMEOUT_SECONDS = 60.0
|
||||
GRAVITYZONE_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Hard ceiling on paginated loops: a misbehaving API that always returns a full
|
||||
# page must never spin forever (the sweep also fans out N detail calls per page).
|
||||
MAX_PAGINATION_PAGES = 1000
|
||||
|
||||
ACG_ROOT_COMPANY_ID = "5c4280716c0318f3478b456a"
|
||||
ACG_COMPANIES_CONTAINER_ID = "5c4280716c0318f3478b456e"
|
||||
|
||||
@@ -257,7 +261,12 @@ class GravityZoneClient:
|
||||
"""Make one JSON-RPC call. Returns body['result'] or raises GravityZoneError."""
|
||||
url = f"{self.api_base_url}/{module}"
|
||||
payload = {"id": "1", "jsonrpc": "2.0", "method": method, "params": params}
|
||||
body = self._post(url, payload)
|
||||
# Read methods (get*/list*) are safe to retry on timeout/5xx; state-changing
|
||||
# methods are NOT (a timeout can fire after the server committed -> a retry
|
||||
# would double-execute, e.g. createScanTask/createPackage). 429 is a
|
||||
# pre-processing rate-limit reject and is always safe (handled in _post).
|
||||
idempotent = method.lower().startswith(("get", "list"))
|
||||
body = self._post(url, payload, idempotent=idempotent)
|
||||
|
||||
if isinstance(body, dict) and "error" in body and body["error"] is not None:
|
||||
err = body["error"]
|
||||
@@ -271,17 +280,21 @@ class GravityZoneClient:
|
||||
return body.get("result")
|
||||
return body
|
||||
|
||||
def _post(self, url: str, payload: dict) -> Any:
|
||||
"""POST with bounded retry on transient failures (429/5xx/timeout)."""
|
||||
def _post(self, url: str, payload: dict, idempotent: bool = False) -> Any:
|
||||
"""POST with bounded retry on transient failures. A 429 (pre-processing
|
||||
rate-limit reject, no side effect) is always retried; a timeout/5xx is
|
||||
retried ONLY for idempotent (read) calls, so a non-idempotent write is
|
||||
never silently re-executed after a server-side commit + timeout."""
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
for attempt in range(RETRY_MAX_ATTEMPTS):
|
||||
try:
|
||||
return self._post_once(url, data)
|
||||
except _RetryableHTTP as exc:
|
||||
if attempt >= RETRY_MAX_ATTEMPTS - 1:
|
||||
retry_safe = (exc.code == 429) or idempotent
|
||||
if not retry_safe or attempt >= RETRY_MAX_ATTEMPTS - 1:
|
||||
note = "" if retry_safe else " (non-idempotent; not retried)"
|
||||
raise GravityZoneError(
|
||||
f"GravityZone HTTP {exc.code} after {RETRY_MAX_ATTEMPTS} "
|
||||
f"attempts: {exc.detail}".rstrip(": ")
|
||||
f"GravityZone HTTP {exc.code}{note}: {exc.detail}".rstrip(": ")
|
||||
) from exc
|
||||
delay = _retry_delay(exc.headers, attempt)
|
||||
print(
|
||||
@@ -302,7 +315,6 @@ class GravityZoneClient:
|
||||
url, content=data, auth=(self.api_key, ""),
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise _RetryableHTTP("timeout", detail=str(exc)) from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
@@ -315,6 +327,12 @@ class GravityZoneClient:
|
||||
except httpx.HTTPError as exc:
|
||||
raise GravityZoneError(
|
||||
f"GravityZone request failed: {exc}") from exc
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
body = (resp.text or "")[:ERROR_BODY_MAX_CHARS]
|
||||
raise GravityZoneError(
|
||||
f"GravityZone returned a non-JSON response: {body}") from exc
|
||||
|
||||
# stdlib fallback
|
||||
token = base64.b64encode(f"{self.api_key}:".encode("utf-8")).decode("ascii")
|
||||
@@ -330,7 +348,6 @@ class GravityZoneClient:
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
|
||||
if exc.code in RETRY_STATUSES:
|
||||
@@ -341,6 +358,12 @@ class GravityZoneClient:
|
||||
raise _RetryableHTTP("timeout", detail=str(exc)) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise GravityZoneError(f"GravityZone request failed: {exc}") from exc
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except ValueError as exc:
|
||||
body = raw.decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
|
||||
raise GravityZoneError(
|
||||
f"GravityZone returned a non-JSON response: {body}") from exc
|
||||
|
||||
# ======================================================================
|
||||
# READ METHODS (always live)
|
||||
@@ -511,6 +534,10 @@ class GravityZoneClient:
|
||||
if len(items) < per_page:
|
||||
break
|
||||
page += 1
|
||||
if page > MAX_PAGINATION_PAGES:
|
||||
print(f"[WARNING] security_sweep hit the {MAX_PAGINATION_PAGES}-page "
|
||||
"ceiling; results may be truncated.", file=sys.stderr)
|
||||
break
|
||||
|
||||
summaries.sort(
|
||||
key=lambda s: (
|
||||
@@ -1252,6 +1279,11 @@ class GravityZoneClient:
|
||||
if len(items) < 100:
|
||||
break
|
||||
page += 1
|
||||
if page > MAX_PAGINATION_PAGES:
|
||||
print("[WARNING] refresh_inventory hit the "
|
||||
f"{MAX_PAGINATION_PAGES}-page ceiling for company "
|
||||
f"{cid}; inventory may be truncated.", file=sys.stderr)
|
||||
break
|
||||
|
||||
try:
|
||||
for p in self.list_policies(per_page=100).get("items", []):
|
||||
@@ -1287,29 +1319,39 @@ class GravityZoneClient:
|
||||
return self.refresh_inventory()
|
||||
|
||||
def _cache_add_group(self, group_id: str, name: str) -> None:
|
||||
with self._cache_lock():
|
||||
cache = self._read_cache()
|
||||
if cache is None:
|
||||
return # no cache yet - next refresh picks it up
|
||||
# Groups live in the inventory tree; store under a 'groups' map.
|
||||
cache.setdefault("groups", {})[group_id] = name
|
||||
self._write_cache(cache)
|
||||
# Best-effort: the cache is only a hint, so a write failure must NEVER
|
||||
# turn a successful API mutation (createCustomGroup) into a reported error.
|
||||
try:
|
||||
with self._cache_lock():
|
||||
cache = self._read_cache()
|
||||
if cache is None:
|
||||
return # no cache yet - next refresh picks it up
|
||||
# Groups live in the inventory tree; store under a 'groups' map.
|
||||
cache.setdefault("groups", {})[group_id] = name
|
||||
self._write_cache(cache)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _cache_add_package(self, package_name: str, create_result: Any) -> None:
|
||||
with self._cache_lock():
|
||||
cache = self._read_cache()
|
||||
if cache is None:
|
||||
return
|
||||
packages = cache.setdefault("packages", [])
|
||||
pkg_id = create_result if isinstance(create_result, str) else None
|
||||
if isinstance(create_result, dict):
|
||||
pkg_id = create_result.get("id")
|
||||
if not any(
|
||||
(isinstance(p, dict) and p.get("name") == package_name)
|
||||
for p in packages
|
||||
):
|
||||
packages.append({"id": pkg_id, "name": package_name})
|
||||
self._write_cache(cache)
|
||||
# Best-effort (see _cache_add_group): never let a cache failure mask a
|
||||
# successful createPackage.
|
||||
try:
|
||||
with self._cache_lock():
|
||||
cache = self._read_cache()
|
||||
if cache is None:
|
||||
return
|
||||
packages = cache.setdefault("packages", [])
|
||||
pkg_id = create_result if isinstance(create_result, str) else None
|
||||
if isinstance(create_result, dict):
|
||||
pkg_id = create_result.get("id")
|
||||
if not any(
|
||||
(isinstance(p, dict) and p.get("name") == package_name)
|
||||
for p in packages
|
||||
):
|
||||
packages.append({"id": pkg_id, "name": package_name})
|
||||
self._write_cache(cache)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -16,6 +16,10 @@ import sys
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GZ = os.path.join(HERE, "gz.py")
|
||||
ACG = "5c428b246c031893678b4569" # ACG internal company (real)
|
||||
# Valid-FORMAT (24-char hex) placeholder id. Non-existent on the tenant but it
|
||||
# passes client-side _require_oid so gate-refusal tests reach the gate (rc3)
|
||||
# instead of short-circuiting on id validation (rc2). Never used with --confirm.
|
||||
VID = "0123456789abcdef01234567"
|
||||
|
||||
results = []
|
||||
|
||||
@@ -96,15 +100,20 @@ check("policy bad id -> rc2 (client-side)", ["policy", "bogus"], want_rc=2, err_
|
||||
check("quarantine missing --company -> rc2", ["quarantine"], want_rc=2)
|
||||
check("endpoint missing positional -> rc2", ["endpoint"], want_rc=2)
|
||||
|
||||
# --- gating: destructive without --confirm -> rc3, no API call ---
|
||||
check("isolate no confirm -> rc3", ["isolate", "--endpoints", "x"], want_rc=3, err_has="Would")
|
||||
check("unisolate no confirm -> rc3", ["unisolate", "--endpoints", "x"], want_rc=3)
|
||||
# --- gating: destructive without --confirm -> rc3, no API call (valid-format ids
|
||||
# so the gate is reached; id validation is tested separately below) ---
|
||||
check("isolate no confirm -> rc3", ["isolate", "--endpoints", VID], want_rc=3, err_has="Would")
|
||||
check("unisolate no confirm -> rc3", ["unisolate", "--endpoints", VID], want_rc=3)
|
||||
check("blocklist-add no confirm -> rc3", ["blocklist-add", "--company", ACG, "--hashes", "abc"], want_rc=3)
|
||||
check("blocklist-remove no confirm -> rc3", ["blocklist-remove", "--id", "x"], want_rc=3)
|
||||
check("delete-endpoint no confirm -> rc3", ["delete-endpoint", "x"], want_rc=3)
|
||||
check("delete-endpoint no confirm -> rc3", ["delete-endpoint", VID], want_rc=3)
|
||||
check("delete-package no confirm -> rc3", ["delete-package", "--id", "x"], want_rc=3)
|
||||
check("delete-group no confirm -> rc3", ["delete-group", "--group", "x"], want_rc=3)
|
||||
check("assign-policy no confirm -> rc3", ["assign-policy", "--policy", "p", "--targets", "x"], want_rc=3, err_has="Would")
|
||||
check("delete-group no confirm -> rc3", ["delete-group", "--group", VID], want_rc=3)
|
||||
check("assign-policy no confirm -> rc3", ["assign-policy", "--policy", VID, "--targets", VID], want_rc=3, err_has="Would")
|
||||
# id validation runs BEFORE the gate: a malformed id on a gated cmd -> rc2 (not rc3)
|
||||
check("delete-endpoint bad id -> rc2", ["delete-endpoint", "x"], want_rc=2, err_has="not a valid")
|
||||
check("isolate bad id -> rc2", ["isolate", "--endpoints", "x", "--confirm"], want_rc=2, err_has="not a valid")
|
||||
check("company-delete bad id -> rc2", ["company-delete", "--id", "x", "--confirm"], want_rc=2, err_has="not a valid")
|
||||
check("push-set no confirm -> rc3", ["push-set", "--status", "1", "--url", "https://x/y"], want_rc=3)
|
||||
check("push-set enable no url -> rc2", ["push-set", "--status", "1", "--confirm"], want_rc=2)
|
||||
check("raw assignPolicy no confirm -> rc3", ["raw", "--module", "network", "--method", "assignPolicy", "--params", "{}"], want_rc=3)
|
||||
@@ -130,25 +139,25 @@ check("raw createCustomRule no confirm -> rc3", ["raw", "--module", "incidents",
|
||||
|
||||
# --- network completion ---
|
||||
check("endpoint-tags", ["endpoint-tags"], want_rc=0)
|
||||
check("set-label no confirm -> rc3", ["set-label", "--endpoint", "x", "--label", "y"], want_rc=3)
|
||||
check("reconfigure no confirm -> rc3", ["reconfigure", "--targets", "x"], want_rc=3)
|
||||
check("set-label no confirm -> rc3", ["set-label", "--endpoint", VID, "--label", "y"], want_rc=3)
|
||||
check("reconfigure no confirm -> rc3", ["reconfigure", "--targets", VID], want_rc=3)
|
||||
check("raw reconfigure no confirm -> rc3", ["raw", "--module", "network", "--method", "createReconfigureClientTask", "--params", "{}"], want_rc=3)
|
||||
check("raw setEndpointLabel no confirm -> rc3", ["raw", "--module", "network", "--method", "setEndpointLabel", "--params", "{}"], want_rc=3)
|
||||
|
||||
# --- companies module ---
|
||||
check("company (own, no id)", ["company"], want_rc=0)
|
||||
check("company-create no confirm -> rc3", ["company-create", "--type", "1", "--name", "Test Co"], want_rc=3, err_has="Would")
|
||||
check("company-suspend no confirm -> rc3", ["company-suspend", "--id", "x"], want_rc=3)
|
||||
check("company-activate no confirm -> rc3", ["company-activate", "--id", "x"], want_rc=3)
|
||||
check("company-delete no confirm -> rc3", ["company-delete", "--id", "x"], want_rc=3)
|
||||
check("company-suspend no confirm -> rc3", ["company-suspend", "--id", VID], want_rc=3)
|
||||
check("company-activate no confirm -> rc3", ["company-activate", "--id", VID], want_rc=3)
|
||||
check("company-delete no confirm -> rc3", ["company-delete", "--id", VID], want_rc=3)
|
||||
check("raw createCompany no confirm -> rc3", ["raw", "--module", "companies", "--method", "createCompany", "--params", "{}"], want_rc=3)
|
||||
|
||||
# --- accounts module ---
|
||||
check("account (own, no id)", ["account"], want_rc=0)
|
||||
check("account-create no confirm -> rc3", ["account-create", "--email", "t@x.io"], want_rc=3, err_has="Would")
|
||||
check("account-update no confirm -> rc3", ["account-update", "--id", "a", "--set-json", "{\"role\":5}"], want_rc=3)
|
||||
check("account-update bad json -> rc2", ["account-update", "--id", "a", "--set-json", "{bad", "--confirm"], want_rc=2)
|
||||
check("account-delete no confirm -> rc3", ["account-delete", "--id", "a"], want_rc=3)
|
||||
check("account-update no confirm -> rc3", ["account-update", "--id", VID, "--set-json", "{\"role\":5}"], want_rc=3)
|
||||
check("account-update bad json -> rc2", ["account-update", "--id", VID, "--set-json", "{bad", "--confirm"], want_rc=2)
|
||||
check("account-delete no confirm -> rc3", ["account-delete", "--id", VID], want_rc=3)
|
||||
check("notif-configure no confirm -> rc3", ["notif-configure", "--settings-json", "{\"deleteAfter\":7}"], want_rc=3)
|
||||
check("raw createAccount no confirm -> rc3", ["raw", "--module", "accounts", "--method", "createAccount", "--params", "{}"], want_rc=3)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
99d6d692-99e0-4359-9f9c-f43be89f49e5
|
||||
@@ -1 +0,0 @@
|
||||
84537db7-e9a6-4e95-b024-aa8ab03f0b14
|
||||
30
errorlog.md
30
errorlog.md
@@ -17,6 +17,36 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
|
||||
|
||||
<!-- Append entries below this line -->
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'run' failed (rc=255) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno SSH connect/auth failed (rc=255) [ctx: host=192.168.0.120 vp=clients/cascades-tucson/synology-cascadesds.sops.yaml]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'run' failed (rc=255) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno SSH connect/auth failed (rc=255) [ctx: host=192.168.0.120 vp=clients/cascades-tucson/synology-cascadesds.sops.yaml]
|
||||
|
||||
2026-06-25 | Howard-Home | datto-edr | [friction] EDR scan endpoints from Infocyte module (targets/{id}/scan, targets/scan, scans) all 404 on Datto EDR tenant; working trigger is POST /Agents/scan {ids:[...]} but 'Scan - EDR' is TENANT-WIDE (empty/ids body scanned 156 hosts); cancel via POST /userTasks/{id}/cancel (204) [ctx: skill=datto-edr tenant=azcomp4587]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'acl' failed (rc=255) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno SSH connect/auth failed (rc=255) [ctx: host=192.168.0.120 vp=clients/cascades-tucson/synology-cascadesds.sops.yaml]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'acl' failed (rc=255) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno SSH connect/auth failed (rc=255) [ctx: host=192.168.0.120 vp=clients/cascades-tucson/synology-cascadesds.sops.yaml]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'groups' failed (rc=127) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'users' failed (rc=127) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'run' failed (rc=255) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno SSH connect/auth failed (rc=255) [ctx: host=192.168.0.120 vp=clients/cascades-tucson/synology-cascadesds.sops.yaml]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'acl' failed (rc=1) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'users' failed (rc=127) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'shares' failed (rc=1) [ctx: host=192.168.0.120]
|
||||
|
||||
2026-06-25 | Howard-Home | synology/ssh | syno-ssh recipe 'acl' failed (rc=1) [ctx: host=192.168.0.120]
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Datto EDR Skill Build + Full Lifecycle Test on RMM-TEST-MACHINE
|
||||
|
||||
## User
|
||||
- **User:** Howard Enos (howard)
|
||||
- **Machine:** Howard-Home
|
||||
- **Role:** tech
|
||||
|
||||
## Session Summary
|
||||
|
||||
Built a new `datto-edr` skill from scratch and ran a full create-group -> install -> scan
|
||||
lifecycle test against the live ACG Datto EDR tenant (`azcomp4587.infocyte.com`). Started by
|
||||
answering skill-inventory questions (no EDR/Autotask/Kaseya skills existed) and verifying that
|
||||
**Syncro's own RMM** (policies, asset/group moves) is GUI-only via API — saved as memory
|
||||
`reference_syncro_rmm_api_gui_only`. Then scoped Datto EDR control: research established Datto
|
||||
EDR == rebranded **Infocyte HUNT**, a per-tenant LoopBack REST API, and that **no Datto RMM
|
||||
skill is needed** (EDR API is standalone).
|
||||
|
||||
Howard provided the EDR API token; it was vaulted at `msp-tools/datto-edr.sops.yaml`,
|
||||
live-verified (215 agents, 96 boxes, 13 client orgs), and the full skill was authored and
|
||||
committed (`.claude/skills/datto-edr/`, commit `bd1e84d` on main). The skill drives the whole
|
||||
MSP fleet from one token: orgs/sites/agents/detections/sweep (all live-verified) plus gated
|
||||
scan/isolate/deploy.
|
||||
|
||||
The lifecycle test on **RMM-TEST-MACHINE** (ACG internal Howard-VM) created an EDR target group,
|
||||
minted a registration key, pushed the agent install via `/rmm`, and confirmed the agent
|
||||
registered into the group (active, default EDR real-time policy applied). The scan step exposed
|
||||
that the documented Infocyte scan endpoints are **dead** on this tenant; a research agent reading
|
||||
the live console's own JS bundle found the **definitive** working scan call. Session paused here
|
||||
to save + clear context before applying the code fix and running a detection->reporting test.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **No Datto RMM skill** — Datto EDR has its own standalone API (Infocyte HUNT); RMM is a separate
|
||||
product/API (already vaulted at `msp-tools/datto-rmm.sops.yaml`, unrelated).
|
||||
- **Skill modeled on `bitdefender`** — same structure (SKILL.md + `<cli>.py` + `<cli>_client.py` +
|
||||
selftest + references), reads free, mutations `--confirm`, vault-keyed, live-verified. This skill
|
||||
is the prototype for GuruRMM security-connector #2 (RMM_THOUGHTS Feature 6).
|
||||
- **Policy assignment is console-only** — verified exhaustively (relation endpoints 404, policies
|
||||
are tenant-global typed `av`/`edr` templates, no policyId on org/target/agent, module ships no
|
||||
policy cmdlets). Default `av`+`edr` policies auto-apply; chose "proceed with defaults" for the test.
|
||||
- **Scan one agent via `where` filter** — the scan param is a LoopBack `where`, NOT `ids`; absent
|
||||
`where` = tenant-wide. Will rewrite the skill's scan command to this.
|
||||
- **Cancelled the accidental tenant-wide scan** immediately (was at 0%, contained).
|
||||
|
||||
## Problems Encountered
|
||||
|
||||
- **Install passed empty `--url`** — `Install-EDR -InstanceName azcomp4587` failed because the
|
||||
install script's loose `.com` regex matches "zcom" inside "azcomp4587", so it thought the cname
|
||||
was already a full URL and built an empty `$hunturl`. Fix: pass the **full URL**
|
||||
`-URL "https://azcomp4587.infocyte.com"`. Re-dispatch succeeded (exit 0).
|
||||
- **`agentKeys` POST 500 on `{targetId}`** — the key `id` is **caller-supplied** (a 10-char
|
||||
string), not auto-generated. `POST /agentKeys {"id":"tstrmm7053","targetId":"<tg>"}` works.
|
||||
- **All Infocyte scan routes 404** (`targets/{id}/scan`, `targets/scan`, `scans`) — superseded.
|
||||
- **`POST /Agents/scan` with `{ids:[...]}` or empty body = tenant-wide scan** ("Scanning 156
|
||||
hosts"). Root cause: endpoint takes a `where` filter; `ids` is silently ignored, no `where` =
|
||||
scan all. Logged to errorlog as friction.
|
||||
- **`is_connected` is null fleet-wide in GuruRMM** — first install dispatch went to the stale
|
||||
(offline) RMM-TEST-MACHINE agent row and queued `pending`. Resolve by **most-recent `last_seen`**,
|
||||
not `is_connected`. Cancelled + redispatched to the live agent.
|
||||
- **`eval "$(rmm-auth.sh)" | tail` lost env vars** — piping puts eval in a subshell; `$TOKEN`/`$RMM`
|
||||
never set in the parent. Run `eval` without a pipe.
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
- **Created skill** `.claude/skills/datto-edr/` — `SKILL.md`, `scripts/edr.py`, `scripts/edr_client.py`,
|
||||
`scripts/selftest.py`, `references/api-reference.md`, `.gitignore`. Committed `bd1e84d` (main).
|
||||
**NOTE: the committed scan code still uses the DEAD `targets/{id}/scan` endpoint — must be fixed
|
||||
next session (see Pending).**
|
||||
- **Memory** `.claude/memory/reference_syncro_rmm_api_gui_only.md` + MEMORY.md index line. Committed.
|
||||
- **RMM_THOUGHTS Feature 6** (`projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md`) — appended Datto EDR
|
||||
connector API research. Committed in guru-rmm submodule `3b3f069`, pointer bumped in main `bd1e84d`.
|
||||
- **errorlog.md** — one `--friction` entry (scan endpoints dead + tenant-wide footgun).
|
||||
|
||||
## Credentials & Secrets
|
||||
|
||||
- **Datto EDR API token** — vaulted `msp-tools/datto-edr.sops.yaml` field `credentials.api_token`.
|
||||
Value: `FpRvE6IENdctE5Mrf8CS8FpyawbY6MTQXwc9Vw9GmdqQq02TfGlvpfv5skzKhjO7`. Pushed to vault repo.
|
||||
**Auth = raw token in `Authorization` header (NO `Bearer`).** Created 2026-06-25, **expires
|
||||
~2027-06-25** (1yr). Generated in console: username menu -> Admin -> Users & Tokens -> API Tokens.
|
||||
- **EDR group registration key** `tstrmm7053` (minted this session, tied to test group `c3ba0672`).
|
||||
Not vaulted (disposable test key).
|
||||
|
||||
## Infrastructure & Servers
|
||||
|
||||
- **Datto EDR tenant:** `https://azcomp4587.infocyte.com` (API base `/api`). LoopBack REST.
|
||||
Explorer/swagger (`/explorer/*`) hangs/times out — unusable; `/api/*` is instant.
|
||||
- **Data model:** Organization (client) -> Location (site, carries `organizationId`) -> Agent
|
||||
(carries `locationId`). `Targets` = scan groups (often alias a Location id). `deviceGroups` =
|
||||
global categories ("Servers"/"Workstations"). Policies = tenant-global typed `av`/`edr`, `isDefault`.
|
||||
- **Test artifacts LIVE on the tenant (pending cleanup decision):**
|
||||
- EDR target group `[TEST] RMM-TEST-MACHINE` — targetId `c3ba0672-e6bb-4784-9a37-2f434fc6f08c`, org
|
||||
ACG `ac78844a-2d44-4c10-acc8-c9bcb6106346`.
|
||||
- Reg key `tstrmm7053`.
|
||||
- EDR agent `rmm-test-machine` — id `b98b3ba0-5f82-466f-911a-5a6b24cdbae7`, active, locationId
|
||||
`c3ba0672`, dattoAvEnabled=false, version 3.17.1.5409, Win11 22H2. deviceId/deviceShortId null.
|
||||
- **RMM-TEST-MACHINE in GuruRMM** (`http://172.16.3.30:3001`): ACG / Howard-VM / Windows. **Live
|
||||
agent id `99d6d692-99e0-4359-9f9c-f43be89f49e5`** (use most-recent last_seen; stale row is
|
||||
`7d3456f5...`).
|
||||
|
||||
## Commands & Outputs
|
||||
|
||||
- **VERIFIED single-agent scan (apply to skill next session):**
|
||||
```
|
||||
POST https://azcomp4587.infocyte.com/api/Agents/scan
|
||||
Authorization: <raw token>
|
||||
{"where":{"id":{"inq":["<agentId>"]}}, "options":{}, "taskName":"Scan - EDR"}
|
||||
```
|
||||
Source: live console JS bundle `index.DhsZtGr7.js` (`post("agents/scan",{where,options,taskName})`).
|
||||
Absent `where` => scans ALL active agents (the footgun). Also `POST organizations/scan`,
|
||||
`locations/scan`, `locations/{id}/scan` take `{where, options}`. `scanType` is client-side only.
|
||||
**AV scans are policy-driven, not callable.**
|
||||
- **Cancel a scan task:** `POST /userTasks/{id}/cancel` -> 204 (or `PATCH /userTasks/{id}` `{status:"Cancelled"}`).
|
||||
- **Create group:** `POST /Targets {"name":"...","organizationId":"..."}` -> `{id,...}`.
|
||||
- **Mint key:** `POST /agentKeys {"id":"<10char>","targetId":"<tg>"}` (id is caller-supplied).
|
||||
- **Install one-liner (push via /rmm, FULL url):**
|
||||
```
|
||||
[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; Install-EDR -URL "https://azcomp4587.infocyte.com" -RegKey tstrmm7053
|
||||
```
|
||||
Result: `Installed RTS agent to C:\Program Files\infocyte\agent\agent.exe`, exit 0.
|
||||
- **Skill CLI (working):** `bash .claude/scripts/py.sh .claude/skills/datto-edr/scripts/edr.py status|orgs|sites --org|agents --org|detections --org --days N|sweep|deploy-cmd|extensions`.
|
||||
|
||||
## Pending / Incomplete Tasks
|
||||
|
||||
**RESUME PLAN (next session, after context clear):**
|
||||
|
||||
1. **Fix the skill scan code** (currently committed with the DEAD `targets/{id}/scan`):
|
||||
- `edr_client.py`: replace `scan_target_group`/`scan_single_target` with `scan_agents(agent_ids)`
|
||||
-> `POST Agents/scan {"where":{"id":{"inq":[ids]}}, "options":{}, "taskName":"Scan - EDR"}`.
|
||||
Add a hard guard: refuse to POST without a non-empty `where`/agent list (prevents tenant-wide).
|
||||
Add `cancel_task(id)` (`POST userTasks/{id}/cancel`). Optionally add `create_group`, `mint_key`.
|
||||
- `edr.py`: change `scan` to `--agent <id>` (and/or `--agents`), keep `--confirm`; add `cancel`,
|
||||
and first-class `create-group` + `mint-key` + `deploy` subcommands. Update `_t_*` as needed.
|
||||
- Update `references/api-reference.md` + `SKILL.md`: verified scan endpoint, tenant-wide footgun,
|
||||
install full-URL gotcha, agentKeys caller-supplied id, policy console-only. Commit + push.
|
||||
2. **Detection -> reporting test:** push a **known-detectable file** to RMM-TEST-MACHINE (RMM agent
|
||||
`99d6d692`), then scan ONLY that agent (`where id inq [b98b3ba0]`) and verify a detection appears
|
||||
in `detections`/Alerts -> proves reporting. **CAVEAT:** the agent is **EDR-only (no Datto AV)**, so
|
||||
an EICAR/AV test file may NOT trigger — Datto EDR is behavioral/forensic (reputation/artifact
|
||||
scoring). Pick an EDR-detectable artifact (known-bad-hash test binary, or a tool flagged by
|
||||
reputation), or assign/enable Datto AV first. Decide the artifact at the start of next session.
|
||||
3. **Cleanup decision** on the test artifacts (group `c3ba0672`, key `tstrmm7053`, installed agent
|
||||
`b98b3ba0` on RMM-TEST-MACHINE) — keep as a live test endpoint, or tear down (agent.exe
|
||||
--uninstall via /rmm + delete group/key). Howard leaned toward keeping a test endpoint.
|
||||
|
||||
## Reference Information
|
||||
|
||||
- Skill: `.claude/skills/datto-edr/` (commit `bd1e84d`, main). Vault: `msp-tools/datto-edr.sops.yaml`.
|
||||
- Tenant: `azcomp4587.infocyte.com`. Org map e.g. Cascades `2d5ea96e...`, Dataforth `4a2664bf...`,
|
||||
ACG `ac78844a-2d44-4c10-acc8-c9bcb6106346`.
|
||||
- KaseyaDEDR/Infocyte GitHub `PowershellTools` (Apache-2.0) — install script + old API patterns;
|
||||
scan routes there are DEAD. Datto EDR help: edr.datto.com/help. RMM_THOUGHTS Feature 6 for the
|
||||
GuruRMM "EDR add-on" (webhooks Admin->Webhooks; needs Mike's go to build).
|
||||
- Research subagents (resumable): scan-endpoint finder `af59ee58a2ba28282`; EDR API research `ab14b157f92f91d49`.
|
||||
Reference in New Issue
Block a user