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/sign-firefox.sh b/sign-firefox.sh index 15b7251..a28938a 100755 --- a/sign-firefox.sh +++ b/sign-firefox.sh @@ -20,7 +20,9 @@ if [ ! -f "$ENV_FILE" ]; then exit 1 fi -# --- Auto-bump patch version --- +# --- 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)) @@ -86,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 diff --git a/src/content/content.css b/src/content/content.css index 1d2da16..dd5a5f6 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -13,14 +13,213 @@ padding: 0 1px; } -/* 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-radius: 2px; - padding: 0 1px; +/* 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(0, 0, 0, 0.85); + color: #4ade80; + padding: 0 2px; + 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; +} + +/* 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 7c3e0ef..d109ae7 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,164 @@ 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 + // 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); + } } 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 => + `
` + ).join(''); + + const more = warnings.length > 5 ? `` : ''; + + warningEl.innerHTML = ` + + ${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. @@ -566,28 +708,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 +758,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,12 +786,10 @@ 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; - // Skip our own badge if (el.classList?.contains('ss-reveal-badge')) return; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { @@ -671,64 +830,139 @@ } } + // --- 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) const SKIP_REVEAL_TAGS = new Set([ '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, { @@ -790,15 +1024,175 @@ } // ============================================================ - // Input Highlighting + // Pre-Send PPI Detection — scans as you type/paste (spellcheck style) // ============================================================ + + // 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': + // RFC 5737 — reserved for documentation, never routed + return '192.0.2.1'; + case 'MAC Address': + return '00:00:00:00:00:00'; + case 'Street Address': + return '123 Example Street, Anytown, ST 00000'; + case 'GPS Coordinates': + return '0.000000,0.000000'; + case 'Date (possible DOB)': + return '01/01/1970'; + case 'EIN / Tax ID': + 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@host:$ '; + case 'Git Remote': + return value.replace(/[:/][^/\s]+\//, ':/example/'); + case 'Env Variable': + return value.split('=')[0] + '=REDACTED'; + default: + return '[REDACTED]'; + } + } + + // 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 `${displayVal}
+ ${w.hint}
+ ${settings.autoAddDetected !== false
+ ? ``
+ : ''}
+ Warn when potential personal data (IPs, addresses, paths) is detected that you haven't configured
+Automatically replace detected PPI with generic placeholders (192.0.2.1, 123 Example Street, etc.) when sending
+Show a + button on detected PPI to instantly create a mapping with a suggested fake value
+Export all your identities, mappings, and settings to move between browsers. Encrypted exports require a password to decrypt.
+Manage all your substitution rules. Longer matches take priority.
${escapeHtml(w.value)} — ${w.hint}