diff --git a/.claude/skills/bitdefender/SKILL.md b/.claude/skills/bitdefender/SKILL.md new file mode 100644 index 0000000..7676920 --- /dev/null +++ b/.claude/skills/bitdefender/SKILL.md @@ -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 --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 --confirm` +- `delete-package --package --confirm` +- `delete-group --group --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 +$GZ endpoint + +# Live security posture +$GZ sweep --company # readable table +$GZ sweep --company --json # machine output + +# Policies (read-only, shallow) +$GZ policies +$GZ policy + +# Quarantine +$GZ quarantine --company + +# Deployment +$GZ packages +$GZ create-package --name "Win Default" --company +$GZ install-links --package "Win Default" --company + +# Org structure +$GZ make-group --name "New Site" --parent +$GZ move --endpoints --group + +# Scans +$GZ scan --targets --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 --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`. diff --git a/.claude/skills/bitdefender/references/api-reference.md b/.claude/skills/bitdefender/references/api-reference.md new file mode 100644 index 0000000..7dfd4f1 --- /dev/null +++ b/.claude/skills/bitdefender/references/api-reference.md @@ -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:** `/` (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": "", + "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 ''` +subcommand can call any method once you know the correct params. diff --git a/.claude/skills/bitdefender/scripts/gz.py b/.claude/skills/bitdefender/scripts/gz.py new file mode 100644 index 0000000..640e7bf --- /dev/null +++ b/.claude/skills/bitdefender/scripts/gz.py @@ -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 + python gz.py sweep --company + python gz.py policies + python gz.py policy + python gz.py packages + python gz.py quarantine --company + python gz.py inventory --refresh + python gz.py create-package --name "Win Default" --company + python gz.py install-links --package "Win Default" --company + python gz.py scan --targets --type 2 --name "Full scan" + python gz.py move --endpoints --group + python gz.py make-group --name "New Group" --parent + python gz.py delete-endpoint --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()) diff --git a/.claude/skills/bitdefender/scripts/gz_client.py b/.claude/skills/bitdefender/scripts/gz_client.py new file mode 100644 index 0000000..905a46d --- /dev/null +++ b/.claude/skills/bitdefender/scripts/gz_client.py @@ -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()) diff --git a/.gitignore b/.gitignore index e1f1ab0..e6c9e01 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ backups/ .cache-remediation/ tmp-remediation/ +# Bitdefender skill cache (identity/structure only — no secrets/PII) +.claude/skills/bitdefender/.cache/ + # Local settings (machine-specific) .claude/settings.local.json .claude/identity.json