fix(bitdefender): doc LOWs + sweep pagination + selftest alignment
Batched the audit doc/LOW findings plus the two pagination LOWs: - Pagination (gz_client): security_sweep and refresh_inventory stopped on a 'total' field some responses omit, truncating after page 1. Now page until a short page (< per_page) - the reliable last-page signal. - isolate/restore docstrings (gz_client): removed the stale 'v1.2 takes an ARRAY endpointIds' lines that contradicted the verified single-endpointId code. - Cache 'no PII' wording corrected (gz_client header + SKILL.md): cache holds infra identifiers (hostnames/FQDNs); no secrets. Dead _require_company_for_sweep removed. - Doc drift fixed: delete-package is '--id <packageId>' not '--package <name>' (SKILL.md + api-reference.md, verified live); module docstring + sweep --company help corrected (sweep with no --company fans out to ALL companies). - selftest aligned to the improved behavior: malformed ids now exit rc2 client-side (H3) instead of rc1; gate-refusal 'Would' messages now assert on stderr (they moved off stdout so --json isn't polluted). 75/75 pass; live sweep verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,8 +51,8 @@ empty password).
|
|||||||
## Cache model (important)
|
## Cache model (important)
|
||||||
|
|
||||||
The CLI keeps a local cache at
|
The CLI keeps a local cache at
|
||||||
`.claude/skills/bitdefender/.cache/inventory.json` (gitignored — no secrets, no
|
`.claude/skills/bitdefender/.cache/inventory.json` (gitignored — no secrets; it
|
||||||
PII).
|
does hold infra identifiers: endpoint hostnames/FQDNs and id<->name maps).
|
||||||
|
|
||||||
- **Cached (identity / structure tier):** company id<->name map, endpoint
|
- **Cached (identity / structure tier):** company id<->name map, endpoint
|
||||||
id<->name/company/fqdn map, policy id<->name map, package list, and custom
|
id<->name/company/fqdn map, policy id<->name map, package list, and custom
|
||||||
@@ -86,7 +86,7 @@ Destructive subcommands refuse to run without `--confirm`; without it they print
|
|||||||
what they would do and exit non-zero:
|
what they would do and exit non-zero:
|
||||||
|
|
||||||
- `delete-endpoint <id> --confirm`
|
- `delete-endpoint <id> --confirm`
|
||||||
- `delete-package --package <name> --confirm`
|
- `delete-package --id <packageId> --confirm` (param is `packageId`, NOT packageName)
|
||||||
- `delete-group --group <id> --confirm`
|
- `delete-group --group <id> --confirm`
|
||||||
- `isolate --endpoints <id> ... --confirm` (cuts the endpoint off the network; reversible via `unisolate`)
|
- `isolate --endpoints <id> ... --confirm` (cuts the endpoint off the network; reversible via `unisolate`)
|
||||||
- `unisolate --endpoints <id> ... --confirm`
|
- `unisolate --endpoints <id> ... --confirm`
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ In `getNetworkInventoryItems` results, `type == 1` denotes a company node.
|
|||||||
| `getPackagesList` | `page?, perPage<=100` | VERIFIED | List installation packages. |
|
| `getPackagesList` | `page?, perPage<=100` | VERIFIED | List installation packages. |
|
||||||
| `createPackage` | `packageName, companyId?, description?, language?, modules?, scanMode?, settings?, roles?, deploymentOptions?` | VERIFIED | Create an installer package. Returns the new package id. |
|
| `createPackage` | `packageName, companyId?, description?, language?, modules?, scanMode?, settings?, roles?, deploymentOptions?` | VERIFIED | Create an installer package. Returns the new package id. |
|
||||||
| `getInstallationLinks` | `packageName, companyId?` | VERIFIED | Returns Windows / Mac / Linux installer download URLs for a package. |
|
| `getInstallationLinks` | `packageName, companyId?` | VERIFIED | Returns Windows / Mac / Linux installer download URLs for a package. |
|
||||||
| `deletePackage` | `packageName, companyId?` | VERIFIED (destructive) | Delete a package. CLI-gated behind `--confirm`. |
|
| `deletePackage` | `packageId` | VERIFIED (destructive) | Delete a package. Param is `packageId` (NOT packageName/companyId - those error "not expected", verified live 2026-06-21). CLI: `delete-package --id <packageId> --confirm`. |
|
||||||
|
|
||||||
## policies (`/policies`) — FULL READ + ASSIGN (authoring is console-only)
|
## policies (`/policies`) — FULL READ + ASSIGN (authoring is console-only)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""CLI for the bitdefender skill - GravityZone Cloud Public API.
|
"""CLI for the bitdefender skill - GravityZone Cloud Public API.
|
||||||
|
|
||||||
Read-only subcommands run freely. Destructive subcommands (delete-endpoint,
|
Read-only subcommands run freely. State-changing subcommands (delete-endpoint,
|
||||||
delete-package, delete-group) refuse to run unless --confirm is passed; without
|
delete-package, delete-group, move, scan, create-package, make-group, isolate,
|
||||||
it they print what they WOULD do and exit non-zero.
|
unisolate, blocklist-add/remove, assign-policy, set-label, reconfigure, push-set,
|
||||||
|
company create/suspend/activate/delete, account/report create, push-test, and any
|
||||||
|
destructive `raw` method) refuse to run unless --confirm is passed; without it
|
||||||
|
they print what they WOULD do and exit non-zero. See SKILL.md for the full list.
|
||||||
|
|
||||||
Output: --json emits raw JSON; otherwise a readable table/summary.
|
Output: --json emits raw JSON; otherwise a readable table/summary.
|
||||||
|
|
||||||
@@ -338,13 +341,6 @@ def cmd_sweep(client, args):
|
|||||||
_print_sweep_table(summaries)
|
_print_sweep_table(summaries)
|
||||||
|
|
||||||
|
|
||||||
def _require_company_for_sweep() -> str:
|
|
||||||
from gz_client import ACG_COMPANIES_CONTAINER_ID
|
|
||||||
print("[INFO] No --company given; sweeping the ACG companies container.",
|
|
||||||
file=sys.stderr)
|
|
||||||
return ACG_COMPANIES_CONTAINER_ID
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_policies(client, args):
|
def cmd_policies(client, args):
|
||||||
_emit(client.list_policies(), args.json, _print_policy_table)
|
_emit(client.list_policies(), args.json, _print_policy_table)
|
||||||
|
|
||||||
@@ -948,7 +944,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
sp.add_argument("endpoint_id")
|
sp.add_argument("endpoint_id")
|
||||||
|
|
||||||
sp = sub.add_parser("sweep", help="Live security posture sweep.", parents=[common])
|
sp = sub.add_parser("sweep", help="Live security posture sweep.", parents=[common])
|
||||||
sp.add_argument("--company", help="Parent id (defaults to ACG container).")
|
sp.add_argument("--company",
|
||||||
|
help="Parent company id. Omit to sweep ALL client companies "
|
||||||
|
"(many live API calls).")
|
||||||
|
|
||||||
sub.add_parser("policies", help="List policies (id + name).", parents=[common])
|
sub.add_parser("policies", help="List policies (id + name).", parents=[common])
|
||||||
sp = sub.add_parser("policy",
|
sp = sub.add_parser("policy",
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Credentials: never hardcoded. Loaded at runtime from the SOPS vault, or from
|
|||||||
the GRAVITYZONE_API_KEY env var (testing override).
|
the GRAVITYZONE_API_KEY env var (testing override).
|
||||||
|
|
||||||
Cache: only the IDENTITY/STRUCTURE tier is cached (company/endpoint/policy
|
Cache: only the IDENTITY/STRUCTURE tier is cached (company/endpoint/policy
|
||||||
id<->name maps, package list). Volatile status (infected, lastSeen, online,
|
id<->name maps + endpoint fqdn, package list). No secrets are ever cached;
|
||||||
signature freshness) is NEVER cached and always pulled live.
|
infra identifiers (hostnames/FQDNs) are. Volatile status (infected, lastSeen,
|
||||||
|
online, signature freshness) is NEVER cached and always pulled live. The cache
|
||||||
|
dir is gitignored.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -503,8 +505,10 @@ class GravityZoneClient:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
total = data.get("total", 0)
|
# Stop at the last page. A short page (< per_page) means no more;
|
||||||
if page * per_page >= total:
|
# do NOT rely on `total` - some responses omit it, which would
|
||||||
|
# truncate the sweep after page 1.
|
||||||
|
if len(items) < per_page:
|
||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
|
|
||||||
@@ -714,11 +718,11 @@ class GravityZoneClient:
|
|||||||
def isolate_endpoints(self, endpoint_ids: list[str]) -> Any:
|
def isolate_endpoints(self, endpoint_ids: list[str]) -> Any:
|
||||||
"""Isolate endpoints from the network (incidents.createIsolateEndpointTask).
|
"""Isolate endpoints from the network (incidents.createIsolateEndpointTask).
|
||||||
|
|
||||||
v1.2 takes an ARRAY `endpointIds` (max 1000) and returns an array of
|
VERIFIED LIVE 2026-06-21: the API takes a SINGLE `endpointId` per call
|
||||||
task ids. STATE-CHANGING - gate behind --confirm at the call site.
|
(an `endpointIds` array errors "not expected"), so we loop. Returns the
|
||||||
|
single task result for one id, else a list. STATE-CHANGING - gate behind
|
||||||
|
--confirm at the call site.
|
||||||
"""
|
"""
|
||||||
# VERIFIED LIVE 2026-06-21: the API takes a SINGLE `endpointId` per call
|
|
||||||
# (NOT an `endpointIds` array - that errors "not expected"). Loop for many.
|
|
||||||
results = []
|
results = []
|
||||||
for eid in endpoint_ids:
|
for eid in endpoint_ids:
|
||||||
results.append(self._jsonrpc_request(
|
results.append(self._jsonrpc_request(
|
||||||
@@ -729,11 +733,11 @@ class GravityZoneClient:
|
|||||||
def restore_endpoints_from_isolation(self, endpoint_ids: list[str]) -> Any:
|
def restore_endpoints_from_isolation(self, endpoint_ids: list[str]) -> Any:
|
||||||
"""Un-isolate endpoints (incidents.createRestoreEndpointFromIsolationTask).
|
"""Un-isolate endpoints (incidents.createRestoreEndpointFromIsolationTask).
|
||||||
|
|
||||||
v1.2 takes an ARRAY `endpointIds` (max 1000) and returns an array of
|
VERIFIED LIVE 2026-06-21: a SINGLE `endpointId` per call (not an array),
|
||||||
task ids. STATE-CHANGING - gate behind --confirm at the call site.
|
so we loop. Returns the single task result for one id, else a list.
|
||||||
|
Note: fails if the isolation task is still in progress - wait + retry.
|
||||||
|
STATE-CHANGING - gate behind --confirm at the call site.
|
||||||
"""
|
"""
|
||||||
# VERIFIED LIVE 2026-06-21: single `endpointId` per call (not an array).
|
|
||||||
# Note: fails if the isolation task is still in progress - wait + retry.
|
|
||||||
results = []
|
results = []
|
||||||
for eid in endpoint_ids:
|
for eid in endpoint_ids:
|
||||||
results.append(self._jsonrpc_request(
|
results.append(self._jsonrpc_request(
|
||||||
@@ -1244,8 +1248,8 @@ class GravityZoneClient:
|
|||||||
"company_id": ep.get("companyId", cid),
|
"company_id": ep.get("companyId", cid),
|
||||||
"fqdn": ep.get("fqdn") or ep.get("FQDN") or "",
|
"fqdn": ep.get("fqdn") or ep.get("FQDN") or "",
|
||||||
}
|
}
|
||||||
total = data.get("total", 0)
|
# Last page = a short page; don't rely on `total` (may be omitted).
|
||||||
if page * 100 >= total:
|
if len(items) < 100:
|
||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ check("companies table", ["companies"], want_rc=0, out_has="Companies:")
|
|||||||
check("companies json", ["companies", "--json"], want_rc=0, out_json_ok=True)
|
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 (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 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("endpoints bogus parent -> rc2 (client-side id validation)", ["endpoints", "--company", "bogus"], want_rc=2, err_has="[ERROR]")
|
||||||
check("policies", ["policies"], want_rc=0, out_has="Policies:")
|
check("policies", ["policies"], want_rc=0, out_has="Policies:")
|
||||||
check("packages", ["packages"], want_rc=0, out_has="Packages:")
|
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 (real co)", ["quarantine", "--company", ACG], want_rc=0, out_has="Quarantine items:")
|
||||||
@@ -84,26 +84,27 @@ check("push-stats (unconfigured -> rc0)", ["push-stats"], want_rc=0, out_has="no
|
|||||||
# policy detail must NOT carry the old false 'shallow' warning anymore
|
# policy detail must NOT carry the old false 'shallow' warning anymore
|
||||||
check("policy no shallow warning", ["policy", "5c42940b6e16d61a0c8b4568"], want_rc=0, err_has=None)
|
check("policy no shallow warning", ["policy", "5c42940b6e16d61a0c8b4568"], want_rc=0, err_has=None)
|
||||||
|
|
||||||
# --- error handling: a MALFORMED id (not valid hex/ObjectId) makes the API
|
# --- error handling: a MALFORMED id (not 24-char hex ObjectId) is now caught
|
||||||
# error, which must exit non-zero (1). Note: a well-formed but non-existent
|
# CLIENT-SIDE by _require_oid and exits rc2 BEFORE any API call (so it neither
|
||||||
# hex id is ACCEPTED by GravityZone and returns a stub (rc 0) -- that is the
|
# hits the live tenant nor pollutes errorlog.md). A well-formed but non-existent
|
||||||
# API's behavior, not a skill bug, so we test with a malformed value here. ---
|
# hex id is ACCEPTED by GravityZone and returns a stub (rc 0) -- the API's
|
||||||
check("endpoint bad id -> rc1", ["endpoint", "bogus"], want_rc=1, err_has="[ERROR]")
|
# behavior, not a skill bug. ---
|
||||||
check("policy bad id -> rc1", ["policy", "bogus"], want_rc=1, err_has="[ERROR]")
|
check("endpoint bad id -> rc2 (client-side)", ["endpoint", "bogus"], want_rc=2, err_has="not a valid")
|
||||||
|
check("policy bad id -> rc2 (client-side)", ["policy", "bogus"], want_rc=2, err_has="not a valid")
|
||||||
|
|
||||||
# --- argparse: missing required arg -> rc2 ---
|
# --- argparse: missing required arg -> rc2 ---
|
||||||
check("quarantine missing --company -> rc2", ["quarantine"], want_rc=2)
|
check("quarantine missing --company -> rc2", ["quarantine"], want_rc=2)
|
||||||
check("endpoint missing positional -> rc2", ["endpoint"], want_rc=2)
|
check("endpoint missing positional -> rc2", ["endpoint"], want_rc=2)
|
||||||
|
|
||||||
# --- gating: destructive without --confirm -> rc3, no API call ---
|
# --- gating: destructive without --confirm -> rc3, no API call ---
|
||||||
check("isolate no confirm -> rc3", ["isolate", "--endpoints", "x"], want_rc=3, out_has="Would")
|
check("isolate no confirm -> rc3", ["isolate", "--endpoints", "x"], want_rc=3, err_has="Would")
|
||||||
check("unisolate no confirm -> rc3", ["unisolate", "--endpoints", "x"], want_rc=3)
|
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-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("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-endpoint no confirm -> rc3", ["delete-endpoint", "x"], want_rc=3)
|
||||||
check("delete-package no confirm -> rc3", ["delete-package", "--id", "x"], want_rc=3)
|
check("delete-package no confirm -> rc3", ["delete-package", "--id", "x"], want_rc=3)
|
||||||
check("delete-group no confirm -> rc3", ["delete-group", "--group", "x"], want_rc=3)
|
check("delete-group no confirm -> rc3", ["delete-group", "--group", "x"], want_rc=3)
|
||||||
check("assign-policy no confirm -> rc3", ["assign-policy", "--policy", "p", "--targets", "x"], want_rc=3, out_has="Would")
|
check("assign-policy no confirm -> rc3", ["assign-policy", "--policy", "p", "--targets", "x"], want_rc=3, err_has="Would")
|
||||||
check("push-set no confirm -> rc3", ["push-set", "--status", "1", "--url", "https://x/y"], want_rc=3)
|
check("push-set no confirm -> rc3", ["push-set", "--status", "1", "--url", "https://x/y"], want_rc=3)
|
||||||
check("push-set enable no url -> rc2", ["push-set", "--status", "1", "--confirm"], want_rc=2)
|
check("push-set enable no url -> rc2", ["push-set", "--status", "1", "--confirm"], want_rc=2)
|
||||||
check("raw assignPolicy no confirm -> rc3", ["raw", "--module", "network", "--method", "assignPolicy", "--params", "{}"], want_rc=3)
|
check("raw assignPolicy no confirm -> rc3", ["raw", "--module", "network", "--method", "assignPolicy", "--params", "{}"], want_rc=3)
|
||||||
@@ -136,7 +137,7 @@ check("raw setEndpointLabel no confirm -> rc3", ["raw", "--module", "network", "
|
|||||||
|
|
||||||
# --- companies module ---
|
# --- companies module ---
|
||||||
check("company (own, no id)", ["company"], want_rc=0)
|
check("company (own, no id)", ["company"], want_rc=0)
|
||||||
check("company-create no confirm -> rc3", ["company-create", "--type", "1", "--name", "Test Co"], want_rc=3, out_has="Would")
|
check("company-create no confirm -> rc3", ["company-create", "--type", "1", "--name", "Test Co"], want_rc=3, err_has="Would")
|
||||||
check("company-suspend no confirm -> rc3", ["company-suspend", "--id", "x"], want_rc=3)
|
check("company-suspend no confirm -> rc3", ["company-suspend", "--id", "x"], want_rc=3)
|
||||||
check("company-activate no confirm -> rc3", ["company-activate", "--id", "x"], want_rc=3)
|
check("company-activate no confirm -> rc3", ["company-activate", "--id", "x"], want_rc=3)
|
||||||
check("company-delete no confirm -> rc3", ["company-delete", "--id", "x"], want_rc=3)
|
check("company-delete no confirm -> rc3", ["company-delete", "--id", "x"], want_rc=3)
|
||||||
@@ -144,7 +145,7 @@ check("raw createCompany no confirm -> rc3", ["raw", "--module", "companies", "-
|
|||||||
|
|
||||||
# --- accounts module ---
|
# --- accounts module ---
|
||||||
check("account (own, no id)", ["account"], want_rc=0)
|
check("account (own, no id)", ["account"], want_rc=0)
|
||||||
check("account-create no confirm -> rc3", ["account-create", "--email", "t@x.io"], want_rc=3, out_has="Would")
|
check("account-create no confirm -> rc3", ["account-create", "--email", "t@x.io"], want_rc=3, err_has="Would")
|
||||||
check("account-update no confirm -> rc3", ["account-update", "--id", "a", "--set-json", "{\"role\":5}"], want_rc=3)
|
check("account-update no confirm -> rc3", ["account-update", "--id", "a", "--set-json", "{\"role\":5}"], want_rc=3)
|
||||||
check("account-update bad json -> rc2", ["account-update", "--id", "a", "--set-json", "{bad", "--confirm"], want_rc=2)
|
check("account-update bad json -> rc2", ["account-update", "--id", "a", "--set-json", "{bad", "--confirm"], want_rc=2)
|
||||||
check("account-delete no confirm -> rc3", ["account-delete", "--id", "a"], want_rc=3)
|
check("account-delete no confirm -> rc3", ["account-delete", "--id", "a"], want_rc=3)
|
||||||
|
|||||||
Reference in New Issue
Block a user