feat(nginx): F5 WAF for NGINX (App Protect) — L2 apply/retire, all 3 forms live-proven - #56
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-labVPC 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 thatpkgs.nginx.comis mutual-TLS (the JWT is only the runtime license; repo auth needsnginx-repo.crt+.key), plus the_aptkey-perms, the three repos, and the second signing key.Code — mirrors the
bigip.pytrionginx.py(NginxSSH transport, stdlib subprocess — no new dep) /nginx_lab.py(guard_site+ the copilot vhost) /nginx_apply.py(apply_nginx/retire_nginx).emit(target='nginx-app-protect'),_run_validation,probe,ledger,auditunchanged; one new control stringnginx_app_protect.vpcopilot-managedinclude→nginx -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)POST /api/resetblocked,/api/healthpasses. §10.1 resolved: no staging.waf(signatures) honestly declined (NAP's own signature staging).Full lifecycle + wiring
_retire_nginxin 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 completenginx-lab create→apply-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