Files
hub-insurance/docs/audit-esp-instanda-2026-07.md
2026-07-02 15:54:39 -06:00

519 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Security, Reliability & PII Audit — `esp-instanda-integration`
**Plugin version:** 1.0.7 · **Audit date:** 2026-07-02 · **Standards:** Getfused Universal Coding Standards
**Target:** local WordPress Studio copy of `hub-insurance` (SQLite, WP 7.0, PHP 8.5, GF 2.10.5)
**Method:** static review → adversarial verification (one skeptic per finding + completeness critics)
→ live read-only inspection → in-process state-machine tests (INSTANDA mocked; **zero external API traffic**).
> **Report-only.** No plugin code was changed. The only site writes were one throwaway transaction
> row and a temporary read-only probe file, both deleted; the transaction table is back to its
> original 14 rows. Severities below were **reconciled against an adversarial verification pass** —
> it refuted 6 candidate findings and downgraded several others (see §5), which is why some items
> are lower than a first read would suggest.
---
## 1. Executive summary
This is a well-built add-on. It has a write-ahead transaction log, a test-mode lock enforced in
three independent layers, redacting logging, `Throwable` guards on every hook so a plugin bug
can't white-screen the form, and libsodium encryption for the stored API password. Most of that
machinery was verified to work as designed. Several things a first read flags as alarming turned
out to be **non-issues** on inspection — no public information disclosure, no SQL injection, no
admin-page privilege leak, and the form is *not* mis-wired into unsubmittability (§5).
What remains are the failure modes that matter for a form which **spends money and stores PII on
every submission**, because the plugin's two guarantees — *no lost quotes, no duplicate policies*
are not fully met. The load-bearing items, each confirmed with live evidence on the running site:
1. **Duplicate INSTANDA quotes on repeat submits** (F-01) — proven live: one email submitted 10
times produced 10 distinct quotes. Duplicate-prevention only stops a double-fire *within one
request* (and production's object cache doesn't change this — it's token instability).
2. **Completing the Go-Live flow can leave real quotes going to the Test endpoint** (F-03) —
`switch_to_live()` flips the mode but never sets the environment.
3. **The stored API password is recoverable from a DB dump** — confirmed on **both** the local and
the production database: no `SPG_INSTANDA_KEY` constant anywhere, so the encryption key sits
beside the ciphertext it protects (F-04).
4. **Plaintext PII with indefinite retention, and failure alerts silently disabled** (no alert
recipients) — confirmed in production too (F-05).
5. **Config drift + a stray active feed on the test form** (F-02) — production has Form 5 correctly
wired *and* an active feed still attached to the test Form 6; the local copy has the feed on the
wrong form entirely.
Remediation is tractable and prioritized in §7. Production is verified to be **pre-launch** (still
in INSTANDA test mode), so these are fixable before go-live.
---
## 2. Scope, environment & method
**In scope:** the plugin (`wp-content/plugins/esp-instanda-integration/`, 18 files, ~4,900 lines)
and its front-end coupling in the active theme (`wp-content/themes/GFeneratePress/functions.php`).
**Live environment observed (read-only):**
| Fact | Value | Why it matters |
|---|---|---|
| Active theme | `GFeneratePress` | carries the Form 5 date validator |
| Timezone | `America/New_York` (`gmt_offset -4`) | makes the TZ-mixing bug a real 4h skew (F-07) |
| `SPG_INSTANDA_*` constants | **none defined** | credentials + key resolve to the DB (F-04) |
| `GRAVITYSMTP_SENDGRID_API_KEY` | **defined** in `wp-config.php` | hardcoded secret (F-08) |
| Object-cache drop-in | **absent** (`wp_using_ext_object_cache()=no`) | cross-request lock is inert locally |
| INSTANDA feed | **Form 6** only (`event_start=7, event_end=8, customer_email=9`) | F-02 |
| Live Form 5 | dates at 8/9, email at 10; **no feed**; page "Form Test Page" | F-02 |
| Operating mode / env | unset→`test` / `test`; domain `consumer.instanda.us` | mode clamp active |
| `alert_emails` | **empty** | failure alerts silently no-op (F-05) |
| Retention / storage | `retention_days=7`, `storage_enabled=1` | delivered-only purge |
| Cleanup cron | `esp_instanda_cleanup` daily (one instance) | correct |
| Transaction table | 14 rows (13 delivered, 1 failed), all Form 6, `environment=test` | real test data |
**Verification passes:** (a) full static read of the six load-bearing files + architecture map of
all 18; (b) an adversarial pass — one agent per candidate finding tasked with *refuting* it, plus
three completeness critics; (c) live probes via `wp eval`/`eval-file` (`wp db query` is unusable
on this SQLite build — it probes MySQL `sql_mode`); (d) in-process `pre_http_request` mocks driving
the real `Api_Client`/`Transaction_Store` through every response class with **no external calls**.
**Production parity:** verified via read-only WP-CLI on the live Pressable `hubinsurance` site
(§6) — object cache, secrets/constants, `sql_mode`, schema, feeds, and options.
---
## 3. Severity summary
| # | Finding | Severity | Status |
|---|---------|----------|--------|
| **F-01** | Duplicate INSTANDA quotes on repeat submits (token instability) | **High** | Live-proven |
| **F-03** | `switch_to_live()` doesn't set env → live quotes keep hitting Test | **High** | Code-confirmed |
| **F-04** | API password recoverable from a DB dump (self-provisioned key) | **High** | Confirmed local **+ prod** |
| F-02 | Stray active feed on test Form 6 + local↔prod feed drift (prod Form 5 wired) | Medium | Live-confirmed |
| F-05 | Plaintext PII, indefinite retention, alerts disabled | Medium | Live-confirmed |
| F-06 | No rate-limit / no CAPTCHA on public → API path | Medium | Live-confirmed |
| F-07 | Timezone mixing skews retention & resubmit self-recovery | Medium | Confirmed (UTC-4) |
| F-08 | Hardcoded SendGrid API key in `wp-config.php` | Medium | Live-confirmed |
| F-09 | Async recording fails silently; Form 6 has zero notifications | Medium | Confirmed |
| F-10 | 5xx auto-retry of the mutating PUT is a duplicate vector | Medium | Verified |
| F-11 | `Guard::wrap` fails open on validation → quote-less entries | Medium | Confirmed |
| F-12 | Delivered transaction can be resubmitted → duplicate quote | Medium | Code-confirmed |
| F-13 | Stranded-lead delivered row is purged by retention → lost lead | Medium | Confirmed |
| F-14 | Write-ahead insert failure / storage-off → silent StartQuote | Medium | Confirmed |
| F-15 | Uninstall keeps the encrypted password **and** its key + all options | Medium | Confirmed |
| F-16 | "Gravity Forms required" admin notice is unreachable when GF is absent | Medium | Code-confirmed |
| F-17 | `200`-without-`quoteRef` recorded as failed, not possible-duplicate | Low | Verified |
| F-18 | Resubmit self-recovery over-matches → skips legitimate resubmits | Low | Confirmed |
| F-19 | DB-tamper trust boundary (resubmit replays stored body; outbound mail) | Low | Confirmed |
| F-20 | Operator-misleading dead code (retry count, resubmit event never fire) | Low | Confirmed |
| F-21 | `event_log` non-atomic read-modify-write drops concurrent audit events | Low | Confirmed |
| F-22 | Retention cron scheduled only at activation, never re-ensured | Low | Confirmed |
| F-23 | Front-end hygiene: ~220 lines inline JS sitewide + observer never disconnects | Low | Confirmed |
| F-24 | Uninstall/deactivation option residue; stale docblocks/template | Info | Confirmed |
§5 lists what was **checked and cleared** (including 6 refuted candidates).
---
## 4. Findings
### F-01 · Duplicate INSTANDA quotes on repeat submits — High · Live-proven
The duplicate-prevention scheme keys off `submission_token()`
([class-submission.php:219-229](../wp-content/plugins/esp-instanda-integration/includes/class-submission.php#L219-L229)),
which uses `GFFormsModel::get_form_unique_id` — a fresh `uniqid()` per request for this
non-upload form (or a client-supplied value). So the per-submission lock, replay cache, and the
"already delivered" short-circuit ([:63-71](../wp-content/plugins/esp-instanda-integration/includes/class-submission.php#L63-L71))
only match *within a single request*. Across a browser re-POST, a back-button resubmit, or the
retry the plugin invites after an ambiguous timeout ("*Please try again in a moment*",
[:312](../wp-content/plugins/esp-instanda-integration/includes/class-submission.php#L312)), the
token differs, the dedupe key differs, and a **new** `PUT /StartQuote` fires. The dedupe key is
computed but never transmitted, and INSTANDA has no idempotency.
**Live proof.** 14 rows, **14 distinct dedupe keys, 13 distinct quote refs**. `zbatte@getfused.com`
appears in **10 submissions**, each with a *different* quote ref (rows 12 and 13 are 36 s apart →
`CVPAZE` and `Y38STE`). The delivered-dedupe short-circuit has never fired in real use.
**Reconciliation.** Rated **High**, not Critical: on the default posture the traffic hits the
*Test* endpoint (mode clamp), so this creates duplicate *Test* quotes today. Once live (F-03), it
creates duplicate real policies.
**Fix.** Derive the dedupe key from **submission content + form id** (hash of the mapped field
values), so identical resubmits collapse; keep the per-request lock as a second layer; persist the
delivered short-circuit by content hash so a re-POST returns the prior quote URL instead of a new
quote.
---
### F-02 · Stray active feed on the test form + local↔prod feed drift — Medium · Live-confirmed
The two environments are configured differently, and both carry loose ends:
| | Form 5 "Start a Quote" (dates 8/9, email 10) | Form 6 "…(INSTANDA test)" (dates 7/8, email 9) |
|---|---|---|
| **Production** | active feed `8/9/10` ✓ + theme validation `8/9` ✓ → **correctly wired** | **active feed still attached** (test form) |
| **Local** | **no feed** (theme validates it, but it never reaches INSTANDA) | active feed `7/8/9` — the only fed form; all 14 tx came from it |
So on production, AGENTS.md's "Form 5 is the live form" actually holds — Form 5's feed mapping and
the theme's hardcoded `8/9` agree. The residual issues are: (a) **a still-active INSTANDA feed on
the *test* Form 6 in production** — if that page is reachable it also creates real (currently Test)
quotes and gets no theme date validation
([functions.php:128-130](../wp-content/themes/GFeneratePress/functions.php#L128-L130) only handles
form id 5); and (b) **config drift** — the local copy has the feed on Form 6 only, so testing
Form 5 locally never exercises the INSTANDA path that runs in prod. The theme validator being
hardcoded to a single form id ([functions.php:74-76](../wp-content/themes/GFeneratePress/functions.php#L74-L76))
is the underlying fragility. (The earlier worry that a field mismatch *bricks* the form was
**refuted** — §5; every fed form is individually wired correctly.)
**Fix.** Deactivate the INSTANDA feed on the test Form 6 in production (or retire the form); align
the local copy with prod so tests exercise the live path; drive the theme's `GF_DATE_FORM_ID` from
the form that actually has the active feed rather than a hardcoded constant, and add a go-live
assertion that the public quote page embeds exactly one fed, theme-validated form.
---
### F-03 · `switch_to_live()` doesn't set the environment — High · Code-confirmed
The sanctioned go-live action sets the operating mode but **not** the environment:
```php
// class-go-live.php
switch_to_live() { update_option('espinstanda_operating_mode', 'live'); } // :131 — no env write
return_to_test() { update_option('espinstanda_operating_mode', 'test');
update_option('espinstanda_env', 'test'); } // :145-146 — sets BOTH
```
`Config::environment()` returns Test whenever `raw_env()` isn't `'live'`
([class-config.php:87-98](../wp-content/plugins/esp-instanda-integration/includes/class-config.php#L87-L98)),
and `raw_env()` reads the `espinstanda_env` option (currently `test`). So an operator who completes
the Go-Live checklist and clicks "switch to live" ends up with `operating_mode=live` but
`environment=Test` — the test-mode lock is now *off*, yet **real customer quotes keep going to the
INSTANDA Test endpoint with Test credentials**. The asymmetry with `return_to_test()` (which sets
both) shows this is a bug, not a design choice.
**Mitigation / caveat.** If the intended Live posture is followed — `SPG_INSTANDA_ENV='live'` (or
the settings env radio) set as part of go-live — `raw_env()` returns `'live'` via the constant and
the switch works. So this bites the operator who relies on the Go-Live button alone. Given that
button *is* the sanctioned flow, this is a High-priority correctness bug.
**Fix.** Have `switch_to_live()` set `espinstanda_env='live'` (mirroring `return_to_test()`), or
make `environment()` follow `operating_mode` directly.
---
### F-04 · API password recoverable from a DB dump — High · Confirmed local **+ production**
With no `SPG_INSTANDA_KEY` constant, the libsodium key is self-provisioned into the DB option
`espinstanda_secret_key`
([class-config.php:193-226](../wp-content/plugins/esp-instanda-integration/includes/class-config.php#L193-L226)) —
i.e. the key lives in the **same database** as the ciphertext it protects. **Verified on both
environments:** neither local nor the production `hubinsurance` site defines `SPG_INSTANDA_KEY`
(nor any `SPG_INSTANDA_*` credential/env constant), and on both the key (len 44 = a 32-byte sodium
key) and the ciphertext (`espinstanda_password_test`, len 76) are present in the database. A
database dump alone therefore yields the API password on the live site. The class-level docblock
([class-config.php:13](../wp-content/plugins/esp-instanda-integration/includes/class-config.php#L13))
asserts the *opposite* as an absolute ("*a database dump alone never exposes a usable secret*");
only the private docblock at :205-211 admits the caveat.
**No mitigation in place.** The intended-to-be-hardened path — setting `SPG_INSTANDA_KEY` (and the
credential constants) in `wp-config.php` to move secrets out of the DB — is **not used in
production**, so the guarantee the docblock states is not met anywhere today.
**Fix.** Set `SPG_INSTANDA_KEY` + credential constants in prod `wp-config.php` and make it a
**blocking go-live check**; correct the class-13 docblock to state the caveat. (This also removes
the F-15 uninstall exposure, since the key would no longer be in the DB.)
---
### F-05 · Plaintext PII, indefinite retention, alerts disabled — Medium · Live-confirmed
`redacted_payload` is a misnomer: it stores the full StartQuote JSON *including the customer email*
(`redact()` strips only auth/password tokens, never PII —
[class-logger.php:39-43](../wp-content/plugins/esp-instanda-integration/includes/class-logger.php#L39-L43)),
alongside plaintext `customer_email`/`role_label` columns
([class-transaction-store.php:104-108](../wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php#L104-L108)).
Retention purges **delivered-only**
([:314-322](../wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php#L314-L322)),
so failed/pending rows keep PII forever with no data-subject deletion path. On mail-send failure,
the customer email is also logged and fanned to a public action hook
([class-notifier.php:70-78](../wp-content/plugins/esp-instanda-integration/includes/class-notifier.php#L70-L78)).
Live: real emails are in the table today, and `espinstanda_alert_emails` is **empty**, so the
failure-alert path silently no-ops — the one real 400 failure generated no alert.
**Fix.** Rename the column and document what it holds; add a retention sweep (or email anonymization)
for failed/pending rows; add a delete-by-email routine for data requests; require ≥1 alert recipient
at go-live.
---
### F-06 · No rate-limit / no CAPTCHA on the public → API path — Medium · Live-confirmed
Every final-page submission of a form with an active feed reaches a live `PUT /StartQuote`. The
only throttle is the per-submission lock, which doesn't limit distinct scripted submissions (it
only dedupes a same-token double-fire). Production has a persistent object cache (verified), so the
lock is atomic cross-request there; locally it has no object cache and is inert — either way it
doesn't throttle distinct submits. Anti-bot is delegated to GF's `is_valid`
([class-submission.php:51-53](../wp-content/plugins/esp-instanda-integration/includes/class-submission.php#L51-L53)),
and **Form 6 has no CAPTCHA field** (verified) — only a honeypot, which (per the verification pass)
actually fires on `gform_entry_is_spam` *after* the plugin's create runs in `gform_validation`, so
it doesn't gate the create at all. A PII row is inserted before every call (F-05), and retention
purges delivered-only, so failed/attacker traffic accumulates.
**Reconciliation.** Rated **Medium**, not Critical: the test-mode clamp means the flood hits the
*Test* endpoint by default (prod is still in test mode), so the paid-API cost-DoS is gated behind
go-live (F-03); Field_Mapper's required-field validation adds friction; edge rate-limiting
(Cloudflare/Pressable) may exist but was not confirmed. The *mechanism* — no application-level rate
limit and no CAPTCHA on the live form — is confirmed.
**Fix.** Add a CAPTCHA (or Akismet) to the live quote form; add server-side rate limiting keyed by
IP and email *before* `create_pending`; cap/alert on pending+failed row growth.
---
### F-07 · Timezone mixing skews retention & resubmit self-recovery — Medium · Confirmed (UTC-4)
Rows are written with site-local `current_time('mysql')`
([class-transaction-store.php:140,162](../wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php#L140)),
but the retention cutoff ([:317](../wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php#L317)),
the resubmit self-recovery window ([:246-249](../wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php#L246-L249)),
and `compute_expiry` ([:338-342](../wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php#L338-L342))
use UTC `gmdate()`. Live: this site is `America/New_York`; at audit time `current_time` was `16:58`
while `gmdate` was `20:58` — a real 4-hour divergence. The purge runs ~4h off its boundary, the 24h
self-recovery window is shifted (feeding F-01 or F-18), and `delivered_at`/`expiry` are on different
clocks. **Fix:** use UTC everywhere (`gmdate`/`current_time('mysql', true)`) for both writes and
cutoffs.
---
### F-08 · Hardcoded SendGrid API key in `wp-config.php` — Medium · Live-confirmed
`GRAVITYSMTP_SENDGRID_API_KEY` is defined in `wp-config.php` with a live-looking `SG.…` value next
to the salts (confirmed present; value not extracted). This is the SMTP add-on's key, but it's in
the audited config surface and powers the plugin's customer emails. **Fix:** move it to an
environment secret; **rotate it** if this copy has ever been committed, backed up, or shared
(escalates to High in that case).
---
### F-09 · Async recording fails silently; Form 6 has zero notifications — Medium · Confirmed
With `_async_feed_processing=true`, `record_feed_result` runs in a later request where
`Submission::$current_dedupe` is null; if `on_entry_created` didn't attach the entry
(a `Guard`-swallowed throw, storage disabled, or a missing dedupe row) it returns with **no meta,
no note, no notification, and no log line**
([class-instanda-addon.php:369-403](../wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php#L369-L403)).
Live, this is compounded: **Form 6 has zero notifications configured**, so the
`espinstanda_quote_success` notification the code sends has nothing to fire — the customer
"quote ready" email is currently inert. **Fix:** fall back to `find_by_entry`/stash and *log* when a
transaction can't be resolved; configure + test the success notification on the canonical form.
---
### F-10 · 5xx auto-retry of the mutating PUT is a duplicate vector — Medium · Verified
`request()` re-sends the mutating `PUT /StartQuote` after a 5xx/transport error
([class-api-client.php:121-141](../wp-content/plugins/esp-instanda-integration/includes/class-api-client.php#L121-L141)),
contradicting the class's own "never auto-retry a mutating ambiguous call" docblock. With no
idempotency and the dedupe key untransmitted, a gateway 5xx *after* INSTANDA processed attempt 1
creates a second quote. Verified in-process: a persistent 500 makes **2** attempts. **Timing
nuance:** a genuine slow timeout (8 s) exceeds the 3 s retry budget after attempt 1, so real
timeouts make only 1 attempt — the auto-retry duplicate risk is real for *fast* 5xx/connection
failures, not slow timeouts. **Fix:** don't auto-retry a non-idempotent create.
---
### F-11 · `Guard::wrap` fails open on validation — Medium · Confirmed
`Guard::wrap` returns `$args[0]` on any `Throwable`
([class-guard.php:24-33](../wp-content/plugins/esp-instanda-integration/includes/class-guard.php#L24-L33)).
On `gform_validation` that leaves `is_valid` **true**, so a bug produces a saved entry with no quote
and the default confirmation — a silent quote-less lead. The fail-open trade-off ("never break the
form") is defensible, but the validation path specifically should record a pending/failed
transaction and fire the `esp_instanda_throwable` alert so the lost quote is visible.
---
### F-12 · A delivered transaction can be resubmitted → duplicate quote — Medium · Code-confirmed
`Resubmit_Handler::run` has no delivered-status guard, and the self-recovery check
`$recent->id !== $tx->id` deliberately does **not** skip when the recent success *is* the row being
resubmitted ([class-transactions-table.php:144-149](../wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php#L144-L149)).
So resubmitting an already-delivered transaction (via the admin AJAX endpoint, which also lacks a
status guard, [class-instanda-addon.php:582-604](../wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php#L582-L604))
fires a second `PUT /StartQuote` and overwrites the original `quote_ref` in the row — a duplicate
quote plus loss of the first quote's reference. Requires the `espinstanda_transactions` capability.
**Fix:** refuse to resubmit rows whose status is already `delivered`.
---
### F-13 · Stranded-lead delivered row is purged by retention — Medium · Confirmed
On the no-entry success path, if both `urlSingleUse` and the `continue_quote` re-mint fail, the
quote is delivered but the customer is never notified and no GF entry exists — only the delivered
transaction row ([class-transactions-table.php:162-173](../wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php#L162-L173)).
That row is then purged after the retention window
([class-transaction-store.php:314-322](../wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php#L314-L322)),
so the lead is lost with no trace. **Fix:** exempt delivered rows with a null `entry_id` (or an
unsent customer email) from the retention purge, or resolve them before purge.
---
### F-14 · Write-ahead insert failure / storage-off → silent StartQuote — Medium · Confirmed
`create_pending` returns `0` on insert failure and the caller guards all state marking behind
`if ($tx_id)` while **still making the StartQuote call**
([class-submission.php:86-125](../wp-content/plugins/esp-instanda-integration/includes/class-submission.php#L86-L125)).
The same guard means that when storage is disabled (an allowed, advisory-only posture) **every**
StartQuote failure is completely silent — no row, no alert. So the write-ahead log's core promise
(never make an unaudited call) is void exactly when it's needed. **Fix:** if storage is enabled and
`create_pending` returns 0, abort or log before proceeding; keep a failure record/alert independent
of the row insert.
---
### F-15 · Uninstall keeps the encrypted password **and** its key — Medium · Confirmed
`uninstall.php` deletes only `espinstanda_db_version` and `espinstanda_operating_mode_audit`,
leaving ~13 other `espinstanda_*` options including both `espinstanda_password_test` (ciphertext)
and `espinstanda_secret_key` (its key), plus env, site domain, retention, alert emails, and the
go-live acknowledgements ([uninstall.php:16-32](../wp-content/plugins/esp-instanda-integration/uninstall.php#L16-L32)).
The recoverable secret pair (F-04) survives uninstall, and a reinstall inherits stale go-live state.
**Fix:** delete all `espinstanda_*` options and transients on uninstall (behind a "remove data"
setting if retention is ever desired).
---
### F-16 · "Gravity Forms required" notice is unreachable when GF is absent — Medium · Code-confirmed
The requirement notice is registered *inside* the `gform_loaded` callback
([esp-instanda-integration.php:86-97](../wp-content/plugins/esp-instanda-integration/esp-instanda-integration.php#L86-L97)),
but `gform_loaded` only fires when Gravity Forms is present. So if GF is deactivated, the "Gravity
Forms is required and is not active" notice ([:69](../wp-content/plugins/esp-instanda-integration/esp-instanda-integration.php#L69))
never registers — the plugin goes silently inactive with no explanation. **Fix:** register the
GF-absent notice on `admin_notices` unconditionally (outside `gform_loaded`).
---
### F-17 to F-24 · Lower-severity findings
| # | Finding | Where | Fix |
|---|---------|-------|-----|
| F-17 | `200`-without-`quoteRef` recorded as `failed`/`possible_duplicate=0` though a quote may exist (verified: `ok=true, quote_ref=null` → failure branch) | [class-submission.php:101](../wp-content/plugins/esp-instanda-integration/includes/class-submission.php#L101); [class-api-client.php:160-163](../wp-content/plugins/esp-instanda-integration/includes/class-api-client.php#L160-L163) | Treat 2xx-no-`quoteRef` (and empty-string ref) as ambiguous |
| F-18 | Resubmit self-recovery matches **any** delivered quote for the email in 24h → skips a legitimate second-event resubmit (also shifted by F-07) | [class-transactions-table.php:144-149](../wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php#L144-L149) | Scope the match to the specific transaction/dedupe |
| F-19 | Resubmit re-PUTs the stored body verbatim; stranded-lead email uses the DB-stored recipient — DB-tamper → data injection / arbitrary mail (no host SSRF; requires DB write) | [class-transactions-table.php:151-173](../wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php#L151-L173) | Re-validate the decoded body through `Field_Mapper` before resubmit |
| F-20 | Dead code misleads operators: `increment_retry()` has no callers (Retries column always 0); `espinstanda_resubmit_success` event registered but never dispatched | [class-transaction-store.php:191-195](../wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php#L191-L195); [class-notifier.php:20](../wp-content/plugins/esp-instanda-integration/includes/class-notifier.php#L20) | Wire up or remove |
| F-21 | `event_log` updated via non-atomic read-modify-write of the whole JSON blob → concurrent writers (async feed + admin resubmit) silently drop audit events | [class-transaction-store.php:326-336](../wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php#L326-L336) | Append via a child rows table or a DB-side JSON op |
| F-22 | Retention cron scheduled only in the activation hook, never re-ensured (unlike the schema, which self-heals on `admin_init`) → if the event is ever lost, purge stops permanently | [esp-instanda-integration.php:41-43](../wp-content/plugins/esp-instanda-integration/esp-instanda-integration.php#L41-L43) vs [:77-82](../wp-content/plugins/esp-instanda-integration/esp-instanda-integration.php#L77-L82) | Re-ensure the schedule on `admin_init` |
| F-23 | Theme prints ~220 lines of inline JS in `wp_footer` on every page where `GFForms` exists, and its `MutationObserver` never `disconnect()`s where the fields never appear | [functions.php:189-422](../wp-content/themes/GFeneratePress/functions.php#L189-L422) | Gate to pages embedding the form; disconnect after N tries |
| F-24 | Uninstall option residue (F-15); stale `class-config.php:13` docblock (F-04); bundled `form/start-a-quote.json` template no longer matches the live forms; stale `M5/M9/M10` milestone comments | various | Align docs with shipped behavior |
---
## 5. Checked and cleared (including refuted candidates)
The verification pass actively tried to break these and could not — they are **not** defects:
- **Public information disclosure** — REFUTED (95%): the public failure path returns only the
generic `esc_html`'d message; INSTANDA `response_excerpt`/`error_detail` appear only on the
capability-gated admin detail page.
- **Admin submenu capability "mismatch"** — REFUTED (90%): WordPress core blocks URL access on the
menu capability *before* the render callback runs, so a user lacking the menu cap gets a core 403,
not a leaked page. Both directions fail safe; it's a cosmetic inconsistency, not an authz hole.
- **Dead bulk "resubmit" as a CSRF hole** — REFUTED: `WP_List_Table` auto-emits the `_wpnonce`, and
there is no handler, so the action is an inert no-op (dead code, not a vulnerability).
- **`$_POST['p']` unsanitized** — correct by design: a password must not be `sanitize_text_field`'d;
it is used only to build an outbound Basic-auth probe and is not persisted or logged.
- **Form "bricked" by theme/plugin field conflict** — REFUTED: live Form 5 has dates at 8/9 (email
at 10), matching the theme's constants; the plugin feed maps Form 6's 7/8/9 correctly. Neither
form is mis-validated. (The real issue is the cross-form decoupling — F-02.)
- **Strict-`Y-m-d` date failure** — REFUTED: live date fields are `ymd_dash` (= `Y-m-d`), which
`Field_Mapper` accepts. (Latent coupling only: changing the field format would break parsing.)
- **Strict-typed entry-integration callbacks TypeError** — REFUTED by test: passing a string
`form_id` did **not** throw, because `strict_types` is caller-scoped and Gravity Forms calls the
filters in coercive mode, so a numeric-string id coerces to `int`. Columns/merge tags register
normally in real requests.
- **SQL injection / stored XSS** — all `$wpdb` access uses `prepare()`/`insert()`/`update()` with
column arrays; admin output of stored data is `esc_html`/`esc_url`'d.
- **Test-mode lock** — enforced in three independent layers (env clamp, outbound Live assertion,
go-live gate); a mutating Live call while locked throws. All stored data is `environment=test`.
---
## 6. Live state-machine results & production-parity gaps
**State-machine oracle — observed vs expected (verified in-process, INSTANDA mocked; also cross-checked
against the 13 live delivered rows and the real 400 failure row):**
| Response | ok | quote_ref | is_ambiguous | attempts | resulting tx row |
|---|---|---|---|---|---|
| `200` + quoteRef | true | set | false | 1 | `delivered` (matches 13 live rows) |
| `200` no body | true | **null** | false | 1 | `failed`, possible_duplicate=**0** (F-17) |
| `400` + errors | false | null | false | 1 | `failed`, deterministic (matches live row id 8) |
| `500` persistent | false | null | **true** | 2 | `failed`, possible_duplicate=**1** |
| transport timeout | false | null | **true** | 2* | `failed`, possible_duplicate=1 |
`*` the instant mock retries; a real 8 s timeout makes 1 attempt (retry budget). `create_pending →
pending` and `mark_ambiguous → failed/possible_duplicate=1` confirmed directly.
**Production (Pressable) parity — VERIFIED** via read-only WP-CLI on the live `hubinsurance` site
(`hubinsurance.mystagingwebsite.com`, site 1705596, PHP 8.5, `wp_environment_type=production`,
currently still in INSTANDA **test** mode — i.e. pre-launch):
| Question | Production result | Effect on findings |
|---|---|---|
| Object cache present? | **yes** (`object-cache.php` drop-in present) | The per-submission lock **is** atomic cross-request in prod (it was inert locally). Narrows F-06's lock note to local only; **F-01 unaffected** (token instability, not lock backend). |
| `SPG_INSTANDA_KEY` / credential / `SPG_INSTANDA_ENV` constants set? | **none — all `undef`** | **F-04 confirmed for prod** — the self-provisioned key (len 44) + ciphertext (len 76) both live in the prod DB. The hardened constant posture is **not** in use anywhere. |
| `GRAVITYSMTP_SENDGRID_API_KEY` | **defined** in prod | F-08 confirmed in prod. |
| `alert_emails` | **empty** | F-05 confirmed in prod (failure alerts disabled). |
| Operating mode / env | `mode=UNSET`→test, `env=test` | Prod not yet live; mode clamp active. Interacts with F-03 at go-live. |
| MySQL `sql_mode` | `ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER` (non-strict, no `NO_ZERO_DATE`) | **Defuses the schema concern** — the `DEFAULT '0000-00-00 00:00:00'` column built cleanly; `wp_esp_instanda_transactions` exists on prod. |
| INSTANDA feeds | **two active feeds**: Form **5** (`start=8,end=9,email=10`) *and* Form **6** (`start=7,end=8,email=9`) | Reframes F-02 — see below. |
**F-02 in production is different from local.** On prod, **Form 5 is correctly wired**: it has its
own active feed whose mapping (`8/9/10`) matches both its field layout and the theme's hardcoded
`8/9`, so AGENTS.md's "Form 5 is the live form" holds there. But prod *also* carries an active feed
on the **test** Form 6, and the local copy has the feed only on Form 6 (not Form 5). So the real
prod issue is a **stray active feed on the test form + local↔prod configuration drift**, not lost
quotes — hence F-02 is Medium, not the local-only "wrong form" hazard.
---
## 7. Prioritized remediation roadmap
**Go-live blockers:**
1. F-03 — `switch_to_live()` must set `env=live` (or drive `environment()` from the mode); otherwise
the go-live button leaves real quotes hitting Test.
2. F-01 — content-based dedupe key so repeat submits collapse instead of duplicating quotes.
3. F-04 — set `SPG_INSTANDA_KEY` + credential constants in prod (verified absent today); blocking check.
4. F-05 — PII retention/deletion for failed rows; configure alert recipients (empty in prod).
5. F-02 — deactivate the stray INSTANDA feed on the test Form 6 in prod; align local↔prod; add the
"one fed, theme-validated public form" go-live assertion.
**Soon (data-integrity & correctness):**
6. F-06 — CAPTCHA/Akismet + outbound rate limiting on the public create.
7. F-07 — UTC everywhere for timestamps and cutoffs.
8. F-09 / F-11 / F-14 — close the silent-failure paths (async recording, validation throwable, insert/storage-off).
9. F-10 / F-12 / F-17 — stop auto-retrying the non-idempotent create; block delivered-row resubmit; flag 2xx-no-`quoteRef` as ambiguous.
10. F-08 — move/rotate the SendGrid key out of committed config.
**Hygiene (low risk, do when touching the files):**
11. F-13, F-15, F-16, F-18F-24.
---
## 8. Appendix — method & limitations
- **Static:** full read of `class-submission.php`, `class-config.php`, `class-api-client.php`,
`class-transaction-store.php`, `class-go-live.php`, `class-entry-integration.php`,
`esp-instanda-integration.php`, and the theme `functions.php`; architecture-mapped all 18 files.
- **Adversarial verification:** one refutation-tasked agent per candidate finding + three
completeness critics; verdicts (CONFIRMED / PLAUSIBLE / REFUTED with 0-100 confidence) drove the
severities here. Six candidates were refuted (§5); several were downgraded.
- **Live (read-only):** `wp eval`/`eval-file` for theme, forms 5/6, feed mapping, options, cron,
transaction rows, filter order, object-cache state. `wp db query` is unusable on this SQLite build.
- **Dynamic:** in-process `pre_http_request` mock drove the real `Api_Client`/`Transaction_Store`
through success/`200`-no-body/`400`/`500`/timeout with **no external calls**; one throwaway row
was created and deleted.
- **Production parity:** read-only WP-CLI on the live Pressable `hubinsurance` site via the
Pressable MCP — object cache, `SPG_INSTANDA_*` constants, options, `sql_mode`, `SHOW CREATE TABLE`,
and the INSTANDA feeds (§6). No writes were made to production.
- **Limitations:** no end-to-end browser pass was run, but the redirect/entry flow is evidenced by
the 13 live delivered rows; abuse (F-06) and duplicate (F-01) behavior were confirmed from
existing data rather than by generating new load against the real API.