Security · python · 32 copies
Panorama Unused Object Finder
Finds network objects in Palo Alto Panorama that no policy references and exports them to CSV, honouring device-group inheritance, nested groups and overrides. Separates verified unused from transitively dead from could-not-verify, never deletes anything, and states in every report which reference classes it did not read.
- PAN-OS
pip install "requests>=2.28"#!/usr/bin/env python3
"""
Panorama Unused Object Finder
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/panorama-unused-objects
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "requests>=2.28"
"""
import argparse
import csv
import os
import sys
import xml.etree.ElementTree as ET
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Object classes this tool inventories, as (xml tag, human label).
OBJECT_CLASSES = [
("address", "address"),
("address-group", "address group"),
("service", "service"),
("service-group", "service group"),
("application-group", "application group"),
("tag", "tag"),
("external-list", "external dynamic list"),
]
# Reference classes scanned inside a rulebase. Every one of these is a place an
# object can be used; missing one is how a tool reports an in-use object as
# unused, which is how somebody deletes something in production.
RULEBASES = [
"security", "nat", "pbf", "decryption", "application-override",
"qos", "authentication", "dos", "sdwan-policy-based-forwarding",
"tunnel-inspect", "network-packet-broker",
]
# Element names inside a rule that can name an object.
RULE_REF_FIELDS = [
"source", "destination", "service", "application", "tag", "group-tag",
"source-translation", "destination-translation", "translated-address",
"dynamic-ip-and-port", "dynamic-ip", "static-ip", "translated-port",
"address", "snat-address", "to-interface", "source-address",
"destination-address",
]
# Things this tool does NOT read. Printed in every report, because "I did not
# check this" and "this is unused" are different facts.
NOT_CHECKED = [
"Template and template-stack configuration (service routes, log forwarding "
"profiles, interface and zone definitions, DHCP, IKE gateways)",
"Security profiles and profile groups that reference EDLs or custom objects",
"Dynamic address group match expressions that resolve by tag at runtime",
"Objects referenced only on a managed firewall's local configuration, "
"outside Panorama",
"Anything referenced by an external system through the API",
]
class Obj:
__slots__ = ("name", "kind", "location", "used_by", "shadowed_by",
"tags", "transitively_dead")
def __init__(self, name, kind, location):
self.name = name
self.kind = kind
self.location = location
self.used_by = []
self.shadowed_by = []
self.tags = []
self.transitively_dead = False
def fetch_config(host, api_key, verify):
"""Pull the whole running configuration in one call.
One export rather than dozens of per-object queries: nested groups,
device-group inheritance and overrides are all resolvable from a single
document, and it avoids guessing an xpath per object class.
"""
import requests
import urllib3
if not verify:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
url = "https://%s/api/" % host
params = {"type": "config", "action": "get", "xpath": "/config", "key": api_key}
r = requests.get(url, params=params, verify=verify, timeout=180)
r.raise_for_status()
root = ET.fromstring(r.content)
status = root.get("status")
if status != "success":
msg = root.findtext(".//msg") or r.text[:300]
raise SystemExit("ERROR: Panorama returned status=%s: %s" % (status, msg))
result = root.find("result")
if result is None or len(result) == 0:
raise SystemExit("ERROR: the API returned an empty configuration.")
return result[0]
def load_config_file(path):
try:
tree = ET.parse(path)
except FileNotFoundError:
raise SystemExit("ERROR: file not found: %s" % path)
except ET.ParseError as exc:
raise SystemExit("ERROR: %s is not valid XML: %s" % (path, exc))
root = tree.getroot()
# Accept either a bare <config> or a full API response wrapping it.
if root.tag == "response":
result = root.find("result")
if result is None or len(result) == 0:
raise SystemExit("ERROR: no <config> inside the API response.")
return result[0]
return root
def device_groups(config):
"""Return {name: parent_name_or_None}, honouring the DG hierarchy."""
out = {}
for dg in config.findall("./devices/entry/device-group/entry"):
out[dg.get("name")] = None
for pe in config.findall("./readonly/devices/entry/device-group/entry") or []:
pass
# Parent relationships live in /config/devices/entry/device-group/entry/..
# or in the Panorama dg-meta-data section depending on version.
for e in config.findall(".//dg-meta-data/dginfo/entry"):
name = e.get("name")
parent = e.findtext("parent-dg")
if name in out:
out[name] = parent or None
return out
def ancestors(dg, parents):
"""shared -> ... -> dg, nearest-last."""
chain, seen, cur = [], set(), dg
while cur and cur not in seen:
seen.add(cur)
chain.append(cur)
cur = parents.get(cur)
chain.append("shared")
return list(reversed(chain))
def descendants(dg, parents):
out, changed = {dg}, True
while changed:
changed = False
for child, parent in parents.items():
if parent in out and child not in out:
out.add(child)
changed = True
return out
def location_roots(config):
"""Yield (location_name, element) for shared and every device group."""
shared = config.find("./shared")
if shared is not None:
yield "shared", shared
for dg in config.findall("./devices/entry/device-group/entry"):
yield dg.get("name"), dg
def collect_objects(config):
objs = {}
for loc, root in location_roots(config):
for tag, kind in OBJECT_CLASSES:
for e in root.findall("./%s/entry" % tag):
name = e.get("name")
if not name:
continue
o = Obj(name, kind, loc)
o.tags = [m.text.strip() for m in e.findall("./tag/member")
if m.text and m.text.strip()]
objs[(loc, kind, name)] = o
return objs
def iter_text(elem):
"""Every member/text value under an element, flattened."""
for m in elem.iter():
if m.text and m.text.strip():
yield m.text.strip()
def collect_references(config, parents):
"""Build {(scope, name): [reference descriptions]}.
`scope` is the location whose objects a reference can resolve against:
a rule in device group DG can resolve to DG, any ancestor, or shared.
"""
refs = {}
def add(scope_dg, value, where):
refs.setdefault((scope_dg, value), []).append(where)
for loc, root in location_roots(config):
# 1. Group membership - nested groups are the class most often missed.
for tag, kind in (("address-group", "address group"),
("service-group", "service group"),
("application-group", "application group")):
for g in root.findall("./%s/entry" % tag):
gname = g.get("name")
for m in g.findall(".//member"):
if m.text:
add(loc, m.text.strip(), "%s '%s'" % (kind, gname))
# A dynamic address group matches by tag, not by member.
df = g.find("./dynamic/filter")
if df is not None and df.text:
for token in df.text.replace("(", " ").replace(")", " ").split():
t = token.strip().strip("'\"")
if t and t.lower() not in ("and", "or", "not"):
add(loc, t, "dynamic group '%s' filter" % gname)
# 2. Tags attached to objects.
for tag, _kind in OBJECT_CLASSES:
for e in root.findall("./%s/entry" % tag):
for m in e.findall("./tag/member"):
if m.text:
add(loc, m.text.strip(), "tag on %s '%s'" % (tag, e.get("name")))
# 3. Every rulebase, pre and post.
for base in RULEBASES:
for stage in ("pre-rulebase", "post-rulebase", "rulebase"):
for rule in root.findall("./%s/%s/rules/entry" % (stage, base)):
rname = rule.get("name")
where = "%s %s rule '%s'" % (stage, base, rname)
for field in RULE_REF_FIELDS:
for fe in rule.findall(".//%s" % field):
for val in iter_text(fe):
add(loc, val, where)
return refs
def resolve(objs, refs, parents):
"""Mark each object used if anything in a scope that can see it names it."""
for (loc, kind, name), obj in objs.items():
if loc == "shared":
scopes = set(parents.keys()) | {"shared"}
else:
scopes = descendants(loc, parents) | {loc}
for scope in scopes:
for where in refs.get((scope, name), []):
obj.used_by.append("%s: %s" % (scope, where))
# Override: a device group defining the same name shadows the ancestor
# copy inside that subtree. The ancestor is NOT unused globally.
if loc == "shared":
for dg in parents:
if (dg, kind, name) in objs:
obj.shadowed_by.append(dg)
# An object whose only references come from containers that are themselves
# unreferenced is dead too - it is being kept alive by a corpse. Commercial
# tools call this transitive; most home-grown scripts miss it entirely.
by_name = {}
for (loc, kind, name), o in objs.items():
by_name.setdefault(name, []).append(o)
def container_of(where):
# "DG: address group 'X'" / "DG: service group 'X'"
if "group '" in where:
return where.split("group '", 1)[1].rstrip("'")
return None
for _ in range(8): # iterate: a chain of dead groups collapses
changed = False
for o in objs.values():
if not o.used_by or o.transitively_dead:
continue
holders = [container_of(w) for w in o.used_by]
if not all(holders):
continue # at least one reference is a rule, so alive
alive = False
for h in holders:
for cand in by_name.get(h, []):
if cand.used_by and not cand.transitively_dead:
alive = True
if not alive:
o.transitively_dead = True
changed = True
if not changed:
break
return objs
RESERVED = {"any", "application-default", "none", "all"}
CSV_HEADER = ["location", "object_type", "name", "verdict",
"safe_to_delete", "referenced_by", "note"]
def write_csv(path, rows):
"""One row per object, with the verdict as a column.
CSV rather than xlsx on purpose: the standard library writes it, Excel
and Sheets both open it, and a cleanup project lives in a spreadsheet
where someone filters by verdict and assigns an owner per row.
"""
try:
with open(path, "w", encoding="utf-8-sig", newline="") as fh:
w = csv.writer(fh)
w.writerow(CSV_HEADER)
w.writerows(rows)
except OSError as exc:
print("WARNING: could not write %s: %s" % (path, exc))
return
print("CSV written: %s (%d rows)" % (path, len(rows)))
def report(objs, parents, show_used, limit, csv_path=None):
rows = []
by_loc = {}
for obj in objs.values():
by_loc.setdefault(obj.location, []).append(obj)
total_unused = 0
print("=" * 78)
print("panorama-unused-objects")
print("=" * 78)
print("Locations: shared plus %d device group(s)" % len(parents))
if parents:
for dg, parent in sorted(parents.items()):
print(" %-32s parent=%s" % (dg, parent or "shared"))
print()
for loc in ["shared"] + sorted(parents):
items = by_loc.get(loc, [])
if not items:
continue
unused = [o for o in items
if (not o.used_by or o.transitively_dead)
and o.name.lower() not in RESERVED]
shadowed = [o for o in unused if o.shadowed_by]
rest = [o for o in unused if not o.shadowed_by]
# A TAGGED object can be matched at runtime by a dynamic address group,
# so it is never a plain "unused" - that would be the exact false
# positive that gets something deleted in production.
tagged = [o for o in rest if o.tags and not o.transitively_dead]
transitive = [o for o in rest if o.transitively_dead]
clean = [o for o in rest if o not in tagged and o not in transitive]
print("-" * 78)
print("%s - %d object(s), %d with no reference found"
% (loc, len(items), len(unused)))
print("-" * 78)
for o in clean:
rows.append([loc, o.kind, o.name, "UNUSED", "candidate", "", ""])
for o in transitive:
rows.append([loc, o.kind, o.name, "UNUSED_TRANSITIVE", "candidate",
o.used_by[0] if o.used_by else "",
"only referenced by a group that is itself unused"])
for o in tagged:
rows.append([loc, o.kind, o.name, "COULD_NOT_VERIFY", "no",
"", "tagged (%s) - a dynamic address group may match "
"it at runtime" % ", ".join(o.tags)])
for o in shadowed:
rows.append([loc, o.kind, o.name, "COULD_NOT_VERIFY", "no", "",
"overridden in: %s" % ", ".join(o.shadowed_by)])
for o in items:
if o.used_by and not o.transitively_dead:
rows.append([loc, o.kind, o.name, "IN_USE", "no",
o.used_by[0]
+ ("" if len(o.used_by) == 1
else " (+%d more)" % (len(o.used_by) - 1)), ""])
if clean:
print(" UNUSED (no reference found anywhere that can see them):")
for o in sorted(clean, key=lambda x: (x.kind, x.name))[:limit]:
print(" %-22s %s" % (o.kind, o.name))
if len(clean) > limit:
print(" ... and %d more (raise --limit)" % (len(clean) - limit))
total_unused += len(clean)
else:
print(" No unreferenced objects found.")
if transitive:
print()
print(" UNUSED TRANSITIVELY - referenced only by groups that are")
print(" themselves unreferenced. Delete the container first.")
for o in sorted(transitive, key=lambda x: (x.kind, x.name))[:limit]:
print(" %-22s %-34s via %s"
% (o.kind, o.name, o.used_by[0].split(": ", 1)[-1]))
total_unused += len(transitive)
if tagged:
print()
print(" COULD NOT VERIFY - tagged, so a dynamic address group may")
print(" match them at runtime. Check the DAG filters before acting.")
for o in sorted(tagged, key=lambda x: (x.kind, x.name))[:limit]:
print(" %-22s %-34s tags: %s"
% (o.kind, o.name, ", ".join(o.tags)))
if shadowed:
print()
print(" COULD NOT VERIFY - overridden in a child device group.")
print(" A device group defines an object with the same name, so this")
print(" copy may still be the one in effect elsewhere. Do not delete")
print(" on the strength of this report.")
for o in sorted(shadowed, key=lambda x: (x.kind, x.name))[:limit]:
print(" %-22s %-34s overridden in: %s"
% (o.kind, o.name, ", ".join(o.shadowed_by[:4])))
if show_used:
used = [o for o in items if o.used_by]
print()
print(" IN USE (%d):" % len(used))
for o in sorted(used, key=lambda x: (x.kind, x.name))[:limit]:
print(" %-22s %-34s %s"
% (o.kind, o.name, o.used_by[0]
+ ("" if len(o.used_by) == 1
else " (+%d more)" % (len(o.used_by) - 1))))
print()
print("=" * 78)
print("WHAT THIS REPORT DID NOT CHECK")
print("=" * 78)
for line in NOT_CHECKED:
print(" - %s" % line)
print()
print(" An object listed as unused is a CANDIDATE TO INVESTIGATE, not a")
print(" delete list. This tool never deletes anything.")
print()
print("Total candidates: %d" % total_unused)
if csv_path:
write_csv(csv_path, rows)
return total_unused
def main():
ap = argparse.ArgumentParser(
description="Find network objects in Panorama that no policy "
"references. Read-only; never deletes anything.")
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--host", help="Panorama hostname or IP")
src.add_argument("--config-file",
help="A configuration XML already exported from Panorama. "
"Lets you run this with no credentials at all.")
ap.add_argument("--insecure", action="store_true",
help="Skip TLS verification (known self-signed certificate only)")
ap.add_argument("--show-used", action="store_true",
help="Also list objects that ARE referenced, and by what")
ap.add_argument("--limit", type=int, default=50,
help="Maximum objects listed per section (default 50)")
ap.add_argument("--csv", metavar="PATH",
help="Also write every object and its verdict to a CSV, "
"for the spreadsheet the cleanup actually lives in")
ap.add_argument("--save-config", metavar="PATH",
help="Write the fetched configuration to a file for re-runs")
args = ap.parse_args()
if args.host:
key = os.environ.get("PAN_API_KEY")
if not key:
raise SystemExit(
"ERROR: PAN_API_KEY must be set in the environment.\n"
"\n"
" Generate one:\n"
" curl -k 'https://PANORAMA/api/?type=keygen&user=USER&password=PASS'\n"
"\n"
" Linux / macOS: export PAN_API_KEY='LUFRPT...'\n"
" PowerShell: $env:PAN_API_KEY='LUFRPT...'\n"
"\n"
"Read from the environment on purpose: an argument is visible in\n"
"`ps auxww` and lands in shell history.")
try:
import requests # noqa: F401
except ImportError:
raise SystemExit("ERROR: pip install requests")
import requests
try:
config = fetch_config(args.host, key, not args.insecure)
except requests.exceptions.SSLError as exc:
raise SystemExit(
"ERROR: TLS verification failed for %s: %s\n"
"Use --insecure only for a known self-signed certificate."
% (args.host, exc))
except requests.exceptions.RequestException as exc:
raise SystemExit("ERROR: could not reach %s: %s" % (args.host, exc))
except ET.ParseError as exc:
raise SystemExit("ERROR: the response was not valid XML: %s" % exc)
if args.save_config:
ET.ElementTree(config).write(args.save_config, encoding="utf-8")
print("Configuration saved to %s" % args.save_config)
else:
config = load_config_file(args.config_file)
parents = device_groups(config)
objs = collect_objects(config)
if not objs:
print("No objects found. Is this a Panorama configuration, and does the")
print("API key's admin role allow reading it?")
return 2
refs = collect_references(config, parents)
resolve(objs, refs, parents)
n = report(objs, parents, args.show_used, args.limit, args.csv)
return 1 if n else 0
if __name__ == "__main__":
sys.exit(main())
--hosttextPanorama hostname or IP. Needs PAN_API_KEY in the environment. Mutually exclusive with --config-file
--config-filetextA configuration XML already exported from Panorama. No credentials and no network connection at all
--insecureflagSkip TLS verification. Only for a management certificate you recognise
--show-usedflagAlso list objects that ARE referenced and by what, to prove a specific object is in use
--limitnumberMaximum objects listed per section. Default 50
--csvtextAlso write every object and its verdict to a CSV, for the spreadsheet the cleanup actually lives in. Opens in Excel
--save-configtextWrite the fetched configuration to a file so later runs can work offline
What This Solves
A firewall estate that has been running for a decade has thousands of address objects, service objects and groups. Nobody knows which ones still matter. Cleaning them up is a real, recurring, well-paid job, and the commercial tools that do it - AlgoSec, FireMon, Skybox, Tufin - are expensive. Panorama has no built-in answer. There is no 'show unused objects' view and no API filter for it, unlike FMC which at least has one for network addresses. This script reads the Panorama configuration and reports which objects no policy references. The hard part is not reading the API. It is being RIGHT. An object can look unreferenced and still be in use, and reporting it as unused is how somebody deletes something in production. So this tool is built around a distinction it never blurs: VERIFIED UNUSED nothing that can see it references it UNUSED TRANSITIVELY referenced only by groups that are dead themselves COULD NOT VERIFY something about it cannot be resolved from the config It never deletes anything, and it says in every report which reference classes it did not read.
Is It Safe to Run
Completely. It is read-only, and it can run with no credentials at all. - Against a live Panorama it issues ONE API call: type=config, action=get, xpath=/config. That is a read of the running configuration. - It never writes, never commits, never deletes. - With --config-file it reads an XML file you already exported and makes no network connection whatsoever. Run it on a laptop with no access to the firewall estate. One call rather than dozens is deliberate: nested groups, device-group inheritance and overrides are all resolvable from a single document, and it avoids hammering the API.
Before You Start
- Python 3.8 or newer - pip install requests (only needed for --host; not for --config-file) - For --host: a Panorama API key, and an admin role that can read the configuration Generating an API key: curl -k "https://PANORAMA/api/?type=keygen&user=USER&password=PASS" Then: export PAN_API_KEY='LUFRPT...' # Linux / macOS $env:PAN_API_KEY='LUFRPT...' # PowerShell set PAN_API_KEY=LUFRPT... # CMD Read from the environment on purpose: an argument is visible in ps auxww while the process runs and lands in shell history.
Your First Run
The safest first run touches nothing. Export the configuration once, then work offline: 1. Export it: curl -k "https://PANORAMA/api/?type=config&action=get&xpath=/config&key=$PAN_API_KEY" \ -o panorama.xml 2. Run against the file: python3 panorama_unused_objects.py --config-file panorama.xml 3. Read the output bottom-up: start with WHAT THIS REPORT DID NOT CHECK, then the COULD NOT VERIFY sections, then the candidates. 4. Get it into a spreadsheet, which is where a cleanup actually happens: python3 panorama_unused_objects.py --config-file panorama.xml --csv unused.csv Against a live Panorama, and saving the config for repeat runs: export PAN_API_KEY='LUFRPT...' python3 panorama_unused_objects.py --host panorama.example.com \ --save-config panorama.xml
What the Output Means
============================================================== panorama-unused-objects ============================================================== Locations: shared plus 2 device group(s) DG-CHILD parent=DG-PARENT DG-PARENT parent=shared -------------------------------------------------------------- shared - 16 object(s), 7 with no reference found -------------------------------------------------------------- UNUSED (no reference found anywhere that can see them): address group GRP-ORPHAN service SVC-UNUSED tag never-applied UNUSED TRANSITIVELY - referenced only by groups that are themselves unreferenced. Delete the container first. address OLD-DECOM-2019 via address group 'GRP-ORPHAN' COULD NOT VERIFY - tagged, so a dynamic address group may match them at runtime. Check the DAG filters before acting. address DAG-TAGGED tags: prod COULD NOT VERIFY - overridden in a child device group. address OVERRIDDEN-OBJ overridden in: DG-CHILD Read it as four different statements, because they are: UNUSED No rule, no group and no tag assignment anywhere that can see this object names it. The strongest claim the tool makes, and still a candidate rather than a decision. Unused transitively The object IS referenced - but only by a group which is itself unreferenced. It is being kept alive by a corpse. Delete the container first, then re-run: the member becomes a plain UNUSED. Most home-grown scripts miss this entirely and report the member as in use forever. COULD NOT VERIFY - tagged The object carries a tag, and a dynamic address group resolves its members by tag AT RUNTIME, not in the configuration. The config cannot tell you whether a DAG currently matches it. Listing it as unused would be exactly the false positive that gets something deleted. COULD NOT VERIFY - overridden A device group defines an object with the same name, shadowing this one in that subtree. The shared copy may still be the one in effect elsewhere. The DEVICE GROUP HIERARCHY is honoured, and this is the part that makes the result trustworthy. An object in shared is visible to every device group. An object in a parent device group is visible to its descendants. So an object defined in DG-PARENT and used only by a rule in DG-CHILD is correctly reported as IN USE. A tool that checks each device group in isolation reports it as unused - and that is a deletion waiting to happen. Reference classes that ARE scanned: - Every rulebase: security, nat, pbf, decryption, application-override, qos, authentication, dos, sdwan pbf, tunnel-inspect, packet-broker - Both pre-rulebase and post-rulebase, plus local rulebase - Group membership, including NESTED groups to any depth - Dynamic address group filter expressions (the tags they name) - Tags attached to objects - NAT translated addresses, source and destination translation And the ones that are NOT, which the report prints every time: - Template and template-stack configuration: service routes, log forwarding, interfaces, zones, DHCP, IKE gateways - Security profiles and profile groups referencing EDLs or custom objects - Which objects a dynamic address group matches right now - A managed firewall's own local configuration, outside Panorama - Anything referenced by an external system through the API That list is the point. A cleanup tool that does not state what it did not look at is not safe to act on. The csv --csv writes one row per object, with the verdict as a column, so the report stops being something you read and becomes something you work from. It opens directly in Excel, LibreOffice and Sheets. location,object_type,name,verdict,safe_to_delete,referenced_by,note shared,address group,GRP-ORPHAN,UNUSED,candidate,, shared,address,OLD-DECOM-2019,UNUSED_TRANSITIVE,candidate,shared: addre... shared,address,DAG-TAGGED,COULD_NOT_VERIFY,no,,tagged (prod) - a dynamic... shared,address,SRV-WEB-01,IN_USE,no,shared: address group 'GRP-OUTER', Every object appears, including the ones in use. That is deliberate: filter on verdict to get the candidates, and keep the IN_USE rows so that when somebody asks 'what about this one', the answer is in the same file. verdict UNUSED, UNUSED_TRANSITIVE, COULD_NOT_VERIFY, IN_USE safe_to_delete candidate or no. Never yes - this tool does not grant permission, it narrows the list referenced_by the first reference found, with a count of the rest note why it could not be verified, where applicable Sort by verdict, assign an owner per row, and the cleanup becomes a list of tickets instead of an argument.
Exit Codes for Cron and CI
0 No candidates found. 1 At least one unused candidate. 2 Could not read the configuration, or the configuration had no objects. Exit 1 is informational rather than a failure - suitable for a scheduled report. Do not wire this to anything that deletes.
Every Flag
Arguments --host HOST Panorama hostname or IP. Needs PAN_API_KEY. --config-file PATH A configuration XML already exported. No credentials and no network connection at all. Mutually exclusive with --host. --insecure Skip TLS verification. Known self-signed certificate only. --show-used Also list the objects that ARE referenced, and by what. Useful for proving a specific object is in use. --limit N Maximum objects listed per section. Default 50. --csv PATH Also write every object and its verdict to a CSV. --save-config PATH Write the fetched configuration to a file, so later runs can use --config-file. Credentials - environment only PAN_API_KEY Panorama API key (required for --host) Object classes inventoried address, address-group, service, service-group, application-group, tag, external-list
When Something Fails
ERROR: PAN_API_KEY must be set in the environment Exported in another shell, or in one that has closed. Export it again here. ERROR: Panorama returned status=error Usually an API key that has expired or an admin role without read access to the configuration. Regenerate the key with type=keygen and try the same xpath in a browser. ERROR: TLS verification failed The management certificate is self-signed. Use --insecure, and only for a device whose certificate you recognise. No objects found Either this is a firewall configuration rather than a Panorama one - a firewall keeps objects under vsys, not under shared and device groups - or the admin role filtered them out of the response. An object you know is in use is reported as unused Check the NOT CHECKED list first: templates and security profiles are the usual answer. If it is none of those, the reference class is missing from the tool and that is worth reporting - a missed reference class is the one defect in this kind of tool that actually causes damage. Everything shows as unused The configuration was exported with a narrower xpath than /config, so the rulebases are not in the file. Re-export with xpath=/config. The device group hierarchy looks wrong in the header Parent relationships are read from the dg-meta-data section. If a device group shows parent=shared when it has a parent, the export did not include that section - re-export the whole configuration.