# High-Yield Unused-Object Cleanup (Panorama)

A tight, command-focused playbook for shrinking a Panorama config without breaking traffic. Work **one device group per change window**: pre-flight (size map and stats), then **rule cleanup first** (pick a phase that fits your org), **burn in for at least a week**, then **object cleanup** as a chained address-then-service-then-tag pass, and finally a **diff verification** that proves rules are byte-identical and only objects were removed.

Each command below gets its own page with a one-line "what's doing the work" callout and short *what / why / how* notes. Caveats live inline as alert blocks next to the command they apply to. The operational rollout discipline around each change window — active-only push, monitoring window, standby-as-rollback, failover decision — is your existing change-management process; this guide stops at the commands.

## Quick context

Every command in this playbook shares the same plumbing. The interesting differences are in the last three arguments.

| Static prefix | Meaning |
|---|---|
| `pan-os-php` | The CLI binary. |
| `in=PanoramaName.xml` | The exported Panorama config (or `in=api://MGMT-IP` for live API). |
| `location=DG-Production template=any` | Scope the work to one device group. |

`DG-Production` is a placeholder — replace it with your real device-group name. Each command targets one DG per change window; cleaning at `location=any` is fine for read-only baselines but unmanageable for destructive runs across a multi-site estate.

The interesting part of every command is `type` + `actions` + `filter`. Everything before that is plumbing.

`out=` is **only** used on actions that mutate the config (`actions=delete`). Never pass `out=` on `display`, `displayreferences`, or `display-shadowrule` — those are read-only and the file would be misleading noise.

The order is fixed and deliberate: **rule cleanup first** (Step C1, C2, or C3 — pick the one your org will accept), then **burn in for a week** so real traffic proves nothing broke, then **object cleanup** as a chain (Step D1 → D2 → D3) where each step's `out=` becomes the next step's `in=`, then **diff verification** (Step E) as the final trust gate. Skipping rule cleanup is a valid conservative path that gives lower yield with the same safety guarantees.

<!-- page-break -->
## Step A. Config-size map

```bash
pan-os-php type=config-size in=PanoramaName.xml
```

> **What's doing the work:** `type=config-size` · whole-config scope (no `location=`)

### What it does
Walks the entire Panorama XML and emits a per-DG, per-section byte breakdown — addresses, services, tags, address-groups, service-groups, rulebases — ranked by size. The output is a structural inventory of where weight lives in the config.

### Why this combo
This is the only command in the playbook that runs without `location=`. Cleanup yield correlates directly with section size: a 2 MB DG repays roughly 4x the change-window cost of a 500 KB DG. Run this once before scoping the project so you target high-yield DGs first instead of working alphabetically.

### How it works
Top-down XML traversal counting bytes per scope and per object class. No API call, no filter, no mutation — pure structural read of the file.

> [!NOTE]
> Read-only. There is no `out=` because nothing is being modified.

> [!TIP]
> Run this **before and after** a cleanup pass against the same DG. The delta is the yield number for the change record.

> [!IMPORTANT]
> For large estates, re-export and re-run at the start of each change window. A size map taken weeks ago will point you at DGs that have already been cleaned or that have shifted in weight, and you will misallocate effort.

<!-- page-break -->
## Step B. Per-type stats baseline (4 commands)

```bash
pan-os-php in=PanoramaName.xml location=DG-Production template=any \
    type=address actions=display stats

pan-os-php in=PanoramaName.xml location=DG-Production template=any \
    type=service actions=display stats

pan-os-php in=PanoramaName.xml location=DG-Production template=any \
    type=tag     actions=display stats

pan-os-php in=PanoramaName.xml location=DG-Production template=any \
    type=rule    actions=display stats
```

> **What's doing the work:** `type={address|service|tag|rule}` · `actions=display` · `stats`

### What it does
Captures the four object-class counts (addresses, services, tags, rules) for the target DG. Re-run after the cleanup chain finishes and the per-class delta is the yield for the change record.

### Why this combo
`stats` short-circuits the per-entry display path and emits counts only — the cheapest read against the DG. No filter, because the baseline must include every entry; a filter here would silently distort the after-comparison. Four commands instead of one keeps each output narrow and trivially diffable as four small files.

### How it works
Each command is a read-only XML walk scoped by `location=`. `stats` skips entry rendering and reports a count per class. Fast even on multi-megabyte DGs because there is no per-entry formatting.

> [!NOTE]
> Pipe each one to `tee filename.txt` (e.g. `… stats | tee baseline_address.txt`) so the change record has the raw numbers, not just a screenshot.

> [!TIP]
> Capturing the baseline as four small files (one per type) makes the after-comparison a trivial side-by-side. Single-table aggregation is harder to diff later.

> [!IMPORTANT]
> Capture all four counts every time. A single class in isolation understates the yield — addresses tend to dominate by count, but tag and service deltas matter for downstream policy hygiene. The four numbers together are the headline: "DG-Production: N1/N2/N3/N4 → M1/M2/M3/M4 in one window."

<!-- page-break -->
## Step C1. Phase 1 — Zero-hit rules

```bash
pan-os-php in=api://MGMT-IP location=DG-Production \
    type=rule actions=display 'filter=(rule is.unused.fast)' \
    | tee phase1_zero_hit_rules.txt
```

> **What's doing the work:** `type=rule` · `actions=display` · `filter=(rule is.unused.fast)`

### What it does
Lists every security rule in the target DG that Panorama reports as having zero hits over its hit-count lookback window. Output is a candidate list of never-matched rules — the largest single cut available in one pass when the org's policy footprint allows it.

### Why this combo
`is.unused.fast` is a hit-count-driven filter. It is the most aggressive rule cut in the playbook because it treats operational signal (zero matches) as deletion criteria. Safe only when the DG contains no DR rules, no change-window-only rules, no seasonal policies, and no audit-only rules — those legitimately sit at zero hits and would be incorrectly flagged. When that profile does not fit, drop to Step C2 or C3.

### How it works
The `.fast` variant requires hit-count metrics, which only exist on the live system — hence `in=api://MGMT-IP`. The filter queries Panorama's API for per-rule hit data and emits any rule where the hit count for the configured lookback is zero. No XML walk; the data source is the live counter store.

> [!CAUTION]
> Zero hits does NOT mean unneeded. DR rules, change-window-only rules, seasonal policies, and audit-only rules all have legitimate zero hits. **Many organizations cannot safely adopt this phase** — verify with application owners before treating the output as deletable.

> [!IMPORTANT]
> API mode required (`in=api://MGMT-IP`). The command needs Panorama API connectivity and credentials.

> [!NOTE]
> `display`-only — no `out=`. The output is a candidate list; deletion happens in the Panorama UI after admin approval, so the change is captured by the normal commit-approval workflow.

<!-- page-break -->
## Step C2. Phase 2 — Surgical human review

```bash
pan-os-php in=PanoramaName.xml location=DG-Production \
    type=rule actions=display \
    | tee phase2_rule_inventory.txt
```

> **What's doing the work:** `type=rule` · `actions=display` · no filter

### What it does
Emits the complete rule inventory for the target DG as a flat list. The captured file is the working artifact for a hand-review pass — the admin marks deletion candidates row by row.

### Why this combo
When zero-hit deletion is not acceptable but yield is still wanted, the right input to a hand-review is the unfiltered list. A filter would hide rules and bias the review; the whole point is admin discretion against the full rulebase. Slower per rule than C1 or C3, but the org keeps full control over every cut.

### How it works
Read-only XML walk against the exported config. No API call, no hit-count data, no filter. `tee` produces the working document; deletions happen in the Panorama UI after the admin marks candidates against it.

> [!TIP]
> Highest-yield review patterns: rules referencing decommissioned sites, rules tied to ended projects, rules pointing at off-network vendors, and rules whose description references a past date. These four patterns recover most of the yield without touching anything ambiguous.

> [!NOTE]
> No filter is by design. A filter on a hand-review command would silently drop entries the reviewer needed to see.

> [!IMPORTANT]
> Phase 2 is the **default path** for most regulated orgs. It avoids the C1 false-positive risk, runs offline against an exported config (no API dependency), and the captured file is itself an audit artifact — exactly what was reviewed and what was decided. Slow per rule, but safe and defensible.

<!-- page-break -->
## Step C3. Phase 3 — Shadow rule cut

```bash
pan-os-php in=api://MGMT-IP actions=display-shadowrule \
    | tee phase3_shadow_rules.txt
```

> **What's doing the work:** `actions=display-shadowrule` (Panorama-side analysis, all DGs)

### What it does
Asks Panorama's shadow-rule analyzer to report every rule that is shadowed by a higher-priority rule. Output for each pair: the shadowed rule, the shadowing rule, both rulebase positions, and the match fields where the upper rule supersedes the lower.

### Why this combo
This is the safest auto-detected rule cleanup available. C1 leans on operational signal (hit counts) and C2 leans on human judgment; shadow analysis is **structural** — Panorama is declaring the rule unreachable regardless of what traffic does or does not arrive. The analyzer does its own filtering, so there is no `filter=` argument; the report is already curated.

### How it works
For every rule, Panorama's analyzer compares match criteria — zones, source/dest addresses, applications, services, users, HIP profile, URL category — against every rule above it in the evaluation order, including ancestor device groups and shared scope. When an upper rule is a superset across all match fields, the lower rule is reported as shadowed. The pan-os-php action is a thin wrapper that invokes the analyzer via the API and renders the report.

> [!IMPORTANT]
> API mode required (`in=api://...`) **and** PAN-OS version >= 9.1. Earlier PAN-OS does not expose this analysis. The command will fail cleanly on older firmware.

> [!NOTE]
> Report-only — no `out=`, no automatic deletion. The cut happens manually in Panorama UI after the admin reviews each shadow pair.

> [!TIP]
> The **highest-confidence delete** in this entire playbook is a rule that appears in Step C1 (zero hits) **and** in Step C3 (officially shadowed). Both signals — operational and structural — agree the rule is dead.

> [!CAUTION]
> If the shadowed rule was *meant* to be the active policy and a broader rule landed above it later, the right action is to **promote it ABOVE the shadowing rule**, not delete it. Both choices are safe; they are different design decisions. Promote when the narrower behavior is what you actually want; delete when the broader rule is correct and the narrower one is leftover.

> [!NOTE]
> Cutting shadow rules unlocks more **object** cleanup yield in the next step. Addresses, services, and tags previously "in use" by dead rules become provably unused once the dead rule is gone — which is why rule cleanup belongs before object cleanup, not the other way around.

<!-- page-break -->
## Step D1. Address chain (preview-then-delete)

```bash
# 1) Preview the candidate list
pan-os-php in=PanoramaName.xml location=DG-Production template=any \
    type=address actions=display \
    'filter=(object is.unused.recursive)' \
    | tee preview_address.txt

# 2) Tagged suspects — surface every tagged unused candidate (DAG safety net)
pan-os-php in=PanoramaName.xml location=DG-Production template=any \
    type=address actions=display \
    'filter=((object is.unused.recursive) and (object tag.count > 0))' \
    | tee preview_address_tagged.txt

# 3) Hand-review with full reference paths
pan-os-php in=PanoramaName.xml location=DG-Production template=any \
    type=address actions=displayreferences \
    'filter=(object is.unused.recursive)' \
    | tee preview_address_refs.txt

# 4) Execute the delete; chain output to D2
pan-os-php in=PanoramaName.xml location=DG-Production template=any \
    type=address 'actions=delete' \
    'filter=(object is.unused.recursive)' \
    out=Pan_DG-Production_after_address.xml
```

> **Preview:** `type=address` · `actions=display` · `filter=(object is.unused.recursive)`
>
> **Tagged suspects:** `type=address` · `actions=display` · `filter=((object is.unused.recursive) and (object tag.count > 0))`
>
> **Hand-review:** `type=address` · `actions=displayreferences` · `filter=(object is.unused.recursive)`
>
> **Execute:** `type=address` · `actions=delete` · `filter=(object is.unused.recursive)`

### What it does
Removes address objects from the target DG that have no static rule reference and no membership in a referenced address-group. The first three commands produce candidate lists for review; the fourth performs the deletion and writes a new XML that becomes the input to D2.

### Why this combo
`is.unused.recursive` is the correct filter for addresses because reachability runs through address-group membership. Plain `is.unused` would mark an address as used purely because it sits inside an address-group, even if no rule references that group — the recursive variant follows the chain up and only flags the address when neither the address itself nor any containing group has a static reference. Without `.recursive`, the cleanup leaves orphaned address-group members in place and yield drops sharply.

The four-command pattern — display, tagged-display, references, delete — gives three independent looks before any mutation. The `tag.count > 0` second pass is the DAG safety net: every address surfaced there needs a manual cross-check against the DAG inventory before it can be deleted. The write goes to a **new** file via `out=` so the original config remains intact for rollback or for the Step E diff.

### How it works
Pure offline XML walk. The tool builds a reference graph (rules → address-groups → addresses) and, for each address, asks whether any rule reaches it directly or transitively through any group it belongs to. Addresses with zero reachable references match the filter. The `delete` action rewrites the XML with those entries stripped out and writes the result to `out=`.

> [!CAUTION]
> Always run the three preview commands first. Never run `actions=delete` blindly — the change-management cost of a broken DG dwarfs the time of a five-minute review.

> [!NOTE]
> ~~Without a preview, an address tagged in XML and matched only by a Dynamic Address Group at runtime would be flagged as unused and deleted, silently breaking traffic.~~ **Mitigated** by the second preview command (`tag.count > 0`), which surfaces every tagged unused candidate. Cross-reference that list against the DAG inventory and treat any match as a keeper — DAG match expressions resolve at runtime against tag membership and do not appear in the static reference graph the filter walks.

> [!TIP]
> `displayreferences` is the right tool for ambiguous candidates — it walks the reference graph and emits the full path (rule, group, nested group) so "wait, is this really unused?" usually settles in seconds.

<!-- page-break -->
## Step D2. Service chain (preview-then-delete)

```bash
# 1) Preview the candidate list
pan-os-php in=Pan_DG-Production_after_address.xml \
    location=DG-Production template=any \
    type=service actions=display \
    'filter=(object is.unused.recursive)' \
    | tee preview_service.txt

# 2) Hand-review with full reference paths
pan-os-php in=Pan_DG-Production_after_address.xml \
    location=DG-Production template=any \
    type=service actions=displayreferences \
    'filter=(object is.unused.recursive)' \
    | tee preview_service_refs.txt

# 3) Execute the delete; chain output to D3
pan-os-php in=Pan_DG-Production_after_address.xml \
    location=DG-Production template=any \
    type=service 'actions=delete' \
    'filter=(object is.unused.recursive)' \
    out=Pan_DG-Production_after_service.xml
```

> **Preview:** `type=service` · `actions=display` · `filter=(object is.unused.recursive)`
>
> **Hand-review:** `type=service` · `actions=displayreferences` · `filter=(object is.unused.recursive)`
>
> **Execute:** `type=service` · `actions=delete` · `filter=(object is.unused.recursive)`

### What it does
Removes service objects from the DG with no static rule reference and no membership in a referenced service-group. Reads from D1's output and writes a new XML that feeds D3. Three commands instead of D1's four — there is no service equivalent of a DAG, so no `tag.count` safety net is needed.

### Why this combo
`is.unused.recursive` walks service-group membership the same way it walked address-group membership in D1: a service referenced only via a referenced group is correctly held as in-use. Services have no runtime resolution layer, so the simpler preview → references → delete flow covers the safety surface fully.

### How it works
Offline XML walk against D1's output. The reference-graph logic is identical to D1; only the entry class being walked is different. `delete` rewrites the XML to `out=`.

> [!NOTE]
> No `tag.count` filter here — services have no DAG-equivalent runtime resolution path, so preview plus `displayreferences` is sufficient.

> [!CAUTION]
> This step reads from D1's `out=` filename. If D1 was skipped, this command's `in=` will not exist. Run the chain in order, or repoint `in=` at the original config and accept that the work won't compose with the rest of the chain.

<!-- page-break -->
## Step D3. Tag chain (preview-then-delete)

```bash
# 1) Preview the candidate list
pan-os-php in=Pan_DG-Production_after_service.xml \
    location=DG-Production template=any \
    type=tag actions=display \
    'filter=(object is.unused)' \
    | tee preview_tag.txt

# 2) Hand-review with full reference paths
pan-os-php in=Pan_DG-Production_after_service.xml \
    location=DG-Production template=any \
    type=tag actions=displayreferences \
    'filter=(object is.unused)' \
    | tee preview_tag_refs.txt

# 3) Execute the delete; final cleaned config
pan-os-php in=Pan_DG-Production_after_service.xml \
    location=DG-Production template=any \
    type=tag 'actions=delete' \
    'filter=(object is.unused)' \
    out=Pan_DG-Production_after_tag.xml
```

> **Preview:** `type=tag` · `actions=display` · `filter=(object is.unused)`
>
> **Hand-review:** `type=tag` · `actions=displayreferences` · `filter=(object is.unused)`
>
> **Execute:** `type=tag` · `actions=delete` · `filter=(object is.unused)`

### What it does
Removes tags from the DG with no static XML reference — no rule `tag` list, no address `tag` property, no DAG `match` expression mentions them. Reads from D2's output and writes the final cleaned config that goes into Step E.

### Why this combo
Tags come last because they are reference *targets*, not referrers. Once rules, addresses, and services have been pruned, the set of references TO each tag has shrunk to its true minimum, so flagging unused tags before that earlier work would underreport the cleanup. The filter is plain `is.unused` rather than `.recursive` because tags have no group container — there is no transitive chain to walk; a tag is either statically referenced somewhere or it is not.

### How it works
Offline XML walk against D2's output. The tool checks every static reference site for tags — rule `tag` lists, address `tag` properties, DAG `match` expressions — and emits any tag that appears in none of them. `delete` rewrites the XML to `out=`.

> [!NOTE]
> ~~A tag referenced ONLY via runtime registration (User-ID feeds, XML-API tag injection) does not appear in any XML element and would be flagged unused.~~ **Mitigated** by the preview pattern: hand-review the candidate list against User-ID and XML-API integrations before executing the delete. Runtime-registered tags must be kept even though the static XML says they're unused — their membership is bound at registration time, not at config-load time, so the static reference graph cannot see them.

> [!TIP]
> The output of this command (`Pan_DG-Production_after_tag.xml`) is the cleaned config that feeds Step E for diff verification, and then goes to the firewall after diff passes.

<!-- page-break -->
## Step E. Diff verification (the trust gate)

```bash
pan-os-php type=diff \
    file1=PanoramaName.xml \
    file2=Pan_DG-Production_after_tag.xml \
    "filter=/config/devices/entry[@name='localhost.localdomain']/device-group/entry[@name='DG-Production']"
```

> **What's doing the work:** `type=diff` · `filter=<XPath scoped to the target DG>`

### What it does
Compares the original Panorama config against the final cleaned config from D3, restricted by XPath to the target DG. Output is an element-by-element list of every difference inside that scope. This is a second, **independent** safety check on top of the preview-then-delete logic in D1–D3 — it converts "trust the filter" into "verify the result on disk."

### Why this combo
The diff is the only verification that does not rely on the same `is.unused` reasoning that drove the deletions. If `is.unused.recursive` had a blind spot — a reference site the reference graph did not walk — the previews would inherit that blind spot and the deletions would too. Structural XML compare does not. Any rule whose serialized form changed because an object it referenced was removed will surface as a diff entry in the rule section, even if every preview said the object was safe to delete.

The XPath scopes the comparison to the DG that was actually changed. Without it, every shared-scope detail captured by the export — templates, log forwarding, ancestor DGs, panorama-level objects — would clutter the report and bury the signal. The XPath as written targets `localhost.localdomain`, which is the standard Panorama device entry name; adjust if the export uses a different management domain name.

### How it works
Both XML trees are parsed, walked under the supplied XPath, and compared element by element. No commit, no API call, no mutation — pure structural compare against the two files on disk.

> [!IMPORTANT]
> **Expected output: only object deletions** (addresses, services, tags removed). Never rule modifications. If a rule appears in the diff, an object that the cleanup deleted was actually in use → **abort the push**, restore the pre-cleanup config, find which preview missed the reference, and rerun the chain.

> [!NOTE]
> Why "rules byte-identical" is the safety guarantee: rules are keyed by name in the XML and serialized in a stable order. If no rule was renamed, added, removed, or had a member changed, the rule sections of the two configs are byte-identical. A rule appearing in the diff is direct evidence that an object's deletion forced a rule edit — i.e. the object was reachable, and the cleanup was wrong about it.

> [!CAUTION]
> Rule cleanup and object cleanup MUST live in **separate change windows**. Combined, rule changes appear in the diff alongside object changes and you cannot tell expected rule churn from "object cleanup unexpectedly broke a rule." That collapses the entire safety guarantee. Hence: rule cleanup first (C1, C2, or C3), burn in for at least a week, then object cleanup (D1 → D2 → D3) against the burned-in baseline, then this diff against that same baseline.

> [!TIP]
> Capture the diff output to a timestamped file in the change ticket. It is the single artifact that closes the loop on "we cleaned the DG and proved we didn't change anything we didn't intend to."

## Closing reassurance

Per device group, separate change windows, preview-then-delete, diff-verify. Rule cleanup first, burn in for a week, then a chained address-service-tag pass, then the diff as the trust gate. Every destructive command in this playbook is preceded by a read-only command that lets you cancel before anything mutates, and every chain step writes to a new file so the original config is always available for rollback. Trust but verify — and the diff is the verify.

---

_Generated May 2026 from PaloAltoNetworks/pan-os-php at d87de87. AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it. Unofficial, AI-generated guide for a Palo Alto Networks open-source repository; not reviewed by Palo Alto Networks._
