diff --git a/manifest.firefox.json b/manifest.firefox.json
index 2f4e3d1..47aa3ec 100644
--- a/manifest.firefox.json
+++ b/manifest.firefox.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Silent Send",
- "version": "0.9.20",
+ "version": "0.9.21",
"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 89c5293..12de1b0 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Silent Send",
- "version": "0.9.20",
+ "version": "0.9.21",
"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 c3aebd4..83f8c94 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "silent-send",
- "version": "0.9.20",
+ "version": "0.9.21",
"private": true,
"license": "MIT",
"description": "Browser extension that substitutes personal data before sending to AI services",
diff --git a/src/background/service-worker.js b/src/background/service-worker.js
index db0b985..f7c0963 100644
--- a/src/background/service-worker.js
+++ b/src/background/service-worker.js
@@ -21,7 +21,6 @@ const tabCounts = new Map();
// Built-in URL patterns
const BUILTIN_URL_PATTERNS = [
- // AI services
'https://claude.ai/*',
'https://chatgpt.com/*',
'https://chat.openai.com/*',
@@ -29,19 +28,6 @@ const BUILTIN_URL_PATTERNS = [
'https://grok.x.ai/*',
'https://x.com/i/grok*',
'https://gemini.google.com/*',
- 'https://www.perplexity.ai/*',
- 'https://copilot.microsoft.com/*',
- 'https://chat.deepseek.com/*',
- 'https://huggingface.co/chat/*',
- 'https://poe.com/*',
- // Developer & support sites
- 'https://github.com/*',
- 'https://gitlab.com/*',
- 'https://www.reddit.com/*',
- 'https://old.reddit.com/*',
- 'https://stackoverflow.com/*',
- 'https://pastebin.com/*',
- // Local
'http://localhost/*',
'http://127.0.0.1/*',
];
@@ -76,44 +62,6 @@ api.tabs.onRemoved.addListener((tabId) => {
// --- Dynamic injection for custom domains ---
-const DYNAMIC_SCRIPT_ID = 'ss-custom-domains';
-
-/**
- * Sync the dynamically registered content script with the current
- * custom domains list. Uses scripting.registerContentScripts so custom
- * domains inject at document_start (like built-in sites) and persist
- * across service worker restarts.
- */
-async function syncCustomDomainScripts() {
- const settings = await Storage.getSettings();
- const customDomains = settings.customDomains || [];
-
- // Build match patterns from domains (e.g. "https://my-ai.com" → "https://my-ai.com/*")
- const matches = customDomains.map(d => d.replace(/\/$/, '') + '/*');
-
- try {
- // Remove existing dynamic script first
- await api.scripting.unregisterContentScripts({ ids: [DYNAMIC_SCRIPT_ID] }).catch(() => {});
-
- if (matches.length > 0) {
- await api.scripting.registerContentScripts([{
- id: DYNAMIC_SCRIPT_ID,
- matches,
- js: ['src/content/injector.js'],
- css: ['src/content/content.css'],
- runAt: 'document_start',
- allFrames: true,
- }]);
- }
- } catch (e) {
- console.warn('[Silent Send] Failed to register custom domain scripts:', e);
- }
-}
-
-/**
- * Inject on an already-open tab for a newly added custom domain.
- * Only needed for tabs that were open before the content script was registered.
- */
async function injectOnCustomDomain(tabId, tabUrl) {
const settings = await Storage.getSettings();
const customDomains = settings.customDomains || [];
@@ -164,17 +112,6 @@ api.runtime.onMessage.addListener((message, sender, sendResponse) => {
});
const messageHandlers = {
- async 'get:decrypted-config'(_message, _sender, sendResponse) {
- try {
- const mappings = await Storage.getMappings();
- const identity = await Storage.getIdentity();
- const settings = await Storage.getSettings();
- sendResponse({ mappings, identity, settings });
- } catch {
- sendResponse(null);
- }
- },
-
async 'substitution:performed'(message, sender) {
const tabId = sender.tab?.id;
if (tabId == null) return;
@@ -437,11 +374,6 @@ api.storage.onChanged.addListener(async (changes, areaName) => {
const settings = await Storage.getSettings();
await updateIcon(settings);
- // Re-register dynamic content scripts when custom domains change
- if (changes.ss_settings && areaName === 'local') {
- await syncCustomDomainScripts();
- }
-
// Push to browser.storage.sync when local data changes (same-browser cross-device)
if (areaName === 'local' && settings.browserSync) {
await SilentSendSync.pushToSyncStorage();
@@ -567,7 +499,6 @@ api.runtime.onInstalled.addListener(async () => {
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
const settings = await Storage.getSettings();
await updateIcon(settings);
- await syncCustomDomainScripts();
// Set up alarms on install
await setupAutoSyncAlarm();
await setupOrgPolicyAlarm();
@@ -577,7 +508,6 @@ api.runtime.onInstalled.addListener(async () => {
(async () => {
const settings = await Storage.getSettings();
await updateIcon(settings);
- await syncCustomDomainScripts();
// Check if extension is locked (encrypted data, no cached key)
const locked = await Storage.isLocked();
diff --git a/src/content/content.css b/src/content/content.css
index 3722767..dd5a5f6 100644
--- a/src/content/content.css
+++ b/src/content/content.css
@@ -35,7 +35,7 @@
border-radius: 2px;
}
-/* Auto-detect PII warning banner */
+/* Auto-detect PPI warning banner */
.ss-autodetect-warning {
position: fixed;
top: 16px;
@@ -136,7 +136,7 @@
font-style: italic;
}
-/* Pre-send PII warning (spellcheck-style, appears while typing) */
+/* Pre-send PPI warning (spellcheck-style, appears while typing) */
.ss-presend-warning {
position: fixed;
top: 16px;
@@ -220,21 +220,6 @@
.ss-ps-add:hover { background: rgba(74, 222, 128, 0.15); }
.ss-ps-add:disabled { border-color: #333; cursor: default; }
-.ss-ps-ignore {
- background: #4b5563;
- border: none;
- color: #d1d5db;
- font-size: 9px;
- cursor: pointer;
- flex-shrink: 0;
- padding: 3px 6px;
- border-radius: 3px;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.ss-ps-ignore:hover { color: #e5e7eb; text-decoration: underline; }
-
/* Floating reveal mode indicator */
.ss-reveal-badge {
position: fixed;
@@ -259,113 +244,3 @@
opacity: 1;
transform: translateY(0);
}
-
-/* Document scan preview overlay */
-.ss-doc-preview {
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%) scale(0.95);
- max-width: 480px;
- width: 90vw;
- background: #1a1a1a;
- color: #e5e7eb;
- border: 1px solid #f59e0b;
- border-radius: 12px;
- padding: 16px 20px;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- font-size: 12px;
- z-index: 9999999;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.2s, transform 0.2s;
- max-height: 70vh;
- overflow-y: auto;
-}
-
-.ss-doc-preview.visible {
- opacity: 1;
- transform: translate(-50%, -50%) scale(1);
- pointer-events: auto;
-}
-
-.ss-dp-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
-}
-
-.ss-dp-count {
- font-size: 11px;
- color: #f59e0b;
- font-weight: 600;
-}
-
-.ss-dp-note {
- font-size: 11px;
- color: #9ca3af;
- margin-bottom: 10px;
- font-style: italic;
-}
-
-.ss-dp-items {
- max-height: 200px;
- overflow-y: auto;
- margin-bottom: 12px;
-}
-
-.ss-dp-item {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 4px 0;
- border-bottom: 1px solid #333;
- font-size: 11px;
-}
-
-.ss-dp-item:last-child { border-bottom: none; }
-
-.ss-dp-orig {
- color: #f87171;
- background: #1f1f1f;
- padding: 2px 5px;
- border-radius: 3px;
-}
-
-.ss-dp-repl {
- color: #4ade80;
- background: #1f1f1f;
- padding: 2px 5px;
- border-radius: 3px;
-}
-
-.ss-dp-actions {
- display: flex;
- gap: 8px;
-}
-
-.ss-dp-btn {
- flex: 1;
- padding: 8px 12px;
- border: none;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 500;
- cursor: pointer;
-}
-
-.ss-dp-confirm {
- background: #10b981;
- color: #fff;
-}
-
-.ss-dp-confirm:hover { background: #059669; }
-
-.ss-dp-cancel {
- background: #374151;
- color: #e5e7eb;
-}
-
-.ss-dp-cancel:hover { background: #4b5563; }
diff --git a/src/content/content.js b/src/content/content.js
index af8b13d..29e0796 100644
--- a/src/content/content.js
+++ b/src/content/content.js
@@ -10,14 +10,6 @@
(function () {
'use strict';
- // --- Safe innerHTML replacement (AMO-compliant, page world) ---
- function safeHTML(el, html) {
- const template = document.createElement('template');
- template.innerHTML = html;
- // Convert to static array — childNodes is live and shrinks as nodes move
- el.replaceChildren(...Array.from(template.content.childNodes));
- }
-
// ============================================================
// Load config from the injector script's data attribute
// ============================================================
@@ -26,13 +18,12 @@
let settings = { enabled: true, revealMode: false, showHighlights: false };
try {
- const configEl = document.getElementById('ss-config-data');
+ const configEl = document.querySelector('script[data-ss-config]');
if (configEl) {
- const config = JSON.parse(configEl.textContent);
+ const config = JSON.parse(configEl.getAttribute('data-ss-config'));
mappings = config.mappings || [];
identity = config.identity || {};
settings = { ...settings, ...(config.settings || {}) };
- configEl.remove(); // clean up
}
} catch (e) {
console.warn('[Silent Send] Failed to parse initial config:', e);
@@ -183,32 +174,6 @@
replacements.push({ original: matched, replaced: sub, category: 'name', pattern: 'smart' });
return sub;
});
- // Concatenated forms: JohnSmith, johnsmith, john.smith, john_smith, john-smith
- // Also reversed: SmithJohn, smithjohn, smith.john, etc.
- const f = first.real, l = last.real;
- const fs = first.substitute, ls = last.substitute;
- const concatPatterns = [
- // first+last
- [f + l, fs + ls],
- [f + '.' + l, fs + '.' + ls],
- [f + '_' + l, fs + '_' + ls],
- [f + '-' + l, fs + '-' + ls],
- // last+first
- [l + f, ls + fs],
- [l + '.' + f, ls + '.' + fs],
- [l + '_' + f, ls + '_' + fs],
- [l + '-' + f, ls + '-' + fs],
- ];
- for (const [real, sub] of concatPatterns) {
- result = result.replace(new RegExp('\\b' + esc(real) + '\\b', 'gi'), (matched) => {
- // Preserve case: all-lower→lower, ALL-UPPER→upper, else use sub as-is
- let replacement = sub;
- if (matched === matched.toLowerCase()) replacement = sub.toLowerCase();
- else if (matched === matched.toUpperCase()) replacement = sub.toUpperCase();
- replacements.push({ original: matched, replaced: replacement, category: 'name', pattern: 'smart-concat' });
- return replacement;
- });
- }
}
}
@@ -289,7 +254,7 @@
// ============================================================
// Combined substitution: smart patterns + explicit + secret scan
- // + auto-detect warning for unconfigured PII
+ // + auto-detect warning for unconfigured PPI
// ============================================================
function substituteAll(text) {
const allReplacements = [];
@@ -304,18 +269,18 @@
// 3. Secret scanner (API keys, tokens, SSNs, credit cards, etc.)
let finalText = explicit.text;
- if (settings.autoRedact !== false) {
- const secrets = runAutoRedact(finalText);
+ if (settings.secretScanning !== false) {
+ const secrets = scanAndRedactSecrets(finalText);
allReplacements.push(...secrets.redactions);
finalText = secrets.text;
}
- // 4. Auto-detect: scan the FINAL text for unconfigured PII
+ // 4. Auto-detect: scan the FINAL text for unconfigured PPI
// Auto-redact if enabled, otherwise just warn
if (settings.autoDetect !== false) {
- const warnings = autoDetectPII(finalText, identity);
+ const warnings = autoDetectPPI(finalText, identity);
if (warnings.length > 0) {
- // Auto-redact detected PII in the outbound text
+ // 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];
@@ -344,9 +309,9 @@
}
// ============================================================
- // Auto-Detect PII Scanner (inline for page world)
+ // Auto-Detect PPI Scanner (inline for page world)
// ============================================================
- const PII_PATTERNS = [
+ 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' },
@@ -383,26 +348,17 @@
// Common English words that are capitalized but aren't proper nouns.
// Used by the proper noun heuristic to reduce false positives.
- // Includes common verbs, nouns, adjectives that appear in titles,
- // headings, UI buttons, and instructions.
const COMMON_CAPITALIZED = new Set([
- // Prepositions, conjunctions, articles, pronouns
'the', 'a', 'an', 'and', 'or', 'but', 'if', 'then', 'else', 'when',
'at', 'by', 'for', 'with', 'about', 'against', 'between', 'through',
'during', 'before', 'after', 'above', 'below', 'to', 'from', 'up',
'down', 'in', 'out', 'on', 'off', 'over', 'under', 'again', 'further',
- 'once', 'here', 'there', 'all', 'each', 'every', 'both',
+ 'then', 'once', 'here', 'there', 'all', 'each', 'every', 'both',
'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not',
'only', 'own', 'same', 'so', 'than', 'too', 'very', 'can', 'will',
'just', 'should', 'now', 'also', 'into', 'could', 'would', 'may',
'might', 'shall', 'must', 'need', 'have', 'has', 'had', 'do', 'does',
'did', 'be', 'is', 'am', 'are', 'was', 'were', 'been', 'being',
- 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her',
- 'us', 'them', 'my', 'your', 'his', 'its', 'our', 'their',
- 'this', 'that', 'these', 'those', 'what', 'who', 'how', 'why',
- 'which', 'where', 'when', 'while', 'since', 'because', 'although',
- 'however', 'therefore', 'moreover', 'furthermore', 'nevertheless',
- // Common verbs (appear in titles, headings, buttons, instructions)
'get', 'got', 'make', 'made', 'go', 'went', 'gone', 'take', 'took',
'come', 'came', 'see', 'saw', 'know', 'knew', 'think', 'thought',
'say', 'said', 'tell', 'told', 'give', 'gave', 'find', 'found',
@@ -415,110 +371,25 @@
'buy', 'wait', 'serve', 'die', 'send', 'expect', 'build', 'stay',
'fall', 'cut', 'reach', 'kill', 'remain', 'suggest', 'raise', 'pass',
'sell', 'require', 'report', 'decide', 'pull', 'develop', 'note',
- 'generate', 'design', 'manage', 'process', 'handle', 'check', 'verify',
- 'submit', 'apply', 'accept', 'reject', 'approve', 'deny', 'confirm',
- 'cancel', 'delete', 'remove', 'edit', 'modify', 'view', 'display',
- 'search', 'filter', 'sort', 'select', 'choose', 'pick', 'enter',
- 'input', 'output', 'upload', 'download', 'install', 'uninstall',
- 'enable', 'disable', 'activate', 'deactivate', 'toggle', 'switch',
- 'connect', 'disconnect', 'sync', 'refresh', 'reload', 'reset',
- 'save', 'load', 'store', 'restore', 'backup', 'export', 'import',
- 'copy', 'paste', 'drag', 'drop', 'click', 'press', 'hold', 'release',
- 'scroll', 'zoom', 'resize', 'expand', 'collapse', 'hide', 'reveal',
- 'lock', 'unlock', 'encrypt', 'decrypt', 'sign', 'register', 'login',
- 'logout', 'subscribe', 'unsubscribe', 'share', 'publish', 'deploy',
- 'launch', 'test', 'debug', 'fix', 'patch', 'merge', 'split', 'join',
- 'link', 'attach', 'detach', 'insert', 'append', 'prepend', 'wrap',
- 'format', 'parse', 'convert', 'transform', 'translate', 'compile',
- 'execute', 'render', 'animate', 'validate', 'sanitize', 'escape',
- // Common nouns (appear in titles, headings, labels)
- 'account', 'action', 'address', 'alert', 'analysis', 'answer',
- 'application', 'area', 'article', 'asset', 'background', 'badge',
- 'banner', 'board', 'body', 'border', 'bottom', 'box', 'brand',
- 'browser', 'buffer', 'button', 'cache', 'calendar', 'card', 'case',
- 'category', 'center', 'channel', 'chart', 'chat', 'child', 'choice',
- 'class', 'client', 'cloud', 'cluster', 'code', 'collection', 'color',
- 'column', 'command', 'comment', 'community', 'company', 'component',
- 'config', 'configuration', 'connection', 'console', 'contact',
- 'container', 'content', 'context', 'control', 'corner', 'count',
- 'country', 'cover', 'custom', 'dashboard', 'data', 'database',
- 'date', 'day', 'default', 'description', 'design', 'desktop',
- 'detail', 'device', 'dialog', 'directory', 'document', 'domain',
- 'draft', 'driver', 'edge', 'editor', 'element', 'email', 'end',
- 'engine', 'entry', 'environment', 'error', 'event', 'example',
- 'exception', 'extension', 'feature', 'feedback', 'field', 'file',
- 'filter', 'folder', 'font', 'footer', 'form', 'format', 'frame',
- 'function', 'gallery', 'general', 'global', 'grid', 'group',
- 'guide', 'handler', 'header', 'health', 'help', 'helper', 'history',
- 'home', 'host', 'icon', 'image', 'index', 'info', 'input', 'instance',
- 'interface', 'issue', 'item', 'job', 'key', 'label', 'language',
- 'layout', 'level', 'library', 'light', 'limit', 'line', 'link',
- 'list', 'local', 'location', 'log', 'logo', 'main', 'manager',
- 'manual', 'map', 'margin', 'master', 'match', 'media', 'member',
- 'memory', 'menu', 'message', 'method', 'middle', 'mobile', 'modal',
- 'mode', 'model', 'module', 'monitor', 'name', 'navigation', 'network',
- 'node', 'note', 'notification', 'number', 'object', 'option',
- 'order', 'origin', 'output', 'overlay', 'overview', 'owner', 'package',
- 'padding', 'page', 'panel', 'parent', 'parser', 'password', 'path',
- 'pattern', 'permission', 'photo', 'pipeline', 'placeholder', 'plan',
- 'platform', 'player', 'plugin', 'point', 'policy', 'pool', 'popup',
- 'port', 'position', 'post', 'power', 'preview', 'primary', 'print',
- 'priority', 'process', 'product', 'profile', 'program', 'progress',
- 'project', 'prompt', 'property', 'protocol', 'provider', 'proxy',
- 'public', 'query', 'queue', 'quick', 'radio', 'range', 'rate',
- 'reader', 'record', 'region', 'release', 'remote', 'render',
- 'report', 'request', 'resource', 'response', 'result', 'review',
- 'role', 'root', 'route', 'row', 'rule', 'runtime', 'sample',
- 'scanner', 'schema', 'scope', 'screen', 'script', 'search',
- 'section', 'security', 'select', 'sender', 'server', 'service',
- 'session', 'setting', 'settings', 'setup', 'share', 'shell',
- 'shortcut', 'sidebar', 'signal', 'simple', 'single', 'site', 'size',
- 'slider', 'slot', 'snapshot', 'socket', 'solution', 'source', 'space',
- 'stage', 'standard', 'start', 'state', 'status', 'step', 'stop',
- 'storage', 'store', 'stream', 'string', 'style', 'subject',
- 'success', 'summary', 'support', 'switch', 'symbol', 'syntax',
- 'system', 'table', 'target', 'task', 'team', 'template', 'terminal',
- 'test', 'text', 'theme', 'thread', 'time', 'timer', 'title', 'token',
- 'tool', 'toolbar', 'tooltip', 'top', 'total', 'track', 'traffic',
- 'tree', 'trigger', 'type', 'unit', 'update', 'upload', 'user',
- 'util', 'utility', 'value', 'variable', 'version', 'video', 'view',
- 'virtual', 'warning', 'watch', 'web', 'widget', 'width', 'window',
- 'wizard', 'word', 'worker', 'workspace', 'wrapper', 'zone',
- // Common adjectives
- 'active', 'advanced', 'available', 'basic', 'best', 'better', 'blank',
- 'bold', 'clean', 'clear', 'close', 'closed', 'complete', 'complex',
- 'connected', 'correct', 'critical', 'current', 'dark', 'deep',
- 'detailed', 'different', 'direct', 'double', 'dynamic', 'early',
- 'easy', 'empty', 'entire', 'equal', 'essential', 'exact', 'extra',
- 'fast', 'final', 'fine', 'fixed', 'flat', 'free', 'fresh', 'front',
- 'full', 'generic', 'given', 'good', 'great', 'green', 'hard',
- 'hidden', 'high', 'hot', 'huge', 'human', 'initial', 'inner',
- 'internal', 'invalid', 'large', 'late', 'latest', 'left', 'light',
- 'live', 'long', 'low', 'major', 'maximum', 'middle', 'minimum',
- 'minor', 'mixed', 'modern', 'multiple', 'native', 'natural',
- 'nested', 'neutral', 'new', 'normal', 'null', 'old', 'online',
- 'open', 'optional', 'outer', 'overall', 'parallel', 'partial',
- 'pending', 'plain', 'popular', 'possible', 'previous', 'primary',
- 'private', 'proper', 'protected', 'quick', 'random', 'raw', 'ready',
- 'real', 'recent', 'red', 'related', 'relative', 'remote', 'required',
- 'responsive', 'rich', 'right', 'round', 'safe', 'secure', 'selected',
- 'sensitive', 'separate', 'serial', 'shared', 'short', 'silent',
- 'similar', 'simple', 'single', 'small', 'smart', 'smooth', 'soft',
- 'solid', 'special', 'specific', 'stable', 'standard', 'static',
- 'strict', 'strong', 'supported', 'sweet', 'thin', 'tight', 'tiny',
- 'total', 'true', 'unique', 'universal', 'unknown', 'upper', 'valid',
- 'various', 'virtual', 'visible', 'visual', 'warm', 'weak', 'white',
- 'whole', 'wide', 'wild',
- // Programming/tech terms
+ 'however', 'because', 'although', 'since', 'while', 'where', 'which',
+ 'what', 'who', 'how', 'why', 'this', 'that', 'these', 'those',
+ 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her',
+ 'us', 'them', 'my', 'your', 'his', 'its', 'our', 'their',
+ 'new', 'old', 'big', 'small', 'long', 'short', 'good', 'bad',
+ 'great', 'little', 'right', 'left', 'first', 'last', 'next',
+ 'sure', 'like', 'well', 'back', 'still', 'even', 'much', 'many',
+ // Programming/tech words that appear capitalized
'string', 'number', 'boolean', 'object', 'array', 'function', 'class',
'type', 'error', 'null', 'undefined', 'true', 'false', 'return',
'import', 'export', 'default', 'const', 'let', 'var', 'async', 'await',
'try', 'catch', 'throw', 'finally', 'switch', 'case', 'break',
- 'example', 'warning', 'important', 'todo', 'fixme', 'hack',
- // Common sentence starters
+ 'note', 'example', 'warning', 'important', 'todo', 'fixme', 'hack',
+ 'step', 'option', 'result', 'value', 'key', 'data', 'info',
+ 'file', 'code', 'test', 'debug', 'config', 'setup', 'update',
+ // Common sentence starters that aren't names
'please', 'thanks', 'hello', 'hi', 'hey', 'dear', 'sincerely',
- 'regards', 'best', 'cheers', 'sorry', 'yes', 'ok', 'okay',
- // Days and months
+ 'regards', 'best', 'cheers', 'sorry', 'yes', 'no', 'ok', 'okay',
+ // Days and months (not PPI)
'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
'january', 'february', 'march', 'april', 'may', 'june', 'july',
'august', 'september', 'october', 'november', 'december',
@@ -529,38 +400,46 @@
/**
* Detect proper nouns (potential names, company names, project names)
* that aren't configured in identity. Uses capitalization heuristics:
- * - ONLY multi-word capitalized sequences (e.g. "Acme Corp", "Project Atlas")
- * - Single capitalized words are too noisy — every sentence starts with one
+ * - Capitalized words not at the start of a sentence
+ * - Multi-word capitalized sequences (e.g. "Acme Corp", "Project Atlas")
* - Filters out common English words and programming terms
*/
function detectProperNouns(text, configured) {
const findings = [];
- // Only match TWO OR MORE consecutive capitalized words
- // Single capitalized words cause too many false positives
- const re = /\b([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,})+)\b/g;
+ // Match capitalized words that aren't at the very start of the text
+ // and aren't after a period/newline (sentence start)
+ const re = /(?:^|[.!?\n]\s*)?([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,})*)/g;
let m;
while ((m = re.exec(text)) !== null) {
const fullMatch = m[1];
if (!fullMatch) continue;
- // Split into individual words and filter common ones
+ // Check if this is at the start of a sentence
+ const before = text.slice(Math.max(0, m.index - 2), m.index);
+ const isSentenceStart = m.index === 0 || /[.!?\n]\s*$/.test(before);
+
+ // Split into individual words and check each
const words = fullMatch.split(/\s+/);
const properWords = words.filter(w =>
w.length >= 3 &&
!COMMON_CAPITALIZED.has(w.toLowerCase()) &&
- !configured.has(w.toLowerCase()) &&
- !ignoredValues.has(w.toLowerCase())
+ !configured.has(w.toLowerCase())
);
- if (properWords.length < 2) continue; // need at least 2 proper words
+ if (properWords.length === 0) continue;
+ // Single capitalized word at sentence start = likely not a proper noun
+ if (isSentenceStart && properWords.length === 1 && words.length === 1) continue;
+
+ // Multi-word capitalized sequence is likely a proper noun
+ // Single capitalized word mid-sentence is likely a proper noun
const value = properWords.join(' ');
- if (value.length >= 5 && !configured.has(value.toLowerCase()) && !ignoredValues.has(value.toLowerCase())) {
+ if (value.length >= 3 && !configured.has(value.toLowerCase())) {
findings.push({
name: 'Possible Name/Org',
value,
- hint: 'Capitalized phrase — could be a name, company, or project',
+ hint: 'Capitalized word — could be a name, company, or project',
category: 'name',
});
}
@@ -575,11 +454,11 @@
});
}
- function autoDetectPII(text, ident) {
+ function autoDetectPPI(text, ident) {
if (!text || text.length < 5) return [];
const hasContext = CONTEXT_WORDS_RE.test(text);
- // Build skip set from configured values (identity + explicit mappings)
+ // Build skip set from configured values
const configured = new Set();
if (ident) {
const addAll = (arr, key) => (arr || []).forEach(item => {
@@ -589,32 +468,24 @@
addAll(ident.names); addAll(ident.emails);
addAll(ident.usernames); addAll(ident.hostnames); addAll(ident.phones);
}
- // Also skip values that are already in explicit mappings
- for (const m of mappings) {
- if (m.real) configured.add(m.real.toLowerCase());
- if (m.substitute) configured.add(m.substitute.toLowerCase());
- }
const findings = [];
- for (const pat of PII_PATTERNS) {
+ for (const pat of PPI_PATTERNS) {
if (pat.contextRequired && !hasContext) continue;
pat.re.lastIndex = 0;
let m;
while ((m = pat.re.exec(text)) !== null) {
const val = m[0];
if (configured.has(val.toLowerCase())) continue;
- if (ignoredValues.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 });
}
}
// Proper noun heuristic — catch names, company names, project names
- // Disabled by default (too many false positives). Enable in Options.
- if (settings.detectProperNouns) {
- const properNouns = detectProperNouns(text, configured);
- findings.push(...properNouns);
- }
+ // that aren't configured in identity
+ const properNouns = detectProperNouns(text, configured);
+ findings.push(...properNouns);
// Deduplicate by value
const seen = new Set();
@@ -648,15 +519,15 @@
const more = warnings.length > 5 ? `
+${warnings.length - 5} more
` : '';
- safeHTML(warningEl, `
+ warningEl.innerHTML = `
${items}
${more}
- `);
+ `;
warningEl.classList.add('visible');
@@ -676,7 +547,7 @@
// Secret Scanner (inline for page world)
// Detects API keys, tokens, passwords, SSNs, credit cards, etc.
// ============================================================
- const REDACT_PATTERNS = [
+ const SECRET_PATTERNS = [
// OpenAI
{ name: 'OpenAI Key', re: /\bsk-[A-Za-z0-9]{20,}\b/g, to: '[REDACTED-OPENAI-KEY]' },
{ name: 'OpenAI Project Key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/g, to: '[REDACTED-OPENAI-KEY]' },
@@ -714,21 +585,11 @@
{ name: 'Credit Card', re: /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g, to: '[REDACTED-CARD]' },
];
- function runAutoRedact(text) {
+ function scanAndRedactSecrets(text) {
const redactions = [];
let result = text;
- // Combine built-in + custom patterns
- const allPatterns = [...REDACT_PATTERNS];
- const custom = settings.customRedactPatterns || [];
- for (const cp of custom) {
- if (!cp.enabled || !cp.pattern) continue;
- try {
- allPatterns.push({ name: cp.name, re: new RegExp(cp.pattern, 'g'), to: cp.redact });
- } catch { /* invalid regex — skip */ }
- }
-
- for (const pat of allPatterns) {
+ for (const pat of SECRET_PATTERNS) {
pat.re.lastIndex = 0;
const matches = [];
let m;
@@ -746,7 +607,7 @@
redactions.push({
original: match.value.slice(0, 8) + '...', // Don't log the full secret
replaced: replacement,
- category: 'redact',
+ category: 'secret',
pattern: pat.name,
});
result =
@@ -769,30 +630,6 @@
// ============================================================
const sessionSubstitutions = new Map();
- // Values the user has explicitly ignored via the "Ignore" button.
- // Persisted to storage so they stay dismissed across page reloads.
- const ignoredValues = new Set();
-
- // Load ignored values from storage
- (async () => {
- const stored = await getStorageData('ss_ignored_ppi');
- if (Array.isArray(stored)) {
- for (const v of stored) ignoredValues.add(v.toLowerCase());
- }
- })();
-
- function addIgnoredValue(value) {
- ignoredValues.add(value.toLowerCase());
- // Persist
- getStorageData('ss_ignored_ppi').then(stored => {
- const list = Array.isArray(stored) ? stored : [];
- if (!list.includes(value.toLowerCase())) {
- list.push(value.toLowerCase());
- setStorageData('ss_ignored_ppi', list);
- }
- });
- }
-
// ============================================================
// Notify content script of substitutions (for badge + logging)
// ============================================================
@@ -800,25 +637,7 @@
// Record what was actually substituted so reveal knows
for (const r of replacements) {
if (r.replaced && r.original) {
- // Store full replacement with lowercase key for lookup
- sessionSubstitutions.set(r.replaced.toLowerCase(), {
- original: r.original,
- replaced: r.replaced,
- });
- // Also store individual words so reveal pairs match identity entries
- // e.g. "Ademo Demo" → store "ademo" and "demo" separately
- const replacedWords = r.replaced.split(/\s+/);
- const originalWords = r.original.split(/\s+/);
- if (replacedWords.length > 1) {
- for (let i = 0; i < replacedWords.length; i++) {
- if (replacedWords[i] && originalWords[i]) {
- sessionSubstitutions.set(replacedWords[i].toLowerCase(), {
- original: originalWords[i],
- replaced: replacedWords[i],
- });
- }
- }
- }
+ sessionSubstitutions.set(r.replaced.toLowerCase(), r.original);
}
}
@@ -938,103 +757,43 @@
return SKIP_URL_PATTERNS.some(p => p.test(url));
}
- window.fetch = async function (input, init) {
+ window.fetch = async function (url, options) {
if (!settings.enabled || !hasSubstitutions()) {
- return originalFetch.call(this, input, init);
- }
-
- // Handle both fetch(url, options) and fetch(Request) signatures
- let url, options;
- if (input instanceof Request) {
- url = input.url;
- // Clone the Request so we can read/modify the body
- options = {
- method: input.method,
- headers: input.headers,
- body: null, // will read below
- mode: input.mode,
- credentials: input.credentials,
- cache: input.cache,
- redirect: input.redirect,
- referrer: input.referrer,
- signal: input.signal,
- };
- // Read the body from the Request object
- try {
- const ct = input.headers.get('content-type') || '';
- if (ct.includes('json') || ct.includes('text')) {
- options.body = await input.text();
- } else {
- // Non-text body — pass through unmodified
- return originalFetch.call(this, input, init);
- }
- } catch {
- return originalFetch.call(this, input, init);
- }
- } else {
- url = input;
- options = init ? { ...init } : {};
+ return originalFetch.call(this, url, options);
}
const urlStr = typeof url === 'string' ? url : url?.url || '';
const method = (options?.method || 'GET').toUpperCase();
- // Convert non-string bodies to string where possible
- if (options.body && typeof options.body !== 'string' && !(options.body instanceof FormData)) {
- try {
- if (options.body instanceof Blob) {
- options.body = await options.body.text();
- } else if (options.body instanceof ArrayBuffer || ArrayBuffer.isView(options.body)) {
- options.body = new TextDecoder().decode(options.body);
- } else if (options.body instanceof URLSearchParams) {
- options.body = options.body.toString();
- }
- } catch { /* leave as-is */ }
- }
-
- // Only intercept POST/PUT/PATCH with a body
+ // Only intercept POST/PUT/PATCH with a string body
if (
(method === 'POST' || method === 'PUT' || method === 'PATCH') &&
- options?.body &&
+ options?.body && typeof options.body === 'string' &&
!shouldSkipUrl(urlStr)
) {
- // Handle FormData with file uploads
- if (options.body instanceof FormData) {
- try {
- const newFormData = await processFormData(options.body);
- if (newFormData) {
- options = { ...options, body: newFormData };
- }
- } catch (e) {
- console.warn('[Silent Send] FormData processing failed:', e);
- }
- }
- // Handle string bodies (JSON, raw text)
- else if (typeof options.body === 'string') {
- try {
- // Try JSON
- const body = JSON.parse(options.body);
- const { modified, replacements } = processBody(body);
+ try {
+ // Try JSON
+ const body = JSON.parse(options.body);
+ const { modified, replacements } = processBody(body);
- if (modified) {
- options = { ...options, body: JSON.stringify(body) };
- notifySubstitutions(replacements);
+ if (modified) {
+ options = { ...options, body: JSON.stringify(body) };
+ notifySubstitutions(replacements);
+ console.log(
+ `[Silent Send] Substituted ${replacements.length} value(s) in ${urlStr}`
+ );
+ }
+ } catch (e) {
+ // Not JSON — try raw string substitution (form data, etc.)
+ if (options.body.length > MIN_STRING_LENGTH) {
+ const result = substituteAll(options.body);
+ if (result.modified) {
+ options = { ...options, body: result.text };
+ notifySubstitutions(result.replacements);
console.log(
- `[Silent Send] Substituted ${replacements.length} value(s) in ${urlStr}`
+ `[Silent Send] Substituted ${result.replacements.length} value(s) in form body`
);
}
- } catch (e) {
- // Not JSON — try raw string substitution (form data, etc.)
- if (options.body.length > MIN_STRING_LENGTH) {
- const result = substituteAll(options.body);
- if (result.modified) {
- options = { ...options, body: result.text };
- notifySubstitutions(result.replacements);
- console.log(
- `[Silent Send] Substituted ${result.replacements.length} value(s) in form body`
- );
- }
- }
}
}
}
@@ -1042,369 +801,6 @@
return originalFetch.call(this, url, options);
};
-
-
- // ============================================================
- // Document Upload Processing
- //
- // Scans files in FormData uploads for PII. Supports PDF, DOCX,
- // XLSX, and text files. Shows preview for binary formats.
- // ============================================================
-
- async function processFormData(formData) {
- let modified = false;
- const newFormData = new FormData();
- const allReplacements = [];
-
- for (const [key, value] of formData.entries()) {
- if (value instanceof File && value.size > 0) {
- // Process the file through document scanner
- const usePreview = settings.docScanPreview !== false &&
- /\.(pdf|docx|xlsx)$/i.test(value.name);
-
- const result = await documentScan(value, value.name, {
- previewMode: usePreview,
- });
-
- if (result.preview && usePreview && result.replacements.length > 0) {
- // Show preview and wait for user confirmation
- const confirmed = await showDocScanPreview(result.preview, value.name);
- if (!confirmed) {
- // User cancelled — use original file
- newFormData.append(key, value);
- continue;
- }
- }
-
- if (result.replacements.length > 0 && !result.skipped) {
- let uploadFile = result.file;
-
- // If preview was confirmed and we have sanitized text, use it
- if (result._sanitizedText) {
- uploadFile = new Blob([result._sanitizedText], { type: 'text/plain' });
- }
-
- const newFile = new File([uploadFile], result.filename || value.name, {
- type: uploadFile.type || value.type,
- });
- newFormData.append(key, newFile);
- allReplacements.push(...result.replacements);
- modified = true;
- console.log(
- `[Silent Send] Substituted ${result.replacements.length} value(s) in file: ${value.name}`
- );
- } else {
- newFormData.append(key, value);
- }
- } else if (typeof value === 'string') {
- // String form field — substitute
- const result = substituteAll(value);
- if (result.modified) {
- newFormData.append(key, result.text);
- allReplacements.push(...result.replacements);
- modified = true;
- } else {
- newFormData.append(key, value);
- }
- } else {
- newFormData.append(key, value);
- }
- }
-
- if (modified) {
- notifySubstitutions(allReplacements);
- return newFormData;
- }
- return null;
- }
-
- /**
- * Scan a document file for PII. Strategy: extract text from any
- * format, substitute PII, upload as plaintext. The AI extracts text
- * from files anyway — no need to preserve formatting in a file
- * the user never gets back. Original stays untouched on disk.
- *
- * Supported: PDF, DOCX, DOC, XLSX, XLS, ODT, ODS, ODP, PPTX, RTF,
- * and all text/code formats.
- */
- async function documentScan(file, filename, options) {
- const ext = (filename || '').split('.').pop().toLowerCase();
-
- // Plain text formats — direct substitution, keep original extension
- const textExts = new Set(['txt','csv','tsv','json','md','markdown','log',
- 'yaml','yml','toml','ini','cfg','conf','xml','html','htm','css',
- 'js','ts','py','rb','go','rs','java','c','cpp','h','hpp','sh',
- 'bash','zsh','ps1','bat','sql','r','swift','kt','scala','pl','php',
- 'lua','vim','env','gitignore']);
-
- if (textExts.has(ext)) {
- const text = await file.text();
- const result = substituteAll(text);
- if (result.modified) {
- return {
- file: new Blob([result.text], { type: file.type || 'text/plain' }),
- filename, replacements: result.replacements,
- };
- }
- return { file, filename, replacements: [] };
- }
-
- // Binary document formats — extract text, substitute, upload as .txt
- const docExts = new Set(['pdf','docx','doc','xlsx','xls','odt','ods',
- 'odp','rtf','pptx']);
-
- if (!docExts.has(ext)) {
- return { file, filename, replacements: [], skipped: true };
- }
-
- try {
- const text = await extractTextFromDocument(file, ext);
-
- if (!text || text.trim().length < 5) {
- return { file, filename, replacements: [], skipped: true,
- reason: `No extractable text in ${ext.toUpperCase()} (may be scanned/image-only)` };
- }
-
- const result = substituteAll(text);
- const preview = {
- format: ext,
- replacementCount: result.replacements.length,
- replacements: result.replacements.slice(0, 15),
- note: `${ext.toUpperCase()} text extracted and sanitized for upload`,
- };
-
- if (options.previewMode && result.replacements.length > 0) {
- return { file, filename, replacements: result.replacements, preview,
- _sanitizedText: result.text };
- }
-
- if (result.modified) {
- return {
- file: new Blob([result.text], { type: 'text/plain' }),
- filename: filename.replace(/\.[^.]+$/, '.txt'),
- replacements: result.replacements, preview,
- };
- }
- return { file, filename, replacements: [] };
- } catch (e) {
- console.warn(`[Silent Send] ${ext.toUpperCase()} processing failed:`, e);
- return { file, filename, replacements: [], skipped: true, reason: e.message };
- }
- }
-
- /**
- * Extract text from any supported document format.
- */
- async function extractTextFromDocument(file, ext) {
- switch (ext) {
- case 'pdf': return extractPDFText(file);
- case 'docx': case 'xlsx': case 'pptx':
- case 'odt': case 'ods': case 'odp':
- return extractZipXMLText(file, ext);
- case 'doc': case 'xls':
- return extractOldBinaryText(file);
- case 'rtf':
- return extractRTFText(file);
- default: return '';
- }
- }
-
- /** PDF: extract text from content stream operators (Tj, TJ). */
- async function extractPDFText(file) {
- const buffer = await file.arrayBuffer();
- const str = new TextDecoder('latin1').decode(new Uint8Array(buffer));
- const texts = [];
- const re = /stream\r?\n([\s\S]*?)endstream/g;
- let m;
- while ((m = re.exec(str)) !== null) {
- const content = m[1];
- const parts = [];
- const tj = /\(([^)]*)\)\s*Tj/g;
- let t;
- while ((t = tj.exec(content)) !== null) {
- parts.push(t[1].replace(/\\([nrt\\()])/g, (_, c) =>
- c === 'n' ? '\n' : c === 'r' ? '\r' : c === 't' ? '\t' : c));
- }
- const tjArr = /\[(.*?)\]\s*TJ/g;
- while ((t = tjArr.exec(content)) !== null) {
- const inner = /\(([^)]*)\)/g;
- let s;
- while ((s = inner.exec(t[1])) !== null) parts.push(s[1]);
- }
- if (parts.length) texts.push(parts.join(''));
- }
- return texts.join('\n');
- }
-
- /**
- * DOCX/XLSX/PPTX/ODT/ODS/ODP: extract text from ZIP XML entries.
- */
- async function extractZipXMLText(file, ext) {
- const buffer = await file.arrayBuffer();
- const bytes = new Uint8Array(buffer);
- const texts = [];
-
- // Scan for ZIP local file headers
- let pos = 0;
- while (pos < bytes.length - 30) {
- if (bytes[pos] !== 0x50 || bytes[pos+1] !== 0x4b ||
- bytes[pos+2] !== 0x03 || bytes[pos+3] !== 0x04) {
- pos++; continue;
- }
- const view = new DataView(buffer, pos);
- const compMethod = view.getUint16(8, true);
- const compSize = view.getUint32(18, true);
- const nameLen = view.getUint16(26, true);
- const extraLen = view.getUint16(28, true);
- const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + nameLen));
- const dataStart = pos + 30 + nameLen + extraLen;
- const rawData = bytes.slice(dataStart, dataStart + compSize);
-
- if (isTextXML(name, ext) && compSize > 0) {
- let xmlStr;
- try {
- if (compMethod === 8) {
- const dec = await inflateData(rawData);
- xmlStr = dec ? new TextDecoder('utf-8').decode(dec) : null;
- } else if (compMethod === 0) {
- xmlStr = new TextDecoder('utf-8').decode(rawData);
- }
- } catch { /* skip */ }
- if (xmlStr) {
- const re2 = />([^<]+)= 2 && !/^[\x00-\x1f]+$/.test(t)) texts.push(t);
- }
- }
- }
- pos = dataStart + compSize;
- }
- return texts.join(' ');
- }
-
- function isTextXML(name, ext) {
- switch (ext) {
- case 'docx': return /^word\/(document|header|footer|comments|endnotes|footnotes)/i.test(name);
- case 'xlsx': return name === 'xl/sharedStrings.xml' || /^xl\/worksheets\/sheet/i.test(name);
- case 'pptx': return /^ppt\/slides\/slide/i.test(name);
- case 'odt': case 'odp': return name === 'content.xml' || name === 'styles.xml';
- case 'ods': return name === 'content.xml';
- default: return name.endsWith('.xml');
- }
- }
-
- /** Old binary .doc/.xls: extract readable text runs. */
- async function extractOldBinaryText(file) {
- const buffer = await file.arrayBuffer();
- const bytes = new Uint8Array(buffer);
- const texts = [];
- // UTF-16LE extraction (Word stores text as UTF-16)
- let cur = '';
- for (let i = 0; i < bytes.length - 1; i += 2) {
- const code = bytes[i] | (bytes[i + 1] << 8);
- if (code >= 32 && code < 127) { cur += String.fromCharCode(code); }
- else { if (cur.length >= 3) texts.push(cur); cur = ''; }
- }
- if (cur.length >= 3) texts.push(cur);
- // ASCII fallback
- cur = '';
- for (let i = 0; i < bytes.length; i++) {
- if (bytes[i] >= 32 && bytes[i] < 127) { cur += String.fromCharCode(bytes[i]); }
- else { if (cur.length >= 4) texts.push(cur); cur = ''; }
- }
- if (cur.length >= 4) texts.push(cur);
- const seen = new Set();
- return texts.filter(t => {
- if (seen.has(t)) return false;
- seen.add(t);
- return t.includes(' ') || t.length >= 8;
- }).join(' ');
- }
-
- /** RTF: strip formatting, extract text. */
- async function extractRTFText(file) {
- const text = await file.text();
- return text
- .replace(/\{\\[^{}]*\}/g, '')
- .replace(/\\[a-z]+\d*\s?/gi, '')
- .replace(/[{}]/g, '')
- .replace(/\\\\/g, '\\')
- .replace(/\\\'([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
- .trim();
- }
-
- /** Decompress DEFLATE data using DecompressionStream API. */
- async function inflateData(data) {
- if (typeof DecompressionStream === 'undefined') return null;
- try {
- const ds = new DecompressionStream('deflate');
- const writer = ds.writable.getWriter();
- const reader = ds.readable.getReader();
- writer.write(data); writer.close();
- const chunks = [];
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- chunks.push(value);
- }
- const total = chunks.reduce((s, c) => s + c.length, 0);
- const result = new Uint8Array(total);
- let p = 0;
- for (const c of chunks) { result.set(c, p); p += c.length; }
- return result;
- } catch { return null; }
- }
-
- // Document Scan Preview UI
- let docPreviewEl = null;
-
- function showDocScanPreview(preview, filename) {
- return new Promise((resolve) => {
- if (!docPreviewEl) {
- docPreviewEl = document.createElement('div');
- docPreviewEl.className = 'ss-doc-preview';
- document.body.appendChild(docPreviewEl);
- }
- const items = (preview.replacements || []).map(r =>
- `
- ${(r.original || '').length > 25 ? r.original.slice(0, 22) + '...' : r.original}
- →
- ${r.replaced}
-
`
- ).join('');
-
- safeHTML(docPreviewEl, `
-
- ${preview.note || ''}
- ${items}
-
- Substitute & Upload
- Upload Original
-
- `);
- docPreviewEl.classList.add('visible');
- const confirm = docPreviewEl.querySelector('.ss-dp-confirm');
- const cancel = docPreviewEl.querySelector('.ss-dp-cancel');
- const cleanup = () => {
- docPreviewEl.classList.remove('visible');
- confirm.removeEventListener('click', onConfirm);
- cancel.removeEventListener('click', onCancel);
- };
- const onConfirm = () => { cleanup(); resolve(true); };
- const onCancel = () => { cleanup(); resolve(false); };
- confirm.addEventListener('click', onConfirm);
- cancel.addEventListener('click', onCancel);
- setTimeout(() => {
- if (docPreviewEl.classList.contains('visible')) { cleanup(); resolve(false); }
- }, 30000);
- });
- }
-
// ============================================================
// XMLHttpRequest Interception — same aggressive approach
// ============================================================
@@ -1477,8 +873,7 @@
// Helper: only add if this substitute was actually sent
function addIfUsed(from, to, caseSensitive) {
if (!from || !to) return;
- const entry = sessionSubstitutions.get(from.toLowerCase());
- if (entry) {
+ if (sessionSubstitutions.has(from.toLowerCase())) {
pairs.push({ from, to, caseSensitive });
}
}
@@ -1507,9 +902,9 @@
}
// Also add auto-detect and secret scanner substitutions from this session
- for (const [key, entry] of sessionSubstitutions) {
- if (!pairs.some(p => p.from.toLowerCase() === key)) {
- pairs.push({ from: entry.replaced, to: entry.original });
+ for (const [replaced, original] of sessionSubstitutions) {
+ if (!pairs.some(p => p.from.toLowerCase() === replaced)) {
+ pairs.push({ from: replaced, to: original });
}
}
@@ -1543,37 +938,17 @@
return result;
}
- /**
- * Reverse of revealText: replace real values back to substitutes.
- * Used when reveal mode is turned OFF to restore the AI's actual text.
- */
- function unrevealText(text) {
- const pairs = getRevealPairs();
- let result = text;
- // Reverse direction: real (p.to) → substitute (p.from)
- // Sort by length descending to avoid partial matches
- const reversed = [...pairs].sort((a, b) => b.to.length - a.to.length);
- for (const p of reversed) {
- const escaped = esc(p.to);
- const regex = new RegExp(escaped, p.caseSensitive ? 'g' : 'gi');
- result = result.replace(regex, p.from);
- }
- return result;
- }
+ const originalTexts = new WeakMap();
function revealInElement(el) {
if (SKIP_REVEAL_TAGS.has(el.tagName)) return;
if (el.classList?.contains('ss-reveal-badge')) return;
- if (el.isContentEditable) return;
- if (isInNonChatArea(el)) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
if (parent?.closest?.('.ss-autodetect-warning, .ss-presend-warning, .ss-reveal-badge')) return NodeFilter.FILTER_REJECT;
- if (parent?.closest?.('[contenteditable="true"]')) return NodeFilter.FILTER_REJECT;
- if (isInNonChatArea(parent)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
@@ -1583,6 +958,10 @@
const text = textNode.textContent;
if (!text || text.length < MIN_STRING_LENGTH) continue;
+ if (!originalTexts.has(textNode)) {
+ originalTexts.set(textNode, text);
+ }
+
const revealed = revealText(text);
if (revealed !== text) {
textNode.textContent = revealed;
@@ -1592,26 +971,13 @@
function unrevealInElement(el) {
if (SKIP_REVEAL_TAGS.has(el.tagName)) return;
- if (el.isContentEditable) return;
- if (isInNonChatArea(el)) return;
- const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
- acceptNode(node) {
- const parent = node.parentElement;
- if (parent?.closest?.('[contenteditable="true"]')) return NodeFilter.FILTER_REJECT;
- if (isInNonChatArea(parent)) return NodeFilter.FILTER_REJECT;
- return NodeFilter.FILTER_ACCEPT;
- }
- });
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let textNode;
while ((textNode = walker.nextNode())) {
- const text = textNode.textContent;
- if (!text || text.length < MIN_STRING_LENGTH) continue;
-
- // Actively replace real→substitute (reverse of reveal)
- const unrevealed = unrevealText(text);
- if (unrevealed !== text) {
- textNode.textContent = unrevealed;
+ const original = originalTexts.get(textNode);
+ if (original && textNode.textContent !== original) {
+ textNode.textContent = original;
}
}
}
@@ -1633,8 +999,6 @@
const parent = node.parentElement;
if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
if (parent?.closest?.('.ss-autodetect-warning, .ss-presend-warning, .ss-reveal-badge')) return NodeFilter.FILTER_REJECT;
- if (parent?.closest?.('[contenteditable="true"]')) return NodeFilter.FILTER_REJECT;
- if (isInNonChatArea(parent)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
@@ -1673,18 +1037,11 @@
}
}
- // Elements to skip when revealing (inputs, scripts, styles, extension UI, navigation)
+ // Elements to skip when revealing (inputs, scripts, styles, extension UI)
const SKIP_REVEAL_TAGS = new Set([
'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'INPUT', 'TEXTAREA', 'SELECT',
- 'NAV', 'ASIDE', 'HEADER', 'FOOTER',
]);
- // Skip reveal in navigation, sidebars, headers, and other non-chat UI
- function isInNonChatArea(el) {
- if (!el) return false;
- return !!el.closest('nav, aside, header, footer, [role="navigation"], [role="banner"], [role="complementary"], [data-sidebar], [class*="sidebar"], [class*="Sidebar"], [class*="nav-"], [class*="Nav"], [class*="menu"], [class*="Menu"], [class*="header"], [class*="Header"]');
- }
-
// Reveal ALL text on the page + apply highlights
function revealAllResponses() {
revealInElement(document.body);
@@ -1722,19 +1079,15 @@
// Only do text replacement in reveal mode
if (settings.revealMode) {
if (node.nodeType === Node.ELEMENT_NODE) {
- // Skip non-chat areas, contenteditable, and skipped tags
- if (!SKIP_REVEAL_TAGS.has(node.tagName) &&
- !node.isContentEditable &&
- !node.closest?.('[contenteditable="true"]') &&
- !isInNonChatArea(node)) {
+ if (!SKIP_REVEAL_TAGS.has(node.tagName)) {
revealInElement(node);
}
} else if (node.nodeType === Node.TEXT_NODE) {
- // Skip text nodes inside contenteditable
- const parent = node.parentElement;
- if (parent?.closest?.('[contenteditable="true"]')) continue;
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;
@@ -1751,9 +1104,10 @@
const text = mutation.target.textContent;
if (text && text.length >= MIN_STRING_LENGTH) {
const parent = mutation.target.parentElement;
- // Skip contenteditable (chat input)
- if (parent?.closest?.('[contenteditable="true"]')) continue;
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;
@@ -1826,7 +1180,7 @@
}
// ============================================================
- // Pre-Send PII Detection — scans as you type/paste (spellcheck style)
+ // Pre-Send PPI Detection — scans as you type/paste (spellcheck style)
// ============================================================
// Generate obviously-fake values using reserved/standard ranges
@@ -1876,21 +1230,21 @@
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;
- const displayFake = fake.length > 20 ? fake.slice(0, 17) + '...' : fake;
return `
${w.name}
${displayVal}
${w.hint}
- +
- ignore
+ ${settings.autoAddDetected !== false
+ ? `+ `
+ : ''}
`;
}).join('');
const more = warnings.length > 8 ? `+${warnings.length - 8} more
` : '';
- safeHTML(preSendWarningEl, `
+ preSendWarningEl.innerHTML = `
${items}
@@ -1899,7 +1253,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');
@@ -1908,33 +1262,35 @@
preSendWarningEl.classList.remove('visible');
});
- // Auto-add buttons (+)
+ // 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 local mappings array (used by the fetch interceptor)
- const newMapping = {
+ // 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(),
- };
- mappings.push(newMapping);
+ });
+ await setStorageData('ss_mappings', currentMappings);
- // Persist via storage bridge (handles encryption transparently)
- setStorageData('ss_mappings', mappings);
+ // Update local mappings so the fetch interceptor uses them immediately
+ mappings = currentMappings;
- // Replace the PII value in the current input right now
+ // Replace the PPI value in the current input right now
if (inputEl) {
replaceInInput(inputEl, real, fake);
- // Re-scan — will dismiss warning if no more PII remains
+ // Re-scan — will dismiss warning if no more PPI remains
if (inputScanTimer) clearTimeout(inputScanTimer);
- inputScanTimer = setTimeout(() => scanInputForPII(inputEl), 150);
+ inputScanTimer = setTimeout(() => scanInputForPPI(inputEl), 150);
}
// Visual feedback
@@ -1943,24 +1299,6 @@
btn.disabled = true;
});
});
-
- // Ignore buttons
- preSendWarningEl.querySelectorAll('.ss-ps-ignore').forEach(btn => {
- btn.addEventListener('click', () => {
- const value = decodeURIComponent(btn.dataset.value);
- addIgnoredValue(value);
-
- // Remove this item's row
- const row = btn.closest('.ss-ps-item');
- if (row) row.remove();
-
- // Re-scan to update warning
- if (inputEl) {
- if (inputScanTimer) clearTimeout(inputScanTimer);
- inputScanTimer = setTimeout(() => scanInputForPII(inputEl), 150);
- }
- });
- });
}
// Replace all occurrences of `real` with `fake` in an input or contenteditable element
@@ -2008,14 +1346,14 @@
// Scan input on type and paste
let inputScanTimer = null;
- function scanInputForPII(target) {
+ function scanInputForPPI(target) {
const text = target.textContent || target.value || '';
if (!text || text.length < 5) {
if (preSendWarningEl) preSendWarningEl.classList.remove('visible');
return;
}
- const warnings = autoDetectPII(text, identity);
+ const warnings = autoDetectPPI(text, identity);
if (warnings.length > 0) {
showPreSendWarning(warnings, target);
} else if (preSendWarningEl) {
@@ -2029,7 +1367,7 @@
if (target.matches?.('[contenteditable], textarea, input[type="text"]')) {
// Debounce — don't scan on every keystroke
if (inputScanTimer) clearTimeout(inputScanTimer);
- inputScanTimer = setTimeout(() => scanInputForPII(target), 800);
+ inputScanTimer = setTimeout(() => scanInputForPPI(target), 800);
}
}, true);
@@ -2039,7 +1377,7 @@
if (target.matches?.('[contenteditable], textarea, input[type="text"]') ||
target.closest?.('[contenteditable]')) {
// Scan shortly after paste completes
- setTimeout(() => scanInputForPII(target.closest?.('[contenteditable]') || target), 200);
+ setTimeout(() => scanInputForPPI(target.closest?.('[contenteditable]') || target), 200);
}
}, true);
diff --git a/src/content/injector.js b/src/content/injector.js
index 9574bdc..7bf78c4 100644
--- a/src/content/injector.js
+++ b/src/content/injector.js
@@ -57,57 +57,22 @@
// Load mappings and settings, then inject into page
async function init() {
- let mappings, identity, settings;
-
const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']);
- const isEncrypted = result.ss_mappings?._ssLocalEncrypted ||
- result.ss_identity?._ssLocalEncrypted;
+ const settings = result.ss_settings || { enabled: true };
- if (isEncrypted) {
- // Data is encrypted — ask the background script for decrypted config.
- // The background has access to the Storage module which can decrypt.
- try {
- const response = await api.runtime.sendMessage({ type: 'get:decrypted-config' });
- if (response?.mappings) {
- mappings = response.mappings;
- identity = response.identity || {};
- settings = response.settings || { enabled: true };
- } else {
- // Background couldn't decrypt (locked) — inject with empty config
- // and wait for vault:unlocked message later
- mappings = [];
- identity = {};
- settings = result.ss_settings || { enabled: true };
- }
- } catch {
- mappings = [];
- identity = {};
- settings = result.ss_settings || { enabled: true };
- }
- } else {
- // Data is plaintext — read directly
- mappings = result.ss_mappings || [];
- const identityData = result.ss_identity || {};
- identity = mergeProfiles(identityData);
- settings = result.ss_settings || { enabled: true };
- }
+ // Check if data is encrypted (locked) — pass empty config
+ // The background will send decrypted data via vault:unlocked when ready
+ const isLocked = result.ss_mappings?._ssLocalEncrypted ||
+ result.ss_identity?._ssLocalEncrypted;
+ const mappings = isLocked ? [] : (result.ss_mappings || []);
+ const identityData = isLocked ? {} : (result.ss_identity || {});
- // Ensure identity is merged if it came from background
- if (identity.profiles) {
- identity = mergeProfiles(identity);
- }
-
- // Inject config as a global variable before loading content.js
- // Using a separate inline-data element ensures content.js can read it
- // even if there's a race condition with script.onload removal
- const configEl = document.createElement('script');
- configEl.type = 'application/json';
- configEl.id = 'ss-config-data';
- configEl.textContent = JSON.stringify({ mappings, identity, settings });
- (document.head || document.documentElement).appendChild(configEl);
+ // Merge active profiles into a flat identity object for the content script
+ 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 }));
script.src = api.runtime.getURL('src/content/content.js');
(document.head || document.documentElement).appendChild(script);
script.onload = () => script.remove();
diff --git a/src/lib/auto-detect.js b/src/lib/auto-detect.js
index 6555eec..9d3e18c 100644
--- a/src/lib/auto-detect.js
+++ b/src/lib/auto-detect.js
@@ -1,14 +1,14 @@
/**
* Silent Send - Auto-Detect
*
- * Scans text for potential PII that the user hasn't configured.
- * This catches things the identity and auto-redact scanner can't —
+ * 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 PII_PATTERNS = [
+const PPI_PATTERNS = [
// --- Network ---
{
name: 'Private IP Address',
@@ -21,7 +21,7 @@ const PII_PATTERNS = [
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-PII IPs
+ // 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)$/,
},
{
@@ -126,12 +126,12 @@ const PII_PATTERNS = [
},
];
-// Context words that make ambiguous patterns more likely to be PII
+// 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 PII.
+ * 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 }
@@ -167,7 +167,7 @@ const AutoDetect = {
}
}
- for (const pattern of PII_PATTERNS) {
+ for (const pattern of PPI_PATTERNS) {
// Skip context-dependent patterns if no context words present
if (pattern.contextRequired && !hasContext) continue;
@@ -221,15 +221,16 @@ const AutoDetect = {
*/
_detectProperNouns(text, configured) {
const findings = [];
- // Only match TWO OR MORE consecutive capitalized words
- // Single capitalized words cause too many false positives (sentence starts)
- const re = /\b([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,})+)\b/g;
+ const re = /(?:^|[.!?\n]\s*)?([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,})*)/g;
let m;
while ((m = re.exec(text)) !== null) {
const fullMatch = m[1];
if (!fullMatch) continue;
+ const before = text.slice(Math.max(0, m.index - 2), m.index);
+ const isSentenceStart = m.index === 0 || /[.!?\n]\s*$/.test(before);
+
const words = fullMatch.split(/\s+/);
const properWords = words.filter(w =>
w.length >= 3 &&
@@ -237,14 +238,15 @@ const AutoDetect = {
!configured.has(w.toLowerCase())
);
- if (properWords.length < 2) continue; // need at least 2 proper words
+ if (properWords.length === 0) continue;
+ if (isSentenceStart && properWords.length === 1 && words.length === 1) continue;
const value = properWords.join(' ');
- if (value.length >= 5 && !configured.has(value.toLowerCase())) {
+ if (value.length >= 3 && !configured.has(value.toLowerCase())) {
findings.push({
name: 'Possible Name/Org',
value,
- hint: 'Capitalized phrase — could be a name, company, or project',
+ hint: 'Capitalized word — could be a name, company, or project',
category: 'name',
});
}
@@ -260,8 +262,6 @@ const AutoDetect = {
};
// Common English words to exclude from proper noun detection
-// Comprehensive list including verbs, nouns, adjectives that appear
-// in titles, headings, UI buttons, and instructions
const COMMON_WORDS = new Set([
'the', 'and', 'but', 'for', 'not', 'you', 'all', 'can', 'had', 'her',
'was', 'one', 'our', 'out', 'are', 'has', 'his', 'how', 'its', 'may',
@@ -281,84 +281,6 @@ const COMMON_WORDS = new Set([
'number', 'other', 'point', 'right', 'small', 'state', 'thing',
'think', 'those', 'three', 'through', 'under', 'until', 'water',
'world', 'write', 'might', 'should', 'because', 'although',
- // Common verbs (titles, headings, buttons, instructions)
- 'generate', 'design', 'manage', 'process', 'handle', 'check', 'verify',
- 'submit', 'apply', 'accept', 'reject', 'approve', 'deny', 'confirm',
- 'cancel', 'delete', 'remove', 'edit', 'modify', 'view', 'display',
- 'search', 'filter', 'sort', 'select', 'choose', 'pick', 'enter',
- 'upload', 'download', 'install', 'enable', 'disable', 'activate',
- 'connect', 'disconnect', 'sync', 'refresh', 'reload', 'reset',
- 'save', 'load', 'store', 'restore', 'backup', 'copy', 'paste',
- 'lock', 'unlock', 'encrypt', 'decrypt', 'sign', 'register', 'login',
- 'logout', 'subscribe', 'share', 'publish', 'deploy', 'launch',
- 'merge', 'split', 'join', 'link', 'attach', 'insert', 'append',
- 'format', 'parse', 'convert', 'transform', 'translate', 'compile',
- 'execute', 'render', 'animate', 'validate', 'sanitize', 'escape',
- 'create', 'build', 'start', 'stop', 'open', 'close', 'run', 'send',
- // Common nouns (titles, headings, labels)
- 'account', 'action', 'address', 'alert', 'analysis', 'application',
- 'area', 'article', 'asset', 'background', 'badge', 'banner', 'board',
- 'body', 'border', 'bottom', 'box', 'browser', 'buffer', 'button',
- 'cache', 'calendar', 'card', 'category', 'center', 'channel', 'chart',
- 'chat', 'child', 'choice', 'client', 'cloud', 'code', 'collection',
- 'color', 'column', 'command', 'comment', 'community', 'company',
- 'component', 'config', 'configuration', 'connection', 'console',
- 'contact', 'container', 'content', 'context', 'control', 'count',
- 'country', 'custom', 'dashboard', 'data', 'database', 'date', 'day',
- 'default', 'description', 'design', 'desktop', 'detail', 'device',
- 'dialog', 'directory', 'document', 'domain', 'draft', 'driver',
- 'edge', 'editor', 'element', 'email', 'engine', 'entry', 'environment',
- 'error', 'event', 'example', 'extension', 'feature', 'feedback',
- 'field', 'file', 'filter', 'folder', 'font', 'footer', 'form',
- 'frame', 'function', 'gallery', 'general', 'global', 'grid',
- 'guide', 'handler', 'header', 'health', 'help', 'history', 'home',
- 'host', 'icon', 'image', 'index', 'info', 'input', 'instance',
- 'interface', 'issue', 'item', 'job', 'key', 'label', 'language',
- 'layout', 'level', 'library', 'light', 'limit', 'line', 'link',
- 'list', 'local', 'location', 'log', 'logo', 'main', 'manager',
- 'manual', 'map', 'media', 'member', 'memory', 'menu', 'message',
- 'method', 'mobile', 'modal', 'mode', 'model', 'module', 'monitor',
- 'navigation', 'network', 'node', 'note', 'notification', 'object',
- 'option', 'order', 'origin', 'output', 'overlay', 'overview', 'owner',
- 'package', 'page', 'panel', 'parent', 'parser', 'password', 'path',
- 'pattern', 'permission', 'photo', 'pipeline', 'placeholder', 'plan',
- 'platform', 'player', 'plugin', 'point', 'policy', 'pool', 'popup',
- 'port', 'position', 'post', 'power', 'preview', 'primary', 'print',
- 'priority', 'process', 'product', 'profile', 'program', 'progress',
- 'project', 'prompt', 'property', 'protocol', 'provider', 'proxy',
- 'public', 'query', 'queue', 'quick', 'range', 'rate', 'reader',
- 'record', 'region', 'release', 'remote', 'report', 'request',
- 'resource', 'response', 'result', 'review', 'role', 'root', 'route',
- 'row', 'rule', 'runtime', 'sample', 'scanner', 'schema', 'scope',
- 'screen', 'script', 'search', 'section', 'security', 'select',
- 'sender', 'server', 'service', 'session', 'setting', 'settings',
- 'setup', 'share', 'shell', 'shortcut', 'sidebar', 'signal', 'simple',
- 'single', 'site', 'size', 'slider', 'snapshot', 'socket', 'solution',
- 'source', 'space', 'stage', 'standard', 'status', 'step', 'storage',
- 'stream', 'string', 'style', 'subject', 'success', 'summary',
- 'support', 'switch', 'symbol', 'syntax', 'system', 'table', 'target',
- 'task', 'team', 'template', 'terminal', 'test', 'text', 'theme',
- 'thread', 'title', 'token', 'tool', 'toolbar', 'tooltip', 'total',
- 'track', 'traffic', 'tree', 'trigger', 'type', 'unit', 'update',
- 'upload', 'user', 'utility', 'value', 'variable', 'version', 'video',
- 'view', 'virtual', 'warning', 'watch', 'web', 'widget', 'width',
- 'window', 'wizard', 'word', 'worker', 'workspace', 'wrapper', 'zone',
- // Common adjectives
- 'active', 'advanced', 'available', 'basic', 'clean', 'clear', 'complete',
- 'connected', 'correct', 'critical', 'current', 'dark', 'deep',
- 'different', 'direct', 'double', 'dynamic', 'easy', 'empty', 'entire',
- 'exact', 'extra', 'fast', 'final', 'fixed', 'flat', 'free', 'fresh',
- 'full', 'generic', 'given', 'hidden', 'initial', 'inner', 'internal',
- 'invalid', 'latest', 'live', 'major', 'maximum', 'minimum', 'minor',
- 'mixed', 'modern', 'multiple', 'native', 'natural', 'nested', 'normal',
- 'online', 'optional', 'outer', 'overall', 'partial', 'pending', 'plain',
- 'popular', 'possible', 'previous', 'private', 'proper', 'protected',
- 'random', 'raw', 'ready', 'real', 'recent', 'related', 'relative',
- 'required', 'responsive', 'safe', 'secure', 'selected', 'sensitive',
- 'separate', 'shared', 'silent', 'similar', 'smart', 'smooth', 'solid',
- 'special', 'specific', 'stable', 'static', 'strict', 'strong',
- 'supported', 'unique', 'universal', 'unknown', 'upper', 'valid',
- 'various', 'visible', 'visual', 'whole', 'wide',
// Programming / tech terms
'string', 'number', 'boolean', 'object', 'array', 'function', 'class',
'type', 'error', 'null', 'undefined', 'true', 'false', 'return',
diff --git a/src/lib/org-policy.js b/src/lib/org-policy.js
index ce50bea..a4efa70 100644
--- a/src/lib/org-policy.js
+++ b/src/lib/org-policy.js
@@ -12,7 +12,7 @@
* - Policy updates are applied automatically
*
* Privacy: the org admin can check compliance (are required fields
- * configured?) but CANNOT see individual PII values.
+ * configured?) but CANNOT see individual PPI values.
*/
import api from './browser-polyfill.js';
@@ -198,11 +198,11 @@ const OrgPolicy = {
},
/**
- * Get org-required auto-redact patterns.
+ * Get org-required secret scanner patterns.
*
- * @returns {Array} additional patterns to add to auto-redact
+ * @returns {Array} additional patterns to add to the secret scanner
*/
- async getOrgRedactPatterns() {
+ async getOrgSecretPatterns() {
const policy = await this.getPolicy();
if (!policy?.requiredSecretPatterns?.length) return [];
@@ -220,7 +220,7 @@ const OrgPolicy = {
/**
* Check if the user's configuration meets org policy requirements.
- * Returns compliance status WITHOUT revealing actual PII values.
+ * Returns compliance status WITHOUT revealing actual PPI values.
*
* @returns {{ compliant: boolean, missing: string[], configured: string[] }}
*/
diff --git a/src/lib/auto-redact.js b/src/lib/secret-scanner.js
similarity index 82%
rename from src/lib/auto-redact.js
rename to src/lib/secret-scanner.js
index 125c703..89758f0 100644
--- a/src/lib/auto-redact.js
+++ b/src/lib/secret-scanner.js
@@ -1,13 +1,11 @@
/**
- * Silent Send - Auto Redact
+ * Silent Send - Secret Scanner
*
* Detects common secret/credential patterns in text and either
* warns or auto-redacts them. This catches things the identity-based
* smart patterns can't: API keys, tokens, passwords, SSNs, credit
* cards, private keys, connection strings, etc.
*
- * Supports user-defined custom patterns for proprietary token formats.
- *
* Each pattern has:
* - name: human-readable label
* - regex: detection pattern
@@ -15,7 +13,7 @@
* - severity: 'critical' (always redact) or 'warning' (flag but allow)
*/
-const REDACT_PATTERNS = [
+const SECRET_PATTERNS = [
// --- API Keys ---
{
name: 'OpenAI API Key',
@@ -163,39 +161,14 @@ const REDACT_PATTERNS = [
},
];
-const AutoRedact = {
- /**
- * Build the full pattern list (built-in + custom).
- * Custom patterns come from settings.customRedactPatterns.
- */
- _buildPatterns(customPatterns) {
- const all = [...REDACT_PATTERNS];
- if (Array.isArray(customPatterns)) {
- for (const cp of customPatterns) {
- if (!cp.enabled || !cp.pattern) continue;
- try {
- all.push({
- name: cp.name || 'Custom Pattern',
- regex: new RegExp(cp.pattern, 'g'),
- redact: cp.redact || '[REDACTED-CUSTOM]',
- severity: 'critical',
- });
- } catch { /* invalid regex — skip */ }
- }
- }
- return all;
- },
-
+const SecretScanner = {
/**
* Scan text for secrets. Returns list of findings.
- * @param {string} text
- * @param {Array} [customPatterns] — from settings.customRedactPatterns
*/
- scan(text, customPatterns) {
+ scan(text) {
const findings = [];
- const patterns = this._buildPatterns(customPatterns);
- for (const pattern of patterns) {
+ for (const pattern of SECRET_PATTERNS) {
// Reset regex lastIndex
pattern.regex.lastIndex = 0;
let match;
@@ -231,11 +204,9 @@ const AutoRedact = {
/**
* Redact all critical secrets in text. Warnings are not auto-redacted.
* Returns { text, redactions[] }
- * @param {string} text
- * @param {Array} [customPatterns] — from settings.customRedactPatterns
*/
- redact(text, customPatterns) {
- const findings = this.scan(text, customPatterns);
+ redact(text) {
+ const findings = this.scan(text);
const redactions = [];
let result = text;
@@ -249,7 +220,7 @@ const AutoRedact = {
redactions.push({
original: f.value,
replaced: f.redactTo,
- category: 'redact',
+ category: 'secret',
pattern: f.name,
});
}
@@ -266,7 +237,7 @@ const AutoRedact = {
};
if (typeof globalThis !== 'undefined') {
- globalThis.AutoRedact = AutoRedact;
+ globalThis.SecretScanner = SecretScanner;
}
-export default AutoRedact;
+export default SecretScanner;
diff --git a/src/lib/storage.js b/src/lib/storage.js
index 4926a08..9a60b2d 100644
--- a/src/lib/storage.js
+++ b/src/lib/storage.js
@@ -23,7 +23,7 @@ const KEYS = {
SETTINGS: 'ss_settings',
};
-// Keys that contain sensitive PII and should be encrypted at rest
+// Keys that contain sensitive PPI and should be encrypted at rest
// All user data keys are encrypted at rest — settings included since
// custom domains and configuration can reveal what services the user
// accesses. Only ss_sync_encryption (salt, verification blob) and
@@ -34,13 +34,12 @@ const DEFAULT_SETTINGS = {
enabled: true,
showHighlights: false,
revealMode: false,
- autoRedact: true,
+ secretScanning: true,
autoDetect: true,
autoRedactDetected: true,
autoAddDetected: true,
- maxLogEntries: 100,
+ maxLogEntries: 200,
customDomains: [],
- customRedactPatterns: [],
categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'password', 'general'],
browserSync: false,
};
diff --git a/src/lib/sync.js b/src/lib/sync.js
index 9f442a2..dc40c40 100644
--- a/src/lib/sync.js
+++ b/src/lib/sync.js
@@ -12,9 +12,8 @@
* 5. Custom HTTP endpoint — any URL supporting GET + PUT (WebDAV,
* self-hosted server, cloud function, etc.).
*
- * Encryption: all sync channels REQUIRE encryption with a password
- * (AES-256-GCM) and/or TOTP verification. Syncing without encryption
- * is not permitted — users must set up encryption before enabling sync.
+ * Encryption: all sync channels can optionally encrypt data with a
+ * password (AES-256-GCM) and/or require TOTP verification.
* Authentication is cached with a configurable TTL so the user only
* needs to authenticate when the cache expires and new data exists.
*
@@ -371,9 +370,6 @@ const SilentSendSync = {
const StorageModule = (await import('./storage.js')).default;
await StorageModule.decryptAllData();
- // Disable all sync channels since encryption is mandatory for sync
- await StorageModule.saveSettings({ browserSync: false });
-
await api.storage.local.remove('ss_sync_encryption');
await SilentSendCrypto.clearCachedKey();
await SilentSendCrypto.clearWebAuthnCredential();
@@ -419,7 +415,7 @@ const SilentSendSync = {
*/
async _encryptForSync(data) {
const config = await this._getSyncEncryption();
- if (!config?.enabled) return { data: null, encrypted: false, needsEncryption: true };
+ if (!config?.enabled) return { data, encrypted: false };
const keyInfo = await this._getEncryptionKey();
if (!keyInfo) {
@@ -583,15 +579,12 @@ const SilentSendSync = {
async exportSyncCode() {
const data = await this._getAllData();
- // Encrypt (mandatory)
+ // Encrypt if enabled
const result = await this._encryptForSync(data);
- if (result.needsEncryption) {
- return { needsEncryption: true };
- }
if (result.needsAuth) {
return { needsAuth: true };
}
- const payload = result.data;
+ const payload = result.data || data;
const json = JSON.stringify(payload);
return btoa(unescape(encodeURIComponent(json)));
@@ -647,10 +640,10 @@ const SilentSendSync = {
try {
const data = await this._getAllData();
- // Encrypt (mandatory)
+ // Encrypt if enabled
const result = await this._encryptForSync(data);
- if (result.needsEncryption || result.needsAuth) return; // skip — encryption required
- const payload = result.data;
+ if (result.needsAuth) return; // silently skip — will sync on next auth
+ const payload = result.data || data;
const json = JSON.stringify(payload);
@@ -716,15 +709,12 @@ const SilentSendSync = {
try {
const data = await this._getAllData();
- // Encrypt (mandatory)
+ // Encrypt if enabled
const encResult = await this._encryptForSync(data);
- if (encResult.needsEncryption) {
- return { success: false, needsEncryption: true, reason: 'Encryption must be enabled before syncing.' };
- }
if (encResult.needsAuth) {
return { success: false, needsAuth: true, reason: 'Authentication required.' };
}
- const payload = encResult.data;
+ const payload = encResult.data || data;
const content = JSON.stringify(payload, null, 2);
const stored = await api.storage.local.get('ss_gist_id');
@@ -820,13 +810,10 @@ const SilentSendSync = {
const data = await this._getAllData();
const encResult = await this._encryptForSync(data);
- if (encResult.needsEncryption) {
- return { success: false, needsEncryption: true, reason: 'Encryption must be enabled before syncing.' };
- }
if (encResult.needsAuth) {
return { success: false, needsAuth: true, reason: 'Authentication required.' };
}
- const payload = encResult.data;
+ const payload = encResult.data || data;
const resp = await fetch(url, {
method,
diff --git a/src/options/options.html b/src/options/options.html
index 5a0799e..abac81e 100644
--- a/src/options/options.html
+++ b/src/options/options.html
@@ -49,38 +49,17 @@
-
Auto Redact
-
Automatically detect and redact API keys, tokens, passwords, SSNs, credit card numbers, and custom patterns
+
Secret scanning
+
Auto-detect and redact API keys, tokens, passwords, SSNs, credit card numbers
-
+
-
-
-
-
Custom Redact Patterns
-
- Define your own patterns to catch proprietary tokens, internal URLs with keys, or any format the built-in scanner doesn't cover.
-
-
-
-
- Tip: For a URL like https://dns.example.com/abc123, use a pattern like dns\.example\.com/[A-Za-z0-9;]+ to match the secret path segment.
-
-
-
+
+
Auto-detect unconfigured PPI
Warn when potential personal data (IPs, addresses, paths) is detected that you haven't configured
@@ -90,8 +69,8 @@
-
Auto-redact detected PII on send
-
Automatically replace detected PII with generic placeholders (192.0.2.1, 123 Example Street, etc.) when sending
+
Auto-redact detected PPI on send
+
Automatically replace detected PPI with generic placeholders (192.0.2.1, 123 Example Street, etc.) when sending
@@ -100,8 +79,8 @@
-
Offer to auto-add detected PII
-
Show a + button on detected PII to instantly create a mapping with a suggested fake value
+
Offer to auto-add detected PPI
+
Show a + button on detected PPI to instantly create a mapping with a suggested fake value
@@ -113,7 +92,7 @@
Max log entries
Number of activity log entries to keep
-
+
@@ -127,7 +106,7 @@
🔒 Sync Encryption
- Encryption is required for all sync channels. Set up a password (and optionally TOTP) before enabling any sync method. Authentication is only required when new data arrives and your cached key has expired.
+ Encrypt your sync data with a password, TOTP, or both. Authentication is only required when new data arrives and your cached key has expired.
@@ -382,7 +361,7 @@
Organization
- Join an organization to receive required substitution rules and auto-redact patterns from your admin. Org rules merge with your personal rules and cannot be disabled.
+ Join an organization to receive required substitution rules and secret scanner patterns from your admin. Org rules merge with your personal rules and cannot be disabled.
@@ -542,31 +521,12 @@
Custom Domains
Add domains for self-hosted AI services (like OpenWebUI). The extension will activate on these domains in addition to the built-in ones.
-
-
+
+
Add Domain
- Bulk Add
-
-
-
-
Quick add popular sites:
-
-
-
-
-
-
Paste domains (one per line):
-
-
- Add All
- Cancel
-
-
-
-
- When you add a domain, your browser will ask you to confirm access. No extra steps needed.
+ When you click "Add Domain", your browser will ask you to confirm access. No extra steps needed.
@@ -631,14 +591,7 @@
- Silent Send v0.9.20
-
- Silent Send is a convenience tool, not a security guarantee. Third-party sites may change how they send data at any time, which can cause missed substitutions without warning. You are responsible for verifying your data before sending. See the LICENSE for full terms.
-
-
- Find Silent Send useful? No obligation, but if you'd like to help keep it going:
- Buy me a coffee
-
+ Silent Send v0.3.0
diff --git a/src/options/options.js b/src/options/options.js
index 2bf03c2..e4bc55c 100644
--- a/src/options/options.js
+++ b/src/options/options.js
@@ -14,23 +14,17 @@ 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(...Array.from(doc.body.childNodes));
-}
-
document.addEventListener('DOMContentLoaded', async () => {
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
// Apply settings to UI
$('#showHighlights').checked = settings.showHighlights || false;
- $('#autoRedactToggle').checked = settings.autoRedact !== 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 || 100;
+ $('#maxLogEntries').value = settings.maxLogEntries || 200;
$('#browserSync').checked = settings.browserSync === true;
renderMappings();
@@ -51,28 +45,17 @@ document.addEventListener('DOMContentLoaded', async () => {
// --- Sync section ---
$('#browserSync').addEventListener('change', async (e) => {
+ await Storage.saveSettings({ browserSync: e.target.checked });
if (e.target.checked) {
- const encEnabled = await SilentSendSync.isEncryptionEnabled();
- if (!encEnabled) {
- e.target.checked = false;
- setSyncStatus('Encryption must be enabled before syncing. Set up encryption first.', 'error');
- return;
- }
- await Storage.saveSettings({ browserSync: true });
await SilentSendSync.pushToSyncStorage();
setSyncStatus('Browser account sync enabled. Your settings will sync automatically.', 'ok');
} else {
- await Storage.saveSettings({ browserSync: false });
setSyncStatus('Browser account sync disabled.', 'neutral');
}
});
$('#btnGenerateSyncCode').addEventListener('click', async () => {
const code = await SilentSendSync.exportSyncCode();
- if (code?.needsEncryption) {
- setSyncStatus('Encryption must be enabled before syncing. Set up encryption first.', 'error');
- return;
- }
if (code?.needsAuth) {
setSyncStatus('Authentication required to encrypt sync code.', 'warn');
showSyncAuthPrompt();
@@ -185,9 +168,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
setGistSyncStatus('Pushing…', 'neutral');
const r = await SilentSendSync.pushToGist(token);
- if (r.needsEncryption) {
- setGistSyncStatus('Encryption must be enabled before syncing.', 'error');
- } else if (r.needsAuth) {
+ if (r.needsAuth) {
setGistSyncStatus('Authentication required to encrypt.', 'warn');
showSyncAuthPrompt();
} else if (r.success) {
@@ -226,9 +207,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const headers = parseHeadersField($('#customSyncHeaders').value);
setUrlSyncStatus('Pushing…', 'neutral');
const r = await SilentSendSync.pushToUrl({ url, headers });
- if (r.needsEncryption) {
- setUrlSyncStatus('Encryption must be enabled before syncing.', 'error');
- } else if (r.needsAuth) {
+ if (r.needsAuth) {
setUrlSyncStatus('Authentication required to encrypt.', 'warn');
showSyncAuthPrompt();
} else if (r.success) {
@@ -280,34 +259,14 @@ document.addEventListener('DOMContentLoaded', async () => {
$('#newDomain').addEventListener('keydown', (e) => {
if (e.key === 'Enter') addDomain();
});
- renderSuggestedDomains();
-
- // Bulk add domains
- $('#btnBulkAddDomains').addEventListener('click', () => {
- const section = $('#bulkDomainSection');
- section.style.display = section.style.display === 'none' ? 'block' : 'none';
- });
- $('#btnCancelBulkDomains').addEventListener('click', () => {
- $('#bulkDomainSection').style.display = 'none';
- $('#bulkDomainText').value = '';
- $('#bulkDomainStatus').textContent = '';
- });
- $('#btnApplyBulkDomains').addEventListener('click', bulkAddDomains);
// Settings listeners
$('#showHighlights').addEventListener('change', async (e) => {
await Storage.saveSettings({ showHighlights: e.target.checked });
});
- $('#autoRedactToggle').addEventListener('change', async (e) => {
- await Storage.saveSettings({ autoRedact: e.target.checked });
- });
-
- // Custom redact patterns
- renderCustomRedactPatterns();
- $('#btnAddRedactPattern').addEventListener('click', addCustomRedactPattern);
- $('#newRedactPattern').addEventListener('keydown', (e) => {
- if (e.key === 'Enter') addCustomRedactPattern();
+ $('#secretScanning').addEventListener('change', async (e) => {
+ await Storage.saveSettings({ secretScanning: e.target.checked });
});
$('#autoDetect').addEventListener('change', async (e) => {
@@ -323,7 +282,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
$('#maxLogEntries').addEventListener('change', async (e) => {
- await Storage.saveSettings({ maxLogEntries: parseInt(e.target.value, 10) || 100 });
+ await Storage.saveSettings({ maxLogEntries: parseInt(e.target.value, 10) || 200 });
});
// Add mapping
@@ -452,11 +411,11 @@ function renderMappings() {
const nonPasswordMappings = mappings.filter(m => m.category !== 'password');
if (nonPasswordMappings.length === 0) {
- safeHTML(tbody, '
No mappings configured ');
+ tbody.innerHTML = '
No mappings configured ';
return;
}
- safeHTML(tbody, nonPasswordMappings
+ tbody.innerHTML = nonPasswordMappings
.map(
(m) => `
@@ -474,7 +433,7 @@ function renderMappings() {
`
)
- .join(''));
+ .join('');
// Bind
tbody.querySelectorAll('.btn-delete').forEach((btn) => {
@@ -504,14 +463,14 @@ function renderPasswords() {
const noMsg = $('#noPasswordsMsg');
if (passwordMappings.length === 0) {
- tbody.replaceChildren();
+ tbody.innerHTML = '';
noMsg.style.display = 'block';
return;
}
noMsg.style.display = 'none';
- safeHTML(tbody, passwordMappings.map(m => {
+ tbody.innerHTML = passwordMappings.map(m => {
const displayReal = passwordsRevealed
? escapeHtml(m.real)
: '••••••••';
@@ -528,7 +487,7 @@ function renderPasswords() {
×
`;
- }).join(''));
+ }).join('');
// Bind delete
tbody.querySelectorAll('.btn-delete-pw').forEach(btn => {
@@ -565,11 +524,11 @@ async function renderLog() {
const list = $('#logList');
if (log.length === 0) {
- safeHTML(list, '
No activity logged
');
+ list.innerHTML = '
No activity logged
';
return;
}
- safeHTML(list, log
+ list.innerHTML = log
.slice(0, 100)
.map((entry) => {
const time = new Date(entry.timestamp).toLocaleString();
@@ -582,315 +541,74 @@ async function renderLog() {
`;
})
- .join(''));
+ .join('');
}
// --- Custom Domains ---
+async function addDomain() {
+ let domain = $('#newDomain').value.trim();
+ if (!domain) return;
-// Suggested popular domains (not already built-in)
-const SUGGESTED_DOMAINS = [
- { label: 'OpenWebUI', url: 'https://openwebui.local' },
- { label: 'Ollama Web', url: 'http://localhost:3000' },
- { label: 'text-generation-webui', url: 'http://localhost:7860' },
- { label: 'Jan.ai', url: 'https://jan.ai' },
- { label: 'You.com', url: 'https://you.com' },
- { label: 'Phind', url: 'https://www.phind.com' },
- { label: 'Cohere', url: 'https://coral.cohere.com' },
- { label: 'Mistral', url: 'https://chat.mistral.ai' },
- { label: 'Pi AI', url: 'https://pi.ai' },
- { label: 'Notion AI', url: 'https://www.notion.so' },
- { label: 'Quora', url: 'https://www.quora.com' },
- { label: 'Discord', url: 'https://discord.com' },
- { label: 'Slack', url: 'https://app.slack.com' },
- { label: 'Jira', url: 'https://atlassian.net' },
- { label: 'Linear', url: 'https://linear.app' },
- { label: 'Bitbucket', url: 'https://bitbucket.org' },
-];
-
-function normalizeDomain(raw) {
- let domain = raw.trim();
- if (!domain) return null;
+ // Normalize: ensure it has a protocol
if (!domain.startsWith('http://') && !domain.startsWith('https://')) {
domain = 'https://' + domain;
}
- return domain.replace(/\/+$/, '');
-}
+ // Strip trailing slashes
+ domain = domain.replace(/\/+$/, '');
-async function addSingleDomain(domain) {
const domains = settings.customDomains || [];
- if (domains.includes(domain)) return { added: false, reason: 'duplicate' };
+ if (domains.includes(domain)) {
+ alert('Domain already added.');
+ return;
+ }
+ // Request browser permission for this domain
try {
- const granted = await api.permissions.request({ origins: [domain + '/*'] });
- if (!granted) return { added: false, reason: 'denied' };
+ const granted = await api.permissions.request({
+ origins: [domain + '/*'],
+ });
+ if (!granted) {
+ alert('Permission denied. The extension needs access to this domain to work.');
+ return;
+ }
} catch (e) {
+ // Firefox or older Chrome may not support optional permissions this way
console.warn('[Silent Send] Could not request permission:', e);
}
domains.push(domain);
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
- return { added: true };
-}
-
-async function addDomain() {
- const domain = normalizeDomain($('#newDomain').value);
- if (!domain) return;
-
- const result = await addSingleDomain(domain);
- if (!result.added) {
- if (result.reason === 'duplicate') alert('Domain already added.');
- else alert('Permission denied. The extension needs access to this domain to work.');
- return;
- }
-
renderDomains();
- renderSuggestedDomains();
$('#newDomain').value = '';
}
-async function bulkAddDomains() {
- const text = $('#bulkDomainText').value;
- const lines = text.split(/[\n,]+/).map(l => l.trim()).filter(Boolean);
- if (lines.length === 0) return;
-
- let added = 0, skipped = 0;
- for (const line of lines) {
- const domain = normalizeDomain(line);
- if (!domain) { skipped++; continue; }
- const result = await addSingleDomain(domain);
- if (result.added) added++;
- else skipped++;
- }
-
- renderDomains();
- renderSuggestedDomains();
- $('#bulkDomainStatus').textContent = `Added ${added}, skipped ${skipped}`;
- if (added > 0) $('#bulkDomainText').value = '';
-}
-
-function renderSuggestedDomains() {
- const container = $('#suggestedDomains');
- if (!container) return;
- const domains = settings.customDomains || [];
-
- // Filter out suggestions that are already added
- const available = SUGGESTED_DOMAINS.filter(s => !domains.includes(s.url));
- if (available.length === 0) {
- safeHTML(container, '
All suggestions added! ');
- return;
- }
-
- safeHTML(container, available.map(s =>
- `
+ ${escapeHtml(s.label)} `
- ).join(''));
-
- container.querySelectorAll('.btn-suggest-domain').forEach(btn => {
- btn.addEventListener('click', async () => {
- const domain = btn.dataset.url;
- const result = await addSingleDomain(domain);
- if (result.added) {
- renderDomains();
- renderSuggestedDomains();
- } else if (result.reason === 'denied') {
- alert('Permission denied for ' + domain);
- }
- });
- });
-}
-
function renderDomains() {
const list = $('#domainList');
const domains = settings.customDomains || [];
if (domains.length === 0) {
- safeHTML(list, '
No custom domains. Built-in sites (Claude, ChatGPT, Gemini, Grok, Perplexity, Copilot, DeepSeek, HuggingChat, Poe, GitHub, GitLab, Reddit, Stack Overflow, Pastebin, localhost) are always active.
');
+ list.innerHTML = '
No custom domains. Built-in sites (Claude, ChatGPT, Grok, Gemini, localhost) are always active.
';
return;
}
- safeHTML(list, domains
+ list.innerHTML = domains
.map((d, i) => `
-
${escapeHtml(d)}
-
- ✎
- ×
-
+
${escapeHtml(d)}
+
×
`)
- .join(''));
+ .join('');
- // Edit handlers
- list.querySelectorAll('.btn-edit-domain').forEach((btn) => {
- btn.addEventListener('click', async () => {
- const idx = parseInt(btn.dataset.index, 10);
- const domains = settings.customDomains || [];
- const current = domains[idx];
- const row = btn.closest('.domain-item');
- const textEl = row.querySelector('.domain-text');
-
- // Replace text with input
- const input = document.createElement('input');
- input.type = 'text';
- input.value = current;
- input.style.cssText = 'flex:1;font-size:12px;font-family:monospace;padding:3px 6px;border:1px solid #3b82f6;border-radius:4px;outline:none;min-width:0';
- textEl.replaceWith(input);
- input.focus();
- input.select();
-
- // Replace edit button with save button
- btn.textContent = '\u2713';
- btn.title = 'Save';
- btn.style.color = '#10b981';
-
- const save = async () => {
- const newDomain = normalizeDomain(input.value);
- if (!newDomain || newDomain === current) {
- renderDomains();
- return;
- }
-
- if (domains.includes(newDomain)) {
- alert('Domain already exists.');
- renderDomains();
- return;
- }
-
- // Request permission for new domain
- try {
- const granted = await api.permissions.request({ origins: [newDomain + '/*'] });
- if (!granted) {
- alert('Permission denied for ' + newDomain);
- renderDomains();
- return;
- }
- } catch (e) { /* non-fatal */ }
-
- // Revoke old permission
- try {
- await api.permissions.remove({ origins: [current + '/*'] });
- } catch (e) { /* non-fatal */ }
-
- domains[idx] = newDomain;
- settings.customDomains = domains;
- await Storage.saveSettings({ customDomains: domains });
- renderDomains();
- renderSuggestedDomains();
- };
-
- btn.onclick = save;
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') save();
- if (e.key === 'Escape') renderDomains();
- });
- input.addEventListener('blur', () => {
- // Small delay to allow button click to fire first
- setTimeout(() => { if (document.contains(input)) renderDomains(); }, 150);
- });
- });
- });
-
- // Remove handlers
list.querySelectorAll('.btn-remove-domain').forEach((btn) => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.index, 10);
const domains = settings.customDomains || [];
- const removed = domains.splice(idx, 1)[0];
+ domains.splice(idx, 1);
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
-
- if (removed) {
- try {
- await api.permissions.remove({ origins: [removed + '/*'] });
- } catch (e) { /* non-fatal */ }
- }
-
renderDomains();
- renderSuggestedDomains();
- });
- });
-}
-
-// --- Custom Redact Patterns ---
-
-function addCustomRedactPattern() {
- const name = $('#newRedactName').value.trim();
- const pattern = $('#newRedactPattern').value.trim();
- const redact = $('#newRedactReplacement').value.trim();
-
- if (!pattern) { alert('Pattern is required.'); return; }
-
- // Validate regex
- try {
- new RegExp(pattern, 'g');
- } catch (e) {
- alert('Invalid regex: ' + e.message);
- return;
- }
-
- const label = name || 'Custom Pattern';
- const replacement = redact || `[REDACTED-${label.toUpperCase().replace(/\s+/g, '-')}]`;
-
- const patterns = settings.customRedactPatterns || [];
- patterns.push({
- id: crypto.randomUUID(),
- name: label,
- pattern,
- redact: replacement,
- enabled: true,
- });
-
- settings.customRedactPatterns = patterns;
- Storage.saveSettings({ customRedactPatterns: patterns });
- renderCustomRedactPatterns();
-
- $('#newRedactName').value = '';
- $('#newRedactPattern').value = '';
- $('#newRedactReplacement').value = '';
-}
-
-function renderCustomRedactPatterns() {
- const list = $('#customRedactList');
- if (!list) return;
- const patterns = settings.customRedactPatterns || [];
-
- if (patterns.length === 0) {
- safeHTML(list, '
No custom patterns defined. Built-in patterns cover common API keys, tokens, and credentials.
');
- return;
- }
-
- safeHTML(list, patterns.map((p, i) => `
-
-
-
-
- ${escapeHtml(p.name)}
- ${escapeHtml(p.pattern)}
- → ${escapeHtml(p.redact)}
- ×
-
- `).join(''));
-
- // Toggle handlers
- list.querySelectorAll('.redact-toggle').forEach(toggle => {
- toggle.addEventListener('change', async () => {
- const idx = parseInt(toggle.dataset.index, 10);
- const patterns = settings.customRedactPatterns || [];
- patterns[idx].enabled = toggle.checked;
- settings.customRedactPatterns = patterns;
- await Storage.saveSettings({ customRedactPatterns: patterns });
- });
- });
-
- // Remove handlers
- list.querySelectorAll('.btn-remove-redact').forEach(btn => {
- btn.addEventListener('click', async () => {
- const idx = parseInt(btn.dataset.index, 10);
- const patterns = settings.customRedactPatterns || [];
- patterns.splice(idx, 1);
- settings.customRedactPatterns = patterns;
- await Storage.saveSettings({ customRedactPatterns: patterns });
- renderCustomRedactPatterns();
});
});
}
@@ -1241,9 +959,8 @@ async function initSyncEncryptionUI() {
$('#btnDisableEncryption').addEventListener('click', async () => {
if (!window.confirm('Disable sync encryption? Existing encrypted sync data will become unreadable.')) return;
await SilentSendSync.disableEncryption();
- $('#browserSync').checked = false;
showEncryptionNotConfigured();
- setSyncEncStatus('Encryption disabled. All sync channels have been turned off.', 'neutral');
+ setSyncEncStatus('Encryption disabled.', 'neutral');
});
// Change password
@@ -1578,11 +1295,11 @@ async function renderVersionHistory() {
const snapshots = await VersionHistory.getSnapshots();
if (snapshots.length === 0) {
- safeHTML(list, '
No snapshots yet. Snapshots are created on each sync.
');
+ list.innerHTML = '
No snapshots yet. Snapshots are created on each sync.
';
return;
}
- safeHTML(list, snapshots.map(s => {
+ list.innerHTML = snapshots.map(s => {
const time = new Date(s.timestamp).toLocaleString();
const mappingCount = (s.data?.mappings || []).length;
return `
@@ -1593,7 +1310,7 @@ async function renderVersionHistory() {
Restore
`;
- }).join(''));
+ }).join('');
list.querySelectorAll('.btn-restore-snapshot').forEach(btn => {
btn.addEventListener('click', async () => {
@@ -1638,13 +1355,13 @@ async function renderDevices() {
const entries = Object.values(devices);
if (entries.length === 0) {
- safeHTML(list, 'No devices synced yet. Push or pull to register this device.
');
+ list.innerHTML = 'No devices synced yet. Push or pull to register this device.
';
return;
}
entries.sort((a, b) => (b.lastSync || 0) - (a.lastSync || 0));
- safeHTML(list, `
+ list.innerHTML = `
Device
Browser
@@ -1661,7 +1378,7 @@ async function renderDevices() {
${!isCurrent ? `× ` : ''}
`;
}).join('')}
-
`);
+
`;
list.querySelectorAll('.btn-remove-device').forEach(btn => {
btn.addEventListener('click', async () => {
@@ -1738,9 +1455,9 @@ async function showOrgJoined() {
const compliance = await OrgPolicy.checkCompliance();
const statusEl = $('#orgComplianceStatus');
if (compliance.compliant) {
- safeHTML(statusEl, '✓ Compliant — all required fields configured ');
+ statusEl.innerHTML = '✓ Compliant — all required fields configured ';
} else {
- safeHTML(statusEl, `Missing: ${compliance.missing.join(', ')} `);
+ statusEl.innerHTML = `Missing: ${compliance.missing.join(', ')} `;
}
const reqMappings = policy?.requiredMappings || [];
@@ -1892,7 +1609,7 @@ async function checkConflicts() {
function renderConflicts(conflicts) {
const list = $('#conflictList');
- safeHTML(list, conflicts.map(c => `
+ list.innerHTML = conflicts.map(c => `
${escapeHtml(c.path)}
@@ -1910,7 +1627,7 @@ function renderConflicts(conflicts) {
Keep Remote
- `).join(''));
+ `).join('');
list.querySelectorAll('.btn-resolve').forEach(btn => {
btn.addEventListener('click', async () => {
@@ -1971,10 +1688,10 @@ async function handleBulkImport(e) {
result.identity.usernames.filter(u => !u.substitute).length +
result.identity.phones.filter(p => !p.substitute).length;
- safeHTML($('#bulkImportSummary'), `
+ $('#bulkImportSummary').innerHTML = `
Found: ${parts.join(', ')}.
${needsMapping > 0 ? `${needsMapping} item(s) need substitutes — you can add them after import. ` : ''}
- `);
+ `;
// Build preview list
const items = [];
@@ -1994,8 +1711,8 @@ async function handleBulkImport(e) {
items.push(`${escapeHtml(m.category)}: ${escapeHtml(m.real)} ${m.substitute ? ' → ' + escapeHtml(m.substitute) : ' needs substitute '}
`);
}
- safeHTML($('#bulkImportItems'), items.slice(0, 50).join('') +
- (items.length > 50 ? `+${items.length - 50} more...
` : ''));
+ $('#bulkImportItems').innerHTML = items.slice(0, 50).join('') +
+ (items.length > 50 ? `+${items.length - 50} more...
` : '');
$('#bulkImportPreview').style.display = 'block';
diff --git a/src/popup/popup.css b/src/popup/popup.css
index 4dc2ed8..df8f32c 100644
--- a/src/popup/popup.css
+++ b/src/popup/popup.css
@@ -623,35 +623,6 @@ body {
font-size: 12px;
}
-/* Options tab */
-.setting-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 0;
- border-bottom: 1px solid #f3f4f6;
-}
-
-.setting-item:last-of-type { border-bottom: none; }
-
-.setting-label {
- flex: 1;
- min-width: 0;
-}
-
-.setting-label strong {
- font-size: 12px;
- display: block;
- color: #1f2937;
-}
-
-.setting-label .setting-desc {
- font-size: 10px;
- color: #9ca3af;
- display: block;
- margin-top: 1px;
-}
-
/* Footer */
.footer {
padding: 8px 16px;
diff --git a/src/popup/popup.html b/src/popup/popup.html
index c770461..33accc5 100644
--- a/src/popup/popup.html
+++ b/src/popup/popup.html
@@ -33,7 +33,6 @@
Mappings
Activity
Test
- Options
@@ -194,75 +193,6 @@
-
-
-
-
- Auto Redact
- Automatically redact API keys, tokens, passwords, SSNs, credit cards, and custom patterns
-
-
-
-
-
-
- Auto-detect PII
- Warn about unconfigured personal data (IPs, addresses, paths)
-
-
-
-
-
-
- Auto-redact detected PII
- Replace detected PII with placeholders on send
-
-
-
-
-
-
- Show highlights
- Highlight substituted values in AI responses
-
-
-
-
-
-
- Document scan preview
- Show PII findings before uploading documents
-
-
-
-
-
-
- Detect proper nouns
- Flag capitalized phrases (names, companies) — may produce false positives
-
-
-
-
-
-
- Custom Domains
- Add sites beyond the built-in list
-
-
-
-
- Add
-
-
-
-
-
-
Open Full Options Page
-
Sync, encryption, org, version history, import/export, and more
-
-
-
diff --git a/src/popup/popup.js b/src/popup/popup.js
index c900149..4fdcf25 100644
--- a/src/popup/popup.js
+++ b/src/popup/popup.js
@@ -1,6 +1,6 @@
import SubstitutionEngine from '../lib/substitution-engine.js';
import SmartPatterns from '../lib/smart-patterns.js';
-import AutoRedact from '../lib/auto-redact.js';
+import SecretScanner from '../lib/secret-scanner.js';
import AutoDetect from '../lib/auto-detect.js';
import Storage from '../lib/storage.js';
import SilentSendSync from '../lib/sync.js';
@@ -18,12 +18,6 @@ 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(...Array.from(doc.body.childNodes));
-}
-
// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
// Check if locked BEFORE trying to read sensitive data
@@ -208,45 +202,12 @@ async function initUnlockedUI() {
});
});
- // Open full options page button (Options tab)
- $('#btnOpenFullOptions').addEventListener('click', () => {
+ // Options link
+ $('#btnOptions').addEventListener('click', (e) => {
+ e.preventDefault();
api.runtime.openOptionsPage();
});
- // Load options tab settings
- $('#optAutoRedact').checked = settings.autoRedact !== false;
- $('#optAutoDetect').checked = settings.autoDetect !== false;
- $('#optAutoRedactDetected').checked = settings.autoRedactDetected !== false;
- $('#optHighlights').checked = settings.showHighlights || false;
- $('#optDocPreview').checked = settings.docScanPreview !== false;
- $('#optProperNouns').checked = settings.detectProperNouns || false;
-
- // Options tab change handlers
- const optHandlers = [
- ['optAutoRedact', 'autoRedact'],
- ['optAutoDetect', 'autoDetect'],
- ['optAutoRedactDetected', 'autoRedactDetected'],
- ['optHighlights', 'showHighlights'],
- ['optDocPreview', 'docScanPreview'],
- ['optProperNouns', 'detectProperNouns'],
- ];
- for (const [id, key] of optHandlers) {
- $(`#${id}`).addEventListener('change', async (e) => {
- settings[key] = e.target.checked;
- await Storage.saveSettings({ [key]: e.target.checked });
- api.runtime.sendMessage({ type: 'update:settings', settings });
- });
- }
-
- // --- Popup domain management ---
- renderPopupDomains();
- renderPopupDomainSuggestions();
-
- $('#btnPopupAddDomain').addEventListener('click', popupAddDomain);
- $('#popupNewDomain').addEventListener('keydown', (e) => {
- if (e.key === 'Enter') popupAddDomain();
- });
-
// Update privacy note based on encryption state
const encEnabled = await Storage._isAtRestEncryptionEnabled();
const encNote = $('#privacyEncNote');
@@ -341,17 +302,11 @@ async function showLockedUI() {
// --- Profiles ---
function renderProfileSelector() {
const select = $('#profileSelect');
- // Build options via DOM API — safeHTML + DOMParser mangles elements
- select.replaceChildren();
- for (const p of profiles) {
- const opt = new Option(
- `${p.name}${p.active ? '' : ' (off)'}`,
- p.id,
- false,
- p.id === currentProfileId
- );
- select.appendChild(opt);
- }
+ select.innerHTML = profiles.map(p =>
+ ` ` +
+ `${escapeHtml(p.name)}${p.active ? '' : ' (off)'}` +
+ ` `
+ ).join('');
const profile = profiles.find(p => p.id === currentProfileId);
$('#profileActive').checked = profile?.active ?? true;
@@ -400,7 +355,7 @@ function renderFieldList(fieldName, items) {
items = [{ real: '', substitute: '', type: config.defaultType || '' }];
}
- safeHTML(container, items.map((item, i) => {
+ container.innerHTML = items.map((item, i) => {
let typeHtml = '';
if (config.typeOptions) {
typeHtml = `` +
@@ -416,7 +371,7 @@ function renderFieldList(fieldName, items) {
×
`;
- }).join(''));
+ }).join('');
// Bind remove buttons
container.querySelectorAll('.btn-remove').forEach(btn => {
@@ -465,13 +420,13 @@ function loadIdentityForm() {
).join('') +
` `;
}
- safeHTML(tempDiv, ``;
const row = tempDiv.firstElementChild;
container.appendChild(row);
row.querySelector('.btn-remove').addEventListener('click', () => {
@@ -621,11 +576,11 @@ function renderMappings() {
const list = $('#mappingList');
if (mappings.length === 0) {
- safeHTML(list, 'No mappings yet. Add your first one above.
');
+ list.innerHTML = 'No mappings yet. Add your first one above.
';
return;
}
- safeHTML(list, mappings
+ list.innerHTML = mappings
.map(
(m) => `
@@ -642,7 +597,7 @@ function renderMappings() {
`
)
- .join(''));
+ .join('');
// Bind actions
list.querySelectorAll('.btn-delete').forEach((btn) => {
@@ -676,11 +631,11 @@ async function renderActivity() {
countEl.textContent = `${log.length} substitution${log.length !== 1 ? 's' : ''} logged`;
if (log.length === 0) {
- safeHTML(list, 'No activity yet.
');
+ list.innerHTML = 'No activity yet.
';
return;
}
- safeHTML(list, log
+ list.innerHTML = log
.slice(0, 50)
.map((entry) => {
const time = new Date(entry.timestamp).toLocaleTimeString([], {
@@ -698,7 +653,7 @@ async function renderActivity() {
`;
})
- .join(''));
+ .join('');
}
// --- Test Diff (Strip: real → fake) ---
@@ -708,23 +663,23 @@ function renderTestDiff() {
const stats = $('#diffStats');
if (!input) {
- output.replaceChildren();
+ output.innerHTML = '';
stats.textContent = '';
return;
}
const smartResult = SmartPatterns.substitute(input, identity);
const explicitResult = SubstitutionEngine.substitute(smartResult.text, mappings);
- const redactResult = AutoRedact.redact(explicitResult.text, settings.customRedactPatterns);
+ const secretResult = SecretScanner.redact(explicitResult.text);
const allReplacements = [
...smartResult.replacements,
...explicitResult.replacements,
- ...redactResult.redactions,
+ ...secretResult.redactions,
];
- const finalText = redactResult.text;
+ const finalText = secretResult.text;
- if (finalText === input && redactResult.warnings.length === 0) {
+ if (finalText === input && secretResult.warnings.length === 0) {
output.textContent = input;
stats.textContent = 'No substitutions detected';
return;
@@ -740,40 +695,38 @@ function renderTestDiff() {
`${escapedReplaced} `
);
}
- // Highlight auto-redactions in red
- for (const r of redactResult.redactions) {
+ // Highlight secret redactions in red
+ for (const r of secretResult.redactions) {
const escapedReplaced = escapeHtml(r.replaced);
html = html.replace(
escapedReplaced,
`${escapedReplaced} `
);
}
- safeHTML(output, html);
+ output.innerHTML = html;
const smartCount = smartResult.replacements.length;
const explicitCount = explicitResult.replacements.length;
- const redactCount = redactResult.redactions.length;
- const warnCount = redactResult.warnings.length;
+ const secretCount = secretResult.redactions.length;
+ const warnCount = secretResult.warnings.length;
const parts = [];
if (smartCount > 0) parts.push(`${smartCount} smart`);
if (explicitCount > 0) parts.push(`${explicitCount} explicit`);
- if (redactCount > 0) parts.push(`${redactCount} auto-redacted`);
+ if (secretCount > 0) parts.push(`${secretCount} secrets redacted`);
if (warnCount > 0) parts.push(`${warnCount} warnings`);
- // Auto-detect unconfigured PII in the final text
- const piiWarnings = AutoDetect.scan(finalText, identity);
- if (piiWarnings.length > 0) parts.push(`${piiWarnings.length} PII detected`);
+ // 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 PII warnings below stats
- if (piiWarnings.length > 0) {
- const piiDiv = document.createElement('div');
- safeHTML(piiDiv, `
-
Unconfigured PII detected:
- ${piiWarnings.map(w => `
${escapeHtml(w.value)} — ${w.hint}
`).join('')}
-
`);
- stats.appendChild(piiDiv);
+ // Show PPI warnings below stats
+ if (ppiWarnings.length > 0) {
+ stats.innerHTML += `
+
Unconfigured PPI detected:
+ ${ppiWarnings.map(w => `
${escapeHtml(w.value)} — ${w.hint}
`).join('')}
+
`;
}
}
@@ -784,7 +737,7 @@ function renderRevealDiff() {
const stats = $('#revealStats');
if (!input) {
- output.replaceChildren();
+ output.innerHTML = '';
stats.textContent = '';
return;
}
@@ -830,7 +783,7 @@ function renderRevealDiff() {
`${escapedReal} `
);
}
- safeHTML(output, html);
+ output.innerHTML = html;
stats.textContent = `${totalCount} value${totalCount !== 1 ? 's' : ''} revealed`;
}
@@ -911,114 +864,6 @@ function updateStatusDot() {
dot.classList.toggle('disabled', !settings.enabled);
}
-// --- Popup Domain Management ---
-
-const POPUP_SUGGESTED_DOMAINS = [
- { label: 'Mistral', url: 'https://chat.mistral.ai' },
- { label: 'Cohere', url: 'https://coral.cohere.com' },
- { label: 'Phind', url: 'https://www.phind.com' },
- { label: 'You.com', url: 'https://you.com' },
- { label: 'Pi AI', url: 'https://pi.ai' },
- { label: 'Discord', url: 'https://discord.com' },
- { label: 'Slack', url: 'https://app.slack.com' },
- { label: 'Notion', url: 'https://www.notion.so' },
- { label: 'Linear', url: 'https://linear.app' },
- { label: 'Bitbucket', url: 'https://bitbucket.org' },
-];
-
-function normalizeDomain(raw) {
- let d = raw.trim();
- if (!d) return null;
- if (!d.startsWith('http://') && !d.startsWith('https://')) d = 'https://' + d;
- return d.replace(/\/+$/, '');
-}
-
-async function popupAddDomain() {
- const domain = normalizeDomain($('#popupNewDomain').value);
- if (!domain) return;
-
- const domains = settings.customDomains || [];
- if (domains.includes(domain)) return;
-
- try {
- const granted = await api.permissions.request({ origins: [domain + '/*'] });
- if (!granted) return;
- } catch (e) { /* non-fatal */ }
-
- domains.push(domain);
- settings.customDomains = domains;
- await Storage.saveSettings({ customDomains: domains });
- $('#popupNewDomain').value = '';
- renderPopupDomains();
- renderPopupDomainSuggestions();
-}
-
-function renderPopupDomains() {
- const list = $('#popupDomainList');
- if (!list) return;
- const domains = settings.customDomains || [];
-
- if (domains.length === 0) {
- safeHTML(list, 'No custom domains added
');
- return;
- }
-
- safeHTML(list, domains.map((d, i) => `
-
- ${escapeHtml(d)}
- ×
-
- `).join(''));
-
- list.querySelectorAll('.btn-popup-remove-domain').forEach(btn => {
- btn.addEventListener('click', async () => {
- const idx = parseInt(btn.dataset.index, 10);
- const domains = settings.customDomains || [];
- const removed = domains.splice(idx, 1)[0];
- settings.customDomains = domains;
- await Storage.saveSettings({ customDomains: domains });
- if (removed) {
- try { await api.permissions.remove({ origins: [removed + '/*'] }); } catch (e) { /* non-fatal */ }
- }
- renderPopupDomains();
- renderPopupDomainSuggestions();
- });
- });
-}
-
-function renderPopupDomainSuggestions() {
- const container = $('#popupDomainSuggestions');
- if (!container) return;
- const domains = settings.customDomains || [];
-
- const available = POPUP_SUGGESTED_DOMAINS.filter(s => !domains.includes(s.url));
- if (available.length === 0) {
- container.replaceChildren();
- return;
- }
-
- safeHTML(container, available.slice(0, 6).map(s =>
- ``
- ).join(''));
-
- container.querySelectorAll('.btn-popup-suggest').forEach(btn => {
- btn.addEventListener('click', async () => {
- const domain = btn.dataset.url;
- const domains = settings.customDomains || [];
- if (domains.includes(domain)) return;
- try {
- const granted = await api.permissions.request({ origins: [domain + '/*'] });
- if (!granted) return;
- } catch (e) { /* non-fatal */ }
- domains.push(domain);
- settings.customDomains = domains;
- await Storage.saveSettings({ customDomains: domains });
- renderPopupDomains();
- renderPopupDomainSuggestions();
- });
- });
-}
-
// --- Util ---
function escapeHtml(str) {
const div = document.createElement('div');