fix(bitdefender): all-clients sweep, quarantine path, EDR controls, self-test
Several bugs found and fixed during live testing against the ACG GravityZone tenant: - security_sweep_all_clients: iterate each company (the companies container is not a valid endpoint parent; passing it 400'd the whole sweep) - list_quarantine: use service-scoped path quarantine/computers with companyId (bare quarantine module 404'd; param is companyId not parentId) - rename GZEndpointSummary.detection_active -> threat_detected with corrected semantics (True = active threat, tracks with infected; not an engine-on flag) - status: readable sectioned table renderer for the nested apiKey/license dict - portable CLAUDETOOLS_ROOT resolution (derive from file path, not a Windows literal) so it works on the Mac/Linux fleet Adds scripts/selftest.py: a 29-check read-only harness (all passing) covering every read command, --json, error exit codes, and destructive-action gating. EDR/incident commands (blocklist, isolate/unisolate, blocklist-add/remove) and raw destructive-method gating are included from this session's work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,12 @@ Usage examples:
|
||||
python gz.py move --endpoints <id1> <id2> --group <groupId>
|
||||
python gz.py make-group --name "New Group" --parent <parentId>
|
||||
python gz.py delete-endpoint <id> --confirm
|
||||
python gz.py blocklist --company <id>
|
||||
python gz.py incidents --company <id>
|
||||
python gz.py isolate --endpoints <id1> <id2> --confirm
|
||||
python gz.py unisolate --endpoints <id1> <id2> --confirm
|
||||
python gz.py blocklist-add --company <id> --hashes <h1> <h2> --confirm
|
||||
python gz.py blocklist-remove --id <hashItemId> --confirm
|
||||
python gz.py raw --module network --method getEndpointsList --params '{"page":1}'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -55,6 +61,23 @@ def _print_kv(d: dict) -> None:
|
||||
print(f" {k}: {v}")
|
||||
|
||||
|
||||
def _print_status(status: dict) -> None:
|
||||
"""Render the nested API-status dict as readable sections.
|
||||
|
||||
get_api_status() returns {"apiKey": {...}, "license": {...}} and possibly
|
||||
"_licenseWarning". Print a [section] header per top-level key, then indented
|
||||
key: value lines for each sub-field. Non-dict top-level values (e.g. the
|
||||
license warning string) print as a single indented line under their header.
|
||||
"""
|
||||
for section, body in status.items():
|
||||
print(f"[{section}]")
|
||||
if isinstance(body, dict):
|
||||
for k, v in body.items():
|
||||
print(f" {k}: {v}")
|
||||
else:
|
||||
print(f" {body}")
|
||||
|
||||
|
||||
def _print_company_table(data: dict) -> None:
|
||||
items = data.get("items", [])
|
||||
print(f"Companies: {data.get('total', len(items))}")
|
||||
@@ -108,6 +131,23 @@ def _print_quarantine_table(data: dict) -> None:
|
||||
f"{q.get('detectionTime','')}")
|
||||
|
||||
|
||||
def _print_blocklist_table(data: dict) -> None:
|
||||
items = data.get("items", [])
|
||||
print(f"Blocklist items: {data.get('total', len(items))}")
|
||||
print(f" {'ID':26} {'HTYPE':6} {'HASH':66} SOURCE-INFO")
|
||||
for b in items:
|
||||
print(f" {str(b.get('id','?')):26} {str(b.get('hashType','?')):6} "
|
||||
f"{str(b.get('hash','')):66} {b.get('sourceInfo','')}")
|
||||
|
||||
|
||||
def _print_incidents_table(data: dict) -> None:
|
||||
items = data.get("items", [])
|
||||
print(f"Incidents: {data.get('total', len(items))}")
|
||||
for i in items:
|
||||
print(f" {str(i.get('id','?')):26} {i.get('name', i.get('title','')):40} "
|
||||
f"{i.get('severity', i.get('status',''))}")
|
||||
|
||||
|
||||
def _print_inventory_table(cache: dict) -> None:
|
||||
print(f"Inventory cached_at: {cache.get('fetched_at')}")
|
||||
print(f" companies: {len(cache.get('companies', {}))}")
|
||||
@@ -119,7 +159,7 @@ def _print_inventory_table(cache: dict) -> None:
|
||||
|
||||
# --- command handlers ---------------------------------------------------------
|
||||
def cmd_status(client, args):
|
||||
_emit(client.get_api_status(), args.json, _print_kv)
|
||||
_emit(client.get_api_status(), args.json, _print_status)
|
||||
|
||||
|
||||
def cmd_companies(client, args):
|
||||
@@ -136,8 +176,12 @@ def cmd_endpoint(client, args):
|
||||
|
||||
|
||||
def cmd_sweep(client, args):
|
||||
target = args.company or _require_company_for_sweep()
|
||||
summaries = client.security_sweep(target)
|
||||
if args.company:
|
||||
summaries = client.security_sweep(args.company)
|
||||
else:
|
||||
print("[INFO] No --company given; sweeping ALL client companies "
|
||||
"(this makes many live API calls).", file=sys.stderr)
|
||||
summaries = client.security_sweep_all_clients()
|
||||
if args.json:
|
||||
print(json.dumps([dataclasses.asdict(s) for s in summaries], indent=2))
|
||||
else:
|
||||
@@ -169,6 +213,17 @@ def cmd_quarantine(client, args):
|
||||
_emit(client.list_quarantine(args.company), args.json, _print_quarantine_table)
|
||||
|
||||
|
||||
def cmd_blocklist(client, args):
|
||||
_emit(client.list_blocklist(args.company, page=args.page, per_page=args.per_page),
|
||||
args.json, _print_blocklist_table)
|
||||
|
||||
|
||||
def cmd_incidents(client, args):
|
||||
_emit(client.list_incidents(args.company, page=args.page,
|
||||
per_page=args.per_page),
|
||||
args.json, _print_incidents_table)
|
||||
|
||||
|
||||
def cmd_inventory(client, args):
|
||||
_emit(client.get_inventory(refresh=args.refresh), args.json,
|
||||
_print_inventory_table)
|
||||
@@ -209,8 +264,11 @@ def cmd_make_group(client, args):
|
||||
|
||||
# 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.
|
||||
DESTRUCTIVE_RAW_PATTERNS = ("delete", "createuninstall", "createremove",
|
||||
"createreconfigure")
|
||||
"createreconfigure", "isolat", "addtoblocklist",
|
||||
"removefromblocklist")
|
||||
|
||||
|
||||
def _is_destructive_method(method: str) -> bool:
|
||||
@@ -270,6 +328,49 @@ def cmd_delete_group(client, args):
|
||||
return 0
|
||||
|
||||
|
||||
# --- EDR / incident response (gated) ------------------------------------------
|
||||
def cmd_isolate(client, args):
|
||||
targets = ",".join(args.endpoints)
|
||||
if not _gated(f"isolate endpoints {targets}", args.confirm):
|
||||
return 3
|
||||
result = client.isolate_endpoints(args.endpoints)
|
||||
_emit({"isolated": args.endpoints, "result": result}, args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_unisolate(client, args):
|
||||
targets = ",".join(args.endpoints)
|
||||
if not _gated(f"restore endpoints from isolation {targets}", args.confirm):
|
||||
return 3
|
||||
result = client.restore_endpoints_from_isolation(args.endpoints)
|
||||
_emit({"unisolated": args.endpoints, "result": result}, args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_blocklist_add(client, args):
|
||||
desc = (f"add {len(args.hashes)} hash(es) to blocklist for company "
|
||||
f"{args.company}: {','.join(args.hashes)}")
|
||||
if not _gated(desc, args.confirm):
|
||||
return 3
|
||||
result = client.add_to_blocklist(
|
||||
company_id=args.company,
|
||||
hash_list=args.hashes,
|
||||
hash_type=args.hash_type,
|
||||
source_info=args.source_info,
|
||||
)
|
||||
_emit({"blocklistAdded": args.hashes, "company": args.company,
|
||||
"result": result}, args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_blocklist_remove(client, args):
|
||||
if not _gated(f"remove blocklist item {args.id}", args.confirm):
|
||||
return 3
|
||||
result = client.remove_from_blocklist(args.id)
|
||||
_emit({"blocklistRemoved": args.id, "result": result}, args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
# --- parser -------------------------------------------------------------------
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
@@ -308,6 +409,20 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parents=[common])
|
||||
sp.add_argument("--company", required=True)
|
||||
|
||||
sp = sub.add_parser("blocklist", help="List blocklisted hash items (EDR).",
|
||||
parents=[common])
|
||||
sp.add_argument("--company", help="Scope to one company id (optional).")
|
||||
sp.add_argument("--page", type=int, default=1)
|
||||
sp.add_argument("--per-page", type=int, default=100)
|
||||
|
||||
sp = sub.add_parser("incidents", help="List incidents under a company (EDR).",
|
||||
parents=[common])
|
||||
sp.add_argument("--company", required=True,
|
||||
help="Parent company/group id (parentId; required).")
|
||||
sp.add_argument("--page", type=int, default=1)
|
||||
sp.add_argument("--per-page", type=int, default=500,
|
||||
help="incidents requires 500-10000 (API constraint).")
|
||||
|
||||
sp = sub.add_parser("inventory", help="Show cached identity/structure.",
|
||||
parents=[common])
|
||||
sp.add_argument("--refresh", action="store_true", help="Force a full re-pull.")
|
||||
@@ -364,6 +479,40 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.add_argument("--group", required=True)
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
# EDR / incident response (gated)
|
||||
sp = sub.add_parser("isolate",
|
||||
help="Isolate endpoints from the network (gated).",
|
||||
parents=[common])
|
||||
sp.add_argument("--endpoints", nargs="+", required=True,
|
||||
help="One or more endpoint ids (max 1000).")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("unisolate",
|
||||
help="Restore endpoints from isolation (gated).",
|
||||
parents=[common])
|
||||
sp.add_argument("--endpoints", nargs="+", required=True,
|
||||
help="One or more endpoint ids (max 1000).")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("blocklist-add",
|
||||
help="Add hashes to the blocklist (gated).",
|
||||
parents=[common])
|
||||
sp.add_argument("--company", required=True)
|
||||
sp.add_argument("--hashes", nargs="+", required=True,
|
||||
help="One or more hash strings to block.")
|
||||
sp.add_argument("--hash-type", type=int, default=1,
|
||||
help="Hash type int (1 common; see console / API docs).")
|
||||
sp.add_argument("--source-info", default="",
|
||||
help="Free-text description of the source.")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("blocklist-remove",
|
||||
help="Remove one blocklist entry (gated).",
|
||||
parents=[common])
|
||||
sp.add_argument("--id", required=True,
|
||||
help="hashItemId — the 'id' from `blocklist` output.")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
return p
|
||||
|
||||
|
||||
@@ -377,6 +526,8 @@ HANDLERS = {
|
||||
"policy": cmd_policy,
|
||||
"packages": cmd_packages,
|
||||
"quarantine": cmd_quarantine,
|
||||
"blocklist": cmd_blocklist,
|
||||
"incidents": cmd_incidents,
|
||||
"inventory": cmd_inventory,
|
||||
"create-package": cmd_create_package,
|
||||
"install-links": cmd_install_links,
|
||||
@@ -387,6 +538,10 @@ HANDLERS = {
|
||||
"delete-endpoint": cmd_delete_endpoint,
|
||||
"delete-package": cmd_delete_package,
|
||||
"delete-group": cmd_delete_group,
|
||||
"isolate": cmd_isolate,
|
||||
"unisolate": cmd_unisolate,
|
||||
"blocklist-add": cmd_blocklist_add,
|
||||
"blocklist-remove": cmd_blocklist_remove,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user