panorama-super-cli 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/CHANGELOG.md +24 -0
  2. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/PKG-INFO +1 -1
  3. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/coverage-and-limitations.md +24 -10
  4. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/references-and-audit.md +4 -2
  5. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/_version.py +1 -1
  6. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/refs_cmds.py +19 -5
  7. panorama_super_cli-0.4.3/psc/core/dagfilter.py +198 -0
  8. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/refs.py +75 -8
  9. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/pyproject.toml +1 -1
  10. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/skills/panorama-super-cli/SKILL.md +2 -1
  11. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli.py +22 -0
  12. panorama_super_cli-0.4.3/tests/test_dagfilter.py +93 -0
  13. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_refs.py +95 -1
  14. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/uv.lock +1 -1
  15. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/CODEOWNERS +0 -0
  16. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/dependabot.yml +0 -0
  20. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/workflows/docs.yml +0 -0
  21. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/workflows/lint.yml +0 -0
  22. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/workflows/release.yml +0 -0
  23. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/workflows/test.yml +0 -0
  24. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.gitignore +0 -0
  25. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.pre-commit-config.yaml +0 -0
  26. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.python-version +0 -0
  27. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/AGENTS.md +0 -0
  28. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/CLAUDE.md +0 -0
  29. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/LICENSE +0 -0
  30. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/README.md +0 -0
  31. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/contributing/branching.md +0 -0
  32. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/contributing/development.md +0 -0
  33. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/contributing/release-process.md +0 -0
  34. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/getting-started/concepts.md +0 -0
  35. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/getting-started/first-run.md +0 -0
  36. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/getting-started/install.md +0 -0
  37. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/duplicates-and-merging.md +0 -0
  38. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/editing-objects.md +0 -0
  39. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/finding-objects.md +0 -0
  40. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/live-vs-offline.md +0 -0
  41. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/naming.md +0 -0
  42. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/output-formats.md +0 -0
  43. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/safety.md +0 -0
  44. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/using-with-ai-agents.md +0 -0
  45. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/index.md +0 -0
  46. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/reference/cli.md +0 -0
  47. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/reference/config.md +0 -0
  48. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/reference/exit-codes.md +0 -0
  49. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/superpowers/specs/2026-06-04-where-used-all-rulebases-design.md +0 -0
  50. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/superpowers/specs/2026-06-08-move-objects-to-shared-design.md +0 -0
  51. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/justfile +0 -0
  52. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/mkdocs.yml +0 -0
  53. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/__init__.py +0 -0
  54. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/__main__.py +0 -0
  55. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/__init__.py +0 -0
  56. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/_options.py +0 -0
  57. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/_plan.py +0 -0
  58. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/app.py +0 -0
  59. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/audit_cmds.py +0 -0
  60. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/auth_cmds.py +0 -0
  61. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/decommission_cmds.py +0 -0
  62. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/dedup_cmds.py +0 -0
  63. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/find_cmds.py +0 -0
  64. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/move_cmds.py +0 -0
  65. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/name_cmds.py +0 -0
  66. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/profile_cmds.py +0 -0
  67. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/rule_cmds.py +0 -0
  68. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/runtime.py +0 -0
  69. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/set_cmds.py +0 -0
  70. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/version_cmds.py +0 -0
  71. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/config/__init__.py +0 -0
  72. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/config/loader.py +0 -0
  73. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/config/models.py +0 -0
  74. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/__init__.py +0 -0
  75. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/apply_live.py +0 -0
  76. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/apply_xml.py +0 -0
  77. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/audit.py +0 -0
  78. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/changeset.py +0 -0
  79. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/crud.py +0 -0
  80. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/decommission.py +0 -0
  81. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/dedup.py +0 -0
  82. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/models.py +0 -0
  83. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/naming.py +0 -0
  84. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/normalize.py +0 -0
  85. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/parse.py +0 -0
  86. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/relocate.py +0 -0
  87. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/resolve.py +0 -0
  88. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/rule_edit.py +0 -0
  89. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/rulebases.py +0 -0
  90. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/setcmd.py +0 -0
  91. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/source.py +0 -0
  92. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/version_check.py +0 -0
  93. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/output/__init__.py +0 -0
  94. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/output/errors.py +0 -0
  95. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/output/format.py +0 -0
  96. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/scripts/sync_agents_md.py +0 -0
  97. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/__init__.py +0 -0
  98. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/conftest.py +0 -0
  99. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/all-rulebases.xml +0 -0
  100. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/decommission-config.xml +0 -0
  101. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/dedup-groups.xml +0 -0
  102. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/nested-device-groups.xml +0 -0
  103. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/panorama-config.xml +0 -0
  104. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_apply_xml.py +0 -0
  105. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_audit.py +0 -0
  106. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_auth.py +0 -0
  107. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_changeset.py +0 -0
  108. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_audit.py +0 -0
  109. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_auth.py +0 -0
  110. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_decommission.py +0 -0
  111. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_move.py +0 -0
  112. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_profile.py +0 -0
  113. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_rule.py +0 -0
  114. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_set.py +0 -0
  115. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_crud.py +0 -0
  116. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_decommission.py +0 -0
  117. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_dedup.py +0 -0
  118. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_hardening.py +0 -0
  119. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_live_apply.py +0 -0
  120. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_naming.py +0 -0
  121. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_nested_dg.py +0 -0
  122. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_normalize.py +0 -0
  123. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_output.py +0 -0
  124. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_parse.py +0 -0
  125. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_relocate.py +0 -0
  126. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_resolve.py +0 -0
  127. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_rule_edit.py +0 -0
  128. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_rulebases.py +0 -0
  129. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_setcmd.py +0 -0
  130. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_source.py +0 -0
  131. {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_version_check.py +0 -0
@@ -7,6 +7,30 @@ project will follow [Semantic Versioning](https://semver.org/). While on
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.4.3 — 2026-06-08
11
+
12
+ ### Fixed
13
+
14
+ - **`refs unused` / `refs used` now resolve dynamic address-group (DAG)
15
+ membership from config tags** (#60) — a new pure evaluator
16
+ (`psc/core/dagfilter.py`) parses a DAG's tag filter (`and`/`or`/`not`,
17
+ parentheses, single-quoted tags) and matches it against the static tags psc
18
+ already parses. An address whose only use is being tag-matched into a
19
+ rule-referenced DAG is no longer reported `unused` (deleting it would have
20
+ silently dropped a host from that rule), and `refs used <addr>` now surfaces
21
+ the DAG → rule path (as a `dynamic` referrer). A DAG matches only addresses
22
+ visible from its own scope (its device-group, ancestors, and `shared`). An
23
+ unparseable filter is matched-nothing and warned about on stderr (naming the
24
+ DAG) rather than crashing the audit or being guessed at.
25
+
26
+ ### Known limitation
27
+
28
+ - DAG membership from **externally registered IPs** (XML-API / User-ID /
29
+ VM-info) is runtime state absent from the config export and is still not
30
+ covered; resolving it requires a live op-command query and is tracked as a
31
+ follow-up. The `refs unused` stderr caveat and the *Coverage and blind spots*
32
+ guide reflect this.
33
+
10
34
  ## v0.4.2 — 2026-06-08
11
35
 
12
36
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panorama-super-cli
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely.
5
5
  Project-URL: Homepage, https://github.com/thomaschristory/panorama-super-cli
6
6
  Project-URL: Documentation, https://thomaschristory.github.io/panorama-super-cli/
@@ -37,6 +37,7 @@ Within those rulebases it models exactly this **reference surface**:
37
37
  | NAT `source-translation` / `destination-translation` | address | ✅ where-used; **review-gated** for repoint |
38
38
  | PBF forwarding `nexthop` (`fqdn` variant) | address | ✅ where-used; **review-gated** for repoint |
39
39
  | static address-group / service-group members | address / service | ✅ |
40
+ | dynamic address-group (DAG) membership | address | ✅ from **config tags**; registered IPs not covered (see below) |
40
41
 
41
42
  "Review-gated" means psc *sees* the reference and will **block** a
42
43
  merge/rename/delete that would strand it (it cannot rewrite a nested,
@@ -64,13 +65,25 @@ saw the reference. **This is the most dangerous gap.** Treat every `unused`
64
65
  result on a **shared** object as "unused by policy," and verify in Panorama
65
66
  before deleting.
66
67
 
67
- ### 2. Dynamic address groups (DAGs) have no computed membership
68
+ ### 2. Dynamic address groups (DAGs): only config-tag membership is resolved
68
69
 
69
- A DAG includes addresses by a **tag expression**, not a static member list, so
70
- psc cannot walk "which addresses this DAG contains." An address whose only use
71
- is being matched into a DAG (via its tags) that a rule then consumes can be
72
- reported `unused`. (DAG *filters* are parsed for the unused-**tag** check, but
73
- DAG **membership** is not resolved for the unused-**address** check.)
70
+ A DAG includes addresses by a **tag expression** (e.g. `'prod' and 'web'`), not
71
+ a static member list. Since v0.4.3 psc **evaluates that filter against the
72
+ static tags it already parses**, so an address whose only use is being matched
73
+ into a rule-referenced DAG is treated as reachable it is no longer reported
74
+ `unused`, and `refs used <addr>` shows the DAG (as a `dynamic` referrer) on the
75
+ path to the rule. DAG filters are also still parsed for the unused-**tag** check.
76
+
77
+ The residual gap is **runtime, not config**: an address pulled into a DAG by an
78
+ **externally registered IP** (XML-API / User-ID / VM-info / cloud plugin) carries
79
+ no config tag, so the export psc reads cannot show that membership. Such an
80
+ address can still be reported `unused`. Only a **live** membership query
81
+ (`show object dynamic-address-group all`) sees registered IPs; resolving them is
82
+ tracked as a follow-up enhancement on the live path.
83
+
84
+ If a DAG's filter is **malformed/unparseable**, psc does not guess its
85
+ membership (it matches nothing) and prints a `warning` on stderr naming that DAG,
86
+ so you know its coverage is unverified.
74
87
 
75
88
  ### 3. Whole object categories psc does not model
76
89
 
@@ -119,8 +132,8 @@ config.
119
132
  block.
120
133
  2. `refs unused` is a **candidate list, not a kill list**, especially for
121
134
  `shared` objects. Before deleting, ask: could this live in a template, in
122
- network/VPN/management config, on a NAT-rule tag, in a DAG, or on a firewall's
123
- local config? If plausibly yes, confirm in Panorama first.
135
+ network/VPN/management config, in a DAG via an externally registered IP, or
136
+ on a firewall's local config? If plausibly yes, confirm in Panorama first.
124
137
  3. The safe operations are the ones psc can fully model and *block* when it
125
138
  can't (merge, rename). The risky operation is **deletion driven by
126
139
  `unused`**, because that is exactly where an unseen reference turns into an
@@ -130,6 +143,7 @@ config.
130
143
 
131
144
  `refs unused` prints a one-line caveat to **stderr** restating these blind spots
132
145
  at the point of use (stdout stays pure machine output). The remaining gaps —
133
- DAG-membership reachability, parsing template/network references, modelling more
134
- object kinds — are tracked in the issue tracker. See
146
+ DAG membership from externally registered IPs (the live-path enhancement),
147
+ parsing template/network references, modelling more object kinds — are tracked in
148
+ the issue tracker. See
135
149
  [github.com/thomaschristory/panorama-super-cli/issues](https://github.com/thomaschristory/panorama-super-cli/issues).
@@ -40,8 +40,10 @@ are its members if nothing else reaches them.
40
40
  !!! danger "`unused` means *unused by policy* — not *safe to delete*"
41
41
  psc only scans device-group objects and policy rulebases. Objects referenced
42
42
  from **templates / network / device config** (IKE gateways, GlobalProtect,
43
- service routes, log servers…) or matched into a **dynamic address group**
44
- are reported `unused` even though they are in use.
43
+ service routes, log servers…) or matched into a **dynamic address group**
44
+ by an *externally registered* IP rather than a config tag — are reported
45
+ `unused` even though they are in use. (Config-tag DAG membership *is* now
46
+ resolved, so an address tagged into a rule-referenced DAG is kept.)
45
47
  Treat this list as **candidates**, verify `shared` objects in Panorama, and
46
48
  read **[Coverage and blind spots](coverage-and-limitations.md)** before
47
49
  deleting. (Unlike delete, `merge`/`rename` are protected — they block when a
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.4.2"
7
+ __version__ = "0.4.3"
@@ -16,6 +16,14 @@ app = typer.Typer(no_args_is_help=True)
16
16
  _KINDS = ("address", "address-group", "service", "service-group", "tag")
17
17
 
18
18
 
19
+ def _emit_graph_warnings(rt: Runtime, graph: ReferenceGraph) -> None:
20
+ """Surface non-fatal coverage gaps (e.g. an unparseable DAG filter whose
21
+ membership could not be resolved) on stderr, so stdout stays pure machine
22
+ output."""
23
+ for w in graph.warnings:
24
+ rt.stderr.print(f"[yellow]warning[/yellow]: {w}", soft_wrap=True, highlight=False)
25
+
26
+
19
27
  @app.command("used")
20
28
  def used(
21
29
  ctx: typer.Context,
@@ -43,6 +51,7 @@ def used(
43
51
 
44
52
  loc = location_from_name(location)
45
53
  refs = graph.where_used(kind, name, loc)
54
+ _emit_graph_warnings(rt, graph)
46
55
  rows = [
47
56
  {
48
57
  "referrer_kind": r.referrer_kind,
@@ -71,16 +80,20 @@ def unused(
71
80
  if rt.strict and not targets:
72
81
  raise PscError(f"no unused {kind}", ErrorType.NOT_FOUND)
73
82
  render(rt.stdout, rt.output, model=rows, rows=rows, table_title=f"unused {kind}")
83
+ _emit_graph_warnings(rt, graph)
74
84
  if targets:
75
85
  # `unused` only sees device-group objects + policy rulebases. Objects
76
- # referenced from templates/network config, NAT-rule tags, or matched
77
- # into a dynamic address group are NOT scanned and look unused here. Warn
78
- # on stderr so stdout stays pure machine output (#56).
86
+ # referenced from templates/network config are NOT scanned and look
87
+ # unused here. DAG membership is now resolved from config tags, but an
88
+ # address pulled into a DAG by an *externally registered* IP (XML-API /
89
+ # User-ID / VM-info) is runtime state absent from the config and is still
90
+ # not covered. Warn on stderr so stdout stays pure machine output (#56).
79
91
  rt.stderr.print(
80
92
  "[yellow]caveat[/yellow]: candidates only — these are unreferenced by the "
81
93
  "scanned objects/policy rulebases. NOT scanned: templates & network/device "
82
- "config, dynamic-address-group membership. Verify before deleting (esp. "
83
- "shared). See docs: Coverage and blind spots.",
94
+ "config, and DAG membership from externally registered IPs (config-tag DAG "
95
+ "membership is scanned). Verify before deleting (esp. shared). See docs: "
96
+ "Coverage and blind spots.",
84
97
  soft_wrap=True,
85
98
  highlight=False,
86
99
  )
@@ -92,6 +105,7 @@ def dangling(ctx: typer.Context) -> None:
92
105
  rt: Runtime = ctx.obj
93
106
  graph = ReferenceGraph.build(rt.snapshot())
94
107
  refs = graph.dangling()
108
+ _emit_graph_warnings(rt, graph)
95
109
  rows = [
96
110
  {
97
111
  "referrer_kind": r.referrer_kind,
@@ -0,0 +1,198 @@
1
+ """Evaluate PAN-OS dynamic address-group (DAG) match filters.
2
+
3
+ A DAG selects addresses by a boolean *tag* expression instead of a static
4
+ member list, e.g. ``'prod' and ('web' or 'app')``. This module parses that
5
+ expression once and answers the two questions the reference graph needs:
6
+
7
+ - which tag names it references (:func:`filter_tags`) — for the unused-*tag*
8
+ check and decommission's DAG-selection blocker, and
9
+ - whether a given set of tags satisfies it (:meth:`Filter.matches`) — for
10
+ resolving which addresses a DAG reaches, so an address used *only* via a
11
+ rule-referenced DAG is not reported unused (#60).
12
+
13
+ Grammar (PAN-OS match-criteria, plus a defensive ``not``)::
14
+
15
+ expr := or_expr
16
+ or_expr := and_expr ('or' and_expr)*
17
+ and_expr := unary ('and' unary)*
18
+ unary := 'not' unary | atom
19
+ atom := TAG | '(' expr ')'
20
+ TAG := "'" <chars> "'"
21
+
22
+ PAN-OS keeps tags single-quoted and operators lowercase, and `and`/`or` bind in
23
+ the usual way (`not` tightest, then `and`, then `or`). The GUI offers no
24
+ negation, but a hand-authored or CLI config can carry one, so we accept and
25
+ evaluate `not` rather than crash on a config the device accepted (#60 Q1).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from abc import ABC, abstractmethod
32
+ from collections.abc import Set
33
+ from dataclasses import dataclass
34
+
35
+ # A quoted tag, or one of the structural tokens. Anything the tokenizer cannot
36
+ # classify makes the whole filter unparseable (see `tokenize`).
37
+ _TOKEN_RE = re.compile(r"\s*(?:'([^']*)'|(\(|\)|\band\b|\bor\b|\bnot\b))", re.IGNORECASE)
38
+
39
+
40
+ class FilterParseError(ValueError):
41
+ """Raised when a DAG filter does not parse as a tag expression."""
42
+
43
+
44
+ class _Node(ABC):
45
+ @abstractmethod
46
+ def evaluate(self, tags: Set[str]) -> bool: ...
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class _Tag(_Node):
51
+ name: str
52
+
53
+ def evaluate(self, tags: Set[str]) -> bool:
54
+ return self.name in tags
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class _Not(_Node):
59
+ child: _Node
60
+
61
+ def evaluate(self, tags: Set[str]) -> bool:
62
+ return not self.child.evaluate(tags)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class _And(_Node):
67
+ left: _Node
68
+ right: _Node
69
+
70
+ def evaluate(self, tags: Set[str]) -> bool:
71
+ return self.left.evaluate(tags) and self.right.evaluate(tags)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class _Or(_Node):
76
+ left: _Node
77
+ right: _Node
78
+
79
+ def evaluate(self, tags: Set[str]) -> bool:
80
+ return self.left.evaluate(tags) or self.right.evaluate(tags)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Filter:
85
+ """A parsed DAG match expression. Immutable; evaluate with :meth:`matches`."""
86
+
87
+ _root: _Node
88
+
89
+ def matches(self, tags: Set[str]) -> bool:
90
+ """True if `tags` (an address's static tag set) satisfies this filter."""
91
+ return self._root.evaluate(tags)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class _Token:
96
+ kind: str # "tag" | "and" | "or" | "not" | "(" | ")"
97
+ value: str # the tag name for "tag", else the keyword/paren
98
+
99
+
100
+ def _tokenize(expr: str) -> list[_Token]:
101
+ tokens: list[_Token] = []
102
+ pos = 0
103
+ for m in _TOKEN_RE.finditer(expr):
104
+ if m.start() != pos:
105
+ # A gap between matches means an unrecognized token (e.g. an
106
+ # unquoted word, or stray punctuation).
107
+ raise FilterParseError(f"unexpected token at {pos}: {expr[pos:]!r}")
108
+ pos = m.end()
109
+ tag, struct = m.group(1), m.group(2)
110
+ if tag is not None:
111
+ tokens.append(_Token("tag", tag))
112
+ else:
113
+ tokens.append(_Token(struct.lower(), struct.lower()))
114
+ if pos != len(expr) and expr[pos:].strip():
115
+ raise FilterParseError(f"unexpected trailing input: {expr[pos:]!r}")
116
+ return tokens
117
+
118
+
119
+ class _Parser:
120
+ """Recursive-descent parser for the grammar in the module docstring."""
121
+
122
+ def __init__(self, tokens: list[_Token]) -> None:
123
+ self._tokens = tokens
124
+ self._i = 0
125
+
126
+ def _peek(self) -> _Token | None:
127
+ return self._tokens[self._i] if self._i < len(self._tokens) else None
128
+
129
+ def _advance(self) -> _Token:
130
+ tok = self._tokens[self._i]
131
+ self._i += 1
132
+ return tok
133
+
134
+ def parse(self) -> _Node:
135
+ node = self._or()
136
+ if self._peek() is not None:
137
+ raise FilterParseError(f"unexpected token: {self._peek()!r}")
138
+ return node
139
+
140
+ def _or(self) -> _Node:
141
+ node = self._and()
142
+ while (tok := self._peek()) is not None and tok.kind == "or":
143
+ self._advance()
144
+ node = _Or(node, self._and())
145
+ return node
146
+
147
+ def _and(self) -> _Node:
148
+ node = self._unary()
149
+ while (tok := self._peek()) is not None and tok.kind == "and":
150
+ self._advance()
151
+ node = _And(node, self._unary())
152
+ return node
153
+
154
+ def _unary(self) -> _Node:
155
+ tok = self._peek()
156
+ if tok is not None and tok.kind == "not":
157
+ self._advance()
158
+ return _Not(self._unary())
159
+ return self._atom()
160
+
161
+ def _atom(self) -> _Node:
162
+ tok = self._peek()
163
+ if tok is None:
164
+ raise FilterParseError("unexpected end of filter")
165
+ if tok.kind == "tag":
166
+ self._advance()
167
+ return _Tag(tok.value)
168
+ if tok.kind == "(":
169
+ self._advance()
170
+ node = self._or()
171
+ close = self._peek()
172
+ if close is None or close.kind != ")":
173
+ raise FilterParseError("unbalanced parenthesis")
174
+ self._advance()
175
+ return node
176
+ raise FilterParseError(f"expected a tag or '(', got {tok.kind!r}")
177
+
178
+
179
+ def parse_filter(expr: str) -> Filter:
180
+ """Parse a DAG match expression into a :class:`Filter`.
181
+
182
+ Raises :class:`FilterParseError` on an empty or malformed expression.
183
+ """
184
+ tokens = _tokenize(expr)
185
+ if not tokens:
186
+ raise FilterParseError("empty filter")
187
+ return Filter(_Parser(tokens).parse())
188
+
189
+
190
+ def filter_tags(expr: str) -> set[str]:
191
+ """The set of tag names a DAG filter references.
192
+
193
+ Best-effort and never raises: this is a name scan (used by the unused-tag
194
+ check and decommission blockers), so it tolerates a malformed expression by
195
+ returning whatever quoted tokens it can find. Exact-token, not substring —
196
+ a filter naming ``'web'`` does not reference ``'webserver'``.
197
+ """
198
+ return set(re.findall(r"'([^']*)'", expr))
@@ -22,11 +22,11 @@ shadowing is exactly why renames are dangerous, so it lives here, in one place
22
22
 
23
23
  from __future__ import annotations
24
24
 
25
- import re
26
25
  from collections import defaultdict
27
26
  from collections.abc import Sequence
28
27
  from dataclasses import dataclass, field
29
28
 
29
+ from psc.core.dagfilter import FilterParseError, filter_tags, parse_filter
30
30
  from psc.core.models import Location, Rulebase, Snapshot, _Named
31
31
  from psc.core.rulebases import rule_container
32
32
 
@@ -45,13 +45,11 @@ PREDEFINED = frozenset(
45
45
  def dag_filter_tags(filter_str: str) -> set[str]:
46
46
  """The tag names a dynamic address-group filter references.
47
47
 
48
- A DAG filter references tags as quoted tokens, e.g. "'prod' and 'web'".
49
- Extract the quoted names and match exactly — a bare substring test would
50
- count tag `web` as used by a `webserver` filter (or as selected by it). The
51
- one place this parse lives, shared by unused-tag analysis and decommission's
52
- DAG-selection blocker.
48
+ Thin alias for :func:`psc.core.dagfilter.filter_tags`, kept here as the
49
+ historical import site for the unused-tag analysis, decommission's
50
+ DAG-selection blocker, and relocate's dependency walk.
53
51
  """
54
- return set(re.findall(r"'([^']+)'", filter_str))
52
+ return filter_tags(filter_str)
55
53
 
56
54
 
57
55
  @dataclass(frozen=True)
@@ -110,16 +108,22 @@ class _NamespaceIndex:
110
108
  class ReferenceGraph:
111
109
  snapshot: Snapshot
112
110
  references: list[Reference] = field(default_factory=list)
111
+ warnings: list[str] = field(default_factory=list)
112
+ """Non-fatal coverage gaps found while building (e.g. an unparseable DAG
113
+ filter whose membership could not be resolved). The CLI surfaces these on
114
+ stderr so the operator knows which findings are unverified."""
113
115
  _addr_idx: _NamespaceIndex = field(default_factory=_NamespaceIndex)
114
116
  _svc_idx: _NamespaceIndex = field(default_factory=_NamespaceIndex)
115
117
  _tag_idx: _NamespaceIndex = field(default_factory=_NamespaceIndex)
116
118
  _by_target: dict[Target, list[Reference]] = field(default_factory=lambda: defaultdict(list))
119
+ _dag_members: dict[Target, list[Target]] = field(default_factory=lambda: defaultdict(list))
117
120
 
118
121
  @classmethod
119
122
  def build(cls, snapshot: Snapshot) -> ReferenceGraph:
120
123
  g = cls(snapshot=snapshot)
121
124
  g._index()
122
125
  g._walk()
126
+ g._resolve_dags()
123
127
  return g
124
128
 
125
129
  # -- indexing --------------------------------------------------------
@@ -346,6 +350,63 @@ class ReferenceGraph:
346
350
  rulebase=p.rulebase,
347
351
  )
348
352
 
353
+ def _resolve_dags(self) -> None:
354
+ """Resolve each dynamic address-group's membership from static tags.
355
+
356
+ A DAG selects addresses by a tag expression, not a static member list, so
357
+ it is invisible to `_members_of` unless we evaluate the filter. Here we
358
+ match each DAG's filter against the *config* tags psc already parses and
359
+ record the matched addresses, so an address used only via a
360
+ rule-referenced DAG counts as reachable (and shows the DAG→address edge
361
+ in where-used) instead of looking unused (#60).
362
+
363
+ Scope: a DAG matches only addresses visible from its own location (the
364
+ device-group, its ancestors, and shared) — the same chain `_members_of`
365
+ uses for static members. An unparseable filter is recorded as a warning
366
+ and contributes no members (match-nothing): psc never guesses membership,
367
+ but the operator is told that DAG's coverage is unverified (#60 Q2).
368
+
369
+ Caveat: this resolves only *config-tagged* membership. Addresses brought
370
+ into a DAG by externally registered IPs (XML-API / User-ID / VM-info) are
371
+ runtime state absent from the config and are still not covered — that is
372
+ the residual gap a live membership query would close.
373
+ """
374
+ addrs_by_loc = self.snapshot.addresses_by_location()
375
+ for ag in self.snapshot.address_groups:
376
+ if not ag.is_dynamic or ag.dynamic_filter is None:
377
+ continue
378
+ dag = Target("address-group", ag.name, ag.location)
379
+ try:
380
+ flt = parse_filter(ag.dynamic_filter)
381
+ except FilterParseError as exc:
382
+ self.warnings.append(
383
+ f"dynamic address-group '{ag.name}'@{ag.location.name}: "
384
+ f"unparseable filter ({exc}); membership not resolved — "
385
+ "addresses matched only by it may be reported unused"
386
+ )
387
+ continue
388
+ scope = {loc.name for loc in self.snapshot.ancestors(ag.location)}
389
+ for loc_name in scope:
390
+ for a in addrs_by_loc.get(loc_name, []):
391
+ if not flt.matches(set(a.tags)):
392
+ continue
393
+ member = Target("address", a.name, a.location)
394
+ self._dag_members[dag].append(member)
395
+ # Surface the DAG as an indirect referrer of the matched
396
+ # address (resolved straight to the concrete object, not by
397
+ # name — a shadowed same-name address must not steal it).
398
+ ref = Reference(
399
+ target_name=a.name,
400
+ namespace="address",
401
+ referrer_kind="address-group",
402
+ referrer_name=ag.name,
403
+ referrer_location=ag.location,
404
+ field="dynamic",
405
+ resolved=member,
406
+ )
407
+ self.references.append(ref)
408
+ self._by_target[member].append(ref)
409
+
349
410
  # -- queries ---------------------------------------------------------
350
411
 
351
412
  def resolve(self, namespace: str, name: str, ref_location: Location) -> Target | None:
@@ -382,10 +443,16 @@ class ReferenceGraph:
382
443
  return seeds
383
444
 
384
445
  def _members_of(self, target: Target) -> list[Target]:
385
- """Resolved members of a group target (empty for leaf objects)."""
446
+ """Resolved members of a group target (empty for leaf objects).
447
+
448
+ For a *dynamic* address-group the members are the tag-matched addresses
449
+ computed in `_resolve_dags`; for a static one they are the resolved
450
+ named members.
451
+ """
386
452
  out: list[Target] = []
387
453
  chain = self.snapshot.ancestors(target.location)
388
454
  if target.kind == "address-group":
455
+ out.extend(self._dag_members.get(target, []))
389
456
  for ag in self.snapshot.address_groups:
390
457
  if ag.name == target.name and ag.location == target.location:
391
458
  for m in ag.static_members or []:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "panorama-super-cli"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  description = "Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -122,7 +122,8 @@ only a non-security rule reaches. A `referrer_kind` like `qos-rule` or
122
122
  > **⚠️ `unused` = unused *by policy*, NOT *safe to delete*.** psc parses only
123
123
  > device-group objects + policy rulebases. It does **not** see: templates &
124
124
  > network/device config (IKE/IPSec, GlobalProtect, service routes, log servers,
125
- > static routes), dynamic-address-group membership, or
125
+ > static routes), dynamic-address-group membership from **externally registered
126
+ > IPs** (config-tag DAG membership *is* resolved), or
126
127
  > profiles/schedules/EDLs/regions/applications. Any object referenced only
127
128
  > there is falsely reported `unused`. **Never auto-delete on an `unused` result
128
129
  > — surface it as a candidate and have a human verify in Panorama**, especially
@@ -44,6 +44,28 @@ def test_unused_prints_scope_caveat_to_stderr() -> None:
44
44
  assert "template" in low # names the most dangerous blind spot
45
45
 
46
46
 
47
+ def test_unparseable_dag_filter_warns_on_stderr(tmp_path: Path) -> None:
48
+ # An unparseable DAG filter must not crash the audit; psc warns on stderr
49
+ # (naming the DAG) that its membership is unverified, and stdout stays clean.
50
+ cfg = tmp_path / "bad-dag.xml"
51
+ cfg.write_text(
52
+ """<config><shared>
53
+ <address><entry name="h"><ip-netmask>10.0.0.1/32</ip-netmask>
54
+ <tag><member>prod</member></tag></entry></address>
55
+ <address-group><entry name="dag-bad">
56
+ <dynamic><filter>'prod' and</filter></dynamic></entry></address-group>
57
+ <pre-rulebase><security><rules><entry name="r">
58
+ <source><member>any</member></source>
59
+ <destination><member>dag-bad</member></destination></entry>
60
+ </rules></security></pre-rulebase>
61
+ </shared></config>"""
62
+ )
63
+ cp = run("-c", str(cfg), "-o", "json", "refs", "unused", "--kind", "address")
64
+ assert cp.returncode == 0
65
+ json.loads(cp.stdout) # stdout stays pure machine output
66
+ assert "dag-bad" in cp.stderr
67
+
68
+
47
69
  def test_find_ip_json_contract() -> None:
48
70
  cp = run("-c", str(FIXTURE), "-o", "json", "find", "ip", "10.0.0.10")
49
71
  assert cp.returncode == 0
@@ -0,0 +1,93 @@
1
+ """Tests for the pure DAG tag-filter evaluator (#60)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from psc.core.dagfilter import FilterParseError, filter_tags, parse_filter
8
+
9
+
10
+ def matches(expr: str, tags: set[str]) -> bool:
11
+ return parse_filter(expr).matches(tags)
12
+
13
+
14
+ def test_single_tag() -> None:
15
+ assert matches("'prod'", {"prod"})
16
+ assert not matches("'prod'", {"web"})
17
+ assert not matches("'prod'", set())
18
+
19
+
20
+ def test_and() -> None:
21
+ assert matches("'prod' and 'web'", {"prod", "web"})
22
+ assert not matches("'prod' and 'web'", {"prod"})
23
+ assert not matches("'prod' and 'web'", {"web"})
24
+
25
+
26
+ def test_or() -> None:
27
+ assert matches("'prod' or 'web'", {"prod"})
28
+ assert matches("'prod' or 'web'", {"web"})
29
+ assert not matches("'prod' or 'web'", {"db"})
30
+
31
+
32
+ def test_not() -> None:
33
+ # PAN-OS GUI offers no negation, but a hand-authored/CLI config can; the
34
+ # evaluator must accept it rather than crash (#60 Q1).
35
+ assert matches("not 'prod'", {"web"})
36
+ assert not matches("not 'prod'", {"prod"})
37
+
38
+
39
+ def test_precedence_not_binds_tighter_than_and() -> None:
40
+ # not 'a' and 'b' == (not 'a') and 'b'
41
+ assert matches("not 'a' and 'b'", {"b"})
42
+ assert not matches("not 'a' and 'b'", {"a", "b"})
43
+
44
+
45
+ def test_precedence_and_binds_tighter_than_or() -> None:
46
+ # 'a' or 'b' and 'c' == 'a' or ('b' and 'c')
47
+ assert matches("'a' or 'b' and 'c'", {"a"})
48
+ assert matches("'a' or 'b' and 'c'", {"b", "c"})
49
+ assert not matches("'a' or 'b' and 'c'", {"b"})
50
+
51
+
52
+ def test_parentheses_override_precedence() -> None:
53
+ # ('a' or 'b') and 'c'
54
+ assert matches("('a' or 'b') and 'c'", {"a", "c"})
55
+ assert not matches("('a' or 'b') and 'c'", {"a"})
56
+
57
+
58
+ def test_tags_with_spaces_dots_and_dashes() -> None:
59
+ # PAN-OS tags routinely contain spaces, dots, dashes (e.g. cloud tags).
60
+ expr = "'aws-tag.env.prod' and 'instanceState.running'"
61
+ assert matches(expr, {"aws-tag.env.prod", "instanceState.running"})
62
+ assert not matches(expr, {"aws-tag.env.prod"})
63
+
64
+
65
+ def test_filter_tags_extracts_referenced_names() -> None:
66
+ assert filter_tags("'prod' and ('web' or 'app')") == {"prod", "web", "app"}
67
+ # Exact-token, not substring: a filter naming 'web' does not reference 'webserver'.
68
+ assert filter_tags("'web'") == {"web"}
69
+
70
+
71
+ def test_filter_tags_is_robust_to_malformed() -> None:
72
+ # filter_tags never raises — it is a best-effort name scan used for the
73
+ # unused-tag check and decommission blockers.
74
+ assert filter_tags("'prod' and") == {"prod"}
75
+ assert filter_tags("") == set()
76
+
77
+
78
+ def test_malformed_filter_raises() -> None:
79
+ with pytest.raises(FilterParseError):
80
+ parse_filter("'prod' and")
81
+ with pytest.raises(FilterParseError):
82
+ parse_filter("'prod' 'web'") # missing operator
83
+ with pytest.raises(FilterParseError):
84
+ parse_filter("('prod'") # unbalanced
85
+ with pytest.raises(FilterParseError):
86
+ parse_filter("and 'prod'") # leading operator
87
+
88
+
89
+ def test_empty_filter_raises() -> None:
90
+ with pytest.raises(FilterParseError):
91
+ parse_filter("")
92
+ with pytest.raises(FilterParseError):
93
+ parse_filter(" ")