feat(bitdefender): GravityZone Cloud Public API skill

Adds a /bitdefender skill that drives the ACG GravityZone partner tenant
via the JSON-RPC Public API. Read + management ops (companies, endpoints,
live security sweep, policies [read-only/shallow], packages, quarantine,
scans, groups, move/delete). Identity-tier JSON cache (24h TTL,
--refresh); volatile status is always pulled live, never cached.

Security hardening: API key loaded from SOPS vault at runtime (never on
disk/logs/argv/cache); destructive deletes gated behind --confirm; `raw`
also gates destructive methods; upstream error bodies truncated. UNVERIFIED
API methods reachable only via `raw`. Reuses the auth/JSON-RPC pattern from
api/services/gravityzone_service.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 00:31:25 -07:00
parent dfa7af4aee
commit 8ba92bf02b
5 changed files with 1276 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
---
name: bitdefender
description: >-
Manage the Arizona Computer Guru (ACG) Bitdefender GravityZone Cloud MSP
tenant via the Public JSON-RPC API. Inventory and audit endpoints, run live
security sweeps (infected / outdated-signature / outdated-product), list
client companies, build and fetch installation packages, manage custom groups,
start scans, move/delete endpoints (gated), inspect policies (read-only,
shallow), and review quarantine. Invoke for: "bitdefender", "gravityzone",
"gravity zone", "add machine to bitdefender", "install bitdefender on",
"list endpoints", "infected machines", "av coverage", "security sweep",
"endpoint protection", "policy assignment", "quarantine". This skill talks to
the real production ACG GravityZone partner tenant — treat destructive actions
conservatively.
---
# Bitdefender GravityZone Skill
Standalone CLI client for the GravityZone Cloud Public API (JSON-RPC). Talks to
the live ACG partner tenant. Read-only by default; destructive operations are
gated behind `--confirm`.
## Running the CLI
This machine's Python launcher is `py` (per identity.json). The scripts also
work with `python`/`python3`.
```bash
# from the scripts dir, or pass full paths
py "C:/claudetools/.claude/skills/bitdefender/scripts/gz.py" status
py "C:/claudetools/.claude/skills/bitdefender/scripts/gz.py" companies
py "C:/claudetools/.claude/skills/bitdefender/scripts/gz.py" sweep --company <id> --json
```
Transport auto-selects: uses `httpx` if installed, otherwise stdlib `urllib`
(no third-party dependency required).
## Credentials
The API key is NEVER hardcoded. At runtime the client loads it from the SOPS
vault:
```
bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \
get-field msp-tools/gravityzone.sops.yaml credentials.api_key
```
`CLAUDETOOLS_ROOT` resolves from the env var, else `claudetools_root` in
`C:/claudetools/.claude/identity.json`, else `C:/claudetools`. For testing you
can override with `GRAVITYZONE_API_KEY`. Auth is HTTP Basic (key as username,
empty password).
## Cache model (important)
The CLI keeps a local cache at
`.claude/skills/bitdefender/.cache/inventory.json` (gitignored — no secrets, no
PII).
- **Cached (identity / structure tier):** company id<->name map, endpoint
id<->name/company/fqdn map, policy id<->name map, package list, and custom
groups created via this tool. TTL = 86400s (24h).
- **NEVER cached (volatile):** infected status, last-seen, online/offline,
signature/product freshness. Those are ALWAYS pulled live — `sweep` and
`endpoint` always hit the API.
- **Refresh:** `inventory --refresh` forces a full re-pull. `get_inventory()`
auto-refreshes when the cache is stale.
- **Write-through:** a successful `create-package` or `make-group` updates the
cache with the new id immediately, so you don't need a full refresh to
reference it.
## Policy API limitation
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.
## Safety gating
Destructive subcommands refuse to run without `--confirm`; without it they print
what they would do and exit non-zero:
- `delete-endpoint <id> --confirm`
- `delete-package --package <name> --confirm`
- `delete-group --group <id> --confirm`
Never run destructive calls casually against this tenant. UNVERIFIED methods
(assignPolicy, uninstall/reconfigure tasks, quarantine remove/restore, set
label) are intentionally NOT exposed as dedicated subcommands — reach them only
through `raw` after confirming the correct params against
`references/api-reference.md` and the official Bitdefender docs.
## Common commands
```bash
GZ="py C:/claudetools/.claude/skills/bitdefender/scripts/gz.py"
# Status / inventory
$GZ status
$GZ companies
$GZ inventory --refresh
$GZ endpoints --company <companyId>
$GZ endpoint <endpointId>
# Live security posture
$GZ sweep --company <companyId> # readable table
$GZ sweep --company <companyId> --json # machine output
# Policies (read-only, shallow)
$GZ policies
$GZ policy <policyId>
# Quarantine
$GZ quarantine --company <companyId>
# Deployment
$GZ packages
$GZ create-package --name "Win Default" --company <companyId>
$GZ install-links --package "Win Default" --company <companyId>
# Org structure
$GZ make-group --name "New Site" --parent <parentId>
$GZ move --endpoints <id1> <id2> --group <groupId>
# Scans
$GZ scan --targets <id1> <id2> --type 2 --name "Full scan"
# Power use — call any method directly
$GZ raw --module network --method getEndpointsList --params '{"page":1,"perPage":50}'
# Destructive (gated)
$GZ delete-endpoint <id> --confirm
```
## Phase-2 hooks (not yet implemented)
- **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`.
## Reference
Full verified vs unverified method spec, JSON-RPC envelope, auth, and the
policy/deployment caveats: `references/api-reference.md`.

View File

@@ -0,0 +1,145 @@
# Bitdefender GravityZone Cloud Public API Reference
Verified spec for the methods used by this skill. Sourced from Bitdefender's
archived Public API documentation. Methods are flagged **VERIFIED** (signature
confirmed and exposed in the CLI) or **UNVERIFIED** (signature not confirmed —
callable only via the generic `raw` subcommand, never exposed as a dedicated CLI
command).
---
## Connection
- **Base URL:** `https://cloud.gravityzone.bitdefender.com/api/v1.0/jsonrpc`
- **Module endpoint:** `<base>/<module>` (e.g. `.../jsonrpc/network`)
- **Auth:** HTTP Basic. Username = API key, password = empty string `""`.
- **Transport:** HTTPS POST, `Content-Type: application/json`.
### JSON-RPC envelope (request)
```json
{
"id": "1",
"jsonrpc": "2.0",
"method": "<methodName>",
"params": { ...method params... }
}
```
### Response
- **Success:** body has a `result` field. The skill returns `body["result"]`.
- **Error:** body has an `error` object. The skill surfaces
`error.data.details` if present, else `error.message`.
```json
{ "error": { "code": -32602, "message": "...", "data": { "details": "..." } } }
```
---
## ACG tenant IDs (hardcoded, partner root)
| Constant | Value | Meaning |
|---|---|---|
| `ACG_ROOT_COMPANY_ID` | `5c4280716c0318f3478b456a` | ACG partner company root |
| `ACG_COMPANIES_CONTAINER_ID` | `5c4280716c0318f3478b456e` | Container holding all client companies |
In `getNetworkInventoryItems` results, `type == 1` denotes a company node.
---
## general (`/general`)
| Method | Params | Status | Notes |
|---|---|---|---|
| `getApiKeyDetails` | `{}` | VERIFIED | Key scopes / rights. |
## licensing (`/licensing`)
| Method | Params | Status | Notes |
|---|---|---|---|
| `getLicenseInfo` | `{}` | VERIFIED | Seats, expiry, usage. |
## companies (`/companies`)
| Method | Params | Status | Notes |
|---|---|---|---|
| `getCompanyDetails` | `{}` or `{companyId}` | VERIFIED | Own company when no arg; a specific company when `companyId` given. |
| `getCompanyDetailsByUser` | uncertain | UNVERIFIED | Param shape not confirmed. Use `raw` if needed. |
## network (`/network`)
| Method | Params | Status | Notes |
|---|---|---|---|
| `getNetworkInventoryItems` | `parentId?, page?, perPage?, filters?` | VERIFIED | Inventory tree: companies/groups/endpoints. `type==1` = company. |
| `getEndpointsList` | `parentId?, page?, perPage<=100, filters?, options?` | VERIFIED | Endpoint list under a parent. `filters` supports name / OS / security status / policy. |
| `getManagedEndpointDetails` | `endpointId, options?` | VERIFIED | Full detail: `malwareStatus`, `agent{productVersion,engineVersion,signatureOutdated,productOutdated,lastUpdate}`, `modules`, `state`, `policy`, `companyId`, `lastSeen`. |
| `getScanTasksList` | `name?, status?, page?, perPage?` | VERIFIED | List scan tasks. |
| `createScanTask` | `targetIds[], type, name?, customScanSettings?` | VERIFIED | Start a scan. `type`: 1=Quick, 2=Full, 3=Memory, 4=Custom (verify against console). |
| `moveEndpoints` | `endpointIds[], groupId` | VERIFIED | Move endpoints into a group. |
| `createCustomGroup` | `name, parentId?` | VERIFIED | Create a custom group; returns new group id. |
| `deleteEndpoint` | `endpointId` | VERIFIED (destructive) | Remove an endpoint from inventory. CLI-gated behind `--confirm`. |
| `deleteCustomGroup` | `groupId` | VERIFIED (destructive) | Delete a custom group. CLI-gated behind `--confirm`. |
| `moveCustomGroup` | `groupId, newParentId` | VERIFIED | Re-parent a custom group. |
| `assignPolicy` | uncertain | UNVERIFIED | Assigns an EXISTING policy to endpoints. Param names not confirmed (likely `policyId` + a targets list). Do NOT use blindly — confirm against archived docs first. `raw` only. |
| `createReconfigureClientTask` | uncertain | UNVERIFIED | Param shape not confirmed. `raw` only. |
| `createUninstallTask` | uncertain | UNVERIFIED | Destructive; param shape not confirmed. `raw` only. |
| `setEndpointLabel` | uncertain | UNVERIFIED | Param shape not confirmed. `raw` only. |
## packages (`/packages`)
| Method | Params | Status | Notes |
|---|---|---|---|
| `getPackagesList` | `page?, perPage<=100` | VERIFIED | List installation packages. |
| `createPackage` | `packageName, companyId?, description?, language?, modules?, scanMode?, settings?, roles?, deploymentOptions?` | VERIFIED | Create an installer package. Returns the new package id. |
| `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
> **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.
| Method | Params | Status | Notes |
|---|---|---|---|
| `getPoliciesList` | `page?, perPage?` | VERIFIED | List policies (id, name). |
| `getPolicyDetails` | `policyId` | VERIFIED | Shallow detail only. Not the full config. |
## quarantine (`/quarantine`)
| Method | Params | Status | Notes |
|---|---|---|---|
| `getQuarantineItemsList` | `parentId, page, perPage, filters?` | VERIFIED | List quarantined items under a parent. |
| `createRemoveQuarantineItemTask` | uncertain | UNVERIFIED (destructive) | Param shape not confirmed. `raw` only. |
| `createRestoreQuarantineItemTask` | uncertain | UNVERIFIED | Param shape not confirmed. `raw` only. |
---
## Verified vs Unverified summary
**VERIFIED (CLI-exposed):**
general.getApiKeyDetails, licensing.getLicenseInfo, companies.getCompanyDetails,
network.getNetworkInventoryItems, network.getEndpointsList,
network.getManagedEndpointDetails, network.getScanTasksList,
network.createScanTask, network.createCustomGroup, network.moveEndpoints,
network.moveCustomGroup, network.deleteEndpoint (gated),
network.deleteCustomGroup (gated), packages.getPackagesList,
packages.createPackage, packages.getInstallationLinks, packages.deletePackage
(gated), policies.getPoliciesList, policies.getPolicyDetails,
quarantine.getQuarantineItemsList.
**UNVERIFIED (raw subcommand only — do NOT trust the param shape):**
network.assignPolicy, network.createReconfigureClientTask,
network.createUninstallTask, network.setEndpointLabel,
companies.getCompanyDetailsByUser, quarantine.createRemoveQuarantineItemTask,
quarantine.createRestoreQuarantineItemTask.
Confirm any UNVERIFIED signature against the official Bitdefender API reference
before relying on it. The generic `raw --module M --method m --params '<json>'`
subcommand can call any method once you know the correct params.

View File

@@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""CLI for the bitdefender skill — GravityZone Cloud Public API.
Read-only subcommands run freely. Destructive subcommands (delete-endpoint,
delete-package, delete-group) refuse to run unless --confirm is passed; without
it they print what they WOULD do and exit non-zero.
Output: --json emits raw JSON; otherwise a readable table/summary.
Usage examples:
python gz.py status
python gz.py companies
python gz.py endpoints --company 5c4280716c0318f3478b456e
python gz.py endpoint <endpointId>
python gz.py sweep --company <id>
python gz.py policies
python gz.py policy <policyId>
python gz.py packages
python gz.py quarantine --company <id>
python gz.py inventory --refresh
python gz.py create-package --name "Win Default" --company <id>
python gz.py install-links --package "Win Default" --company <id>
python gz.py scan --targets <id1> <id2> --type 2 --name "Full scan"
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 raw --module network --method getEndpointsList --params '{"page":1}'
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import sys
from gz_client import GravityZoneClient, GravityZoneError, GZEndpointSummary
def _emit(obj, as_json: bool, table_fn=None) -> None:
if as_json or table_fn is None:
print(json.dumps(obj, indent=2, default=_json_default))
else:
table_fn(obj)
def _json_default(o):
if dataclasses.is_dataclass(o):
return dataclasses.asdict(o)
return str(o)
# --- table renderers ----------------------------------------------------------
def _print_kv(d: dict) -> None:
for k, v in d.items():
print(f" {k}: {v}")
def _print_company_table(data: dict) -> None:
items = data.get("items", [])
print(f"Companies: {data.get('total', len(items))}")
for c in items:
print(f" {c.get('id','?'):26} {c.get('name','')}")
def _print_endpoint_table(data: dict) -> None:
items = data.get("items", [])
print(f"Endpoints: {data.get('total', len(items))}")
for e in items:
print(f" {e.get('id','?'):26} {e.get('name',''):30} "
f"{e.get('operatingSystemVersion', e.get('os',''))}")
def _print_sweep_table(summaries: list) -> None:
print(f"Endpoints swept: {len(summaries)}")
print(f" {'STATUS':10} {'NAME':30} {'AGENT':14} {'LAST SEEN'}")
for s in summaries:
flags = []
if s.infected:
flags.append("INFECTED")
if s.signature_outdated:
flags.append("SIG-OLD")
if s.product_outdated:
flags.append("PROD-OLD")
status = ",".join(flags) if flags else "OK"
print(f" {status:10} {s.name[:30]:30} {str(s.agent_version or '-'):14} "
f"{s.last_seen or '-'}")
def _print_policy_table(data: dict) -> None:
items = data.get("items", [])
print(f"Policies: {data.get('total', len(items))}")
for p in items:
print(f" {p.get('id','?'):26} {p.get('name','')}")
def _print_package_table(data: dict) -> None:
items = data.get("items", [])
print(f"Packages: {data.get('total', len(items))}")
for p in items:
print(f" {str(p.get('id','?')):26} {p.get('name','')}")
def _print_quarantine_table(data: dict) -> None:
items = data.get("items", [])
print(f"Quarantine items: {data.get('total', len(items))}")
for q in items:
print(f" {q.get('threatName','?'):30} {q.get('endpointName','')} "
f"{q.get('detectionTime','')}")
def _print_inventory_table(cache: dict) -> None:
print(f"Inventory cached_at: {cache.get('fetched_at')}")
print(f" companies: {len(cache.get('companies', {}))}")
print(f" endpoints: {len(cache.get('endpoints', {}))}")
print(f" policies: {len(cache.get('policies', {}))}")
print(f" packages: {len(cache.get('packages', []))}")
print(f" groups: {len(cache.get('groups', {}))}")
# --- command handlers ---------------------------------------------------------
def cmd_status(client, args):
_emit(client.get_api_status(), args.json, _print_kv)
def cmd_companies(client, args):
_emit(client.list_companies(), args.json, _print_company_table)
def cmd_endpoints(client, args):
_emit(client.list_endpoints(args.company, per_page=args.per_page),
args.json, _print_endpoint_table)
def cmd_endpoint(client, args):
_emit(client.get_endpoint_details(args.endpoint_id), args.json, _print_kv)
def cmd_sweep(client, args):
target = args.company or _require_company_for_sweep()
summaries = client.security_sweep(target)
if args.json:
print(json.dumps([dataclasses.asdict(s) for s in summaries], indent=2))
else:
_print_sweep_table(summaries)
def _require_company_for_sweep() -> str:
from gz_client import ACG_COMPANIES_CONTAINER_ID
print("[INFO] No --company given; sweeping the ACG companies container.",
file=sys.stderr)
return ACG_COMPANIES_CONTAINER_ID
def cmd_policies(client, args):
_emit(client.list_policies(), args.json, _print_policy_table)
def cmd_policy(client, args):
print("[WARNING] Public API returns shallow policy detail only "
"(no granular config).", file=sys.stderr)
_emit(client.get_policy_details(args.policy_id), args.json, _print_kv)
def cmd_packages(client, args):
_emit(client.list_packages(), args.json, _print_package_table)
def cmd_quarantine(client, args):
_emit(client.list_quarantine(args.company), args.json, _print_quarantine_table)
def cmd_inventory(client, args):
_emit(client.get_inventory(refresh=args.refresh), args.json,
_print_inventory_table)
def cmd_create_package(client, args):
result = client.create_package(
package_name=args.name,
company_id=args.company,
description=args.description,
language=args.language,
)
_emit({"created": args.name, "result": result}, args.json, _print_kv)
def cmd_install_links(client, args):
_emit(client.get_installation_links(args.package, args.company),
args.json, _print_kv)
def cmd_scan(client, args):
result = client.create_scan_task(
target_ids=args.targets, scan_type=args.type, name=args.name
)
_emit({"scanTask": result}, args.json, _print_kv)
def cmd_move(client, args):
result = client.move_endpoints(args.endpoints, args.group)
_emit({"moved": args.endpoints, "to": args.group, "result": result},
args.json, _print_kv)
def cmd_make_group(client, args):
result = client.create_custom_group(args.name, args.parent)
_emit({"createdGroup": args.name, "result": result}, args.json, _print_kv)
def cmd_raw(client, args):
try:
params = json.loads(args.params) if args.params else {}
except json.JSONDecodeError as exc:
print(f"[ERROR] --params is not valid JSON: {exc}", file=sys.stderr)
return 2
if not isinstance(params, dict):
print("[ERROR] --params must be a JSON object.", file=sys.stderr)
return 2
result = client._jsonrpc_request(args.module, args.method, params)
print(json.dumps(result, indent=2, default=_json_default))
return 0
# --- destructive (gated) ------------------------------------------------------
def _gated(action_desc: str, confirm: bool) -> bool:
if not confirm:
print(f"[WARNING] Refusing destructive action without --confirm.")
print(f"[INFO] Would: {action_desc}")
return False
return True
def cmd_delete_endpoint(client, args):
if not _gated(f"delete endpoint {args.endpoint_id}", args.confirm):
return 3
result = client.delete_endpoint(args.endpoint_id)
_emit({"deletedEndpoint": args.endpoint_id, "result": result},
args.json, _print_kv)
return 0
def cmd_delete_package(client, args):
if not _gated(f"delete package '{args.package}'", args.confirm):
return 3
result = client.delete_package(args.package, args.company)
_emit({"deletedPackage": args.package, "result": result}, args.json, _print_kv)
return 0
def cmd_delete_group(client, args):
if not _gated(f"delete custom group {args.group}", args.confirm):
return 3
result = client.delete_custom_group(args.group)
_emit({"deletedGroup": args.group, "result": result}, args.json, _print_kv)
return 0
# --- parser -------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="gz.py",
description="GravityZone Cloud Public API CLI (ACG MSP tenant).",
)
p.add_argument("--json", action="store_true", help="Emit raw JSON output.")
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser("status", help="API key + license status.")
sub.add_parser("companies", help="List client companies.")
sp = sub.add_parser("endpoints", help="List endpoints under a company/group.")
sp.add_argument("--company", help="Parent company/group id.")
sp.add_argument("--per-page", type=int, default=100)
sp = sub.add_parser("endpoint", help="Full detail for one endpoint.")
sp.add_argument("endpoint_id")
sp = sub.add_parser("sweep", help="Live security posture sweep.")
sp.add_argument("--company", help="Parent id (defaults to ACG container).")
sub.add_parser("policies", help="List policies (id + name).")
sp = sub.add_parser("policy", help="Shallow detail for one policy.")
sp.add_argument("policy_id")
sub.add_parser("packages", help="List installation packages.")
sp = sub.add_parser("quarantine", help="List quarantine items for a company.")
sp.add_argument("--company", required=True)
sp = sub.add_parser("inventory", help="Show cached identity/structure.")
sp.add_argument("--refresh", action="store_true", help="Force a full re-pull.")
sp = sub.add_parser("create-package", help="Create an installer package.")
sp.add_argument("--name", required=True)
sp.add_argument("--company")
sp.add_argument("--description")
sp.add_argument("--language")
sp = sub.add_parser("install-links", help="Get installer download URLs.")
sp.add_argument("--package", required=True)
sp.add_argument("--company")
sp = sub.add_parser("scan", help="Create a scan task.")
sp.add_argument("--targets", nargs="+", required=True)
sp.add_argument("--type", type=int, required=True,
help="1=Quick 2=Full 3=Memory 4=Custom (verify in console).")
sp.add_argument("--name")
sp = sub.add_parser("move", help="Move endpoints into a group.")
sp.add_argument("--endpoints", nargs="+", required=True)
sp.add_argument("--group", required=True)
sp = sub.add_parser("make-group", help="Create a custom group.")
sp.add_argument("--name", required=True)
sp.add_argument("--parent")
sp = sub.add_parser("raw", help="Call any method directly (power use).")
sp.add_argument("--module", required=True)
sp.add_argument("--method", required=True)
sp.add_argument("--params", default="{}", help="JSON object of params.")
# destructive (gated)
sp = sub.add_parser("delete-endpoint", help="Delete an endpoint (gated).")
sp.add_argument("endpoint_id")
sp.add_argument("--confirm", action="store_true")
sp = sub.add_parser("delete-package", help="Delete a package (gated).")
sp.add_argument("--package", required=True)
sp.add_argument("--company")
sp.add_argument("--confirm", action="store_true")
sp = sub.add_parser("delete-group", help="Delete a custom group (gated).")
sp.add_argument("--group", required=True)
sp.add_argument("--confirm", action="store_true")
return p
HANDLERS = {
"status": cmd_status,
"companies": cmd_companies,
"endpoints": cmd_endpoints,
"endpoint": cmd_endpoint,
"sweep": cmd_sweep,
"policies": cmd_policies,
"policy": cmd_policy,
"packages": cmd_packages,
"quarantine": cmd_quarantine,
"inventory": cmd_inventory,
"create-package": cmd_create_package,
"install-links": cmd_install_links,
"scan": cmd_scan,
"move": cmd_move,
"make-group": cmd_make_group,
"raw": cmd_raw,
"delete-endpoint": cmd_delete_endpoint,
"delete-package": cmd_delete_package,
"delete-group": cmd_delete_group,
}
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
handler = HANDLERS[args.command]
try:
client = GravityZoneClient()
rc = handler(client, args)
return rc if isinstance(rc, int) else 0
except GravityZoneError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,603 @@
#!/usr/bin/env python3
"""GravityZone Cloud Public API client for the bitdefender skill.
Standalone (does not import the api/ service). Reuses the proven JSON-RPC
shape, HTTP Basic auth (api_key as username, empty password), and the ACG
hardcoded tenant IDs.
Transport: prefers httpx if installed, else falls back to stdlib urllib so the
script has no hard third-party dependency.
Credentials: never hardcoded. Loaded at runtime from the SOPS vault, or from
the GRAVITYZONE_API_KEY env var (testing override).
Cache: only the IDENTITY/STRUCTURE tier is cached (company/endpoint/policy
id<->name maps, package list). Volatile status (infected, lastSeen, online,
signature freshness) is NEVER cached and always pulled live.
"""
from __future__ import annotations
import base64
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
# --- optional httpx -----------------------------------------------------------
# urllib (stdlib) is always imported as the fallback transport; httpx is used
# when present for connection pooling/timeouts.
try:
import httpx # type: ignore
_HAS_HTTPX = True
except ImportError: # pragma: no cover - depends on environment
_HAS_HTTPX = False
# Cap upstream error bodies surfaced in exceptions. The GravityZone key never
# appears here, but `raw` can call arbitrary methods whose responses may reflect
# other data — bound the blast radius rather than echo full bodies into logs.
ERROR_BODY_MAX_CHARS = 500
# --- constants ----------------------------------------------------------------
GRAVITYZONE_API_BASE_URL = os.environ.get(
"GRAVITYZONE_API_BASE_URL",
"https://cloud.gravityzone.bitdefender.com/api/v1.0/jsonrpc",
)
GRAVITYZONE_TIMEOUT_SECONDS = 60.0
GRAVITYZONE_CONNECT_TIMEOUT_SECONDS = 10.0
ACG_ROOT_COMPANY_ID = "5c4280716c0318f3478b456a"
ACG_COMPANIES_CONTAINER_ID = "5c4280716c0318f3478b456e"
VAULT_ENTRY = "msp-tools/gravityzone.sops.yaml"
VAULT_FIELD = "credentials.api_key"
CACHE_TTL_SECONDS = 86400
SKILL_DIR = Path(__file__).resolve().parent.parent
CACHE_DIR = SKILL_DIR / ".cache"
CACHE_FILE = CACHE_DIR / "inventory.json"
class GravityZoneError(RuntimeError):
"""Raised for transport or JSON-RPC errors."""
@dataclass
class GZEndpointSummary:
endpoint_id: str
name: str
company_id: str
infected: bool
detection_active: bool
signature_outdated: bool
product_outdated: bool
last_seen: Optional[str]
agent_version: Optional[str]
state: int
# --- credential loading -------------------------------------------------------
def _resolve_claudetools_root() -> Path:
"""Resolve the ClaudeTools repo root: env var, then identity.json, then derived path.
Final fallback is derived from this file's location
(.claude/skills/bitdefender/scripts -> repo root) so it works on the
Mac/Linux fleet machines, not only the Windows default.
"""
# SKILL_DIR = .../.claude/skills/bitdefender ; root is three levels up.
derived_root = SKILL_DIR.parent.parent.parent
env_root = os.environ.get("CLAUDETOOLS_ROOT")
if env_root:
return Path(env_root)
identity_path = derived_root / ".claude" / "identity.json"
if identity_path.exists():
try:
data = json.loads(identity_path.read_text(encoding="utf-8"))
root = data.get("claudetools_root")
if root:
return Path(root)
except (json.JSONDecodeError, OSError):
pass
return derived_root
def load_api_key() -> str:
"""Load the GravityZone API key.
Order: GRAVITYZONE_API_KEY env override, then the SOPS vault wrapper.
Never returns an empty key — raises if it cannot resolve one.
"""
env_key = os.environ.get("GRAVITYZONE_API_KEY")
if env_key:
return env_key.strip()
root = _resolve_claudetools_root()
vault_script = root / ".claude" / "scripts" / "vault.sh"
if not vault_script.exists():
raise GravityZoneError(
f"Cannot load API key: vault wrapper not found at {vault_script} "
"and GRAVITYZONE_API_KEY is not set."
)
try:
completed = subprocess.run(
["bash", str(vault_script), "get-field", VAULT_ENTRY, VAULT_FIELD],
capture_output=True,
text=True,
timeout=60,
)
except FileNotFoundError as exc:
raise GravityZoneError(
"Cannot load API key: 'bash' not found on PATH. Install Git Bash or "
"set GRAVITYZONE_API_KEY."
) from exc
except subprocess.TimeoutExpired as exc:
raise GravityZoneError("Cannot load API key: vault call timed out.") from exc
if completed.returncode != 0:
raise GravityZoneError(
"Cannot load API key from vault "
f"(exit {completed.returncode}): {completed.stderr.strip()}"
)
key = completed.stdout.strip()
if not key:
raise GravityZoneError("Vault returned an empty API key.")
return key
# --- client -------------------------------------------------------------------
class GravityZoneClient:
def __init__(
self,
api_key: Optional[str] = None,
api_base_url: str = GRAVITYZONE_API_BASE_URL,
timeout: float = GRAVITYZONE_TIMEOUT_SECONDS,
connect_timeout: float = GRAVITYZONE_CONNECT_TIMEOUT_SECONDS,
):
self.api_base_url = api_base_url.rstrip("/")
self._api_key = api_key # lazily loaded if None
self.timeout = timeout
self.connect_timeout = connect_timeout
@property
def api_key(self) -> str:
if not self._api_key:
self._api_key = load_api_key()
return self._api_key
# -- core transport --------------------------------------------------------
def _jsonrpc_request(self, module: str, method: str, params: dict) -> Any:
"""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)
if isinstance(body, dict) and "error" in body and body["error"] is not None:
err = body["error"]
detail = None
if isinstance(err, dict):
detail = (err.get("data") or {}).get("details") or err.get("message")
raise GravityZoneError(
f"GravityZone API error [{module}.{method}]: {detail or err}"
)
if isinstance(body, dict):
return body.get("result")
return body
def _post(self, url: str, payload: dict) -> Any:
data = json.dumps(payload).encode("utf-8")
if _HAS_HTTPX:
try:
timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
with httpx.Client(timeout=timeout) as client:
resp = client.post(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 GravityZoneError(f"GravityZone request timed out: {exc}") from exc
except httpx.HTTPStatusError as exc:
detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS]
raise GravityZoneError(
f"GravityZone HTTP {exc.response.status_code}: {detail}"
) from exc
except httpx.HTTPError as exc:
raise GravityZoneError(f"GravityZone request failed: {exc}") from exc
# stdlib fallback
token = base64.b64encode(f"{self.api_key}:".encode("utf-8")).decode("ascii")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Basic {token}",
},
)
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]
raise GravityZoneError(f"GravityZone HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise GravityZoneError(f"GravityZone request failed: {exc}") from exc
# ======================================================================
# READ METHODS (always live)
# ======================================================================
def get_api_status(self) -> dict:
api_key_details = self._jsonrpc_request("general", "getApiKeyDetails", {}) or {}
# Nest the two responses to avoid silent key collisions on the merge.
status: dict = {"apiKey": api_key_details}
try:
status["license"] = (
self._jsonrpc_request("licensing", "getLicenseInfo", {}) or {}
)
except GravityZoneError as exc:
status["_licenseWarning"] = str(exc)
return status
def get_own_company(self) -> dict:
return self._jsonrpc_request("companies", "getCompanyDetails", {}) or {}
def list_companies(self, page: int = 1, per_page: int = 100) -> dict:
result = self._jsonrpc_request(
"network",
"getNetworkInventoryItems",
{
"parentId": ACG_COMPANIES_CONTAINER_ID,
"page": page,
"perPage": per_page,
},
) or {}
items = [i for i in result.get("items", []) if i.get("type") == 1]
return {"total": len(items), "items": items}
def list_endpoints(
self, parent_id: Optional[str] = None, page: int = 1, per_page: int = 100
) -> dict:
params: dict = {"page": page, "perPage": per_page}
if parent_id:
params["parentId"] = parent_id
return self._jsonrpc_request("network", "getEndpointsList", params) or {}
def get_endpoint_details(self, endpoint_id: str) -> dict:
return self._jsonrpc_request(
"network", "getManagedEndpointDetails", {"endpointId": endpoint_id}
) or {}
def list_policies(self, page: int = 1, per_page: int = 100) -> dict:
return self._jsonrpc_request(
"policies", "getPoliciesList", {"page": page, "perPage": per_page}
) or {}
def get_policy_details(self, policy_id: str) -> dict:
return self._jsonrpc_request(
"policies", "getPolicyDetails", {"policyId": policy_id}
) or {}
def list_packages(self, page: int = 1, per_page: int = 100) -> dict:
return self._jsonrpc_request(
"packages", "getPackagesList", {"page": page, "perPage": per_page}
) or {}
def list_quarantine(
self, parent_id: str, page: int = 1, per_page: int = 100
) -> dict:
return self._jsonrpc_request(
"quarantine",
"getQuarantineItemsList",
{"parentId": parent_id, "page": page, "perPage": per_page},
) or {}
def security_sweep(self, parent_id: str) -> list[GZEndpointSummary]:
"""Live security posture sweep of all endpoints under a parent."""
summaries: list[GZEndpointSummary] = []
page = 1
per_page = 100
while True:
data = self.list_endpoints(parent_id, page=page, per_page=per_page)
items = data.get("items", [])
if not items:
break
for item in items:
endpoint_id = item.get("id", "")
if not endpoint_id:
continue
try:
detail = self.get_endpoint_details(endpoint_id)
except GravityZoneError:
continue
malware = detail.get("malwareStatus", {}) or {}
agent = detail.get("agent", {}) or {}
summaries.append(
GZEndpointSummary(
endpoint_id=endpoint_id,
name=detail.get("name") or item.get("name", ""),
company_id=detail.get("companyId") or item.get("companyId", ""),
infected=bool(malware.get("infected", False)),
detection_active=bool(malware.get("detection", False)),
signature_outdated=bool(agent.get("signatureOutdated", False)),
product_outdated=bool(agent.get("productOutdated", False)),
last_seen=detail.get("lastSeen"),
agent_version=agent.get("productVersion"),
state=detail.get("state", 0),
)
)
total = data.get("total", 0)
if page * per_page >= total:
break
page += 1
summaries.sort(
key=lambda s: (
not s.infected,
not s.signature_outdated,
not s.product_outdated,
s.name.lower(),
)
)
return summaries
# ======================================================================
# MANAGEMENT METHODS (verified only)
# ======================================================================
def create_package(
self,
package_name: str,
company_id: Optional[str] = None,
description: Optional[str] = None,
language: Optional[str] = None,
modules: Optional[dict] = None,
scan_mode: Optional[dict] = None,
settings: Optional[dict] = None,
roles: Optional[dict] = None,
deployment_options: Optional[dict] = None,
) -> Any:
params: dict = {"packageName": package_name}
if company_id:
params["companyId"] = company_id
if description is not None:
params["description"] = description
if language is not None:
params["language"] = language
if modules is not None:
params["modules"] = modules
if scan_mode is not None:
params["scanMode"] = scan_mode
if settings is not None:
params["settings"] = settings
if roles is not None:
params["roles"] = roles
if deployment_options is not None:
params["deploymentOptions"] = deployment_options
result = self._jsonrpc_request("packages", "createPackage", params)
# Write-through: refresh package list in cache.
self._cache_add_package(package_name, result)
return result
def get_installation_links(
self, package_name: str, company_id: Optional[str] = None
) -> Any:
params: dict = {"packageName": package_name}
if company_id:
params["companyId"] = company_id
return self._jsonrpc_request("packages", "getInstallationLinks", params)
def delete_package(
self, package_name: str, company_id: Optional[str] = None
) -> Any:
params: dict = {"packageName": package_name}
if company_id:
params["companyId"] = company_id
return self._jsonrpc_request("packages", "deletePackage", params)
def create_scan_task(
self,
target_ids: list[str],
scan_type: int,
name: Optional[str] = None,
custom_scan_settings: Optional[dict] = None,
) -> Any:
params: dict = {"targetIds": target_ids, "type": scan_type}
if name is not None:
params["name"] = name
if custom_scan_settings is not None:
params["customScanSettings"] = custom_scan_settings
return self._jsonrpc_request("network", "createScanTask", params)
def move_endpoints(self, endpoint_ids: list[str], group_id: str) -> Any:
return self._jsonrpc_request(
"network",
"moveEndpoints",
{"endpointIds": endpoint_ids, "groupId": group_id},
)
def delete_endpoint(self, endpoint_id: str) -> Any:
return self._jsonrpc_request(
"network", "deleteEndpoint", {"endpointId": endpoint_id}
)
def create_custom_group(self, name: str, parent_id: Optional[str] = None) -> Any:
params: dict = {"name": name}
if parent_id:
params["parentId"] = parent_id
result = self._jsonrpc_request("network", "createCustomGroup", params)
# Write-through: createCustomGroup returns the new group id (string).
group_id = result if isinstance(result, str) else None
if isinstance(result, dict):
group_id = result.get("id") or result.get("groupId")
if group_id:
self._cache_add_group(group_id, name)
return result
def delete_custom_group(self, group_id: str) -> Any:
return self._jsonrpc_request(
"network", "deleteCustomGroup", {"groupId": group_id}
)
def move_custom_group(self, group_id: str, new_parent_id: str) -> Any:
return self._jsonrpc_request(
"network",
"moveCustomGroup",
{"groupId": group_id, "newParentId": new_parent_id},
)
# ======================================================================
# CACHE LAYER (identity / structure only — never volatile status)
# ======================================================================
def _read_cache(self) -> Optional[dict]:
if not CACHE_FILE.exists():
return None
try:
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
def _write_cache(self, cache: dict) -> None:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
CACHE_FILE.write_text(
json.dumps(cache, indent=2, sort_keys=True), encoding="utf-8"
)
def _cache_is_fresh(self, cache: dict) -> bool:
fetched = cache.get("fetched_at")
ttl = cache.get("ttl_seconds", CACHE_TTL_SECONDS)
if not fetched:
return False
try:
ts = datetime.fromisoformat(fetched)
except ValueError:
return False
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - ts).total_seconds()
return age < ttl
def refresh_inventory(self) -> dict:
"""Full identity/structure pull. Writes and returns the cache."""
companies_map: dict[str, str] = {}
endpoints_map: dict[str, dict] = {}
policies_map: dict[str, str] = {}
companies = self.list_companies(per_page=100).get("items", [])
for c in companies:
cid = c.get("id")
if cid:
companies_map[cid] = c.get("name", "")
# Endpoints per company (identity tier only — no status fields).
for cid in list(companies_map.keys()) + [ACG_ROOT_COMPANY_ID]:
page = 1
while True:
try:
data = self.list_endpoints(cid, page=page, per_page=100)
except GravityZoneError:
break
items = data.get("items", [])
if not items:
break
for ep in items:
eid = ep.get("id")
if not eid:
continue
endpoints_map[eid] = {
"name": ep.get("name", ""),
"company_id": ep.get("companyId", cid),
"fqdn": ep.get("fqdn") or ep.get("FQDN") or "",
}
total = data.get("total", 0)
if page * 100 >= total:
break
page += 1
try:
for p in self.list_policies(per_page=100).get("items", []):
pid = p.get("id")
if pid:
policies_map[pid] = p.get("name", "")
except GravityZoneError:
pass
packages: list = []
try:
packages = self.list_packages(per_page=100).get("items", [])
except GravityZoneError:
pass
cache = {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"ttl_seconds": CACHE_TTL_SECONDS,
"companies": companies_map,
"endpoints": endpoints_map,
"policies": policies_map,
"packages": packages,
}
self._write_cache(cache)
return cache
def get_inventory(self, refresh: bool = False) -> dict:
"""Return cached identity/structure, refreshing if stale or forced."""
if not refresh:
cache = self._read_cache()
if cache and self._cache_is_fresh(cache):
return cache
return self.refresh_inventory()
def _cache_add_group(self, group_id: str, name: str) -> None:
cache = self._read_cache()
if cache is None:
return # no cache yet — next refresh picks it up
cache.setdefault("companies", {})
# Groups live in the inventory tree; store under a 'groups' map.
cache.setdefault("groups", {})[group_id] = name
self._write_cache(cache)
def _cache_add_package(self, package_name: str, create_result: Any) -> None:
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)
def main() -> int:
"""Minimal self-check: load key (no network call)."""
try:
client = GravityZoneClient()
_ = client.api_key # triggers vault load
print("[OK] API key loaded; transport =",
"httpx" if _HAS_HTTPX else "urllib")
return 0
except GravityZoneError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())