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:
@@ -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}'
|
||||
|
||||
|
||||
@@ -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>'`
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
# ======================================================================
|
||||
|
||||
108
.claude/skills/bitdefender/scripts/selftest.py
Normal file
108
.claude/skills/bitdefender/scripts/selftest.py
Normal 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)
|
||||
Reference in New Issue
Block a user