From 5ef935b253bca0c86f5984328e08dd14639155b4 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Sun, 7 Jun 2026 15:36:22 -0400 Subject: [PATCH] Fix: scarcity/countdown synonym evasion (#203) Addresses audit item #21. Adds precise synonym coverage so a malicious page can't bypass scarcity- and countdown-redaction with common verbiage swaps. Each addition is anchored on a number, a fixed retail idiom, or a definite expiry verb to keep false-positive risk flat. scarcity-redact: - "Just N left/remaining/in stock/available" (synonym of "only N") - "N items/units/pieces left/remaining" - "While supplies last" / "while stocks last" - "Selling/going quickly" - "Flying off the shelves" / "going off shelves" - "N added to cart/basket/bag/wishlist" countdown-timer-redact: - URGENCY_UNIT_PATTERN: "to claim" / "to save" alongside "left/remaining/to go/until" - New EXPIRY_LEAD_PATTERN: "ends/expires/closes in N " Candidate-only; decrement gate still required, so a static "Expires in 30 days" badge is never replaced. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/countdown-timer-redact.test.ts | 37 +++++++++++++++++++ .../rules/__tests__/scarcity-redact.test.ts | 20 ++++++++++ extension/src/rules/countdown-timer-redact.ts | 11 +++++- extension/src/rules/scarcity-redact.ts | 15 ++++++-- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/extension/src/rules/__tests__/countdown-timer-redact.test.ts b/extension/src/rules/__tests__/countdown-timer-redact.test.ts index ee73cc7..1231969 100644 --- a/extension/src/rules/__tests__/countdown-timer-redact.test.ts +++ b/extension/src/rules/__tests__/countdown-timer-redact.test.ts @@ -50,6 +50,18 @@ describe("matchesTimerPattern", () => { expect(matchesTimerPattern("15 minutes remaining")).toBe(true); }); + it("matches urgency suffix synonyms (#203 item 21)", () => { + expect(matchesTimerPattern("5 hours to claim")).toBe(true); + expect(matchesTimerPattern("45 minutes to save")).toBe(true); + }); + + it("matches expiry-lead phrasings (#203 item 21)", () => { + expect(matchesTimerPattern("Sale ends in 3h")).toBe(true); + expect(matchesTimerPattern("Offer expires in 45 minutes")).toBe(true); + expect(matchesTimerPattern("Closes in 2d")).toBe(true); + expect(matchesTimerPattern("Ends in 30 seconds")).toBe(true); + }); + it("does not match arbitrary numbers", () => { expect(matchesTimerPattern("Item #123456")).toBe(false); expect(matchesTimerPattern("Save 30%")).toBe(false); @@ -125,6 +137,31 @@ describe("countdownTimerRedactRule", () => { expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).not.toBeNull(); }); + it("hides an expiry-lead countdown that decreased (#203 item 21)", () => { + document.body.innerHTML = `
Sale ends in 2h 15m
`; + countdownTimerRedactRule.apply(document.body); + + const timer = document.querySelector("#t"); + if (timer) { + timer.textContent = "Sale ends in 2h 14m"; + } + + jest.advanceTimersByTime(SNAPSHOT_DELAY_MS); + + expect(document.querySelector("#t")).toBeNull(); + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).not.toBeNull(); + }); + + it("leaves a static expiry-lead badge alone (decrement gate)", () => { + document.body.innerHTML = `
Expires in 30 days
`; + countdownTimerRedactRule.apply(document.body); + + jest.advanceTimersByTime(SNAPSHOT_DELAY_MS); + + expect(document.querySelector("#t")).not.toBeNull(); + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + }); + it("leaves static clock-shaped text alone", () => { document.body.innerHTML = `Posted at 12:34`; countdownTimerRedactRule.apply(document.body); diff --git a/extension/src/rules/__tests__/scarcity-redact.test.ts b/extension/src/rules/__tests__/scarcity-redact.test.ts index efd7f55..2bcb365 100644 --- a/extension/src/rules/__tests__/scarcity-redact.test.ts +++ b/extension/src/rules/__tests__/scarcity-redact.test.ts @@ -46,6 +46,21 @@ describe("matchesScarcityPattern", () => { "8 purchased in the past day", "5 have it in their cart", "3 in carts", + // Synonym evasion bypasses (#203 item 21). + "Just 3 left", + "Just 2 in stock", + "3 items left", + "5 units remaining", + "2 pieces left", + "While supplies last", + "While stocks last", + "Selling quickly", + "Going quickly", + "Flying off the shelves", + "Going off shelves", + "12 added to cart in the last hour", + "8 shoppers added this to their bag", + "5 others added to wishlist", ])("matches urgency phrasing: %s", (text) => { expect(matchesScarcityPattern(text)).toBe(true); }); @@ -67,6 +82,11 @@ describe("matchesScarcityPattern", () => { "Ships in 2 days", "Save 30%", "Free shipping", + // "Just" alone is not an urgency signal. + "Just released", + "Just for you", + // Bare "added to cart" without a count is a UI confirmation, not scarcity. + "Added to cart", ])("leaves non-urgency text alone: %s", (text) => { expect(matchesScarcityPattern(text)).toBe(false); }); diff --git a/extension/src/rules/countdown-timer-redact.ts b/extension/src/rules/countdown-timer-redact.ts index d733ece..a77fcfd 100644 --- a/extension/src/rules/countdown-timer-redact.ts +++ b/extension/src/rules/countdown-timer-redact.ts @@ -36,13 +36,20 @@ const COLON_PATTERN = /(?