diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..c0c6e9f --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,67 @@ +# Privacy Policy — Silent Send + +**Last updated:** March 26, 2026 + +## Summary + +Silent Send does not collect, transmit, or store any data externally. All processing happens 100% locally in your browser. There are no servers, no analytics, no tracking, and no telemetry of any kind. + +## What data Silent Send accesses + +Silent Send accesses the following data **only within your browser** to perform its core function (substituting personal information before it reaches AI services): + +- **Text you type** in AI chat interfaces (Claude, ChatGPT, Grok, Gemini, etc.) — scanned for configured personal information and substituted before sending +- **Files you upload** to AI services — text is extracted and scanned for personal information before upload +- **Your identity configuration** — names, emails, usernames, hostnames, phones, and their substitute values, stored in browser local storage +- **Your substitution mappings** — real-to-substitute value pairs you configure +- **Activity log** — a local record of substitutions performed (never sent anywhere) +- **Settings and preferences** — extension configuration + +## Where data is stored + +All data is stored in your browser's `storage.local` (the extension's private storage area). When at-rest encryption is enabled, all sensitive data is AES-256-GCM encrypted before being written to storage. + +Data is **never** sent to any server operated by Silent Send or any third party. The only network requests Silent Send makes are: + +- **To the AI service you are already using** (e.g., claude.ai, chatgpt.com) — this is the substituted/sanitized version of your text, not the original +- **GitHub Gist sync** (optional, user-initiated) — if you configure Gist sync, your encrypted settings are stored in a private Gist on your own GitHub account +- **Custom URL sync** (optional, user-initiated) — if you configure a custom sync endpoint, encrypted settings are sent to the URL you specify +- **Org policy URL** (optional) — if you join an organization, the extension fetches the policy JSON from the URL your admin provides + +## Data sharing + +Silent Send does not share any data with anyone. There are no analytics providers, no crash reporting services, no advertising networks, and no data brokers involved. + +## Data retention + +All data persists in your browser until you delete it. You can: +- Clear all data via Options → Danger Zone → Reset Everything +- Uninstall the extension (removes all stored data) +- Export your data before clearing + +## Permissions explained + +| Permission | Why it's needed | +|---|---| +| `storage` | Store your identity, mappings, settings, and activity log locally | +| `activeTab` | Access the current tab to inject the substitution script | +| `scripting` | Inject content scripts on custom domains you configure | +| `notifications` | Show desktop notifications for sync status updates | +| `alarms` | Background polling for auto-sync and org policy updates | +| Host permissions (claude.ai, etc.) | Intercept API requests to substitute personal information before sending | + +## Children's privacy + +Silent Send does not knowingly collect data from children under 13. The extension does not collect data from anyone — it processes everything locally. + +## Changes to this policy + +If this privacy policy changes, the updated version will be posted at this URL and in the extension's GitHub repository. + +## Contact + +For questions about this privacy policy, open an issue at: https://github.com/outis1one/silent-send/issues + +## Open source + +Silent Send's source code is publicly available at https://github.com/outis1one/silent-send — you can verify every claim in this policy by reading the code. diff --git a/manifest.firefox.json b/manifest.firefox.json index b9c358f..4242f80 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -1,7 +1,7 @@ { - "manifest_version": 1, + "manifest_version": 3, "name": "Silent Send", - "version": "1.2.0", + "version": "2.0.0", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", "browser_specific_settings": { "gecko": { diff --git a/manifest.json b/manifest.json index 5f069a4..efe7a11 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Silent Send", - "version": "0.3.1", + "version": "2.0.0", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", "permissions": [ "storage", diff --git a/package.json b/package.json index 4ded9a7..74b78c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "silent-send", - "version": "0.3.1", + "version": "2.0.0", "private": true, "license": "BSL-1.1", "description": "Browser extension that substitutes personal data before sending to AI services", diff --git a/sign-firefox.sh b/sign-firefox.sh index f86ce84..27b7efe 100755 --- a/sign-firefox.sh +++ b/sign-firefox.sh @@ -1,7 +1,9 @@ #!/bin/bash # # Sign the Firefox extension using Mozilla's API. -# Auto-bumps the patch version to avoid "version already exists" conflicts. +# Tries the current version first. Only bumps if that version +# already exists at Mozilla. Handles rate limiting with backoff. +# # Reads credentials from .env file. # @@ -20,25 +22,12 @@ if [ ! -f "$ENV_FILE" ]; then exit 1 fi -# --- Auto-bump version — always unique, no metadata --- -# Reads current version, increments patch. If already signed, -# keeps incrementing until it works. +# --- Read current version (don't bump yet — try current first) --- 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" +NEW_VERSION="$CURRENT_VERSION" -echo "Version: $CURRENT_VERSION → $NEW_VERSION" - -# Update all version references (only top-level "version", not "manifest_version") -sed -i "s/^ \"version\": \"$CURRENT_VERSION\"/ \"version\": \"$NEW_VERSION\"/" "$MANIFEST" -sed -i "s/^ \"version\": \"$CURRENT_VERSION\"/ \"version\": \"$NEW_VERSION\"/" "$MANIFEST_CHROME" -sed -i "s/^ \"version\": \"$CURRENT_VERSION\"/ \"version\": \"$NEW_VERSION\"/" "$PACKAGE_JSON" - -# Commit the version bump -cd "$SCRIPT_DIR" -git add manifest.json manifest.firefox.json package.json 2>/dev/null -git commit -m "chore: auto-bump version to $NEW_VERSION for Firefox signing" --allow-empty 2>/dev/null || true +echo "Current version: $CURRENT_VERSION" # --- Parse .env --- API_KEY="" @@ -88,38 +77,73 @@ echo "" echo "Building Firefox extension..." "$SCRIPT_DIR/build.sh" firefox -# 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)..." +# --- Sign with retry --- +MAX_ATTEMPTS=5 +WAIT_TIME=10 - if npx web-ext sign \ +for attempt in $(seq 1 $MAX_ATTEMPTS); do + echo "" + echo "=== Attempt $attempt: signing v$NEW_VERSION ===" + + # Capture output to check for specific errors + OUTPUT=$(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 - + --api-secret "$API_SECRET" 2>&1) && { + echo "$OUTPUT" 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." + echo "Success! v$NEW_VERSION signed." + echo "Install: dist/firefox-signed/" + + # Update source files to match the signed version + sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$MANIFEST" + sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$MANIFEST_CHROME" + sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$PACKAGE_JSON" + + cd "$SCRIPT_DIR" + git add manifest.json manifest.firefox.json package.json 2>/dev/null + git commit -m "chore: release v$NEW_VERSION (Firefox signed)" --allow-empty 2>/dev/null || true + exit 0 + } + + echo "$OUTPUT" + + # Check if rate limited + if echo "$OUTPUT" | grep -q "throttled"; then + # Extract wait time from error message + THROTTLE_SECS=$(echo "$OUTPUT" | grep -oP 'available in \K\d+' || echo "60") + echo "" + echo "Rate limited by Mozilla. Waiting ${THROTTLE_SECS}s..." + sleep "$THROTTLE_SECS" + # Don't bump version — retry the same version after cooldown + continue 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" + # Check if version already exists + if echo "$OUTPUT" | grep -qi "already exists\|version.*conflict\|could not be uploaded"; then + PATCH=$((PATCH + 1)) + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + echo "" + echo "Version conflict. Bumping to $NEW_VERSION..." - # Only replace the top-level "version" field, not "manifest_version" - sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$SCRIPT_DIR/dist/firefox/manifest.json" + # Update only the built manifest (not source — we'll update source on success) + sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$SCRIPT_DIR/dist/firefox/manifest.json" - # Wait before retrying to avoid Mozilla rate limiting - echo "Waiting 8 seconds before retry..." - sleep 8 + sleep "$WAIT_TIME" + continue + fi + + # Unknown error — wait and retry + echo "" + echo "Unknown error. Waiting ${WAIT_TIME}s before retry..." + sleep "$WAIT_TIME" done +echo "" echo "Error: Failed after $MAX_ATTEMPTS attempts." +echo "If rate limited, wait a few minutes and try again." exit 1 diff --git a/src/content/content.js b/src/content/content.js index 35b4915..0641d10 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -10,6 +10,13 @@ (function () { 'use strict'; + // --- Safe innerHTML replacement (AMO-compliant, page world) --- + function safeHTML(el, html) { + const template = document.createElement('template'); + template.innerHTML = html; + el.replaceChildren(...template.content.childNodes); + } + // ============================================================ // Load config from the injector script's data attribute // ============================================================ @@ -514,7 +521,7 @@ const more = warnings.length > 5 ? `
+${warnings.length - 5} more
` : ''; - warningEl.innerHTML = ` + safeHTML(warningEl, `
Silent Send detected potential PPI that may not be substituted: @@ -522,7 +529,7 @@ ${items} ${more} - `; + `); warningEl.classList.add('visible'); @@ -1165,7 +1172,7 @@
` ).join(''); - docPreviewEl.innerHTML = ` + safeHTML(docPreviewEl, `
PPI found in ${esc(filename)} ${preview.replacementCount} item(s) @@ -1176,7 +1183,7 @@
- `; + `); docPreviewEl.classList.add('visible'); const confirm = docPreviewEl.querySelector('.ss-dp-confirm'); const cancel = docPreviewEl.querySelector('.ss-dp-cancel'); @@ -1657,7 +1664,7 @@ const more = warnings.length > 8 ? `
+${warnings.length - 8} more
` : ''; - preSendWarningEl.innerHTML = ` + safeHTML(preSendWarningEl, `
Potential PPI detected — not yet configured: @@ -1668,7 +1675,7 @@ ${settings.autoRedactDetected !== false ? 'Auto-redacted with standard placeholders.' : 'These were sent as-is.'} ${settings.autoAddDetected !== false ? ' Click + to add a permanent mapping.' : ''}
- `; + `); preSendWarningEl.classList.add('visible'); diff --git a/src/options/options.html b/src/options/options.html index c4dc127..ad41502 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -589,7 +589,7 @@ diff --git a/src/options/options.js b/src/options/options.js index 3e5d010..5f6eaa6 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -14,6 +14,12 @@ let passwordsRevealed = false; const $ = (sel) => document.querySelector(sel); +// --- Safe innerHTML replacement (AMO-compliant) --- +function safeHTML(el, html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + el.replaceChildren(...doc.body.childNodes); +} + document.addEventListener('DOMContentLoaded', async () => { mappings = await Storage.getMappings(); settings = await Storage.getSettings(); @@ -405,11 +411,11 @@ function renderMappings() { const nonPasswordMappings = mappings.filter(m => m.category !== 'password'); if (nonPasswordMappings.length === 0) { - tbody.innerHTML = 'No mappings configured'; + safeHTML(tbody, 'No mappings configured'); return; } - tbody.innerHTML = nonPasswordMappings + safeHTML(tbody, nonPasswordMappings .map( (m) => ` @@ -427,7 +433,7 @@ function renderMappings() { ` ) - .join(''); + .join('')); // Bind tbody.querySelectorAll('.btn-delete').forEach((btn) => { @@ -457,14 +463,14 @@ function renderPasswords() { const noMsg = $('#noPasswordsMsg'); if (passwordMappings.length === 0) { - tbody.innerHTML = ''; + tbody.replaceChildren(); noMsg.style.display = 'block'; return; } noMsg.style.display = 'none'; - tbody.innerHTML = passwordMappings.map(m => { + safeHTML(tbody, passwordMappings.map(m => { const displayReal = passwordsRevealed ? escapeHtml(m.real) : '••••••••'; @@ -481,7 +487,7 @@ function renderPasswords() { `; - }).join(''); + }).join('')); // Bind delete tbody.querySelectorAll('.btn-delete-pw').forEach(btn => { @@ -518,11 +524,11 @@ async function renderLog() { const list = $('#logList'); if (log.length === 0) { - list.innerHTML = '
No activity logged
'; + safeHTML(list, '
No activity logged
'); return; } - list.innerHTML = log + safeHTML(list, log .slice(0, 100) .map((entry) => { const time = new Date(entry.timestamp).toLocaleString(); @@ -535,7 +541,7 @@ async function renderLog() { `; }) - .join(''); + .join('')); } // --- Custom Domains --- @@ -582,18 +588,18 @@ function renderDomains() { const domains = settings.customDomains || []; if (domains.length === 0) { - list.innerHTML = '
No custom domains. Built-in sites (Claude, ChatGPT, Grok, Gemini, localhost) are always active.
'; + safeHTML(list, '
No custom domains. Built-in sites (Claude, ChatGPT, Grok, Gemini, localhost) are always active.
'); return; } - list.innerHTML = domains + safeHTML(list, domains .map((d, i) => `
${escapeHtml(d)}
`) - .join(''); + .join('')); list.querySelectorAll('.btn-remove-domain').forEach((btn) => { btn.addEventListener('click', async () => { @@ -1239,11 +1245,11 @@ async function renderVersionHistory() { const snapshots = await VersionHistory.getSnapshots(); if (snapshots.length === 0) { - list.innerHTML = '
No snapshots yet. Snapshots are created on each sync.
'; + safeHTML(list, '
No snapshots yet. Snapshots are created on each sync.
'); return; } - list.innerHTML = snapshots.map(s => { + safeHTML(list, snapshots.map(s => { const time = new Date(s.timestamp).toLocaleString(); const mappingCount = (s.data?.mappings || []).length; return `
@@ -1254,7 +1260,7 @@ async function renderVersionHistory() {
`; - }).join(''); + }).join('')); list.querySelectorAll('.btn-restore-snapshot').forEach(btn => { btn.addEventListener('click', async () => { @@ -1299,13 +1305,13 @@ async function renderDevices() { const entries = Object.values(devices); if (entries.length === 0) { - list.innerHTML = '
No devices synced yet. Push or pull to register this device.
'; + safeHTML(list, '
No devices synced yet. Push or pull to register this device.
'); return; } entries.sort((a, b) => (b.lastSync || 0) - (a.lastSync || 0)); - list.innerHTML = ` + safeHTML(list, `
@@ -1322,7 +1328,7 @@ async function renderDevices() { `; }).join('')} -
Device Browser${!isCurrent ? `` : ''}
`; + `); list.querySelectorAll('.btn-remove-device').forEach(btn => { btn.addEventListener('click', async () => { @@ -1399,9 +1405,9 @@ async function showOrgJoined() { const compliance = await OrgPolicy.checkCompliance(); const statusEl = $('#orgComplianceStatus'); if (compliance.compliant) { - statusEl.innerHTML = '✓ Compliant — all required fields configured'; + safeHTML(statusEl, '✓ Compliant — all required fields configured'); } else { - statusEl.innerHTML = `Missing: ${compliance.missing.join(', ')}`; + safeHTML(statusEl, `Missing: ${compliance.missing.join(', ')}`); } const reqMappings = policy?.requiredMappings || []; @@ -1553,7 +1559,7 @@ async function checkConflicts() { function renderConflicts(conflicts) { const list = $('#conflictList'); - list.innerHTML = conflicts.map(c => ` + safeHTML(list, conflicts.map(c => `
${escapeHtml(c.path)}
@@ -1571,7 +1577,7 @@ function renderConflicts(conflicts) {
- `).join(''); + `).join('')); list.querySelectorAll('.btn-resolve').forEach(btn => { btn.addEventListener('click', async () => { @@ -1632,10 +1638,10 @@ async function handleBulkImport(e) { result.identity.usernames.filter(u => !u.substitute).length + result.identity.phones.filter(p => !p.substitute).length; - $('#bulkImportSummary').innerHTML = ` + safeHTML($('#bulkImportSummary'), ` Found: ${parts.join(', ')}. ${needsMapping > 0 ? `${needsMapping} item(s) need substitutes — you can add them after import.` : ''} - `; + `); // Build preview list const items = []; @@ -1655,8 +1661,8 @@ async function handleBulkImport(e) { items.push(`
${escapeHtml(m.category)}: ${escapeHtml(m.real)}${m.substitute ? ' → ' + escapeHtml(m.substitute) : ' needs substitute'}
`); } - $('#bulkImportItems').innerHTML = items.slice(0, 50).join('') + - (items.length > 50 ? `
+${items.length - 50} more...
` : ''); + safeHTML($('#bulkImportItems'), items.slice(0, 50).join('') + + (items.length > 50 ? `
+${items.length - 50} more...
` : '')); $('#bulkImportPreview').style.display = 'block'; diff --git a/src/popup/popup.js b/src/popup/popup.js index 4fdcf25..76de432 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -18,6 +18,12 @@ let settings = {}; const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); +// --- Safe innerHTML replacement (AMO-compliant) --- +function safeHTML(el, html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + el.replaceChildren(...doc.body.childNodes); +} + // --- Init --- document.addEventListener('DOMContentLoaded', async () => { // Check if locked BEFORE trying to read sensitive data @@ -302,11 +308,11 @@ async function showLockedUI() { // --- Profiles --- function renderProfileSelector() { const select = $('#profileSelect'); - select.innerHTML = profiles.map(p => + safeHTML(select, profiles.map(p => `` - ).join(''); + ).join('')); const profile = profiles.find(p => p.id === currentProfileId); $('#profileActive').checked = profile?.active ?? true; @@ -355,7 +361,7 @@ function renderFieldList(fieldName, items) { items = [{ real: '', substitute: '', type: config.defaultType || '' }]; } - container.innerHTML = items.map((item, i) => { + safeHTML(container, items.map((item, i) => { let typeHtml = ''; if (config.typeOptions) { typeHtml = ` `; - }).join(''); + }).join('')); // Bind remove buttons container.querySelectorAll('.btn-remove').forEach(btn => { @@ -420,13 +426,13 @@ function loadIdentityForm() { ).join('') + ``; } - tempDiv.innerHTML = `
+ safeHTML(tempDiv, `
${typeHtml} -
`; +
`); const row = tempDiv.firstElementChild; container.appendChild(row); row.querySelector('.btn-remove').addEventListener('click', () => { @@ -576,11 +582,11 @@ function renderMappings() { const list = $('#mappingList'); if (mappings.length === 0) { - list.innerHTML = '
No mappings yet. Add your first one above.
'; + safeHTML(list, '
No mappings yet. Add your first one above.
'); return; } - list.innerHTML = mappings + safeHTML(list, mappings .map( (m) => `
@@ -597,7 +603,7 @@ function renderMappings() {
` ) - .join(''); + .join('')); // Bind actions list.querySelectorAll('.btn-delete').forEach((btn) => { @@ -631,11 +637,11 @@ async function renderActivity() { countEl.textContent = `${log.length} substitution${log.length !== 1 ? 's' : ''} logged`; if (log.length === 0) { - list.innerHTML = '
No activity yet.
'; + safeHTML(list, '
No activity yet.
'); return; } - list.innerHTML = log + safeHTML(list, log .slice(0, 50) .map((entry) => { const time = new Date(entry.timestamp).toLocaleTimeString([], { @@ -653,7 +659,7 @@ async function renderActivity() { `; }) - .join(''); + .join('')); } // --- Test Diff (Strip: real → fake) --- @@ -663,7 +669,7 @@ function renderTestDiff() { const stats = $('#diffStats'); if (!input) { - output.innerHTML = ''; + output.replaceChildren(); stats.textContent = ''; return; } @@ -703,7 +709,7 @@ function renderTestDiff() { `${escapedReplaced}` ); } - output.innerHTML = html; + safeHTML(output, html); const smartCount = smartResult.replacements.length; const explicitCount = explicitResult.replacements.length; @@ -723,10 +729,12 @@ function renderTestDiff() { // Show PPI warnings below stats if (ppiWarnings.length > 0) { - stats.innerHTML += `
+ const ppiDiv = document.createElement('div'); + safeHTML(ppiDiv, `
Unconfigured PPI detected: ${ppiWarnings.map(w => `
${escapeHtml(w.value)} — ${w.hint}
`).join('')} -
`; +
`); + stats.appendChild(ppiDiv); } } @@ -737,7 +745,7 @@ function renderRevealDiff() { const stats = $('#revealStats'); if (!input) { - output.innerHTML = ''; + output.replaceChildren(); stats.textContent = ''; return; } @@ -783,7 +791,7 @@ function renderRevealDiff() { `${escapedReal}` ); } - output.innerHTML = html; + safeHTML(output, html); stats.textContent = `${totalCount} value${totalCount !== 1 ? 's' : ''} revealed`; }