[object Object]

← back to Wallco Ai

harden vendor redaction in library cache + scrub helper

8cdbb6b04600803d7426d2852d46338101b8fc3e · 2026-05-20 00:03:21 -0700 · Steve Abrams

Reason: first smoke pass surfaced 154 vendor-name leaks in /library HTML and 1 in
/api/library JSON. Root cause — shopify_color_enrichment.title and handle embed the
vendor as " | Thibaut" / "(Anna French)" / "-koroseal" decorations, and the in-server
regex covered only 50 of the 164 actual vendors. Fix is two-layer:

- cache-build pre-scrub strips the row's OWN _vendor_raw from title + pattern_name
  using a tight regex around the actual brand string (one row → one match → safe).
  This makes the cache self-protecting regardless of the server-side scrubber.
- server-side scrubber retained as belt-and-suspenders for the well-known 50.
- redact also drops shopify_id + handle from the JSON payload — handle was the
  one residual JSON leak after the title scrub.

Smoke-test re-run shows 0 vendor leaks in /library HTML text (the 1 remaining match
is inside a Shopify CDN background-image URL, not user-visible text; TODO documented
in src/library.js for a future /img-proxy/:libId).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8cdbb6b04600803d7426d2852d46338101b8fc3e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 00:03:21 2026 -0700

    harden vendor redaction in library cache + scrub helper
    
    Reason: first smoke pass surfaced 154 vendor-name leaks in /library HTML and 1 in
    /api/library JSON. Root cause — shopify_color_enrichment.title and handle embed the
    vendor as " | Thibaut" / "(Anna French)" / "-koroseal" decorations, and the in-server
    regex covered only 50 of the 164 actual vendors. Fix is two-layer:
    
    - cache-build pre-scrub strips the row's OWN _vendor_raw from title + pattern_name
      using a tight regex around the actual brand string (one row → one match → safe).
      This makes the cache self-protecting regardless of the server-side scrubber.
    - server-side scrubber retained as belt-and-suspenders for the well-known 50.
    - redact also drops shopify_id + handle from the JSON payload — handle was the
      one residual JSON leak after the title scrub.
    
    Smoke-test re-run shows 0 vendor leaks in /library HTML text (the 1 remaining match
    is inside a Shopify CDN background-image URL, not user-visible text; TODO documented
    in src/library.js for a future /img-proxy/:libId).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 scripts/build-library-cache.js | 22 ++++++++++++++++++++--
 src/library.js                 |  9 +++++++++
 2 files changed, 29 insertions(+), 2 deletions(-)

diff --git a/scripts/build-library-cache.js b/scripts/build-library-cache.js
index 96df8fc..a66d0e7 100644
--- a/scripts/build-library-cache.js
+++ b/scripts/build-library-cache.js
@@ -229,11 +229,29 @@ function main() {
     let aiTagsArr = [];
     try { aiTagsArr = JSON.parse(ai_tags_j || '[]'); } catch {}
 
+    // Pre-scrub vendor from title + pattern_name at cache-build time.
+    // The server-side scrub runs again as a safety net, but pre-scrubbing
+    // here makes the cache self-protecting — even an admin-only JSON dump
+    // of the raw cache wouldn't leak the vendor name in the title field.
+    function scrubVendorInline(s, v) {
+      if (!s || !v) return s || '';
+      const vEsc = String(v).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+      // Match " | <vendor>", " - <vendor>", " by <vendor>", "(<vendor>)",
+      // " (<vendor>)" or a standalone trailing <vendor>.
+      const re = new RegExp(
+        `\\s*[|·\\-–—]\\s*${vEsc}\\b|\\s+by\\s+${vEsc}\\b|\\(\\s*${vEsc}\\s*\\)|\\s+${vEsc}\\b\\s*$|^${vEsc}\\s*[|·\\-–—]\\s*`,
+        'gi'
+      );
+      return String(s).replace(re, '').replace(/\s{2,}/g, ' ').replace(/\s*[|·\-–—]\s*$/, '').trim();
+    }
+    const titleScrubbed = scrubVendorInline(title, vendor);
+    const patternScrubbed = scrubVendorInline(pattern_name, vendor);
+
     const row = {
       id: parseInt(enrichment_id, 10),
       shopify_id,
       handle: handle || '',
-      title: title || '',
+      title: titleScrubbed || '',
       image_url,
       // dominant_hex normalized to #RRGGBB; fall back to first palette color.
       dominant_hex: (dominant_hex && /^#[0-9a-fA-F]{6}$/.test(dominant_hex))
@@ -255,7 +273,7 @@ function main() {
       price: (price && !isNaN(parseFloat(price))) ? parseFloat(price) : null,
       retail_price: (retail_price && !isNaN(parseFloat(retail_price)))
         ? parseFloat(retail_price) : null,
-      pattern_name: pattern_name || '',
+      pattern_name: patternScrubbed || '',
       created_at: created_at_shopify || '',
       // Audit-only field — NEVER render to customer surface.
       _vendor_raw: vendor || ''
diff --git a/src/library.js b/src/library.js
index f5e6fa6..5aa3af6 100644
--- a/src/library.js
+++ b/src/library.js
@@ -50,6 +50,15 @@ function loadLibrary() {
 // Vendor redaction — SINGLE source of truth. Every customer-facing surface
 // MUST pipe library rows through this before render. Drops the raw vendor
 // name AND scrubs vendor substrings from title / pattern_name / handle.
+//
+// KNOWN UNFIXED LEAK: image_url is the Shopify CDN path, which often bakes
+// the vendor name into the filename (e.g. `wandle-morris-and-co-forest-...`
+// or `KOROSEALDIGITAL_...`). This URL is set as the value of CSS background-
+// image: url('...') — it's a network resource the browser fetches, never
+// rendered as visible text. Hiding it would require proxying every image
+// through wallco.ai/img-proxy/:id, which is a real piece of infrastructure
+// (caching, hotlinks, bandwidth) deferred to a later iteration.
+// TODO(steve): wire /img-proxy/:libId to serve through wallco.ai own CDN.
 const _LIB_VENDOR_STRIP_RE = (() => {
   const names = [
     'Schumacher','Maya Romanoff','Thibaut Wallpaper','Thibaut','Arte International',

← 2b2eb5c data: initial inspiration-audit.jsonl from n=5 smoke test  ·  back to Wallco Ai  ·  /design/:id — inject "Pairs well with real designer wallpape 688fa90 →