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.
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/CHANGELOG.md +24 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/PKG-INFO +1 -1
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/coverage-and-limitations.md +24 -10
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/references-and-audit.md +4 -2
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/_version.py +1 -1
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/refs_cmds.py +19 -5
- panorama_super_cli-0.4.3/psc/core/dagfilter.py +198 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/refs.py +75 -8
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/pyproject.toml +1 -1
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/skills/panorama-super-cli/SKILL.md +2 -1
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli.py +22 -0
- panorama_super_cli-0.4.3/tests/test_dagfilter.py +93 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_refs.py +95 -1
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/uv.lock +1 -1
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/CODEOWNERS +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/dependabot.yml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/workflows/docs.yml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/workflows/lint.yml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/workflows/release.yml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.github/workflows/test.yml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.gitignore +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.pre-commit-config.yaml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/.python-version +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/AGENTS.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/CLAUDE.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/LICENSE +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/README.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/contributing/branching.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/contributing/development.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/contributing/release-process.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/getting-started/concepts.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/getting-started/first-run.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/getting-started/install.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/duplicates-and-merging.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/editing-objects.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/finding-objects.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/live-vs-offline.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/naming.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/output-formats.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/safety.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/using-with-ai-agents.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/index.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/reference/cli.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/reference/config.md +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/reference/exit-codes.md +0 -0
- {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
- {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
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/justfile +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/mkdocs.yml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/__init__.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/__main__.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/__init__.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/_options.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/_plan.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/app.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/audit_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/auth_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/decommission_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/dedup_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/find_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/move_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/name_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/profile_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/rule_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/runtime.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/set_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/cli/version_cmds.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/config/__init__.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/config/loader.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/config/models.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/__init__.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/apply_live.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/apply_xml.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/audit.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/changeset.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/crud.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/decommission.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/dedup.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/models.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/naming.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/normalize.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/parse.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/relocate.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/resolve.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/rule_edit.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/rulebases.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/setcmd.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/source.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/core/version_check.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/output/__init__.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/output/errors.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/psc/output/format.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/scripts/sync_agents_md.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/__init__.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/conftest.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/all-rulebases.xml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/decommission-config.xml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/dedup-groups.xml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/nested-device-groups.xml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/fixtures/panorama-config.xml +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_apply_xml.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_audit.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_auth.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_changeset.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_audit.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_auth.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_decommission.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_move.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_profile.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_rule.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_cli_set.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_crud.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_decommission.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_dedup.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_hardening.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_live_apply.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_naming.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_nested_dg.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_normalize.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_output.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_parse.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_relocate.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_resolve.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_rule_edit.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_rulebases.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_setcmd.py +0 -0
- {panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/tests/test_source.py +0 -0
- {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.
|
|
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/
|
{panorama_super_cli-0.4.2 → panorama_super_cli-0.4.3}/docs/guides/coverage-and-limitations.md
RENAMED
|
@@ -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)
|
|
68
|
+
### 2. Dynamic address groups (DAGs): only config-tag membership is resolved
|
|
68
69
|
|
|
69
|
-
A DAG includes addresses by a **tag expression
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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,
|
|
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
|
|
134
|
-
object kinds — are tracked in
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
77
|
-
#
|
|
78
|
-
#
|
|
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,
|
|
83
|
-
"shared). See docs:
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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.
|
|
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
|
|
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(" ")
|