W3 pm2 jlist credential exposure — verify-first investigation (#3905 / #4336)
Status: investigation complete — verify-first no-op (no rotation, no force-recreate)
Owner: sys-admin / infra-task worker on bms-4 (bms-1 SSH); SOPS writes delegate to secret-manager.
Applies to: W3 (v32-prod-*) containers on bms-1 (94.23.26.113). Same method applies to the rest of the W3/W4 rotation batch — the W4 V42_* JWT set (#3216) was verified next in w4-v42-jwt-exposure-verify-first.md (#4339, also a no-op).
This documents both the method (how to verify-first a “creds exposed via pm2 jlist” report without ever printing a secret value) and the 2026-07-19 finding for v32-prod-reso, which corrected the issue’s premise.
1. What “exposed via pm2 jlist” actually is here
v32-prod-reso is a Docker container (compose project p24-v-32, service reso, dir
/home/gitlab-runner/builds/<hash>/0/pinbox24/p24-v-3.2), not a host pm2 process. Inside it:
docker-compose (env_file:) → container Config.Env (41 vars incl. secrets)
→ CMD: pm2-runtime start ecosystem.config.js
→ pm2 records the inherited process environment in pm2_env
→ `docker exec v32-prod-reso pm2 jlist` dumps it
So the credentials appear in pm2 jlist because pm2-runtime records the inherited process
environment — the standard way pm2 works. Reaching it requires root / docker access on bms-1
(the top trust boundary; equivalent to /proc/<pid>/environ or docker inspect).
2. Verify-first method (never prints a value)
All commands emit key names + sha256[:12] digests only. Never cat/echo/docker inspect
raw env. Raw JSON is piped into a python3 running on bms-1 (or on the SOPS host); only
names/digests cross the SSH boundary.
- Enumerate container cred key names:
docker exec v32-prod-reso pm2 jlist | python3 -c '<extract [..] json, print pm2_env keys>'(pm2 prepends a non-JSON line — slice from first[to last]beforejson.loads.) - Digest each container cred:
hashlib.sha256(value)[:12]inside the same remote python. - Digest the SOPS source of truth on the SOPS host:
sops -d --input-type dotenv --output-type dotenv secrets/pinbox24-w3.env.sops | python3 …The container strips theV32_prefix, so containerDB_URI↔ SOPSV32_DB_URI, etc. - Compare digests. Match ⇒ container runs the SOPS-managed value (healthy, wired). Mismatch ⇒ drift — investigate whether live or SOPS is authoritative before any write.
- Check external egress: is PM2 Plus/keymetrics linked? (
km_linkset + an interactor/agent process running). Ifkm_link=Falseand no agent process, the env is not streaming off-box.
A key that is SOPS-wired + digest-matches + not externally leaked is a healthy no-op — do not rotate it (same rule proven by #4169 stale-deploy clobber and #4333 LogDNA no-op).
3. Finding — 2026-07-19 (v32-prod-reso)
- ~22 credential env vars exposed via
pm2 jlist(Mongo/DB URIs,JWT_TOKEN_SECRET,LOGDNA_KEY,s3Bucket_api_*,onesignal_*,twilio*,jabber_*,monitoringToken,MONGODB_W3_APP_PASSWORD,REDIS_*, …). - 20/22 digests exactly match
secrets/pinbox24-w3.env.sops(V32_*). Container is correctly SOPS-wired. S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEYare container-only aliases whose digests equal the SOPS-manageds3Bucket_api_accessKeyId/s3Bucket_api_secretAccessKey(same value, second name).- One drift:
REDIS_PASSWORD— container value = local-redis password (matchesREDIS_LOCAL_PASSWORD); SOPSV32_REDIS_PASSWORDstill holds the decommissioned OVH DBaaS Redis (kr40258-001, #3712) value. Stale SOPS, not used live (W3 is on localredis-v32). Harmless — asecret-managerhousekeeping reconcile, not a breach. - No external egress: PM2 Plus not linked (
km_link=False, no interactor/keymetrics agent). - Endpoints healthy:
v32-prod(api),v32-prod-socket(socket),v32-prod-reso(reso) all return HTTP 200 (local probe on bms-1).
Verdict: 0 genuinely-compromised keys → verify-first no-op. No rotation. No force-recreate.
4. The issue’s proposed fix does not apply
The tracking issue proposed “adjust the pm2 ecosystem config so jlist no longer dumps secrets
(env sourced from file, not inline args).” Empirically false premise:
/app/ecosystem.config.jsis 14 lines with noenv:block and no secret literals (onlyname/interpreter/script/log-path refs to non-secretprocess.envvars).docker-compose.ymlsources theresoenv viaenv_file:(5 directives) — no inline literals.
There is no inline env to remove. Secrets are in pm2 jlist purely via process-env inheritance.
No pm2-ecosystem or compose edit removes them while the Node app reads process.env.
5. The only real fix — and why it’s a human/dev-coder decision
To keep secrets out of pm2 jlist / /proc/<pid>/environ, the W3 backend would have to read
secrets from a mounted file at runtime instead of process.env — a code change in GitLab
pinbox24/p24-v-3.2, shipped as push + MR (human merge) and a coordinated redeploy of a
2022-era prod app. Marginal security benefit (the file is still root-readable; exposure is already
bounded by the root/docker trust boundary and confirmed non-egressing), high blast radius. Do not
auto-apply — surface as a human risk-acceptance decision.
6. Recommended follow-ups (not auto-dispatched)
- secret-manager (low): reconcile/remove stale SOPS
V32_REDIS_PASSWORD(points at decommissioned OVH DBaaSkr40258-001; live uses localredis-v32). Tie to #3712. - human / dev-coder (optional hardening): decide whether the §5 secrets-from-file app change is worth the redeploy risk, or accept the exposure as defense-in-depth given no external egress.
- Related known log-leak class (separate): Mongoose logs full Mongo URI on startup → #2397.
Related
docs/w3-w4-stack-operations.md— permission matrix + escalationdocs/playbooks/w3-w4-rotation-orchestration.md— rotation SOPdocs/playbooks/pinbox24-w3-operations.md— W3 ops runbook- #4169 (stale-deploy clobber, not breach) · #4333 (LogDNA no-op) — same verify-first outcome class