Skip to content

feat(nginx): F5 WAF for NGINX (App Protect) — L2 apply/retire, all 3 forms live-proven - #56

Merged
henleda merged 14 commits into
mainfrom
feat-nginx-app-protect-design
Aug 18, 2026
Merged

feat(nginx): F5 WAF for NGINX (App Protect) — L2 apply/retire, all 3 forms live-proven#56
henleda merged 14 commits into
mainfrom
feat-nginx-app-protect-design

Conversation

@henleda

@henleda henleda commented Aug 18, 2026

Copy link
Copy Markdown
Owner

The implementation for the NGINX App Protect variant. (PR #55 merged early with just the design doc docs/design/nginx-app-protect-apply.md; these are the 13 commits of actual work that followed.)

Same discipline as the BIG-IP path: same emitter, same _run_validation, same ledger/audit — a third enforcement point for the exact same findings. Every claim below is proven on a real NGINX + App Protect box, not asserted.

Infra — infra/vpcopilot-nap/

A single NGINX Plus R37 + App Protect (v4 module) EC2 that reuses the vpcopilot-lab VPC and fronts the existing Larkspur origin (terraform plan: 7 add, 0 change to the lab). Onboarding is out-of-band over SSM; the proven recipe is codified (onboard/nap-onboard.sh) — the live install surfaced that pkgs.nginx.com is mutual-TLS (the JWT is only the runtime license; repo auth needs nginx-repo.crt+.key), plus the _apt key-perms, the three repos, and the second signing key.

Code — mirrors the bigip.py trio

  • nginx.py (Nginx SSH transport, stdlib subprocess — no new dep) / nginx_lab.py (guard_site + the copilot vhost) / nginx_apply.py (apply_nginx / retire_nginx).
  • Reuses emit(target='nginx-app-protect'), _run_validation, probe, ledger, audit unchanged; one new control string nginx_app_protect.
  • Transport: write policy + a vpcopilot- managed includenginx -t → reload → settle-poll for enforcement → validate through the box → keep/rollback. Detach strips only our files.

All three forms PROVEN live (via vpcopilot apply-nginx)

  • service_policy — negative transfer blocked, positive passes.
  • api_schema — off-contract POST /api/reset blocked, /api/health passes. §10.1 resolved: no staging.
  • waf_data_guard — leaked PAN + SSN come back masked.
  • waf (signatures) honestly declined (NAP's own signature staging).

Full lifecycle + wiring

_retire_nginx in reconcile, /api/apply-nginx + retire routing in the console, the report (generalized _bigip_html → both targets), and the CLI (nginx-lab / apply-nginx / retire-nginx). The complete nginx-lab createapply-nginx → auto-retire lifecycle runs end-to-end through the CLI.

Discipline

Two live-CLI-found bugs the offline tests missed (reject-as-fake-dry-run, broken-vhost-on-failure) fixed + tested. Every §10 adversarial must-fix resolved by ground truth. Full suite 1158 pass, ruff clean.

🤖 Generated with Claude Code

Daniel Henley and others added 14 commits August 18, 2026 11:04
…b VPC)

The L2 proof box for the NGINX App Protect variant — a single NGINX Plus + App
Protect v4 EC2 that fronts the EXISTING Larkspur origin so a finding's declarative
band-aid can be applied + validated on a real NAP box (docs/design/nginx-app-protect-apply.md).

- Reuses the vpcopilot-lab VPC + external subnet (data-sourced by tag) + the running
  Larkspur origin (10.30.10.22:8080). The lab's origin SG already admits :8080 from
  the VPC CIDR, so this adds ONE box with NO change to vpcopilot-lab. `terraform plan`:
  7 to add, 0 to change, 0 to destroy (validated against the live lab VPC).
- Box: Ubuntu 22.04 t3.medium at 10.30.10.30, public EIP, SG (vhost :80; SSH via SSM),
  SSM instance role, keypair from .secrets/. IMDSv2, encrypted gp3.
- Onboarding is OUT-OF-BAND over SSM (onboard/nap-onboard.sh via `make onboard JWT=…`):
  installs NGINX Plus + App Protect v4 using the F5 subscription JWT and stands up a
  reverse-proxy vhost to Larkspur with App Protect enforcing. The JWT lands only at
  /etc/nginx/license.jwt on the box — never in user_data or Terraform state.
- HONEST scaffold: F5 gated the JWT-era v4 install docs behind MyF5, so the script
  carries `# VERIFY:` markers at the repo-auth line, package names, and bundled log
  path — this is the NAP Phase-0 live spike, expected to iterate on first contact
  (as the BIG-IP onboarding did). Ends with a smoke test (benign 200 / canned SQLi block).
- Makefile mirrors the lab: check / plan / apply / onboard / tunnel / ssh / shell /
  nap-up / nap-down / nap-nuke, all Project-scoped. Secrets + state + *.jwt gitignored.

Provisioning stays user-gated — `terraform apply` is yours to run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test -f "$(JWT)" quoted the value, so a leading ~ was not tilde-expanded and a
JWT passed as ~/Downloads/... failed the existence check. Resolve via eval echo
(also expands a glob), folded into the recipe's single shell block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live spike on the box: pkgs.nginx.com does mutual-TLS and returns HTTP 400 'No
required SSL certificate was sent' to any request without a client cert — proven
by probing the JWT in every header/basic-auth position (all 400) and reading the
400 body. The JWT is only the R33+ RUNTIME license (/etc/nginx/license.jwt); repo
access needs nginx-repo.crt + nginx-repo.key (mTLS), the official 90pkgs-nginx
method. Onboard now installs the cert+key and uses the SslCert/SslKey apt config;
make onboard takes JWT= CERT= KEY=. This is exactly the VERIFY: marker paying off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ect blocks

NAP Phase-0 spike PASSED on the box: a canned SQLi is blocked with the 'Request
Rejected / Your support ID is' page (HTTP 200 body — the same block page the
copilot's _XC_BLOCK_MARKERS already match). Rewrote onboard/nap-onboard.sh to the
recipe that actually worked, discovered by driving the install over SSM:

- pkgs.nginx.com is mutual-TLS (returns '400 No required SSL certificate was sent'
  to the JWT). Repo auth = nginx-repo.crt + .key via SslCert/SslKey; the JWT is
  only the runtime license (/etc/nginx/license.jwt).
- apt's acquire runs as _apt, so the key must be readable by it (0640 root:_apt,
  else 0644) — otherwise 'Could not load client certificate ... Error while
  reading file' and every fetch 400s. Also clears any stale JWT auth.conf.d.
- THREE repos (plus, app-protect, app-protect-security-updates) and TWO signing
  keys (nginx_signing.key + the separate app-protect-security-updates.key,
  ...A5F6473795E778F4).
- Install the  metapackage alone (pulls nginx-plus + module + engine
  + compiler + signatures + geoip); co-listing nginx-plus yields held broken pkgs.
- v4-style module (ngx_http_app_protect_module.so, raw-JSON app_protect_policy_file);
  the enforcer is the nginx-app-protect service. Smoke test baked in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The box resolved the environment unknowns: NGINX Plus R37 + App Protect module
(v4-style, raw-JSON app_protect_policy_file, nginx-app-protect enforcer service),
NAP's block page is the Request-Rejected/support-ID page at HTTP 200 that the
copilot's validator already matches (no _run_validation change needed), and repo
auth is client-cert not the JWT. Still to prove (needs the emit path): entity
staging for service_policy/api_schema and reconcile around-the-box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…en live

nginx.py (Nginx SSH transport, the bigip.py analogue) + nginx_apply.py
(apply_nginx / retire_nginx, the bigip_apply.py analogue). Reuses emit(target=
nginx-app-protect), _run_validation, probe_negative_pay, ledger, audit unchanged;
one new control string, nginx_app_protect. Transport is stdlib ssh/scp via
subprocess (no new dep): write policy + a copilot-owned managed include under
vpcopilot- -> nginx -t -> reload -> validate through the box -> keep or roll back.
Detach strips only our vpcopilot- files, so a user's own policy survives.

PROVEN end-to-end on the live NAP box (larkspur-neg-transfer-001): before the
band-aid the negative transfer is processed; after, NAP blocks it (support-id
page) while the legit positive transfer passes -> passed=True. Resolves two §10
live gates:
- §10.2: NAP enforces the declared numeric-parameter constraint immediately, NOT
  in staging.
- §10.4: NAP loads a policy into the enforcer ASYNCHRONOUSLY after reload — a
  single-shot validation right after reload races the load and reports a false
  "still succeeds". apply_nginx now settle-polls the validation until enforcement
  is live (bounded), never reporting a false block.

+14 offline tests (fake Nginx client): decline, dry-run, keep/rollback fail-closed,
the settle-poll, ledger+audit dict shape, surgical detach, transport command
construction, redaction. Full suite green, ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nginx_apply blocks larkspur-neg-transfer-001 end-to-end (before: processed;
after: NAP support-id block; legit passes). §10.2 resolved (numeric-param
constraint enforces immediately, no staging) and §10.4 resolved (async
enforcer-load after reload -> settle-poll). Still open: §10.1 (api_schema URL
staging), §10.5 (reconcile around-the-box), + nginx_lab/console/report/cli and
the other two forms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…into apply/retire

nginx_lab.py mirrors bigip_lab.py: guard_site (the catch-all `_` refused
unconditionally — the /Common analogue; VPCOPILOT_PROTECTED_NGINX_SITES overridable),
validate_site, create/remove/status. create() writes the copilot-owned vhost — a
reverse proxy to the origin whose location includes /etc/nginx/conf.d/vpcopilot-active/*.conf
(empty = clean slate, App Protect OFF until apply drops a policy), nginx -t, reload,
audit. Idempotent, dry-run stages+rolls back like AS3 dry-run. apply_nginx/retire_nginx
now call guard_site. Nginx client gains ensure_dir. +11 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e CLI

Full XC-parity lifecycle for the nginx_app_protect control, mirroring the BIG-IP
wiring at the same four sites the bigip_awaf string touches:

- reconcile: a _retire_nginx branch in _one (before the XC-only presence checks —
  a BYO-NGINX user has no XC), detaching on the box via retire_nginx. Completes
  apply -> validate -> keep -> reconcile -> auto-retire.
- console: POST /api/apply-nginx (NginxApplyReq + _run_nginx_apply, same job model
  as /api/apply-bigip) + a do_retire branch routing nginx_app_protect to retire_nginx.
- report: generalized _bigip_html -> _declarative_waf_html(target, ...), called for
  both bigip-awaf and nginx-app-protect (one code path, two headings); the
  apply_nginx_app_protect impact label.
- cli: nginx-lab {create,rm,status} + apply-nginx + retire-nginx, mirroring the
  bigip-lab/apply-bigip/retire-bigip option surface and exit-code contract
  (LabRefused->3, (NginxError,RuntimeError)->1, 0 iff passed).

+6 tests (reconcile routing, console apply job + retire routing, report NGINX
section, CLI guard exit-3 + registration). Full suite 1157 pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nd by the live CLI)

The full CLI lifecycle (nginx-lab create -> apply-nginx) on the real box surfaced
two bugs:
- apply_nginx returned {applied:False, dry_run:True} when nginx -t REJECTED the
  staged policy, which the CLI rendered as a benign "would deploy (dry run)" — the
  exact looks-applied-and-is-not false positive. Now it re-raises NginxError, so
  the CLI/console show "box error" and exit non-zero. +1 test.
- nginx_lab.create left the broken vhost on the box when nginx -t failed, breaking
  the next nginx -t. Now it rolls the vhost back (like apply) before re-raising.

Live-proven after the fixes: `vpcopilot nginx-lab create` + `vpcopilot apply-nginx
--finding larkspur-neg-transfer-001` blocks the negative transfer (settle-poll
False,False,True), legit passes, exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e CLI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… resolved

api_schema (disallow off-contract URL) and waf_data_guard (response masking) both
proven via apply-nginx on the box. §10.1 resolved: NAP enforces the declared-URL
disallow immediately (no staging). Form coverage complete: service_policy +
api_schema + waf_data_guard all live-proven. Only §10.5 (reconcile around-the-box
on a single-ingress topology) remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… model as BIG-IP

reverse-proxy deployment fires the exploit at the separate origin via TARGETS_ENV
(around the box), like BIG-IP fires at the pool member; retire routing wired +
unit-tested. Single-ingress worry only applies to a co-located deployment (report-
only there, same as any WAF). Task B form coverage complete + full lifecycle proven.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors Task A's BIG-IP panel for the new enforcement point: an "Apply on your own
NGINX (App Protect)" panel driven by /api/emit (target=nginx-app-protect), so all
three shipped forms are selectable and every decline shows its reason. Wired to
POST /api/apply-nginx (server/location/url + dry-run/keep), streamed through the
shared job model. New GET /api/nginx-lab status endpoint (nginx_lab.status), the
twin of /api/bigip-lab. The report's NGINX section + the ② "Emit for another WAF"
target list already cover NGINX, so console + report now match the BIG-IP surface.

+4 tests (nginx-lab status endpoint, /api/emit panel contract, static page wiring),
node --check clean on the inline JS. Full suite green, ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@henleda
henleda merged commit f8b4e18 into main Aug 18, 2026
4 checks passed
@henleda
henleda deleted the feat-nginx-app-protect-design branch August 19, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant