From db6aa3683f2ace5e0e402e009356f7e8a1b6be88 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Sat, 30 May 2026 07:28:02 -0700 Subject: [PATCH] fix(bitdefender): all-clients sweep, quarantine path, EDR controls, self-test Several bugs found and fixed during live testing against the ACG GravityZone tenant: - security_sweep_all_clients: iterate each company (the companies container is not a valid endpoint parent; passing it 400'd the whole sweep) - list_quarantine: use service-scoped path quarantine/computers with companyId (bare quarantine module 404'd; param is companyId not parentId) - rename GZEndpointSummary.detection_active -> threat_detected with corrected semantics (True = active threat, tracks with infected; not an engine-on flag) - status: readable sectioned table renderer for the nested apiKey/license dict - portable CLAUDETOOLS_ROOT resolution (derive from file path, not a Windows literal) so it works on the Mac/Linux fleet Adds scripts/selftest.py: a 29-check read-only harness (all passing) covering every read command, --json, error exit codes, and destructive-action gating. EDR/incident commands (blocklist, isolate/unisolate, blocklist-add/remove) and raw destructive-method gating are included from this session's work. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/bitdefender/SKILL.md | 16 +- .../bitdefender/references/api-reference.md | 50 +++++- .claude/skills/bitdefender/scripts/gz.py | 163 ++++++++++++++++- .../skills/bitdefender/scripts/gz_client.py | 169 +++++++++++++++++- .../skills/bitdefender/scripts/selftest.py | 108 +++++++++++ .gitignore | 1 + 6 files changed, 495 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/bitdefender/scripts/selftest.py diff --git a/.claude/skills/bitdefender/SKILL.md b/.claude/skills/bitdefender/SKILL.md index 023ed7d..b6985a8 100644 --- a/.claude/skills/bitdefender/SKILL.md +++ b/.claude/skills/bitdefender/SKILL.md @@ -85,6 +85,10 @@ what they would do and exit non-zero: - `delete-endpoint --confirm` - `delete-package --package --confirm` - `delete-group --group --confirm` +- `isolate --endpoints ... --confirm` (cuts the endpoint off the network; reversible via `unisolate`) +- `unisolate --endpoints ... --confirm` +- `blocklist-add --company --hashes ... --confirm` +- `blocklist-remove --id --confirm` Never run destructive calls casually against this tenant. UNVERIFIED methods (assignPolicy, uninstall/reconfigure tasks, quarantine remove/restore, set @@ -93,7 +97,8 @@ through `raw` after confirming the correct params against `references/api-reference.md` and the official Bitdefender docs. `raw` itself refuses destructive method names (delete/uninstall/remove/ -reconfigure) unless `--confirm` is passed. Note that `raw` prints the upstream +reconfigure, plus the EDR verbs isolat*/addToBlocklist/removeFromBlocklist) +unless `--confirm` is passed. Note that `raw` prints the upstream response verbatim — it can carry data from the called method, so do not paste raw output into tickets/logs without review. @@ -132,6 +137,15 @@ $GZ move --endpoints --group # Scans $GZ scan --targets --type 2 --name "Full scan" +# EDR / incident response +$GZ blocklist # list blocklisted hashes (whole tenant) +$GZ blocklist --company # scope to one company +$GZ incidents --company # list incidents (parentId required; method UNVERIFIED on this tenant - may return "Method not found") +$GZ isolate --endpoints --confirm # cut endpoint(s) off the network (reversible via unisolate) +$GZ unisolate --endpoints --confirm # restore endpoint(s) from isolation +$GZ blocklist-add --company --hashes

--hash-type 1 --source-info "..." --confirm +$GZ blocklist-remove --id --confirm # id comes from `blocklist` output + # Power use — call any method directly $GZ raw --module network --method getEndpointsList --params '{"page":1,"perPage":50}' diff --git a/.claude/skills/bitdefender/references/api-reference.md b/.claude/skills/bitdefender/references/api-reference.md index 7dfd4f1..fe6f960 100644 --- a/.claude/skills/bitdefender/references/api-reference.md +++ b/.claude/skills/bitdefender/references/api-reference.md @@ -119,6 +119,39 @@ In `getNetworkInventoryItems` results, `type == 1` denotes a company node. | `createRemoveQuarantineItemTask` | uncertain | UNVERIFIED (destructive) | Param shape not confirmed. `raw` only. | | `createRestoreQuarantineItemTask` | uncertain | UNVERIFIED | Param shape not confirmed. `raw` only. | +## incidents (`/incidents`) — EDR / incident response + +> The incidents module backs the EDR controls: endpoint isolation, the hash +> blocklist, and the incident list. READ methods are safe; the state-changing +> methods (isolate / restore / blocklist add+remove) are CLI-gated behind +> `--confirm`. `isolate` and `addToBlocklist` / `removeFromBlocklist` are NEW +> destructive verbs — the `raw` subcommand also gates any method whose name +> contains `isolat`, `addtoblocklist`, or `removefromblocklist`. + +| Method | Params | Status | Notes | +|---|---|---|---| +| `getBlocklistItems` | `companyId?, page?, perPage?` | VERIFIED LIVE | Returns `{total, page, perPage, pagesCount, items:[{id, source, sourceInfo, hashType, hash, companyId}]}`. Returned 26 items live. `perPage` defaults to 100 in the CLI. `companyId` scopes to one company; omit for the whole tenant. | +| `getIncidentsList` | `parentId, page?, perPage (500-10000), filters?` | UNVERIFIED / possibly unavailable | `parentId` = a company/group id and is REQUIRED. `perPage` must be 500-10000 (the API rejected 100 with "Invalid value for 'perPage' parameter. The value should be between 500 and 10000"); the CLI defaults it to 500. **However**, live re-testing on 2026-05-30 returned `Method not found` for this method on the `/incidents` module, while `getBlocklistItems` on the SAME module succeeds in the same request — so this is NOT rate-limiting or a bad key. The method is likely gated behind an EDR/incidents license feature that is OFF on this tenant, or is named differently in this API version. The CLI `incidents` subcommand is wired up but will surface `Method not found` until the feature is enabled / the correct name is confirmed. | +| `createIsolateEndpointTask` | `endpointIds[]` | VERIFIED (destructive) | v1.2: takes an ARRAY `endpointIds` (max 1000), returns an array of task ids. Cuts the endpoint off the network. CLI-gated behind `--confirm`; the client enforces the 1000-id cap. | +| `createRestoreEndpointFromIsolationTask` | `endpointIds[]` | VERIFIED (destructive) | v1.2: takes an ARRAY `endpointIds` (max 1000), returns an array of task ids. Un-isolates (reverses `createIsolateEndpointTask`). CLI-gated behind `--confirm`; the client enforces the 1000-id cap. | +| `addToBlocklist` | `companyId, hashType, hashList[], sourceInfo, operatingSystems?` | VERIFIED (destructive) | `hashType` is an int (1 is the common value seen live; see the console / API docs for the full mapping). `hashList` is an array of hash strings. `sourceInfo` is a free-text description. CLI-gated behind `--confirm`. | +| `removeFromBlocklist` | `hashItemId` *(UNVERIFIED param name)* | VERIFIED method, UNVERIFIED param | Removes one blocklist entry. The param name `hashItemId` is UNVERIFIED — the `id` field from `getBlocklistItems` is the candidate. Confirm against the official API reference before relying on it. CLI-gated behind `--confirm`; the CLI `--id` value comes from `blocklist` output. | +| `changeIncidentStatus` | uncertain | UNVERIFIED | Not implemented. `raw` only. | +| `updateIncidentNote` | uncertain | UNVERIFIED | Not implemented. `raw` only. | +| `createCustomRule` | uncertain | UNVERIFIED | Not implemented. `raw` only. | +| `getCustomRulesList` | uncertain | UNVERIFIED | Not implemented. `raw` only. | +| `deleteCustomRule` | uncertain | UNVERIFIED (destructive) | Not implemented. `raw` only. | + +## Other modules — raw-reachable only + +The following modules are reachable via `raw --module ` but have no +dedicated CLI methods and no verified signatures here: + +- `patchmanagement` — raw only. NOTE: the patchmanagement / PHASR license + features are OFF on this tenant, so these calls will not return useful data. +- `integrations` — raw only, UNVERIFIED. +- `maintenancewindows` — raw only, UNVERIFIED. + --- ## Verified vs Unverified summary @@ -132,13 +165,26 @@ network.moveCustomGroup, network.deleteEndpoint (gated), network.deleteCustomGroup (gated), packages.getPackagesList, packages.createPackage, packages.getInstallationLinks, packages.deletePackage (gated), policies.getPoliciesList, policies.getPolicyDetails, -quarantine.getQuarantineItemsList. +quarantine.getQuarantineItemsList, incidents.getBlocklistItems, +incidents.createIsolateEndpointTask (gated), +incidents.createRestoreEndpointFromIsolationTask (gated), +incidents.addToBlocklist (gated), incidents.removeFromBlocklist (gated; +param name UNVERIFIED). + +> NOTE: `incidents.getIncidentsList` is wired into the CLI (`incidents` +> subcommand) but returned `Method not found` on live re-test (2026-05-30) — +> see the incidents module table. Likely a license-gated EDR feature that is OFF +> on this tenant. Not counted as VERIFIED LIVE. **UNVERIFIED (raw subcommand only — do NOT trust the param shape):** network.assignPolicy, network.createReconfigureClientTask, network.createUninstallTask, network.setEndpointLabel, companies.getCompanyDetailsByUser, quarantine.createRemoveQuarantineItemTask, -quarantine.createRestoreQuarantineItemTask. +quarantine.createRestoreQuarantineItemTask, incidents.changeIncidentStatus, +incidents.updateIncidentNote, incidents.createCustomRule, +incidents.getCustomRulesList, incidents.deleteCustomRule (destructive). +Whole modules raw-only/UNVERIFIED: patchmanagement (license OFF), +integrations, maintenancewindows. Confirm any UNVERIFIED signature against the official Bitdefender API reference before relying on it. The generic `raw --module M --method m --params ''` diff --git a/.claude/skills/bitdefender/scripts/gz.py b/.claude/skills/bitdefender/scripts/gz.py index 97e0cc3..be18a01 100644 --- a/.claude/skills/bitdefender/scripts/gz.py +++ b/.claude/skills/bitdefender/scripts/gz.py @@ -24,6 +24,12 @@ Usage examples: python gz.py move --endpoints --group python gz.py make-group --name "New Group" --parent python gz.py delete-endpoint --confirm + python gz.py blocklist --company + python gz.py incidents --company + python gz.py isolate --endpoints --confirm + python gz.py unisolate --endpoints --confirm + python gz.py blocklist-add --company --hashes

--confirm + python gz.py blocklist-remove --id --confirm python gz.py raw --module network --method getEndpointsList --params '{"page":1}' """ from __future__ import annotations @@ -55,6 +61,23 @@ def _print_kv(d: dict) -> None: print(f" {k}: {v}") +def _print_status(status: dict) -> None: + """Render the nested API-status dict as readable sections. + + get_api_status() returns {"apiKey": {...}, "license": {...}} and possibly + "_licenseWarning". Print a [section] header per top-level key, then indented + key: value lines for each sub-field. Non-dict top-level values (e.g. the + license warning string) print as a single indented line under their header. + """ + for section, body in status.items(): + print(f"[{section}]") + if isinstance(body, dict): + for k, v in body.items(): + print(f" {k}: {v}") + else: + print(f" {body}") + + def _print_company_table(data: dict) -> None: items = data.get("items", []) print(f"Companies: {data.get('total', len(items))}") @@ -108,6 +131,23 @@ def _print_quarantine_table(data: dict) -> None: f"{q.get('detectionTime','')}") +def _print_blocklist_table(data: dict) -> None: + items = data.get("items", []) + print(f"Blocklist items: {data.get('total', len(items))}") + print(f" {'ID':26} {'HTYPE':6} {'HASH':66} SOURCE-INFO") + for b in items: + print(f" {str(b.get('id','?')):26} {str(b.get('hashType','?')):6} " + f"{str(b.get('hash','')):66} {b.get('sourceInfo','')}") + + +def _print_incidents_table(data: dict) -> None: + items = data.get("items", []) + print(f"Incidents: {data.get('total', len(items))}") + for i in items: + print(f" {str(i.get('id','?')):26} {i.get('name', i.get('title','')):40} " + f"{i.get('severity', i.get('status',''))}") + + def _print_inventory_table(cache: dict) -> None: print(f"Inventory cached_at: {cache.get('fetched_at')}") print(f" companies: {len(cache.get('companies', {}))}") @@ -119,7 +159,7 @@ def _print_inventory_table(cache: dict) -> None: # --- command handlers --------------------------------------------------------- def cmd_status(client, args): - _emit(client.get_api_status(), args.json, _print_kv) + _emit(client.get_api_status(), args.json, _print_status) def cmd_companies(client, args): @@ -136,8 +176,12 @@ def cmd_endpoint(client, args): def cmd_sweep(client, args): - target = args.company or _require_company_for_sweep() - summaries = client.security_sweep(target) + if args.company: + summaries = client.security_sweep(args.company) + else: + print("[INFO] No --company given; sweeping ALL client companies " + "(this makes many live API calls).", file=sys.stderr) + summaries = client.security_sweep_all_clients() if args.json: print(json.dumps([dataclasses.asdict(s) for s in summaries], indent=2)) else: @@ -169,6 +213,17 @@ def cmd_quarantine(client, args): _emit(client.list_quarantine(args.company), args.json, _print_quarantine_table) +def cmd_blocklist(client, args): + _emit(client.list_blocklist(args.company, page=args.page, per_page=args.per_page), + args.json, _print_blocklist_table) + + +def cmd_incidents(client, args): + _emit(client.list_incidents(args.company, page=args.page, + per_page=args.per_page), + args.json, _print_incidents_table) + + def cmd_inventory(client, args): _emit(client.get_inventory(refresh=args.refresh), args.json, _print_inventory_table) @@ -209,8 +264,11 @@ def cmd_make_group(client, args): # Substrings that mark a JSON-RPC method as state-destroying. `raw` can reach # any method (incl. UNVERIFIED ones), so gate these behind --confirm too. +# isolate / blocklist add+remove are NEW destructive verbs from the incidents +# (EDR) module — gate them in `raw` as well as via the dedicated subcommands. DESTRUCTIVE_RAW_PATTERNS = ("delete", "createuninstall", "createremove", - "createreconfigure") + "createreconfigure", "isolat", "addtoblocklist", + "removefromblocklist") def _is_destructive_method(method: str) -> bool: @@ -270,6 +328,49 @@ def cmd_delete_group(client, args): return 0 +# --- EDR / incident response (gated) ------------------------------------------ +def cmd_isolate(client, args): + targets = ",".join(args.endpoints) + if not _gated(f"isolate endpoints {targets}", args.confirm): + return 3 + result = client.isolate_endpoints(args.endpoints) + _emit({"isolated": args.endpoints, "result": result}, args.json, _print_kv) + return 0 + + +def cmd_unisolate(client, args): + targets = ",".join(args.endpoints) + if not _gated(f"restore endpoints from isolation {targets}", args.confirm): + return 3 + result = client.restore_endpoints_from_isolation(args.endpoints) + _emit({"unisolated": args.endpoints, "result": result}, args.json, _print_kv) + return 0 + + +def cmd_blocklist_add(client, args): + desc = (f"add {len(args.hashes)} hash(es) to blocklist for company " + f"{args.company}: {','.join(args.hashes)}") + if not _gated(desc, args.confirm): + return 3 + result = client.add_to_blocklist( + company_id=args.company, + hash_list=args.hashes, + hash_type=args.hash_type, + source_info=args.source_info, + ) + _emit({"blocklistAdded": args.hashes, "company": args.company, + "result": result}, args.json, _print_kv) + return 0 + + +def cmd_blocklist_remove(client, args): + if not _gated(f"remove blocklist item {args.id}", args.confirm): + return 3 + result = client.remove_from_blocklist(args.id) + _emit({"blocklistRemoved": args.id, "result": result}, args.json, _print_kv) + return 0 + + # --- parser ------------------------------------------------------------------- def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( @@ -308,6 +409,20 @@ def build_parser() -> argparse.ArgumentParser: parents=[common]) sp.add_argument("--company", required=True) + sp = sub.add_parser("blocklist", help="List blocklisted hash items (EDR).", + parents=[common]) + sp.add_argument("--company", help="Scope to one company id (optional).") + sp.add_argument("--page", type=int, default=1) + sp.add_argument("--per-page", type=int, default=100) + + sp = sub.add_parser("incidents", help="List incidents under a company (EDR).", + parents=[common]) + sp.add_argument("--company", required=True, + help="Parent company/group id (parentId; required).") + sp.add_argument("--page", type=int, default=1) + sp.add_argument("--per-page", type=int, default=500, + help="incidents requires 500-10000 (API constraint).") + sp = sub.add_parser("inventory", help="Show cached identity/structure.", parents=[common]) sp.add_argument("--refresh", action="store_true", help="Force a full re-pull.") @@ -364,6 +479,40 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("--group", required=True) sp.add_argument("--confirm", action="store_true") + # EDR / incident response (gated) + sp = sub.add_parser("isolate", + help="Isolate endpoints from the network (gated).", + parents=[common]) + sp.add_argument("--endpoints", nargs="+", required=True, + help="One or more endpoint ids (max 1000).") + sp.add_argument("--confirm", action="store_true") + + sp = sub.add_parser("unisolate", + help="Restore endpoints from isolation (gated).", + parents=[common]) + sp.add_argument("--endpoints", nargs="+", required=True, + help="One or more endpoint ids (max 1000).") + sp.add_argument("--confirm", action="store_true") + + sp = sub.add_parser("blocklist-add", + help="Add hashes to the blocklist (gated).", + parents=[common]) + sp.add_argument("--company", required=True) + sp.add_argument("--hashes", nargs="+", required=True, + help="One or more hash strings to block.") + sp.add_argument("--hash-type", type=int, default=1, + help="Hash type int (1 common; see console / API docs).") + sp.add_argument("--source-info", default="", + help="Free-text description of the source.") + sp.add_argument("--confirm", action="store_true") + + sp = sub.add_parser("blocklist-remove", + help="Remove one blocklist entry (gated).", + parents=[common]) + sp.add_argument("--id", required=True, + help="hashItemId — the 'id' from `blocklist` output.") + sp.add_argument("--confirm", action="store_true") + return p @@ -377,6 +526,8 @@ HANDLERS = { "policy": cmd_policy, "packages": cmd_packages, "quarantine": cmd_quarantine, + "blocklist": cmd_blocklist, + "incidents": cmd_incidents, "inventory": cmd_inventory, "create-package": cmd_create_package, "install-links": cmd_install_links, @@ -387,6 +538,10 @@ HANDLERS = { "delete-endpoint": cmd_delete_endpoint, "delete-package": cmd_delete_package, "delete-group": cmd_delete_group, + "isolate": cmd_isolate, + "unisolate": cmd_unisolate, + "blocklist-add": cmd_blocklist_add, + "blocklist-remove": cmd_blocklist_remove, } diff --git a/.claude/skills/bitdefender/scripts/gz_client.py b/.claude/skills/bitdefender/scripts/gz_client.py index 905a46d..1330a3b 100644 --- a/.claude/skills/bitdefender/scripts/gz_client.py +++ b/.claude/skills/bitdefender/scripts/gz_client.py @@ -74,7 +74,8 @@ class GZEndpointSummary: name: str company_id: str infected: bool - detection_active: bool + # True = a malware detection is currently active on the endpoint (tracks with infected); NOT an "engine on" indicator. + threat_detected: bool signature_outdated: bool product_outdated: bool last_seen: Optional[str] @@ -295,12 +296,14 @@ class GravityZoneClient: ) or {} def list_quarantine( - self, parent_id: str, page: int = 1, per_page: int = 100 + self, company_id: str, page: int = 1, per_page: int = 100 ) -> dict: + # Service-scoped path 'quarantine/computers' (bare 'quarantine' 404s); + # the param is 'companyId', NOT 'parentId'. return self._jsonrpc_request( - "quarantine", + "quarantine/computers", "getQuarantineItemsList", - {"parentId": parent_id, "page": page, "perPage": per_page}, + {"companyId": company_id, "page": page, "perPage": per_page}, ) or {} def security_sweep(self, parent_id: str) -> list[GZEndpointSummary]: @@ -332,7 +335,7 @@ class GravityZoneClient: 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)), + threat_detected=bool(malware.get("detection", False)), signature_outdated=bool(agent.get("signatureOutdated", False)), product_outdated=bool(agent.get("productOutdated", False)), last_seen=detail.get("lastSeen"), @@ -356,6 +359,34 @@ class GravityZoneClient: ) return summaries + def security_sweep_all_clients(self) -> list[GZEndpointSummary]: + """Sweep every client company. The companies container is NOT a valid + endpoint parent, so iterate each company and sweep it individually.""" + summaries: list[GZEndpointSummary] = [] + companies = self.list_companies(per_page=100).get("items", []) + for company in companies: + cid = company.get("id") + if not cid: + continue + try: + company_summaries = self.security_sweep(cid) + except GravityZoneError: + continue + for s in company_summaries: + if not s.company_id: + s.company_id = cid + summaries.extend(company_summaries) + + 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) # ====================================================================== @@ -461,6 +492,134 @@ class GravityZoneClient: {"groupId": group_id, "newParentId": new_parent_id}, ) + # ====================================================================== + # INCIDENTS / EDR (module `/incidents`) + # ---------------------------------------------------------------------- + # READ methods are always live and never cached (blocklist + incident + # status are volatile). State-changing methods (isolate, restore, + # add/remove blocklist) return the raw upstream result; the CLI caller is + # responsible for gating them behind --confirm. + # ====================================================================== + + # -- READ (safe) ----------------------------------------------------------- + def list_blocklist( + self, + company_id: Optional[str] = None, + page: int = 1, + per_page: int = 100, + ) -> dict: + """List blocklisted hash items. VERIFIED LIVE (incidents.getBlocklistItems). + + Returns {total, page, perPage, pagesCount, items:[{id, source, + sourceInfo, hashType, hash, companyId}]}. `companyId` scopes to one + company; omit for the whole partner tenant. + """ + params: dict = {"page": page, "perPage": per_page} + if company_id: + params["companyId"] = company_id + return self._jsonrpc_request("incidents", "getBlocklistItems", params) or {} + + def list_incidents( + self, + parent_id: str, + page: int = 1, + per_page: int = 500, + filters: Optional[dict] = None, + ) -> dict: + """List incidents under a company/group. + + `parent_id` (a company/group id) is REQUIRED for this method. + + perPage default is 500: live testing surfaced an + "Invalid value for 'perPage' parameter. The value should be between 500 + and 10000" error for smaller values, so 100 (the default elsewhere) is + rejected here. + + NOTE (UNVERIFIED / possibly unavailable on this tenant): live re-testing + on 2026-05-30 returned "Method not found" for `getIncidentsList` on the + `/incidents` module, even though `getBlocklistItems` on the SAME module + succeeds in the same request (so this is not rate-limiting or a bad key). + The method may be gated by an EDR/incidents license feature that is OFF + on this tenant, or named differently in this API version. Treat the + return value as best-effort and confirm against the official Bitdefender + API reference / console before relying on incident data. + """ + params: dict = {"parentId": parent_id, "page": page, "perPage": per_page} + if filters is not None: + params["filters"] = filters + return self._jsonrpc_request("incidents", "getIncidentsList", params) or {} + + # -- STATE-CHANGING (caller MUST gate behind --confirm) -------------------- + def isolate_endpoints(self, endpoint_ids: list[str]) -> Any: + """Isolate endpoints from the network (incidents.createIsolateEndpointTask). + + v1.2 takes an ARRAY `endpointIds` (max 1000) and returns an array of + task ids. STATE-CHANGING — gate behind --confirm at the call site. + """ + if len(endpoint_ids) > 1000: + raise GravityZoneError( + "isolate_endpoints accepts at most 1000 endpoint ids per call " + f"(got {len(endpoint_ids)})." + ) + return self._jsonrpc_request( + "incidents", "createIsolateEndpointTask", {"endpointIds": endpoint_ids} + ) + + def restore_endpoints_from_isolation(self, endpoint_ids: list[str]) -> Any: + """Un-isolate endpoints (incidents.createRestoreEndpointFromIsolationTask). + + v1.2 takes an ARRAY `endpointIds` (max 1000) and returns an array of + task ids. STATE-CHANGING — gate behind --confirm at the call site. + """ + if len(endpoint_ids) > 1000: + raise GravityZoneError( + "restore_endpoints_from_isolation accepts at most 1000 endpoint " + f"ids per call (got {len(endpoint_ids)})." + ) + return self._jsonrpc_request( + "incidents", + "createRestoreEndpointFromIsolationTask", + {"endpointIds": endpoint_ids}, + ) + + def add_to_blocklist( + self, + company_id: str, + hash_list: list[str], + hash_type: int = 1, + source_info: str = "", + operating_systems: Optional[list[str]] = None, + ) -> Any: + """Add hashes to the blocklist (incidents.addToBlocklist). + + `hash_type` is an int (1 is the common value seen live; see the + GravityZone console / API docs for the full mapping). `hash_list` is an + array of hash strings. `source_info` is a free-text description. + STATE-CHANGING — gate behind --confirm at the call site. + """ + params: dict = { + "companyId": company_id, + "hashType": hash_type, + "hashList": hash_list, + "sourceInfo": source_info, + } + if operating_systems is not None: + params["operatingSystems"] = operating_systems + return self._jsonrpc_request("incidents", "addToBlocklist", params) + + def remove_from_blocklist(self, hash_item_id: str) -> Any: + """Remove one blocklist entry (incidents.removeFromBlocklist). + + STATE-CHANGING — gate behind --confirm at the call site. + + UNVERIFIED: the param name `hashItemId` is the candidate (the `id` + field from getBlocklistItems). Confirm against the official Bitdefender + API reference before relying on it. + """ + return self._jsonrpc_request( + "incidents", "removeFromBlocklist", {"hashItemId": hash_item_id} + ) + # ====================================================================== # CACHE LAYER (identity / structure only — never volatile status) # ====================================================================== diff --git a/.claude/skills/bitdefender/scripts/selftest.py b/.claude/skills/bitdefender/scripts/selftest.py new file mode 100644 index 0000000..ed41fbf --- /dev/null +++ b/.claude/skills/bitdefender/scripts/selftest.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Read-only self-test harness for the bitdefender skill. + +Runs each CLI command as an isolated subprocess and checks exit code + +output markers. NO state-changing API calls are made (create/scan/move/ +isolate/blocklist-add/delete are only tested in their --confirm-absent +refusal path). Prints a PASS/FAIL report. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +GZ = os.path.join(HERE, "gz.py") +ACG = "5c428b246c031893678b4569" # ACG internal company (real) + +results = [] + + +def run(args): + env = dict(os.environ) + env.setdefault("CLAUDETOOLS_ROOT", "C:/claudetools") + env["PYTHONIOENCODING"] = "utf-8" + p = subprocess.run([sys.executable, GZ] + args, capture_output=True, + text=True, env=env, timeout=120) + return p.returncode, p.stdout, p.stderr + + +def check(name, args, *, want_rc=None, out_has=None, err_has=None, + out_json_ok=False, not_out=None): + rc, out, err = run(args) + problems = [] + if want_rc is not None and rc != want_rc: + problems.append(f"rc={rc} want {want_rc}") + if out_has and out_has not in out: + problems.append(f"stdout missing {out_has!r}") + if err_has and err_has not in err: + problems.append(f"stderr missing {err_has!r}") + if not_out and not_out in out: + problems.append(f"stdout should NOT contain {not_out!r}") + if out_json_ok: + try: + json.loads(out) + except Exception as e: + problems.append(f"stdout not valid JSON: {e}") + status = "PASS" if not problems else "FAIL" + results.append((status, name, "; ".join(problems), (out[:120] + (out[120:] and "...")).replace("\n", " "))) + return rc, out, err + + +# --- read commands: should succeed (rc 0) --- +check("status table", ["status"], want_rc=0, out_has="apiKey") +check("status json", ["status", "--json"], want_rc=0, out_json_ok=True) +check("companies table", ["companies"], want_rc=0, out_has="Companies:") +check("companies json", ["companies", "--json"], want_rc=0, out_json_ok=True) +check("endpoints (real co)", ["endpoints", "--company", ACG], want_rc=0, out_has="Endpoints:") +check("endpoints json", ["endpoints", "--company", ACG, "--json"], want_rc=0, out_json_ok=True) +check("endpoints bogus parent -> err", ["endpoints", "--company", "bogus"], want_rc=1, err_has="[ERROR]") +check("policies", ["policies"], want_rc=0, out_has="Policies:") +check("packages", ["packages"], want_rc=0, out_has="Packages:") +check("quarantine (real co)", ["quarantine", "--company", ACG], want_rc=0, out_has="Quarantine items:") +check("quarantine json", ["quarantine", "--company", ACG, "--json"], want_rc=0, out_json_ok=True) +check("blocklist", ["blocklist"], want_rc=0, out_has="Blocklist items:") +check("blocklist json", ["blocklist", "--json"], want_rc=0, out_json_ok=True) +check("blocklist page2", ["blocklist", "--page", "2", "--per-page", "3"], want_rc=0, out_has="Blocklist items:") +check("inventory cached", ["inventory"], want_rc=0, out_has="Inventory cached_at:") + +# --- error handling: a MALFORMED id (not valid hex/ObjectId) makes the API +# error, which must exit non-zero (1). Note: a well-formed but non-existent +# hex id is ACCEPTED by GravityZone and returns a stub (rc 0) -- that is the +# API's behavior, not a skill bug, so we test with a malformed value here. --- +check("endpoint bad id -> rc1", ["endpoint", "bogus"], want_rc=1, err_has="[ERROR]") +check("policy bad id -> rc1", ["policy", "bogus"], want_rc=1, err_has="[ERROR]") + +# --- argparse: missing required arg -> rc2 --- +check("quarantine missing --company -> rc2", ["quarantine"], want_rc=2) +check("endpoint missing positional -> rc2", ["endpoint"], want_rc=2) + +# --- gating: destructive without --confirm -> rc3, no API call --- +check("isolate no confirm -> rc3", ["isolate", "--endpoints", "x"], want_rc=3, out_has="Would") +check("unisolate no confirm -> rc3", ["unisolate", "--endpoints", "x"], want_rc=3) +check("blocklist-add no confirm -> rc3", ["blocklist-add", "--company", ACG, "--hashes", "abc"], want_rc=3) +check("blocklist-remove no confirm -> rc3", ["blocklist-remove", "--id", "x"], want_rc=3) +check("delete-endpoint no confirm -> rc3", ["delete-endpoint", "x"], want_rc=3) +check("delete-package no confirm -> rc3", ["delete-package", "--package", "x"], want_rc=3) +check("delete-group no confirm -> rc3", ["delete-group", "--group", "x"], want_rc=3) + +# --- raw gating --- +check("raw destructive no confirm -> rc3", ["raw", "--module", "network", + "--method", "deleteEndpoint", "--params", "{}"], want_rc=3) +check("raw bad json params -> rc2", ["raw", "--module", "general", + "--method", "getApiKeyDetails", "--params", "{bad"], want_rc=2) +check("raw read ok", ["raw", "--module", "general", "--method", + "getApiKeyDetails", "--params", "{}"], want_rc=0) + +# --- report --- +print("\n==== bitdefender skill self-test ====") +npass = sum(1 for r in results if r[0] == "PASS") +for status, name, prob, sample in results: + line = f"[{status}] {name}" + if prob: + line += f" -> {prob}" + print(line) +print(f"\n{npass}/{len(results)} passed, {len(results)-npass} failed") +sys.exit(0 if npass == len(results) else 1) diff --git a/.gitignore b/.gitignore index e6c9e01..8b16868 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ tmp-remediation/ .claude/identity.json .claude/current-mode .claude/coord-broadcasts-seen +.claude/scheduled_tasks.lock # /autotask command — kept local/undistributed (Syncro is the default PSA; Autotask is opt-in). # Remove this line to distribute /autotask to the fleet. See .claude/memory/feedback_psa_default_syncro.md