Merge pull request #10 from outis1one/claude/read-repo-wA3y1

Claude/read repo w a3y1
This commit is contained in:
Outis
2026-03-26 16:08:45 -04:00
committed by GitHub
6 changed files with 934 additions and 3 deletions
+115
View File
@@ -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.
+113
View File
@@ -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 => {
+86 -3
View File
@@ -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;
}
+449
View File
@@ -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;
+26
View File
@@ -398,6 +398,32 @@
<button class="btn" id="btnImportAll">Import from File</button>
<input type="file" id="fileImportAll" accept=".json,.ssbackup" hidden>
</div>
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb">
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Bulk Import — populate from existing data</h3>
<p class="section-desc" style="margin-bottom:8px">
Import real values from a CSV, password manager export, or browser autofill export.
Passwords are <strong>never</strong> imported — only usernames, emails, names, and domains.
Imported values appear with blank substitutes so you can fill in fakes.
</p>
<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary" id="btnBulkImport">Import CSV / Password Export</button>
<input type="file" id="fileBulkImport" accept=".csv,.tsv,.txt" hidden>
<span style="font-size:11px;color:#6b7280">Chrome passwords, Firefox logins, Bitwarden, 1Password, autofill, or plain CSV</span>
</div>
<div id="bulkImportPreview" style="display:none;margin-top:10px;padding:10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px">
<h4 style="font-size:12px;font-weight:600;margin:0 0 8px">Import Preview</h4>
<div id="bulkImportSummary" style="font-size:12px;margin-bottom:8px"></div>
<div id="bulkImportItems" style="max-height:200px;overflow-y:auto;font-size:11px;margin-bottom:8px"></div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="btnApplyBulkImport">Apply Import</button>
<button class="btn btn-sm" id="btnCancelBulkImport">Cancel</button>
</div>
</div>
<div id="bulkImportStatus" style="font-size:12px;margin-top:6px;min-height:16px"></div>
</div>
</section>
<section class="section">
+145
View File
@@ -2,6 +2,7 @@ import Storage from '../lib/storage.js';
import SilentSendCrypto from '../lib/crypto.js';
import SilentSendSync from '../lib/sync.js';
import VersionHistory from '../lib/version-history.js';
import ImportParser from '../lib/import-parser.js';
import SilentSendMerge from '../lib/merge.js';
import OrgPolicy from '../lib/org-policy.js';
import TamperGuard from '../lib/tamper-guard.js';
@@ -237,6 +238,14 @@ document.addEventListener('DOMContentLoaded', async () => {
$('#btnImportAll').addEventListener('click', () => $('#fileImportAll').click());
$('#fileImportAll').addEventListener('change', importAll);
// Bulk import
$('#btnBulkImport').addEventListener('click', () => $('#fileBulkImport').click());
$('#fileBulkImport').addEventListener('change', handleBulkImport);
$('#btnCancelBulkImport').addEventListener('click', () => {
$('#bulkImportPreview').style.display = 'none';
$('#fileBulkImport').value = '';
});
// Custom domains
$('#btnAddDomain').addEventListener('click', addDomain);
$('#newDomain').addEventListener('keydown', (e) => {
@@ -1497,6 +1506,142 @@ function renderConflicts(conflicts) {
});
}
// ----------------------------------------------------------------
// Bulk Import
// ----------------------------------------------------------------
let pendingImport = null;
async function handleBulkImport(e) {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const result = ImportParser.parse(text, file.name);
pendingImport = result;
// Build summary
const parts = [];
if (result.identity.names.length) parts.push(`${result.identity.names.length} name(s)`);
if (result.identity.emails.length) parts.push(`${result.identity.emails.length} email(s)`);
if (result.identity.usernames.length) parts.push(`${result.identity.usernames.length} username(s)`);
if (result.identity.phones.length) parts.push(`${result.identity.phones.length} phone(s)`);
if (result.mappings.length) parts.push(`${result.mappings.length} mapping(s)`);
const needsMapping = result.mappings.filter(m => m.needsMapping).length +
result.identity.names.filter(n => !n.substitute).length +
result.identity.emails.filter(e => !e.substitute).length +
result.identity.usernames.filter(u => !u.substitute).length +
result.identity.phones.filter(p => !p.substitute).length;
$('#bulkImportSummary').innerHTML = `
Found: ${parts.join(', ')}.
${needsMapping > 0 ? `<span style="color:#b45309">${needsMapping} item(s) need substitutes — you can add them after import.</span>` : ''}
`;
// Build preview list
const items = [];
for (const n of result.identity.names) {
items.push(`<div><span style="color:#6b7280">name:</span> <strong>${escapeHtml(n.real)}</strong>${n.substitute ? ' → ' + escapeHtml(n.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
}
for (const e of result.identity.emails) {
items.push(`<div><span style="color:#6b7280">email:</span> <strong>${escapeHtml(e.real)}</strong>${e.substitute ? ' → ' + escapeHtml(e.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
}
for (const u of result.identity.usernames) {
items.push(`<div><span style="color:#6b7280">username:</span> <strong>${escapeHtml(u.real)}</strong>${u.substitute ? ' → ' + escapeHtml(u.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
}
for (const p of result.identity.phones) {
items.push(`<div><span style="color:#6b7280">phone:</span> <strong>${escapeHtml(p.real)}</strong>${p.substitute ? ' → ' + escapeHtml(p.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
}
for (const m of result.mappings) {
items.push(`<div><span style="color:#6b7280">${escapeHtml(m.category)}:</span> <strong>${escapeHtml(m.real)}</strong>${m.substitute ? ' → ' + escapeHtml(m.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
}
$('#bulkImportItems').innerHTML = items.slice(0, 50).join('') +
(items.length > 50 ? `<div style="color:#6b7280;margin-top:4px">+${items.length - 50} more...</div>` : '');
$('#bulkImportPreview').style.display = 'block';
// Wire up apply button
$('#btnApplyBulkImport').onclick = applyBulkImport;
setBulkImportStatus(`Parsed ${file.name} — review and click Apply.`, 'ok');
} catch (err) {
setBulkImportStatus('Failed to parse: ' + err.message, 'error');
}
e.target.value = '';
}
async function applyBulkImport() {
if (!pendingImport) return;
const result = pendingImport;
let addedCount = 0;
// Add to identity (first active profile)
const profiles = await Storage.getProfiles();
if (profiles.length > 0) {
const profile = profiles.find(p => p.active) || profiles[0];
if (result.identity.names.length) {
profile.names = [...(profile.names || []), ...result.identity.names];
addedCount += result.identity.names.length;
}
if (result.identity.emails.length) {
profile.emails = [...(profile.emails || []), ...result.identity.emails];
addedCount += result.identity.emails.length;
}
if (result.identity.usernames.length) {
profile.usernames = [...(profile.usernames || []), ...result.identity.usernames];
addedCount += result.identity.usernames.length;
}
if (result.identity.phones.length) {
profile.phones = [...(profile.phones || []), ...result.identity.phones];
addedCount += result.identity.phones.length;
}
await Storage.updateProfile(profile.id, profile);
}
// Add mappings
for (const m of result.mappings) {
await Storage.addMapping({
real: m.real,
substitute: m.substitute || '',
category: m.category || 'general',
caseSensitive: false,
});
addedCount++;
}
// Refresh UI
mappings = await Storage.getMappings();
renderMappings();
$('#bulkImportPreview').style.display = 'none';
pendingImport = null;
const needsSubs = result.mappings.filter(m => !m.substitute).length +
result.identity.names.filter(n => !n.substitute).length +
result.identity.emails.filter(e => !e.substitute).length +
result.identity.usernames.filter(u => !u.substitute).length +
result.identity.phones.filter(p => !p.substitute).length;
setBulkImportStatus(
`Imported ${addedCount} items.${needsSubs > 0 ? ` ${needsSubs} still need substitutes — check Identity tab and Mappings.` : ''}`,
'ok'
);
}
function setBulkImportStatus(msg, type) {
const el = $('#bulkImportStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
// ----------------------------------------------------------------
// Utility
// ----------------------------------------------------------------