From 53698c3a3d743d501c63716ceada01b9aac01f2e Mon Sep 17 00:00:00 2001 From: AJ <845325+Former-Stranger@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:11:29 -0400 Subject: [PATCH 1/3] Rename the project to panos-decrypt-bypass It's about to support firewalls that aren't managed by Strata Cloud Manager, so the SCM-specific name no longer fits. Renames the Python package, CLI command, images, compose service, docs and the session cookie (signing everyone out once). PDB_VERSION replaces SDB_VERSION. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 2 +- .env.example | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- .github/ISSUE_TEMPLATE/config.yml | 4 +- .github/dependabot.yml | 2 +- .github/workflows/ci.yml | 16 +++---- .github/workflows/release.yml | 6 +-- CONTRIBUTING.md | 14 +++---- Dockerfile | 10 ++--- README.md | 20 ++++----- SECURITY.md | 2 +- compose.yaml | 8 ++-- docs/configuration.md | 10 ++--- docs/installation.md | 36 ++++++++-------- docs/prerequisites.md | 2 +- docs/reverse-proxy.md | 24 +++++------ docs/troubleshooting.md | 8 ++-- docs/usage.md | 28 ++++++------- examples/caddy/Caddyfile | 2 +- examples/caddy/Caddyfile.step-ca | 2 +- examples/caddy/compose.yaml | 12 +++--- pyproject.toml | 16 +++---- .../__init__.py | 2 +- src/panos_decrypt_bypass/__main__.py | 3 ++ .../bypass_lists.py | 0 .../cli.py | 42 +++++++++---------- .../scm_api.py | 0 .../templates/bypass/exclusion-hostnames.txt | 0 .../bypass/no-decrypt-categories.txt | 0 .../bypass/no-decrypt-destinations.txt | 0 .../templates/bypass/no-decrypt-sources.txt | 0 .../templates/bypass/no-decrypt-urls.txt | 0 .../templates/bypass/no-decrypt-users.txt | 0 .../templates/terraform.tfvars | 4 +- .../terraform/.terraform.lock.hcl | 0 .../terraform/main.tf | 2 +- .../terraform/modules/no-decrypt-rule/main.tf | 0 .../terraform/outputs.tf | 0 .../terraform/rules.tf | 0 .../terraform/tests/plan.tftest.hcl | 4 +- .../terraform/variables.tf | 0 .../terraform/versions.tf | 0 .../web/__init__.py | 0 .../web/advice.py | 2 +- .../web/app.py | 18 ++++---- .../web/auth.py | 2 +- .../web/demo_logs.xml | 0 .../web/deployer.py | 6 +-- .../web/panos.py | 0 .../web/poller.py | 8 ++-- .../web/settings.py | 2 +- .../web/static/app.js | 2 +- .../web/static/index.html | 0 .../web/static/login.html | 0 .../web/static/login.js | 0 .../web/static/style.css | 0 .../web/store.py | 2 +- .../workspace.py | 2 +- src/scm_decrypt_bypass/__main__.py | 3 -- tests/test_auth.py | 4 +- tests/test_bypass_lists.py | 4 +- tests/test_deployer.py | 12 +++--- tests/test_webgui.py | 10 ++--- uv.lock | 2 +- 64 files changed, 183 insertions(+), 183 deletions(-) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/__init__.py (84%) create mode 100644 src/panos_decrypt_bypass/__main__.py rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/bypass_lists.py (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/cli.py (91%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/scm_api.py (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/templates/bypass/exclusion-hostnames.txt (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/templates/bypass/no-decrypt-categories.txt (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/templates/bypass/no-decrypt-destinations.txt (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/templates/bypass/no-decrypt-sources.txt (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/templates/bypass/no-decrypt-urls.txt (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/templates/bypass/no-decrypt-users.txt (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/templates/terraform.tfvars (93%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/terraform/.terraform.lock.hcl (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/terraform/main.tf (99%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/terraform/modules/no-decrypt-rule/main.tf (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/terraform/outputs.tf (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/terraform/rules.tf (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/terraform/tests/plan.tftest.hcl (97%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/terraform/variables.tf (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/terraform/versions.tf (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/__init__.py (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/advice.py (99%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/app.py (93%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/auth.py (99%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/demo_logs.xml (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/deployer.py (98%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/panos.py (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/poller.py (93%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/settings.py (98%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/static/app.js (99%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/static/index.html (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/static/login.html (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/static/login.js (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/static/style.css (100%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/web/store.py (99%) rename src/{scm_decrypt_bypass => panos_decrypt_bypass}/workspace.py (98%) delete mode 100644 src/scm_decrypt_bypass/__main__.py diff --git a/.dockerignore b/.dockerignore index 076cbc5..26755e7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,4 @@ tests **/__pycache__ **/.terraform **/terraform.tfstate* -src/scm_decrypt_bypass/terraform/tests +src/panos_decrypt_bypass/terraform/tests diff --git a/.env.example b/.env.example index abedf91..33802b0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# scm-decrypt-bypass settings. Copy to .env and fill in. Never commit .env. +# panos-decrypt-bypass settings. Copy to .env and fill in. Never commit .env. # Every variable is described in docs/configuration.md. # --- Web GUI login (required) -------------------------------------------------- diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 46df8e9..b84c1e3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -19,13 +19,13 @@ body: id: logs attributes: label: Logs or error messages - description: From the deploy panel's log, `docker logs scm-decrypt-bypass`, or the CLI. Redacted, please. + description: From the deploy panel's log, `docker logs panos-decrypt-bypass`, or the CLI. Redacted, please. render: text - type: input id: version attributes: label: Version - description: Shown at the bottom of the web GUI, or `scm-decrypt-bypass version`. + description: Shown at the bottom of the web GUI, or `panos-decrypt-bypass version`. validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c750292..95fa63a 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: Security vulnerability - url: https://github.com/Former-Stranger/scm-decrypt-bypass/security/advisories/new + url: https://github.com/Former-Stranger/panos-decrypt-bypass/security/advisories/new about: Report security issues privately. - name: Documentation - url: https://github.com/Former-Stranger/scm-decrypt-bypass/tree/main/docs + url: https://github.com/Former-Stranger/panos-decrypt-bypass/tree/main/docs about: Setup, configuration and troubleshooting guides. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6c5d0dc..d2a4ebf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,6 +19,6 @@ updates: schedule: interval: weekly - package-ecosystem: terraform - directory: /src/scm_decrypt_bypass/terraform + directory: /src/panos_decrypt_bypass/terraform schedule: interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1406148..23bad05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: src/scm_decrypt_bypass/terraform + working-directory: src/panos_decrypt_bypass/terraform steps: - uses: actions/checkout@v7 - uses: hashicorp/setup-terraform@v4 @@ -62,20 +62,20 @@ jobs: with: context: . load: true - tags: scm-decrypt-bypass:ci + tags: panos-decrypt-bypass:ci cache-from: type=gha cache-to: type=gha,mode=max - name: Smoke test (demo mode) run: | - docker run -d --name sdb -p 8088:8088 -e WEBGUI_DEMO=1 -e WEBGUI_PASSWORD=ci-password-1234 scm-decrypt-bypass:ci + docker run -d --name pdb -p 8088:8088 -e WEBGUI_DEMO=1 -e WEBGUI_PASSWORD=ci-password-1234 panos-decrypt-bypass:ci for i in $(seq 1 30); do - [ "$(docker inspect -f '{{.State.Health.Status}}' sdb)" = healthy ] && break + [ "$(docker inspect -f '{{.State.Health.Status}}' pdb)" = healthy ] && break sleep 2 done - docker inspect -f '{{.State.Health.Status}}' sdb | grep -qx healthy + docker inspect -f '{{.State.Health.Status}}' pdb | grep -qx healthy curl -fsS http://127.0.0.1:8088/healthz test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8088/api/status)" = 401 - docker exec sdb terraform version - docker exec sdb scm-decrypt-bypass version + docker exec pdb terraform version + docker exec pdb panos-decrypt-bypass version - if: failure() - run: docker logs sdb + run: docker logs pdb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fbef90..00f11a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,8 +53,8 @@ jobs: uses: docker/metadata-action@v6 with: images: | - ghcr.io/former-stranger/scm-decrypt-bypass - name=docker.io/${{ env.DOCKERHUB_NAMESPACE }}/scm-decrypt-bypass,enable=${{ env.DOCKERHUB_USERNAME != '' }} + ghcr.io/former-stranger/panos-decrypt-bypass + name=docker.io/${{ env.DOCKERHUB_NAMESPACE }}/panos-decrypt-bypass,enable=${{ env.DOCKERHUB_USERNAME != '' }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} @@ -75,7 +75,7 @@ jobs: - uses: actions/attest-build-provenance@v4 with: - subject-name: ghcr.io/former-stranger/scm-decrypt-bypass + subject-name: ghcr.io/former-stranger/panos-decrypt-bypass subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db1b73d..ce4f818 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,8 @@ You need [uv](https://docs.astral.sh/uv/) and [Terraform](https://developer.hashicorp.com/terraform/install) 1.9 or newer. ```bash -git clone https://github.com/Former-Stranger/scm-decrypt-bypass.git -cd scm-decrypt-bypass +git clone https://github.com/Former-Stranger/panos-decrypt-bypass.git +cd panos-decrypt-bypass uv sync ``` @@ -17,7 +17,7 @@ Run the GUI with sample data and simulated deploys (nothing is sent anywhere). The password below is for this local demo only: ```bash -WEBGUI_DEMO=1 WEBGUI_PASSWORD=demo-password-1234 uv run scm-decrypt-bypass serve +WEBGUI_DEMO=1 WEBGUI_PASSWORD=demo-password-1234 uv run panos-decrypt-bypass serve ``` Then open http://127.0.0.1:8088. @@ -30,9 +30,9 @@ CI runs all of these on every pull request: uv run ruff check src tests uv run ruff format --check src tests uv run python -m unittest discover tests -terraform -chdir=src/scm_decrypt_bypass/terraform init -backend=false -terraform -chdir=src/scm_decrypt_bypass/terraform test -docker build -t scm-decrypt-bypass:dev . +terraform -chdir=src/panos_decrypt_bypass/terraform init -backend=false +terraform -chdir=src/panos_decrypt_bypass/terraform test +docker build -t panos-decrypt-bypass:dev . ``` The Python tests use a fake `terraform` and a fake firewall, so they never @@ -41,7 +41,7 @@ touch SCM or a real firewall. `terraform test` uses a mocked provider. ## Project layout ``` -src/scm_decrypt_bypass/ +src/panos_decrypt_bypass/ cli.py command line and interactive menu bypass_lists.py reading, validating and editing the bypass lists scm_api.py SCM config push and job polling diff --git a/Dockerfile b/Dockerfile index f64c7c7..dfd16a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -# scm-decrypt-bypass: web GUI + CLI, with Terraform and the SCM provider built in. +# panos-decrypt-bypass: web GUI + CLI, with Terraform and the SCM provider built in. FROM python:3.13-slim ARG TERRAFORM_VERSION=1.16.6 @@ -31,7 +31,7 @@ RUN uv sync --locked --no-dev --no-editable # Pre-install the SCM provider into a local mirror and point Terraform at it, # so `terraform init` never needs the registry at runtime. RUN mkdir -p /opt/terraform/providers \ - && cp -r /app/src/scm_decrypt_bypass/terraform /tmp/tf-module \ + && cp -r /app/src/panos_decrypt_bypass/terraform /tmp/tf-module \ && cd /tmp/tf-module \ && terraform init -backend=false -input=false -no-color > /dev/null \ && terraform providers mirror -platform=linux_${TARGETARCH:-amd64} /opt/terraform/providers \ @@ -65,9 +65,9 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ CMD ["python", "-c", "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ.get('WEBGUI_PORT', '8088') + '/healthz', timeout=3)"] ENTRYPOINT ["entrypoint"] -CMD ["scm-decrypt-bypass", "serve"] +CMD ["panos-decrypt-bypass", "serve"] -LABEL org.opencontainers.image.title="scm-decrypt-bypass" \ +LABEL org.opencontainers.image.title="panos-decrypt-bypass" \ org.opencontainers.image.description="Find TLS decryption failures on Palo Alto Networks firewalls and bypass them through Strata Cloud Manager" \ - org.opencontainers.image.source="https://github.com/Former-Stranger/scm-decrypt-bypass" \ + org.opencontainers.image.source="https://github.com/Former-Stranger/panos-decrypt-bypass" \ org.opencontainers.image.licenses="MIT" diff --git a/README.md b/README.md index 873fabb..4b81398 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# scm-decrypt-bypass +# panos-decrypt-bypass -[![CI](https://github.com/Former-Stranger/scm-decrypt-bypass/actions/workflows/ci.yml/badge.svg)](https://github.com/Former-Stranger/scm-decrypt-bypass/actions/workflows/ci.yml) -[![Release](https://img.shields.io/github/v/release/Former-Stranger/scm-decrypt-bypass)](https://github.com/Former-Stranger/scm-decrypt-bypass/releases) +[![CI](https://github.com/Former-Stranger/panos-decrypt-bypass/actions/workflows/ci.yml/badge.svg)](https://github.com/Former-Stranger/panos-decrypt-bypass/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/Former-Stranger/panos-decrypt-bypass)](https://github.com/Former-Stranger/panos-decrypt-bypass/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) **Find TLS decryption failures on Palo Alto Networks firewalls managed by @@ -16,7 +16,7 @@ Strata Cloud Manager, and bypass them in one click.** SSL/TLS decryption breaks some apps: ones that pin their certificates, use client certificates, or speak TLS the firewall can't proxy. Finding them means digging through decryption logs, and fixing them means editing -decryption policy and pushing config. scm-decrypt-bypass does both: +decryption policy and pushing config. panos-decrypt-bypass does both: - a **web GUI** that pulls decryption logs from the firewall, groups the failures by destination and explains each one in plain English; @@ -37,7 +37,7 @@ decryption policy and pushing config. scm-decrypt-bypass does both: ## How it works ``` -firewall ──XML API (decryption logs)──► scm-decrypt-bypass ──► web GUI +firewall ──XML API (decryption logs)──► panos-decrypt-bypass ──► web GUI │ bypass lists + terraform.tfvars │ Terraform (PaloAltoNetworks/scm) ▼ @@ -57,9 +57,9 @@ You need Docker, an SCM service account, and an XML API key for the firewall. [Prerequisites](docs/prerequisites.md) walks through creating them. ```bash -mkdir scm-decrypt-bypass && cd scm-decrypt-bypass -curl -fsSLO https://raw.githubusercontent.com/Former-Stranger/scm-decrypt-bypass/main/compose.yaml -curl -fsSL https://raw.githubusercontent.com/Former-Stranger/scm-decrypt-bypass/main/.env.example -o .env +mkdir panos-decrypt-bypass && cd panos-decrypt-bypass +curl -fsSLO https://raw.githubusercontent.com/Former-Stranger/panos-decrypt-bypass/main/compose.yaml +curl -fsSL https://raw.githubusercontent.com/Former-Stranger/panos-decrypt-bypass/main/.env.example -o .env $EDITOR .env # password, SCM service account, firewall docker compose up -d # creates ./data with starter files $EDITOR data/terraform.tfvars # set your SCM folder (required) @@ -72,7 +72,7 @@ Just want a look? Demo mode needs no firewall or SCM account, and its deploys are simulated: ```bash -docker run --rm -p 8088:8088 -e WEBGUI_DEMO=1 -e WEBGUI_AUTH=none ghcr.io/former-stranger/scm-decrypt-bypass +docker run --rm -p 8088:8088 -e WEBGUI_DEMO=1 -e WEBGUI_AUTH=none ghcr.io/former-stranger/panos-decrypt-bypass ``` ## Documentation @@ -95,7 +95,7 @@ docker run --rm -p 8088:8088 -e WEBGUI_DEMO=1 -e WEBGUI_AUTH=none ghcr.io/former Prisma Access, and firewalls whose logs are only in Strata Logging Service, aren't supported yet. - Images are published for linux/amd64 and linux/arm64, on - `ghcr.io/former-stranger/scm-decrypt-bypass` and `docker.io/formerstranger/scm-decrypt-bypass`. + `ghcr.io/former-stranger/panos-decrypt-bypass` and `docker.io/formerstranger/panos-decrypt-bypass`. ## Security diff --git a/SECURITY.md b/SECURITY.md index ef2fb00..a135ebd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security policy -scm-decrypt-bypass changes the decryption policy of production firewalls, so +panos-decrypt-bypass changes the decryption policy of production firewalls, so security reports are taken seriously. ## Reporting a vulnerability diff --git a/compose.yaml b/compose.yaml index 4e4b0fa..af4f274 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,11 +1,11 @@ -# scm-decrypt-bypass: copy this file and .env.example (as .env) into a folder, +# panos-decrypt-bypass: copy this file and .env.example (as .env) into a folder, # fill in .env, then: docker compose up -d # The GUI is then at http://:8088 (put it behind HTTPS; see docs/reverse-proxy.md). services: - scm-decrypt-bypass: - image: ghcr.io/former-stranger/scm-decrypt-bypass:${SDB_VERSION:-latest} - container_name: scm-decrypt-bypass + panos-decrypt-bypass: + image: ghcr.io/former-stranger/panos-decrypt-bypass:${PDB_VERSION:-latest} + container_name: panos-decrypt-bypass restart: unless-stopped env_file: .env environment: diff --git a/docs/configuration.md b/docs/configuration.md index a1a4b39..ebe73e6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -There are three places to configure scm-decrypt-bypass: +There are three places to configure panos-decrypt-bypass: | Where | What | |---|---| @@ -55,14 +55,14 @@ container after changing them: `docker compose up -d`. |---|---|---| | `DATA_DIR` | `/data` in the image, `./data` from source | The [data directory](installation.md#the-data-directory). | | `PUID` / `PGID` | `1000` | User and group the container runs as, and owner of `./data`. | -| `SDB_VERSION` | `latest` | Image tag used by `compose.yaml`. | +| `PDB_VERSION` | `latest` | Image tag used by `compose.yaml`. | ## terraform.tfvars `data/terraform.tfvars` holds your settings for the Terraform module. It's created from a commented template on first start. Changes take effect on the next deploy: use **Deploy…** in the GUI, which shows the plan for review, or -`scm-decrypt-bypass deploy`. +`panos-decrypt-bypass deploy`. ### Where the config lives @@ -137,12 +137,12 @@ overwrites files it didn't ship. For example: terraform { backend "s3" { bucket = "my-terraform-state" - key = "scm-decrypt-bypass/terraform.tfstate" + key = "panos-decrypt-bypass/terraform.tfstate" region = "eu-central-1" } } ``` Then migrate once: -`docker compose exec scm-decrypt-bypass sh -c 'cd /data/terraform && terraform init -migrate-state'`. +`docker compose exec panos-decrypt-bypass sh -c 'cd /data/terraform && terraform init -migrate-state'`. Give the container the backend's credentials through `.env`. diff --git a/docs/installation.md b/docs/installation.md index 952ca12..f3454c7 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,15 +1,15 @@ # Installation -The recommended way to run scm-decrypt-bypass is the Docker image, which +The recommended way to run panos-decrypt-bypass is the Docker image, which includes Terraform and the SCM provider. Check the [prerequisites](prerequisites.md) first. ## Docker Compose ```bash -mkdir scm-decrypt-bypass && cd scm-decrypt-bypass -curl -fsSLO https://raw.githubusercontent.com/Former-Stranger/scm-decrypt-bypass/main/compose.yaml -curl -fsSL https://raw.githubusercontent.com/Former-Stranger/scm-decrypt-bypass/main/.env.example -o .env +mkdir panos-decrypt-bypass && cd panos-decrypt-bypass +curl -fsSLO https://raw.githubusercontent.com/Former-Stranger/panos-decrypt-bypass/main/compose.yaml +curl -fsSL https://raw.githubusercontent.com/Former-Stranger/panos-decrypt-bypass/main/.env.example -o .env chmod 600 .env ``` @@ -28,7 +28,7 @@ chmod 600 .env [Configuration](configuration.md#terraformtfvars). 4. **Check the plan** (read-only): ```bash - docker compose exec scm-decrypt-bypass scm-decrypt-bypass plan + docker compose exec panos-decrypt-bypass panos-decrypt-bypass plan ``` On a fresh install it shows the tag it will create, plus rules for any lists you've filled in. @@ -41,11 +41,11 @@ Then put it behind HTTPS: see the [reverse proxy guide](reverse-proxy.md). | Registry | Image | |---|---| -| GitHub Container Registry | `ghcr.io/former-stranger/scm-decrypt-bypass` | -| Docker Hub | `docker.io/formerstranger/scm-decrypt-bypass` | +| GitHub Container Registry | `ghcr.io/former-stranger/panos-decrypt-bypass` | +| Docker Hub | `docker.io/formerstranger/panos-decrypt-bypass` | Tags: `latest`, the exact version (`0.1.0`), and the minor line (`0.1`). Set -`SDB_VERSION` in `.env` to pin a version. Images are built for linux/amd64 and +`PDB_VERSION` in `.env` to pin a version. Images are built for linux/amd64 and linux/arm64, with provenance attestations and an SBOM. ### The data directory @@ -70,15 +70,15 @@ knows which SCM objects it manages. To keep state elsewhere, see The CLI is in the image. Run it with `docker compose exec`: ```bash -docker compose exec scm-decrypt-bypass scm-decrypt-bypass list -docker compose exec scm-decrypt-bypass scm-decrypt-bypass add url example.org --subdomains -d "Vendor app" -docker compose exec scm-decrypt-bypass scm-decrypt-bypass # interactive menu +docker compose exec panos-decrypt-bypass panos-decrypt-bypass list +docker compose exec panos-decrypt-bypass panos-decrypt-bypass add url example.org --subdomains -d "Vendor app" +docker compose exec panos-decrypt-bypass panos-decrypt-bypass # interactive menu ``` An alias saves typing: ```bash -alias sdb='docker compose exec scm-decrypt-bypass scm-decrypt-bypass' +alias pdb='docker compose exec panos-decrypt-bypass panos-decrypt-bypass' ``` ## Upgrading @@ -97,12 +97,12 @@ For development, or without Docker. You need Python 3.12+, [uv](https://docs.astral.sh/uv/) and Terraform 1.9+. ```bash -git clone https://github.com/Former-Stranger/scm-decrypt-bypass.git -cd scm-decrypt-bypass +git clone https://github.com/Former-Stranger/panos-decrypt-bypass.git +cd panos-decrypt-bypass uv sync cp .env.example .env # and fill it in -uv run scm-decrypt-bypass init -uv run scm-decrypt-bypass serve # http://127.0.0.1:8088 +uv run panos-decrypt-bypass init +uv run panos-decrypt-bypass serve # http://127.0.0.1:8088 ``` The CLI reads `.env` from the current directory and keeps data in `./data` @@ -120,8 +120,8 @@ The bypass objects stay in SCM. To remove everything the app created first, run `terraform destroy` in the data directory, then push: ```bash -docker compose exec scm-decrypt-bypass sh -c 'cd /data/terraform && terraform destroy' -docker compose exec scm-decrypt-bypass scm-decrypt-bypass push +docker compose exec panos-decrypt-bypass sh -c 'cd /data/terraform && terraform destroy' +docker compose exec panos-decrypt-bypass panos-decrypt-bypass push ``` Objects are named with the `name_prefix` (default `tf-ssl-bypass`) and tagged diff --git a/docs/prerequisites.md b/docs/prerequisites.md index e8f075d..ceb62a9 100644 --- a/docs/prerequisites.md +++ b/docs/prerequisites.md @@ -1,6 +1,6 @@ # Prerequisites -scm-decrypt-bypass only works with firewalls **managed by Strata Cloud +panos-decrypt-bypass only works with firewalls **managed by Strata Cloud Manager**. Firewalls managed by Panorama or locally aren't supported. You need three things before installing: an SCM service account, an XML API diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md index 5283605..411dbf4 100644 --- a/docs/reverse-proxy.md +++ b/docs/reverse-proxy.md @@ -18,11 +18,11 @@ Whatever proxy you use: gets and renews certificates by itself. ```bash -mkdir scm-decrypt-bypass && cd scm-decrypt-bypass +mkdir panos-decrypt-bypass && cd panos-decrypt-bypass for f in compose.yaml Caddyfile Caddyfile.step-ca; do - curl -fsSLO https://raw.githubusercontent.com/Former-Stranger/scm-decrypt-bypass/main/examples/caddy/$f + curl -fsSLO https://raw.githubusercontent.com/Former-Stranger/panos-decrypt-bypass/main/examples/caddy/$f done -curl -fsSL https://raw.githubusercontent.com/Former-Stranger/scm-decrypt-bypass/main/.env.example -o .env +curl -fsSL https://raw.githubusercontent.com/Former-Stranger/panos-decrypt-bypass/main/.env.example -o .env echo "SITE_ADDRESS=decrypt.example.com" >> .env # your hostname docker compose up -d ``` @@ -48,14 +48,14 @@ Pick the Caddyfile that matches how you get certificates: } {$SITE_ADDRESS} { - reverse_proxy scm-decrypt-bypass:8088 + reverse_proxy panos-decrypt-bypass:8088 } ``` step-ca validates with the http-01 challenge, so it must reach Caddy on port 80 at the name you're requesting. step-ca's certificates last 24 hours by default; Caddy renews them automatically, about a third of the lifetime before expiry. -`docker logs scm-decrypt-bypass-caddy | grep -i certificate` shows the +`docker logs panos-decrypt-bypass-caddy | grep -i certificate` shows the renewals. ## Adding it to an existing reverse proxy @@ -65,7 +65,7 @@ instead of publishing a port: ```yaml services: - scm-decrypt-bypass: + panos-decrypt-bypass: # ...as in compose.yaml, but without "ports:" networks: [proxy] networks: @@ -73,13 +73,13 @@ networks: external: true # your proxy's network ``` -Then point the proxy at `scm-decrypt-bypass:8088`. +Then point the proxy at `panos-decrypt-bypass:8088`. **Caddy:** ```caddyfile decrypt.example.com { - reverse_proxy scm-decrypt-bypass:8088 + reverse_proxy panos-decrypt-bypass:8088 } ``` @@ -93,7 +93,7 @@ server { ssl_certificate_key /etc/nginx/certs/decrypt.key; location / { - proxy_pass http://scm-decrypt-bypass:8088; + proxy_pass http://panos-decrypt-bypass:8088; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -107,9 +107,9 @@ server { ```yaml labels: - traefik.enable=true - - traefik.http.routers.sdb.rule=Host(`decrypt.example.com`) - - traefik.http.routers.sdb.tls=true - - traefik.http.services.sdb.loadbalancer.server.port=8088 + - traefik.http.routers.pdb.rule=Host(`decrypt.example.com`) + - traefik.http.routers.pdb.tls=true + - traefik.http.services.pdb.loadbalancer.server.port=8088 ``` ## Logging in through your proxy instead diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5e85c7a..5014c28 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4,7 +4,7 @@ Start with the status line at the top right of the GUI (last poll, or the error), the deploy panel's **Log**, and the container log: ```bash -docker compose logs --tail 100 scm-decrypt-bypass +docker compose logs --tail 100 panos-decrypt-bypass ``` ## The container keeps restarting @@ -43,7 +43,7 @@ firewall's timezone. | A rule refers to an object that doesn't exist | Names in `source_addresses`, `no_decrypt_profile` and the EDL settings must exist in the folder or a parent folder. | **The push failed but the apply worked:** the change is in SCM. Use -**Retry push** in the deploy panel, or `scm-decrypt-bypass push`. After a +**Retry push** in the deploy panel, or `panos-decrypt-bypass push`. After a restart, **Deploy…** finds nothing to apply and offers **Push to firewall**. **A bypass is deployed but traffic is still decrypted:** @@ -58,7 +58,7 @@ restart, **Deploy…** finds nothing to apply and offers **Push to firewall**. ### Importing an existing object ```bash -docker compose exec scm-decrypt-bypass sh -c \ +docker compose exec panos-decrypt-bypass sh -c \ 'cd /data/terraform && terraform import "scm_decryption_exclusion.this[\"pinned.example.com\"]" "my-folder:::"' ``` @@ -79,6 +79,6 @@ forward-trust certificate. Bypassing them one by one hides the real problem. ## Getting help -Open an [issue](https://github.com/Former-Stranger/scm-decrypt-bypass/issues) +Open an [issue](https://github.com/Former-Stranger/panos-decrypt-bypass/issues) with the version, how you run it and the error. Remove hostnames, IPs, tenant IDs and keys first. diff --git a/docs/usage.md b/docs/usage.md index 436dd52..c893a58 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,4 +1,4 @@ -# Using scm-decrypt-bypass +# Using panos-decrypt-bypass ## The web GUI @@ -75,7 +75,7 @@ You can close the panel while a deploy runs; a banner at the top shows its progress. Only one deploy runs at a time. Destinations with no hostname can't go in the URL list (the button is greyed -out). Add their IP from the CLI: `scm-decrypt-bypass add destination 203.0.113.50`. +out). Add their IP from the CLI: `panos-decrypt-bypass add destination 203.0.113.50`. ### Deploy… @@ -93,21 +93,21 @@ the bypass dialog asks you to complete it before adding. ## The command line -In Docker, prefix commands with `docker compose exec scm-decrypt-bypass`. +In Docker, prefix commands with `docker compose exec panos-decrypt-bypass`. | Command | | |---|---| -| `scm-decrypt-bypass` | Interactive menu: add, remove, list, plan, deploy | -| `scm-decrypt-bypass add url example.org --subdomains -d "Vendor app"` | Add entries. Kinds: `url`, `category`, `destination`, `source`, `user`. | -| `scm-decrypt-bypass remove url example.org` | Remove entries (also `exclusion`) | -| `scm-decrypt-bypass list [kind]` | Show the lists | -| `scm-decrypt-bypass plan` | Show what would change in SCM (read-only) | -| `scm-decrypt-bypass deploy` | Plan, confirm, apply, push, and wait for each firewall's result | -| `scm-decrypt-bypass deploy --no-push` | Apply to SCM only; push later | -| `scm-decrypt-bypass push` | Push without applying (e.g. retry a failed push) | -| `scm-decrypt-bypass init` | Create the data directory and starter files | -| `scm-decrypt-bypass serve` | Run the web GUI | -| `scm-decrypt-bypass version` | Print the version | +| `panos-decrypt-bypass` | Interactive menu: add, remove, list, plan, deploy | +| `panos-decrypt-bypass add url example.org --subdomains -d "Vendor app"` | Add entries. Kinds: `url`, `category`, `destination`, `source`, `user`. | +| `panos-decrypt-bypass remove url example.org` | Remove entries (also `exclusion`) | +| `panos-decrypt-bypass list [kind]` | Show the lists | +| `panos-decrypt-bypass plan` | Show what would change in SCM (read-only) | +| `panos-decrypt-bypass deploy` | Plan, confirm, apply, push, and wait for each firewall's result | +| `panos-decrypt-bypass deploy --no-push` | Apply to SCM only; push later | +| `panos-decrypt-bypass push` | Push without applying (e.g. retry a failed push) | +| `panos-decrypt-bypass init` | Create the data directory and starter files | +| `panos-decrypt-bypass serve` | Run the web GUI | +| `panos-decrypt-bypass version` | Print the version | `add` and `remove` only edit the lists; add `--deploy` to deploy straight away. Entries are cleaned up as they're added: pasted URLs become hostnames, case and diff --git a/examples/caddy/Caddyfile b/examples/caddy/Caddyfile index 16dca70..eda1da1 100644 --- a/examples/caddy/Caddyfile +++ b/examples/caddy/Caddyfile @@ -6,5 +6,5 @@ {$SITE_ADDRESS} { tls internal - reverse_proxy scm-decrypt-bypass:8088 + reverse_proxy panos-decrypt-bypass:8088 } diff --git a/examples/caddy/Caddyfile.step-ca b/examples/caddy/Caddyfile.step-ca index 9f6e1f3..5516970 100644 --- a/examples/caddy/Caddyfile.step-ca +++ b/examples/caddy/Caddyfile.step-ca @@ -13,5 +13,5 @@ } {$SITE_ADDRESS} { - reverse_proxy scm-decrypt-bypass:8088 + reverse_proxy panos-decrypt-bypass:8088 } diff --git a/examples/caddy/compose.yaml b/examples/caddy/compose.yaml index 3269818..bce56b7 100644 --- a/examples/caddy/compose.yaml +++ b/examples/caddy/compose.yaml @@ -1,9 +1,9 @@ -# scm-decrypt-bypass behind Caddy for HTTPS. Copy this folder, add .env (from +# panos-decrypt-bypass behind Caddy for HTTPS. Copy this folder, add .env (from # the repo's .env.example), pick a Caddyfile, then: docker compose up -d services: - scm-decrypt-bypass: - image: ghcr.io/former-stranger/scm-decrypt-bypass:${SDB_VERSION:-latest} - container_name: scm-decrypt-bypass + panos-decrypt-bypass: + image: ghcr.io/former-stranger/panos-decrypt-bypass:${PDB_VERSION:-latest} + container_name: panos-decrypt-bypass restart: unless-stopped env_file: .env environment: @@ -17,10 +17,10 @@ services: caddy: image: caddy:2 - container_name: scm-decrypt-bypass-caddy + container_name: panos-decrypt-bypass-caddy restart: unless-stopped depends_on: - - scm-decrypt-bypass + - panos-decrypt-bypass ports: - "80:80" - "443:443" diff --git a/pyproject.toml b/pyproject.toml index f464fb4..d899899 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "scm-decrypt-bypass" +name = "panos-decrypt-bypass" version = "0.1.0" description = "Find TLS decryption failures on Palo Alto Networks firewalls and bypass them through Strata Cloud Manager with Terraform" readme = "README.md" @@ -26,13 +26,13 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/Former-Stranger/scm-decrypt-bypass" -Documentation = "https://github.com/Former-Stranger/scm-decrypt-bypass/tree/main/docs" -Issues = "https://github.com/Former-Stranger/scm-decrypt-bypass/issues" -Changelog = "https://github.com/Former-Stranger/scm-decrypt-bypass/blob/main/CHANGELOG.md" +Homepage = "https://github.com/Former-Stranger/panos-decrypt-bypass" +Documentation = "https://github.com/Former-Stranger/panos-decrypt-bypass/tree/main/docs" +Issues = "https://github.com/Former-Stranger/panos-decrypt-bypass/issues" +Changelog = "https://github.com/Former-Stranger/panos-decrypt-bypass/blob/main/CHANGELOG.md" [project.scripts] -scm-decrypt-bypass = "scm_decrypt_bypass.cli:main" +panos-decrypt-bypass = "panos_decrypt_bypass.cli:main" [dependency-groups] dev = ["ruff>=0.6"] @@ -42,8 +42,8 @@ requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/scm_decrypt_bypass"] -exclude = ["src/scm_decrypt_bypass/terraform/tests", "src/scm_decrypt_bypass/terraform/.terraform"] +packages = ["src/panos_decrypt_bypass"] +exclude = ["src/panos_decrypt_bypass/terraform/tests", "src/panos_decrypt_bypass/terraform/.terraform"] [tool.hatch.build.targets.sdist] include = ["src", "tests", "docs", "examples", "README.md", "LICENSE", "CHANGELOG.md"] diff --git a/src/scm_decrypt_bypass/__init__.py b/src/panos_decrypt_bypass/__init__.py similarity index 84% rename from src/scm_decrypt_bypass/__init__.py rename to src/panos_decrypt_bypass/__init__.py index bf61aa6..76c6cc6 100644 --- a/src/scm_decrypt_bypass/__init__.py +++ b/src/panos_decrypt_bypass/__init__.py @@ -3,6 +3,6 @@ from importlib.metadata import PackageNotFoundError, version try: - __version__ = version("scm-decrypt-bypass") + __version__ = version("panos-decrypt-bypass") except PackageNotFoundError: # running from a source checkout without installing __version__ = "0.0.0+unknown" diff --git a/src/panos_decrypt_bypass/__main__.py b/src/panos_decrypt_bypass/__main__.py new file mode 100644 index 0000000..0bc2343 --- /dev/null +++ b/src/panos_decrypt_bypass/__main__.py @@ -0,0 +1,3 @@ +from panos_decrypt_bypass.cli import main + +main() diff --git a/src/scm_decrypt_bypass/bypass_lists.py b/src/panos_decrypt_bypass/bypass_lists.py similarity index 100% rename from src/scm_decrypt_bypass/bypass_lists.py rename to src/panos_decrypt_bypass/bypass_lists.py diff --git a/src/scm_decrypt_bypass/cli.py b/src/panos_decrypt_bypass/cli.py similarity index 91% rename from src/scm_decrypt_bypass/cli.py rename to src/panos_decrypt_bypass/cli.py index 19d6628..92ef110 100644 --- a/src/scm_decrypt_bypass/cli.py +++ b/src/panos_decrypt_bypass/cli.py @@ -1,14 +1,14 @@ -"""scm-decrypt-bypass: manage SSL decryption bypasses in Strata Cloud Manager. - - scm-decrypt-bypass # interactive menu - scm-decrypt-bypass add url mybank.com --subdomains -d "Online banking" - scm-decrypt-bypass remove url mybank.com - scm-decrypt-bypass list - scm-decrypt-bypass plan # show what would change in SCM - scm-decrypt-bypass deploy # plan, confirm, apply, push to firewalls - scm-decrypt-bypass push # push the candidate config without applying - scm-decrypt-bypass serve # run the web GUI - scm-decrypt-bypass init # create the data directory +"""panos-decrypt-bypass: manage SSL decryption bypasses in Strata Cloud Manager. + + panos-decrypt-bypass # interactive menu + panos-decrypt-bypass add url mybank.com --subdomains -d "Online banking" + panos-decrypt-bypass remove url mybank.com + panos-decrypt-bypass list + panos-decrypt-bypass plan # show what would change in SCM + panos-decrypt-bypass deploy # plan, confirm, apply, push to firewalls + panos-decrypt-bypass push # push the candidate config without applying + panos-decrypt-bypass serve # run the web GUI + panos-decrypt-bypass init # create the data directory add/remove/list only edit the bypass lists; nothing reaches SCM until you deploy. Kinds you can add: url, category, destination, source, user. @@ -29,9 +29,9 @@ import sys from pathlib import Path -from scm_decrypt_bypass import __version__, bypass_lists, scm_api -from scm_decrypt_bypass.bypass_lists import KINDS -from scm_decrypt_bypass.workspace import Workspace, terraform_env +from panos_decrypt_bypass import __version__, bypass_lists, scm_api +from panos_decrypt_bypass.bypass_lists import KINDS +from panos_decrypt_bypass.workspace import Workspace, terraform_env PLAN_FILE = "tfplan" ADDABLE = [k for k in KINDS if k != "exclusion"] @@ -58,7 +58,7 @@ def drop_root() -> None: if os.name != "posix" or os.geteuid() != 0 or not puid or not shutil.which("setpriv"): return pgid = os.environ.get("PGID", puid) - argv = ["setpriv", f"--reuid={puid}", f"--regid={pgid}", "--clear-groups", sys.executable, "-m", "scm_decrypt_bypass", *sys.argv[1:]] + argv = ["setpriv", f"--reuid={puid}", f"--regid={pgid}", "--clear-groups", sys.executable, "-m", "panos_decrypt_bypass", *sys.argv[1:]] os.execvp("setpriv", argv) # noqa: S606 - fixed program, re-running this same command @@ -143,7 +143,7 @@ def cmd_deploy(args: argparse.Namespace) -> None: print("No changes to apply, nothing to push. Use --force-push to push anyway.") return if args.no_push: - print("Skipping push (--no-push). Run `scm-decrypt-bypass push` when ready.") + print("Skipping push (--no-push). Run `panos-decrypt-bypass push` when ready.") return do_push(args) @@ -168,15 +168,15 @@ def cmd_serve(args: argparse.Namespace) -> None: import uvicorn - from scm_decrypt_bypass.web.settings import Settings + from panos_decrypt_bypass.web.settings import Settings if os.environ.get("WEBGUI_DEMO", "").lower() in ("1", "true", "yes", "on") and not os.environ.get("DATA_DIR"): - os.environ["DATA_DIR"] = tempfile.mkdtemp(prefix="scm-decrypt-bypass-demo-") + os.environ["DATA_DIR"] = tempfile.mkdtemp(prefix="panos-decrypt-bypass-demo-") print(f"Demo mode: using a throwaway data directory, {os.environ['DATA_DIR']}") errors = Settings.from_env().startup_errors() if errors: sys.exit("\n".join(errors)) - uvicorn.run("scm_decrypt_bypass.web.app:app", host=args.host, port=args.port, proxy_headers=True, forwarded_allow_ips="*", access_log=False) + uvicorn.run("panos_decrypt_bypass.web.app:app", host=args.host, port=args.port, proxy_headers=True, forwarded_allow_ips="*", access_log=False) def cmd_version(args: argparse.Namespace) -> None: @@ -271,7 +271,7 @@ def finish_edit(changed: int, deploy: bool) -> None: elif deploy: cmd_deploy(build_parser().parse_args(["deploy"])) else: - print(f"\n{changed} change(s) saved to {BYPASS_DIR}. Run `scm-decrypt-bypass deploy` to push them.") + print(f"\n{changed} change(s) saved to {BYPASS_DIR}. Run `panos-decrypt-bypass deploy` to push them.") # --- Interactive menu -------------------------------------------------------- @@ -402,7 +402,7 @@ def interactive() -> None: except (EOFError, KeyboardInterrupt): print() if pending: - print(f"{pending} change(s) saved to {BYPASS_DIR} but not deployed. Run `scm-decrypt-bypass deploy` when ready.") + print(f"{pending} change(s) saved to {BYPASS_DIR} but not deployed. Run `panos-decrypt-bypass deploy` when ready.") def build_parser() -> argparse.ArgumentParser: diff --git a/src/scm_decrypt_bypass/scm_api.py b/src/panos_decrypt_bypass/scm_api.py similarity index 100% rename from src/scm_decrypt_bypass/scm_api.py rename to src/panos_decrypt_bypass/scm_api.py diff --git a/src/scm_decrypt_bypass/templates/bypass/exclusion-hostnames.txt b/src/panos_decrypt_bypass/templates/bypass/exclusion-hostnames.txt similarity index 100% rename from src/scm_decrypt_bypass/templates/bypass/exclusion-hostnames.txt rename to src/panos_decrypt_bypass/templates/bypass/exclusion-hostnames.txt diff --git a/src/scm_decrypt_bypass/templates/bypass/no-decrypt-categories.txt b/src/panos_decrypt_bypass/templates/bypass/no-decrypt-categories.txt similarity index 100% rename from src/scm_decrypt_bypass/templates/bypass/no-decrypt-categories.txt rename to src/panos_decrypt_bypass/templates/bypass/no-decrypt-categories.txt diff --git a/src/scm_decrypt_bypass/templates/bypass/no-decrypt-destinations.txt b/src/panos_decrypt_bypass/templates/bypass/no-decrypt-destinations.txt similarity index 100% rename from src/scm_decrypt_bypass/templates/bypass/no-decrypt-destinations.txt rename to src/panos_decrypt_bypass/templates/bypass/no-decrypt-destinations.txt diff --git a/src/scm_decrypt_bypass/templates/bypass/no-decrypt-sources.txt b/src/panos_decrypt_bypass/templates/bypass/no-decrypt-sources.txt similarity index 100% rename from src/scm_decrypt_bypass/templates/bypass/no-decrypt-sources.txt rename to src/panos_decrypt_bypass/templates/bypass/no-decrypt-sources.txt diff --git a/src/scm_decrypt_bypass/templates/bypass/no-decrypt-urls.txt b/src/panos_decrypt_bypass/templates/bypass/no-decrypt-urls.txt similarity index 100% rename from src/scm_decrypt_bypass/templates/bypass/no-decrypt-urls.txt rename to src/panos_decrypt_bypass/templates/bypass/no-decrypt-urls.txt diff --git a/src/scm_decrypt_bypass/templates/bypass/no-decrypt-users.txt b/src/panos_decrypt_bypass/templates/bypass/no-decrypt-users.txt similarity index 100% rename from src/scm_decrypt_bypass/templates/bypass/no-decrypt-users.txt rename to src/panos_decrypt_bypass/templates/bypass/no-decrypt-users.txt diff --git a/src/scm_decrypt_bypass/templates/terraform.tfvars b/src/panos_decrypt_bypass/templates/terraform.tfvars similarity index 93% rename from src/scm_decrypt_bypass/templates/terraform.tfvars rename to src/panos_decrypt_bypass/templates/terraform.tfvars index 7245d90..4857cce 100644 --- a/src/scm_decrypt_bypass/templates/terraform.tfvars +++ b/src/panos_decrypt_bypass/templates/terraform.tfvars @@ -1,6 +1,6 @@ -# scm-decrypt-bypass settings. Lives in your data directory (DATA_DIR, +# panos-decrypt-bypass settings. Lives in your data directory (DATA_DIR, # /data in the container); edit it, then use Deploy… in the web GUI or -# `scm-decrypt-bypass deploy`. Every setting is described in variables.tf +# `panos-decrypt-bypass deploy`. Every setting is described in variables.tf # and in docs/configuration.md. # --- Where the config lives in Strata Cloud Manager (required) --------------- diff --git a/src/scm_decrypt_bypass/terraform/.terraform.lock.hcl b/src/panos_decrypt_bypass/terraform/.terraform.lock.hcl similarity index 100% rename from src/scm_decrypt_bypass/terraform/.terraform.lock.hcl rename to src/panos_decrypt_bypass/terraform/.terraform.lock.hcl diff --git a/src/scm_decrypt_bypass/terraform/main.tf b/src/panos_decrypt_bypass/terraform/main.tf similarity index 99% rename from src/scm_decrypt_bypass/terraform/main.tf rename to src/panos_decrypt_bypass/terraform/main.tf index b74a854..066ba64 100644 --- a/src/scm_decrypt_bypass/terraform/main.tf +++ b/src/panos_decrypt_bypass/terraform/main.tf @@ -107,7 +107,7 @@ resource "scm_tag" "bypass" { snippet = local.location.snippet name = var.name_prefix color = "Orange" - comments = "Managed by Terraform (scm-decrypt-bypass)" + comments = "Managed by Terraform (panos-decrypt-bypass)" } resource "scm_url_category" "no_decrypt" { diff --git a/src/scm_decrypt_bypass/terraform/modules/no-decrypt-rule/main.tf b/src/panos_decrypt_bypass/terraform/modules/no-decrypt-rule/main.tf similarity index 100% rename from src/scm_decrypt_bypass/terraform/modules/no-decrypt-rule/main.tf rename to src/panos_decrypt_bypass/terraform/modules/no-decrypt-rule/main.tf diff --git a/src/scm_decrypt_bypass/terraform/outputs.tf b/src/panos_decrypt_bypass/terraform/outputs.tf similarity index 100% rename from src/scm_decrypt_bypass/terraform/outputs.tf rename to src/panos_decrypt_bypass/terraform/outputs.tf diff --git a/src/scm_decrypt_bypass/terraform/rules.tf b/src/panos_decrypt_bypass/terraform/rules.tf similarity index 100% rename from src/scm_decrypt_bypass/terraform/rules.tf rename to src/panos_decrypt_bypass/terraform/rules.tf diff --git a/src/scm_decrypt_bypass/terraform/tests/plan.tftest.hcl b/src/panos_decrypt_bypass/terraform/tests/plan.tftest.hcl similarity index 97% rename from src/scm_decrypt_bypass/terraform/tests/plan.tftest.hcl rename to src/panos_decrypt_bypass/terraform/tests/plan.tftest.hcl index bac5062..51a23ff 100644 --- a/src/scm_decrypt_bypass/terraform/tests/plan.tftest.hcl +++ b/src/panos_decrypt_bypass/terraform/tests/plan.tftest.hcl @@ -1,6 +1,6 @@ # Offline plan against a mocked SCM provider. From the repo root: -# terraform -chdir=src/scm_decrypt_bypass/terraform init -backend=false -# terraform -chdir=src/scm_decrypt_bypass/terraform test +# terraform -chdir=src/panos_decrypt_bypass/terraform init -backend=false +# terraform -chdir=src/panos_decrypt_bypass/terraform test # Uses the lists in tests/fixtures instead of a data directory. mock_provider "scm" {} diff --git a/src/scm_decrypt_bypass/terraform/variables.tf b/src/panos_decrypt_bypass/terraform/variables.tf similarity index 100% rename from src/scm_decrypt_bypass/terraform/variables.tf rename to src/panos_decrypt_bypass/terraform/variables.tf diff --git a/src/scm_decrypt_bypass/terraform/versions.tf b/src/panos_decrypt_bypass/terraform/versions.tf similarity index 100% rename from src/scm_decrypt_bypass/terraform/versions.tf rename to src/panos_decrypt_bypass/terraform/versions.tf diff --git a/src/scm_decrypt_bypass/web/__init__.py b/src/panos_decrypt_bypass/web/__init__.py similarity index 100% rename from src/scm_decrypt_bypass/web/__init__.py rename to src/panos_decrypt_bypass/web/__init__.py diff --git a/src/scm_decrypt_bypass/web/advice.py b/src/panos_decrypt_bypass/web/advice.py similarity index 99% rename from src/scm_decrypt_bypass/web/advice.py rename to src/panos_decrypt_bypass/web/advice.py index 2ef7a67..4e7dcba 100644 --- a/src/scm_decrypt_bypass/web/advice.py +++ b/src/panos_decrypt_bypass/web/advice.py @@ -4,7 +4,7 @@ import re from pathlib import Path -from scm_decrypt_bypass import bypass_lists +from panos_decrypt_bypass import bypass_lists # Alerts a client sends when it refuses the firewall's forward-proxy cert: # the app pins its certificate, or this device doesn't trust the firewall CA. diff --git a/src/scm_decrypt_bypass/web/app.py b/src/panos_decrypt_bypass/web/app.py similarity index 93% rename from src/scm_decrypt_bypass/web/app.py rename to src/panos_decrypt_bypass/web/app.py index 54fc7a9..452eedc 100644 --- a/src/scm_decrypt_bypass/web/app.py +++ b/src/panos_decrypt_bypass/web/app.py @@ -1,6 +1,6 @@ """Web GUI: shows TLS decryption failures and turns them into bypasses. -Started by `scm-decrypt-bypass serve` (the container's default command). +Started by `panos-decrypt-bypass serve` (the container's default command). """ import asyncio @@ -15,13 +15,13 @@ from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field -from scm_decrypt_bypass import __version__, bypass_lists -from scm_decrypt_bypass.web.advice import bypass_status, explain -from scm_decrypt_bypass.web.auth import COOKIE, Auth -from scm_decrypt_bypass.web.deployer import Busy, DemoDeployer, Deployer -from scm_decrypt_bypass.web.poller import Poller -from scm_decrypt_bypass.web.settings import Settings -from scm_decrypt_bypass.web.store import Store +from panos_decrypt_bypass import __version__, bypass_lists +from panos_decrypt_bypass.web.advice import bypass_status, explain +from panos_decrypt_bypass.web.auth import COOKIE, Auth +from panos_decrypt_bypass.web.deployer import Busy, DemoDeployer, Deployer +from panos_decrypt_bypass.web.poller import Poller +from panos_decrypt_bypass.web.settings import Settings +from panos_decrypt_bypass.web.store import Store logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") @@ -57,7 +57,7 @@ async def lifespan(_: FastAPI): task.cancel() -app = FastAPI(title="scm-decrypt-bypass", version=__version__, lifespan=lifespan, docs_url=None, redoc_url=None) +app = FastAPI(title="panos-decrypt-bypass", version=__version__, lifespan=lifespan, docs_url=None, redoc_url=None) app.mount("/static", StaticFiles(directory=STATIC), name="static") diff --git a/src/scm_decrypt_bypass/web/auth.py b/src/panos_decrypt_bypass/web/auth.py similarity index 99% rename from src/scm_decrypt_bypass/web/auth.py rename to src/panos_decrypt_bypass/web/auth.py index 0b82768..7edb21e 100644 --- a/src/scm_decrypt_bypass/web/auth.py +++ b/src/panos_decrypt_bypass/web/auth.py @@ -16,7 +16,7 @@ import time from pathlib import Path -COOKIE = "sdb_session" +COOKIE = "pdb_session" FAILURE_WINDOW = 600 # seconds FAILURE_LIMIT = 10 # failures per client per window before refusing SCRYPT = {"n": 2**14, "r": 8, "p": 1, "dklen": 32} # ~16 MiB, tens of ms per check diff --git a/src/scm_decrypt_bypass/web/demo_logs.xml b/src/panos_decrypt_bypass/web/demo_logs.xml similarity index 100% rename from src/scm_decrypt_bypass/web/demo_logs.xml rename to src/panos_decrypt_bypass/web/demo_logs.xml diff --git a/src/scm_decrypt_bypass/web/deployer.py b/src/panos_decrypt_bypass/web/deployer.py similarity index 98% rename from src/scm_decrypt_bypass/web/deployer.py rename to src/panos_decrypt_bypass/web/deployer.py index c67da02..fc10d2c 100644 --- a/src/scm_decrypt_bypass/web/deployer.py +++ b/src/panos_decrypt_bypass/web/deployer.py @@ -11,8 +11,8 @@ import time from dataclasses import asdict, dataclass, field -from scm_decrypt_bypass import bypass_lists, scm_api -from scm_decrypt_bypass.workspace import Workspace, terraform_env +from panos_decrypt_bypass import bypass_lists, scm_api +from panos_decrypt_bypass.workspace import Workspace, terraform_env PLAN_FILE = "tfplan.web" MAX_LOG_LINES = 3000 @@ -221,7 +221,7 @@ def _push(self, job: Job) -> None: folders = json.loads(self._tf_output(job, "output", "-json", "push_folders")) hosts = list(dict.fromkeys(e.removeprefix("*.") for e in job.added)) what = f"bypass {' '.join(hosts)}" if hosts else "deploy" - scm_api.push_and_wait(folders, f"scm-decrypt-bypass: {what}", log=job.log.append) + scm_api.push_and_wait(folders, f"panos-decrypt-bypass: {what}", log=job.log.append) self._finish(job, "done") except Exception as e: self._fail(job, "push", e) diff --git a/src/scm_decrypt_bypass/web/panos.py b/src/panos_decrypt_bypass/web/panos.py similarity index 100% rename from src/scm_decrypt_bypass/web/panos.py rename to src/panos_decrypt_bypass/web/panos.py diff --git a/src/scm_decrypt_bypass/web/poller.py b/src/panos_decrypt_bypass/web/poller.py similarity index 93% rename from src/scm_decrypt_bypass/web/poller.py rename to src/panos_decrypt_bypass/web/poller.py index 9205008..b2a8c2b 100644 --- a/src/scm_decrypt_bypass/web/poller.py +++ b/src/panos_decrypt_bypass/web/poller.py @@ -6,11 +6,11 @@ from datetime import datetime, timedelta from pathlib import Path -from scm_decrypt_bypass.web.panos import Firewall, LogEntry, PanosError, parse_entries -from scm_decrypt_bypass.web.settings import Settings -from scm_decrypt_bypass.web.store import Store +from panos_decrypt_bypass.web.panos import Firewall, LogEntry, PanosError, parse_entries +from panos_decrypt_bypass.web.settings import Settings +from panos_decrypt_bypass.web.store import Store -log = logging.getLogger("scm_decrypt_bypass.poller") +log = logging.getLogger("panos_decrypt_bypass.poller") PAGE_SIZE = 1000 MAX_PAGES = 10 diff --git a/src/scm_decrypt_bypass/web/settings.py b/src/panos_decrypt_bypass/web/settings.py similarity index 98% rename from src/scm_decrypt_bypass/web/settings.py rename to src/panos_decrypt_bypass/web/settings.py index 0360b0c..d4dd1ee 100644 --- a/src/scm_decrypt_bypass/web/settings.py +++ b/src/panos_decrypt_bypass/web/settings.py @@ -8,7 +8,7 @@ from pathlib import Path from zoneinfo import ZoneInfo -from scm_decrypt_bypass.workspace import Workspace +from panos_decrypt_bypass.workspace import Workspace def _bool(value: str | None, default: bool) -> bool: diff --git a/src/scm_decrypt_bypass/web/static/app.js b/src/panos_decrypt_bypass/web/static/app.js similarity index 99% rename from src/scm_decrypt_bypass/web/static/app.js rename to src/panos_decrypt_bypass/web/static/app.js index d160278..77c95b8 100644 --- a/src/scm_decrypt_bypass/web/static/app.js +++ b/src/panos_decrypt_bypass/web/static/app.js @@ -60,7 +60,7 @@ async function loadStatus() { const s = await getJSON("/api/status"); lastStatus = s; $("logout").hidden = !s.auth; - $("version").textContent = `scm-decrypt-bypass ${s.version}`; + $("version").textContent = `panos-decrypt-bypass ${s.version}`; $("setupHint").hidden = s.configured; $("setupHint").textContent = "Not configured yet: set folder (or snippet) in terraform.tfvars in the data directory before deploying. See docs/configuration.md."; const problem = s.problems.length ? s.problems.join(" ") : s.last_error; diff --git a/src/scm_decrypt_bypass/web/static/index.html b/src/panos_decrypt_bypass/web/static/index.html similarity index 100% rename from src/scm_decrypt_bypass/web/static/index.html rename to src/panos_decrypt_bypass/web/static/index.html diff --git a/src/scm_decrypt_bypass/web/static/login.html b/src/panos_decrypt_bypass/web/static/login.html similarity index 100% rename from src/scm_decrypt_bypass/web/static/login.html rename to src/panos_decrypt_bypass/web/static/login.html diff --git a/src/scm_decrypt_bypass/web/static/login.js b/src/panos_decrypt_bypass/web/static/login.js similarity index 100% rename from src/scm_decrypt_bypass/web/static/login.js rename to src/panos_decrypt_bypass/web/static/login.js diff --git a/src/scm_decrypt_bypass/web/static/style.css b/src/panos_decrypt_bypass/web/static/style.css similarity index 100% rename from src/scm_decrypt_bypass/web/static/style.css rename to src/panos_decrypt_bypass/web/static/style.css diff --git a/src/scm_decrypt_bypass/web/store.py b/src/panos_decrypt_bypass/web/store.py similarity index 99% rename from src/scm_decrypt_bypass/web/store.py rename to src/panos_decrypt_bypass/web/store.py index 3ab397d..229a594 100644 --- a/src/scm_decrypt_bypass/web/store.py +++ b/src/panos_decrypt_bypass/web/store.py @@ -9,7 +9,7 @@ from pathlib import Path from zoneinfo import ZoneInfo -from scm_decrypt_bypass.web.panos import LogEntry, fields_from_raw +from panos_decrypt_bypass.web.panos import LogEntry, fields_from_raw COLUMNS = ( "receive_time", diff --git a/src/scm_decrypt_bypass/workspace.py b/src/panos_decrypt_bypass/workspace.py similarity index 98% rename from src/scm_decrypt_bypass/workspace.py rename to src/panos_decrypt_bypass/workspace.py index 6cc8531..672fa4a 100644 --- a/src/scm_decrypt_bypass/workspace.py +++ b/src/panos_decrypt_bypass/workspace.py @@ -21,7 +21,7 @@ PACKAGE_DIR = Path(__file__).resolve().parent MODULE_DIR = PACKAGE_DIR / "terraform" TEMPLATES_DIR = PACKAGE_DIR / "templates" -PATHS_FILE = "zz-scm-decrypt-bypass.auto.tfvars.json" +PATHS_FILE = "zz-panos-decrypt-bypass.auto.tfvars.json" def default_data_dir() -> Path: diff --git a/src/scm_decrypt_bypass/__main__.py b/src/scm_decrypt_bypass/__main__.py deleted file mode 100644 index 7037e03..0000000 --- a/src/scm_decrypt_bypass/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from scm_decrypt_bypass.cli import main - -main() diff --git a/tests/test_auth.py b/tests/test_auth.py index a048595..61f1dfc 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -7,8 +7,8 @@ from pathlib import Path from unittest import mock -from scm_decrypt_bypass.web import auth as auth_module -from scm_decrypt_bypass.web.auth import Auth +from panos_decrypt_bypass.web import auth as auth_module +from panos_decrypt_bypass.web.auth import Auth PASSWORD = "correct-horse-battery" diff --git a/tests/test_bypass_lists.py b/tests/test_bypass_lists.py index d8d30aa..82dd93b 100644 --- a/tests/test_bypass_lists.py +++ b/tests/test_bypass_lists.py @@ -7,8 +7,8 @@ import unittest from pathlib import Path -from scm_decrypt_bypass import bypass_lists as bl -from scm_decrypt_bypass.workspace import Workspace +from panos_decrypt_bypass import bypass_lists as bl +from panos_decrypt_bypass.workspace import Workspace class CleanTest(unittest.TestCase): diff --git a/tests/test_deployer.py b/tests/test_deployer.py index 668814d..8beedec 100644 --- a/tests/test_deployer.py +++ b/tests/test_deployer.py @@ -13,9 +13,9 @@ from pathlib import Path from unittest import mock -from scm_decrypt_bypass import bypass_lists, scm_api -from scm_decrypt_bypass.web.deployer import Busy, DemoDeployer, Deployer, summarize_plan -from scm_decrypt_bypass.workspace import PATHS_FILE, Workspace +from panos_decrypt_bypass import bypass_lists, scm_api +from panos_decrypt_bypass.web.deployer import Busy, DemoDeployer, Deployer, summarize_plan +from panos_decrypt_bypass.workspace import PATHS_FILE, Workspace FAKE_TERRAFORM = r"""#!/usr/bin/env python3 import json, os, sys @@ -146,13 +146,13 @@ def test_bypass_applies_and_pushes_without_review(self): job = self.wait("done", "failed", "review") self.assertEqual(job.state, "done", job.log) self.assertIn("apply -input=false tfplan.web", self.calls.read_text()) - self.assertEqual(self.pushes, [(["ngfw-shared"], "scm-decrypt-bypass: bypass new.example")]) + self.assertEqual(self.pushes, [(["ngfw-shared"], "panos-decrypt-bypass: bypass new.example")]) def test_push_description_names_domains_once(self): self.plan(URL_UPDATE) self.deployer.start("bypass", self.add("apple.com") + self.add("*.apple.com")) self.assertEqual(self.wait("done", "failed").state, "done") - self.assertEqual(self.pushes[0][1], "scm-decrypt-bypass: bypass apple.com") + self.assertEqual(self.pushes[0][1], "panos-decrypt-bypass: bypass apple.com") def test_nothing_to_apply_can_still_push(self): os.environ["FAKE_TF_PLAN"] = "" @@ -160,7 +160,7 @@ def test_nothing_to_apply_can_still_push(self): self.assertEqual(self.wait("nochange", "failed").state, "nochange") self.deployer.retry_push(job.id) self.assertEqual(self.wait("done", "failed").state, "done") - self.assertEqual(self.pushes, [(["ngfw-shared"], "scm-decrypt-bypass: deploy")]) + self.assertEqual(self.pushes, [(["ngfw-shared"], "panos-decrypt-bypass: deploy")]) self.assertFalse((self.work / "tfplan.web").exists()) def test_unexpected_changes_wait_for_review_then_confirm(self): diff --git a/tests/test_webgui.py b/tests/test_webgui.py index 5d7763b..2fa4309 100644 --- a/tests/test_webgui.py +++ b/tests/test_webgui.py @@ -12,10 +12,10 @@ from pathlib import Path from zoneinfo import ZoneInfo -from scm_decrypt_bypass.web.advice import bypass_status, explain -from scm_decrypt_bypass.web.panos import Firewall, PanosError, parse_entries -from scm_decrypt_bypass.web.poller import DEMO_FIXTURE as FIXTURE -from scm_decrypt_bypass.web.store import Store +from panos_decrypt_bypass.web.advice import bypass_status, explain +from panos_decrypt_bypass.web.panos import Firewall, PanosError, parse_entries +from panos_decrypt_bypass.web.poller import DEMO_FIXTURE as FIXTURE +from panos_decrypt_bypass.web.store import Store TZ = ZoneInfo("Europe/Berlin") @@ -63,7 +63,7 @@ def test_migration_repairs_truncated_sni_in_old_database(self): import sqlite3 path = self.dir / "old.db" - old_cols = [c for c in __import__("scm_decrypt_bypass.web.store", fromlist=["COLUMNS"]).COLUMNS if c != "sni_truncated"] + old_cols = [c for c in __import__("panos_decrypt_bypass.web.store", fromlist=["COLUMNS"]).COLUMNS if c != "sni_truncated"] db = sqlite3.connect(path) db.execute( f"CREATE TABLE events (uid TEXT PRIMARY KEY, ts REAL, target TEXT, failed INTEGER, {', '.join(c + ' TEXT' for c in old_cols)}, raw TEXT)" diff --git a/uv.lock b/uv.lock index b1cb011..f0715a1 100644 --- a/uv.lock +++ b/uv.lock @@ -236,7 +236,7 @@ wheels = [ ] [[package]] -name = "scm-decrypt-bypass" +name = "panos-decrypt-bypass" version = "0.1.0" source = { editable = "." } dependencies = [ From 37b21d403b1a8689800ef34850194193813641d7 Mon Sep 17 00:00:00 2001 From: AJ <845325+Former-Stranger@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:26:09 -0400 Subject: [PATCH 2/3] Support firewalls that aren't managed by SCM or Panorama MANAGEMENT=local switches the app to a second Terraform module that uses the PaloAltoNetworks/panos provider to create the same URL category, address objects, tag, EDLs, profile and ordered no-decrypt rules in a vsys on the firewall itself. Deploys then commit over the XML API, partial to FIREWALL_USERNAME's changes, instead of pushing from SCM. - The SCM module moves to terraform/scm; list parsing is shared with terraform/local through an identical lists.tf (checked by a test). - Data directories record which module files they hold, so upgrades and mode switches remove stale files; running one mode against the other's state is refused. - One-click bypasses on a local firewall still apply without review only when the plan just grows the URL list or adds the URL rule. - FIREWALL_CA_FILE is passed to Terraform as a CA bundle via SSL_CERT_FILE. - Decryption exclusions stay SCM-only: on a local firewall they live in the SSL decryption settings next to the forward-trust certificates. - The image mirrors both providers; CI tests both modules and checks they initialise offline. Co-Authored-By: Claude Opus 5.5 --- .env.example | 16 +- .github/workflows/ci.yml | 29 +++- CHANGELOG.md | 30 ++++ CONTRIBUTING.md | 14 +- Dockerfile | 20 +-- README.md | 48 +++--- SECURITY.md | 4 +- docs/configuration.md | 39 +++-- docs/how-it-works.md | 49 ++++-- docs/installation.md | 52 +++++-- docs/prerequisites.md | 62 +++++--- docs/troubleshooting.md | 24 ++- docs/usage.md | 35 +++-- pyproject.toml | 8 +- src/panos_decrypt_bypass/cli.py | 93 ++++++----- src/panos_decrypt_bypass/panos_commit.py | 75 +++++++++ .../templates/local.tfvars | 45 ++++++ .../{terraform.tfvars => scm.tfvars} | 8 +- .../terraform/local/.terraform.lock.hcl | 27 ++++ .../terraform/local/lists.tf | 74 +++++++++ .../terraform/local/main.tf | 98 ++++++++++++ .../terraform/local/outputs.tf | 16 ++ .../terraform/local/rules.tf | 87 +++++++++++ .../terraform/local/tests/plan.tftest.hcl | 132 ++++++++++++++++ .../terraform/local/variables.tf | 125 +++++++++++++++ .../terraform/local/versions.tf | 16 ++ .../terraform/{ => scm}/.terraform.lock.hcl | 0 .../terraform/scm/lists.tf | 74 +++++++++ .../terraform/{ => scm}/main.tf | 70 --------- .../{ => scm}/modules/no-decrypt-rule/main.tf | 0 .../terraform/{ => scm}/outputs.tf | 0 .../terraform/{ => scm}/rules.tf | 0 .../terraform/{ => scm}/tests/plan.tftest.hcl | 10 +- .../terraform/{ => scm}/variables.tf | 0 .../terraform/{ => scm}/versions.tf | 0 src/panos_decrypt_bypass/web/app.py | 7 +- src/panos_decrypt_bypass/web/deployer.py | 122 ++++++++++++--- src/panos_decrypt_bypass/web/panos.py | 28 +++- src/panos_decrypt_bypass/web/settings.py | 14 +- src/panos_decrypt_bypass/web/static/app.js | 43 ++++-- .../web/static/index.html | 8 +- .../web/static/login.html | 4 +- src/panos_decrypt_bypass/workspace.py | 146 ++++++++++++++++-- tests/test_deployer.py | 130 +++++++++++++++- tests/test_webgui.py | 62 +++++++- tests/test_workspace.py | 105 +++++++++++++ uv.lock | 50 +++--- 47 files changed, 1764 insertions(+), 335 deletions(-) create mode 100644 src/panos_decrypt_bypass/panos_commit.py create mode 100644 src/panos_decrypt_bypass/templates/local.tfvars rename src/panos_decrypt_bypass/templates/{terraform.tfvars => scm.tfvars} (86%) create mode 100644 src/panos_decrypt_bypass/terraform/local/.terraform.lock.hcl create mode 100644 src/panos_decrypt_bypass/terraform/local/lists.tf create mode 100644 src/panos_decrypt_bypass/terraform/local/main.tf create mode 100644 src/panos_decrypt_bypass/terraform/local/outputs.tf create mode 100644 src/panos_decrypt_bypass/terraform/local/rules.tf create mode 100644 src/panos_decrypt_bypass/terraform/local/tests/plan.tftest.hcl create mode 100644 src/panos_decrypt_bypass/terraform/local/variables.tf create mode 100644 src/panos_decrypt_bypass/terraform/local/versions.tf rename src/panos_decrypt_bypass/terraform/{ => scm}/.terraform.lock.hcl (100%) create mode 100644 src/panos_decrypt_bypass/terraform/scm/lists.tf rename src/panos_decrypt_bypass/terraform/{ => scm}/main.tf (54%) rename src/panos_decrypt_bypass/terraform/{ => scm}/modules/no-decrypt-rule/main.tf (100%) rename src/panos_decrypt_bypass/terraform/{ => scm}/outputs.tf (100%) rename src/panos_decrypt_bypass/terraform/{ => scm}/rules.tf (100%) rename src/panos_decrypt_bypass/terraform/{ => scm}/tests/plan.tftest.hcl (93%) rename src/panos_decrypt_bypass/terraform/{ => scm}/variables.tf (100%) rename src/panos_decrypt_bypass/terraform/{ => scm}/versions.tf (100%) create mode 100644 tests/test_workspace.py diff --git a/.env.example b/.env.example index 33802b0..5eb4217 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,13 @@ WEBGUI_PASSWORD= # WEBGUI_AUTH=password # WEBGUI_SESSION_HOURS=12 -# --- Strata Cloud Manager (required for deploys) -------------------------------- +# --- How the firewall is managed ----------------------------------------------- +# scm: managed by Strata Cloud Manager. Bypasses are created in SCM and pushed. +# local: configured on the firewall itself (not by SCM or Panorama). Bypasses +# are created on the firewall and committed. +MANAGEMENT=scm + +# --- Strata Cloud Manager (MANAGEMENT=scm only) ---------------------------------- # A service account (Settings > Identity & Access > Add Identity > Service # Account) with a role that can edit configuration and push. # See docs/prerequisites.md. @@ -18,11 +24,15 @@ SCM_SCOPE=tsg_id:1234567890 # SCM_HOST=api.sase.paloaltonetworks.com # SCM_AUTH_URL=https://auth.apps.paloaltonetworks.com/auth/v1/oauth2/access_token -# --- Firewall (required for the decryption log viewer) -------------------------- +# --- Firewall (required) --------------------------------------------------------- # Management address of the firewall; must match a name in its certificate. FIREWALL_HOST=firewall.example.com -# XML API key for an admin that can read logs (see docs/prerequisites.md). +# XML API key. With MANAGEMENT=scm it only needs to read logs; with +# MANAGEMENT=local it also changes config and commits (see docs/prerequisites.md). FIREWALL_API_KEY= +# MANAGEMENT=local only: the admin that owns FIREWALL_API_KEY. Commits include +# only this admin's changes. +# FIREWALL_USERNAME=decrypt-bypass # Timezone the firewall writes log timestamps in. FIREWALL_TIMEZONE=UTC # If the management certificate comes from a private CA, mount the CA file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23bad05..af9d0ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,21 +36,27 @@ jobs: run: uv run python -m unittest discover tests -v terraform: + # Job name stays "Terraform" (a required check); one step per module. name: Terraform runs-on: ubuntu-latest - defaults: - run: - working-directory: src/panos_decrypt_bypass/terraform steps: - uses: actions/checkout@v7 - uses: hashicorp/setup-terraform@v4 with: terraform_wrapper: false - - run: terraform fmt -check -recursive - - run: terraform init -backend=false -input=false -lockfile=readonly - - run: terraform validate - - name: Test (mocked provider) - run: terraform test + - run: terraform fmt -check -recursive src/panos_decrypt_bypass/terraform + - name: SCM module (validate, mocked-provider tests) + working-directory: src/panos_decrypt_bypass/terraform/scm + run: | + terraform init -backend=false -input=false -lockfile=readonly + terraform validate + terraform test + - name: Local firewall module (validate, mocked-provider tests) + working-directory: src/panos_decrypt_bypass/terraform/local + run: | + terraform init -backend=false -input=false -lockfile=readonly + terraform validate + terraform test docker: name: Docker image @@ -77,5 +83,12 @@ jobs: test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8088/api/status)" = 401 docker exec pdb terraform version docker exec pdb panos-decrypt-bypass version + - name: Both modules initialise offline from the bundled providers + run: | + for module in scm local; do + docker run --rm --network none --entrypoint sh panos-decrypt-bypass:ci -c " + cp -r \"\$(python -c 'from panos_decrypt_bypass.workspace import TERRAFORM_DIR; print(TERRAFORM_DIR)')/$module\" /tmp/tf && + cd /tmp/tf && terraform init -backend=false -input=false -lockfile=readonly -no-color" + done - if: failure() run: docker logs pdb diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf815a..1d2cd94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ All notable changes are listed here. The format follows ## [Unreleased] +## [0.2.0] - 2026-10-15 + +### Added +- Support for locally managed firewalls (`MANAGEMENT=local`): a second + Terraform module, using the PaloAltoNetworks/panos provider, creates the + same URL category, address objects, tag, EDLs, profile and no-decrypt + rules in a vsys on the firewall, and the app commits them over the XML + API. Commits include only `FIREWALL_USERNAME`'s changes. One-click + bypasses, review, retry and the CLI work as with SCM. +- `FIREWALL_CA_FILE` is also trusted by Terraform in local mode. +- `panos-decrypt-bypass commit` as an alias of `push`. +- The status area now flags missing SCM or firewall settings for the + selected mode. + +### Changed +- **Renamed from scm-decrypt-bypass to panos-decrypt-bypass**: the + repository, images (`ghcr.io/former-stranger/panos-decrypt-bypass`, + `docker.io/formerstranger/panos-decrypt-bypass`), CLI command, Python + package and compose service. `PDB_VERSION` replaces `SDB_VERSION`. See + [upgrading from 0.1.0](docs/installation.md#upgrading-from-scm-decrypt-bypass-010). +- The bypass tag's comment now names panos-decrypt-bypass, so the first + deploy after upgrading shows it as a change to review. +- Signing in again is needed once, because the session cookie was renamed. +- The SCM Terraform module moved to `terraform/scm/`. Data directories are + migrated automatically, and module files from older versions are removed. + +### Notes +- Decryption exclusions (`exclusion-hostnames.txt`) remain SCM-only. On a + locally managed firewall they're ignored with a warning. + ## [0.1.0] - 2026-10-14 First public release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce4f818..94622ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,13 +30,17 @@ CI runs all of these on every pull request: uv run ruff check src tests uv run ruff format --check src tests uv run python -m unittest discover tests -terraform -chdir=src/panos_decrypt_bypass/terraform init -backend=false -terraform -chdir=src/panos_decrypt_bypass/terraform test +for m in scm local; do + terraform -chdir=src/panos_decrypt_bypass/terraform/$m init -backend=false + terraform -chdir=src/panos_decrypt_bypass/terraform/$m test +done docker build -t panos-decrypt-bypass:dev . ``` The Python tests use a fake `terraform` and a fake firewall, so they never touch SCM or a real firewall. `terraform test` uses a mocked provider. +`terraform/scm/lists.tf` and `terraform/local/lists.tf` must stay identical; a +test checks. ## Project layout @@ -45,9 +49,11 @@ src/panos_decrypt_bypass/ cli.py command line and interactive menu bypass_lists.py reading, validating and editing the bypass lists scm_api.py SCM config push and job polling + panos_commit.py commit on a locally managed firewall (MANAGEMENT=local) workspace.py the data directory and Terraform working directory - terraform/ the Terraform module (and its tests/) - templates/ starter bypass lists and terraform.tfvars + terraform/scm/ Terraform module for Strata Cloud Manager (and its tests/) + terraform/local/ Terraform module for a locally managed firewall (and its tests/) + templates/ starter bypass lists and a terraform.tfvars per mode web/ FastAPI app, log poller, deployer, login, static page tests/ Python tests and fixtures docs/ user documentation diff --git a/Dockerfile b/Dockerfile index dfd16a7..3f28f36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -# panos-decrypt-bypass: web GUI + CLI, with Terraform and the SCM provider built in. +# panos-decrypt-bypass: web GUI + CLI, with Terraform and the SCM and PAN-OS providers built in. FROM python:3.13-slim ARG TERRAFORM_VERSION=1.16.6 @@ -28,14 +28,16 @@ RUN uv sync --locked --no-dev --no-install-project COPY src ./src RUN uv sync --locked --no-dev --no-editable -# Pre-install the SCM provider into a local mirror and point Terraform at it, -# so `terraform init` never needs the registry at runtime. +# Pre-install the SCM and PAN-OS providers into a local mirror and point +# Terraform at it, so `terraform init` never needs the registry at runtime. RUN mkdir -p /opt/terraform/providers \ - && cp -r /app/src/panos_decrypt_bypass/terraform /tmp/tf-module \ - && cd /tmp/tf-module \ - && terraform init -backend=false -input=false -no-color > /dev/null \ - && terraform providers mirror -platform=linux_${TARGETARCH:-amd64} /opt/terraform/providers \ - && cd / && rm -rf /tmp/tf-module /tmp/.terraform.d \ + && for module in scm local; do \ + cp -r /app/src/panos_decrypt_bypass/terraform/$module /tmp/tf-module \ + && (cd /tmp/tf-module && terraform init -backend=false -input=false -no-color > /dev/null \ + && terraform providers mirror -platform=linux_${TARGETARCH:-amd64} /opt/terraform/providers) \ + && rm -rf /tmp/tf-module || exit 1; \ + done \ + && rm -rf /tmp/.terraform.d \ && printf '%s\n' \ 'provider_installation {' \ ' filesystem_mirror {' \ @@ -68,6 +70,6 @@ ENTRYPOINT ["entrypoint"] CMD ["panos-decrypt-bypass", "serve"] LABEL org.opencontainers.image.title="panos-decrypt-bypass" \ - org.opencontainers.image.description="Find TLS decryption failures on Palo Alto Networks firewalls and bypass them through Strata Cloud Manager" \ + org.opencontainers.image.description="Find TLS decryption failures on Palo Alto Networks firewalls and bypass them in one click, through Strata Cloud Manager or on the firewall itself" \ org.opencontainers.image.source="https://github.com/Former-Stranger/panos-decrypt-bypass" \ org.opencontainers.image.licenses="MIT" diff --git a/README.md b/README.md index 4b81398..96e1d52 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,18 @@ [![Release](https://img.shields.io/github/v/release/Former-Stranger/panos-decrypt-bypass)](https://github.com/Former-Stranger/panos-decrypt-bypass/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -**Find TLS decryption failures on Palo Alto Networks firewalls managed by -Strata Cloud Manager, and bypass them in one click.** +**Find TLS decryption failures on Palo Alto Networks firewalls, and bypass +them in one click.** > [!IMPORTANT] -> **Requires firewalls managed by Strata Cloud Manager (SCM).** Bypasses are -> created and pushed through SCM, so firewalls managed by Panorama or locally -> aren't supported. The Docker host also needs HTTPS access to the firewall's -> management interface to read decryption logs. +> **Works with firewalls managed by Strata Cloud Manager (SCM), or configured +> locally.** With SCM, bypasses are created in SCM and pushed; on a locally +> managed firewall they're created on the firewall and committed. Firewalls +> managed by Panorama aren't supported. Either way, the Docker host needs +> HTTPS access to the firewall's management interface. +> +> This project was called **scm-decrypt-bypass** until 0.2.0. +> See the [changelog](CHANGELOG.md#020) to upgrade. SSL/TLS decryption breaks some apps: ones that pin their certificates, use client certificates, or speak TLS the firewall can't proxy. Finding them @@ -21,7 +25,7 @@ decryption policy and pushing config. panos-decrypt-bypass does both: - a **web GUI** that pulls decryption logs from the firewall, groups the failures by destination and explains each one in plain English; - a **Bypass…** button that adds the hostname to a no-decrypt URL category, - applies it in Strata Cloud Manager (SCM) with Terraform and pushes it to the + applies it with Terraform, and pushes it from SCM or commits it on the firewall, showing every step; - a **CLI** and plain-text bypass lists for everything else: URL categories, source and destination addresses, users and external dynamic lists. @@ -39,12 +43,13 @@ decryption policy and pushing config. panos-decrypt-bypass does both: ``` firewall ──XML API (decryption logs)──► panos-decrypt-bypass ──► web GUI │ - bypass lists + terraform.tfvars │ Terraform (PaloAltoNetworks/scm) + bypass lists + terraform.tfvars │ Terraform ▼ - Strata Cloud Manager ──push──► firewall + MANAGEMENT=scm: Strata Cloud Manager ──push──► firewalls + MANAGEMENT=local: the firewall itself ──commit ``` -Bypasses become ordinary SCM objects you can see and audit: a custom URL +Bypasses become ordinary objects you can see and audit: a custom URL category, address objects and **no-decrypt decryption rules** at the top of the rulebase, scoped by the zones and source addresses you choose. A one-click bypass is applied automatically only when the plan changes nothing but the @@ -53,16 +58,17 @@ See [How it works](docs/how-it-works.md). ## Quick start -You need Docker, an SCM service account, and an XML API key for the firewall. +You need Docker and an XML API key for the firewall, plus an SCM service +account if SCM manages the firewall. [Prerequisites](docs/prerequisites.md) walks through creating them. ```bash mkdir panos-decrypt-bypass && cd panos-decrypt-bypass curl -fsSLO https://raw.githubusercontent.com/Former-Stranger/panos-decrypt-bypass/main/compose.yaml curl -fsSL https://raw.githubusercontent.com/Former-Stranger/panos-decrypt-bypass/main/.env.example -o .env -$EDITOR .env # password, SCM service account, firewall +$EDITOR .env # password, MANAGEMENT, firewall (+ SCM service account) docker compose up -d # creates ./data with starter files -$EDITOR data/terraform.tfvars # set your SCM folder (required) +$EDITOR data/terraform.tfvars # SCM: set your folder (required); local: zones, vsys ``` Open `http://:8088` and sign in. Before you rely on it, put it @@ -79,7 +85,7 @@ docker run --rm -p 8088:8088 -e WEBGUI_DEMO=1 -e WEBGUI_AUTH=none ghcr.io/former | Guide | | |---|---| -| [Prerequisites](docs/prerequisites.md) | SCM service account, firewall API key, decryption logging | +| [Prerequisites](docs/prerequisites.md) | Firewall API key, SCM service account, decryption logging | | [Installation](docs/installation.md) | Docker Compose, upgrading, backups, running from source | | [Configuration](docs/configuration.md) | Every environment variable, `terraform.tfvars` setting and bypass list | | [Using it](docs/usage.md) | The web GUI and the command line | @@ -89,11 +95,13 @@ docker run --rm -p 8088:8088 -e WEBGUI_DEMO=1 -e WEBGUI_AUTH=none ghcr.io/former ## Requirements and limits -- Firewalls managed by **Strata Cloud Manager**. The app talks to SCM for - config and push, and to the firewall's management interface for logs. +- Firewalls managed by **Strata Cloud Manager** (`MANAGEMENT=scm`, the + default), or a firewall **configured locally** (`MANAGEMENT=local`, PAN-OS + 10.2 or later). One install manages one of these; run a second container for + the other. +- Not supported: Panorama-managed firewalls, Prisma Access, and firewalls + whose logs are only in Strata Logging Service. - The Docker host must reach the firewall's management interface over HTTPS. - Prisma Access, and firewalls whose logs are only in Strata Logging Service, - aren't supported yet. - Images are published for linux/amd64 and linux/arm64, on `ghcr.io/former-stranger/panos-decrypt-bypass` and `docker.io/formerstranger/panos-decrypt-bypass`. @@ -111,6 +119,6 @@ Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). ## License and disclaimer [MIT](LICENSE). This project is not affiliated with, endorsed by or supported -by Palo Alto Networks. "Palo Alto Networks", "PAN-OS" and "Strata Cloud -Manager" are trademarks of their owners. It changes production firewall +by Palo Alto Networks. "Palo Alto Networks", "PAN-OS", "Panorama" and +"Strata Cloud Manager" are trademarks of their owners. It changes production firewall configuration: review plans, test in a lab first, and use at your own risk. diff --git a/SECURITY.md b/SECURITY.md index a135ebd..f18127c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,8 +21,8 @@ What the app protects, and how: | **Cross-site requests** | Every request that changes something needs a custom header and a same-origin `Origin`, which another website can't send. Pages are served with a strict Content-Security-Policy, `X-Frame-Options: DENY` and `nosniff`. | | **Hostile log content** | SNI, certificate names and error strings in decryption logs are controlled by whoever is on either end of the connection. The page only inserts them as text, never as HTML. Hostnames are validated before they reach a bypass list or Terraform. | | **Scope of a bypass** | One-click bypasses only add hostnames to the no-decrypt URL category, whose rule is scoped by the zones and source addresses you configure. A deploy applies without review only if the plan changes nothing but that list. | -| **Credentials** | SCM and firewall credentials are read from the environment (`.env`), never stored by the app or written to logs. TLS to the firewall is verified by default. | -| **Container** | The app runs as an unprivileged user (`PUID`/`PGID`). Terraform is checksum-verified at build time, and the SCM provider is pre-installed, so no providers are downloaded at runtime. Release images carry provenance attestations and an SBOM. | +| **Credentials** | SCM and firewall credentials are read from the environment (`.env`), never stored by the app or written to logs. TLS to the firewall is verified by default, by the app and by Terraform. With `MANAGEMENT=local`, the firewall API key can change and commit config, so protect it like an admin password. | +| **Container** | The app runs as an unprivileged user (`PUID`/`PGID`). Terraform is checksum-verified at build time, and the SCM and PAN-OS providers are pre-installed, so no providers are downloaded at runtime. Release images carry provenance attestations and an SBOM. | ## Recommended deployment diff --git a/docs/configuration.md b/docs/configuration.md index ebe73e6..3fe4861 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -5,7 +5,7 @@ There are three places to configure panos-decrypt-bypass: | Where | What | |---|---| | [`.env`](#environment-variables) | Credentials, the login, log polling and container settings | -| [`data/terraform.tfvars`](#terraformtfvars) | Where the config lives in SCM, and what the no-decrypt rules match | +| [`data/terraform.tfvars`](#terraformtfvars) | Where the config lives (SCM folder or vsys), and what the no-decrypt rules match | | [`data/bypass/*.txt`](#bypass-lists) | What's bypassed. Usually edited through the GUI or CLI. | ## Environment variables @@ -25,7 +25,18 @@ container after changing them: `docker compose up -d`. | `WEBGUI_HOST` | `127.0.0.1` | Address `serve` listens on when run from source (the image uses `0.0.0.0`). | | `WEBGUI_DEMO` | `false` | Sample logs and simulated deploys. Nothing is sent to SCM or a firewall. | -### Strata Cloud Manager +### Management + +| Variable | Default | | +|---|---|---| +| `MANAGEMENT` | `scm` | `scm` for a firewall managed by Strata Cloud Manager; `local` for a firewall configured on its own (not by SCM or Panorama). See [prerequisites](prerequisites.md). | + +Keep one mode per data directory. The app refuses to run one mode against +Terraform state the other mode created, because Terraform would plan to delete +everything. To manage both kinds of firewall, run two containers with separate +data directories. + +### Strata Cloud Manager (`MANAGEMENT=scm`) | Variable | Default | | |---|---|---| @@ -41,8 +52,9 @@ container after changing them: `docker compose up -d`. |---|---|---| | `FIREWALL_HOST` | (required) | Management address. Must match a name in its certificate. | | `FIREWALL_API_KEY` | (required) | XML API key ([how to create one](prerequisites.md#2-firewall-xml-api-key)). | +| `FIREWALL_USERNAME` | (`local`: required) | The admin that owns `FIREWALL_API_KEY`. Commits include only this admin's changes. | | `FIREWALL_TIMEZONE` | `UTC` | The timezone the firewall writes log timestamps in, e.g. `Europe/Berlin`. If it's wrong, "last seen" times are off. | -| `FIREWALL_CA_FILE` | (none) | CA certificate (path inside the container) that issued the management certificate, for a private CA. | +| `FIREWALL_CA_FILE` | (none) | CA certificate (path inside the container) that issued the management certificate, for a private CA. With `local`, Terraform trusts it too. | | `FIREWALL_VERIFY_TLS` | `true` | Set `false` to skip certificate checks. Avoid it; use `FIREWALL_CA_FILE`. | | `POLL_SECONDS` | `300` | How often to pull new logs. **Poll now** in the GUI fetches immediately. | | `INITIAL_LOOKBACK_HOURS` | `24` | How far back the first poll reaches. | @@ -60,11 +72,11 @@ container after changing them: `docker compose up -d`. ## terraform.tfvars `data/terraform.tfvars` holds your settings for the Terraform module. It's -created from a commented template on first start. Changes take effect on the -next deploy: use **Deploy…** in the GUI, which shows the plan for review, or -`panos-decrypt-bypass deploy`. +created on first start from a commented template for your `MANAGEMENT` mode. +Changes take effect on the next deploy: use **Deploy…** in the GUI, which +shows the plan for review, or `panos-decrypt-bypass deploy`. -### Where the config lives +### Where the config lives: SCM | Setting | Default | | |---|---|---| @@ -77,6 +89,13 @@ Rules in parent folders are evaluated first. If a decrypt rule in a parent folder matches the traffic you want to bypass, it wins over these no-decrypt rules, so the bypass config has to live in that parent folder or above. +### Where the config lives: locally managed firewall + +| Setting | Default | | +|---|---|---| +| `vsys` | `vsys1` | Virtual system for the objects and rules. | +| `name_prefix` | `tf-ssl-bypass` | Prefix for everything created: rules, URL category, address objects, tag. | + ### What the no-decrypt rules match | Setting | Default | | @@ -84,7 +103,7 @@ rules, so the bypass config has to live in that parent folder or above. | `from_zones` / `to_zones` | `["any"]` | Source and destination zones. | | `source_addresses` | `["any"]` | Names of existing address objects or groups. The rule for `no-decrypt-sources.txt` uses its own list instead. | | `services` | `["any"]` | `any` also catches TLS on ports other than 443. | -| `rule_placement` | top | Where the first rule goes, e.g. `{ relative_position = "after", target_rule = "My-Rule" }`. The others follow it in order. | +| `rule_placement` | top | Where the first rule goes, e.g. `{ relative_position = "after", target_rule = "My-Rule" }`. The others follow it in order. With `local` the rules are kept together as one block in that position, and moving them by hand shows up as a change on the next deploy. | | `log_success` / `log_fail` | `true` | Log sessions the rules match. Undecrypted traffic is only logged when a decryption rule matches it. | | `log_setting` | (none) | Log forwarding profile. | @@ -111,7 +130,7 @@ itself, without a push. `data/bypass/` holds one file per kind of bypass. The GUI and CLI edit them for you. By hand: one entry per line, `#` starts a comment, and a comment after an -entry becomes that object's description in SCM. +entry becomes that object's description. | File | Becomes | Use for | |---|---|---| @@ -120,7 +139,7 @@ entry becomes that object's description in SCM. | `no-decrypt-destinations.txt` | Address objects and a no-decrypt rule | Servers without a hostname (no SNI), whole ranges | | `no-decrypt-sources.txt` | Address objects and a no-decrypt rule | Clients that can't trust your forward-trust CA: IoT, printers, guest networks | | `no-decrypt-users.txt` | A no-decrypt rule matching users or groups | People or teams (needs User-ID) | -| `exclusion-hostnames.txt` | Custom decryption exclusions | Edited by hand only. Applies to every device, regardless of zones or source. | +| `exclusion-hostnames.txt` | Custom decryption exclusions (SCM only) | Edited by hand only. Applies to every device, regardless of zones or source. Ignored with `local`, with a warning: exclusions live next to the forward-trust certificates in the firewall's SSL decryption settings, and the app doesn't touch those. Use `no-decrypt-urls.txt` instead. | Each file's header explains its format. An empty list creates nothing, and the rule for a list is removed when the list is emptied. Wildcard rules differ diff --git a/docs/how-it-works.md b/docs/how-it-works.md index ed4af73..8ba621f 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -6,13 +6,14 @@ firewall management ──XML API──► poller ──► SQLite ──► web GUI ◄── you │ Bypass… ▼ - bypass lists + terraform.tfvars ──► Terraform (scm provider) + bypass lists + terraform.tfvars ──► Terraform │ - ▼ - Strata Cloud Manager (candidate config) - │ push (SCM API) - ▼ - firewalls + ┌──────────────────────────────────┴───────────────┐ + ▼ MANAGEMENT=scm (scm provider) ▼ MANAGEMENT=local (panos provider) + Strata Cloud Manager (candidate config) the firewall (candidate config) + │ push (SCM API) │ commit (XML API) + ▼ ▼ + firewalls running config ``` - **Poller:** every `POLL_SECONDS` it asks the firewall's XML API for @@ -21,13 +22,20 @@ firewall management ──XML API──► poller ──► SQLite ──► web - **Bypass lists:** plain text files, one per kind of bypass. They're the source of truth; the GUI and CLI only edit them. - **Terraform:** a bundled module turns the lists and your `terraform.tfvars` - into SCM objects with the + into objects. With `MANAGEMENT=scm` they're SCM objects, through the [PaloAltoNetworks/scm](https://registry.terraform.io/providers/PaloAltoNetworks/scm) - provider. State lives in your data directory. -- **Push:** the app pushes through SCM's API + provider. With `MANAGEMENT=local` they're created on the firewall in a vsys, + through the + [PaloAltoNetworks/panos](https://registry.terraform.io/providers/PaloAltoNetworks/panos) + provider. Both modules parse the lists the same way. State lives in your + data directory. +- **Push (SCM):** the app pushes through SCM's API (`POST /config/operations/v1/config-versions/candidate:push`) and follows the job and each firewall's child job to the end. Pushes include only the service account's own changes, so other admins' unfinished edits never go out. +- **Commit (local):** the app starts a partial commit over the XML API with + only `FIREWALL_USERNAME`'s changes, then follows the commit job to the end, + so other admins' unfinished edits never go live with a bypass. ## Two ways to bypass decryption @@ -99,17 +107,28 @@ every change is one of: - creating that list and its rule (the first bypass). Anything else stops for review. That includes changes to other rules, profile -changes, deletions, and drift (someone edited the objects in SCM by hand). +changes, deletions, and drift (someone edited the objects by hand). With +`local`, all the no-decrypt rules are one Terraform resource, so a change to it +is applied without review only if it adds the URL rule and leaves every other +rule, and their position, exactly as they were. Cancelling a review removes the entries that were just added, so the lists and -SCM stay in step. Only one deploy runs at a time. +the config stay in step. Only one deploy runs at a time. ## Terraform details -- The image ships Terraform and the SCM provider in a local mirror, so +- The image ships Terraform and both providers in a local mirror, so `terraform init` never contacts the registry at runtime. -- Before every run the bundled module is copied into `data/terraform/` with - your `terraform.tfvars`. Files you add there yourself, such as `backend.tf`, - are kept. +- Before every run the module for your `MANAGEMENT` mode is copied into + `data/terraform/` with your `terraform.tfvars`. Files you add there + yourself, such as `backend.tf`, are kept. +- With `local`, the app passes `FIREWALL_HOST`, `FIREWALL_API_KEY` and + `FIREWALL_VERIFY_TLS` to the provider as `PANOS_*` variables. The provider + has no CA setting, so for `FIREWALL_CA_FILE` the app writes a bundle of the + system roots plus your CA to `data/terraform/.ca-bundle.pem` and points + `SSL_CERT_FILE` at it. +- With `local`, decryption exclusions aren't managed: the provider can only + manage them through the firewall's whole SSL decryption settings, forward + trust certificates included. - The provider now also offers a native `scm_config_push` action. The app keeps using the API push for now, because it reports each firewall's result and scopes pushes to the service account. diff --git a/docs/installation.md b/docs/installation.md index f3454c7..d821733 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,7 +1,7 @@ # Installation The recommended way to run panos-decrypt-bypass is the Docker image, which -includes Terraform and the SCM provider. Check the +includes Terraform and both Palo Alto Networks providers. Check the [prerequisites](prerequisites.md) first. ## Docker Compose @@ -13,18 +13,20 @@ curl -fsSL https://raw.githubusercontent.com/Former-Stranger/panos-decrypt-bypas chmod 600 .env ``` -1. **Edit `.env`.** At minimum set `WEBGUI_PASSWORD` (12+ characters), the - three `SCM_*` values, `FIREWALL_HOST`, `FIREWALL_API_KEY` and - `FIREWALL_TIMEZONE`. If your user's ID isn't 1000, set `PUID` and `PGID` - to the output of `id -u` and `id -g`. +1. **Edit `.env`.** At minimum set `WEBGUI_PASSWORD` (12+ characters), + `MANAGEMENT`, `FIREWALL_HOST`, `FIREWALL_API_KEY` and `FIREWALL_TIMEZONE`. + With `MANAGEMENT=scm` (the default) also set the three `SCM_*` values; with + `MANAGEMENT=local`, set `FIREWALL_USERNAME`. If your user's ID isn't 1000, + set `PUID` and `PGID` to the output of `id -u` and `id -g`. 2. **Start it:** ```bash docker compose up -d ``` The first start creates `./data` with starter files. -3. **Tell it where your config lives in SCM.** Edit `data/terraform.tfvars` - and set `folder` (or `snippet`). Optionally narrow `from_zones`, - `to_zones` and `source_addresses`, and attach a decryption profile. See +3. **Tell it where your config lives.** Edit `data/terraform.tfvars`. With + SCM, set `folder` (or `snippet`); a locally managed firewall uses `vsys1` + unless you set `vsys`. Optionally narrow `from_zones`, `to_zones` and + `source_addresses`, and attach a decryption profile. See [Configuration](configuration.md#terraformtfvars). 4. **Check the plan** (read-only): ```bash @@ -62,7 +64,7 @@ data/ ``` **Back it up**, especially `terraform/terraform.tfstate`: it's how Terraform -knows which SCM objects it manages. To keep state elsewhere, see +knows which objects it manages. To keep state elsewhere, see [remote state](configuration.md#remote-terraform-state). ### Running CLI commands @@ -89,7 +91,25 @@ docker compose pull && docker compose up -d Read the [changelog](../CHANGELOG.md) first. After an upgrade, click **Deploy…** in the GUI (or run `plan`) to see whether the new version changes -anything in SCM. +anything. + +### Upgrading from scm-decrypt-bypass 0.1.0 + +The project was renamed in 0.2.0, and the images moved with it. Your data +directory works as it is. + +1. In your `compose.yaml`, change the image to + `ghcr.io/former-stranger/panos-decrypt-bypass` (or + `docker.io/formerstranger/panos-decrypt-bypass`). The old image names get + no more updates. Optionally rename the service and container to + `panos-decrypt-bypass`, as in the new [compose.yaml](../compose.yaml). +2. `docker compose pull && docker compose up -d`. Everyone is signed out once. +3. The CLI command is now `panos-decrypt-bypass`, so update any alias, for + example `docker compose exec panos-decrypt-bypass list`. +4. Click **Deploy…** once and apply. It updates the bypass tag's comment to + the new name. Until then, one-click bypasses stop for review because of it. + +Existing installs keep `MANAGEMENT=scm`, the default. ## Running from source @@ -107,8 +127,9 @@ uv run panos-decrypt-bypass serve # http://127.0.0.1:8088 The CLI reads `.env` from the current directory and keeps data in `./data` (override with `DATA_DIR`). `serve` listens on 127.0.0.1 unless you pass -`--host` or set `WEBGUI_HOST`. Terraform downloads the SCM provider on first -use. +`--host` or set `WEBGUI_HOST`. Terraform downloads the provider on first +use. With `MANAGEMENT=local` and a private CA on macOS, add the CA to the +system keychain: Terraform ignores `FIREWALL_CA_FILE` there. ## Uninstalling @@ -116,8 +137,9 @@ use. docker compose down ``` -The bypass objects stay in SCM. To remove everything the app created first, -run `terraform destroy` in the data directory, then push: +The bypass objects stay in SCM or on the firewall. To remove everything the +app created first, run `terraform destroy` in the data directory, then push or +commit: ```bash docker compose exec panos-decrypt-bypass sh -c 'cd /data/terraform && terraform destroy' @@ -125,4 +147,4 @@ docker compose exec panos-decrypt-bypass panos-decrypt-bypass push ``` Objects are named with the `name_prefix` (default `tf-ssl-bypass`) and tagged -with it, so they're easy to find in SCM. +with it, so they're easy to find. diff --git a/docs/prerequisites.md b/docs/prerequisites.md index ceb62a9..cb21dfe 100644 --- a/docs/prerequisites.md +++ b/docs/prerequisites.md @@ -1,17 +1,22 @@ # Prerequisites -panos-decrypt-bypass only works with firewalls **managed by Strata Cloud -Manager**. Firewalls managed by Panorama or locally aren't supported. +First decide how the app changes your config. That depends on what manages +the firewall: -You need three things before installing: an SCM service account, an XML API -key for the firewall, and decryption logging on your decrypt rules. You also -need a Docker host that can reach both SCM and the firewall's management -interface. +| The firewall is managed by | Set | Bypasses are | +|---|---|---| +| Strata Cloud Manager | `MANAGEMENT=scm` (default) | created in SCM and pushed | +| Nothing else: you configure it locally | `MANAGEMENT=local` | created on the firewall and committed | +| Panorama | | not supported | + +You need an XML API key for the firewall and decryption logging on your +decrypt rules. With SCM you also need a service account. The Docker host must +reach the firewall's management interface (and SCM, if you use it). -## 1. Strata Cloud Manager service account +## 1. Strata Cloud Manager service account (SCM only) -The app (and Terraform) use a service account to create the bypass objects and -push them. +Skip this with `MANAGEMENT=local`. The app (and Terraform) use a service +account to create the bypass objects and push them. 1. In Strata Cloud Manager, go to **Settings → Identity & Access** and add an identity of type **Service Account**. @@ -38,12 +43,22 @@ other admins' unfinished edits never go out with a bypass. ## 2. Firewall XML API key The decryption log viewer reads logs straight from the firewall's management -interface through the PAN-OS XML API. - -1. Create an **admin role profile** whose only permission is **XML API → Log** - (turn off Web UI and the REST API). Configure it in SCM's device settings - for the firewall, or locally on the firewall. -2. Create an administrator that uses that role. +interface through the PAN-OS XML API. With `MANAGEMENT=local` the same key +also creates the bypass objects and commits them. + +1. Create an **admin role profile** with only the XML API permissions you + need, and turn off the Web UI and the REST API: + + | Mode | XML API permissions | + |---|---| + | `scm` | **Log** | + | `local` | **Log**, **Configuration**, **Operational Requests**, **Commit** | + + With SCM, configure the role in SCM's device settings for the firewall (or + locally). With `local`, configure it on the firewall. +2. Create an administrator that uses that role. With `local`, put its name in + `FIREWALL_USERNAME`: commits include only this admin's changes, so other + admins' unfinished edits never go live with a bypass. 3. Generate an API key for it. This prompts for the password so it isn't echoed or saved in your shell history (add `--cacert ` if the management certificate is from a private CA): @@ -56,7 +71,9 @@ interface through the PAN-OS XML API. 4. Copy the value between `` and `` into `FIREWALL_API_KEY` in `.env`. -The key stops working if that admin's password changes. +The key stops working if that admin's password changes. With `local`, treat +the key like an admin password: it can change and commit the firewall's +config. ## 3. Decryption logging @@ -74,9 +91,9 @@ The logs are read from the firewall itself, so no log forwarding is needed. | From the Docker host to | Port | Used for | |---|---|---| -| Firewall management interface | 443 | Reading decryption logs | -| `auth.apps.paloaltonetworks.com` | 443 | SCM authentication | -| `api.sase.paloaltonetworks.com` | 443 | SCM configuration and push | +| Firewall management interface | 443 | Reading decryption logs; with `local`, config and commits | +| `auth.apps.paloaltonetworks.com` | 443 | SCM authentication (SCM only) | +| `api.sase.paloaltonetworks.com` | 443 | SCM configuration and push (SCM only) | **If the Docker host's own traffic is decrypted by the firewall**, the container won't trust the forward-proxy certificate, and SCM calls fail with @@ -88,4 +105,9 @@ Docker host at least) before you start. The app verifies the firewall's management certificate. If it's issued by a private CA, mount that CA's certificate into the container and set `FIREWALL_CA_FILE` (see [Configuration](configuration.md)). The name in -`FIREWALL_HOST` must match the certificate. +`FIREWALL_HOST` must match the certificate. With `local`, Terraform trusts the +same CA. + +A factory-default firewall uses a self-signed management certificate. Replace +it with one from your CA, or as a last resort, export the self-signed +certificate and use it as `FIREWALL_CA_FILE`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5014c28..162e8ea 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -35,22 +35,28 @@ firewall's timezone. | Error | Fix | |---|---| | `Set folder (or snippet) in …/terraform.tfvars first` | Edit `data/terraform.tfvars`. | -| `Missing SCM_CLIENT_ID …` | Fill in the `SCM_*` values in `.env`. | +| `Missing SCM_CLIENT_ID …` | Fill in the `SCM_*` values in `.env`, or set `MANAGEMENT=local` if SCM doesn't manage the firewall. | +| `Missing FIREWALL_USERNAME …` | With `MANAGEMENT=local`, set it to the admin that owns the API key. | +| `The firewall found no changes by … to commit` | `FIREWALL_USERNAME` isn't the admin that owns `FIREWALL_API_KEY`, so the partial commit skipped the changes. Fix the name, then use **Retry commit**. | +| `The Terraform state … manages objects in …` | `MANAGEMENT` changed, but `data/` holds the other mode's state. Set it back, or use a new data directory. | +| `Unauthorized` / `403` from the panos provider | The API admin's role is missing XML API **Configuration**, **Operational Requests** or **Commit** (see [prerequisites](prerequisites.md#2-firewall-xml-api-key)). | +| `x509: certificate signed by unknown authority` (local) | Terraform doesn't trust the management certificate. Set `FIREWALL_CA_FILE`; `FIREWALL_HOST` must match the certificate. | | `Refresh JWT Authentication error` / HTTP 401 | Wrong client ID, secret or `SCM_SCOPE` (`tsg_id:`). | | `CERTIFICATE_VERIFY_FAILED` contacting `api.sase.paloaltonetworks.com` or `auth.apps.paloaltonetworks.com` | Your firewall is decrypting the Docker host's own traffic to SCM. Bypass decryption for those hostnames (see [prerequisites](prerequisites.md#4-network-access-from-the-docker-host)). | | `Object … already exists` / name conflict | An object with the same name already exists in SCM. Rename it, delete it, or import it into the state (below). | | `Invalid Command … description is invalid` | Push descriptions are cleaned automatically; if you see this, please open an issue with the description. | | A rule refers to an object that doesn't exist | Names in `source_addresses`, `no_decrypt_profile` and the EDL settings must exist in the folder or a parent folder. | -**The push failed but the apply worked:** the change is in SCM. Use -**Retry push** in the deploy panel, or `panos-decrypt-bypass push`. After a -restart, **Deploy…** finds nothing to apply and offers **Push to firewall**. +**The push or commit failed but the apply worked:** the change is in SCM or +the firewall's candidate config. Use **Retry push** (**Retry commit**) in the +deploy panel, or `panos-decrypt-bypass push`. After a restart, **Deploy…** +finds nothing to apply and offers **Push to firewall** (**Commit now**). **A bypass is deployed but traffic is still decrypted:** -- Rules in parent folders are evaluated first: a decrypt rule above yours may - match first. Move the bypass config to that folder, or adjust - `rule_placement`. +- A decrypt rule above the no-decrypt rules may match first. With SCM, rules + in parent folders are evaluated first: move the bypass config to that + folder. Otherwise adjust `rule_placement`. - Check the zones and source in `terraform.tfvars` match the client. - The firewall only sees the SNI. If the client sends a different name than you added (check the session details), add that name. @@ -63,7 +69,9 @@ docker compose exec panos-decrypt-bypass sh -c \ ``` The ID format is `folder:::uuid` (or `:snippet::uuid`). Find the UUID in SCM or -through the SCM API. +through the SCM API. For a locally managed firewall, see each resource's +*Import* section in the +[PAN-OS provider docs](https://registry.terraform.io/providers/PaloAltoNetworks/panos/latest/docs). ## Hostnames ending in "…" diff --git a/docs/usage.md b/docs/usage.md index c893a58..b0a7cf4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -54,22 +54,24 @@ Click **Bypass…**: *Will add* shows exactly what goes into the list, and you can edit the hostnames before adding. 2. Optionally change the description, which is stored with each entry and - shown in SCM. + shown on the URL category. 3. Click **Add & deploy**. -The deploy panel then shows each step: plan, apply, push to firewall. +The deploy panel then shows each step: plan, apply, then push to firewall +(SCM) or commit (locally managed firewall). ![Finished deploy](images/deploy-done.jpg) - **Applied without asking** only when the plan changes nothing but the no-decrypt URL list (and, the first time, creates its rule). - **Stops for review** if anything else would change: other rules, settings - you edited in `terraform.tfvars`, deletions, or changes someone made in SCM - by hand. It lists every change: **Apply & push** continues, **Cancel** takes - your new entries back out of the list, and **Later** leaves the review - waiting. -- If **the push fails**, the change is already in SCM. **Retry push** sends it - again without re-applying. + you edited in `terraform.tfvars`, deletions, or changes someone made by + hand. It lists every change: **Apply & push** (or **Apply & commit**) + continues, **Cancel** takes your new entries back out of the list, and + **Later** leaves the review waiting. +- If **the push or commit fails**, the change is already in SCM or the + firewall's candidate config. **Retry push** (**Retry commit**) tries again + without re-applying. You can close the panel while a deploy runs; a banner at the top shows its progress. Only one deploy runs at a time. @@ -81,8 +83,8 @@ out). Add their IP from the CLI: `panos-decrypt-bypass add destination 203.0.113 **Deploy…** (top right) plans whatever is pending, such as edits to `terraform.tfvars` or lists you changed by hand, and always stops for review. If -there's nothing to apply but an earlier push failed, it offers **Push to -firewall**. +there's nothing to apply but an earlier push or commit failed, it offers +**Push to firewall** (**Commit now**). ### Truncated hostnames @@ -101,10 +103,10 @@ In Docker, prefix commands with `docker compose exec panos-decrypt-bypass`. | `panos-decrypt-bypass add url example.org --subdomains -d "Vendor app"` | Add entries. Kinds: `url`, `category`, `destination`, `source`, `user`. | | `panos-decrypt-bypass remove url example.org` | Remove entries (also `exclusion`) | | `panos-decrypt-bypass list [kind]` | Show the lists | -| `panos-decrypt-bypass plan` | Show what would change in SCM (read-only) | -| `panos-decrypt-bypass deploy` | Plan, confirm, apply, push, and wait for each firewall's result | -| `panos-decrypt-bypass deploy --no-push` | Apply to SCM only; push later | -| `panos-decrypt-bypass push` | Push without applying (e.g. retry a failed push) | +| `panos-decrypt-bypass plan` | Show what would change (read-only) | +| `panos-decrypt-bypass deploy` | Plan, confirm, apply, then push or commit and wait for the result | +| `panos-decrypt-bypass deploy --no-push` | Apply only; push or commit later | +| `panos-decrypt-bypass push` (or `commit`) | Push or commit without applying (e.g. retry a failed one) | | `panos-decrypt-bypass init` | Create the data directory and starter files | | `panos-decrypt-bypass serve` | Run the web GUI | | `panos-decrypt-bypass version` | Print the version | @@ -114,5 +116,6 @@ Entries are cleaned up as they're added: pasted URLs become hostnames, case and trailing slashes are normalised, and duplicates are skipped. `--subdomains` turns `www.example.org` into `example.org` and `*.example.org`. -`deploy` and `push` only include the service account's own changes. Add -`--all-admins` to push everything that's pending in the folder. +`deploy` and `push` only include the SCM service account's own changes, or +`FIREWALL_USERNAME`'s on a locally managed firewall. Add `--all-admins` to +push or commit everything that's pending. diff --git a/pyproject.toml b/pyproject.toml index d899899..c7416e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "panos-decrypt-bypass" -version = "0.1.0" -description = "Find TLS decryption failures on Palo Alto Networks firewalls and bypass them through Strata Cloud Manager with Terraform" +version = "0.2.0" +description = "Find TLS decryption failures on Palo Alto Networks firewalls and bypass them with Terraform, through Strata Cloud Manager or on the firewall itself" readme = "README.md" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "Former-Stranger" }] requires-python = ">=3.12" -keywords = ["palo-alto-networks", "strata-cloud-manager", "ssl-decryption", "tls", "terraform", "firewall"] +keywords = ["palo-alto-networks", "pan-os", "strata-cloud-manager", "ssl-decryption", "tls", "terraform", "firewall"] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Web Environment", @@ -43,7 +43,7 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/panos_decrypt_bypass"] -exclude = ["src/panos_decrypt_bypass/terraform/tests", "src/panos_decrypt_bypass/terraform/.terraform"] +exclude = ["src/panos_decrypt_bypass/terraform/*/tests", "src/panos_decrypt_bypass/terraform/*/.terraform"] [tool.hatch.build.targets.sdist] include = ["src", "tests", "docs", "examples", "README.md", "LICENSE", "CHANGELOG.md"] diff --git a/src/panos_decrypt_bypass/cli.py b/src/panos_decrypt_bypass/cli.py index 92ef110..1ac888c 100644 --- a/src/panos_decrypt_bypass/cli.py +++ b/src/panos_decrypt_bypass/cli.py @@ -1,23 +1,25 @@ -"""panos-decrypt-bypass: manage SSL decryption bypasses in Strata Cloud Manager. +"""panos-decrypt-bypass: manage SSL decryption bypasses on Palo Alto Networks +firewalls, through Strata Cloud Manager or directly on a locally managed firewall. panos-decrypt-bypass # interactive menu panos-decrypt-bypass add url mybank.com --subdomains -d "Online banking" panos-decrypt-bypass remove url mybank.com panos-decrypt-bypass list - panos-decrypt-bypass plan # show what would change in SCM - panos-decrypt-bypass deploy # plan, confirm, apply, push to firewalls - panos-decrypt-bypass push # push the candidate config without applying + panos-decrypt-bypass plan # show what would change + panos-decrypt-bypass deploy # plan, confirm, apply, push/commit + panos-decrypt-bypass push # push/commit without applying (alias: commit) panos-decrypt-bypass serve # run the web GUI panos-decrypt-bypass init # create the data directory -add/remove/list only edit the bypass lists; nothing reaches SCM until you -deploy. Kinds you can add: url, category, destination, source, user. +add/remove/list only edit the bypass lists; nothing reaches the firewall +until you deploy. Kinds you can add: url, category, destination, source, user. Decryption exclusions are edited by hand, and can be listed and removed here. Your settings, bypass lists and Terraform state live in DATA_DIR (/data in -the container, ./data otherwise). Credentials come from the environment or a -.env file in the current directory: SCM_CLIENT_ID, SCM_CLIENT_SECRET, -SCM_SCOPE (tsg_id:), and optionally SCM_HOST and SCM_AUTH_URL. +the container, ./data otherwise). Settings come from the environment or a +.env file in the current directory. MANAGEMENT=scm (the default) needs +SCM_CLIENT_ID, SCM_CLIENT_SECRET and SCM_SCOPE; MANAGEMENT=local needs +FIREWALL_HOST, FIREWALL_API_KEY and FIREWALL_USERNAME. See .env.example. """ import argparse @@ -29,12 +31,14 @@ import sys from pathlib import Path -from panos_decrypt_bypass import __version__, bypass_lists, scm_api +from panos_decrypt_bypass import __version__, bypass_lists, panos_commit, scm_api from panos_decrypt_bypass.bypass_lists import KINDS -from panos_decrypt_bypass.workspace import Workspace, terraform_env +from panos_decrypt_bypass.web.panos import PanosError +from panos_decrypt_bypass.workspace import Workspace, WorkspaceError PLAN_FILE = "tfplan" ADDABLE = [k for k in KINDS if k != "exclusion"] +# Set again in main(), once .env is loaded. WS = Workspace.from_env() BYPASS_DIR = WS.bypass_dir @@ -63,13 +67,14 @@ def drop_root() -> None: def preflight() -> None: - """Checks needed before anything talks to Terraform or SCM.""" + """Checks needed before anything talks to Terraform, SCM or the firewall.""" if not shutil.which("terraform"): sys.exit("terraform not found on PATH.") - missing = [k for k in scm_api.REQUIRED_ENV if not os.environ.get(k)] + required = panos_commit.REQUIRED_ENV if WS.mode.name == "local" else scm_api.REQUIRED_ENV + missing = [k for k in required if not os.environ.get(k, "").strip()] if missing: sys.exit(f"Missing {', '.join(missing)}. Set them in the environment or in .env (see .env.example).") - if not WS.has_location(): + if not WS.configured(): sys.exit(f"Set folder (or snippet) in {WS.tfvars} first.") @@ -80,7 +85,7 @@ def terraform(*args: str, check: bool = True, capture: bool = False) -> subproce return subprocess.run( ["terraform", *args], cwd=WS.tf_dir, - env=terraform_env(), + env=WS.terraform_env(), check=check, text=True, stdout=subprocess.PIPE if capture else None, @@ -88,7 +93,10 @@ def terraform(*args: str, check: bool = True, capture: bool = False) -> subproce def terraform_init() -> None: - WS.prepare_terraform() + try: + WS.prepare_terraform() + except WorkspaceError as e: + sys.exit(str(e)) result = terraform("init", "-input=false", check=False, capture=True) if result.returncode: print(result.stdout) @@ -114,10 +122,14 @@ def push_folders() -> list[str]: # --- Commands ---------------------------------------------------------------- -def do_push(args: argparse.Namespace) -> None: +def do_publish(args: argparse.Namespace, applied: bool = False) -> None: + """Push (SCM) or commit (local firewall) the candidate config.""" try: - scm_api.push_and_wait(push_folders(), args.description, args.all_admins, args.timeout) - except scm_api.ScmError as e: + if WS.mode.name == "local": + panos_commit.commit_and_wait(args.description, args.all_admins, args.timeout, expect_changes=applied) + else: + scm_api.push_and_wait(push_folders(), args.description, args.all_admins, args.timeout) + except (scm_api.ScmError, PanosError) as e: sys.exit(str(e)) @@ -133,25 +145,28 @@ def cmd_deploy(args: argparse.Namespace) -> None: try: changed = terraform_plan() if changed: - if not args.auto_approve and input("\nApply these changes to SCM and push? [y/N] ").strip().lower() != "y": + prompt = f"\nApply these changes to {WS.mode.target} and {WS.mode.publish}? [y/N] " + if not args.auto_approve and input(prompt).strip().lower() != "y": sys.exit("Cancelled.") terraform("apply", "-input=false", PLAN_FILE) finally: (WS.tf_dir / PLAN_FILE).unlink(missing_ok=True) + publish = WS.mode.publish if not changed and not args.force_push: - print("No changes to apply, nothing to push. Use --force-push to push anyway.") + print(f"No changes to apply, nothing to {publish}. Use --force-push to {publish} anyway.") return if args.no_push: - print("Skipping push (--no-push). Run `panos-decrypt-bypass push` when ready.") + print(f"Skipping the {publish} (--no-push). Run `panos-decrypt-bypass {publish}` when ready.") return - do_push(args) + do_publish(args, applied=changed) def cmd_push(args: argparse.Namespace) -> None: preflight() - terraform_init() - do_push(args) + if WS.mode.name == "scm": + terraform_init() # push folders come from terraform output + do_publish(args) def cmd_init(args: argparse.Namespace) -> None: @@ -159,7 +174,7 @@ def cmd_init(args: argparse.Namespace) -> None: for path in created: print(f" created {path}") print(f"Data directory: {WS.data_dir}") - if not WS.has_location(): + if not WS.configured(): print(f"Next: set folder (or snippet) in {WS.tfvars}.") @@ -373,8 +388,8 @@ def interactive() -> None: ("add", "Add a bypass"), ("remove", "Remove a bypass"), ("list", "List current bypasses"), - ("plan", "Preview changes in SCM (terraform plan)"), - ("deploy", "Deploy: apply to SCM and push to firewalls"), + ("plan", f"Preview changes in {WS.mode.target} (terraform plan)"), + ("deploy", f"Deploy: apply to {WS.mode.target} and {WS.mode.publish}"), ("quit", "Quit"), ], ) @@ -424,19 +439,19 @@ def build_parser() -> argparse.ArgumentParser: list_ = sub.add_parser("list", help="show current bypass entries") list_.add_argument("kind", nargs="?", choices=KINDS) - sub.add_parser("plan", help="show what would change in SCM") + sub.add_parser("plan", help="show what would change") push_opts = argparse.ArgumentParser(add_help=False) push_opts.add_argument("--description", default="SSL decryption bypass update via Terraform") - push_opts.add_argument("--timeout", type=int, default=1800, help="seconds to wait for the push (default 1800)") - push_opts.add_argument("--all-admins", action="store_true", help="push every admin's pending changes, not just this service account's") + push_opts.add_argument("--timeout", type=int, default=1800, help="seconds to wait for the push or commit (default 1800)") + push_opts.add_argument("--all-admins", action="store_true", help="push/commit every admin's pending changes, not just this account's") - deploy = sub.add_parser("deploy", parents=[push_opts], help="apply changes to SCM and push to firewalls") + deploy = sub.add_parser("deploy", parents=[push_opts], help="apply the changes, then push (SCM) or commit (local firewall)") deploy.add_argument("--auto-approve", action="store_true", help="skip the confirmation prompt") - deploy.add_argument("--no-push", action="store_true", help="apply to SCM but do not push") - deploy.add_argument("--force-push", action="store_true", help="push even if Terraform had nothing to change") + deploy.add_argument("--no-push", action="store_true", help="apply, but do not push or commit") + deploy.add_argument("--force-push", action="store_true", help="push or commit even if Terraform had nothing to change") - sub.add_parser("push", parents=[push_opts], help="push the candidate config only") + sub.add_parser("push", aliases=["commit"], parents=[push_opts], help="push (SCM) or commit (local firewall) the candidate config only") sub.add_parser("init", help="create the data directory and starter files") serve = sub.add_parser("serve", help="run the web GUI") @@ -447,8 +462,15 @@ def build_parser() -> argparse.ArgumentParser: def main() -> None: + global WS, BYPASS_DIR drop_root() load_env_file(Path.cwd() / ".env") + WS = Workspace.from_env() + BYPASS_DIR = WS.bypass_dir + try: + WS.mode # noqa: B018 - validates MANAGEMENT + except WorkspaceError as e: + sys.exit(str(e)) args = build_parser().parse_args() commands = { "add": cmd_add, @@ -457,6 +479,7 @@ def main() -> None: "plan": cmd_plan, "deploy": cmd_deploy, "push": cmd_push, + "commit": cmd_push, "init": cmd_init, "serve": cmd_serve, "version": cmd_version, diff --git a/src/panos_decrypt_bypass/panos_commit.py b/src/panos_decrypt_bypass/panos_commit.py new file mode 100644 index 0000000..3dc9283 --- /dev/null +++ b/src/panos_decrypt_bypass/panos_commit.py @@ -0,0 +1,75 @@ +"""Commit on a locally managed firewall (MANAGEMENT=local), shared by the CLI +and the web GUI. The SCM equivalent is scm_api.push_and_wait.""" + +import os +import time +from collections.abc import Callable + +from panos_decrypt_bypass.web.panos import Firewall, PanosError + +REQUIRED_ENV = ("FIREWALL_HOST", "FIREWALL_API_KEY", "FIREWALL_USERNAME") + + +def firewall_from_env() -> Firewall: + missing = [k for k in REQUIRED_ENV if not os.environ.get(k, "").strip()] + if missing: + raise PanosError(f"Missing {', '.join(missing)} in the environment / .env.") + verify = os.environ.get("FIREWALL_VERIFY_TLS", "").strip().lower() not in ("0", "false", "no", "off") + return Firewall( + os.environ["FIREWALL_HOST"].strip(), + os.environ["FIREWALL_API_KEY"].strip(), + verify_tls=verify, + ca_file=os.environ.get("FIREWALL_CA_FILE", "").strip() or None, + ) + + +def job_lines(job) -> list[str]: + """Detail and warning lines from a finished job.""" + sections = [job.find(tag) for tag in ("details", "warnings")] + return [line.text.strip() for s in sections if s is not None for line in s.iter("line") if line.text and line.text.strip()] + + +def commit_and_wait( + description: str, + all_admins: bool = False, + timeout: int = 900, + interval: int = 5, + log: Callable[[str], None] = print, + firewall: Firewall | None = None, + expect_changes: bool = False, +) -> None: + """Commit the candidate config and wait for it. Raises PanosError on failure. + + expect_changes: Terraform just changed the candidate config, so "nothing + to commit" means the changes belong to someone else (a wrong + FIREWALL_USERNAME), not that they're already live.""" + fw = firewall or firewall_from_env() + # Commit only the API admin's changes so another admin's half-finished + # edits never go live along with a bypass. + admin = os.environ.get("FIREWALL_USERNAME", "").strip() + admins = None if all_admins else [admin] + log(f"Committing{'' if all_admins else ' changes by ' + admin + ' only'}") + job_id = fw.commit(description, admins) + if job_id is None: + if expect_changes and not all_admins: + raise PanosError( + f"The firewall found no changes by {admin} to commit, though Terraform just made some. " + "Check that FIREWALL_USERNAME is the admin that owns FIREWALL_API_KEY." + ) + log("Nothing to commit: the firewall's running config already has these changes.") + return + + deadline = time.monotonic() + timeout + while (job := fw.job(job_id)).findtext("status") != "FIN": + log(f" commit job {job_id}: {job.findtext('status')} {job.findtext('progress') or ''}%") + if time.monotonic() > deadline: + raise PanosError(f"Timed out waiting for commit job {job_id}. Check Tasks on the firewall.") + time.sleep(interval) + + result = job.findtext("result") + for line in job_lines(job): + log(f" {line}") + log(f"Commit job {job_id}: {result}") + if result != "OK": + raise PanosError(f"Commit failed ({result}). See the job details above or Tasks on the firewall.") + log("Commit complete.") diff --git a/src/panos_decrypt_bypass/templates/local.tfvars b/src/panos_decrypt_bypass/templates/local.tfvars new file mode 100644 index 0000000..332f628 --- /dev/null +++ b/src/panos_decrypt_bypass/templates/local.tfvars @@ -0,0 +1,45 @@ +# panos-decrypt-bypass settings for a locally managed firewall (MANAGEMENT=local). +# Lives in your data directory (DATA_DIR, /data in the container); edit it, +# then use Deploy… in the web GUI or `panos-decrypt-bypass deploy`. Every +# setting is described in docs/configuration.md. + +# --- Where the config lives on the firewall ------------------------------------ +# Virtual system for the bypass objects and rules. Single-vsys firewalls use +# vsys1 (the default). +# vsys = "vsys1" + +# --- What the no-decrypt rules match ------------------------------------------- +# Zones, source addresses and services for every no-decrypt rule. "any" is the +# safe default for no-decrypt; narrow them to limit who gets bypassed. +# from_zones = ["trust"] +# to_zones = ["untrust"] +# source_addresses = ["my-laptop"] # existing address objects or groups +# services = ["any"] + +# The rules go to the top of the decryption rulebase by default, as one block. +# To slot them under an existing rule instead: +# rule_placement = { relative_position = "after", target_rule = "My-Existing-Rule" } + +# --- Decryption profile ------------------------------------------------------ +# Attach an existing decryption profile to every no-decrypt rule (its +# "No Decryption" settings apply to bypassed traffic)... +# no_decrypt_profile = "My-Decryption-Profile" +# ...or have Terraform create one that blocks expired and untrusted certs: +# create_no_decrypt_profile = true + +# --- Logging ------------------------------------------------------------------- +# log_setting = "my-log-forwarding-profile" + +# --- External dynamic lists ------------------------------------------------------ +# Reference existing EDLs... +# existing_url_edls = ["corp-no-decrypt-urls"] +# existing_ip_edls = ["corp-no-decrypt-ips"] +# ...or create them: +# edls = { +# "ssl-bypass-vendor-urls" = { +# type = "url" +# source_url = "https://example.com/no-decrypt-urls.txt" +# recurring = "hourly" +# description = "Vendor-maintained bypass list" +# } +# } diff --git a/src/panos_decrypt_bypass/templates/terraform.tfvars b/src/panos_decrypt_bypass/templates/scm.tfvars similarity index 86% rename from src/panos_decrypt_bypass/templates/terraform.tfvars rename to src/panos_decrypt_bypass/templates/scm.tfvars index 4857cce..75e883f 100644 --- a/src/panos_decrypt_bypass/templates/terraform.tfvars +++ b/src/panos_decrypt_bypass/templates/scm.tfvars @@ -1,7 +1,7 @@ -# panos-decrypt-bypass settings. Lives in your data directory (DATA_DIR, -# /data in the container); edit it, then use Deploy… in the web GUI or -# `panos-decrypt-bypass deploy`. Every setting is described in variables.tf -# and in docs/configuration.md. +# panos-decrypt-bypass settings for Strata Cloud Manager (MANAGEMENT=scm, the default). +# Lives in your data directory (DATA_DIR, /data in the container); edit it, +# then use Deploy… in the web GUI or `panos-decrypt-bypass deploy`. Every +# setting is described in docs/configuration.md. # --- Where the config lives in Strata Cloud Manager (required) --------------- # Set exactly one of folder or snippet. Use the folder name as SCM shows it; diff --git a/src/panos_decrypt_bypass/terraform/local/.terraform.lock.hcl b/src/panos_decrypt_bypass/terraform/local/.terraform.lock.hcl new file mode 100644 index 0000000..f23184f --- /dev/null +++ b/src/panos_decrypt_bypass/terraform/local/.terraform.lock.hcl @@ -0,0 +1,27 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/paloaltonetworks/panos" { + version = "2.0.14" + constraints = "~> 2.0.14" + hashes = [ + "h1:PLnLNtmMowlOOeOXVs3GV5c7TYZ9PegKmSaxmFwZsMo=", + "h1:fTv/byo98OsH/gn4IGSFFWghBXRgtWzWx+i1loZQnTo=", + "h1:qSJTT332aCQ2jhjk038gDW63BxggniQqfZ27vIZsGYc=", + "h1:zlZUFqJyANJjKGfjo5QhMvKaNpt+8CaHVD9FwAoiZjc=", + "zh:0dde99e7b343fa01f8eefc378171fb8621bedb20f59157d6cc8e3d46c738105f", + "zh:15412a5c5335693aa84cf96c33c4a14a64019fa9fbfa5146b9aeb021a8c7ca94", + "zh:28831dbf6a36f813edb616f068daf88fd9e2bd1a271e20bab31bf2b89f081a6c", + "zh:39a280dc11291abf8c11ed90b895ea1834236f92e83136a08e419c30a9c30416", + "zh:4c0e26efda51607f0f616e9aa4417879c10e5c719678b8a6583ded28e563c4d7", + "zh:5430b6a065c9301582060acf9e50b5cbe3dcc3ab3f71716e248d7ad8d85a98d6", + "zh:57d3a1941d8c03f03ce1bb08a2884a6dd14136797dbf06b2637cb3c656b1dba1", + "zh:6cadc6bd7d3ada79d2c3e2af290147668941801c4a1e8c228dcac6a168814719", + "zh:78e76c9633508aa20679381f3d13bbab89a770b8afe49370e1adc4e9a211b41d", + "zh:86856d2216f5da50290e9876fc090e45e75e27bae3860afcf7f3198bc548c90e", + "zh:980044565ff083c39c13e4762e9367ceea41142c2d679caef56876a998a4382f", + "zh:9bac7fe76778b263b6bde5c4b4fbe34cf5d7eb6dbadfa6892335889004ca3925", + "zh:be5a6dd5675b35a727d169d4a47a97943c73d1bb9f1ffaa254ae2fcf12779682", + "zh:d80223a774c3845ed8cdac59a3ccf5830c3d66ed8756dbf4c49c6e5e04205b06", + ] +} diff --git a/src/panos_decrypt_bypass/terraform/local/lists.tf b/src/panos_decrypt_bypass/terraform/local/lists.tf new file mode 100644 index 0000000..719a3b2 --- /dev/null +++ b/src/panos_decrypt_bypass/terraform/local/lists.tf @@ -0,0 +1,74 @@ +# Shared by the scm and local modules: keep both copies identical +# (tests/test_terraform_modules.py checks). + +############################################################################### +# Parse the bypass list files. +# +# Each file holds one entry per line. Anything after "#" is a comment; a +# comment on the same line as an entry becomes that entry's description where +# the object supports one. Blank lines are ignored, duplicates are merged. +############################################################################### + +locals { + bypass_files = { + exclusions = "exclusion-hostnames.txt" + urls = "no-decrypt-urls.txt" + categories = "no-decrypt-categories.txt" + destinations = "no-decrypt-destinations.txt" + sources = "no-decrypt-sources.txt" + users = "no-decrypt-users.txt" + } + + bypass_lines = { + for kind, file in local.bypass_files : kind => [ + for line in split("\n", fileexists("${var.bypass_dir}/${file}") ? file("${var.bypass_dir}/${file}") : "") : { + entry = trimspace(split("#", line)[0]) + description = trimspace(join("#", slice(split("#", line), 1, length(split("#", line))))) + } if trimspace(split("#", line)[0]) != "" + ] + } + + # Hostnames are case-insensitive; user/group names are left as written. + # Scheme and trailing slash are stripped so pasted URLs still work. + bypass_grouped = { + for kind, lines in local.bypass_lines : kind => { + for l in lines : + (kind == "users" ? l.entry : trimsuffix(replace(lower(l.entry), "/^https?:\\/\\//", ""), "/")) => l.description... + } + } + + bypass = { + for kind, entries in local.bypass_grouped : kind => { + for entry, descriptions in entries : entry => try(compact(descriptions)[0], null) + } + } + + ipv4_re = "[0-9]{1,3}(\\.[0-9]{1,3}){3}" + ipv6_re = "[0-9a-f]*:[0-9a-f:.]*" + cidr_re = "^(${local.ipv4_re}|${local.ipv6_re})(/[0-9]{1,3})?$" + range_re = "^(${local.ipv4_re}|${local.ipv6_re})-(${local.ipv4_re}|${local.ipv6_re})$" + + # Destination and source entries become address objects: IP/CIDR, IP range, or FQDN. + addresses = { + for side in ["destinations", "sources"] : side => { + for entry, description in local.bypass[side] : entry => { + name = substr("${var.name_prefix}-${side == "destinations" ? "dst" : "src"}-${replace(replace(entry, "/", "_"), ":", ".")}", 0, 63) + description = description + ip_netmask = can(regex(local.cidr_re, entry)) ? entry : null + ip_range = can(regex(local.range_re, entry)) ? entry : null + fqdn = !can(regex(local.cidr_re, entry)) && !can(regex(local.range_re, entry)) ? entry : null + } + } + } +} + +############################################################################### +# Sanity checks. These warn at plan time rather than fail. +############################################################################### + +check "url_entries_are_hostnames" { + assert { + condition = alltrue([for u in keys(local.bypass.urls) : !strcontains(u, "/")]) + error_message = "no-decrypt-urls.txt has entries with a path. Decryption rules only see the hostname (SNI), so anything after the first \"/\" never matches: ${join(", ", [for u in keys(local.bypass.urls) : u if strcontains(u, "/")])}" + } +} diff --git a/src/panos_decrypt_bypass/terraform/local/main.tf b/src/panos_decrypt_bypass/terraform/local/main.tf new file mode 100644 index 0000000..40ceaa2 --- /dev/null +++ b/src/panos_decrypt_bypass/terraform/local/main.tf @@ -0,0 +1,98 @@ +locals { + location = { + vsys = { name = var.vsys } + } +} + +# Decryption exclusions live in the firewall's device-wide SSL decryption +# settings, next to the forward trust certificates. Managing that block from +# here could overwrite them, so this module leaves exclusions alone. +check "exclusions_not_managed" { + assert { + condition = length(local.bypass.exclusions) == 0 + error_message = "exclusion-hostnames.txt is ignored on locally managed firewalls. Move these entries to no-decrypt-urls.txt: ${join(", ", keys(local.bypass.exclusions))}" + } +} + +############################################################################### +# Supporting objects for the no-decrypt policy rules +############################################################################### + +resource "panos_administrative_tag" "bypass" { + location = local.location + name = var.name_prefix + color = "color6" # orange + comments = "Managed by Terraform (panos-decrypt-bypass)" +} + +resource "panos_custom_url_category" "no_decrypt" { + count = length(local.bypass.urls) > 0 ? 1 : 0 + + location = local.location + name = "${var.name_prefix}-urls" + description = "Hostnames excluded from SSL decryption. Managed by Terraform from bypass/no-decrypt-urls.txt." + type = "URL List" + list = sort(keys(local.bypass.urls)) +} + +resource "panos_address" "destination" { + for_each = local.addresses.destinations + + location = local.location + name = each.value.name + description = each.value.description + ip_netmask = each.value.ip_netmask + ip_range = each.value.ip_range + fqdn = each.value.fqdn + tags = [panos_administrative_tag.bypass.name] +} + +resource "panos_address" "source" { + for_each = local.addresses.sources + + location = local.location + name = each.value.name + description = each.value.description + ip_netmask = each.value.ip_netmask + ip_range = each.value.ip_range + fqdn = each.value.fqdn + tags = [panos_administrative_tag.bypass.name] +} + +resource "panos_external_dynamic_list" "this" { + for_each = var.edls + + location = local.location + name = each.key + + type = { + url = each.value.type != "url" ? null : { + url = each.value.source_url + description = each.value.description + recurring = { + five_minute = each.value.recurring == "five_minute" ? {} : null + hourly = each.value.recurring == "hourly" ? {} : null + } + } + ip = each.value.type != "ip" ? null : { + url = each.value.source_url + description = each.value.description + recurring = { + five_minute = each.value.recurring == "five_minute" ? {} : null + hourly = each.value.recurring == "hourly" ? {} : null + } + } + } +} + +resource "panos_decryption_profile" "no_decrypt" { + count = var.create_no_decrypt_profile ? 1 : 0 + + location = local.location + name = "${var.name_prefix}-no-decrypt" + + ssl_no_proxy = { + block_expired_certificate = true + block_untrusted_issuer = true + } +} diff --git a/src/panos_decrypt_bypass/terraform/local/outputs.tf b/src/panos_decrypt_bypass/terraform/local/outputs.tf new file mode 100644 index 0000000..1160a58 --- /dev/null +++ b/src/panos_decrypt_bypass/terraform/local/outputs.tf @@ -0,0 +1,16 @@ +output "vsys" { + description = "Virtual system the bypass config lives in." + value = var.vsys +} + +output "summary" { + description = "What is currently bypassed." + value = { + no_decrypt_rules = [for k in local.enabled_rules : "${var.name_prefix}-${k}"] + no_decrypt_urls = sort(keys(local.bypass.urls)) + no_decrypt_categories = sort(keys(local.bypass.categories)) + no_decrypt_dsts = sort(keys(local.bypass.destinations)) + no_decrypt_srcs = sort(keys(local.bypass.sources)) + no_decrypt_users = sort(keys(local.bypass.users)) + } +} diff --git a/src/panos_decrypt_bypass/terraform/local/rules.tf b/src/panos_decrypt_bypass/terraform/local/rules.tf new file mode 100644 index 0000000..ed2136c --- /dev/null +++ b/src/panos_decrypt_bypass/terraform/local/rules.tf @@ -0,0 +1,87 @@ +############################################################################### +# No-decrypt decryption policy rules +# +# One rule per bypass type so the rule name in the decryption/traffic log tells +# you why a session was not decrypted. A rule is only created when its list has +# entries. Order follows Palo's recommendation (IP exceptions first, broad +# categories last). All of them are managed as one block, kept together at +# rule_placement. +############################################################################### + +locals { + url_edls = concat(var.existing_url_edls, [for k, e in panos_external_dynamic_list.this : e.name if var.edls[k].type == "url"]) + ip_edls = concat(var.existing_ip_edls, [for k, e in panos_external_dynamic_list.this : e.name if var.edls[k].type == "ip"]) + + rule_match = { + destinations = { + description = "Destination IPs/FQDNs from bypass/no-decrypt-destinations.txt and IP EDLs" + enabled = length(local.bypass.destinations) + length(var.existing_ip_edls) + length([for e in values(var.edls) : e if e.type == "ip"]) > 0 + destination = concat([for a in panos_address.destination : a.name], local.ip_edls) + } + sources = { + description = "Source IPs/FQDNs from bypass/no-decrypt-sources.txt" + enabled = length(local.bypass.sources) > 0 + source = [for a in panos_address.source : a.name] + } + users = { + description = "Users/groups from bypass/no-decrypt-users.txt" + enabled = length(local.bypass.users) > 0 + source_user = sort(keys(local.bypass.users)) + } + urls = { + description = "Hostnames from bypass/no-decrypt-urls.txt and URL EDLs" + enabled = length(local.bypass.urls) + length(var.existing_url_edls) + length([for e in values(var.edls) : e if e.type == "url"]) > 0 + category = concat(panos_custom_url_category.no_decrypt[*].name, local.url_edls) + } + categories = { + description = "Predefined URL categories from bypass/no-decrypt-categories.txt" + enabled = length(local.bypass.categories) > 0 + category = sort(keys(local.bypass.categories)) + } + } + + rule_order = ["destinations", "sources", "users", "urls", "categories"] + enabled_rules = [for k in local.rule_order : k if local.rule_match[k].enabled] + + profile = var.create_no_decrypt_profile ? panos_decryption_profile.no_decrypt[0].name : var.no_decrypt_profile + + rules = { + for k, m in local.rule_match : k => { + name = "${var.name_prefix}-${k}" + description = "Managed by Terraform. ${m.description}." + action = "no-decrypt" + type = { ssl_forward_proxy = {} } + source_zones = var.from_zones + destination_zones = var.to_zones + source_addresses = try(m.source, var.source_addresses) + destination_addresses = try(m.destination, ["any"]) + source_user = try(m.source_user, ["any"]) + category = try(m.category, ["any"]) + services = var.services + profile = local.profile + log_setting = var.log_setting + log_success = var.log_success + log_fail = var.log_fail + tag = [panos_administrative_tag.bypass.name] + } + } + + position = { + where = { + top = "first" + bottom = "last" + before = "before" + after = "after" + }[var.rule_placement.relative_position] + pivot = var.rule_placement.target_rule + directly = var.rule_placement.target_rule == null ? null : true + } +} + +resource "panos_decryption_policy_rules" "no_decrypt" { + count = length(local.enabled_rules) > 0 ? 1 : 0 + + location = local.location + position = local.position + rules = [for k in local.enabled_rules : local.rules[k]] +} diff --git a/src/panos_decrypt_bypass/terraform/local/tests/plan.tftest.hcl b/src/panos_decrypt_bypass/terraform/local/tests/plan.tftest.hcl new file mode 100644 index 0000000..a1ffbef --- /dev/null +++ b/src/panos_decrypt_bypass/terraform/local/tests/plan.tftest.hcl @@ -0,0 +1,132 @@ +# Offline plan against a mocked PAN-OS provider. From the repo root: +# terraform -chdir=src/panos_decrypt_bypass/terraform/local init -backend=false +# terraform -chdir=src/panos_decrypt_bypass/terraform/local test +# Uses the lists in tests/fixtures instead of a data directory. + +mock_provider "panos" {} + +variables { + bypass_dir = "../../../../tests/fixtures/bypass" +} + +run "builds_objects_in_vsys" { + command = plan + + # The fixture lists include exclusions, which this module ignores (warning). + expect_failures = [check.exclusions_not_managed] + + assert { + condition = panos_custom_url_category.no_decrypt[0].list == tolist(["*.mybank.com", "benefits.example-hr.com", "mybank.com"]) + error_message = "URL list should be normalised (scheme and trailing slash stripped, lowercase, sorted)." + } + assert { + condition = panos_custom_url_category.no_decrypt[0].location.vsys.name == "vsys1" + error_message = "Objects should default to vsys1." + } + assert { + condition = panos_address.destination["203.0.113.10-203.0.113.20"].ip_range == "203.0.113.10-203.0.113.20" + error_message = "IP ranges should become ip_range address objects." + } + assert { + condition = panos_address.destination["cafe.be"].fqdn == "cafe.be" + error_message = "Hostnames should become fqdn address objects." + } + assert { + condition = panos_address.source["10.20.30.0/24"].tags == tolist(["tf-ssl-bypass"]) + error_message = "Address objects should carry the bypass tag." + } +} + +run "rules_in_order_at_top" { + command = plan + expect_failures = [check.exclusions_not_managed] + + assert { + condition = [for r in panos_decryption_policy_rules.no_decrypt[0].rules : r.name] == ["tf-ssl-bypass-destinations", "tf-ssl-bypass-sources", "tf-ssl-bypass-users", "tf-ssl-bypass-urls", "tf-ssl-bypass-categories"] + error_message = "Every non-empty list should produce a rule, in order." + } + assert { + condition = panos_decryption_policy_rules.no_decrypt[0].position.where == "first" && panos_decryption_policy_rules.no_decrypt[0].position.pivot == null + error_message = "Rules should go to the top by default." + } + assert { + condition = alltrue([for r in panos_decryption_policy_rules.no_decrypt[0].rules : r.action == "no-decrypt"]) + error_message = "Every rule should be no-decrypt." + } + assert { + condition = jsonencode([local.rules.users.source_user, local.rules.users.category]) == jsonencode([["EXAMPLE\\CEO"], ["any"]]) + error_message = "User rule should keep case and match any category." + } + assert { + condition = local.rules.urls.category == ["tf-ssl-bypass-urls"] + error_message = "URL rule should use the custom URL category." + } +} + +run "skips_empty_rules" { + command = plan + + variables { + bypass_dir = "../../../../tests/fixtures/empty" + } + + assert { + condition = length(panos_decryption_policy_rules.no_decrypt) + length(panos_custom_url_category.no_decrypt) + length(panos_address.destination) == 0 + error_message = "Empty lists should create nothing but the tag." + } +} + +run "placement_edls_and_profile" { + command = plan + + variables { + bypass_dir = "../../../../tests/fixtures/empty" + vsys = "vsys2" + create_no_decrypt_profile = true + rule_placement = { relative_position = "after", target_rule = "Allow-Infra" } + edls = { + tf-edl-urls = { type = "url", source_url = "https://example.com/urls.txt" } + tf-edl-ips = { type = "ip", source_url = "https://example.com/ips.txt", recurring = "five_minute" } + } + } + + assert { + condition = [for r in panos_decryption_policy_rules.no_decrypt[0].rules : r.name] == ["tf-ssl-bypass-destinations", "tf-ssl-bypass-urls"] + error_message = "EDLs alone should enable the destination and URL rules." + } + assert { + condition = jsonencode([local.rules.urls.category, local.rules.destinations.destination_addresses]) == jsonencode([["tf-edl-urls"], ["tf-edl-ips"]]) + error_message = "URL EDLs go in category, IP EDLs in destination." + } + assert { + condition = jsonencode(panos_decryption_policy_rules.no_decrypt[0].position) == jsonencode({ directly = true, pivot = "Allow-Infra", where = "after" }) + error_message = "rule_placement should map to the provider's position." + } + assert { + condition = local.rules.urls.profile == "tf-ssl-bypass-no-decrypt" && panos_decryption_profile.no_decrypt[0].location.vsys.name == "vsys2" + error_message = "Created no-decrypt profile should be attached, in the chosen vsys." + } +} + +run "zones_and_source_object_apply_to_rules" { + command = plan + expect_failures = [check.exclusions_not_managed] + + variables { + from_zones = ["trust"] + to_zones = ["untrust"] + source_addresses = ["laptop-jdoe"] + } + + assert { + condition = alltrue([ + for k in ["destinations", "users", "urls", "categories"] : + jsonencode([local.rules[k].source_zones, local.rules[k].destination_zones, local.rules[k].source_addresses]) == jsonencode([["trust"], ["untrust"], ["laptop-jdoe"]]) + ]) + error_message = "Rules should use the configured zones and source object." + } + assert { + condition = jsonencode(local.rules.sources.source_addresses) == jsonencode(["tf-ssl-bypass-src-10.20.30.0_24"]) + error_message = "The sources rule should keep the addresses from its own list." + } +} diff --git a/src/panos_decrypt_bypass/terraform/local/variables.tf b/src/panos_decrypt_bypass/terraform/local/variables.tf new file mode 100644 index 0000000..60467ff --- /dev/null +++ b/src/panos_decrypt_bypass/terraform/local/variables.tf @@ -0,0 +1,125 @@ +variable "vsys" { + description = "Virtual system to create the bypass config in. Single-vsys firewalls use vsys1." + type = string + default = "vsys1" +} + +variable "name_prefix" { + description = "Prefix for every object this config creates (rules, URL category, addresses, tag, profile)." + type = string + default = "tf-ssl-bypass" +} + +variable "bypass_dir" { + description = "Directory holding the bypass list text files." + type = string + default = "bypass" +} + +# --- No-decrypt rule settings ------------------------------------------------- + +variable "from_zones" { + description = "Source zones for the no-decrypt rules." + type = list(string) + default = ["any"] +} + +variable "to_zones" { + description = "Destination zones for the no-decrypt rules." + type = list(string) + default = ["any"] +} + +variable "source_addresses" { + description = "Source addresses for the no-decrypt rules: names of existing address objects/groups, or \"any\". Not used by the sources rule, which matches bypass/no-decrypt-sources.txt instead." + type = list(string) + default = ["any"] +} + +variable "services" { + description = "Services for the no-decrypt rules. \"any\" also catches TLS on non-443 ports." + type = list(string) + default = ["any"] +} + +variable "rule_placement" { + description = "Where the no-decrypt rules go in the decryption rulebase, as one block in order. relative_position is top, bottom, before or after; target_rule is required for before/after." + type = object({ + relative_position = string + target_rule = optional(string) + }) + default = { relative_position = "top" } + + validation { + condition = contains(["top", "bottom", "before", "after"], var.rule_placement.relative_position) + error_message = "relative_position must be top, bottom, before or after." + } + validation { + condition = !contains(["before", "after"], var.rule_placement.relative_position) || var.rule_placement.target_rule != null + error_message = "target_rule is required when relative_position is before or after." + } +} + +variable "log_success" { + description = "Log sessions that match the no-decrypt rules. Undecrypted traffic is only logged when a decryption rule matches it, so leave this on for visibility." + type = bool + default = true +} + +variable "log_fail" { + description = "Log decryption failures on the no-decrypt rules." + type = bool + default = true +} + +variable "log_setting" { + description = "Optional log forwarding profile for the no-decrypt rules." + type = string + default = null +} + +variable "no_decrypt_profile" { + description = "Name of an existing decryption profile to attach to the no-decrypt rules. Ignored when create_no_decrypt_profile is true." + type = string + default = null +} + +variable "create_no_decrypt_profile" { + description = "Create and attach a no-decryption profile that blocks expired certs and untrusted issuers on bypassed traffic. Off by default: it can block the very sites you bypass (e.g. ones signed by a private CA)." + type = bool + default = false +} + +# --- External dynamic lists --------------------------------------------------- + +variable "existing_url_edls" { + description = "Names of existing URL-type EDLs to add to the URL no-decrypt rule." + type = list(string) + default = [] +} + +variable "existing_ip_edls" { + description = "Names of existing IP-type EDLs to add to the destination no-decrypt rule." + type = list(string) + default = [] +} + +variable "edls" { + description = "EDLs to create. url-type EDLs join the URL rule; ip-type EDLs join the destination rule." + type = map(object({ + type = string + source_url = string + recurring = optional(string, "hourly") + description = optional(string) + })) + default = {} + + validation { + condition = alltrue([for e in values(var.edls) : contains(["url", "ip"], e.type)]) + error_message = "EDL type must be \"url\" or \"ip\"." + } + validation { + condition = alltrue([for e in values(var.edls) : contains(["five_minute", "hourly"], e.recurring)]) + error_message = "EDL recurring must be \"five_minute\" or \"hourly\"." + } +} diff --git a/src/panos_decrypt_bypass/terraform/local/versions.tf b/src/panos_decrypt_bypass/terraform/local/versions.tf new file mode 100644 index 0000000..9013f3e --- /dev/null +++ b/src/panos_decrypt_bypass/terraform/local/versions.tf @@ -0,0 +1,16 @@ +terraform { + # 1.9+ for cross-variable validation. + required_version = ">= 1.9" + + required_providers { + panos = { + source = "PaloAltoNetworks/panos" + version = "~> 2.0.14" + } + } +} + +# Connection settings come from the environment (PANOS_HOSTNAME, PANOS_API_KEY, +# PANOS_PORT, PANOS_SKIP_VERIFY_CERTIFICATE), which the app fills in from +# FIREWALL_HOST, FIREWALL_API_KEY and FIREWALL_VERIFY_TLS. +provider "panos" {} diff --git a/src/panos_decrypt_bypass/terraform/.terraform.lock.hcl b/src/panos_decrypt_bypass/terraform/scm/.terraform.lock.hcl similarity index 100% rename from src/panos_decrypt_bypass/terraform/.terraform.lock.hcl rename to src/panos_decrypt_bypass/terraform/scm/.terraform.lock.hcl diff --git a/src/panos_decrypt_bypass/terraform/scm/lists.tf b/src/panos_decrypt_bypass/terraform/scm/lists.tf new file mode 100644 index 0000000..719a3b2 --- /dev/null +++ b/src/panos_decrypt_bypass/terraform/scm/lists.tf @@ -0,0 +1,74 @@ +# Shared by the scm and local modules: keep both copies identical +# (tests/test_terraform_modules.py checks). + +############################################################################### +# Parse the bypass list files. +# +# Each file holds one entry per line. Anything after "#" is a comment; a +# comment on the same line as an entry becomes that entry's description where +# the object supports one. Blank lines are ignored, duplicates are merged. +############################################################################### + +locals { + bypass_files = { + exclusions = "exclusion-hostnames.txt" + urls = "no-decrypt-urls.txt" + categories = "no-decrypt-categories.txt" + destinations = "no-decrypt-destinations.txt" + sources = "no-decrypt-sources.txt" + users = "no-decrypt-users.txt" + } + + bypass_lines = { + for kind, file in local.bypass_files : kind => [ + for line in split("\n", fileexists("${var.bypass_dir}/${file}") ? file("${var.bypass_dir}/${file}") : "") : { + entry = trimspace(split("#", line)[0]) + description = trimspace(join("#", slice(split("#", line), 1, length(split("#", line))))) + } if trimspace(split("#", line)[0]) != "" + ] + } + + # Hostnames are case-insensitive; user/group names are left as written. + # Scheme and trailing slash are stripped so pasted URLs still work. + bypass_grouped = { + for kind, lines in local.bypass_lines : kind => { + for l in lines : + (kind == "users" ? l.entry : trimsuffix(replace(lower(l.entry), "/^https?:\\/\\//", ""), "/")) => l.description... + } + } + + bypass = { + for kind, entries in local.bypass_grouped : kind => { + for entry, descriptions in entries : entry => try(compact(descriptions)[0], null) + } + } + + ipv4_re = "[0-9]{1,3}(\\.[0-9]{1,3}){3}" + ipv6_re = "[0-9a-f]*:[0-9a-f:.]*" + cidr_re = "^(${local.ipv4_re}|${local.ipv6_re})(/[0-9]{1,3})?$" + range_re = "^(${local.ipv4_re}|${local.ipv6_re})-(${local.ipv4_re}|${local.ipv6_re})$" + + # Destination and source entries become address objects: IP/CIDR, IP range, or FQDN. + addresses = { + for side in ["destinations", "sources"] : side => { + for entry, description in local.bypass[side] : entry => { + name = substr("${var.name_prefix}-${side == "destinations" ? "dst" : "src"}-${replace(replace(entry, "/", "_"), ":", ".")}", 0, 63) + description = description + ip_netmask = can(regex(local.cidr_re, entry)) ? entry : null + ip_range = can(regex(local.range_re, entry)) ? entry : null + fqdn = !can(regex(local.cidr_re, entry)) && !can(regex(local.range_re, entry)) ? entry : null + } + } + } +} + +############################################################################### +# Sanity checks. These warn at plan time rather than fail. +############################################################################### + +check "url_entries_are_hostnames" { + assert { + condition = alltrue([for u in keys(local.bypass.urls) : !strcontains(u, "/")]) + error_message = "no-decrypt-urls.txt has entries with a path. Decryption rules only see the hostname (SNI), so anything after the first \"/\" never matches: ${join(", ", [for u in keys(local.bypass.urls) : u if strcontains(u, "/")])}" + } +} diff --git a/src/panos_decrypt_bypass/terraform/main.tf b/src/panos_decrypt_bypass/terraform/scm/main.tf similarity index 54% rename from src/panos_decrypt_bypass/terraform/main.tf rename to src/panos_decrypt_bypass/terraform/scm/main.tf index 066ba64..2d01801 100644 --- a/src/panos_decrypt_bypass/terraform/main.tf +++ b/src/panos_decrypt_bypass/terraform/scm/main.tf @@ -1,80 +1,10 @@ -############################################################################### -# Parse the bypass list files. -# -# Each file holds one entry per line. Anything after "#" is a comment; a -# comment on the same line as an entry becomes that entry's description where -# the object supports one. Blank lines are ignored, duplicates are merged. -############################################################################### - locals { - bypass_files = { - exclusions = "exclusion-hostnames.txt" - urls = "no-decrypt-urls.txt" - categories = "no-decrypt-categories.txt" - destinations = "no-decrypt-destinations.txt" - sources = "no-decrypt-sources.txt" - users = "no-decrypt-users.txt" - } - - bypass_lines = { - for kind, file in local.bypass_files : kind => [ - for line in split("\n", fileexists("${var.bypass_dir}/${file}") ? file("${var.bypass_dir}/${file}") : "") : { - entry = trimspace(split("#", line)[0]) - description = trimspace(join("#", slice(split("#", line), 1, length(split("#", line))))) - } if trimspace(split("#", line)[0]) != "" - ] - } - - # Hostnames are case-insensitive; user/group names are left as written. - # Scheme and trailing slash are stripped so pasted URLs still work. - bypass_grouped = { - for kind, lines in local.bypass_lines : kind => { - for l in lines : - (kind == "users" ? l.entry : trimsuffix(replace(lower(l.entry), "/^https?:\\/\\//", ""), "/")) => l.description... - } - } - - bypass = { - for kind, entries in local.bypass_grouped : kind => { - for entry, descriptions in entries : entry => try(compact(descriptions)[0], null) - } - } - - ipv4_re = "[0-9]{1,3}(\\.[0-9]{1,3}){3}" - ipv6_re = "[0-9a-f]*:[0-9a-f:.]*" - cidr_re = "^(${local.ipv4_re}|${local.ipv6_re})(/[0-9]{1,3})?$" - range_re = "^(${local.ipv4_re}|${local.ipv6_re})-(${local.ipv4_re}|${local.ipv6_re})$" - - # Destination and source entries become address objects: IP/CIDR, IP range, or FQDN. - addresses = { - for side in ["destinations", "sources"] : side => { - for entry, description in local.bypass[side] : entry => { - name = substr("${var.name_prefix}-${side == "destinations" ? "dst" : "src"}-${replace(replace(entry, "/", "_"), ":", ".")}", 0, 63) - description = description - ip_netmask = can(regex(local.cidr_re, entry)) ? entry : null - ip_range = can(regex(local.range_re, entry)) ? entry : null - fqdn = !can(regex(local.cidr_re, entry)) && !can(regex(local.range_re, entry)) ? entry : null - } - } - } - location = { folder = var.folder snippet = var.snippet } } -############################################################################### -# Sanity checks. These warn at plan time rather than fail. -############################################################################### - -check "url_entries_are_hostnames" { - assert { - condition = alltrue([for u in keys(local.bypass.urls) : !strcontains(u, "/")]) - error_message = "no-decrypt-urls.txt has entries with a path. Decryption rules only see the hostname (SNI), so anything after the first \"/\" never matches: ${join(", ", [for u in keys(local.bypass.urls) : u if strcontains(u, "/")])}" - } -} - check "exclusion_entries_are_hostnames" { assert { condition = alltrue([for h in keys(local.bypass.exclusions) : can(regex("^(\\*\\.)*[a-z0-9_-]+(\\.[a-z0-9_*-]+)*$", h))]) diff --git a/src/panos_decrypt_bypass/terraform/modules/no-decrypt-rule/main.tf b/src/panos_decrypt_bypass/terraform/scm/modules/no-decrypt-rule/main.tf similarity index 100% rename from src/panos_decrypt_bypass/terraform/modules/no-decrypt-rule/main.tf rename to src/panos_decrypt_bypass/terraform/scm/modules/no-decrypt-rule/main.tf diff --git a/src/panos_decrypt_bypass/terraform/outputs.tf b/src/panos_decrypt_bypass/terraform/scm/outputs.tf similarity index 100% rename from src/panos_decrypt_bypass/terraform/outputs.tf rename to src/panos_decrypt_bypass/terraform/scm/outputs.tf diff --git a/src/panos_decrypt_bypass/terraform/rules.tf b/src/panos_decrypt_bypass/terraform/scm/rules.tf similarity index 100% rename from src/panos_decrypt_bypass/terraform/rules.tf rename to src/panos_decrypt_bypass/terraform/scm/rules.tf diff --git a/src/panos_decrypt_bypass/terraform/tests/plan.tftest.hcl b/src/panos_decrypt_bypass/terraform/scm/tests/plan.tftest.hcl similarity index 93% rename from src/panos_decrypt_bypass/terraform/tests/plan.tftest.hcl rename to src/panos_decrypt_bypass/terraform/scm/tests/plan.tftest.hcl index 51a23ff..9bca912 100644 --- a/src/panos_decrypt_bypass/terraform/tests/plan.tftest.hcl +++ b/src/panos_decrypt_bypass/terraform/scm/tests/plan.tftest.hcl @@ -1,13 +1,13 @@ # Offline plan against a mocked SCM provider. From the repo root: -# terraform -chdir=src/panos_decrypt_bypass/terraform init -backend=false -# terraform -chdir=src/panos_decrypt_bypass/terraform test +# terraform -chdir=src/panos_decrypt_bypass/terraform/scm init -backend=false +# terraform -chdir=src/panos_decrypt_bypass/terraform/scm test # Uses the lists in tests/fixtures instead of a data directory. mock_provider "scm" {} variables { folder = "ngfw-shared" - bypass_dir = "../../../tests/fixtures/bypass" + bypass_dir = "../../../../tests/fixtures/bypass" } run "parses_lists_and_builds_objects" { @@ -64,7 +64,7 @@ run "skips_empty_rules_and_rechains" { command = plan variables { - bypass_dir = "../../../tests/fixtures/empty" + bypass_dir = "../../../../tests/fixtures/empty" } assert { @@ -77,7 +77,7 @@ run "edls_feed_rules" { command = plan variables { - bypass_dir = "../../../tests/fixtures/empty" + bypass_dir = "../../../../tests/fixtures/empty" create_no_decrypt_profile = true edls = { tf-edl-urls = { type = "url", source_url = "https://example.com/urls.txt" } diff --git a/src/panos_decrypt_bypass/terraform/variables.tf b/src/panos_decrypt_bypass/terraform/scm/variables.tf similarity index 100% rename from src/panos_decrypt_bypass/terraform/variables.tf rename to src/panos_decrypt_bypass/terraform/scm/variables.tf diff --git a/src/panos_decrypt_bypass/terraform/versions.tf b/src/panos_decrypt_bypass/terraform/scm/versions.tf similarity index 100% rename from src/panos_decrypt_bypass/terraform/versions.tf rename to src/panos_decrypt_bypass/terraform/scm/versions.tf diff --git a/src/panos_decrypt_bypass/web/app.py b/src/panos_decrypt_bypass/web/app.py index 452eedc..5ee0e94 100644 --- a/src/panos_decrypt_bypass/web/app.py +++ b/src/panos_decrypt_bypass/web/app.py @@ -133,7 +133,12 @@ def status() -> dict: "timezone": str(settings.timezone), "version": __version__, "auth": auth.enabled, - "configured": settings.workspace.has_location() or settings.demo, + "configured": settings.workspace.configured() or settings.demo, + "management": { + "mode": settings.workspace.mode.name, + "target": settings.workspace.mode.target, + "publish": settings.workspace.mode.publish, + }, } diff --git a/src/panos_decrypt_bypass/web/deployer.py b/src/panos_decrypt_bypass/web/deployer.py index fc10d2c..f3364ab 100644 --- a/src/panos_decrypt_bypass/web/deployer.py +++ b/src/panos_decrypt_bypass/web/deployer.py @@ -1,4 +1,5 @@ -"""One deploy at a time from the web GUI: terraform plan → (review) → apply → SCM push. +"""One deploy at a time from the web GUI: terraform plan → (review) → apply → +push (SCM) or commit (local firewall). A bypass deploy applies without asking only when the plan touches nothing but the no-decrypt URL list (and, the first time, the rule that uses it). Anything @@ -11,8 +12,8 @@ import time from dataclasses import asdict, dataclass, field -from panos_decrypt_bypass import bypass_lists, scm_api -from panos_decrypt_bypass.workspace import Workspace, terraform_env +from panos_decrypt_bypass import bypass_lists, panos_commit, scm_api +from panos_decrypt_bypass.workspace import Workspace PLAN_FILE = "tfplan.web" MAX_LOG_LINES = 3000 @@ -22,7 +23,30 @@ ("scm_url_category.no_decrypt[0]", ("update",)), ("scm_url_category.no_decrypt[0]", ("create",)), ("module.rule_urls[0].scm_decryption_rule.this", ("create",)), + ("panos_custom_url_category.no_decrypt[0]", ("update",)), + ("panos_custom_url_category.no_decrypt[0]", ("create",)), } +# On a local firewall every no-decrypt rule lives in this one resource, so it +# may change without review only by gaining the URL rule (see _only_adds_url_rule). +PANOS_RULES = "panos_decryption_policy_rules.no_decrypt[0]" +RULE_KEYS = ( + "name", + "description", + "action", + "type", + "source_zones", + "destination_zones", + "source_addresses", + "destination_addresses", + "source_user", + "category", + "services", + "profile", + "log_setting", + "log_success", + "log_fail", + "tag", +) IGNORED_ATTRS = {"id", "tfid"} @@ -37,6 +61,7 @@ class Job: state: str = "planning" # planning, review, applying, pushing, done, nochange, failed, cancelled added: list[str] = field(default_factory=list) description: str = "" + applied: bool = False changes: list[dict] = field(default_factory=list) review_reason: str = "" failed_stage: str = "" @@ -82,15 +107,49 @@ def _diff(before: dict, after: dict, unknown: dict) -> list[str]: return parts +def _rule(rule: dict) -> dict: + return {k: rule.get(k) for k in RULE_KEYS} + + +def _rules_diff(before: dict, after: dict) -> list[str]: + """Per-rule changes for the PAN-OS rules resource, instead of one huge list diff.""" + old = {r["name"]: _rule(r) for r in before.get("rules") or []} + new = {r["name"]: _rule(r) for r in after.get("rules") or []} + parts = [f"+ rule {n}" for n in new if n not in old] + [f"- rule {n}" for n in old if n not in new] + for name in new.keys() & old.keys(): + parts += [f"rule {name}: {d}" for d in _diff(old[name], new[name], {})] + if before and list(old) != list(new) and [n for n in new if n in old] != [n for n in old if n in new]: + parts.append("rule order: " + ", ".join(new)) + if before and before.get("position") != after.get("position"): + parts.append(f"position: {_fmt(before.get('position'))} → {_fmt(after.get('position'))}") + return parts + + +def _only_adds_url_rule(change: dict) -> bool: + """True if a PAN-OS rules change does nothing but add the URL rule.""" + before, after = change.get("before") or {}, change.get("after") or {} + old = [_rule(r) for r in before.get("rules") or []] + new = [_rule(r) for r in after.get("rules") or []] + added = [r for r in new if r["name"] not in {o["name"] for o in old}] + return ( + len(added) == 1 + and added[0]["name"].endswith("-urls") + and [r for r in new if r is not added[0]] == old + and (not before or (before.get("position") == after.get("position") and before.get("location") == after.get("location"))) + ) + + def _label(address: str, after: dict | None, before: dict | None) -> str: name = (after or before or {}).get("name", "") - if address.startswith("scm_url_category.no_decrypt"): + if address.startswith(("scm_url_category.no_decrypt", "panos_custom_url_category.no_decrypt")): return f"No-decrypt URL list ({name})" if ".scm_decryption_rule." in address: return f"Decryption rule {name}" + if address.startswith("panos_decryption_policy_rules"): + return "No-decrypt decryption rules" if address.startswith("scm_decryption_exclusion"): return f"Decryption exclusion {name}" - if address.startswith("scm_address"): + if address.startswith(("scm_address", "panos_address")): return f"Address object {name}" return f"{address}" @@ -104,7 +163,9 @@ def summarize_plan(plan: dict) -> tuple[list[dict], bool]: if actions in (("no-op",), ("read",)): continue before, after = ch.get("before") or {}, ch.get("after") or {} - if actions == ("update",): + if rc["address"] == PANOS_RULES and actions in (("update",), ("create",)): + detail = _rules_diff(before, after) + elif actions == ("update",): detail = _diff(before, after, ch.get("after_unknown") or {}) elif "delete" in actions and "create" in actions: detail = ["replace"] @@ -116,15 +177,18 @@ def summarize_plan(plan: dict) -> tuple[list[dict], bool]: "actions": list(actions), "label": _label(rc["address"], ch.get("after"), ch.get("before")), "detail": detail, + "auto": (rc["address"], actions) in AUTO_APPROVE + or (rc["address"] == PANOS_RULES and actions in (("update",), ("create",)) and _only_adds_url_rule(ch)), } ) - auto = bool(changes) and all((c["address"], tuple(c["actions"])) in AUTO_APPROVE for c in changes) + auto = bool(changes) and all(c.pop("auto") for c in changes) return changes, auto class Deployer: def __init__(self, workspace: Workspace): self.workspace = workspace + self.mode = workspace.mode self.workdir = workspace.tf_dir self.bypass_dir = workspace.bypass_dir self.lock = threading.Lock() @@ -171,7 +235,7 @@ def retry_push(self, job_id: int) -> Job: if not job or job.id != job_id: raise Busy(f"Deploy #{job_id} is not the current deploy.") if not (job.state == "nochange" or (job.state == "failed" and job.failed_stage == "push")): - raise Busy("Only a failed push, or a deploy with nothing to apply, can be pushed; start a new deploy instead.") + raise Busy(f"Only a failed {self.mode.publish}, or a deploy with nothing to apply, can be retried; start a new deploy instead.") job.state, job.error, job.failed_stage, job.finished = "pushing", "", "", None threading.Thread(target=self._push, args=(job,), daemon=True).start() return job @@ -180,7 +244,7 @@ def retry_push(self, job_id: int) -> Job: def _plan(self, job: Job) -> None: try: - if not self.workspace.has_location(): + if not self.workspace.configured(): raise RuntimeError(f"Set folder (or snippet) in {self.workspace.tfvars} first.") self.workspace.prepare_terraform() self._tf_output(job, "init", "-input=false") @@ -188,7 +252,7 @@ def _plan(self, job: Job) -> None: rc = self._tf(job, "plan", "-input=false", "-detailed-exitcode", f"-out={PLAN_FILE}", ok=(0, 2)) if rc == 0: (self.workdir / PLAN_FILE).unlink(missing_ok=True) - job.log.append("No changes: SCM already matches the bypass lists.") + job.log.append(f"No changes: {self.mode.target} already matches the bypass lists.") self._finish(job, "nochange") return plan = json.loads(self._tf_output(job, "show", "-json", PLAN_FILE)) @@ -198,7 +262,9 @@ def _plan(self, job: Job) -> None: self._apply_and_push(job) else: job.review_reason = ( - "This deploy changes more than the no-decrypt URL list." if job.kind == "bypass" else "Review the changes, then apply and push." + "This deploy changes more than the no-decrypt URL list." + if job.kind == "bypass" + else f"Review the changes, then apply and {self.mode.publish}." ) job.state = "review" except Exception as e: @@ -208,6 +274,7 @@ def _plan(self, job: Job) -> None: def _apply_and_push(self, job: Job) -> None: try: self._tf(job, "apply", "-input=false", PLAN_FILE) + job.applied = True except Exception as e: self._fail(job, "apply", e) return @@ -218,10 +285,13 @@ def _apply_and_push(self, job: Job) -> None: def _push(self, job: Job) -> None: job.state = "pushing" try: - folders = json.loads(self._tf_output(job, "output", "-json", "push_folders")) hosts = list(dict.fromkeys(e.removeprefix("*.") for e in job.added)) - what = f"bypass {' '.join(hosts)}" if hosts else "deploy" - scm_api.push_and_wait(folders, f"panos-decrypt-bypass: {what}", log=job.log.append) + description = f"panos-decrypt-bypass: {'bypass ' + ' '.join(hosts) if hosts else 'deploy'}" + if self.mode.name == "local": + panos_commit.commit_and_wait(description, log=job.log.append, expect_changes=job.applied) + else: + folders = json.loads(self._tf_output(job, "output", "-json", "push_folders")) + scm_api.push_and_wait(folders, description, log=job.log.append) self._finish(job, "done") except Exception as e: self._fail(job, "push", e) @@ -257,7 +327,7 @@ def _tf(self, job: Job, *args: str, ok: tuple[int, ...] = (0,)) -> int: proc = subprocess.Popen( ["terraform", args[0], "-no-color", *args[1:]], cwd=self.workdir, - env=terraform_env(), + env=self.workspace.terraform_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, @@ -274,7 +344,7 @@ def _tf_output(self, job: Job, *args: str) -> str: result = subprocess.run( ["terraform", args[0], "-no-color", *args[1:]], cwd=self.workdir, - env=terraform_env(), + env=self.workspace.terraform_env(), capture_output=True, text=True, ) @@ -286,7 +356,7 @@ def _tf_output(self, job: Job, *args: str) -> str: class DemoDeployer(Deployer): """For WEBGUI_DEMO: walks through the same states with made-up output and - never runs Terraform or contacts SCM.""" + never runs Terraform or contacts SCM or the firewall.""" STEP = 1.2 @@ -294,12 +364,12 @@ def _plan(self, job: Job) -> None: job.log.append("$ terraform plan (demo: nothing is sent anywhere)") time.sleep(self.STEP) if not job.added: - job.log.append("No changes: SCM already matches the bypass lists.") + job.log.append(f"No changes: {self.mode.target} already matches the bypass lists.") self._finish(job, "nochange") return job.changes = [ { - "address": "scm_url_category.no_decrypt[0]", + "address": f"{'panos_custom' if self.mode.name == 'local' else 'scm'}_url_category.no_decrypt[0]", "actions": ["update"], "label": "No-decrypt URL list (tf-ssl-bypass-urls)", "detail": [f"list: {', '.join('+' + e for e in job.added)}"], @@ -317,8 +387,14 @@ def _apply_and_push(self, job: Job) -> None: def _push(self, job: Job) -> None: job.state = "pushing" - job.log.append("Pushing ngfw-shared (demo)") - time.sleep(self.STEP * 2) - job.log.append(" fw-demo: OK") - job.log.append("Push complete.") + if self.mode.name == "local": + job.log.append("Committing changes by api-admin only (demo)") + time.sleep(self.STEP * 2) + job.log.append("Commit job 42: OK") + job.log.append("Commit complete.") + else: + job.log.append("Pushing ngfw-shared (demo)") + time.sleep(self.STEP * 2) + job.log.append(" fw-demo: OK") + job.log.append("Push complete.") self._finish(job, "done") diff --git a/src/panos_decrypt_bypass/web/panos.py b/src/panos_decrypt_bypass/web/panos.py index fc34f28..8b6e157 100644 --- a/src/panos_decrypt_bypass/web/panos.py +++ b/src/panos_decrypt_bypass/web/panos.py @@ -1,7 +1,8 @@ -"""PAN-OS XML API client for decryption logs. +"""PAN-OS XML API client: decryption logs, and commits for MANAGEMENT=local. Log retrieval is a two-step job: type=log creates a query job, then action=get polls it until status is FIN and returns elements. +Commits work the same way: type=commit returns a job id to poll. """ import ssl @@ -119,6 +120,31 @@ def _call(self, params: dict[str, str]) -> ET.Element: root.set("_body", body) return root + def commit(self, description: str, admins: list[str] | None = None) -> str | None: + """Start a commit, limited to the given admins' changes when set. + Returns the job id, or None if there was nothing to commit.""" + cmd = ET.Element("commit") + ET.SubElement(cmd, "description").text = description[:512] + params = {"type": "commit"} + if admins: + partial = ET.SubElement(ET.SubElement(cmd, "partial"), "admin") + for admin in admins: + ET.SubElement(partial, "member").text = admin + params["action"] = "partial" + params["cmd"] = ET.tostring(cmd, encoding="unicode") + root = self._call(params) + job = root.findtext(".//job") + return job.strip() if job else None + + def job(self, job_id: str) -> ET.Element: + """The element of `show jobs id `.""" + cmd = ET.Element("show") + ET.SubElement(ET.SubElement(cmd, "jobs"), "id").text = job_id + job = self._call({"type": "op", "cmd": ET.tostring(cmd, encoding="unicode")}).find(".//job") + if job is None: + raise PanosError(f"Firewall did not return job {job_id}.") + return job + def query_decryption_logs(self, query: str, nlogs: int = 1000, job_timeout: int = 120) -> list[LogEntry]: """Run a decryption log query, oldest first, and return the entries.""" params = {"type": "log", "log-type": "decryption", "nlogs": str(nlogs), "dir": "forward"} diff --git a/src/panos_decrypt_bypass/web/settings.py b/src/panos_decrypt_bypass/web/settings.py index d4dd1ee..8c001ac 100644 --- a/src/panos_decrypt_bypass/web/settings.py +++ b/src/panos_decrypt_bypass/web/settings.py @@ -8,7 +8,8 @@ from pathlib import Path from zoneinfo import ZoneInfo -from panos_decrypt_bypass.workspace import Workspace +from panos_decrypt_bypass import scm_api +from panos_decrypt_bypass.workspace import MODES, Workspace def _bool(value: str | None, default: bool) -> bool: @@ -22,6 +23,7 @@ class Settings: workspace: Workspace firewall_host: str api_key: str + firewall_username: str verify_tls: bool ca_file: Path | None timezone: ZoneInfo @@ -42,6 +44,7 @@ def from_env(cls) -> "Settings": workspace=Workspace.from_env(), firewall_host=os.environ.get("FIREWALL_HOST", "").strip(), api_key=os.environ.get("FIREWALL_API_KEY", "").strip(), + firewall_username=os.environ.get("FIREWALL_USERNAME", "").strip(), verify_tls=_bool(os.environ.get("FIREWALL_VERIFY_TLS"), True), ca_file=Path(os.environ["FIREWALL_CA_FILE"]) if os.environ.get("FIREWALL_CA_FILE") else None, timezone=ZoneInfo(tz), @@ -67,6 +70,8 @@ def bypass_dir(self) -> Path: def startup_errors(self) -> list[str]: """Problems that stop the app from starting at all.""" errors = [] + if self.workspace.management not in MODES: + errors.append(f"MANAGEMENT must be {' or '.join(MODES)}, not {self.workspace.management!r}.") if self.auth not in ("password", "none"): errors.append(f"WEBGUI_AUTH must be 'password' or 'none', not {self.auth!r}.") elif self.auth == "password" and not self.password: @@ -81,7 +86,12 @@ def problems(self) -> list[str]: """Problems shown in the GUI's status area.""" if self.demo: return [] - missing = [name for name, value in (("FIREWALL_HOST", self.firewall_host), ("FIREWALL_API_KEY", self.api_key)) if not value] + required = [("FIREWALL_HOST", self.firewall_host), ("FIREWALL_API_KEY", self.api_key)] + if self.workspace.management == "local": + required.append(("FIREWALL_USERNAME", self.firewall_username)) + else: + required += [(name, os.environ.get(name, "").strip()) for name in scm_api.REQUIRED_ENV] + missing = [name for name, value in required if not value] problems = [f"{', '.join(missing)} not set. Add to .env (see .env.example)."] if missing else [] if self.ca_file and not self.ca_file.exists(): problems.append(f"FIREWALL_CA_FILE {self.ca_file} does not exist.") diff --git a/src/panos_decrypt_bypass/web/static/app.js b/src/panos_decrypt_bypass/web/static/app.js index 77c95b8..282027d 100644 --- a/src/panos_decrypt_bypass/web/static/app.js +++ b/src/panos_decrypt_bypass/web/static/app.js @@ -1,4 +1,4 @@ -// SCM Decrypt Bypass front end. Every value from the logs goes through +// PAN-OS Decrypt Bypass front end. Every value from the logs goes through // textContent (never innerHTML): SNI, CN and error strings are attacker-controlled. const $ = (id) => document.getElementById(id); @@ -63,6 +63,10 @@ async function loadStatus() { $("version").textContent = `panos-decrypt-bypass ${s.version}`; $("setupHint").hidden = s.configured; $("setupHint").textContent = "Not configured yet: set folder (or snippet) in terraform.tfvars in the data directory before deploying. See docs/configuration.md."; + const m = s.management; + $("dlgHow").textContent = m.mode === "local" + ? "Adds the hostnames to the no-decrypt URL category on the firewall, then commits." + : "Adds the hostnames to the no-decrypt URL category, then applies it in SCM and pushes to the firewall."; const problem = s.problems.length ? s.problems.join(" ") : s.last_error; const dot = el("span", { class: `dot ${problem ? "err" : s.polling ? "busy" : ""}` }); const parts = [dot, `Firewall ${s.firewall}`]; @@ -293,10 +297,26 @@ let deployJob = null; let deployTimer = null; const ACTIVE = ["planning", "applying", "pushing"]; +// Wording for SCM (push) or a locally managed firewall (commit). +function words() { + const local = lastStatus?.management?.mode === "local"; + return local + ? { target: "the firewall", publish: "commit", step: "Commit", nochangeButton: "Commit now", + publishing: "Committing on the firewall. This usually takes a minute or two; you can close this window and keep working.", + done: "Done. The firewall has committed the new config.", + nochange: "Nothing to apply: the firewall already matches the bypass lists. If an earlier commit failed, commit now.", + failed: " The change is in the firewall's candidate config; only the commit failed." } + : { target: "SCM", publish: "push", step: "Push to firewall", nochangeButton: "Push to firewall", + publishing: "Pushing to the firewall. This usually takes a few minutes; you can close this window and keep working.", + done: "Done. SCM is updated and the firewall has the new config.", + nochange: "Nothing to apply: SCM already matches the bypass lists. If an earlier push failed, push now to send SCM's config to the firewall.", + failed: " SCM has the change; only the push to the firewall failed." }; +} + function steps(job) { const list = [["planning", "Plan"]]; if (job.kind === "manual" || job.review_reason) list.push(["review", "Review"]); - list.push(["applying", "Apply"], ["pushing", "Push to firewall"]); + list.push(["applying", "Apply"], ["pushing", words().step]); const failedAt = { plan: "planning", apply: "applying", push: "pushing" }[job.failed_stage]; const at = job.state === "failed" ? failedAt : job.state; const idx = list.findIndex(([k]) => k === at); @@ -310,18 +330,19 @@ function steps(job) { } function deployMessage(job) { + const w = words(); switch (job.state) { - case "planning": return "Checking what will change in SCM…"; + case "planning": return `Checking what will change in ${w.target}…`; case "review": return `${job.review_reason} Nothing has been changed yet.`; - case "applying": return "Applying to SCM…"; - case "pushing": return "Pushing to the firewall. This usually takes a few minutes; you can close this window and keep working."; - case "done": return job.added.length ? `Done. ${job.added.join(", ")} ${job.added.length > 1 ? "are" : "is"} no longer decrypted.` : "Done. SCM is updated and the firewall has the new config."; - case "nochange": return "Nothing to apply: SCM already matches the bypass lists. If an earlier push failed, push now to send SCM's config to the firewall."; + case "applying": return `Applying to ${w.target}…`; + case "pushing": return w.publishing; + case "done": return job.added.length ? `Done. ${job.added.join(", ")} ${job.added.length > 1 ? "are" : "is"} no longer decrypted.` : w.done; + case "nochange": return w.nochange; case "cancelled": return "Cancelled. Nothing was changed" + (job.added.length ? `, and ${job.added.join(", ")} came back out of the list.` : "."); case "failed": return `Failed during ${job.failed_stage}: ${job.error}` + (job.failed_stage === "plan" && job.added.length ? " The new entries were taken back out of the list." : "") + - (job.failed_stage === "push" ? " SCM has the change; only the push to the firewall failed." : ""); + (job.failed_stage === "push" ? w.failed : ""); } return ""; } @@ -352,12 +373,12 @@ function renderDeploy() { buttons.push( act("Later", () => $("deployDialog").close(), "link"), act("Cancel", () => deployAction("cancel"), "secondary"), - act("Apply & push", () => deployAction("confirm")), + act(`Apply & ${words().publish}`, () => deployAction("confirm")), ); } else if (job.state === "failed" && job.failed_stage === "push") { - buttons.push(close, act("Retry push", () => deployAction("retry-push"))); + buttons.push(close, act(`Retry ${words().publish}`, () => deployAction("retry-push"))); } else if (job.state === "nochange") { - buttons.push(close, act("Push to firewall", () => deployAction("retry-push"), "secondary")); + buttons.push(close, act(words().nochangeButton, () => deployAction("retry-push"), "secondary")); } else { buttons.push(close); } diff --git a/src/panos_decrypt_bypass/web/static/index.html b/src/panos_decrypt_bypass/web/static/index.html index a6631a0..049ef10 100644 --- a/src/panos_decrypt_bypass/web/static/index.html +++ b/src/panos_decrypt_bypass/web/static/index.html @@ -3,13 +3,13 @@ - SCM Decrypt Bypass + PAN-OS Decrypt Bypass
-

SCM Decrypt Bypass

+

PAN-OS Decrypt Bypass

TLS decryption failures from the firewall, ready to bypass
@@ -61,8 +61,8 @@

SCM Decrypt Bypass

Bypass

-

Adds the hostnames to the no-decrypt URL category, then applies it in SCM and pushes to the - firewall. The rule is scoped by the zones and source in terraform.tfvars.

+

Adds the hostnames to the no-decrypt URL category, then applies it and + updates the firewall. The rule is scoped by the zones and source in terraform.tfvars.

What to bypass diff --git a/src/panos_decrypt_bypass/web/static/login.html b/src/panos_decrypt_bypass/web/static/login.html index 08ba00d..aeea342 100644 --- a/src/panos_decrypt_bypass/web/static/login.html +++ b/src/panos_decrypt_bypass/web/static/login.html @@ -3,12 +3,12 @@ - Sign in · SCM Decrypt Bypass + Sign in · PAN-OS Decrypt Bypass
-

SCM Decrypt Bypass

+

PAN-OS Decrypt Bypass

Sign in to view decryption failures and manage bypasses.