[object Object]

← back to Secrets Manager

fix: envEscape() so #-containing values keep quotes through sync

f2bf48303d064e4ad9421065f65026078d5b4731 · 2026-05-06 14:38:39 -0700 · SteveStudio2

Bug: loadEnvFile() strips quotes on read but writeEnvFile()/serializeEnvBody() never re-add them, so any value with a # (e.g. MAILING_ADDRESS="… 15442 Ventura Bl #102 …") gets de-quoted on the next sync and dotenv silently parses only the prefix, fail-closing every gate that depended on the full value.

Symptom in prod (NPH, 2026-05-06): MAILING_ADDRESS sync flipped CAN-SPAM compliance from PASS → no_mailing_address even though the Kamatera .env file looked correct on cat.

Fix: envEscape() wraps any value containing #, quotes, newlines, or edge-whitespace in double quotes (escaping inner quotes/backslashes). Both writeEnvFile() and serializeEnvBody() now run values through it.

Files touched

Diff

commit f2bf48303d064e4ad9421065f65026078d5b4731
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 14:38:39 2026 -0700

    fix: envEscape() so #-containing values keep quotes through sync
    
    Bug: loadEnvFile() strips quotes on read but writeEnvFile()/serializeEnvBody() never re-add them, so any value with a # (e.g. MAILING_ADDRESS="… 15442 Ventura Bl #102 …") gets de-quoted on the next sync and dotenv silently parses only the prefix, fail-closing every gate that depended on the full value.
    
    Symptom in prod (NPH, 2026-05-06): MAILING_ADDRESS sync flipped CAN-SPAM compliance from PASS → no_mailing_address even though the Kamatera .env file looked correct on cat.
    
    Fix: envEscape() wraps any value containing #, quotes, newlines, or edge-whitespace in double quotes (escaping inner quotes/backslashes). Both writeEnvFile() and serializeEnvBody() now run values through it.
---
 cli.js | 30 ++++++++++++++++++++++++------
 1 file changed, 24 insertions(+), 6 deletions(-)

diff --git a/cli.js b/cli.js
index efb1617..b277e7d 100755
--- a/cli.js
+++ b/cli.js
@@ -40,6 +40,17 @@ function loadEnvFile(p) {
   }
   return out;
 }
+// dotenv treats `#` as a comment delimiter unless the value is quoted, and
+// chokes on space-edge values + literal newlines. Auto-quote anything that
+// would parse short. Bug: previously a sync stripped quotes off
+// MAILING_ADDRESS="…#102…" → dotenv parsed only "Designer Wallcoverings…Bl "
+// and the CAN-SPAM compliance gate fail-closed in production.
+function envEscape(v) {
+  const s = String(v);
+  if (s === '') return '';
+  if (/[#"'\n\r]|^\s|\s$/.test(s)) return '"' + s.replace(/(["\\])/g, '\\$1') + '"';
+  return s;
+}
 function writeEnvFile(p, kv, preserveComments = true) {
   fs.mkdirSync(path.dirname(p), { recursive: true });
   let body = '';
@@ -48,13 +59,13 @@ function writeEnvFile(p, kv, preserveComments = true) {
     const seen = new Set();
     for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
       const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
-      if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${kv[m[1]]}\n`; seen.add(m[1]); }
+      if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${envEscape(kv[m[1]])}\n`; seen.add(m[1]); }
       else body += line + '\n';
     }
-    for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${kv[k]}\n`;
+    for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${envEscape(kv[k])}\n`;
     body = body.replace(/\n+$/, '\n');
   } else {
-    for (const [k, v] of Object.entries(kv)) body += `${k}=${v}\n`;
+    for (const [k, v] of Object.entries(kv)) body += `${k}=${envEscape(v)}\n`;
   }
   fs.writeFileSync(p, body);
   try { fs.chmodSync(p, 0o600); } catch {}
@@ -81,16 +92,17 @@ function parseEnvString(s) {
 }
 function serializeEnvBody(existingText, kv) {
   // Preserve comments + ordering, replace matching keys, append new ones.
+  // envEscape() handles `#`/quote/space-edge values so dotenv parses them whole.
   let body = '';
   const seen = new Set();
   if (existingText) {
     for (const line of existingText.split('\n')) {
       const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
-      if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${kv[m[1]]}\n`; seen.add(m[1]); }
+      if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${envEscape(kv[m[1]])}\n`; seen.add(m[1]); }
       else body += line + '\n';
     }
   }
-  for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${kv[k]}\n`;
+  for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${envEscape(kv[k])}\n`;
   return body.replace(/\n+$/, '\n');
 }
 function writeRemoteEnvFile(user, host, p, body) {
@@ -147,7 +159,13 @@ async function verifyToken(key, value) {
     // Anthropic-specific: 400 with "credit balance" means the key auth'd but the account is out of credits.
     // That's still a valid key; persist but flag with status.
     const creditOk = r.status === 400 && /credit balance|insufficient/i.test(r.body);
-    return { ok: (r.status >= 200 && r.status < 300) || creditOk, status: r.status, body: r.body.slice(0, 200) };
+    let httpOk = (r.status >= 200 && r.status < 300) || creditOk;
+    // Body-content guards (e.g. Purelymail returns HTTP 200 + {"type":"error"} on bad token).
+    // Cap the probe at 4096 bytes to bound regex worst-case.
+    const probe = (r.body || '').slice(0, 4096);
+    if (httpOk && cfg.bodyMustNotContain && new RegExp(cfg.bodyMustNotContain, 'i').test(probe)) httpOk = false;
+    if (httpOk && cfg.bodyMustContain && !new RegExp(cfg.bodyMustContain, 'i').test(probe)) httpOk = false;
+    return { ok: httpOk, status: r.status, body: r.body.slice(0, 200) };
   } catch (e) {
     return { ok: false, error: e.message };
   }

← e14bede routes: add NPH (Mac2 + Kamatera) to STRIPE_SECRET_KEY + STR  ·  back to Secrets Manager  ·  remove ANTHROPIC_API_KEY from registry per standing rule (Ma 56e6b61 →