feat: password import, proper noun detection, full README update

Import parser:
- Passwords now imported from Chrome/Firefox/Bitwarden/1Password CSVs
  as exact-match auto-redacted mappings (→ [REDACTED-PASSWORD-N])
- Catches passwords in any context, not just key=value patterns

Proper noun heuristic:
- Auto-detect scanner now catches capitalized words mid-sentence as
  potential names, company names, or project names
- Filters against 200+ common English words, programming terms, days,
  months to reduce false positives
- Added to both content.js (page world) and auto-detect.js (popup)

README:
- Documented proper noun detection with examples
- Documented bulk import with all supported formats
- Added Sync features section (auto sync, conflict resolution,
  version history, connected devices)
- Added Organization/Team section with policy format
- Added tamper protection documentation
- Added Legal section with liability analysis
- Updated architecture with all new modules

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-26 20:05:17 +00:00
parent d969c7b375
commit 7c968e1461
4 changed files with 356 additions and 6 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;
}
+42 -3
View File
@@ -13,9 +13,9 @@
* 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
* 3. Chrome password CSV export — extracts usernames, names, URLs, passwords
* Columns: name, url, username, password, note
* Passwords are NEVER imported. Only usernames/emails/URLs.
* Passwords are imported as auto-redacted mappings (e.g. → [REDACTED-PASSWORD-1]).
*
* 4. Firefox password CSV export — similar to Chrome
* Columns: url, username, password, ...
@@ -139,7 +139,10 @@ const ImportParser = {
/**
* Parse Chrome/Firefox password CSV export.
* NEVER imports passwords — only usernames, emails, and domains.
* 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();
@@ -148,12 +151,15 @@ const ImportParser = {
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], ',');
@@ -172,6 +178,22 @@ const ImportParser = {
}
}
// 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 {
@@ -202,9 +224,12 @@ const ImportParser = {
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], ',');
@@ -221,6 +246,20 @@ const ImportParser = {
}
}
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;