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:
149
.claude/skills/bitdefender/SKILL.md
Normal file
149
.claude/skills/bitdefender/SKILL.md
Normal 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`.
|
||||||
145
.claude/skills/bitdefender/references/api-reference.md
Normal file
145
.claude/skills/bitdefender/references/api-reference.md
Normal 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.
|
||||||
376
.claude/skills/bitdefender/scripts/gz.py
Normal file
376
.claude/skills/bitdefender/scripts/gz.py
Normal 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())
|
||||||
603
.claude/skills/bitdefender/scripts/gz_client.py
Normal file
603
.claude/skills/bitdefender/scripts/gz_client.py
Normal 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())
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,6 +5,9 @@ backups/
|
|||||||
.cache-remediation/
|
.cache-remediation/
|
||||||
tmp-remediation/
|
tmp-remediation/
|
||||||
|
|
||||||
|
# Bitdefender skill cache (identity/structure only — no secrets/PII)
|
||||||
|
.claude/skills/bitdefender/.cache/
|
||||||
|
|
||||||
# Local settings (machine-specific)
|
# Local settings (machine-specific)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/identity.json
|
.claude/identity.json
|
||||||
|
|||||||
Reference in New Issue
Block a user