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 => + `
+ ${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. @@ -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 `
+ ${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 f42a1c1..fc11458 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, '*'); } }); @@ -92,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/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/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/lib/storage.js b/src/lib/storage.js index 70916d1..0e6664e 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -19,6 +19,9 @@ const DEFAULT_SETTINGS = { showHighlights: false, revealMode: false, secretScanning: true, + autoDetect: true, + autoRedactDetected: 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 2a9312f..facd3e3 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -57,6 +57,36 @@ +
+
+ +

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

+
+ +
@@ -66,13 +96,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..3ffe967 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 = []; @@ -13,12 +14,21 @@ document.addEventListener('DOMContentLoaded', async () => { // Apply settings to UI $('#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; renderMappings(); 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) => { @@ -34,6 +44,18 @@ document.addEventListener('DOMContentLoaded', async () => { await Storage.saveSettings({ secretScanning: e.target.checked }); }); + $('#autoDetect').addEventListener('change', async (e) => { + 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 }); + }); + $('#maxLogEntries').addEventListener('change', async (e) => { await Storage.saveSettings({ maxLogEntries: parseInt(e.target.value, 10) || 200 }); }); @@ -271,6 +293,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; 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) ---