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) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 07:28:02 -07:00
parent 446d25c66b
commit db6aa3683f
6 changed files with 495 additions and 12 deletions

View File

@@ -85,6 +85,10 @@ what they would do and exit non-zero:
- `delete-endpoint <id> --confirm`
- `delete-package --package <name> --confirm`
- `delete-group --group <id> --confirm`
- `isolate --endpoints <id> ... --confirm` (cuts the endpoint off the network; reversible via `unisolate`)
- `unisolate --endpoints <id> ... --confirm`
- `blocklist-add --company <id> --hashes <h> ... --confirm`
- `blocklist-remove --id <hashItemId> --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 <id1> <id2> --group <groupId>
# Scans
$GZ scan --targets <id1> <id2> --type 2 --name "Full scan"
# EDR / incident response
$GZ blocklist # list blocklisted hashes (whole tenant)
$GZ blocklist --company <companyId> # scope to one company
$GZ incidents --company <companyId> # list incidents (parentId required; method UNVERIFIED on this tenant - may return "Method not found")
$GZ isolate --endpoints <id1> <id2> --confirm # cut endpoint(s) off the network (reversible via unisolate)
$GZ unisolate --endpoints <id1> <id2> --confirm # restore endpoint(s) from isolation
$GZ blocklist-add --company <companyId> --hashes <h1> <h2> --hash-type 1 --source-info "..." --confirm
$GZ blocklist-remove --id <hashItemId> --confirm # id comes from `blocklist` output
# Power use — call any method directly
$GZ raw --module network --method getEndpointsList --params '{"page":1,"perPage":50}'

View File

@@ -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 <name>` 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 '<json>'`

View File

@@ -24,6 +24,12 @@ Usage examples:
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 blocklist --company <id>
python gz.py incidents --company <id>
python gz.py isolate --endpoints <id1> <id2> --confirm
python gz.py unisolate --endpoints <id1> <id2> --confirm
python gz.py blocklist-add --company <id> --hashes <h1> <h2> --confirm
python gz.py blocklist-remove --id <hashItemId> --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,
}

View File

@@ -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)
# ======================================================================

View File

@@ -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)