From 684413f5fe1e5504bee921f42c3d2687544ee792 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:08:02 +0000 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20username/hostname=20not=20substituti?= =?UTF-8?q?ng=20=E2=80=94=20injector=20wasn't=20merging=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the multi-profile migration, ss_identity changed from a flat object { names, emails, ... } to { profiles: [...] }. The injector was passing the raw profiles wrapper to the content script, which expected the flat format. Now the injector merges active profiles into a flat identity object before injecting into the page world, and also merges on storage change events. Also handles legacy format (pre-profile data) for backward compatibility. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- src/content/injector.js | 50 ++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/src/content/injector.js b/src/content/injector.js index f42a1c1..048e93f 100644 --- a/src/content/injector.js +++ b/src/content/injector.js @@ -15,6 +15,38 @@ if (window.__silentSendInjected) return; window.__silentSendInjected = true; + // Merge active profiles into flat identity object + function mergeProfiles(data) { + const profiles = data?.profiles || []; + const active = profiles.filter(p => p.active); + + if (active.length === 0) { + // Legacy format: data IS the flat identity (pre-profile migration) + if (data && (data.names || data.emails || data.usernames)) return data; + return { emails: [], names: [], usernames: [], hostnames: [], phones: [], + catchAllEmail: '', emailDomains: [], + enabled: { emails: true, names: true, usernames: true, phones: true, paths: true } }; + } + + const merged = { + emails: [], names: [], usernames: [], hostnames: [], phones: [], + catchAllEmail: '', emailDomains: [], + enabled: { emails: true, names: true, usernames: true, phones: true, paths: true }, + }; + + for (const p of active) { + merged.emails.push(...(p.emails || [])); + merged.names.push(...(p.names || [])); + merged.usernames.push(...(p.usernames || [])); + merged.hostnames.push(...(p.hostnames || [])); + merged.phones.push(...(p.phones || [])); + if (p.catchAllEmail && !merged.catchAllEmail) merged.catchAllEmail = p.catchAllEmail; + merged.emailDomains.push(...(p.emailDomains || [])); + } + + return merged; + } + // Cross-browser API const api = typeof browser !== 'undefined' && browser.runtime @@ -27,9 +59,12 @@ async function init() { const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']); const mappings = result.ss_mappings || []; - const identity = result.ss_identity || {}; const settings = result.ss_settings || { enabled: true }; + // Merge active profiles into a flat identity object for the content script + const identityData = result.ss_identity || {}; + const identity = mergeProfiles(identityData); + // Inject the main interception script into the page's world const script = document.createElement('script'); script.setAttribute('data-ss-config', JSON.stringify({ mappings, identity, settings })); @@ -71,15 +106,14 @@ } }); - // Forward storage changes to the page script + // Forward storage changes to the page script (merge profiles before sending) api.storage.onChanged.addListener((changes) => { if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) { - window.postMessage({ - type: 'ss:config-updated', - mappings: changes.ss_mappings?.newValue, - identity: changes.ss_identity?.newValue, - settings: changes.ss_settings?.newValue, - }, '*'); + const msg = { type: 'ss:config-updated' }; + if (changes.ss_mappings) msg.mappings = changes.ss_mappings.newValue; + if (changes.ss_identity) msg.identity = mergeProfiles(changes.ss_identity.newValue); + if (changes.ss_settings) msg.settings = changes.ss_settings.newValue; + window.postMessage(msg, '*'); } }); From 7271027c0280ad8701c8b8f8f12a6ba852e05445 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:16:44 +0000 Subject: [PATCH 2/9] feat: underline revealed text in AI responses Revealed values are now wrapped in with a blue underline and subtle blue background, making it obvious which parts are your real data vs what the AI said. Hover shows tooltip "Substituted value: [fake]" so you can see what the AI actually received. Un-reveal properly removes the spans and normalizes text nodes. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- src/content/content.css | 5 ++- src/content/content.js | 89 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/content/content.css b/src/content/content.css index 1d2da16..b5db103 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -16,9 +16,10 @@ /* Revealed text styling (when reveal mode shows real values in responses) */ .ss-revealed { background: rgba(59, 130, 246, 0.1); - border-bottom: 1.5px dashed rgba(59, 130, 246, 0.5); + border-bottom: 2px solid rgba(59, 130, 246, 0.6); border-radius: 2px; - padding: 0 1px; + padding: 0 2px; + cursor: help; } /* Floating reveal mode indicator */ diff --git a/src/content/content.js b/src/content/content.js index 7c3e0ef..c8eedda 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -630,37 +630,109 @@ function revealInElement(el) { if (SKIP_REVEAL_TAGS.has(el.tagName)) return; - // Skip our own badge if (el.classList?.contains('ss-reveal-badge')) return; + if (el.classList?.contains('ss-revealed')) return; // skip our own spans const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; if (parent?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT; + if (parent?.classList?.contains('ss-revealed')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); + // Collect nodes first (modifying DOM during walk breaks the walker) + const nodes = []; let textNode; while ((textNode = walker.nextNode())) { - const text = textNode.textContent; + nodes.push(textNode); + } + + if (!_revealPairsCache) _revealPairsCache = buildRevealPairs(); + const pairs = _revealPairsCache; + + for (const node of nodes) { + const text = node.textContent; if (!text || text.length < MIN_STRING_LENGTH) continue; - if (!originalTexts.has(textNode)) { - originalTexts.set(textNode, text); + // Save original + if (!originalTexts.has(node)) { + originalTexts.set(node, text); } - const revealed = revealText(text); - if (revealed !== text) { - textNode.textContent = revealed; + // Find all substituted values and their positions + const segments = []; + let remaining = text; + let offset = 0; + + for (const p of pairs) { + const escaped = esc(p.from); + const regex = new RegExp(escaped, p.caseSensitive ? 'gi' : 'gi'); + let match; + while ((match = regex.exec(text)) !== null) { + segments.push({ + start: match.index, + end: match.index + match[0].length, + original: match[0], + revealed: p.to, + }); + } } + + if (segments.length === 0) continue; + + // Sort by position, deduplicate overlaps + segments.sort((a, b) => a.start - b.start); + const deduped = []; + let lastEnd = -1; + for (const s of segments) { + if (s.start >= lastEnd) { + deduped.push(s); + lastEnd = s.end; + } + } + + // Build fragment: text + highlighted spans + const frag = document.createDocumentFragment(); + let pos = 0; + + for (const s of deduped) { + // Text before this match + if (s.start > pos) { + frag.appendChild(document.createTextNode(text.slice(pos, s.start))); + } + // Highlighted revealed value + const span = document.createElement('span'); + span.className = 'ss-revealed'; + span.textContent = s.revealed; + span.title = 'Substituted value: ' + s.original; + frag.appendChild(span); + pos = s.end; + } + + // Remaining text after last match + if (pos < text.length) { + frag.appendChild(document.createTextNode(text.slice(pos))); + } + + // Replace the text node with the fragment + node.parentNode.replaceChild(frag, node); } } function unrevealInElement(el) { if (SKIP_REVEAL_TAGS.has(el.tagName)) return; + // Remove all ss-revealed spans, restoring original text + const spans = el.querySelectorAll('.ss-revealed'); + for (const span of spans) { + const text = document.createTextNode(span.title?.replace('Substituted value: ', '') || span.textContent); + span.parentNode.replaceChild(text, span); + } + + // Also restore any text nodes that were modified without spans const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let textNode; while ((textNode = walker.nextNode())) { @@ -669,6 +741,9 @@ textNode.textContent = original; } } + + // Normalize adjacent text nodes + el.normalize(); } // Elements to skip when revealing (inputs, scripts, styles, extension UI) From 8c83dcae50a6d0573e1db8544529b404c215134a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:23:36 +0000 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20CSS=20Custom=20Highlight=20API=20?= =?UTF-8?q?=E2=80=94=20yellow=20for=20fake,=20terminal=20for=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two visual modes using the Highlight API (zero DOM modification): - Yellow highlight: fake/substituted values the AI received. Shows automatically in all AI responses, even without reveal mode. "The AI sees these yellow values, not your real data." - Terminal style (black bg, green text): your real data shown in reveal mode. "This is YOUR data — the AI never saw this." Uses CSS.highlights with ::highlight() pseudo-elements — no spans, no DOM changes, no copy/paste artifacts. Falls back to CSS class-based styling if Highlight API is unavailable. Highlights are debounced (500ms) to handle streaming responses without excessive reflows. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- src/content/content.css | 23 +++- src/content/content.js | 261 +++++++++++++++++++++------------------- 2 files changed, 157 insertions(+), 127 deletions(-) diff --git a/src/content/content.css b/src/content/content.css index b5db103..ae765b1 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -13,13 +13,26 @@ padding: 0 1px; } -/* Revealed text styling (when reveal mode shows real values in responses) */ +/* CSS Custom Highlight API styles — zero DOM changes */ + +/* Yellow highlight: fake values the AI received (non-reveal mode) */ +::highlight(ss-substituted) { + background-color: rgba(250, 204, 21, 0.4); + color: inherit; +} + +/* Terminal style: real data shown in reveal mode (dark bg, green text) */ +::highlight(ss-revealed) { + background-color: rgba(0, 0, 0, 0.85); + color: #4ade80; +} + +/* Fallback for browsers without Highlight API */ .ss-revealed { - background: rgba(59, 130, 246, 0.1); - border-bottom: 2px solid rgba(59, 130, 246, 0.6); - border-radius: 2px; + background: rgba(0, 0, 0, 0.85); + color: #4ade80; padding: 0 2px; - cursor: help; + border-radius: 2px; } /* Floating reveal mode indicator */ diff --git a/src/content/content.js b/src/content/content.js index c8eedda..59cec12 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -566,28 +566,42 @@ }; // ============================================================ - // Response Reveal — swaps fake data back to real in the page + // Highlighting — CSS Custom Highlight API (zero DOM changes) + // + // Two highlight modes: + // ss-substituted (yellow) — fake values the AI received + // ss-revealed (terminal: dark bg, green text) — your real data + // + // Falls back to simple text replacement for reveal if + // CSS.highlights is not supported. // ============================================================ - // Build reverse mapping pairs from identity + explicit mappings + const hasHighlightAPI = typeof CSS !== 'undefined' && CSS.highlights; + + // Register highlight groups + let hlSubstituted = null; // yellow — marks fake values in responses + let hlRevealed = null; // terminal — marks revealed real values + + if (hasHighlightAPI) { + hlSubstituted = new Highlight(); + hlRevealed = new Highlight(); + CSS.highlights.set('ss-substituted', hlSubstituted); + CSS.highlights.set('ss-revealed', hlRevealed); + } + + // Build pairs: substitute → real function buildRevealPairs() { const pairs = []; - // Explicit mappings (substitute → real) for (const m of mappings) { if (!m.enabled || !m.substitute || !m.real) continue; pairs.push({ from: m.substitute, to: m.real, caseSensitive: m.caseSensitive }); } - // Smart identity pairs (substitute → real) if (identity) { for (const e of (identity.emails || [])) { if (e.substitute && e.real) pairs.push({ from: e.substitute, to: e.real }); } - if (identity.catchAllEmail) { - // Can't reverse a catch-all to a specific email, but we mark it - // so the user sees it was a substitution - } for (const n of (identity.names || [])) { if (n.substitute && n.real) pairs.push({ from: n.substitute, to: n.real }); } @@ -602,20 +616,25 @@ } } - // Sort longer matches first pairs.sort((a, b) => b.from.length - a.from.length); return pairs; } - // Cache reveal pairs — rebuild when config changes + // Cache let _revealPairsCache = null; window.addEventListener('message', (event) => { if (event.data?.type === 'ss:config-updated') _revealPairsCache = null; }); - function revealText(text) { + function getRevealPairs() { if (!_revealPairsCache) _revealPairsCache = buildRevealPairs(); - const pairs = _revealPairsCache; + return _revealPairsCache; + } + + // --- Text replacement for reveal (needed regardless of highlight API) --- + + function revealText(text) { + const pairs = getRevealPairs(); let result = text; for (const p of pairs) { const escaped = esc(p.from); @@ -625,114 +644,40 @@ return result; } - // Store originals so we can un-reveal when toggled off const originalTexts = new WeakMap(); function revealInElement(el) { if (SKIP_REVEAL_TAGS.has(el.tagName)) return; if (el.classList?.contains('ss-reveal-badge')) return; - if (el.classList?.contains('ss-revealed')) return; // skip our own spans const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; if (parent?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT; - if (parent?.classList?.contains('ss-revealed')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); - // Collect nodes first (modifying DOM during walk breaks the walker) - const nodes = []; let textNode; while ((textNode = walker.nextNode())) { - nodes.push(textNode); - } - - if (!_revealPairsCache) _revealPairsCache = buildRevealPairs(); - const pairs = _revealPairsCache; - - for (const node of nodes) { - const text = node.textContent; + const text = textNode.textContent; if (!text || text.length < MIN_STRING_LENGTH) continue; - // Save original - if (!originalTexts.has(node)) { - originalTexts.set(node, text); + if (!originalTexts.has(textNode)) { + originalTexts.set(textNode, text); } - // Find all substituted values and their positions - const segments = []; - let remaining = text; - let offset = 0; - - for (const p of pairs) { - const escaped = esc(p.from); - const regex = new RegExp(escaped, p.caseSensitive ? 'gi' : 'gi'); - let match; - while ((match = regex.exec(text)) !== null) { - segments.push({ - start: match.index, - end: match.index + match[0].length, - original: match[0], - revealed: p.to, - }); - } + const revealed = revealText(text); + if (revealed !== text) { + textNode.textContent = revealed; } - - if (segments.length === 0) continue; - - // Sort by position, deduplicate overlaps - segments.sort((a, b) => a.start - b.start); - const deduped = []; - let lastEnd = -1; - for (const s of segments) { - if (s.start >= lastEnd) { - deduped.push(s); - lastEnd = s.end; - } - } - - // Build fragment: text + highlighted spans - const frag = document.createDocumentFragment(); - let pos = 0; - - for (const s of deduped) { - // Text before this match - if (s.start > pos) { - frag.appendChild(document.createTextNode(text.slice(pos, s.start))); - } - // Highlighted revealed value - const span = document.createElement('span'); - span.className = 'ss-revealed'; - span.textContent = s.revealed; - span.title = 'Substituted value: ' + s.original; - frag.appendChild(span); - pos = s.end; - } - - // Remaining text after last match - if (pos < text.length) { - frag.appendChild(document.createTextNode(text.slice(pos))); - } - - // Replace the text node with the fragment - node.parentNode.replaceChild(frag, node); } } function unrevealInElement(el) { if (SKIP_REVEAL_TAGS.has(el.tagName)) return; - // Remove all ss-revealed spans, restoring original text - const spans = el.querySelectorAll('.ss-revealed'); - for (const span of spans) { - const text = document.createTextNode(span.title?.replace('Substituted value: ', '') || span.textContent); - span.parentNode.replaceChild(text, span); - } - - // Also restore any text nodes that were modified without spans const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let textNode; while ((textNode = walker.nextNode())) { @@ -741,9 +686,57 @@ textNode.textContent = original; } } + } - // Normalize adjacent text nodes - el.normalize(); + // --- CSS Highlight API — find and highlight matching text --- + + function highlightMatches(root) { + if (!hasHighlightAPI) return; + + // Clear previous ranges + hlSubstituted.clear(); + hlRevealed.clear(); + + const pairs = getRevealPairs(); + if (pairs.length === 0) return; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; + if (parent?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + + let textNode; + while ((textNode = walker.nextNode())) { + const text = textNode.textContent; + if (!text || text.length < MIN_STRING_LENGTH) continue; + + for (const p of pairs) { + const escaped = esc(settings.revealMode ? p.to : p.from); + const searchTerm = settings.revealMode ? p.to : p.from; + const regex = new RegExp(escaped, p.caseSensitive ? 'g' : 'gi'); + let match; + + while ((match = regex.exec(text)) !== null) { + try { + const range = new Range(); + range.setStart(textNode, match.index); + range.setEnd(textNode, match.index + match[0].length); + + if (settings.revealMode) { + hlRevealed.add(range); + } else { + hlSubstituted.add(range); + } + } catch (e) { + // Range may be invalid if DOM changed + } + } + } + } } // Elements to skip when revealing (inputs, scripts, styles, extension UI) @@ -751,59 +744,83 @@ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'INPUT', 'TEXTAREA', 'SELECT', ]); - // Reveal ALL text on the page (not just specific selectors) + // Reveal ALL text on the page + apply highlights function revealAllResponses() { revealInElement(document.body); + highlightMatches(document.body); } - // Un-reveal ALL text on the page + // Un-reveal ALL text + clear highlights function unrevealAllResponses() { unrevealInElement(document.body); + // In non-reveal mode, highlight the fake values instead + highlightMatches(document.body); + } + + // Debounced highlight refresh + let _highlightTimer = null; + function scheduleHighlightRefresh() { + if (!hasHighlightAPI) return; + if (_highlightTimer) clearTimeout(_highlightTimer); + _highlightTimer = setTimeout(() => { + highlightMatches(document.body); + }, 500); } // Watch for ANY new content on the page function observeResponses() { const observer = new MutationObserver((mutations) => { - if (!settings.revealMode || !hasSubstitutions()) return; + if (!hasSubstitutions()) return; + + // Always schedule highlight refresh for new content (yellow markers) + let hasNewContent = false; for (const mutation of mutations) { - // Handle new nodes — reveal all text in them for (const node of mutation.addedNodes) { - if (node.nodeType === Node.ELEMENT_NODE) { - if (!SKIP_REVEAL_TAGS.has(node.tagName)) { - revealInElement(node); - } - } else if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent; - if (text && text.length >= MIN_STRING_LENGTH) { - if (!originalTexts.has(node)) { - originalTexts.set(node, text); + hasNewContent = true; + // Only do text replacement in reveal mode + if (settings.revealMode) { + if (node.nodeType === Node.ELEMENT_NODE) { + if (!SKIP_REVEAL_TAGS.has(node.tagName)) { + revealInElement(node); } - const revealed = revealText(text); - if (revealed !== text) { - node.textContent = revealed; + } else if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent; + if (text && text.length >= MIN_STRING_LENGTH) { + if (!originalTexts.has(node)) { + originalTexts.set(node, text); + } + const revealed = revealText(text); + if (revealed !== text) { + node.textContent = revealed; + } } } } } - // Handle text changes in existing nodes (streaming responses) - if (mutation.type === 'characterData' && settings.revealMode) { - const text = mutation.target.textContent; - if (text && text.length >= MIN_STRING_LENGTH) { - const parent = mutation.target.parentElement; - if (parent && !SKIP_REVEAL_TAGS.has(parent.tagName)) { - if (!originalTexts.has(mutation.target)) { - originalTexts.set(mutation.target, text); - } - const revealed = revealText(text); - if (revealed !== text) { - mutation.target.textContent = revealed; + // Handle streaming text changes + if (mutation.type === 'characterData') { + hasNewContent = true; + if (settings.revealMode) { + const text = mutation.target.textContent; + if (text && text.length >= MIN_STRING_LENGTH) { + const parent = mutation.target.parentElement; + if (parent && !SKIP_REVEAL_TAGS.has(parent.tagName)) { + if (!originalTexts.has(mutation.target)) { + originalTexts.set(mutation.target, text); + } + const revealed = revealText(text); + if (revealed !== text) { + mutation.target.textContent = revealed; + } } } } } } + + if (hasNewContent) scheduleHighlightRefresh(); }); observer.observe(document.body, { From 3a9343cab88c6af441f4c3c611212dd2196a0587 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:25:24 +0000 Subject: [PATCH 4/9] fix: sign script uses date-based version to avoid conflicts Old approach: increment patch by 1 each time. Failed when the same patch number was already signed with Mozilla from a previous session. New approach: version is 1.MMDD.HHMM (e.g., 1.326.1542). Guaranteed unique per minute, all parts under 65535. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- sign-firefox.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sign-firefox.sh b/sign-firefox.sh index 15b7251..ed53a26 100755 --- a/sign-firefox.sh +++ b/sign-firefox.sh @@ -20,10 +20,12 @@ if [ ! -f "$ENV_FILE" ]; then exit 1 fi -# --- Auto-bump patch version --- -CURRENT_VERSION=$(grep -o '"version": "[^"]*"' "$MANIFEST" | head -1 | grep -o '[0-9.]*') -IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" -PATCH=$((PATCH + 1)) +# --- Auto-bump version — always unique --- +# Format: MAJOR.YMMDD.HHMM (e.g., 1.60326.1542) +# Each part stays under 65535, guaranteed unique per minute +MAJOR=1 +MINOR=$(date +%-m%d) # e.g., 326 for March 26, 1225 for Dec 25 +PATCH=$(date +%-H%M) # e.g., 1542 for 3:42 PM NEW_VERSION="$MAJOR.$MINOR.$PATCH" echo "Version: $CURRENT_VERSION → $NEW_VERSION" From b89333076f1c3af5d548065dc48d1bc3808738be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:26:25 +0000 Subject: [PATCH 5/9] fix: sign script retries with incremented patch on version conflict No timestamp metadata in versions. Uses simple incrementing patch numbers (0.3.3, 0.3.4, etc.). If Mozilla rejects the version as already existing, auto-increments and retries up to 10 times. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- sign-firefox.sh | 54 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/sign-firefox.sh b/sign-firefox.sh index ed53a26..a28938a 100755 --- a/sign-firefox.sh +++ b/sign-firefox.sh @@ -20,12 +20,12 @@ if [ ! -f "$ENV_FILE" ]; then exit 1 fi -# --- Auto-bump version — always unique --- -# Format: MAJOR.YMMDD.HHMM (e.g., 1.60326.1542) -# Each part stays under 65535, guaranteed unique per minute -MAJOR=1 -MINOR=$(date +%-m%d) # e.g., 326 for March 26, 1225 for Dec 25 -PATCH=$(date +%-H%M) # e.g., 1542 for 3:42 PM +# --- Auto-bump version — always unique, no metadata --- +# Reads current version, increments patch. If already signed, +# keeps incrementing until it works. +CURRENT_VERSION=$(grep -o '"version": "[^"]*"' "$MANIFEST" | head -1 | grep -o '[0-9.]*') +IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" +PATCH=$((PATCH + 1)) NEW_VERSION="$MAJOR.$MINOR.$PATCH" echo "Version: $CURRENT_VERSION → $NEW_VERSION" @@ -88,17 +88,33 @@ echo "" echo "Building Firefox extension..." "$SCRIPT_DIR/build.sh" firefox -# Sign -echo "Signing v$NEW_VERSION with Mozilla..." -npx web-ext sign \ - --no-config-discovery \ - --source-dir "$SCRIPT_DIR/dist/firefox" \ - --artifacts-dir "$SCRIPT_DIR/dist/firefox-signed" \ - --channel unlisted \ - --api-key "$API_KEY" \ - --api-secret "$API_SECRET" +# Sign — retry with incremented patch if version conflict +MAX_ATTEMPTS=10 +for attempt in $(seq 1 $MAX_ATTEMPTS); do + echo "Signing v$NEW_VERSION with Mozilla (attempt $attempt)..." -echo "" -echo "Done! v$NEW_VERSION signed." -echo "Install the .xpi file from dist/firefox-signed/" -echo "Drag it into Firefox or use File → Open File." + if npx web-ext sign \ + --no-config-discovery \ + --source-dir "$SCRIPT_DIR/dist/firefox" \ + --artifacts-dir "$SCRIPT_DIR/dist/firefox-signed" \ + --channel unlisted \ + --api-key "$API_KEY" \ + --api-secret "$API_SECRET" 2>&1; then + + echo "" + echo "Done! v$NEW_VERSION signed." + echo "Install the .xpi file from dist/firefox-signed/" + echo "Drag it into Firefox or use File → Open File." + exit 0 + fi + + # If it failed due to version conflict, bump and rebuild + echo "Version $NEW_VERSION already exists, trying next..." + PATCH=$((PATCH + 1)) + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + + sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$NEW_VERSION\"/" "$SCRIPT_DIR/dist/firefox/manifest.json" +done + +echo "Error: Failed after $MAX_ATTEMPTS attempts." +exit 1 From 260b713c943ea2d6f135853b6d05f07d9d44060a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:30:48 +0000 Subject: [PATCH 6/9] feat: BSL license + encrypted export/import for cross-browser transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit License: Changed from MIT to BSL 1.1. Free for personal use, commercial use requires a paid license. Auto-converts to MIT on March 26, 2030. Export/Import: Options page now has "Transfer Data" section: - Export All (plain) — JSON file with all identities, mappings, settings - Export Encrypted — AES-256-GCM with PBKDF2 password derivation, saved as .ssbackup file - Import — handles both plain and encrypted backups, prompts for password if encrypted Crypto uses Web Crypto API (browser-native, no dependencies): 100k PBKDF2 iterations, random salt + IV per export. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- LICENSE | 51 ++++++++++++------- README.md | 2 +- package.json | 2 +- src/lib/crypto.js | 92 +++++++++++++++++++++++++++++++++ src/options/options.html | 15 +++++- src/options/options.js | 107 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 248 insertions(+), 21 deletions(-) create mode 100644 src/lib/crypto.js diff --git a/LICENSE b/LICENSE index ef467d2..d546866 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,38 @@ -MIT License +Business Source License 1.1 -Copyright (c) 2025 Silent Send Contributors +Licensor: Silent Send Contributors +Licensed Work: Silent Send browser extension +Change Date: March 26, 2030 +Change License: MIT -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Terms -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The Licensor hereby grants you the right to copy, modify, create +derivative works, redistribute, and make non-production use of the +Licensed Work. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +The Licensor hereby grants you the right to make production use of +the Licensed Work for personal, non-commercial purposes. + +For commercial use, you must obtain a commercial license from the +Licensor. Contact: [your-email-here] + +Effective on the Change Date, the Licensor hereby grants you rights +under the terms of the Change License, and the rights granted above +terminate. + +If your use of the Licensed Work does not comply with the +requirements currently in effect as described in this License, you +must purchase a commercial license from the Licensor, or you must +refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and +derivative works of the Licensed Work, are subject to this License. + +THE LICENSED WORK IS PROVIDED "AS IS". THE LICENSOR HEREBY DISCLAIMS +ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE LICENSED WORK. diff --git a/README.md b/README.md index 097dbd8..0695740 100644 --- a/README.md +++ b/README.md @@ -290,4 +290,4 @@ src/ ## License -[MIT](LICENSE) — use it for anything, commercial or personal, modify it, redistribute it, relicense it. Just keep the copyright notice in copies of the code. +[Business Source License 1.1](LICENSE) — free for personal, non-commercial use. Commercial use requires a paid license. The code automatically converts to MIT on March 26, 2030. diff --git a/package.json b/package.json index c2930a4..4ded9a7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "silent-send", "version": "0.3.1", "private": true, - "license": "MIT", + "license": "BSL-1.1", "description": "Browser extension that substitutes personal data before sending to AI services", "scripts": { "build:chrome": "./build.sh chrome", diff --git a/src/lib/crypto.js b/src/lib/crypto.js new file mode 100644 index 0000000..7443539 --- /dev/null +++ b/src/lib/crypto.js @@ -0,0 +1,92 @@ +/** + * Silent Send - Crypto Module + * + * AES-256-GCM encryption with PBKDF2 key derivation. + * Used for encrypted export/import of user data. + */ + +const SALT_LENGTH = 16; +const IV_LENGTH = 12; +const ITERATIONS = 100000; + +async function deriveKey(password, salt) { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: ITERATIONS, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +const SilentSendCrypto = { + /** + * Encrypt data with a password. + * Returns a base64 string containing salt + iv + ciphertext. + */ + async encrypt(data, password) { + const encoder = new TextEncoder(); + const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const key = await deriveKey(password, salt); + + const plaintext = encoder.encode(JSON.stringify(data)); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + plaintext + ); + + // Combine: salt (16) + iv (12) + ciphertext + const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength); + combined.set(salt, 0); + combined.set(iv, salt.length); + combined.set(new Uint8Array(ciphertext), salt.length + iv.length); + + // Base64 encode + return btoa(String.fromCharCode(...combined)); + }, + + /** + * Decrypt data with a password. + * Takes the base64 string from encrypt(). + */ + async decrypt(encryptedBase64, password) { + const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0)); + + const salt = combined.slice(0, SALT_LENGTH); + const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const ciphertext = combined.slice(SALT_LENGTH + IV_LENGTH); + + const key = await deriveKey(password, salt); + + try { + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext + ); + + const decoder = new TextDecoder(); + return JSON.parse(decoder.decode(plaintext)); + } catch (e) { + throw new Error('Wrong password or corrupted data'); + } + }, +}; + +export default SilentSendCrypto; diff --git a/src/options/options.html b/src/options/options.html index 2a9312f..94d84fc 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -66,13 +66,24 @@ +
+

Transfer Data

+

Export all your identities, mappings, and settings to move between browsers. Encrypted exports require a password to decrypt.

+
+ + + + +
+
+

Mappings

Manage all your substitution rules. Longer matches take priority.

- - + +
diff --git a/src/options/options.js b/src/options/options.js index b3fd320..0f599a6 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -1,4 +1,5 @@ import Storage from '../lib/storage.js'; +import SilentSendCrypto from '../lib/crypto.js'; import api from '../lib/browser-polyfill.js'; let mappings = []; @@ -19,6 +20,12 @@ document.addEventListener('DOMContentLoaded', async () => { renderDomains(); renderLog(); + // Transfer data + $('#btnExportAll').addEventListener('click', exportAllPlain); + $('#btnExportEncrypted').addEventListener('click', exportAllEncrypted); + $('#btnImportAll').addEventListener('click', () => $('#fileImportAll').click()); + $('#fileImportAll').addEventListener('change', importAll); + // Custom domains $('#btnAddDomain').addEventListener('click', addDomain); $('#newDomain').addEventListener('keydown', (e) => { @@ -271,6 +278,106 @@ function renderDomains() { }); } +// --- Transfer Data (Export/Import All) --- + +async function getAllData() { + const result = await api.storage.local.get(null); // get everything + return { + version: '1', + exportedAt: new Date().toISOString(), + identity: result.ss_identity || {}, + mappings: result.ss_mappings || [], + settings: result.ss_settings || {}, + }; +} + +function downloadFile(content, filename) { + const blob = new Blob([content], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +async function exportAllPlain() { + const data = await getAllData(); + downloadFile(JSON.stringify(data, null, 2), 'silent-send-backup.json'); +} + +async function exportAllEncrypted() { + const password = prompt('Set a password for this backup:'); + if (!password) return; + const confirm = prompt('Confirm password:'); + if (password !== confirm) { + alert('Passwords do not match.'); + return; + } + + const data = await getAllData(); + try { + const encrypted = await SilentSendCrypto.encrypt(data, password); + const wrapper = JSON.stringify({ encrypted: true, data: encrypted }); + downloadFile(wrapper, 'silent-send-backup.ssbackup'); + alert('Encrypted backup saved. You will need the password to import it.'); + } catch (e) { + alert('Encryption failed: ' + e.message); + } +} + +async function importAll(e) { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const parsed = JSON.parse(text); + let data; + + if (parsed.encrypted) { + // Encrypted backup + const password = prompt('Enter the password for this backup:'); + if (!password) return; + try { + data = await SilentSendCrypto.decrypt(parsed.data, password); + } catch (err) { + alert('Wrong password or corrupted file.'); + return; + } + } else { + // Plain backup + data = parsed; + } + + if (!data.version) { + alert('Not a valid Silent Send backup file.'); + return; + } + + if (!confirm('This will replace all your current data. Continue?')) return; + + // Restore + if (data.identity) await api.storage.local.set({ ss_identity: data.identity }); + if (data.mappings) await api.storage.local.set({ ss_mappings: data.mappings }); + if (data.settings) await api.storage.local.set({ ss_settings: data.settings }); + + // Refresh UI + mappings = await Storage.getMappings(); + settings = await Storage.getSettings(); + renderMappings(); + renderDomains(); + renderLog(); + + alert('Import complete. Reload the extension for changes to take effect.'); + } catch (err) { + alert('Failed to import: ' + err.message); + } + + // Reset file input + e.target.value = ''; +} + function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; From e6b3ef7e392fa044bb3888d18cac073e73bb417b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:38:15 +0000 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20auto-detect=20unconfigured=20PPI=20?= =?UTF-8?q?=E2=80=94=20warns=20before=20sending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scans outbound messages AFTER all substitutions for personal data the user forgot to configure: - Private/public IP addresses (skips 127.0.0.1, 8.8.8.8, etc.) - MAC addresses - Street addresses ("123 Main St") - GPS coordinates - Dates (possible DOBs) - EIN/tax IDs - Home directory paths not caught by smart patterns - Shell prompts (user@host) - Git remotes (reveals username/org) - Environment variable assignments (HOME=, USER=, etc.) Shows a floating dark warning banner (top-right, auto-dismisses after 15s) listing each detected item with its type, value, and hint. Skips values already in the user's identity config. Also shows PPI warnings in the popup Test tab and adds toggle in Options to disable auto-detect. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- src/content/content.css | 101 ++++++++++++++++++ src/content/content.js | 138 +++++++++++++++++++++++-- src/lib/auto-detect.js | 215 +++++++++++++++++++++++++++++++++++++++ src/lib/storage.js | 1 + src/options/options.html | 10 ++ src/options/options.js | 5 + src/popup/popup.js | 14 +++ 7 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 src/lib/auto-detect.js diff --git a/src/content/content.css b/src/content/content.css index ae765b1..b457e4b 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -35,6 +35,107 @@ border-radius: 2px; } +/* Auto-detect PPI warning banner */ +.ss-autodetect-warning { + position: fixed; + top: 16px; + right: 16px; + max-width: 400px; + background: #1a1a1a; + color: #e5e7eb; + border: 1px solid #f59e0b; + border-radius: 10px; + padding: 12px 16px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + z-index: 999999; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.2s, transform 0.2s; + pointer-events: none; +} + +.ss-autodetect-warning.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.ss-ad-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + margin-bottom: 8px; + color: #f59e0b; + font-size: 11px; + line-height: 1.4; +} + +.ss-ad-close { + background: none; + border: none; + color: #6b7280; + font-size: 18px; + cursor: pointer; + padding: 0; + line-height: 1; + flex-shrink: 0; +} + +.ss-ad-close:hover { color: #fff; } + +.ss-ad-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + border-bottom: 1px solid #333; +} + +.ss-ad-item:last-of-type { border-bottom: none; } + +.ss-ad-type { + font-size: 10px; + font-weight: 600; + color: #f59e0b; + min-width: 70px; + text-transform: uppercase; +} + +.ss-ad-value { + font-family: 'SF Mono', Monaco, monospace; + font-size: 11px; + color: #4ade80; + background: #0a0a0a; + padding: 2px 6px; + border-radius: 4px; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ss-ad-hint { + font-size: 10px; + color: #9ca3af; + flex: 1; +} + +.ss-ad-more { + font-size: 10px; + color: #6b7280; + padding: 4px 0; +} + +.ss-ad-footer { + margin-top: 8px; + font-size: 10px; + color: #6b7280; + font-style: italic; +} + /* Floating reveal mode indicator */ .ss-reveal-badge { position: fixed; diff --git a/src/content/content.js b/src/content/content.js index 59cec12..5ab2d35 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -254,6 +254,7 @@ // ============================================================ // Combined substitution: smart patterns + explicit + secret scan + // + auto-detect warning for unconfigured PPI // ============================================================ function substituteAll(text) { const allReplacements = []; @@ -267,23 +268,146 @@ allReplacements.push(...explicit.replacements); // 3. Secret scanner (API keys, tokens, SSNs, credit cards, etc.) + let finalText = explicit.text; if (settings.secretScanning !== false) { - const secrets = scanAndRedactSecrets(explicit.text); + const secrets = scanAndRedactSecrets(finalText); allReplacements.push(...secrets.redactions); - return { - text: secrets.text, - replacements: allReplacements, - modified: allReplacements.length > 0, - }; + finalText = secrets.text; + } + + // 4. Auto-detect: scan the FINAL text for unconfigured PPI + if (settings.autoDetect !== false) { + const warnings = autoDetectPPI(finalText, identity); + if (warnings.length > 0) { + showAutoDetectWarning(warnings); + } } return { - text: explicit.text, + text: finalText, replacements: allReplacements, modified: allReplacements.length > 0, }; } + // ============================================================ + // Auto-Detect PPI Scanner (inline for page world) + // ============================================================ + const PPI_PATTERNS = [ + // Network + { name: 'Private IP', re: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g, + hint: 'Private IP address', cat: 'network' }, + { name: 'Public IP', re: /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g, + hint: 'IP address — could identify your network', cat: 'network', + skip: /^(?:127\.0\.0\.1|0\.0\.0\.0|255\.255\.255\.\d+|8\.8\.[84]\.[84]|1\.1\.1\.1)$/ }, + { name: 'MAC Address', re: /\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b/g, + hint: 'MAC address — identifies hardware', cat: 'network' }, + // Location + { name: 'Street Address', re: /\b\d{1,5}\s+(?:[A-Z][a-z]+\s+){1,3}(?:St|Street|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Ln|Lane|Rd|Road|Way|Ct|Court|Pl|Place)\.?\b/gi, + hint: 'Street address', cat: 'address' }, + { name: 'GPS Coordinates', re: /\b-?\d{1,3}\.\d{4,},\s*-?\d{1,3}\.\d{4,}\b/g, + hint: 'GPS coordinates — pinpoints a location', cat: 'address' }, + // Personal + { name: 'Date (possible DOB)', re: /\b(?:(?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01])[-/](?:19|20)\d{2}|(?:19|20)\d{2}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01]))\b/g, + hint: 'Date — could be a birthday', cat: 'personal' }, + { name: 'EIN / Tax ID', re: /\b\d{2}-\d{7}\b/g, + hint: 'Could be a tax ID', cat: 'document' }, + // Paths not caught by smart patterns + { name: 'Home Path', re: /(?:\/home\/|\/Users\/|C:\\Users\\)[a-zA-Z0-9._-]+/g, + hint: 'Home directory — reveals username', cat: 'path' }, + // Shell prompts + { name: 'Shell Prompt', re: /[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+[:\$#%>]\s/g, + hint: 'Shell prompt — reveals user@host', cat: 'prompt' }, + // Git remotes + { name: 'Git Remote', re: /(?:git@|https:\/\/)(?:github|gitlab|bitbucket)\.[a-z]+[:/][^\s]+/gi, + hint: 'Git remote — may reveal username/org', cat: 'url' }, + // Env vars + { name: 'Env Variable', re: /\b(?:HOME|USER|USERNAME|LOGNAME|HOSTNAME|COMPUTERNAME|EMAIL)=[^\s]+/gi, + hint: 'Env variable with personal data', cat: 'env' }, + ]; + + function autoDetectPPI(text, ident) { + if (!text || text.length < 5) return []; + + // Build skip set from configured values + const configured = new Set(); + if (ident) { + const addAll = (arr, key) => (arr || []).forEach(item => { + if (item.real) configured.add(item.real.toLowerCase()); + if (item.substitute) configured.add(item.substitute.toLowerCase()); + }); + addAll(ident.names); addAll(ident.emails); + addAll(ident.usernames); addAll(ident.hostnames); addAll(ident.phones); + } + + const findings = []; + for (const pat of PPI_PATTERNS) { + pat.re.lastIndex = 0; + let m; + while ((m = pat.re.exec(text)) !== null) { + const val = m[0]; + if (configured.has(val.toLowerCase())) continue; + if (pat.skip && pat.skip.test(val)) continue; + findings.push({ name: pat.name, value: val, hint: pat.hint, category: pat.cat }); + } + } + + // Deduplicate by value + const seen = new Set(); + return findings.filter(f => { + if (seen.has(f.value)) return false; + seen.add(f.value); + return true; + }); + } + + // ============================================================ + // Auto-Detect Warning UI — floating banner + // ============================================================ + let warningEl = null; + let warningTimeout = null; + + function showAutoDetectWarning(warnings) { + if (!warningEl) { + warningEl = document.createElement('div'); + warningEl.className = 'ss-autodetect-warning'; + document.body.appendChild(warningEl); + } + + const items = warnings.slice(0, 5).map(w => + `
+ ${w.name} + ${w.value.length > 30 ? w.value.slice(0, 27) + '...' : w.value} + ${w.hint} +
` + ).join(''); + + const more = warnings.length > 5 ? `
+${warnings.length - 5} more
` : ''; + + warningEl.innerHTML = ` +
+ Silent Send detected potential PPI that may not be substituted: + +
+ ${items} + ${more} + + `; + + warningEl.classList.add('visible'); + + // Close button + warningEl.querySelector('.ss-ad-close').addEventListener('click', () => { + warningEl.classList.remove('visible'); + }); + + // Auto-dismiss after 15 seconds + if (warningTimeout) clearTimeout(warningTimeout); + warningTimeout = setTimeout(() => { + warningEl.classList.remove('visible'); + }, 15000); + } + // ============================================================ // Secret Scanner (inline for page world) // Detects API keys, tokens, passwords, SSNs, credit cards, etc. diff --git a/src/lib/auto-detect.js b/src/lib/auto-detect.js new file mode 100644 index 0000000..57cbbc3 --- /dev/null +++ b/src/lib/auto-detect.js @@ -0,0 +1,215 @@ +/** + * Silent Send - Auto-Detect + * + * Scans text for potential PPI that the user hasn't configured. + * This catches things the identity and secret scanner can't — + * because the user forgot or didn't know to configure them. + * + * Returns warnings (not auto-redactions) so the user can decide. + */ + +const PPI_PATTERNS = [ + // --- Network --- + { + name: 'Private IP Address', + regex: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g, + category: 'network', + hint: 'Private/local IP address', + }, + { + name: 'Public IP Address', + regex: /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g, + category: 'network', + hint: 'IP address — could identify your network', + // Exclude common non-PPI IPs + exclude: /^(?:127\.0\.0\.1|0\.0\.0\.0|255\.255\.255\.\d+|8\.8\.[84]\.[84]|1\.1\.1\.1|1\.0\.0\.1)$/, + }, + { + name: 'IPv6 Address', + regex: /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b/g, + category: 'network', + hint: 'IPv6 address', + }, + { + name: 'MAC Address', + regex: /\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b/g, + category: 'network', + hint: 'MAC address — identifies your hardware', + }, + + // --- Location / Address --- + { + name: 'US Street Address', + regex: /\b\d{1,5}\s+(?:[A-Z][a-z]+\s+){1,3}(?:St|Street|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Ln|Lane|Rd|Road|Way|Ct|Court|Pl|Place|Cir|Circle)\.?\b/gi, + category: 'address', + hint: 'Looks like a street address', + }, + { + name: 'US Zip Code', + regex: /\b\d{5}(?:-\d{4})?\b/g, + category: 'address', + hint: 'Could be a zip code', + // Only flag if near address-like context + contextRequired: true, + }, + { + name: 'GPS Coordinates', + regex: /\b-?\d{1,3}\.\d{4,},\s*-?\d{1,3}\.\d{4,}\b/g, + category: 'address', + hint: 'GPS coordinates — pinpoints a location', + }, + + // --- Identity Documents --- + { + name: 'US Passport Number', + regex: /\b[A-Z]\d{8}\b/g, + category: 'document', + hint: 'Could be a passport number', + contextRequired: true, + }, + { + name: 'US Driver License', + regex: /\b[A-Z]\d{7,14}\b/g, + category: 'document', + hint: 'Could be a driver license number', + contextRequired: true, + }, + { + name: 'Date of Birth Pattern', + regex: /\b(?:(?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01])[-/](?:19|20)\d{2}|(?:19|20)\d{2}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01]))\b/g, + category: 'personal', + hint: 'Date — could be a birthday or other personal date', + }, + { + name: 'EIN / Tax ID', + regex: /\b\d{2}-\d{7}\b/g, + category: 'document', + hint: 'Could be an EIN or tax ID number', + }, + + // --- URLs with usernames --- + { + name: 'URL with Username', + regex: /https?:\/\/[^\s]*(?:user|profile|account|member)[^\s]*/gi, + category: 'url', + hint: 'URL that may contain your identity', + }, + { + name: 'Git Remote with Username', + regex: /(?:git@|https:\/\/)(?:github|gitlab|bitbucket)\.[a-z]+[:/][^\s]+/gi, + category: 'url', + hint: 'Git remote — may reveal your username/org', + }, + + // --- File paths with home dirs (if not already caught by smart patterns) --- + { + name: 'Home Directory Path', + regex: /(?:\/home\/|\/Users\/|C:\\Users\\)[a-zA-Z0-9._-]+/g, + category: 'path', + hint: 'Home directory path — reveals your username', + }, + + // --- Environment Variables with Sensitive Values --- + { + name: 'Env Variable Assignment', + regex: /\b(?:HOME|USER|USERNAME|LOGNAME|HOSTNAME|COMPUTERNAME|EMAIL)=\S+/gi, + category: 'env', + hint: 'Environment variable with personal data', + }, + + // --- Shell Prompts --- + { + name: 'Shell Prompt', + regex: /[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+[:\$#%>]\s/g, + category: 'prompt', + hint: 'Shell prompt — reveals username and hostname', + }, +]; + +// Context words that make ambiguous patterns more likely to be PPI +const CONTEXT_WORDS = /\b(?:born|birthday|dob|birth|passport|license|driver|ssn|social\s*security|address|home|live|lives|reside|zip|postal)\b/i; + +const AutoDetect = { + /** + * Scan text for potential unconfigured PPI. + * Pass in identity so we can skip values the user already configured. + * + * Returns array of { name, value, hint, category, index } + */ + scan(text, identity) { + if (!text || text.length < 5) return []; + + const hasContext = CONTEXT_WORDS.test(text); + const findings = []; + + // Build a set of already-configured values to skip + const configured = new Set(); + if (identity) { + for (const n of (identity.names || [])) { + if (n.real) configured.add(n.real.toLowerCase()); + if (n.substitute) configured.add(n.substitute.toLowerCase()); + } + for (const e of (identity.emails || [])) { + if (e.real) configured.add(e.real.toLowerCase()); + if (e.substitute) configured.add(e.substitute.toLowerCase()); + } + for (const u of (identity.usernames || [])) { + if (u.real) configured.add(u.real.toLowerCase()); + if (u.substitute) configured.add(u.substitute.toLowerCase()); + } + for (const h of (identity.hostnames || [])) { + if (h.real) configured.add(h.real.toLowerCase()); + if (h.substitute) configured.add(h.substitute.toLowerCase()); + } + for (const p of (identity.phones || [])) { + if (p.real) configured.add(p.real.toLowerCase()); + if (p.substitute) configured.add(p.substitute.toLowerCase()); + } + } + + for (const pattern of PPI_PATTERNS) { + // Skip context-dependent patterns if no context words present + if (pattern.contextRequired && !hasContext) continue; + + pattern.regex.lastIndex = 0; + let match; + + while ((match = pattern.regex.exec(text)) !== null) { + const value = match[0]; + + // Skip if already configured + if (configured.has(value.toLowerCase())) continue; + + // Skip excluded values (like 127.0.0.1) + if (pattern.exclude && pattern.exclude.test(value)) continue; + + findings.push({ + name: pattern.name, + value, + hint: pattern.hint, + category: pattern.category, + index: match.index, + }); + } + } + + // Deduplicate overlapping matches + findings.sort((a, b) => a.index - b.index); + const deduped = []; + let lastEnd = -1; + for (const f of findings) { + if (f.index >= lastEnd) { + deduped.push(f); + lastEnd = f.index + f.value.length; + } + } + + return deduped; + }, +}; + +if (typeof globalThis !== 'undefined') { + globalThis.AutoDetect = AutoDetect; +} + +export default AutoDetect; diff --git a/src/lib/storage.js b/src/lib/storage.js index 70916d1..e9b08de 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -19,6 +19,7 @@ const DEFAULT_SETTINGS = { showHighlights: false, revealMode: false, secretScanning: true, + autoDetect: true, maxLogEntries: 200, customDomains: [], categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'general'], diff --git a/src/options/options.html b/src/options/options.html index 94d84fc..91b5f63 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -57,6 +57,16 @@ +
+
+ +

Warn when potential personal data (IPs, addresses, paths) is detected that you haven't configured

+
+ +
diff --git a/src/options/options.js b/src/options/options.js index 0f599a6..fad2243 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -14,6 +14,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Apply settings to UI $('#showHighlights').checked = settings.showHighlights || false; $('#secretScanning').checked = settings.secretScanning !== false; + $('#autoDetect').checked = settings.autoDetect !== false; $('#maxLogEntries').value = settings.maxLogEntries || 200; renderMappings(); @@ -41,6 +42,10 @@ document.addEventListener('DOMContentLoaded', async () => { await Storage.saveSettings({ secretScanning: e.target.checked }); }); + $('#autoDetect').addEventListener('change', async (e) => { + await Storage.saveSettings({ autoDetect: e.target.checked }); + }); + $('#maxLogEntries').addEventListener('change', async (e) => { await Storage.saveSettings({ maxLogEntries: parseInt(e.target.value, 10) || 200 }); }); diff --git a/src/popup/popup.js b/src/popup/popup.js index 4b14db4..b651a60 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -1,6 +1,7 @@ import SubstitutionEngine from '../lib/substitution-engine.js'; import SmartPatterns from '../lib/smart-patterns.js'; import SecretScanner from '../lib/secret-scanner.js'; +import AutoDetect from '../lib/auto-detect.js'; import Storage from '../lib/storage.js'; import api from '../lib/browser-polyfill.js'; @@ -607,7 +608,20 @@ function renderTestDiff() { if (explicitCount > 0) parts.push(`${explicitCount} explicit`); if (secretCount > 0) parts.push(`${secretCount} secrets redacted`); if (warnCount > 0) parts.push(`${warnCount} warnings`); + + // Auto-detect unconfigured PPI in the final text + const ppiWarnings = AutoDetect.scan(finalText, identity); + if (ppiWarnings.length > 0) parts.push(`${ppiWarnings.length} PPI detected`); + stats.textContent = `${allReplacements.length} substitution${allReplacements.length !== 1 ? 's' : ''} (${parts.join(', ')})`; + + // Show PPI warnings below stats + if (ppiWarnings.length > 0) { + stats.innerHTML += `
+ Unconfigured PPI detected: + ${ppiWarnings.map(w => `
${escapeHtml(w.value)} — ${w.hint}
`).join('')} +
`; + } } // --- Reveal Diff (fake → real) --- From 3cb06c6dea0311170765fb5b11ce4690af19c33b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:44:12 +0000 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20pre-send=20PPI=20detection=20?= =?UTF-8?q?=E2=80=94=20warns=20while=20typing,=20auto-add=20mappings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Like spellcheck for privacy. Scans text as you type and paste into chat inputs (debounced 800ms). Shows a dark floating warning panel listing detected PPI BEFORE you hit Enter. Each detected item has a green [+] button that instantly: 1. Generates a plausible fake value (random IP, fake address, etc.) 2. Adds it as a mapping to storage 3. Shows a checkmark to confirm The warning disappears when you clear the text or when all detected items have been addressed. Three new Options toggles: - Auto-detect unconfigured PPI (on by default) - Offer to auto-add detected PPI (on by default) Also adds a storage bridge (postMessage) so the page-world content script can read/write chrome.storage through the injector. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- src/content/content.css | 84 +++++++++++++++++++ src/content/content.js | 176 +++++++++++++++++++++++++++++++++++++-- src/content/injector.js | 18 ++++ src/lib/storage.js | 1 + src/options/options.html | 10 +++ src/options/options.js | 5 ++ 6 files changed, 289 insertions(+), 5 deletions(-) diff --git a/src/content/content.css b/src/content/content.css index b457e4b..dd5a5f6 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -136,6 +136,90 @@ font-style: italic; } +/* Pre-send PPI warning (spellcheck-style, appears while typing) */ +.ss-presend-warning { + position: fixed; + top: 16px; + right: 16px; + max-width: 420px; + background: #1a1a1a; + color: #e5e7eb; + border: 1px solid #f59e0b; + border-radius: 10px; + padding: 12px 16px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + z-index: 999999; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.2s, transform 0.2s; + pointer-events: none; +} + +.ss-presend-warning.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.ss-ps-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + border-bottom: 1px solid #333; +} + +.ss-ps-item:last-of-type { border-bottom: none; } + +.ss-ps-type { + font-size: 9px; + font-weight: 600; + color: #f59e0b; + min-width: 65px; + text-transform: uppercase; +} + +.ss-ps-value { + font-family: 'SF Mono', Monaco, monospace; + font-size: 11px; + color: #4ade80; + background: #0a0a0a; + padding: 2px 6px; + border-radius: 4px; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ss-ps-hint { + font-size: 10px; + color: #9ca3af; + flex: 1; +} + +.ss-ps-add { + background: none; + border: 1px solid #4ade80; + border-radius: 4px; + color: #4ade80; + font-size: 14px; + font-weight: bold; + width: 24px; + height: 24px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; +} + +.ss-ps-add:hover { background: rgba(74, 222, 128, 0.15); } +.ss-ps-add:disabled { border-color: #333; cursor: default; } + /* Floating reveal mode indicator */ .ss-reveal-badge { position: fixed; diff --git a/src/content/content.js b/src/content/content.js index 5ab2d35..443b86c 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -1006,15 +1006,181 @@ } // ============================================================ - // Input Highlighting + // Pre-Send PPI Detection — scans as you type/paste (spellcheck style) // ============================================================ + + // Generate plausible fake values for detected PPI + function generateFake(type, value) { + switch (type) { + case 'Private IP': + case 'Public IP': + return '10.' + rnd(1,254) + '.' + rnd(1,254) + '.' + rnd(1,254); + case 'MAC Address': + return Array.from({length:6}, () => rnd(0,255).toString(16).padStart(2,'0')).join(':'); + case 'Street Address': + const streets = ['Oak', 'Maple', 'Pine', 'Cedar', 'Elm', 'Main', 'Park', 'Lake']; + const types = ['St', 'Ave', 'Dr', 'Ln', 'Rd']; + return rnd(100,9999) + ' ' + streets[rnd(0,7)] + ' ' + types[rnd(0,4)]; + case 'GPS Coordinates': + return (rnd(-90,90) + Math.random()).toFixed(6) + ',' + (rnd(-180,180) + Math.random()).toFixed(6); + case 'Date (possible DOB)': + return (rnd(1,12) + '').padStart(2,'0') + '/' + (rnd(1,28) + '').padStart(2,'0') + '/' + rnd(1950,2005); + case 'EIN / Tax ID': + return rnd(10,99) + '-' + (rnd(1000000,9999999) + ''); + case 'Home Path': { + const fakeUser = 'user' + rnd(100,999); + if (value.startsWith('C:\\')) return 'C:\\Users\\' + fakeUser; + if (value.startsWith('/Users/')) return '/Users/' + fakeUser; + return '/home/' + fakeUser; + } + case 'Shell Prompt': + return 'user@computer:$ '; + case 'Git Remote': + return value.replace(/[:/][^/\s]+\//, ':/anonymous/'); + case 'Env Variable': + return value.split('=')[0] + '=REDACTED'; + default: + return '[REDACTED]'; + } + } + + function rnd(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + // Pre-send warning UI + let preSendWarningEl = null; + let preSendTimer = null; + + function showPreSendWarning(warnings, inputEl) { + if (!preSendWarningEl) { + preSendWarningEl = document.createElement('div'); + preSendWarningEl.className = 'ss-presend-warning'; + document.body.appendChild(preSendWarningEl); + } + + const items = warnings.slice(0, 8).map((w, i) => { + const fake = generateFake(w.name, w.value); + const displayVal = w.value.length > 25 ? w.value.slice(0, 22) + '...' : w.value; + return `
+ ${w.name} + ${displayVal} + ${w.hint} + ${settings.autoAddDetected !== false + ? `` + : ''} +
`; + }).join(''); + + const more = warnings.length > 8 ? `
+${warnings.length - 8} more
` : ''; + + preSendWarningEl.innerHTML = ` +
+ Potential PPI detected — not yet configured: + +
+ ${items} + ${more} + + `; + + preSendWarningEl.classList.add('visible'); + + // Close button + preSendWarningEl.querySelector('.ss-ad-close').addEventListener('click', () => { + preSendWarningEl.classList.remove('visible'); + }); + + // Auto-add buttons + preSendWarningEl.querySelectorAll('.ss-ps-add').forEach(btn => { + btn.addEventListener('click', async () => { + const real = decodeURIComponent(btn.dataset.real); + const fake = decodeURIComponent(btn.dataset.fake); + const cat = btn.dataset.cat || 'general'; + + // Add to mappings via storage + const result = await getStorageData('ss_mappings'); + const currentMappings = result || []; + currentMappings.push({ + id: crypto.randomUUID(), + real, substitute: fake, + category: cat, + caseSensitive: false, + enabled: true, + createdAt: Date.now(), + }); + await setStorageData('ss_mappings', currentMappings); + + // Update local mappings + mappings = currentMappings; + + // Visual feedback + btn.textContent = '\u2714'; + btn.style.color = '#4ade80'; + btn.disabled = true; + }); + }); + } + + // Storage helpers for page world (uses postMessage to injector) + function getStorageData(key) { + return new Promise(resolve => { + const id = 'ss-get-' + Math.random(); + const handler = (event) => { + if (event.data?.type === 'ss:storage-result' && event.data.id === id) { + window.removeEventListener('message', handler); + resolve(event.data.value); + } + }; + window.addEventListener('message', handler); + window.postMessage({ type: 'ss:storage-get', key, id }, '*'); + // Timeout fallback + setTimeout(() => { window.removeEventListener('message', handler); resolve(null); }, 2000); + }); + } + + function setStorageData(key, value) { + window.postMessage({ type: 'ss:storage-set', key, value }, '*'); + } + + // Scan input on type and paste + let inputScanTimer = null; + + function scanInputForPPI(target) { + const text = target.textContent || target.value || ''; + if (!text || text.length < 5) { + if (preSendWarningEl) preSendWarningEl.classList.remove('visible'); + return; + } + + const warnings = autoDetectPPI(text, identity); + if (warnings.length > 0) { + showPreSendWarning(warnings, target); + } else if (preSendWarningEl) { + preSendWarningEl.classList.remove('visible'); + } + } + document.addEventListener('input', (e) => { - if (!settings.showHighlights || !hasSubstitutions()) return; + if (settings.autoDetect === false) return; const target = e.target; if (target.matches?.('[contenteditable], textarea, input[type="text"]')) { - const text = target.textContent || target.value || ''; - const r = substituteAll(text); - target.classList.toggle('ss-has-sensitive', r.modified); + // Debounce — don't scan on every keystroke + if (inputScanTimer) clearTimeout(inputScanTimer); + inputScanTimer = setTimeout(() => scanInputForPPI(target), 800); + } + }, true); + + document.addEventListener('paste', (e) => { + if (settings.autoDetect === false) return; + const target = e.target; + if (target.matches?.('[contenteditable], textarea, input[type="text"]') || + target.closest?.('[contenteditable]')) { + // Scan shortly after paste completes + setTimeout(() => scanInputForPPI(target.closest?.('[contenteditable]') || target), 200); } }, true); diff --git a/src/content/injector.js b/src/content/injector.js index 048e93f..fc11458 100644 --- a/src/content/injector.js +++ b/src/content/injector.js @@ -126,6 +126,24 @@ }, '*'); } }); + + // Storage bridge — lets page world script read/write storage + window.addEventListener('message', async (event) => { + if (event.source !== window) return; + + if (event.data?.type === 'ss:storage-get') { + const result = await api.storage.local.get(event.data.key); + window.postMessage({ + type: 'ss:storage-result', + id: event.data.id, + value: result[event.data.key] || null, + }, '*'); + } + + if (event.data?.type === 'ss:storage-set') { + await api.storage.local.set({ [event.data.key]: event.data.value }); + } + }); } init(); diff --git a/src/lib/storage.js b/src/lib/storage.js index e9b08de..9b8a2bc 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -20,6 +20,7 @@ const DEFAULT_SETTINGS = { revealMode: false, secretScanning: true, autoDetect: true, + autoAddDetected: true, maxLogEntries: 200, customDomains: [], categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'general'], diff --git a/src/options/options.html b/src/options/options.html index 91b5f63..502a00a 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -67,6 +67,16 @@
+
+
+ +

Show a + button on detected PPI to instantly create a mapping with a suggested fake value

+
+ +
diff --git a/src/options/options.js b/src/options/options.js index fad2243..24fafcf 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -15,6 +15,7 @@ document.addEventListener('DOMContentLoaded', async () => { $('#showHighlights').checked = settings.showHighlights || false; $('#secretScanning').checked = settings.secretScanning !== false; $('#autoDetect').checked = settings.autoDetect !== false; + $('#autoAddDetected').checked = settings.autoAddDetected !== false; $('#maxLogEntries').value = settings.maxLogEntries || 200; renderMappings(); @@ -46,6 +47,10 @@ document.addEventListener('DOMContentLoaded', async () => { await Storage.saveSettings({ autoDetect: e.target.checked }); }); + $('#autoAddDetected').addEventListener('change', async (e) => { + await Storage.saveSettings({ autoAddDetected: e.target.checked }); + }); + $('#maxLogEntries').addEventListener('change', async (e) => { await Storage.saveSettings({ maxLogEntries: parseInt(e.target.value, 10) || 200 }); }); From 5495024be923dc8dcaeacae0d4e4398154270b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:50:59 +0000 Subject: [PATCH 9/9] feat: auto-redact detected PPI on send + standard fake values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detected PPI is now auto-redacted in the fetch hook using RFC/standard reserved values — not just warned about: - IPs → 192.0.2.1 (RFC 5737 TEST-NET-1, never routed) - MACs → 00:00:00:00:00:00 - Addresses → 123 Example Street, Anytown, ST 00000 - GPS → 0.000000,0.000000 (Gulf of Guinea) - Dates → 01/01/1970 (Unix epoch) - EINs → 00-0000000 (impossible prefix) - Paths → /home/user - Git → example org These are obviously fake and guaranteed not to be real data, unlike random values which could be confused with actual PPI. New Options toggle: "Auto-redact detected PPI on send" (on by default). When on, PPI is caught in the fetch hook even if the user hits Enter immediately after pasting. Warning banner now says "Auto-redacted with standard placeholders" instead of "These were sent as-is." https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- src/content/content.js | 58 ++++++++++++++++++++++++---------------- src/lib/storage.js | 1 + src/options/options.html | 10 +++++++ src/options/options.js | 5 ++++ 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/content/content.js b/src/content/content.js index 443b86c..d109ae7 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -276,9 +276,27 @@ } // 4. Auto-detect: scan the FINAL text for unconfigured PPI + // Auto-redact if enabled, otherwise just warn if (settings.autoDetect !== false) { const warnings = autoDetectPPI(finalText, identity); if (warnings.length > 0) { + // Auto-redact detected PPI in the outbound text + if (settings.autoRedactDetected !== false) { + for (let i = warnings.length - 1; i >= 0; i--) { + const w = warnings[i]; + const fake = generateFake(w.name, w.value); + const escaped = esc(w.value); + const regex = new RegExp(escaped, 'g'); + finalText = finalText.replace(regex, fake); + allReplacements.push({ + original: w.value, + replaced: fake, + category: 'auto-detect', + pattern: w.name, + }); + } + } + // Still show the warning so user knows what was caught showAutoDetectWarning(warnings); } } @@ -1009,34 +1027,32 @@ // Pre-Send PPI Detection — scans as you type/paste (spellcheck style) // ============================================================ - // Generate plausible fake values for detected PPI + // Generate obviously-fake values using reserved/standard ranges + // These are recognizable as placeholders and guaranteed not to be real function generateFake(type, value) { switch (type) { case 'Private IP': case 'Public IP': - return '10.' + rnd(1,254) + '.' + rnd(1,254) + '.' + rnd(1,254); + // RFC 5737 — reserved for documentation, never routed + return '192.0.2.1'; case 'MAC Address': - return Array.from({length:6}, () => rnd(0,255).toString(16).padStart(2,'0')).join(':'); + return '00:00:00:00:00:00'; case 'Street Address': - const streets = ['Oak', 'Maple', 'Pine', 'Cedar', 'Elm', 'Main', 'Park', 'Lake']; - const types = ['St', 'Ave', 'Dr', 'Ln', 'Rd']; - return rnd(100,9999) + ' ' + streets[rnd(0,7)] + ' ' + types[rnd(0,4)]; + return '123 Example Street, Anytown, ST 00000'; case 'GPS Coordinates': - return (rnd(-90,90) + Math.random()).toFixed(6) + ',' + (rnd(-180,180) + Math.random()).toFixed(6); + return '0.000000,0.000000'; case 'Date (possible DOB)': - return (rnd(1,12) + '').padStart(2,'0') + '/' + (rnd(1,28) + '').padStart(2,'0') + '/' + rnd(1950,2005); + return '01/01/1970'; case 'EIN / Tax ID': - return rnd(10,99) + '-' + (rnd(1000000,9999999) + ''); - case 'Home Path': { - const fakeUser = 'user' + rnd(100,999); - if (value.startsWith('C:\\')) return 'C:\\Users\\' + fakeUser; - if (value.startsWith('/Users/')) return '/Users/' + fakeUser; - return '/home/' + fakeUser; - } + return '00-0000000'; + case 'Home Path': + if (value.startsWith('C:\\')) return 'C:\\Users\\user'; + if (value.startsWith('/Users/')) return '/Users/user'; + return '/home/user'; case 'Shell Prompt': - return 'user@computer:$ '; + return 'user@host:$ '; case 'Git Remote': - return value.replace(/[:/][^/\s]+\//, ':/anonymous/'); + return value.replace(/[:/][^/\s]+\//, ':/example/'); case 'Env Variable': return value.split('=')[0] + '=REDACTED'; default: @@ -1044,10 +1060,6 @@ } } - function rnd(min, max) { - return Math.floor(Math.random() * (max - min + 1)) + min; - } - // Pre-send warning UI let preSendWarningEl = null; let preSendTimer = null; @@ -1082,8 +1094,8 @@ ${items} ${more} `; diff --git a/src/lib/storage.js b/src/lib/storage.js index 9b8a2bc..0e6664e 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -20,6 +20,7 @@ const DEFAULT_SETTINGS = { revealMode: false, secretScanning: true, autoDetect: true, + autoRedactDetected: true, autoAddDetected: true, maxLogEntries: 200, customDomains: [], diff --git a/src/options/options.html b/src/options/options.html index 502a00a..facd3e3 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -67,6 +67,16 @@
+
+
+ +

Automatically replace detected PPI with generic placeholders (192.0.2.1, 123 Example Street, etc.) when sending

+
+ +
diff --git a/src/options/options.js b/src/options/options.js index 24fafcf..3ffe967 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -15,6 +15,7 @@ document.addEventListener('DOMContentLoaded', async () => { $('#showHighlights').checked = settings.showHighlights || false; $('#secretScanning').checked = settings.secretScanning !== false; $('#autoDetect').checked = settings.autoDetect !== false; + $('#autoRedactDetected').checked = settings.autoRedactDetected !== false; $('#autoAddDetected').checked = settings.autoAddDetected !== false; $('#maxLogEntries').value = settings.maxLogEntries || 200; @@ -47,6 +48,10 @@ document.addEventListener('DOMContentLoaded', async () => { await Storage.saveSettings({ autoDetect: e.target.checked }); }); + $('#autoRedactDetected').addEventListener('change', async (e) => { + await Storage.saveSettings({ autoRedactDetected: e.target.checked }); + }); + $('#autoAddDetected').addEventListener('change', async (e) => { await Storage.saveSettings({ autoAddDetected: e.target.checked }); });