From d622a05b84613b4b2c5a6ac84c32535a0fb309b4 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Sun, 21 Jun 2026 10:02:25 -0700 Subject: [PATCH] feat(bitdefender): expand GravityZone control surface + correct policy docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/skills/bitdefender/SKILL.md | 80 ++++++--- .../bitdefender/references/api-reference.md | 71 ++++++-- .claude/skills/bitdefender/scripts/gz.py | 169 +++++++++++++++++- .../skills/bitdefender/scripts/gz_client.py | 121 +++++++++++++ .../skills/bitdefender/scripts/selftest.py | 17 ++ 5 files changed, 417 insertions(+), 41 deletions(-) diff --git a/.claude/skills/bitdefender/SKILL.md b/.claude/skills/bitdefender/SKILL.md index 21f95985..bb147ba4 100644 --- a/.claude/skills/bitdefender/SKILL.md +++ b/.claude/skills/bitdefender/SKILL.md @@ -4,10 +4,12 @@ description: >- Manage the ACG Bitdefender GravityZone Cloud MSP tenant (Public JSON-RPC API): inventory/audit endpoints, live security sweeps (infected / outdated-signature / outdated-product), client companies, install packages, custom groups, scans, - move/delete endpoints (gated), policies (read-only), quarantine. Live production - partner tenant — treat destructive actions conservatively. Triggers: bitdefender, - gravityzone, install bitdefender on, list endpoints, infected machines, av coverage, - security sweep, endpoint protection, quarantine. + move/delete endpoints (gated), policies (full read + assign), reports, accounts, + scan tasks, notifications, push event service, quarantine, EDR (isolate / + blocklist). Live production partner tenant — treat destructive actions + conservatively. Triggers: bitdefender, gravityzone, install bitdefender on, list + endpoints, infected machines, av coverage, security sweep, endpoint protection, + assign policy, quarantine, reports, accounts. --- # Bitdefender GravityZone Skill @@ -64,14 +66,19 @@ PII). cache with the new id immediately, so you don't need a full refresh to reference it. -## Policy API limitation +## Policy control (corrected 2026-06-21) -The Public API exposes policies only shallowly. You CAN list policies, read -their id/name, audit which endpoints carry which policy (via endpoint detail), -and — via the UNVERIFIED `assignPolicy` — assign an existing policy. You CANNOT -read the granular module configuration of a policy, and there is NO create / -edit / clone policy method in the Public API. For policy authoring, use the -GravityZone console. +Earlier docs claimed policy detail was "shallow." That was WRONG. Verified live: + +- **READ — full config.** `policy --json` returns the COMPLETE granular + module configuration (general/antimalware/firewall/content-control/etc.), not + a shallow subset. `policies` lists id+name; `policy ` dumps the full tree. +- **ASSIGN — supported.** `assign-policy --policy --targets ` + pushes an EXISTING policy onto endpoints/groups (gated behind `--confirm`). + Param shape (`policyId` + `targetIds`) verified live. +- **AUTHOR — still console-only.** The Public API has NO create / edit / clone + policy method. You can read and assign, but to CREATE or MODIFY a policy body + you still use the GravityZone console. (This is the one true API limitation.) ## Safety gating @@ -85,6 +92,8 @@ what they would do and exit non-zero: - `unisolate --endpoints ... --confirm` - `blocklist-add --company --hashes ... --confirm` - `blocklist-remove --id --confirm` +- `assign-policy --policy --targets ... --confirm` (applies an existing policy to endpoints/groups) +- `push-set --status 1 --url --confirm` (configures the GravityZone push event service) Never run destructive calls casually against this tenant. UNVERIFIED methods (assignPolicy, uninstall/reconfigure tasks, quarantine remove/restore, set @@ -114,9 +123,22 @@ $GZ endpoint $GZ sweep --company # readable table $GZ sweep --company --json # machine output -# Policies (read-only, shallow) +# Policies (full read + assign; authoring is console-only) $GZ policies -$GZ policy +$GZ policy --json # FULL granular config +$GZ assign-policy --policy --targets ... --confirm + +# Reports / accounts / scan tasks / notifications (read) +$GZ reports +$GZ accounts +$GZ scan-tasks +$GZ notif-settings + +# Push event service (event-driven alerts instead of polling) +$GZ push-settings # current config (or "not configured") +$GZ push-stats +$GZ push-set --status 1 --url https:// --confirm # enable +$GZ push-set --status 0 --confirm # disable # Quarantine $GZ quarantine --company @@ -149,14 +171,32 @@ $GZ raw --module network --method getEndpointsList --params '{"page":1,"perPage" $GZ delete-endpoint --confirm ``` -## Phase-2 hooks (not yet implemented) +## Enabled API scopes (live key, 2026-06-21) -- **GuruRMM push-deploy:** use `install-links` to fetch the platform installer - URL, then push the installer to a target via the GuruRMM agent fleet (`/rmm`) - for one-step Bitdefender rollout from RMM. -- **Push webhook:** subscribe to GravityZone Push events (new malware / - endpoint state changes) and surface them through the coord API / RMM alerts - instead of polling `sweep`. +`companies, licensing, packages, network, integrations, policies, +maintenancewindows, reports, accounts, incidents, push, quarantine, phasr, +patchmanagement`. + +**Wrapped & verified:** companies, licensing, packages, network (endpoints/ +groups/scan/move/delete/assignPolicy), policies (read+assign), reports, +accounts, incidents (blocklist + isolate), quarantine, push (get/stats/set). +**Dead on this tenant (license/feature OFF — `raw` only, returns errors):** +`patchmanagement`, `phasr`, `maintenancewindows`, `integrations`. `incidents. +getIncidentsList` returns "Method not found" (blocklist + isolate on the same +module DO work). + +## Phase-2 hooks + +- **Push webhook (half-built):** `push-set` now configures the GravityZone push + event service over the API (verified the `status` param + gating). Remaining: + stand up the RECEIVER — an HTTPS endpoint (coord API or an RMM route) that + accepts GravityZone's event POSTs and fans them into coord/RMM alerts — then + `push-set --status 1 --url --confirm`. Without a receiver URL there + is nothing to enable yet. +- **GuruRMM push-deploy (not yet wired):** use `install-links` to fetch the + platform installer URL, then push the installer to a target via the GuruRMM + agent fleet (`/rmm`) for one-step Bitdefender rollout from RMM. Needs the + cross-skill call into `/rmm` against a chosen agent. ## Reference diff --git a/.claude/skills/bitdefender/references/api-reference.md b/.claude/skills/bitdefender/references/api-reference.md index fe6f960b..3ace2359 100644 --- a/.claude/skills/bitdefender/references/api-reference.md +++ b/.claude/skills/bitdefender/references/api-reference.md @@ -96,20 +96,50 @@ In `getNetworkInventoryItems` results, `type == 1` denotes a company node. | `getInstallationLinks` | `packageName, companyId?` | VERIFIED | Returns Windows / Mac / Linux installer download URLs for a package. | | `deletePackage` | `packageName, companyId?` | VERIFIED (destructive) | Delete a package. CLI-gated behind `--confirm`. | -## policies (`/policies`) — READ ONLY, SHALLOW +## policies (`/policies`) — FULL READ + ASSIGN (authoring is console-only) -> **Important limitation:** The Public API exposes policies only at a shallow -> level. `getPolicyDetails` returns id / name / a limited subset of settings — -> **NOT** the granular module configuration shown in the console. There is **no -> create / edit / clone** policy method in the Public API. You can: list -> policies, read their names/ids, and (via the UNVERIFIED `assignPolicy`) assign -> an existing policy to endpoints. You CANNOT author or modify policy bodies -> programmatically. +> **Corrected 2026-06-21:** the earlier "shallow only" claim was WRONG. +> `getPolicyDetails` returns the COMPLETE granular module configuration +> (general/antimalware/firewall/content-control/etc.), confirmed live on a real +> policy. You CAN: list policies, read the full config, and assign an existing +> policy to endpoints/groups (`network.assignPolicy`, param shape now verified). +> You still CANNOT **create / edit / clone** a policy body via the Public API — +> authoring stays in the GravityZone console. | Method | Params | Status | Notes | |---|---|---|---| | `getPoliciesList` | `page?, perPage?` | VERIFIED | List policies (id, name). | -| `getPolicyDetails` | `policyId` | VERIFIED | Shallow detail only. Not the full config. | +| `getPolicyDetails` | `policyId` | VERIFIED | **Full** granular config (not shallow). | +| `assignPolicy` (`/network`) | `policyId, targetIds[], forcePolicyInheritance?` | VERIFIED LIVE (param shape) | Assign existing policy to endpoints/groups. Param shape confirmed via validation probe 2026-06-21. CLI `assign-policy`, gated. STATE-CHANGING. | + +## reports (`/reports`) — VERIFIED LIVE + +| Method | Params | Status | Notes | +|---|---|---|---| +| `getReportsList` | `page?, perPage?` | VERIFIED LIVE | List saved reports. CLI `reports`. | +| `createReport` | `name, type, targetIds, ...` | param `name` required (probed) | Not yet a dedicated CLI command — `raw` only. | +| `getDownloadLinks` | `reportId` *(candidate)* | UNVERIFIED param | Report download links. Client helper `get_report_links`. | + +## accounts (`/accounts`) — VERIFIED LIVE (read) + +| Method | Params | Status | Notes | +|---|---|---|---| +| `getAccountsList` | `page?, perPage?` | VERIFIED LIVE | List console accounts/users. CLI `accounts`. | +| `getNotificationsSettings` | `{}` | VERIFIED LIVE | Notification config. CLI `notif-settings`. | +| `createAccount` / `updateAccount` / `deleteAccount` | uncertain | UNVERIFIED (state-changing) | Not exposed; `raw` only after confirming shape. | + +## push (`/push`) — event push service (VERIFIED reachable) + +> Powers event-driven alerting (GravityZone POSTs security events to a receiver +> you specify) instead of polling `sweep`. `get`/`stats` error with "…were not +> set" until configured — that is an EXPECTED unconfigured state, handled cleanly +> by the CLI (rc0 + INFO), NOT a failure. + +| Method | Params | Status | Notes | +|---|---|---|---| +| `getPushEventSettings` | `{}` | VERIFIED LIVE | Current settings. CLI `push-settings`. | +| `getPushEventStats` | `{}` | VERIFIED LIVE | Delivery stats. CLI `push-stats`. | +| `setPushEventSettings` | `status (req), serviceType, serviceSettings{url,requireValidSslCertificate,authorization}, subscribeToEventTypes?` | `status` VERIFIED (probe); nested shape UNVERIFIED | Configure the service. CLI `push-set`, gated. STATE-CHANGING. Needs a receiver URL. ## quarantine (`/quarantine`) @@ -142,15 +172,18 @@ In `getNetworkInventoryItems` results, `type == 1` denotes a company node. | `getCustomRulesList` | uncertain | UNVERIFIED | Not implemented. `raw` only. | | `deleteCustomRule` | uncertain | UNVERIFIED (destructive) | Not implemented. `raw` only. | -## Other modules — raw-reachable only +## Dead / unavailable modules on this tenant (probed 2026-06-21) -The following modules are reachable via `raw --module ` but have no -dedicated CLI methods and no verified signatures here: +In the API-key scope but NOT usable — calls return "not available" / "method +not found". Do not build against these without a license/feature change: -- `patchmanagement` — raw only. NOTE: the patchmanagement / PHASR license - features are OFF on this tenant, so these calls will not return useful data. -- `integrations` — raw only, UNVERIFIED. -- `maintenancewindows` — raw only, UNVERIFIED. +- `patchmanagement` — license OFF (`managePatchManagement: false`). "not available". +- `phasr` — license/feature OFF. `getStatus` → method not found. +- `maintenancewindows` — `getMaintenanceWindows(List)` → "not available". +- `integrations` — `getPSAIntegrationList` → method not found (correct method + name unconfirmed). +- `incidents.getIncidentsList` — "Method not found" (yet `getBlocklistItems` and + the isolate tasks on the SAME module work — likely an EDR sub-feature gate). --- @@ -169,7 +202,11 @@ quarantine.getQuarantineItemsList, incidents.getBlocklistItems, incidents.createIsolateEndpointTask (gated), incidents.createRestoreEndpointFromIsolationTask (gated), incidents.addToBlocklist (gated), incidents.removeFromBlocklist (gated; -param name UNVERIFIED). +param name UNVERIFIED), network.getScanTasksList, network.assignPolicy (gated; +param shape verified 2026-06-21), reports.getReportsList, accounts.getAccountsList, +accounts.getNotificationsSettings, push.getPushEventSettings, +push.getPushEventStats, push.setPushEventSettings (gated; `status` verified, +nested shape UNVERIFIED). > NOTE: `incidents.getIncidentsList` is wired into the CLI (`incidents` > subcommand) but returned `Method not found` on live re-test (2026-05-30) — diff --git a/.claude/skills/bitdefender/scripts/gz.py b/.claude/skills/bitdefender/scripts/gz.py index 6b8e23a7..f638107c 100644 --- a/.claude/skills/bitdefender/scripts/gz.py +++ b/.claude/skills/bitdefender/scripts/gz.py @@ -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, diff --git a/.claude/skills/bitdefender/scripts/gz_client.py b/.claude/skills/bitdefender/scripts/gz_client.py index 1330a3b0..c051c296 100644 --- a/.claude/skills/bitdefender/scripts/gz_client.py +++ b/.claude/skills/bitdefender/scripts/gz_client.py @@ -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) # ====================================================================== diff --git a/.claude/skills/bitdefender/scripts/selftest.py b/.claude/skills/bitdefender/scripts/selftest.py index ed41fbf5..0522259d 100644 --- a/.claude/skills/bitdefender/scripts/selftest.py +++ b/.claude/skills/bitdefender/scripts/selftest.py @@ -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",