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>
109 lines
5.1 KiB
Python
109 lines
5.1 KiB
Python
#!/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)
|