feat(bitdefender): expand GravityZone control surface + correct policy docs
Re-verified the live tenant's full API scope and wrapped the modules the key allows but the skill didn't expose. New CLI subcommands: - assign-policy (gated) — apply an existing policy to endpoints/groups (param shape policyId+targetIds verified live) - reports, accounts, notif-settings, scan-tasks — read - push-settings / push-stats / push-set (gated) — push event service (status param verified; needs a receiver URL to enable) Corrections from live probing: - policies are NOT shallow: getPolicyDetails returns the FULL granular config. Removed the false "shallow" warning; documented read+assign, console-only authoring. - raw now gates assignPolicy + setPushEventSettings. - documented dead modules (patchmanagement/phasr/maintenancewindows/integrations, incidents.getIncidentsList) and unconfigured-push handled cleanly (rc0, no errorlog). selftest 29/29 -> 42/42, all green against the live tenant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -168,6 +168,31 @@ def _print_incidents_table(data: dict) -> None:
|
||||
f"{i.get('severity', i.get('status',''))}")
|
||||
|
||||
|
||||
def _print_reports_table(data: dict) -> None:
|
||||
items = data.get("items", [])
|
||||
print(f"Reports: {data.get('total', len(items))}")
|
||||
for r in items:
|
||||
print(f" {str(r.get('id','?')):26} {str(r.get('name','')):40} "
|
||||
f"type={r.get('type','')}")
|
||||
|
||||
|
||||
def _print_accounts_table(data: dict) -> None:
|
||||
items = data.get("items", [])
|
||||
print(f"Accounts: {data.get('total', len(items))}")
|
||||
for a in items:
|
||||
prof = a.get("profile", {}) or {}
|
||||
print(f" {str(a.get('id','?')):26} {str(a.get('email','')):34} "
|
||||
f"{prof.get('fullName','')}")
|
||||
|
||||
|
||||
def _print_scan_tasks_table(data: dict) -> None:
|
||||
items = data.get("items", [])
|
||||
print(f"Scan tasks: {data.get('total', len(items))}")
|
||||
for t in items:
|
||||
print(f" {str(t.get('id','?')):26} {str(t.get('name','')):30} "
|
||||
f"status={t.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', {}))}")
|
||||
@@ -220,11 +245,90 @@ def cmd_policies(client, args):
|
||||
|
||||
|
||||
def cmd_policy(client, args):
|
||||
print("[WARNING] Public API returns shallow policy detail only "
|
||||
"(no granular config).", file=sys.stderr)
|
||||
# getPolicyDetails returns the FULL granular module configuration (verified
|
||||
# live 2026-06-21). Use --json for the complete settings tree; the table
|
||||
# view shows the top-level keys only.
|
||||
_emit(client.get_policy_details(args.policy_id), args.json, _print_kv)
|
||||
|
||||
|
||||
def cmd_reports(client, args):
|
||||
_emit(client.list_reports(page=args.page, per_page=args.per_page),
|
||||
args.json, _print_reports_table)
|
||||
|
||||
|
||||
def cmd_accounts(client, args):
|
||||
_emit(client.list_accounts(page=args.page, per_page=args.per_page),
|
||||
args.json, _print_accounts_table)
|
||||
|
||||
|
||||
def cmd_notif_settings(client, args):
|
||||
_emit(client.get_notifications_settings(), args.json, _print_kv)
|
||||
|
||||
|
||||
def cmd_scan_tasks(client, args):
|
||||
_emit(client.list_scan_tasks(page=args.page, per_page=args.per_page),
|
||||
args.json, _print_scan_tasks_table)
|
||||
|
||||
|
||||
def cmd_assign_policy(client, args):
|
||||
desc = (f"assign policy {args.policy} to {len(args.targets)} target(s): "
|
||||
f"{','.join(args.targets)}")
|
||||
if not _gated(desc, args.confirm):
|
||||
return 3
|
||||
result = client.assign_policy(
|
||||
args.policy, args.targets, force_inheritance=args.force_inheritance
|
||||
)
|
||||
_emit({"assignedPolicy": args.policy, "targets": args.targets,
|
||||
"result": result}, args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
def _push_read(emit_fn) -> int:
|
||||
"""Run a push read, treating 'never configured' as an expected (non-error)
|
||||
state rather than a failure (so it does not pollute errorlog)."""
|
||||
try:
|
||||
emit_fn()
|
||||
return 0
|
||||
except GravityZoneError as exc:
|
||||
msg = str(exc).lower()
|
||||
if "not set" in msg or "are not" in msg or "not available" in msg:
|
||||
print("[INFO] Push event service is not configured on this tenant.")
|
||||
return 0
|
||||
raise
|
||||
|
||||
|
||||
def cmd_push_settings(client, args):
|
||||
return _push_read(
|
||||
lambda: _emit(client.get_push_settings(), args.json, _print_kv)
|
||||
)
|
||||
|
||||
|
||||
def cmd_push_stats(client, args):
|
||||
return _push_read(
|
||||
lambda: _emit(client.get_push_stats(), args.json, _print_kv)
|
||||
)
|
||||
|
||||
|
||||
def cmd_push_set(client, args):
|
||||
state = "ENABLE" if args.status == 1 else "DISABLE"
|
||||
if args.status == 1 and not args.url:
|
||||
print("[ERROR] --url is required to enable the push event service.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
desc = f"{state} GravityZone push event service (url={args.url or '-'})"
|
||||
if not _gated(desc, args.confirm):
|
||||
return 3
|
||||
result = client.set_push_settings(
|
||||
status=args.status,
|
||||
service_type=args.service_type,
|
||||
url=args.url,
|
||||
require_valid_ssl=not args.allow_insecure_ssl,
|
||||
authorization=args.authorization,
|
||||
)
|
||||
_emit({"pushService": state, "result": result}, args.json, _print_kv)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_packages(client, args):
|
||||
_emit(client.list_packages(), args.json, _print_package_table)
|
||||
|
||||
@@ -288,7 +392,8 @@ def cmd_make_group(client, args):
|
||||
# (EDR) module — gate them in `raw` as well as via the dedicated subcommands.
|
||||
DESTRUCTIVE_RAW_PATTERNS = ("delete", "createuninstall", "createremove",
|
||||
"createreconfigure", "isolat", "addtoblocklist",
|
||||
"removefromblocklist")
|
||||
"removefromblocklist", "assignpolicy",
|
||||
"setpushevent")
|
||||
|
||||
|
||||
def _is_destructive_method(method: str) -> bool:
|
||||
@@ -419,12 +524,34 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.add_argument("--company", help="Parent id (defaults to ACG container).")
|
||||
|
||||
sub.add_parser("policies", help="List policies (id + name).", parents=[common])
|
||||
sp = sub.add_parser("policy", help="Shallow detail for one policy.",
|
||||
sp = sub.add_parser("policy",
|
||||
help="Full granular config for one policy (use --json).",
|
||||
parents=[common])
|
||||
sp.add_argument("policy_id")
|
||||
|
||||
sub.add_parser("packages", help="List installation packages.", parents=[common])
|
||||
|
||||
sp = sub.add_parser("reports", help="List saved reports.", parents=[common])
|
||||
sp.add_argument("--page", type=int, default=1)
|
||||
sp.add_argument("--per-page", type=int, default=100)
|
||||
|
||||
sp = sub.add_parser("accounts", help="List GravityZone console accounts.",
|
||||
parents=[common])
|
||||
sp.add_argument("--page", type=int, default=1)
|
||||
sp.add_argument("--per-page", type=int, default=100)
|
||||
|
||||
sub.add_parser("notif-settings", help="Show notification settings.",
|
||||
parents=[common])
|
||||
|
||||
sp = sub.add_parser("scan-tasks", help="List scan tasks.", parents=[common])
|
||||
sp.add_argument("--page", type=int, default=1)
|
||||
sp.add_argument("--per-page", type=int, default=100)
|
||||
|
||||
sub.add_parser("push-settings",
|
||||
help="Show push event service settings.", parents=[common])
|
||||
sub.add_parser("push-stats",
|
||||
help="Show push event service delivery stats.", parents=[common])
|
||||
|
||||
sp = sub.add_parser("quarantine", help="List quarantine items for a company.",
|
||||
parents=[common])
|
||||
sp.add_argument("--company", required=True)
|
||||
@@ -533,6 +660,32 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="hashItemId — the 'id' from `blocklist` output.")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("assign-policy",
|
||||
help="Assign an existing policy to endpoints/groups (gated).",
|
||||
parents=[common])
|
||||
sp.add_argument("--policy", required=True, help="policyId to assign.")
|
||||
sp.add_argument("--targets", nargs="+", required=True,
|
||||
help="One or more endpoint/group ids.")
|
||||
sp.add_argument("--force-inheritance", action="store_true",
|
||||
help="Force policy inheritance to sub-items.")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("push-set",
|
||||
help="Configure the push event service (gated).",
|
||||
parents=[common])
|
||||
sp.add_argument("--status", type=int, required=True, choices=[0, 1],
|
||||
help="1=enable, 0=disable.")
|
||||
sp.add_argument("--url",
|
||||
help="Receiver URL GravityZone POSTs events to "
|
||||
"(required to enable).")
|
||||
sp.add_argument("--service-type", default="jsonRPC",
|
||||
help="jsonRPC|splunk|cef (default jsonRPC).")
|
||||
sp.add_argument("--authorization",
|
||||
help="Optional Authorization header the receiver expects.")
|
||||
sp.add_argument("--allow-insecure-ssl", action="store_true",
|
||||
help="Do not require a valid SSL cert on the receiver.")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
return p
|
||||
|
||||
|
||||
@@ -545,6 +698,14 @@ HANDLERS = {
|
||||
"policies": cmd_policies,
|
||||
"policy": cmd_policy,
|
||||
"packages": cmd_packages,
|
||||
"reports": cmd_reports,
|
||||
"accounts": cmd_accounts,
|
||||
"notif-settings": cmd_notif_settings,
|
||||
"scan-tasks": cmd_scan_tasks,
|
||||
"push-settings": cmd_push_settings,
|
||||
"push-stats": cmd_push_stats,
|
||||
"assign-policy": cmd_assign_policy,
|
||||
"push-set": cmd_push_set,
|
||||
"quarantine": cmd_quarantine,
|
||||
"blocklist": cmd_blocklist,
|
||||
"incidents": cmd_incidents,
|
||||
|
||||
@@ -620,6 +620,127 @@ class GravityZoneClient:
|
||||
"incidents", "removeFromBlocklist", {"hashItemId": hash_item_id}
|
||||
)
|
||||
|
||||
# ======================================================================
|
||||
# POLICY ASSIGNMENT (state-changing; gate behind --confirm at call site)
|
||||
# ----------------------------------------------------------------------
|
||||
# NOTE: getPolicyDetails (above) returns the FULL granular module config
|
||||
# (verified live 2026-06-21 — the earlier "shallow only" claim was wrong).
|
||||
# The Public API still has NO create/edit/clone policy method — authoring
|
||||
# stays in the console — but assigning an EXISTING policy is supported here.
|
||||
# ======================================================================
|
||||
def assign_policy(
|
||||
self,
|
||||
policy_id: str,
|
||||
target_ids: list[str],
|
||||
force_inheritance: bool = False,
|
||||
) -> Any:
|
||||
"""Assign an existing policy to endpoints/groups (network.assignPolicy).
|
||||
|
||||
Param shape VERIFIED LIVE via validation probe (2026-06-21): requires
|
||||
`policyId` and `targetIds` (a list of endpoint/group ids).
|
||||
`forcePolicyInheritance` is optional. STATE-CHANGING — gate at the call
|
||||
site behind --confirm.
|
||||
"""
|
||||
params: dict = {"policyId": policy_id, "targetIds": target_ids}
|
||||
if force_inheritance:
|
||||
params["forcePolicyInheritance"] = True
|
||||
return self._jsonrpc_request("network", "assignPolicy", params)
|
||||
|
||||
def list_scan_tasks(
|
||||
self,
|
||||
page: int = 1,
|
||||
per_page: int = 100,
|
||||
name: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""List scan tasks (network.getScanTasksList). VERIFIED LIVE."""
|
||||
params: dict = {"page": page, "perPage": per_page}
|
||||
if name is not None:
|
||||
params["name"] = name
|
||||
if status is not None:
|
||||
params["status"] = status
|
||||
return self._jsonrpc_request("network", "getScanTasksList", params) or {}
|
||||
|
||||
# ======================================================================
|
||||
# REPORTS (module `/reports`) — VERIFIED LIVE
|
||||
# ======================================================================
|
||||
def list_reports(self, page: int = 1, per_page: int = 100) -> dict:
|
||||
"""List saved reports (reports.getReportsList)."""
|
||||
return self._jsonrpc_request(
|
||||
"reports", "getReportsList", {"page": page, "perPage": per_page}
|
||||
) or {}
|
||||
|
||||
def get_report_links(self, report_id: str) -> Any:
|
||||
"""Get download links for a generated report (reports.getDownloadLinks).
|
||||
|
||||
Param name `reportId` is the candidate — confirm against the console if
|
||||
it errors.
|
||||
"""
|
||||
return self._jsonrpc_request(
|
||||
"reports", "getDownloadLinks", {"reportId": report_id}
|
||||
)
|
||||
|
||||
# ======================================================================
|
||||
# ACCOUNTS (module `/accounts`) — VERIFIED LIVE (read)
|
||||
# ======================================================================
|
||||
def list_accounts(self, page: int = 1, per_page: int = 100) -> dict:
|
||||
"""List GravityZone console accounts/users (accounts.getAccountsList)."""
|
||||
return self._jsonrpc_request(
|
||||
"accounts", "getAccountsList", {"page": page, "perPage": per_page}
|
||||
) or {}
|
||||
|
||||
def get_notifications_settings(self) -> dict:
|
||||
"""Notification configuration (accounts.getNotificationsSettings)."""
|
||||
return self._jsonrpc_request(
|
||||
"accounts", "getNotificationsSettings", {}
|
||||
) or {}
|
||||
|
||||
# ======================================================================
|
||||
# PUSH EVENT SERVICE (module `/push`)
|
||||
# ----------------------------------------------------------------------
|
||||
# `get`/`stats` are read (but error when the service was never configured —
|
||||
# that is an EXPECTED state, not a failure; the CLI handles it cleanly).
|
||||
# `set` is STATE-CHANGING (it configures where GravityZone POSTs security
|
||||
# events) — gate behind --confirm at the call site.
|
||||
# ======================================================================
|
||||
def get_push_settings(self) -> dict:
|
||||
"""Current push event service settings (push.getPushEventSettings)."""
|
||||
return self._jsonrpc_request("push", "getPushEventSettings", {}) or {}
|
||||
|
||||
def get_push_stats(self) -> dict:
|
||||
"""Push event service delivery stats (push.getPushEventStats)."""
|
||||
return self._jsonrpc_request("push", "getPushEventStats", {}) or {}
|
||||
|
||||
def set_push_settings(
|
||||
self,
|
||||
status: int,
|
||||
service_type: str = "jsonRPC",
|
||||
url: Optional[str] = None,
|
||||
require_valid_ssl: bool = True,
|
||||
authorization: Optional[str] = None,
|
||||
subscribe_event_types: Optional[dict] = None,
|
||||
) -> Any:
|
||||
"""Configure the GravityZone Push event service (push.setPushEventSettings).
|
||||
|
||||
`status` (1=on / 0=off) is REQUIRED (verified via validation probe
|
||||
2026-06-21). When enabling, `serviceSettings.url` is the receiver
|
||||
endpoint GravityZone POSTs events to. The nested shape
|
||||
(serviceType/serviceSettings/subscribeToEventTypes) follows Bitdefender's
|
||||
documented push API and is UNVERIFIED beyond `status` on this tenant —
|
||||
confirm the first successful enable against the live response.
|
||||
STATE-CHANGING — gate at the call site behind --confirm.
|
||||
"""
|
||||
params: dict = {"status": status, "serviceType": service_type}
|
||||
service_settings: dict = {"requireValidSslCertificate": require_valid_ssl}
|
||||
if url is not None:
|
||||
service_settings["url"] = url
|
||||
if authorization is not None:
|
||||
service_settings["authorization"] = authorization
|
||||
params["serviceSettings"] = service_settings
|
||||
if subscribe_event_types is not None:
|
||||
params["subscribeToEventTypes"] = subscribe_event_types
|
||||
return self._jsonrpc_request("push", "setPushEventSettings", params)
|
||||
|
||||
# ======================================================================
|
||||
# CACHE LAYER (identity / structure only — never volatile status)
|
||||
# ======================================================================
|
||||
|
||||
@@ -68,6 +68,19 @@ check("blocklist json", ["blocklist", "--json"], want_rc=0, out_json_ok=True)
|
||||
check("blocklist page2", ["blocklist", "--page", "2", "--per-page", "3"], want_rc=0, out_has="Blocklist items:")
|
||||
check("inventory cached", ["inventory"], want_rc=0, out_has="Inventory cached_at:")
|
||||
|
||||
# --- expanded control surface (read) ---
|
||||
check("reports", ["reports"], want_rc=0, out_has="Reports:")
|
||||
check("reports json", ["reports", "--json"], want_rc=0, out_json_ok=True)
|
||||
check("accounts", ["accounts"], want_rc=0, out_has="Accounts:")
|
||||
check("accounts json", ["accounts", "--json"], want_rc=0, out_json_ok=True)
|
||||
check("notif-settings", ["notif-settings"], want_rc=0)
|
||||
check("scan-tasks", ["scan-tasks"], want_rc=0, out_has="Scan tasks:")
|
||||
# push read: unconfigured tenant must be treated as expected (rc0 + INFO), NOT an error
|
||||
check("push-settings (unconfigured -> rc0)", ["push-settings"], want_rc=0, out_has="not configured")
|
||||
check("push-stats (unconfigured -> rc0)", ["push-stats"], want_rc=0, out_has="not configured")
|
||||
# policy detail must NOT carry the old false 'shallow' warning anymore
|
||||
check("policy no shallow warning", ["policy", "5c42940b6e16d61a0c8b4568"], want_rc=0, err_has=None)
|
||||
|
||||
# --- error handling: a MALFORMED id (not valid hex/ObjectId) makes the API
|
||||
# error, which must exit non-zero (1). Note: a well-formed but non-existent
|
||||
# hex id is ACCEPTED by GravityZone and returns a stub (rc 0) -- that is the
|
||||
@@ -87,6 +100,10 @@ check("blocklist-remove no confirm -> rc3", ["blocklist-remove", "--id", "x"], w
|
||||
check("delete-endpoint no confirm -> rc3", ["delete-endpoint", "x"], want_rc=3)
|
||||
check("delete-package no confirm -> rc3", ["delete-package", "--package", "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, out_has="Would")
|
||||
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)
|
||||
|
||||
# --- raw gating ---
|
||||
check("raw destructive no confirm -> rc3", ["raw", "--module", "network",
|
||||
|
||||
Reference in New Issue
Block a user