diff --git a/README.md b/README.md index 578bc91..b50d992 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,36 @@ A browser extension (Chrome + Firefox) that intercepts personal information and | `123-45-6789` | `[REDACTED-SSN]` | | `4111 1111 1111 1111` | `[REDACTED-CARD]` | +### Proper noun detection (automatic) + +The auto-detect scanner also catches capitalized words mid-sentence that might be names, company names, or project names you forgot to configure. For example: + +| You type | What happens | +|----------|-------------| +| `...talked to Sarah about the deploy` | Flags "Sarah" as a possible name | +| `...the Acme Corp internal API` | Flags "Acme Corp" as a possible organization | +| `...pushed to Project Atlas staging` | Flags "Project Atlas" as a possible project name | + +These are flagged as warnings (not auto-redacted) so you can decide whether to add them as mappings. Common English words, programming terms, days, and months are excluded to reduce false positives. + +### Bulk import (speed up setup) + +Import your existing data from password managers and browser autofill to pre-populate identity and mappings: + +| Source | What's imported | +|---|---| +| Chrome password CSV | Usernames, emails, domains, passwords (auto-redacted) | +| Firefox logins CSV | Usernames, emails, domains, passwords (auto-redacted) | +| Bitwarden CSV | Usernames, emails, domains, passwords (auto-redacted) | +| 1Password CSV | Usernames, emails, domains, passwords (auto-redacted) | +| Browser autofill CSV | Names, emails, phones, addresses | +| Plain CSV (2 columns) | Real → substitute pairs | +| Plain text (1 per line) | Auto-categorized values needing substitutes | + +Passwords are imported as exact-match mappings (e.g. `MyS3cret!` → `[REDACTED-PASSWORD-1]`) so they get caught in any context — not just `password=value` patterns. + +Go to **Options** → **Transfer Data** → **Import CSV / Password Export**. + ## First-time setup After installing the extension, **it does nothing until you configure it**. The icon will be gray to remind you. @@ -266,9 +296,75 @@ src/ crypto.js — AES-256-GCM encryption, PBKDF2 key derivation, TOTP (RFC 6238), WebAuthn, key caching sync.js — Cross-browser sync with encryption (browser sync, Gist, folder, URL, sync codes) storage.js — Browser storage wrapper with transparent at-rest encryption + auto-detect.js — PPI pattern detection (IPs, addresses, paths, proper nouns) + import-parser.js — Bulk import from CSV, password managers, autofill exports + version-history.js — Sync version snapshots + rollback + merge.js — Three-way field-level merge for sync conflicts + org-policy.js — Organization policy management (shared rules, compliance) + tamper-guard.js — Admin password protection for destructive actions browser-polyfill.js — Chrome/Firefox API compatibility ``` +## Sync features + +### Auto sync + +When configured, the extension automatically pushes and pulls settings on a configurable interval (5/15/30/60 minutes) using GitHub Gist or a custom URL endpoint. Local changes trigger an immediate push. + +### Conflict resolution + +When both this device and another device change the same data between syncs, the extension performs a three-way merge: +- Non-conflicting changes are merged automatically +- True conflicts (same field changed on both sides) are presented in a side-by-side UI where you choose "Keep Local" or "Keep Remote" for each conflict + +### Version history + rollback + +Every sync operation saves a snapshot of your data. You can browse previous versions and restore any snapshot. Configurable max snapshots (default 10). + +### Connected devices + +Each device registers itself with a name and browser type. The device list is shared via sync data so you can see all connected devices, when they last synced, and remove old ones. + +## Organization / Team + +For teams that want to enforce privacy rules across all members: + +1. **Admin** creates a JSON policy file hosted at any URL (static file, S3, cloud function) +2. **Team members** join by entering the policy URL or an invite code in Options → Organization +3. **Org rules merge** with personal rules — required mappings are always active and cannot be disabled +4. **Compliance dashboard** shows which required fields are configured (without revealing actual PPI) +5. **Policy updates** are polled automatically (hourly) + +### Org policy format + +```json +{ + "orgId": "acme-corp", + "orgName": "Acme Corp", + "version": 2, + "requiredMappings": [ + { "real": "acme-internal.com", "substitute": "example-corp.com", "category": "domain" } + ], + "requiredSecretPatterns": [ + { "name": "Acme Token", "regex": "acme_[a-z0-9]{32}", "redact": "[REDACTED-ACME-TOKEN]" } + ], + "sharedIdentityRules": { + "requireCatchAllEmail": true, + "requiredCategories": ["name", "email", "domain"] + } +} +``` + +### Tamper protection + +Optional admin password (separate from vault password) that gates destructive actions: +- Disabling the extension +- Clearing data or mappings +- Leaving an organization +- Exporting data in plaintext + +This is a deterrent for casual tampering. It cannot prevent browser-level uninstall or developer tools access. + ## Privacy & Security - All data stays local in browser storage — no external servers, no telemetry, no analytics @@ -351,6 +447,25 @@ Reveal mode only replaces values that were **actually substituted** in outbound **You should still review sensitive messages before sending.** Silent Send is a safety net, not a guarantee. Think of it like a spell checker for privacy — it catches most things, but you wouldn't send a legal document without proofreading. +## Legal + +### Liability + +Silent Send is provided **"as is" without warranty of any kind**. The disclaimer section above explicitly documents known limitations. Users should not rely on Silent Send as their sole privacy protection. + +To mitigate litigation risk: +- The extension clearly states it is a **convenience tool, not a security guarantee** in both the README and the popup footer +- Known failure modes are documented (images, file uploads, encoded data, unconfigured data) +- The "~85-90% correctness" estimate sets realistic expectations +- No marketing claims of "complete protection" or "guaranteed privacy" are made +- The BSL license includes standard liability limitation language + +This is comparable to how antivirus software, ad blockers, and password managers handle liability — they document limitations, disclaim warranties, and don't claim perfection. The key is to never overstate what the tool does. + +### Open source + +The source code is available under BSL 1.1. Contributions are welcome. If you find a bug, especially a privacy-related one, please report it. + ## License [Business Source License 1.1](LICENSE) — free for personal, non-commercial use. Commercial use requires a paid license. The code automatically converts to MIT on March 26, 2030. diff --git a/src/content/content.js b/src/content/content.js index 3b6dd4b..29e0796 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -346,6 +346,114 @@ const CONTEXT_WORDS_RE = /\b(?:born|birthday|dob|birth|passport|license|driver|ssn|social\s*security|address|zip|postal|date\s+of\s+birth)\b/i; + // Common English words that are capitalized but aren't proper nouns. + // Used by the proper noun heuristic to reduce false positives. + const COMMON_CAPITALIZED = new Set([ + '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', + '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', + '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', + 'want', 'let', 'put', 'set', 'run', 'keep', 'try', 'start', 'turn', + 'show', 'hear', 'play', 'move', 'live', 'believe', 'bring', 'happen', + 'write', 'provide', 'sit', 'stand', 'lose', 'pay', 'meet', 'include', + 'continue', 'learn', 'change', 'lead', 'understand', 'watch', 'follow', + 'stop', 'create', 'speak', 'read', 'allow', 'add', 'spend', 'grow', + 'open', 'walk', 'win', 'offer', 'remember', 'love', 'consider', 'appear', + 'buy', 'wait', 'serve', 'die', 'send', 'expect', 'build', 'stay', + 'fall', 'cut', 'reach', 'kill', 'remain', 'suggest', 'raise', 'pass', + 'sell', 'require', 'report', 'decide', 'pull', 'develop', 'note', + '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', + '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', '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', + 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun', + 'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec', + ]); + + /** + * Detect proper nouns (potential names, company names, project names) + * that aren't configured in identity. Uses capitalization heuristics: + * - 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 = []; + // 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; + + // 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()) + ); + + 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 >= 3 && !configured.has(value.toLowerCase())) { + findings.push({ + name: 'Possible Name/Org', + value, + hint: 'Capitalized word — could be a name, company, or project', + category: 'name', + }); + } + } + + // Deduplicate + const seen = new Set(); + return findings.filter(f => { + if (seen.has(f.value)) return false; + seen.add(f.value); + return true; + }); + } + function autoDetectPPI(text, ident) { if (!text || text.length < 5) return []; const hasContext = CONTEXT_WORDS_RE.test(text); @@ -374,6 +482,11 @@ } } + // Proper noun heuristic — catch names, company names, project names + // that aren't configured in identity + const properNouns = detectProperNouns(text, configured); + findings.push(...properNouns); + // Deduplicate by value const seen = new Set(); return findings.filter(f => { diff --git a/src/lib/auto-detect.js b/src/lib/auto-detect.js index 57cbbc3..e1af629 100644 --- a/src/lib/auto-detect.js +++ b/src/lib/auto-detect.js @@ -193,21 +193,104 @@ const AutoDetect = { } } + // Proper noun heuristic — catch names, company names, project names + const properNouns = this._detectProperNouns(text, configured); + findings.push(...properNouns); + // Deduplicate overlapping matches - findings.sort((a, b) => a.index - b.index); + findings.sort((a, b) => (a.index || 0) - (b.index || 0)); const deduped = []; let lastEnd = -1; for (const f of findings) { - if (f.index >= lastEnd) { + const idx = f.index || 0; + if (idx >= lastEnd) { deduped.push(f); - lastEnd = f.index + f.value.length; + lastEnd = idx + f.value.length; } } return deduped; }, + + /** + * Detect capitalized words mid-sentence that might be proper nouns + * (names, company names, project names) not in the configured set. + */ + _detectProperNouns(text, configured) { + const findings = []; + 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 && + !COMMON_WORDS.has(w.toLowerCase()) && + !configured.has(w.toLowerCase()) + ); + + if (properWords.length === 0) continue; + if (isSentenceStart && properWords.length === 1 && words.length === 1) continue; + + const value = properWords.join(' '); + if (value.length >= 3 && !configured.has(value.toLowerCase())) { + findings.push({ + name: 'Possible Name/Org', + value, + hint: 'Capitalized word — could be a name, company, or project', + category: 'name', + }); + } + } + + const seen = new Set(); + return findings.filter(f => { + if (seen.has(f.value)) return false; + seen.add(f.value); + return true; + }); + }, }; +// Common English words to exclude from proper noun detection +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', + 'new', 'now', 'old', 'see', 'way', 'who', 'did', 'get', 'let', 'say', + 'she', 'too', 'use', 'also', 'back', 'been', 'call', 'came', 'come', + 'could', 'each', 'even', 'find', 'from', 'give', 'good', 'great', + 'have', 'here', 'high', 'into', 'just', 'keep', 'know', 'last', 'like', + 'live', 'long', 'look', 'made', 'make', 'many', 'more', 'most', 'much', + 'must', 'name', 'next', 'only', 'over', 'part', 'people', 'place', + 'same', 'show', 'side', 'since', 'some', 'still', 'such', 'take', + 'tell', 'than', 'that', 'them', 'then', 'there', 'these', 'they', + 'this', 'time', 'turn', 'used', 'very', 'want', 'well', 'were', + 'what', 'when', 'where', 'which', 'while', 'will', 'with', 'work', + 'would', 'year', 'your', 'about', 'after', 'again', 'being', 'between', + 'both', 'before', 'down', 'during', 'first', 'found', 'group', + 'however', 'important', 'large', 'later', 'little', 'never', + 'number', 'other', 'point', 'right', 'small', 'state', 'thing', + 'think', 'those', 'three', 'through', 'under', 'until', 'water', + 'world', 'write', 'might', 'should', 'because', 'although', + // Programming / tech terms + 'string', 'number', 'boolean', 'object', 'array', 'function', 'class', + 'type', 'error', 'null', 'undefined', 'true', 'false', 'return', + 'import', 'export', 'default', 'const', 'async', 'await', + 'note', 'example', 'warning', 'step', 'option', 'result', 'value', + 'key', 'data', 'info', 'file', 'code', 'test', 'debug', 'config', + 'setup', 'update', 'please', 'thanks', 'hello', 'sorry', + // Days and months + 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', + 'january', 'february', 'march', 'april', 'may', 'june', 'july', + 'august', 'september', 'october', 'november', 'december', +]); + if (typeof globalThis !== 'undefined') { globalThis.AutoDetect = AutoDetect; } diff --git a/src/lib/import-parser.js b/src/lib/import-parser.js new file mode 100644 index 0000000..265723f --- /dev/null +++ b/src/lib/import-parser.js @@ -0,0 +1,449 @@ +/** + * Silent Send - Import Parser + * + * Parses bulk import files to pre-populate identity and mappings. + * + * Supported formats: + * + * 1. CSV/TSV mappings — two columns: real, substitute + * Optional third column: category + * Header row auto-detected and skipped. + * + * 2. Real-values-only list — one value per line + * Imports as identity fields with blank substitutes so the user + * can see what needs mapping and fill in fakes. + * + * 3. Chrome password CSV export — extracts usernames, names, URLs, passwords + * Columns: name, url, username, password, note + * Passwords are imported as auto-redacted mappings (e.g. → [REDACTED-PASSWORD-1]). + * + * 4. Firefox password CSV export — similar to Chrome + * Columns: url, username, password, ... + * + * 5. Bitwarden CSV export — extracts identity fields + * Columns: folder, favorite, type, name, login_uri, login_username, ... + * + * 6. 1Password CSV export — extracts identity fields + * Various formats, but typically: Title, URL, Username, Password, ... + * + * 7. Browser autofill CSV — Chrome's autofill export + * Columns vary but typically include: name, email, phone, address + */ + +const ImportParser = { + /** + * Auto-detect format and parse. + * Returns { mappings: [], identity: { names, emails, usernames, phones, addresses } } + */ + parse(text, filename = '') { + const lower = filename.toLowerCase(); + + // Try to detect format from filename + if (lower.includes('password') || lower.includes('logins')) { + return this.parsePasswordCSV(text); + } + if (lower.includes('bitwarden')) { + return this.parseBitwardenCSV(text); + } + if (lower.includes('1password')) { + return this.parse1PasswordCSV(text); + } + if (lower.includes('autofill') || lower.includes('address')) { + return this.parseAutofillCSV(text); + } + + // Auto-detect from content + const lines = text.trim().split('\n'); + if (lines.length === 0) return this._empty(); + + const firstLine = lines[0].toLowerCase(); + + // CSV with headers + if (firstLine.includes('username') || firstLine.includes('password') || firstLine.includes('login')) { + return this.parsePasswordCSV(text); + } + if (firstLine.includes('bitwarden') || firstLine.includes('folder,favorite')) { + return this.parseBitwardenCSV(text); + } + + // Check if it's a two-column CSV (real → substitute mapping) + const hasTwoColumns = lines.some(l => l.includes(',') || l.includes('\t')); + if (hasTwoColumns) { + return this.parseMappingCSV(text); + } + + // Plain list — one value per line (real values only) + return this.parseValueList(text); + }, + + /** + * Parse a two-column CSV: real,substitute[,category] + */ + parseMappingCSV(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + const sep = lines[0].includes('\t') ? '\t' : ','; + + for (let i = 0; i < lines.length; i++) { + const cols = this._splitCSVLine(lines[i], sep); + if (cols.length < 2) continue; + + const real = cols[0].trim(); + const substitute = cols[1].trim(); + + // Skip header row + if (i === 0 && this._isHeader(real, substitute)) continue; + if (!real) continue; + + const category = (cols[2] || '').trim().toLowerCase() || this._guessCategory(real); + + result.mappings.push({ + real, + substitute: substitute || '', // may be blank — needs mapping + category, + needsMapping: !substitute, + }); + } + + return result; + }, + + /** + * Parse a plain list of real values (one per line). + * All imported as needing substitutes. + */ + parseValueList(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + + for (const line of lines) { + const value = line.trim(); + if (!value || value.length < 2) continue; + + const category = this._guessCategory(value); + + // Route to identity or mappings based on detected category + if (category === 'email') { + result.identity.emails.push({ real: value, substitute: '' }); + } else if (category === 'phone') { + result.identity.phones.push({ real: value, substitute: '' }); + } else if (category === 'name') { + result.identity.names.push({ real: value, substitute: '', type: 'first' }); + } else { + result.mappings.push({ real: value, substitute: '', category, needsMapping: true }); + } + } + + return result; + }, + + /** + * Parse Chrome/Firefox password CSV export. + * Imports usernames, emails, domains, AND passwords. + * Passwords are imported as mappings with auto-generated redaction + * substitutes (e.g. "[REDACTED-PASSWORD-1]") so they get caught + * if pasted into any context — not just key=value patterns. + */ + parsePasswordCSV(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + if (lines.length < 2) return result; + + const headers = this._splitCSVLine(lines[0], ',').map(h => h.trim().toLowerCase()); + const usernameIdx = headers.findIndex(h => h === 'username' || h === 'login_username' || h === 'user'); + const passwordIdx = headers.findIndex(h => h === 'password' || h === 'login_password'); + const urlIdx = headers.findIndex(h => h === 'url' || h === 'login_uri' || h === 'origin' || h === 'web site'); + const nameIdx = headers.findIndex(h => h === 'name' || h === 'title'); + + const seenEmails = new Set(); + const seenUsernames = new Set(); + const seenDomains = new Set(); + const seenPasswords = new Set(); + let passwordCount = 0; + + for (let i = 1; i < lines.length; i++) { + const cols = this._splitCSVLine(lines[i], ','); + + // Extract username/email + if (usernameIdx >= 0 && cols[usernameIdx]) { + const username = cols[usernameIdx].trim(); + if (username && !seenEmails.has(username) && !seenUsernames.has(username)) { + if (username.includes('@')) { + seenEmails.add(username); + result.identity.emails.push({ real: username, substitute: '' }); + } else if (username.length >= 3) { + seenUsernames.add(username); + result.identity.usernames.push({ real: username, substitute: '' }); + } + } + } + + // Extract password — import as a redacted mapping + if (passwordIdx >= 0 && cols[passwordIdx]) { + const password = cols[passwordIdx].trim(); + // Skip very short or empty passwords, and deduplicate + if (password && password.length >= 4 && !seenPasswords.has(password)) { + seenPasswords.add(password); + passwordCount++; + result.mappings.push({ + real: password, + substitute: `[REDACTED-PASSWORD-${passwordCount}]`, + category: 'password', + caseSensitive: true, + }); + } + } + + // Extract domain from URL + if (urlIdx >= 0 && cols[urlIdx]) { + try { + const domain = new URL(cols[urlIdx].trim()).hostname; + if (domain && !seenDomains.has(domain) && !this._isCommonDomain(domain)) { + seenDomains.add(domain); + result.mappings.push({ + real: domain, + substitute: '', + category: 'domain', + needsMapping: true, + }); + } + } catch { /* invalid URL */ } + } + } + + return result; + }, + + /** + * Parse Bitwarden CSV export. + */ + parseBitwardenCSV(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + if (lines.length < 2) return result; + + const headers = this._splitCSVLine(lines[0], ',').map(h => h.trim().toLowerCase()); + const usernameIdx = headers.findIndex(h => h.includes('username')); + const passwordIdx = headers.findIndex(h => h.includes('password')); + const uriIdx = headers.findIndex(h => h.includes('uri') || h.includes('url')); + + const seen = new Set(); + const seenPasswords = new Set(); + let passwordCount = 0; + + for (let i = 1; i < lines.length; i++) { + const cols = this._splitCSVLine(lines[i], ','); + + if (usernameIdx >= 0 && cols[usernameIdx]) { + const val = cols[usernameIdx].trim(); + if (val && !seen.has(val)) { + seen.add(val); + if (val.includes('@')) { + result.identity.emails.push({ real: val, substitute: '' }); + } else if (val.length >= 3) { + result.identity.usernames.push({ real: val, substitute: '' }); + } + } + } + + if (passwordIdx >= 0 && cols[passwordIdx]) { + const pw = cols[passwordIdx].trim(); + if (pw && pw.length >= 4 && !seenPasswords.has(pw)) { + seenPasswords.add(pw); + passwordCount++; + result.mappings.push({ + real: pw, + substitute: `[REDACTED-PASSWORD-${passwordCount}]`, + category: 'password', + caseSensitive: true, + }); + } + } + + if (uriIdx >= 0 && cols[uriIdx]) { + try { + const domain = new URL(cols[uriIdx].trim()).hostname; + if (domain && !seen.has(domain) && !this._isCommonDomain(domain)) { + seen.add(domain); + result.mappings.push({ real: domain, substitute: '', category: 'domain', needsMapping: true }); + } + } catch { /* skip */ } + } + } + + return result; + }, + + /** + * Parse 1Password CSV export. + */ + parse1PasswordCSV(text) { + // 1Password CSV is similar enough to handle like password CSV + return this.parsePasswordCSV(text); + }, + + /** + * Parse browser autofill/address CSV. + * Extracts names, emails, phones, addresses. + */ + parseAutofillCSV(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + if (lines.length < 2) return result; + + const headers = this._splitCSVLine(lines[0], ',').map(h => h.trim().toLowerCase()); + + const nameFields = ['name', 'full name', 'first name', 'last name', 'given name', 'family name']; + const emailFields = ['email', 'e-mail', 'email address']; + const phoneFields = ['phone', 'phone number', 'tel', 'telephone']; + const addressFields = ['address', 'street', 'address line 1', 'street address']; + + const findIdx = (targets) => headers.findIndex(h => targets.some(t => h.includes(t))); + + const nameIdx = findIdx(nameFields); + const firstNameIdx = headers.findIndex(h => h === 'first name' || h === 'given name'); + const lastNameIdx = headers.findIndex(h => h === 'last name' || h === 'family name'); + const emailIdx = findIdx(emailFields); + const phoneIdx = findIdx(phoneFields); + const addressIdx = findIdx(addressFields); + + const seen = new Set(); + + for (let i = 1; i < lines.length; i++) { + const cols = this._splitCSVLine(lines[i], ','); + + // Names + if (firstNameIdx >= 0 && cols[firstNameIdx]) { + const val = cols[firstNameIdx].trim(); + if (val && !seen.has('fn:' + val)) { + seen.add('fn:' + val); + result.identity.names.push({ real: val, substitute: '', type: 'first' }); + } + } + if (lastNameIdx >= 0 && cols[lastNameIdx]) { + const val = cols[lastNameIdx].trim(); + if (val && !seen.has('ln:' + val)) { + seen.add('ln:' + val); + result.identity.names.push({ real: val, substitute: '', type: 'last' }); + } + } + if (nameIdx >= 0 && cols[nameIdx] && firstNameIdx < 0) { + const val = cols[nameIdx].trim(); + if (val && !seen.has('n:' + val)) { + seen.add('n:' + val); + // Split "First Last" into two entries + const parts = val.split(/\s+/); + if (parts.length >= 2) { + result.identity.names.push({ real: parts[0], substitute: '', type: 'first' }); + result.identity.names.push({ real: parts.slice(1).join(' '), substitute: '', type: 'last' }); + } else { + result.identity.names.push({ real: val, substitute: '', type: 'first' }); + } + } + } + + // Emails + if (emailIdx >= 0 && cols[emailIdx]) { + const val = cols[emailIdx].trim(); + if (val && !seen.has('e:' + val)) { + seen.add('e:' + val); + result.identity.emails.push({ real: val, substitute: '' }); + } + } + + // Phones + if (phoneIdx >= 0 && cols[phoneIdx]) { + const val = cols[phoneIdx].trim(); + if (val && !seen.has('p:' + val)) { + seen.add('p:' + val); + result.identity.phones.push({ real: val, substitute: '' }); + } + } + + // Addresses + if (addressIdx >= 0 && cols[addressIdx]) { + const val = cols[addressIdx].trim(); + if (val && !seen.has('a:' + val)) { + seen.add('a:' + val); + result.mappings.push({ real: val, substitute: '', category: 'address', needsMapping: true }); + } + } + } + + return result; + }, + + // ---------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------- + + _empty() { + return { + mappings: [], + identity: { + names: [], + emails: [], + usernames: [], + hostnames: [], + phones: [], + }, + }; + }, + + _isHeader(a, b) { + const headers = ['real', 'substitute', 'fake', 'original', 'replacement', 'from', 'to', 'value', 'category', 'type']; + return headers.includes(a.toLowerCase()) || headers.includes(b.toLowerCase()); + }, + + _guessCategory(value) { + if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return 'email'; + if (/^[\d\s()+.-]{7,}$/.test(value)) return 'phone'; + if (/^\d{3}-\d{2}-\d{4}$/.test(value)) return 'ssn'; + if (/\d{1,5}\s+\w+\s+(st|street|ave|avenue|blvd|dr|drive|rd|road|ln|lane)/i.test(value)) return 'address'; + if (/^[a-z][a-z0-9._-]*$/i.test(value) && value.length >= 3 && value.length <= 20) return 'general'; + if (/^[A-Z][a-z]+(\s[A-Z][a-z]+)*$/.test(value)) return 'name'; + return 'general'; + }, + + /** + * Split a CSV line respecting quoted fields. + */ + _splitCSVLine(line, sep = ',') { + const result = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (ch === sep && !inQuotes) { + result.push(current); + current = ''; + } else { + current += ch; + } + } + result.push(current); + + // Strip surrounding quotes + return result.map(s => s.replace(/^"|"$/g, '')); + }, + + _isCommonDomain(domain) { + const common = new Set([ + 'google.com', 'facebook.com', 'twitter.com', 'x.com', 'amazon.com', + 'apple.com', 'microsoft.com', 'github.com', 'youtube.com', 'reddit.com', + 'netflix.com', 'linkedin.com', 'instagram.com', 'wikipedia.org', + 'stackoverflow.com', 'accounts.google.com', 'login.microsoftonline.com', + ]); + return common.has(domain); + }, +}; + +export default ImportParser; diff --git a/src/options/options.html b/src/options/options.html index e0bb909..f71b13f 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -398,6 +398,32 @@ + +
+ Import real values from a CSV, password manager export, or browser autofill export. + Passwords are never imported — only usernames, emails, names, and domains. + Imported values appear with blank substitutes so you can fill in fakes. +
+