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())
|
||||
Reference in New Issue
Block a user