# Security audit: PHP CVE-2026-109618 / CVE-2026-109619

**Date:** 22 October 2026 · **Repo:** blu2green (branch `FEAT/Carbon_Marketplace`) · **Plan:** `intent/2026-10-22-php-cve-2026-109618/plan.md`
**Sources:** CERT-EU SA 2026-018 v1.1 and UK NCSC alert, both 21 Oct 2026.

> The interim mitigations below only reduce risk. In CERT-EU's words: *"Only an update fully fixes these vulnerabilities."*

---

## 1. Summary

**Overall risk: CRITICAL.**

- All 12 internet-facing PHP backends run php-fpm on a PHP 8.2 base image pulled from a floating tag (`php:8.2-fpm-alpine`).
- That base is only rebuilt when `docker/php-nginx/` changes, so production almost certainly runs a release older than 8.2.35. This has to be confirmed on the running tasks (section 7, item 1).
- `session.upload_progress.enabled` is still at its default of On, and filemst uses native file sessions heavily.
- Kong (the gateway) has no authentication or request-size limit on ECS. There is also no WAF.

**Top 3 actions**

1. **Pin `php:8.2.35-fpm-alpine`** in `docker/php-nginx/Dockerfile:12`. The edit itself triggers a base rebuild and the redeploy of all 12 backends. Ship to demo, then prod, today.
2. **Harden the runtime in the same PR:**
   - set `session.upload_progress.enabled = 0`
   - align the nginx and Kong body limits with PHP's 64M
3. **Hunt for signs of compromise:**
   - in CloudWatch, S3 and the DB, starting from 15 Oct
   - check `b2g_filemst` `mws/ws/uploadimage` urgently: it is a likely unauthenticated arbitrary-file-upload that can become remote code execution, and it's unrelated to the CVE (section 5.2)

There is also a deadline: **PHP 8.2's security support ends on 31 Dec 2026.** After this patch, migrate to 8.5 (section 6.3).

---

## 2. Version inventory

There is one shared base image. All 12 backends (`needsBase: true`, `ci/service-map.json:52-213`) build `FROM ${BASE_IMAGE}`, which is `<ECR>/b2g/php-nginx-base:8.2` (`ci/pipeline.py:309-311`).

| App / service | Where the PHP version is set | Version | Status | Target |
|---|---|---|---|---|
| **Shared base, used by all 12 deployed backends:** micro, usermgmt, profilemgmt, confmgmt, gmarketapi, mail, subpayment, zohoapi, cengex, digitall (+ carbon_marketplace_api bundled at :82), filemst, nibe_datamanager | `docker/php-nginx/Dockerfile:12` `FROM php:8.2-fpm-alpine` | 8.2.x, whatever was current at the last base rebuild | **UNPINNED** (probably VULNERABLE) | `php:8.2.35-fpm-alpine`, then 8.5 by December |
| Base rebuild trigger | `ci/pipeline.py:118-119` (`base_image_dirty`), `:237-245` (`cmd_plan`); `ci/buildspec-cd.yml:53-58` otherwise pulls the existing `:8.2` | — | Explains why the tag is stale | The pin itself forces a rebuild |
| ECR base repo | `infra/terraform/modules/cicd/main.tf:35` `image_tag_mutability = "MUTABLE"` | `:8.2` is overwritten in place | Note | Consider IMMUTABLE for sha tags |
| PHP extension installer | `docker/php-nginx/Dockerfile:16` `.../releases/latest/...` | floating | UNPINNED (low risk, it's a tool) | Optional: pin a release |
| Local Compose | `docker-compose.yml:26` `b2g/php-nginx:8.2` (built from `./docker/php-nginx`) | same as base | UNPINNED | Follows the base |
| Legacy per-repo Compose (dev only, not deployed) | `b2g_{cengex,confmgmt,digitall,gmarketapi,mail,micro,profilemgmt,subpayment,usermgmt}/docker-compose.yml:4` `yiisoft/yii2-php:7.4-apache` | 7.4, **mod_php** | **EOL** | Delete, or don't use |
| nibe dev Compose (dev only) | `nibe_datamanager/docker-compose.yml:3` `yiisoft/yii2-php:8.5-apache-latest` | 8.5.x, mod_php | UNPINNED | Don't expose it |
| Vagrant (legacy, not deployed) | `b2g_*/vagrant/provision/once-as-root.sh:38` `php7.2-*`; `b2g_micro/Vagrantfile:45` `bento/ubuntu-18.04` | 7.2 | **EOL** | Delete |
| composer `require.php` | `>=5.6.0`: cengex, mail, micro, usermgmt (`composer.json:17`). `>=7.4.0`: confmgmt, digitall, gmarketapi, profilemgmt, subpayment (`:17`), zohoapi (`:9`). `"*"`: `b2g_filemst/composer.json:26`. `>=8.2`: `carbon_marketplace_api/composer.json:9`, `nibe_datamanager/composer.json:17` | lower bounds only | Doesn't set the runtime | — |
| composer platform pin | `carbon_marketplace_api/composer.json:20-21` and `composer.lock:494-495` `"php": "8.2.4"` | resolution only | Doesn't set the runtime | Raise it for the 8.5 migration |
| Platform-check bypass | `b2g_filemst/Dockerfile:31`, `b2g_usermgmt/Dockerfile:34` `--ignore-platform-req=php` | — | Note (hides PHP-7-only packages) | Track 2 |

Not present in the repo: `.php-version`, `.tool-versions`, `Jenkinsfile`, `.github/workflows`, GitLab/Bitbucket/Azure CI, Helm, K8s manifests, Ansible, Packer, `.platform.app.yaml`, `.ddev`, `.lando.yml`. The Terraform does not set a PHP version. It deploys `${ecr}:${sha}` images (`infra/terraform/modules/ecs/main.tf:79`).

**The repo can't prove the running version.** Run `php -v` in each running task (section 7, item 1).

---

## 3. SAPI per app

| App | SAPI | Evidence |
|---|---|---|
| All 12 deployed backends | **php-fpm** behind nginx FastCGI (`127.0.0.1:9000`) under supervisord | `docker/php-nginx/supervisord.conf:7` `command=php-fpm -F`; `docker/php-nginx/nginx.conf:50,74,96,116,136` |
| FPM pool | `pm = ondemand`, `pm.max_children = 8`, `pm.max_requests = 500`, `request_terminate_timeout = 300` | `docker/php-nginx/php-fpm-pool.conf:3-9` |
| Legacy dev Compose / nibe dev Compose | **mod_php (Apache)**, the highest-risk SAPI, but dev only and not deployed | see section 2 |
| Local catalogue sync | CLI | `docker-compose.yml:237` `php /app/yii catalogue/sync` |

No deployed image uses `php-cgi`, FrankenPHP, LiteSpeed, Swoole, RoadRunner or Octane. `pm.max_requests = 500` recycles workers. That helps a little against CVE-2026-109619, which relies on stale state left in a worker, but doesn't fix it.

---

## 4. Config findings

Only two PHP config files are deployed: `docker/php-nginx/php.ini` (installed as `conf.d/zz-b2g.ini`) and `docker/php-nginx/php-fpm-pool.conf` (installed as `php-fpm.d/zz-b2g.conf`). The official image has no `php.ini`, so anything not set below uses PHP's compiled-in default. There is no `.user.ini` anywhere, no `.htaccess` contains `php_value` or `php_flag`, and `php_admin_value` is not used.

| Setting | Current value | Where | Recommended | Effective? / note |
|---|---|---|---|---|
| `memory_limit` | `256M` | `php.ini:1` | **Keep 256M** while patching. Use `-1` via `php_admin_value` only on a host that can't be patched. | The app overrides it at runtime: `ini_set('memory_limit','-1')` ×13 in filemst (section 5.1). Fargate task memory is a hard cgroup cap (prod backend 1024 MB, `envs/prod/terraform.tfvars:136`; demo 512 MB, `envs/demo/terraform.tfvars:132`). 8 workers × 256M already exceeds it. |
| `post_max_size` | `64M` | `php.ini:3` | 32M per the advisory. **Keep 64M** (decision recorded in the plan). | Effective |
| `upload_max_filesize` | `64M` | `php.ini:2` | Below `post_max_size` | Equal to `post_max_size`; acceptable |
| `max_file_uploads` | unset (20) | — | Default is fine | — |
| `max_input_vars` / `max_multipart_body_parts` | unset (1000 / default `-1` = input_vars + file_uploads) | — | Default is fine | — |
| **`session.upload_progress.enabled`** | **unset (On)** | — | **`0` in `php.ini`** | **Not mitigated.** filemst runs native file sessions (`b2g_filemst/api/config/web.php:147`, `@session_start()` in about 17 base controllers). subpayment also uses sessions. |
| `fatal_error_backtraces` | n/a | — | `0` (PHP 8.5 only) | Not applicable on 8.2. Add it during the 8.5 migration. |
| `error_reporting` | `E_ALL & ~E_DEPRECATED & ~E_STRICT & ~E_NOTICE & ~E_WARNING` | `php.ini:12` | Remove `~E_STRICT` on 8.4+ | `E_STRICT` is deprecated from 8.4. It isn't invalid on 8.2. No invalid or unknown directives exist on 8.2. |
| `display_errors` | `Off` | `php.ini:13` | Off | Runtime overrides: `b2g_filemst/api/modules/mail/controllers/MailController.php:2580-2581`, `b2g_profilemgmt/web/index.php:16` (dev hosts, including demo) |
| `max_execution_time` | `300` | `php.ini:4` | — | The app overrides it with `set_time_limit(0)` (section 5.1), capped by FPM `request_terminate_timeout = 300` (`php-fpm-pool.conf:9`) |
| nginx `client_max_body_size` | `128M` | `nginx.conf:21` (http level, every server) | **64M** (matches PHP) | Larger than PHP's limit. Also applies to chunked bodies. |
| nginx header limits | unset (defaults: `client_header_buffer_size 1k`, `large_client_header_buffers 4 8k`) | — | Defaults are fine | — |
| Kong body limit, ECS | **unset** (no `request-size-limiting` plugin, no `KONG_NGINX_PROXY_CLIENT_MAX_BODY_SIZE`) | `docker/kong/Dockerfile:10-19`, `docker/kong/kong.yml` | **64m** | Kong's default is believed to be unlimited (not checked against the Kong docs) |
| Kong body limit, Compose | `128m` | `docker-compose.yml:117` | 64m | Local only |
| Container memory caps | ECS task `memory` 1024 (prod) / 512 (demo). Compose: none. | `infra/terraform/modules/ecs/main.tf:59-66` | ECS is OK. Compose is local only. | Fargate memory is a hard limit; it doesn't use `RLIMIT_AS`. |
| ALB | HTTP only (:80/:8001/:8002), no WAF, `drop_invalid_header_fields` unset | `infra/terraform/modules/alb/main.tf:16-24,108,121,132` | Add WAF + HTTPS; set `drop_invalid_header_fields = true` | ALB has no body-size setting |

---

## 5. Code findings (section 4.4)

None of these fix the engine bug, but each one widens the attack surface. All paths are relative to the repo root; `vendor/` is excluded.

### 5.1 Runtime limit overrides
- **`ini_set('memory_limit','-1')`, 13 active, all in filemst:**
  - Run at file scope whenever the class loads: `b2g_filemst/api/modules/bussrc/controllers/BussourceController.php:24`.
  - Inside actions: `BussourceController.php:750`; `api/modules/tms/controllers/TmsController.php:251`; `api/modules/bs/controllers/BizsearchController.php:470,2523,2548`; `api/modules/bs/components/Overallexport.php:24`; `api/modules/apr/controllers/ApprovalController.php:457`; `common/models/MemberregistrationmstTbl.php:2563,2810`; `api/modules/pms/components/Pmsproject.php:1795,1805`.
  - **On an unauthenticated upload path:** `common/components/Common.php:591` (`file_multiupload_temp`, used by drive/uploadmultiplimg).
- **Other memory raises:**
  - 2048M at file scope: `b2g_filemst/api/modules/pms/controllers/PmsController.php:31`, `api/modules/pm/controllers/ProfileController.php:78`.
  - 1024M: `api/modules/pms/components/ImportContract.php:37`, `api/modules/pms/models/CmstenderhdrTblQuery.php:4382,5610`.
  - `-1`: `b2g_profilemgmt/modules/apipm/controllers/BussourceController.php:259` (JWT-protected).
- **`set_time_limit(0)` / `-1`, about 54 in filemst** (27 of them in `BizsearchController.php`, e.g. `:469,:577,:749`). Others:
  - `b2g_gmarketapi/modules/services/controllers/ServicesController.php:1507` and `modules/products/controllers/ProductsController.php:2590` (SyncNibe*All). These are effectively unauthenticated, because `components/BaseApiController.php:138` accepts any `x.y.z`-shaped token.
  - `b2g_profilemgmt/modules/apipm/controllers/BridgesyncController.php:48-49` (600s, no auth).
  - All of these are capped at 300s by FPM, but they tie up one of only 8 workers.
- **No effect (typo):** `ini_set('maximum_execution_time', …)` ×10 in `b2g_filemst/api/modules/pms/models/Cmsrqprodservdtls*TblQuery.php` (`maximum_execution_time` isn't a real setting).
- **No `ini_set` of upload, post or session settings anywhere,** so nothing relies on `ini_set` or `.user.ini` for mitigation.

### 5.2 Upload endpoints (`$_FILES` / `UploadedFile`: 99 hits in 31 files)

**Gateway context:**
- Kong has no auth plugin (`docker/kong/kong.yml`, only `cors` and a `/healthz` `request-termination`).
- filemst API auth is effectively absent:
  - The authenticator is commented out at `b2g_filemst/api/modules/mst/controllers/MasterController.php:136-143`.
  - `common/components/TokenData.php:25-27` decodes JWTs without verifying them.
  - `api/common/BaseController.php:195-260` passes requests that carry no token.
- Executable docroots: nginx :81 `root /app/api` denies only `^/(config|models|modules|common|commonfunction)/` (`nginx.conf:70`), and any other `.php` file executes.

| Endpoint | Location | Auth | Note |
|---|---|---|---|
| **filemst `mws/ws/uploadimage`** | `b2g_filemst/api/modules/mws/controllers/WsController.php:6450-6462` | **None** (behaviors at `:78-93` is CORS only) | Saves `baseName.extension` from the client into `@webroot/api/assets/` (= `/app/api/api/assets/`, served and executed on :81). **Likely unauthenticated remote code execution.** PR 2. |
| filemst `drv/drive/upload`, `uploadcrop`, `uploadxlsx`, `uploadmultiplimg`, `datatoimage` | `b2g_filemst/api/modules/drv/controllers/DriveController.php:583-678` | None | Extension, MIME and size checks (`common/components/Common.php:301-377`), but the client picks `company_pk` and `user_pk`. Multi-upload sets `memory_limit -1`. |
| filemst `stkreg/register/consultationform` | `b2g_filemst/api/modules/stkreg/controllers/RegisterController.php:2779-2792` | None (`unset authenticator` at `:49`) | No allow-list. Target is outside the docroot. |
| filemst `mst/menu/uploadfiles`, `mst/eventupload/uploadfiles` | `MenuController.php:207-214`, `EventuploadController.php:253-260` (`api/modules/mst/controllers/`) | None | Raw `basename()` into `DOCUMENT_ROOT/bgiv3/uploads/`. This is remote code execution if that directory exists. |
| filemst `shp/eventmst/uploadsponser` | `b2g_filemst/api/modules/shp/controllers/EventmstController.php:330-336` | None | Trusts the client-supplied MIME type. Target is not served. |
| filemst `etws/etender/uplreceipt` | `b2g_filemst/api/modules/etws/controllers/EtenderController.php:1876-1888` | None (`beforeAction` skips filters) | PDF only, 1MB |
| filemst `tend/tender/tendercreate` | `b2g_filemst/api/modules/tend/controllers/TenderController.php:53-54` | None | Moves the file before the MIME check, and leaves it in place on failure |
| filemst backend `configuration/upload`, `templateimport` | `b2g_filemst/backend/controllers/ConfigurationController.php:193,227-231` | None (`globalAccess` commented out, `backend/config/web.php:140`) | Upload probably fatals on 8.2 before writing (undefined constants) |
| filemst `bizsearch/selectfile` | `b2g_filemst/api/modules/bs/controllers/BizsearchController.php:3531` | None | Loads a whole workbook with `toArray()`. A decompression bomb here can exhaust memory (DoS). |
| usermgmt `apium/calltoaction/sustainability`, `network` | `b2g_usermgmt/modules/apium/controllers/CalltoactionController.php:133-226` | None (no `behaviors()`) | Extension allow-list plus finfo MIME check |
| usermgmt `reg/registration/register` | `b2g_usermgmt/modules/reg/controllers/RegistrationController.php:692` (auth `except` at `:93`) | None (by design) | Extension, MIME and `getimagesize` checks (`:1178-1201`) |
| profilemgmt `apipm/taskinsert/synccompanydata`, `syncaccomplishmentdata` | `b2g_profilemgmt/modules/apipm/controllers/TaskinsertController.php:23,1169,1323` | None | Writes base64 files from Bridge with Bridge's extension (`:450-488`). Returns the Bridge token (`:1191`). |
| profilemgmt `apipm/profile/savecpbasicinfo` (logo) | `b2g_profilemgmt/models/MembercompanymstTbl.php:559-566` | JWT | Keeps the client's extension and writes into `@webroot/uploads/company_logo/` (:80 docroot). A logged-in user can get remote code execution. PR 2. |
| usermgmt `apium/filesync/receive` | `b2g_usermgmt/modules/apium/controllers/FilesyncController.php:61-144` | Shared secret | Multipart or base64 |

### 5.3 Large in-memory processing and handlers
- `php://input` appears 1,349 times in 146 files (about 1,250 in filemst), with no size check beyond `post_max_size`.
- filemst runs `getRawBody()` plus `json_decode` on every request before auth (`api/common/BaseController.php:202-203`, `api/modules/mst/controllers/MasterController.php:193-194`). A 64MB JSON body can hit the 256M limit, which is exactly the OOM path CVE-2026-109618 uses.
- `register_shutdown_function`: only `nibe_datamanager/c3.php:416`, a test harness guarded to loopback addresses.
- `set_error_handler` / `set_exception_handler`: none.
- `__destruct`: 5 instances, all trivial.

### 5.4 Bundled or third-party PHP (section 4.5)
None. There is no phpMyAdmin, Adminer, CMS or webmail outside `vendor/`.
- `index-test.php` exists in 20 services, all guarded to 127.0.0.1/::1.
- One stray demo file, `b2g_filemst/backend/assets/plugins/tablesaw-master/demo/issues/charset-test-issue-106.php`, sits outside the docroot.

---

## 6. Proposed changes (NOT applied yet)

### 6.1 PR 1: patch and runtime hardening

Confirm the tag `php:8.2.35-fpm-alpine` exists on Docker Hub before applying.

```diff
--- a/docker/php-nginx/Dockerfile
+++ b/docker/php-nginx/Dockerfile
-# PHP 8.2 matches the XAMPP 8.2.4 the codebase runs on today. Only nibe_datamanager
+# PHP 8.2, pinned to a patch release: a floating tag let ECR's cached :8.2 base fall behind
+# security fixes (CVE-2026-109618/109619 need >= 8.2.35). Only nibe_datamanager
 ...
-FROM php:8.2-fpm-alpine
+FROM php:8.2.35-fpm-alpine
```

```diff
--- a/docker/php-nginx/php.ini
+++ b/docker/php-nginx/php.ini
 expose_php = Off
 date.timezone = Asia/Muscat
+
+; CVE-2026-109619 mitigation (CERT-EU SA 2026-018). Nothing uses upload progress.
+; Must be set here (not ini_set/.user.ini) to be effective.
+session.upload_progress.enabled = 0
```

```diff
--- a/docker/php-nginx/nginx.conf
+++ b/docker/php-nginx/nginx.conf
-    client_max_body_size 128M;
+    # Match php.ini post_max_size; anything larger PHP rejected anyway.
+    client_max_body_size 64M;
```

```diff
--- a/docker/kong/Dockerfile
+++ b/docker/kong/Dockerfile
 ENV KONG_ADMIN_ERROR_LOG=/dev/stderr
+# Cap request bodies at the gateway (backend nginx/PHP allow 64M).
+ENV KONG_NGINX_PROXY_CLIENT_MAX_BODY_SIZE=64m
```

```diff
--- a/docker-compose.yml
+++ b/docker-compose.yml
-#   * ONE shared backend image (b2g/php-nginx:7.4) for all 9 PHP services; code bind-mounted.
+#   * ONE shared backend image (b2g/php-nginx:8.2) for all PHP services; code bind-mounted.
 ...
-      KONG_NGINX_PROXY_CLIENT_MAX_BODY_SIZE: 128m
+      KONG_NGINX_PROXY_CLIENT_MAX_BODY_SIZE: 64m
```

```diff
--- a/ci/pipeline.py   (build_base)
+++ b/ci/pipeline.py
     run(["docker", "run", "--rm", "%s:%s" % (uri, b["tag"]),
-         "sh", "-c", "mkdir -p /run/nginx && nginx -t"])
+         "sh", "-c", "mkdir -p /run/nginx && nginx -t && php -v && "
+                     "php -i | grep -E '^(memory_limit|post_max_size|session.upload_progress.enabled) '"],
+        capture=False)
```

No CI or buildspec changes are needed. Editing `docker/php-nginx/` makes `cmd_plan` set `base: true` and rebuild all 12 backends.

### 6.2 PR 2: upload hardening (separate and urgent)
- `docker/php-nginx/nginx.conf`: in each server block, before `location ~ \.php$`, add
  `location ~* ^/(api/assets|assets|uploads)/.*\.php$ { deny all; }`
- `WsController::actionUploadimage`: add an authenticator, an image extension and MIME allow-list (reuse `Common.php:301-377`), and a generated filename.
- `MembercompanymstTbl.php:559-566`: add an extension allow-list.
- The remaining unauthenticated endpoints in 5.2 are follow-ups for the module owners.

### 6.3 Track 2: PHP 8.5 migration before 8.2 goes EOL (31 Dec 2026)
- **Our own code:** no hard breaks found.
- **Silent deprecations:** about 482 `curl_close`, 46 old casts and 13 implicitly nullable params.
- **Behaviour change:** 8.4's `round()` changed at .5 boundaries. Before/after tests are needed for billed and stored values:
  - `b2g_usermgmt/modules/subm/controllers/AdvisoryPaymentController.php:274`
  - `SubscriptionManagementController.php:528`
  - `b2g_filemst/api/modules/pms/components/InvoiceService.php:102`
  - digitall emissions
  - `b2g_confmgmt/.../ConfenggController.php:779`
- **`composer install` fails on 8.5:**
  - gmarketapi and subpayment: `ezyang/htmlpurifier` 4.18 needs to go to 4.19.
  - filemst: `htmlpurifier` 4.16 and `mpdf` dev-master need to go to 8.3.1.
  - filemst/usermgmt: `lcobucci/jwt` 3.3.3, `bacon/bacon-qr-code` 1.0.3 and `trntv/sitemaped` 0.1.2 are hidden by `--ignore-platform-req=php`.
  - carbon: raise the `platform.php` pin.
- **Still to verify (needs Docker):**
  - an 8.5 `php -l` of every file
  - the Imagick build on `php:8.5-fpm-alpine`
  - filemst runtime on yii2 2.0.48.1, phpspreadsheet 1.19 and guzzle 6.5.8
- **Also on 8.5:**
  - set `fatal_error_backtraces = 0`
  - remove `E_STRICT` (`php.ini:12`; front controllers `b2g_usermgmt/web/index.php:15,18`, `b2g_profilemgmt/web/index.php:15,21`, `b2g_confmgmt/web/index.php:12`, `b2g_filemst/api/index.php:52`)

---

## 7. Manual follow-up checklist

- [ ] **1. Baseline the running version now.** For every backend service in `b2g-prod` and `b2g-demo`, run this via ECS Exec:
      `aws ecs execute-command --cluster b2g-prod --task <task> --container <c> --interactive --command "sh -c 'php -v; php -i | grep -E \"memory_limit|post_max_size|upload_progress|Server API\"'"`
      Record the results in this report.
- [ ] **2. Rebuild and redeploy.**
  - [ ] Commit PR 1 and push to `prelive`. The `b2g-cd-demo` log should show `build-base` and `php -v` = 8.2.35.
  - [ ] Confirm all 12 demo services are on the new sha and ECS Exec shows 8.2.35.
  - [ ] Open a PR to `main` and merge locally with `--no-ff`.
  - [ ] Repeat the check in prod.
- [ ] **3. Restart workers.** An ECS deploy replaces every task, so php-fpm restarts with the new config. No separate restart is needed. Confirm there are no old tasks left (`aws ecs list-tasks`).
- [ ] **4. Look for signs of compromise**, from 2026-10-15 onward:
  - CloudWatch Logs Insights on `/ecs/b2g-prod/*` and `/ecs/b2g-demo/*`:
    - requests for `*.php` other than `index.php`
    - POSTs to `mws/ws/uploadimage`, `menu/uploadfiles`, `eventupload/uploadfiles`
    - php-fpm `exited on signal 11` / `SIGSEGV` (crashes can mean exploit attempts)
    - unusual outbound connections or errors from `curl`
  - S3 bucket (`S3_BUCKET`): unexpected `.php` or `.phtml` objects, and recently written objects under `comp_*/user_*`.
  - DB: new admin or RBAC users, and changed passwords on privileged accounts.
  - CloudTrail: unusual API calls made with the ECS task role.
  - If anything looks wrong, rotate the DB, S3 and Bridge/Thawani credentials. They're readable from the container env (`clear_env = no`).
  - Redeploying wipes any web shells written to container disk. It doesn't clean S3, the DB, or credentials that were already stolen.
- [ ] **5. Contingency.** If 8.2.35 can't ship today, consider scaling the filemst service to 0 or blocking its routes in Kong. It has the widest unauthenticated surface. This is the official NCSC, CERT-EU and NSA advice for internet-facing systems that can't be patched quickly.
- [ ] **6. Follow-ups:**
  - add AWS WAF and HTTPS to the ALB
  - make ECR sha tags immutable
  - delete the legacy 7.4-apache Compose and Vagrant files
  - schedule Track 2 (8.5)
