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:
2026-06-25 13:44:18 -07:00
parent 1852f755ad
commit a96a15a1d2
7 changed files with 333 additions and 52 deletions

View File

@@ -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).",

View File

@@ -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:

View File

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