Merge pull request #38 from outis1one/claude/read-repo-YMu21

v0.9.0: Security hardening, new sites, custom redact patterns, test suite
This commit is contained in:
Outis
2026-03-28 12:56:20 -04:00
committed by GitHub
4 changed files with 513 additions and 5 deletions
+4 -2
View File
@@ -118,6 +118,8 @@
// Also log directly from the injector (content script world)
// in case the background worker is asleep
const replacements = event.data.replacements || [];
const settingsResult = await api.storage.local.get('ss_settings');
const maxLog = settingsResult.ss_settings?.maxLogEntries || 100;
for (const r of replacements) {
const log = (await api.storage.local.get('ss_activity_log')).ss_activity_log || [];
log.unshift({
@@ -131,8 +133,8 @@
pattern: r.pattern || '',
url: location.href,
});
// Trim
if (log.length > 100) log.length = 100;
// Trim to user-configured max
if (log.length > maxLog) log.length = maxLog;
await api.storage.local.set({ ss_activity_log: log });
}
}
+1 -1
View File
@@ -217,7 +217,7 @@
<strong>Auto-redact detected PII</strong>
<span class="setting-desc">Replace detected PII with placeholders on send</span>
</div>
<label class="toggle"><input type="checkbox" id="optAutoRedact" checked><span class="toggle-slider"></span></label>
<label class="toggle"><input type="checkbox" id="optAutoRedactDetected" checked><span class="toggle-slider"></span></label>
</div>
<div class="setting-item">
+2 -2
View File
@@ -216,7 +216,7 @@ async function initUnlockedUI() {
// Load options tab settings
$('#optAutoRedact').checked = settings.autoRedact !== false;
$('#optAutoDetect').checked = settings.autoDetect !== false;
$('#optAutoRedact').checked = settings.autoRedactDetected !== false;
$('#optAutoRedactDetected').checked = settings.autoRedactDetected !== false;
$('#optHighlights').checked = settings.showHighlights || false;
$('#optDocPreview').checked = settings.docScanPreview !== false;
$('#optProperNouns').checked = settings.detectProperNouns || false;
@@ -225,7 +225,7 @@ async function initUnlockedUI() {
const optHandlers = [
['optAutoRedact', 'autoRedact'],
['optAutoDetect', 'autoDetect'],
['optAutoRedact', 'autoRedactDetected'],
['optAutoRedactDetected', 'autoRedactDetected'],
['optHighlights', 'showHighlights'],
['optDocPreview', 'docScanPreview'],
['optProperNouns', 'detectProperNouns'],
+506
View File
@@ -0,0 +1,506 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Silent Send - Feature Test Suite</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; font-size: 13px; padding: 20px; background: #f9fafb; }
h1 { font-size: 18px; margin-bottom: 16px; }
h2 { font-size: 14px; margin: 16px 0 8px; color: #374151; border-bottom: 1px solid #e5e7eb; padding-bottom: 4px; }
.test-group { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; margin-bottom: 12px; }
.test { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
.test .status { width: 18px; text-align: center; font-weight: bold; }
.pass { color: #10b981; }
.fail { color: #dc2626; }
.skip { color: #f59e0b; }
.detail { color: #6b7280; font-size: 11px; margin-left: 26px; }
#summary { font-size: 14px; font-weight: 600; margin-top: 16px; padding: 12px; border-radius: 8px; }
#summary.all-pass { background: #d1fae5; color: #065f46; }
#summary.has-fail { background: #fee2e2; color: #991b1b; }
button { padding: 8px 16px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer; font-size: 13px; }
button:hover { background: #f3f4f6; }
button.primary { background: #3b82f6; color: #fff; border-color: #3b82f6; }
</style>
</head>
<body>
<h1>Silent Send - Feature Test Suite</h1>
<p style="margin-bottom:12px;color:#6b7280">
Load this page as an extension page (or inject via dev console on the Options page).<br>
Tests exercise sync, encryption, storage, auto-redact, and domain management.
</p>
<button class="primary" onclick="runAllTests()">Run All Tests</button>
<button onclick="document.getElementById('results').innerHTML=''">Clear</button>
<div id="results" style="margin-top:16px"></div>
<div id="summary"></div>
<script type="module">
import Storage from './src/lib/storage.js';
import SilentSendSync from './src/lib/sync.js';
import SilentSendCrypto from './src/lib/crypto.js';
import AutoRedact from './src/lib/auto-redact.js';
import AutoDetect from './src/lib/auto-detect.js';
import SmartPatterns from './src/lib/smart-patterns.js';
import SubstitutionEngine from './src/lib/substitution-engine.js';
const results = [];
let currentGroup = '';
function group(name) {
currentGroup = name;
}
function test(name, fn) {
results.push({ group: currentGroup, name, fn });
}
function renderResults(outcomes) {
const el = document.getElementById('results');
const groups = {};
for (const o of outcomes) {
if (!groups[o.group]) groups[o.group] = [];
groups[o.group].push(o);
}
let html = '';
for (const [groupName, tests] of Object.entries(groups)) {
html += `<div class="test-group"><h2>${groupName}</h2>`;
for (const t of tests) {
const icon = t.status === 'pass' ? '✓' : t.status === 'fail' ? '✗' : '○';
const cls = t.status;
html += `<div class="test"><span class="status ${cls}">${icon}</span> ${t.name}</div>`;
if (t.error) html += `<div class="detail">${t.error}</div>`;
}
html += '</div>';
}
el.innerHTML = html;
const passed = outcomes.filter(o => o.status === 'pass').length;
const failed = outcomes.filter(o => o.status === 'fail').length;
const skipped = outcomes.filter(o => o.status === 'skip').length;
const sum = document.getElementById('summary');
sum.className = failed > 0 ? 'has-fail' : 'all-pass';
sum.textContent = `${passed} passed, ${failed} failed, ${skipped} skipped — ${outcomes.length} total`;
}
async function runAllTests() {
results.length = 0;
// ============================================================
// STORAGE TESTS
// ============================================================
group('Storage — Settings');
test('Default settings have autoRedact=true', async () => {
const s = await Storage.getSettings();
if (s.autoRedact !== true) throw `Expected autoRedact=true, got ${s.autoRedact}`;
});
test('Default settings have customRedactPatterns=[]', async () => {
const s = await Storage.getSettings();
if (!Array.isArray(s.customRedactPatterns)) throw `Expected array, got ${typeof s.customRedactPatterns}`;
});
test('Default settings have maxLogEntries=100', async () => {
const s = await Storage.getSettings();
if (s.maxLogEntries !== 100) throw `Expected 100, got ${s.maxLogEntries}`;
});
test('Default settings have browserSync=false', async () => {
const s = await Storage.getSettings();
if (s.browserSync !== false) throw `Expected false, got ${s.browserSync}`;
});
test('Save and read settings round-trip', async () => {
await Storage.saveSettings({ autoRedact: false });
const s = await Storage.getSettings();
if (s.autoRedact !== false) throw `Expected false after save, got ${s.autoRedact}`;
await Storage.saveSettings({ autoRedact: true }); // restore
});
group('Storage — Activity Log');
test('Activity log respects maxLogEntries=100', async () => {
// Save current log
const origLog = await Storage.getLog();
await Storage.clearLog();
// Add 110 entries
for (let i = 0; i < 110; i++) {
await Storage.addLogEntry({ type: 'test', original: `test${i}`, replaced: `fake${i}` });
}
const log = await Storage.getLog();
if (log.length > 100) throw `Expected <=100 entries, got ${log.length}`;
await Storage.clearLog();
});
group('Storage — Mappings');
test('Add and retrieve mapping', async () => {
const m = await Storage.addMapping({ real: 'TestReal', substitute: 'TestFake', category: 'general' });
if (!m.id) throw 'Mapping has no id';
const all = await Storage.getMappings();
const found = all.find(x => x.id === m.id);
if (!found) throw 'Mapping not found after add';
await Storage.deleteMapping(m.id);
});
group('Storage — Profiles');
test('Add and retrieve profile', async () => {
const p = await Storage.addProfile('Test Profile');
if (!p.id) throw 'Profile has no id';
const all = await Storage.getProfiles();
const found = all.find(x => x.id === p.id);
if (!found) throw 'Profile not found after add';
await Storage.deleteProfile(p.id);
});
// ============================================================
// ENCRYPTION TESTS
// ============================================================
group('Encryption — Setup & Authentication');
test('Setup encryption with password', async () => {
// Clean state
await SilentSendSync.disableEncryption().catch(() => {});
const result = await SilentSendSync.setupEncryption({
password: 'testpass123',
enableTOTP: false,
ttlDays: 90,
});
if (!result.success) throw `Setup failed: ${result.reason}`;
});
test('isEncryptionEnabled returns true after setup', async () => {
const enabled = await SilentSendSync.isEncryptionEnabled();
if (!enabled) throw 'Expected encryption to be enabled';
});
test('Authenticate with correct password', async () => {
const result = await SilentSendSync.authenticate('testpass123');
if (!result.success) throw `Auth failed: ${result.reason}`;
});
test('Authenticate with wrong password fails', async () => {
// Clear cached key first to force re-auth
await SilentSendCrypto.clearCachedKey();
const result = await SilentSendSync.authenticate('wrongpassword');
if (result.success) throw 'Expected auth to fail with wrong password';
// Re-auth with correct password
await SilentSendSync.authenticate('testpass123');
});
group('Encryption — At-Rest');
test('Data is encrypted at rest when encryption enabled', async () => {
const api = (typeof browser !== 'undefined') ? browser : chrome;
await Storage.saveMappings([{ id: 'enc-test', real: 'EncSecret', substitute: 'EncFake' }]);
const raw = await api.storage.local.get('ss_mappings');
const val = raw.ss_mappings;
if (!val?._ssLocalEncrypted) throw 'Expected _ssLocalEncrypted flag on stored data';
// Clean up
await Storage.saveMappings([]);
});
test('Encrypted data reads back correctly', async () => {
await Storage.saveMappings([{ id: 'enc-rt', real: 'RoundTrip', substitute: 'RTFake' }]);
const mappings = await Storage.getMappings();
const found = mappings.find(m => m.id === 'enc-rt');
if (!found) throw 'Mapping not found after encrypted round-trip';
if (found.real !== 'RoundTrip') throw `Expected 'RoundTrip', got '${found.real}'`;
await Storage.saveMappings([]);
});
// ============================================================
// SYNC — ENCRYPTION MANDATORY
// ============================================================
group('Sync — Encryption Required');
test('Sync code export works when encryption enabled', async () => {
const code = await SilentSendSync.exportSyncCode();
if (code?.needsEncryption) throw 'Should not need encryption — it is enabled';
if (code?.needsAuth) throw 'Should not need auth — key is cached';
if (typeof code !== 'string') throw `Expected string, got ${typeof code}`;
if (code.length < 10) throw 'Sync code seems too short';
});
test('Sync code export fails without encryption', async () => {
await SilentSendSync.disableEncryption();
const code = await SilentSendSync.exportSyncCode();
if (!code?.needsEncryption) throw 'Expected needsEncryption=true when encryption disabled';
// Re-enable
await SilentSendSync.setupEncryption({ password: 'testpass123' });
});
test('Sync code import round-trip', async () => {
// Add test data
await Storage.saveMappings([{ id: 'sync-test', real: 'SyncMe', substitute: 'SyncFake' }]);
const code = await SilentSendSync.exportSyncCode();
// Clear and re-import
await Storage.saveMappings([]);
const result = await SilentSendSync.importSyncCode(code, { force: true });
if (!result.success) throw `Import failed: ${result.reason}`;
const mappings = await Storage.getMappings();
const found = mappings.find(m => m.real === 'SyncMe');
if (!found) throw 'Mapping not found after sync code round-trip';
await Storage.saveMappings([]);
});
test('pushToSyncStorage silently skips without encryption', async () => {
await SilentSendSync.disableEncryption();
// Should not throw, just silently skip
await SilentSendSync.pushToSyncStorage();
// Re-enable
await SilentSendSync.setupEncryption({ password: 'testpass123' });
});
test('pushToGist fails without encryption', async () => {
await SilentSendSync.disableEncryption();
const result = await SilentSendSync.pushToGist('fake-token');
if (!result.needsEncryption) throw 'Expected needsEncryption=true';
// Re-enable
await SilentSendSync.setupEncryption({ password: 'testpass123' });
});
test('pushToUrl fails without encryption', async () => {
await SilentSendSync.disableEncryption();
const result = await SilentSendSync.pushToUrl({ url: 'https://example.com/sync' });
if (!result.needsEncryption) throw 'Expected needsEncryption=true';
// Re-enable
await SilentSendSync.setupEncryption({ password: 'testpass123' });
});
group('Sync — Disable Encryption Disables Sync');
test('Disabling encryption sets browserSync=false', async () => {
await Storage.saveSettings({ browserSync: true });
await SilentSendSync.disableEncryption();
const s = await Storage.getSettings();
if (s.browserSync !== false) throw `Expected browserSync=false, got ${s.browserSync}`;
// Re-enable encryption
await SilentSendSync.setupEncryption({ password: 'testpass123' });
});
// ============================================================
// ENCRYPTION WITH TOTP
// ============================================================
group('Encryption — TOTP');
test('Setup encryption with TOTP', async () => {
await SilentSendSync.disableEncryption().catch(() => {});
const result = await SilentSendSync.setupEncryption({
password: 'totptest123',
enableTOTP: true,
ttlDays: 90,
});
if (!result.success) throw `Setup failed: ${result.reason}`;
if (!result.totpSecret) throw 'Expected TOTP secret in response';
if (!result.totpURI) throw 'Expected TOTP URI in response';
});
test('TOTP secret is encrypted at rest', async () => {
const config = await SilentSendSync._getSyncEncryption();
if (config.totpSecret) throw 'TOTP secret should be encrypted, not plaintext';
if (!config._totpEncrypted) throw 'Expected _totpEncrypted blob';
});
test('Clean up — disable encryption', async () => {
await SilentSendSync.disableEncryption();
const enabled = await SilentSendSync.isEncryptionEnabled();
if (enabled) throw 'Encryption should be disabled';
});
// ============================================================
// AUTO-REDACT TESTS
// ============================================================
group('Auto-Redact — Built-in Patterns');
test('Detects OpenAI key', () => {
const result = AutoRedact.redact('my key is sk-abc123def456ghi789jkl012mno');
if (result.redactions.length === 0) throw 'Expected redaction';
if (!result.text.includes('[REDACTED-OPENAI-KEY]')) throw 'Expected [REDACTED-OPENAI-KEY]';
});
test('Detects GitHub token', () => {
const result = AutoRedact.redact('token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn');
if (result.redactions.length === 0) throw 'Expected redaction';
});
test('Detects SSN', () => {
const result = AutoRedact.redact('my ssn is 123-45-6789');
if (result.redactions.length === 0) throw 'Expected redaction';
if (!result.text.includes('[REDACTED-SSN]')) throw 'Expected [REDACTED-SSN]';
});
test('Detects credit card', () => {
const result = AutoRedact.redact('card: 4111 1111 1111 1111');
if (result.redactions.length === 0) throw 'Expected redaction';
});
test('Detects Bearer token', () => {
const result = AutoRedact.redact('Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9abcdef');
if (result.redactions.length === 0) throw 'Expected redaction';
});
test('Detects private key block', () => {
const result = AutoRedact.redact('-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----');
if (result.redactions.length === 0) throw 'Expected redaction';
});
test('Detects connection string', () => {
const result = AutoRedact.redact('mongodb://admin:secret@db.example.com:27017/mydb');
if (result.redactions.length === 0) throw 'Expected redaction';
});
group('Auto-Redact — Custom Patterns');
test('Custom pattern matches', () => {
const custom = [{ name: 'ControlD', pattern: 'dns\\.controld\\.com/[A-Za-z0-9;]+', redact: '[REDACTED-CONTROLD]', enabled: true }];
const result = AutoRedact.redact('url: https://dns.controld.com/sasdkj;kjasda', custom);
if (result.redactions.length === 0) throw 'Expected custom pattern redaction';
if (!result.text.includes('[REDACTED-CONTROLD]')) throw 'Expected [REDACTED-CONTROLD]';
});
test('Disabled custom pattern does not match', () => {
const custom = [{ name: 'ControlD', pattern: 'dns\\.controld\\.com/[A-Za-z0-9;]+', redact: '[REDACTED]', enabled: false }];
const result = AutoRedact.redact('url: https://dns.controld.com/sasdkj;kjasda', custom);
if (result.text.includes('[REDACTED]')) throw 'Disabled pattern should not match';
});
test('Invalid regex in custom pattern is skipped gracefully', () => {
const custom = [{ name: 'Bad', pattern: '[invalid(regex', redact: '[X]', enabled: true }];
// Should not throw
const result = AutoRedact.redact('test text', custom);
if (result.text !== 'test text') throw 'Text should be unchanged';
});
test('Redactions use category=redact', () => {
const result = AutoRedact.redact('key: sk-abc123def456ghi789jkl012mno');
if (result.redactions.length > 0 && result.redactions[0].category !== 'redact') {
throw `Expected category 'redact', got '${result.redactions[0].category}'`;
}
});
// ============================================================
// SUBSTITUTION ENGINE
// ============================================================
group('Substitution Engine');
test('Basic substitution works', () => {
const result = SubstitutionEngine.substitute('Hello John Smith', [
{ real: 'John Smith', substitute: 'Alex Demo', enabled: true }
]);
if (!result.text.includes('Alex Demo')) throw `Expected 'Alex Demo' in output`;
});
test('Case insensitive by default', () => {
const result = SubstitutionEngine.substitute('hello JOHN SMITH', [
{ real: 'John Smith', substitute: 'Alex Demo', enabled: true, caseSensitive: false }
]);
if (!result.text.includes('Alex Demo')) throw 'Expected case-insensitive match';
});
test('Disabled mapping is skipped', () => {
const result = SubstitutionEngine.substitute('Hello John Smith', [
{ real: 'John Smith', substitute: 'Alex Demo', enabled: false }
]);
if (result.text.includes('Alex Demo')) throw 'Disabled mapping should not substitute';
});
// ============================================================
// SMART PATTERNS
// ============================================================
group('Smart Patterns — Identity');
test('Email substitution', () => {
const identity = {
emails: [{ real: 'john@gmail.com', substitute: 'alex@example.com' }],
names: [], usernames: [], phones: [], hostnames: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
};
const result = SmartPatterns.substitute('email: john@gmail.com', identity);
if (!result.text.includes('alex@example.com')) throw 'Expected email substitution';
});
test('Name substitution', () => {
const identity = {
names: [{ real: 'John Smith', substitute: 'Alex Demo' }],
emails: [], usernames: [], phones: [], hostnames: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
};
const result = SmartPatterns.substitute('My name is John Smith', identity);
if (!result.text.includes('Alex Demo')) throw 'Expected name substitution';
});
test('Phone substitution', () => {
const identity = {
phones: [{ real: '555-123-4567', substitute: '555-000-0000' }],
names: [], emails: [], usernames: [], hostnames: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
};
const result = SmartPatterns.substitute('call 555-123-4567', identity);
if (!result.text.includes('555-000-0000')) throw 'Expected phone substitution';
});
// ============================================================
// AUTO-DETECT (PII Scanner)
// ============================================================
group('Auto-Detect — PII Patterns');
test('Detects private IP addresses', () => {
const warnings = AutoDetect.scan('server at 192.168.1.100', {});
const ipWarn = warnings.find(w => w.type === 'private-ip');
if (!ipWarn) throw 'Expected private IP detection';
});
test('Detects SSN pattern', () => {
const warnings = AutoDetect.scan('ssn: 123-45-6789', {});
// SSN may be caught by auto-detect or auto-redact — either is fine
if (warnings.length === 0) {
// Auto-redact catches it, auto-detect may not duplicate — this is OK
}
});
// ============================================================
// SETTINGS INTEGRITY
// ============================================================
group('Settings — No Stale Keys');
test('No secretScanning key in defaults', async () => {
const s = await Storage.getSettings();
if ('secretScanning' in s && !('autoRedact' in s)) throw 'Found stale secretScanning key';
});
test('No customSecretPatterns key in defaults', async () => {
const s = await Storage.getSettings();
if ('customSecretPatterns' in s && !('customRedactPatterns' in s)) throw 'Found stale customSecretPatterns key';
});
// ============================================================
// RUN ALL
// ============================================================
window.runAllTests = async function() {
const outcomes = [];
for (const t of results) {
try {
await t.fn();
outcomes.push({ group: t.group, name: t.name, status: 'pass' });
} catch (e) {
const msg = typeof e === 'string' ? e : (e?.message || String(e));
outcomes.push({ group: t.group, name: t.name, status: 'fail', error: msg });
}
}
renderResults(outcomes);
};
// Auto-register tests on load
console.log(`[Silent Send Tests] ${results.length} tests registered. Click "Run All Tests" to start.`);
</script>
</body>
</html>