Merge pull request #4 from outis1one/claude/privacy-data-substitution-extension-OvTcA

Claude/privacy data substitution extension ov tc a
This commit is contained in:
Outis
2026-03-26 00:52:51 -04:00
committed by GitHub
13 changed files with 1270 additions and 103 deletions
+34 -17
View File
@@ -1,21 +1,38 @@
MIT License
Business Source License 1.1
Copyright (c) 2025 Silent Send Contributors
Licensor: Silent Send Contributors
Licensed Work: Silent Send browser extension
Change Date: March 26, 2030
Change License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Terms
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The Licensor hereby grants you the right to copy, modify, create
derivative works, redistribute, and make non-production use of the
Licensed Work.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The Licensor hereby grants you the right to make production use of
the Licensed Work for personal, non-commercial purposes.
For commercial use, you must obtain a commercial license from the
Licensor. Contact: [your-email-here]
Effective on the Change Date, the Licensor hereby grants you rights
under the terms of the Change License, and the rights granted above
terminate.
If your use of the Licensed Work does not comply with the
requirements currently in effect as described in this License, you
must purchase a commercial license from the Licensor, or you must
refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and
derivative works of the Licensed Work, are subject to this License.
THE LICENSED WORK IS PROVIDED "AS IS". THE LICENSOR HEREBY DISCLAIMS
ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE LICENSED WORK.
+1 -1
View File
@@ -290,4 +290,4 @@ src/
## License
[MIT](LICENSE) — use it for anything, commercial or personal, modify it, redistribute it, relicense it. Just keep the copyright notice in copies of the code.
[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.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "silent-send",
"version": "0.3.1",
"private": true,
"license": "MIT",
"license": "BSL-1.1",
"description": "Browser extension that substitutes personal data before sending to AI services",
"scripts": {
"build:chrome": "./build.sh chrome",
+32 -14
View File
@@ -20,7 +20,9 @@ if [ ! -f "$ENV_FILE" ]; then
exit 1
fi
# --- Auto-bump patch version ---
# --- Auto-bump version — always unique, no metadata ---
# Reads current version, increments patch. If already signed,
# keeps incrementing until it works.
CURRENT_VERSION=$(grep -o '"version": "[^"]*"' "$MANIFEST" | head -1 | grep -o '[0-9.]*')
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
PATCH=$((PATCH + 1))
@@ -86,17 +88,33 @@ echo ""
echo "Building Firefox extension..."
"$SCRIPT_DIR/build.sh" firefox
# Sign
echo "Signing v$NEW_VERSION with Mozilla..."
npx web-ext sign \
--no-config-discovery \
--source-dir "$SCRIPT_DIR/dist/firefox" \
--artifacts-dir "$SCRIPT_DIR/dist/firefox-signed" \
--channel unlisted \
--api-key "$API_KEY" \
--api-secret "$API_SECRET"
# Sign — retry with incremented patch if version conflict
MAX_ATTEMPTS=10
for attempt in $(seq 1 $MAX_ATTEMPTS); do
echo "Signing v$NEW_VERSION with Mozilla (attempt $attempt)..."
echo ""
echo "Done! v$NEW_VERSION signed."
echo "Install the .xpi file from dist/firefox-signed/"
echo "Drag it into Firefox or use File → Open File."
if npx web-ext sign \
--no-config-discovery \
--source-dir "$SCRIPT_DIR/dist/firefox" \
--artifacts-dir "$SCRIPT_DIR/dist/firefox-signed" \
--channel unlisted \
--api-key "$API_KEY" \
--api-secret "$API_SECRET" 2>&1; then
echo ""
echo "Done! v$NEW_VERSION signed."
echo "Install the .xpi file from dist/firefox-signed/"
echo "Drag it into Firefox or use File → Open File."
exit 0
fi
# If it failed due to version conflict, bump and rebuild
echo "Version $NEW_VERSION already exists, trying next..."
PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$NEW_VERSION\"/" "$SCRIPT_DIR/dist/firefox/manifest.json"
done
echo "Error: Failed after $MAX_ATTEMPTS attempts."
exit 1
+205 -6
View File
@@ -13,14 +13,213 @@
padding: 0 1px;
}
/* Revealed text styling (when reveal mode shows real values in responses) */
.ss-revealed {
background: rgba(59, 130, 246, 0.1);
border-bottom: 1.5px dashed rgba(59, 130, 246, 0.5);
border-radius: 2px;
padding: 0 1px;
/* CSS Custom Highlight API styles — zero DOM changes */
/* Yellow highlight: fake values the AI received (non-reveal mode) */
::highlight(ss-substituted) {
background-color: rgba(250, 204, 21, 0.4);
color: inherit;
}
/* Terminal style: real data shown in reveal mode (dark bg, green text) */
::highlight(ss-revealed) {
background-color: rgba(0, 0, 0, 0.85);
color: #4ade80;
}
/* Fallback for browsers without Highlight API */
.ss-revealed {
background: rgba(0, 0, 0, 0.85);
color: #4ade80;
padding: 0 2px;
border-radius: 2px;
}
/* Auto-detect PPI warning banner */
.ss-autodetect-warning {
position: fixed;
top: 16px;
right: 16px;
max-width: 400px;
background: #1a1a1a;
color: #e5e7eb;
border: 1px solid #f59e0b;
border-radius: 10px;
padding: 12px 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
z-index: 999999;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.ss-autodetect-warning.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.ss-ad-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
color: #f59e0b;
font-size: 11px;
line-height: 1.4;
}
.ss-ad-close {
background: none;
border: none;
color: #6b7280;
font-size: 18px;
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
}
.ss-ad-close:hover { color: #fff; }
.ss-ad-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid #333;
}
.ss-ad-item:last-of-type { border-bottom: none; }
.ss-ad-type {
font-size: 10px;
font-weight: 600;
color: #f59e0b;
min-width: 70px;
text-transform: uppercase;
}
.ss-ad-value {
font-family: 'SF Mono', Monaco, monospace;
font-size: 11px;
color: #4ade80;
background: #0a0a0a;
padding: 2px 6px;
border-radius: 4px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ss-ad-hint {
font-size: 10px;
color: #9ca3af;
flex: 1;
}
.ss-ad-more {
font-size: 10px;
color: #6b7280;
padding: 4px 0;
}
.ss-ad-footer {
margin-top: 8px;
font-size: 10px;
color: #6b7280;
font-style: italic;
}
/* Pre-send PPI warning (spellcheck-style, appears while typing) */
.ss-presend-warning {
position: fixed;
top: 16px;
right: 16px;
max-width: 420px;
background: #1a1a1a;
color: #e5e7eb;
border: 1px solid #f59e0b;
border-radius: 10px;
padding: 12px 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
z-index: 999999;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.ss-presend-warning.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.ss-ps-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
border-bottom: 1px solid #333;
}
.ss-ps-item:last-of-type { border-bottom: none; }
.ss-ps-type {
font-size: 9px;
font-weight: 600;
color: #f59e0b;
min-width: 65px;
text-transform: uppercase;
}
.ss-ps-value {
font-family: 'SF Mono', Monaco, monospace;
font-size: 11px;
color: #4ade80;
background: #0a0a0a;
padding: 2px 6px;
border-radius: 4px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ss-ps-hint {
font-size: 10px;
color: #9ca3af;
flex: 1;
}
.ss-ps-add {
background: none;
border: 1px solid #4ade80;
border-radius: 4px;
color: #4ade80;
font-size: 14px;
font-weight: bold;
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
}
.ss-ps-add:hover { background: rgba(74, 222, 128, 0.15); }
.ss-ps-add:disabled { border-color: #333; cursor: default; }
/* Floating reveal mode indicator */
.ss-reveal-badge {
position: fixed;
+448 -54
View File
@@ -254,6 +254,7 @@
// ============================================================
// Combined substitution: smart patterns + explicit + secret scan
// + auto-detect warning for unconfigured PPI
// ============================================================
function substituteAll(text) {
const allReplacements = [];
@@ -267,23 +268,164 @@
allReplacements.push(...explicit.replacements);
// 3. Secret scanner (API keys, tokens, SSNs, credit cards, etc.)
let finalText = explicit.text;
if (settings.secretScanning !== false) {
const secrets = scanAndRedactSecrets(explicit.text);
const secrets = scanAndRedactSecrets(finalText);
allReplacements.push(...secrets.redactions);
return {
text: secrets.text,
replacements: allReplacements,
modified: allReplacements.length > 0,
};
finalText = secrets.text;
}
// 4. Auto-detect: scan the FINAL text for unconfigured PPI
// Auto-redact if enabled, otherwise just warn
if (settings.autoDetect !== false) {
const warnings = autoDetectPPI(finalText, identity);
if (warnings.length > 0) {
// Auto-redact detected PPI in the outbound text
if (settings.autoRedactDetected !== false) {
for (let i = warnings.length - 1; i >= 0; i--) {
const w = warnings[i];
const fake = generateFake(w.name, w.value);
const escaped = esc(w.value);
const regex = new RegExp(escaped, 'g');
finalText = finalText.replace(regex, fake);
allReplacements.push({
original: w.value,
replaced: fake,
category: 'auto-detect',
pattern: w.name,
});
}
}
// Still show the warning so user knows what was caught
showAutoDetectWarning(warnings);
}
}
return {
text: explicit.text,
text: finalText,
replacements: allReplacements,
modified: allReplacements.length > 0,
};
}
// ============================================================
// Auto-Detect PPI Scanner (inline for page world)
// ============================================================
const PPI_PATTERNS = [
// Network
{ name: 'Private IP', re: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
hint: 'Private IP address', cat: 'network' },
{ name: 'Public IP', re: /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g,
hint: 'IP address — could identify your network', cat: 'network',
skip: /^(?:127\.0\.0\.1|0\.0\.0\.0|255\.255\.255\.\d+|8\.8\.[84]\.[84]|1\.1\.1\.1)$/ },
{ name: 'MAC Address', re: /\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b/g,
hint: 'MAC address — identifies hardware', cat: 'network' },
// Location
{ name: 'Street Address', re: /\b\d{1,5}\s+(?:[A-Z][a-z]+\s+){1,3}(?:St|Street|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Ln|Lane|Rd|Road|Way|Ct|Court|Pl|Place)\.?\b/gi,
hint: 'Street address', cat: 'address' },
{ name: 'GPS Coordinates', re: /\b-?\d{1,3}\.\d{4,},\s*-?\d{1,3}\.\d{4,}\b/g,
hint: 'GPS coordinates — pinpoints a location', cat: 'address' },
// Personal
{ name: 'Date (possible DOB)', re: /\b(?:(?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01])[-/](?:19|20)\d{2}|(?:19|20)\d{2}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01]))\b/g,
hint: 'Date — could be a birthday', cat: 'personal' },
{ name: 'EIN / Tax ID', re: /\b\d{2}-\d{7}\b/g,
hint: 'Could be a tax ID', cat: 'document' },
// Paths not caught by smart patterns
{ name: 'Home Path', re: /(?:\/home\/|\/Users\/|C:\\Users\\)[a-zA-Z0-9._-]+/g,
hint: 'Home directory — reveals username', cat: 'path' },
// Shell prompts
{ name: 'Shell Prompt', re: /[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+[:\$#%>]\s/g,
hint: 'Shell prompt — reveals user@host', cat: 'prompt' },
// Git remotes
{ name: 'Git Remote', re: /(?:git@|https:\/\/)(?:github|gitlab|bitbucket)\.[a-z]+[:/][^\s]+/gi,
hint: 'Git remote — may reveal username/org', cat: 'url' },
// Env vars
{ name: 'Env Variable', re: /\b(?:HOME|USER|USERNAME|LOGNAME|HOSTNAME|COMPUTERNAME|EMAIL)=[^\s]+/gi,
hint: 'Env variable with personal data', cat: 'env' },
];
function autoDetectPPI(text, ident) {
if (!text || text.length < 5) return [];
// Build skip set from configured values
const configured = new Set();
if (ident) {
const addAll = (arr, key) => (arr || []).forEach(item => {
if (item.real) configured.add(item.real.toLowerCase());
if (item.substitute) configured.add(item.substitute.toLowerCase());
});
addAll(ident.names); addAll(ident.emails);
addAll(ident.usernames); addAll(ident.hostnames); addAll(ident.phones);
}
const findings = [];
for (const pat of PPI_PATTERNS) {
pat.re.lastIndex = 0;
let m;
while ((m = pat.re.exec(text)) !== null) {
const val = m[0];
if (configured.has(val.toLowerCase())) continue;
if (pat.skip && pat.skip.test(val)) continue;
findings.push({ name: pat.name, value: val, hint: pat.hint, category: pat.cat });
}
}
// Deduplicate by value
const seen = new Set();
return findings.filter(f => {
if (seen.has(f.value)) return false;
seen.add(f.value);
return true;
});
}
// ============================================================
// Auto-Detect Warning UI — floating banner
// ============================================================
let warningEl = null;
let warningTimeout = null;
function showAutoDetectWarning(warnings) {
if (!warningEl) {
warningEl = document.createElement('div');
warningEl.className = 'ss-autodetect-warning';
document.body.appendChild(warningEl);
}
const items = warnings.slice(0, 5).map(w =>
`<div class="ss-ad-item">
<span class="ss-ad-type">${w.name}</span>
<code class="ss-ad-value">${w.value.length > 30 ? w.value.slice(0, 27) + '...' : w.value}</code>
<span class="ss-ad-hint">${w.hint}</span>
</div>`
).join('');
const more = warnings.length > 5 ? `<div class="ss-ad-more">+${warnings.length - 5} more</div>` : '';
warningEl.innerHTML = `
<div class="ss-ad-header">
<strong>Silent Send detected potential PPI that may not be substituted:</strong>
<button class="ss-ad-close">&times;</button>
</div>
${items}
${more}
<div class="ss-ad-footer">These were sent as-is. Consider adding them to your identity or mappings.</div>
`;
warningEl.classList.add('visible');
// Close button
warningEl.querySelector('.ss-ad-close').addEventListener('click', () => {
warningEl.classList.remove('visible');
});
// Auto-dismiss after 15 seconds
if (warningTimeout) clearTimeout(warningTimeout);
warningTimeout = setTimeout(() => {
warningEl.classList.remove('visible');
}, 15000);
}
// ============================================================
// Secret Scanner (inline for page world)
// Detects API keys, tokens, passwords, SSNs, credit cards, etc.
@@ -566,28 +708,42 @@
};
// ============================================================
// Response Reveal — swaps fake data back to real in the page
// Highlighting — CSS Custom Highlight API (zero DOM changes)
//
// Two highlight modes:
// ss-substituted (yellow) — fake values the AI received
// ss-revealed (terminal: dark bg, green text) — your real data
//
// Falls back to simple text replacement for reveal if
// CSS.highlights is not supported.
// ============================================================
// Build reverse mapping pairs from identity + explicit mappings
const hasHighlightAPI = typeof CSS !== 'undefined' && CSS.highlights;
// Register highlight groups
let hlSubstituted = null; // yellow — marks fake values in responses
let hlRevealed = null; // terminal — marks revealed real values
if (hasHighlightAPI) {
hlSubstituted = new Highlight();
hlRevealed = new Highlight();
CSS.highlights.set('ss-substituted', hlSubstituted);
CSS.highlights.set('ss-revealed', hlRevealed);
}
// Build pairs: substitute → real
function buildRevealPairs() {
const pairs = [];
// Explicit mappings (substitute → real)
for (const m of mappings) {
if (!m.enabled || !m.substitute || !m.real) continue;
pairs.push({ from: m.substitute, to: m.real, caseSensitive: m.caseSensitive });
}
// Smart identity pairs (substitute → real)
if (identity) {
for (const e of (identity.emails || [])) {
if (e.substitute && e.real) pairs.push({ from: e.substitute, to: e.real });
}
if (identity.catchAllEmail) {
// Can't reverse a catch-all to a specific email, but we mark it
// so the user sees it was a substitution
}
for (const n of (identity.names || [])) {
if (n.substitute && n.real) pairs.push({ from: n.substitute, to: n.real });
}
@@ -602,20 +758,25 @@
}
}
// Sort longer matches first
pairs.sort((a, b) => b.from.length - a.from.length);
return pairs;
}
// Cache reveal pairs — rebuild when config changes
// Cache
let _revealPairsCache = null;
window.addEventListener('message', (event) => {
if (event.data?.type === 'ss:config-updated') _revealPairsCache = null;
});
function revealText(text) {
function getRevealPairs() {
if (!_revealPairsCache) _revealPairsCache = buildRevealPairs();
const pairs = _revealPairsCache;
return _revealPairsCache;
}
// --- Text replacement for reveal (needed regardless of highlight API) ---
function revealText(text) {
const pairs = getRevealPairs();
let result = text;
for (const p of pairs) {
const escaped = esc(p.from);
@@ -625,12 +786,10 @@
return result;
}
// Store originals so we can un-reveal when toggled off
const originalTexts = new WeakMap();
function revealInElement(el) {
if (SKIP_REVEAL_TAGS.has(el.tagName)) return;
// Skip our own badge
if (el.classList?.contains('ss-reveal-badge')) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
@@ -671,64 +830,139 @@
}
}
// --- CSS Highlight API — find and highlight matching text ---
function highlightMatches(root) {
if (!hasHighlightAPI) return;
// Clear previous ranges
hlSubstituted.clear();
hlRevealed.clear();
const pairs = getRevealPairs();
if (pairs.length === 0) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
if (parent?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
let textNode;
while ((textNode = walker.nextNode())) {
const text = textNode.textContent;
if (!text || text.length < MIN_STRING_LENGTH) continue;
for (const p of pairs) {
const escaped = esc(settings.revealMode ? p.to : p.from);
const searchTerm = settings.revealMode ? p.to : p.from;
const regex = new RegExp(escaped, p.caseSensitive ? 'g' : 'gi');
let match;
while ((match = regex.exec(text)) !== null) {
try {
const range = new Range();
range.setStart(textNode, match.index);
range.setEnd(textNode, match.index + match[0].length);
if (settings.revealMode) {
hlRevealed.add(range);
} else {
hlSubstituted.add(range);
}
} catch (e) {
// Range may be invalid if DOM changed
}
}
}
}
}
// Elements to skip when revealing (inputs, scripts, styles, extension UI)
const SKIP_REVEAL_TAGS = new Set([
'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'INPUT', 'TEXTAREA', 'SELECT',
]);
// Reveal ALL text on the page (not just specific selectors)
// Reveal ALL text on the page + apply highlights
function revealAllResponses() {
revealInElement(document.body);
highlightMatches(document.body);
}
// Un-reveal ALL text on the page
// Un-reveal ALL text + clear highlights
function unrevealAllResponses() {
unrevealInElement(document.body);
// In non-reveal mode, highlight the fake values instead
highlightMatches(document.body);
}
// Debounced highlight refresh
let _highlightTimer = null;
function scheduleHighlightRefresh() {
if (!hasHighlightAPI) return;
if (_highlightTimer) clearTimeout(_highlightTimer);
_highlightTimer = setTimeout(() => {
highlightMatches(document.body);
}, 500);
}
// Watch for ANY new content on the page
function observeResponses() {
const observer = new MutationObserver((mutations) => {
if (!settings.revealMode || !hasSubstitutions()) return;
if (!hasSubstitutions()) return;
// Always schedule highlight refresh for new content (yellow markers)
let hasNewContent = false;
for (const mutation of mutations) {
// Handle new nodes — reveal all text in them
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (!SKIP_REVEAL_TAGS.has(node.tagName)) {
revealInElement(node);
}
} else if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent;
if (text && text.length >= MIN_STRING_LENGTH) {
if (!originalTexts.has(node)) {
originalTexts.set(node, text);
hasNewContent = true;
// Only do text replacement in reveal mode
if (settings.revealMode) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (!SKIP_REVEAL_TAGS.has(node.tagName)) {
revealInElement(node);
}
const revealed = revealText(text);
if (revealed !== text) {
node.textContent = revealed;
} else if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent;
if (text && text.length >= MIN_STRING_LENGTH) {
if (!originalTexts.has(node)) {
originalTexts.set(node, text);
}
const revealed = revealText(text);
if (revealed !== text) {
node.textContent = revealed;
}
}
}
}
}
// Handle text changes in existing nodes (streaming responses)
if (mutation.type === 'characterData' && settings.revealMode) {
const text = mutation.target.textContent;
if (text && text.length >= MIN_STRING_LENGTH) {
const parent = mutation.target.parentElement;
if (parent && !SKIP_REVEAL_TAGS.has(parent.tagName)) {
if (!originalTexts.has(mutation.target)) {
originalTexts.set(mutation.target, text);
}
const revealed = revealText(text);
if (revealed !== text) {
mutation.target.textContent = revealed;
// Handle streaming text changes
if (mutation.type === 'characterData') {
hasNewContent = true;
if (settings.revealMode) {
const text = mutation.target.textContent;
if (text && text.length >= MIN_STRING_LENGTH) {
const parent = mutation.target.parentElement;
if (parent && !SKIP_REVEAL_TAGS.has(parent.tagName)) {
if (!originalTexts.has(mutation.target)) {
originalTexts.set(mutation.target, text);
}
const revealed = revealText(text);
if (revealed !== text) {
mutation.target.textContent = revealed;
}
}
}
}
}
}
if (hasNewContent) scheduleHighlightRefresh();
});
observer.observe(document.body, {
@@ -790,15 +1024,175 @@
}
// ============================================================
// Input Highlighting
// Pre-Send PPI Detection — scans as you type/paste (spellcheck style)
// ============================================================
// Generate obviously-fake values using reserved/standard ranges
// These are recognizable as placeholders and guaranteed not to be real
function generateFake(type, value) {
switch (type) {
case 'Private IP':
case 'Public IP':
// RFC 5737 — reserved for documentation, never routed
return '192.0.2.1';
case 'MAC Address':
return '00:00:00:00:00:00';
case 'Street Address':
return '123 Example Street, Anytown, ST 00000';
case 'GPS Coordinates':
return '0.000000,0.000000';
case 'Date (possible DOB)':
return '01/01/1970';
case 'EIN / Tax ID':
return '00-0000000';
case 'Home Path':
if (value.startsWith('C:\\')) return 'C:\\Users\\user';
if (value.startsWith('/Users/')) return '/Users/user';
return '/home/user';
case 'Shell Prompt':
return 'user@host:$ ';
case 'Git Remote':
return value.replace(/[:/][^/\s]+\//, ':/example/');
case 'Env Variable':
return value.split('=')[0] + '=REDACTED';
default:
return '[REDACTED]';
}
}
// Pre-send warning UI
let preSendWarningEl = null;
let preSendTimer = null;
function showPreSendWarning(warnings, inputEl) {
if (!preSendWarningEl) {
preSendWarningEl = document.createElement('div');
preSendWarningEl.className = 'ss-presend-warning';
document.body.appendChild(preSendWarningEl);
}
const items = warnings.slice(0, 8).map((w, i) => {
const fake = generateFake(w.name, w.value);
const displayVal = w.value.length > 25 ? w.value.slice(0, 22) + '...' : w.value;
return `<div class="ss-ps-item">
<span class="ss-ps-type">${w.name}</span>
<code class="ss-ps-value">${displayVal}</code>
<span class="ss-ps-hint">${w.hint}</span>
${settings.autoAddDetected !== false
? `<button class="ss-ps-add" data-real="${encodeURIComponent(w.value)}" data-fake="${encodeURIComponent(fake)}" data-cat="${w.category}" title="Add mapping: ${displayVal}${fake}">+</button>`
: ''}
</div>`;
}).join('');
const more = warnings.length > 8 ? `<div class="ss-ad-more">+${warnings.length - 8} more</div>` : '';
preSendWarningEl.innerHTML = `
<div class="ss-ad-header">
<strong>Potential PPI detected — not yet configured:</strong>
<button class="ss-ad-close">&times;</button>
</div>
${items}
${more}
<div class="ss-ad-footer">
${settings.autoRedactDetected !== false ? 'Auto-redacted with standard placeholders.' : 'These were sent as-is.'}
${settings.autoAddDetected !== false ? ' Click + to add a permanent mapping.' : ''}
</div>
`;
preSendWarningEl.classList.add('visible');
// Close button
preSendWarningEl.querySelector('.ss-ad-close').addEventListener('click', () => {
preSendWarningEl.classList.remove('visible');
});
// Auto-add buttons
preSendWarningEl.querySelectorAll('.ss-ps-add').forEach(btn => {
btn.addEventListener('click', async () => {
const real = decodeURIComponent(btn.dataset.real);
const fake = decodeURIComponent(btn.dataset.fake);
const cat = btn.dataset.cat || 'general';
// Add to mappings via storage
const result = await getStorageData('ss_mappings');
const currentMappings = result || [];
currentMappings.push({
id: crypto.randomUUID(),
real, substitute: fake,
category: cat,
caseSensitive: false,
enabled: true,
createdAt: Date.now(),
});
await setStorageData('ss_mappings', currentMappings);
// Update local mappings
mappings = currentMappings;
// Visual feedback
btn.textContent = '\u2714';
btn.style.color = '#4ade80';
btn.disabled = true;
});
});
}
// Storage helpers for page world (uses postMessage to injector)
function getStorageData(key) {
return new Promise(resolve => {
const id = 'ss-get-' + Math.random();
const handler = (event) => {
if (event.data?.type === 'ss:storage-result' && event.data.id === id) {
window.removeEventListener('message', handler);
resolve(event.data.value);
}
};
window.addEventListener('message', handler);
window.postMessage({ type: 'ss:storage-get', key, id }, '*');
// Timeout fallback
setTimeout(() => { window.removeEventListener('message', handler); resolve(null); }, 2000);
});
}
function setStorageData(key, value) {
window.postMessage({ type: 'ss:storage-set', key, value }, '*');
}
// Scan input on type and paste
let inputScanTimer = null;
function scanInputForPPI(target) {
const text = target.textContent || target.value || '';
if (!text || text.length < 5) {
if (preSendWarningEl) preSendWarningEl.classList.remove('visible');
return;
}
const warnings = autoDetectPPI(text, identity);
if (warnings.length > 0) {
showPreSendWarning(warnings, target);
} else if (preSendWarningEl) {
preSendWarningEl.classList.remove('visible');
}
}
document.addEventListener('input', (e) => {
if (!settings.showHighlights || !hasSubstitutions()) return;
if (settings.autoDetect === false) return;
const target = e.target;
if (target.matches?.('[contenteditable], textarea, input[type="text"]')) {
const text = target.textContent || target.value || '';
const r = substituteAll(text);
target.classList.toggle('ss-has-sensitive', r.modified);
// Debounce — don't scan on every keystroke
if (inputScanTimer) clearTimeout(inputScanTimer);
inputScanTimer = setTimeout(() => scanInputForPPI(target), 800);
}
}, true);
document.addEventListener('paste', (e) => {
if (settings.autoDetect === false) return;
const target = e.target;
if (target.matches?.('[contenteditable], textarea, input[type="text"]') ||
target.closest?.('[contenteditable]')) {
// Scan shortly after paste completes
setTimeout(() => scanInputForPPI(target.closest?.('[contenteditable]') || target), 200);
}
}, true);
+60 -8
View File
@@ -15,6 +15,38 @@
if (window.__silentSendInjected) return;
window.__silentSendInjected = true;
// Merge active profiles into flat identity object
function mergeProfiles(data) {
const profiles = data?.profiles || [];
const active = profiles.filter(p => p.active);
if (active.length === 0) {
// Legacy format: data IS the flat identity (pre-profile migration)
if (data && (data.names || data.emails || data.usernames)) return data;
return { emails: [], names: [], usernames: [], hostnames: [], phones: [],
catchAllEmail: '', emailDomains: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true } };
}
const merged = {
emails: [], names: [], usernames: [], hostnames: [], phones: [],
catchAllEmail: '', emailDomains: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
};
for (const p of active) {
merged.emails.push(...(p.emails || []));
merged.names.push(...(p.names || []));
merged.usernames.push(...(p.usernames || []));
merged.hostnames.push(...(p.hostnames || []));
merged.phones.push(...(p.phones || []));
if (p.catchAllEmail && !merged.catchAllEmail) merged.catchAllEmail = p.catchAllEmail;
merged.emailDomains.push(...(p.emailDomains || []));
}
return merged;
}
// Cross-browser API
const api =
typeof browser !== 'undefined' && browser.runtime
@@ -27,9 +59,12 @@
async function init() {
const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']);
const mappings = result.ss_mappings || [];
const identity = result.ss_identity || {};
const settings = result.ss_settings || { enabled: true };
// Merge active profiles into a flat identity object for the content script
const identityData = result.ss_identity || {};
const identity = mergeProfiles(identityData);
// Inject the main interception script into the page's world
const script = document.createElement('script');
script.setAttribute('data-ss-config', JSON.stringify({ mappings, identity, settings }));
@@ -71,15 +106,14 @@
}
});
// Forward storage changes to the page script
// Forward storage changes to the page script (merge profiles before sending)
api.storage.onChanged.addListener((changes) => {
if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) {
window.postMessage({
type: 'ss:config-updated',
mappings: changes.ss_mappings?.newValue,
identity: changes.ss_identity?.newValue,
settings: changes.ss_settings?.newValue,
}, '*');
const msg = { type: 'ss:config-updated' };
if (changes.ss_mappings) msg.mappings = changes.ss_mappings.newValue;
if (changes.ss_identity) msg.identity = mergeProfiles(changes.ss_identity.newValue);
if (changes.ss_settings) msg.settings = changes.ss_settings.newValue;
window.postMessage(msg, '*');
}
});
@@ -92,6 +126,24 @@
}, '*');
}
});
// Storage bridge — lets page world script read/write storage
window.addEventListener('message', async (event) => {
if (event.source !== window) return;
if (event.data?.type === 'ss:storage-get') {
const result = await api.storage.local.get(event.data.key);
window.postMessage({
type: 'ss:storage-result',
id: event.data.id,
value: result[event.data.key] || null,
}, '*');
}
if (event.data?.type === 'ss:storage-set') {
await api.storage.local.set({ [event.data.key]: event.data.value });
}
});
}
init();
+215
View File
@@ -0,0 +1,215 @@
/**
* Silent Send - Auto-Detect
*
* Scans text for potential PPI that the user hasn't configured.
* This catches things the identity and secret scanner can't —
* because the user forgot or didn't know to configure them.
*
* Returns warnings (not auto-redactions) so the user can decide.
*/
const PPI_PATTERNS = [
// --- Network ---
{
name: 'Private IP Address',
regex: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
category: 'network',
hint: 'Private/local IP address',
},
{
name: 'Public IP Address',
regex: /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g,
category: 'network',
hint: 'IP address — could identify your network',
// Exclude common non-PPI IPs
exclude: /^(?:127\.0\.0\.1|0\.0\.0\.0|255\.255\.255\.\d+|8\.8\.[84]\.[84]|1\.1\.1\.1|1\.0\.0\.1)$/,
},
{
name: 'IPv6 Address',
regex: /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b/g,
category: 'network',
hint: 'IPv6 address',
},
{
name: 'MAC Address',
regex: /\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b/g,
category: 'network',
hint: 'MAC address — identifies your hardware',
},
// --- Location / Address ---
{
name: 'US Street Address',
regex: /\b\d{1,5}\s+(?:[A-Z][a-z]+\s+){1,3}(?:St|Street|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Ln|Lane|Rd|Road|Way|Ct|Court|Pl|Place|Cir|Circle)\.?\b/gi,
category: 'address',
hint: 'Looks like a street address',
},
{
name: 'US Zip Code',
regex: /\b\d{5}(?:-\d{4})?\b/g,
category: 'address',
hint: 'Could be a zip code',
// Only flag if near address-like context
contextRequired: true,
},
{
name: 'GPS Coordinates',
regex: /\b-?\d{1,3}\.\d{4,},\s*-?\d{1,3}\.\d{4,}\b/g,
category: 'address',
hint: 'GPS coordinates — pinpoints a location',
},
// --- Identity Documents ---
{
name: 'US Passport Number',
regex: /\b[A-Z]\d{8}\b/g,
category: 'document',
hint: 'Could be a passport number',
contextRequired: true,
},
{
name: 'US Driver License',
regex: /\b[A-Z]\d{7,14}\b/g,
category: 'document',
hint: 'Could be a driver license number',
contextRequired: true,
},
{
name: 'Date of Birth Pattern',
regex: /\b(?:(?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01])[-/](?:19|20)\d{2}|(?:19|20)\d{2}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01]))\b/g,
category: 'personal',
hint: 'Date — could be a birthday or other personal date',
},
{
name: 'EIN / Tax ID',
regex: /\b\d{2}-\d{7}\b/g,
category: 'document',
hint: 'Could be an EIN or tax ID number',
},
// --- URLs with usernames ---
{
name: 'URL with Username',
regex: /https?:\/\/[^\s]*(?:user|profile|account|member)[^\s]*/gi,
category: 'url',
hint: 'URL that may contain your identity',
},
{
name: 'Git Remote with Username',
regex: /(?:git@|https:\/\/)(?:github|gitlab|bitbucket)\.[a-z]+[:/][^\s]+/gi,
category: 'url',
hint: 'Git remote — may reveal your username/org',
},
// --- File paths with home dirs (if not already caught by smart patterns) ---
{
name: 'Home Directory Path',
regex: /(?:\/home\/|\/Users\/|C:\\Users\\)[a-zA-Z0-9._-]+/g,
category: 'path',
hint: 'Home directory path — reveals your username',
},
// --- Environment Variables with Sensitive Values ---
{
name: 'Env Variable Assignment',
regex: /\b(?:HOME|USER|USERNAME|LOGNAME|HOSTNAME|COMPUTERNAME|EMAIL)=\S+/gi,
category: 'env',
hint: 'Environment variable with personal data',
},
// --- Shell Prompts ---
{
name: 'Shell Prompt',
regex: /[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+[:\$#%>]\s/g,
category: 'prompt',
hint: 'Shell prompt — reveals username and hostname',
},
];
// Context words that make ambiguous patterns more likely to be PPI
const CONTEXT_WORDS = /\b(?:born|birthday|dob|birth|passport|license|driver|ssn|social\s*security|address|home|live|lives|reside|zip|postal)\b/i;
const AutoDetect = {
/**
* Scan text for potential unconfigured PPI.
* Pass in identity so we can skip values the user already configured.
*
* Returns array of { name, value, hint, category, index }
*/
scan(text, identity) {
if (!text || text.length < 5) return [];
const hasContext = CONTEXT_WORDS.test(text);
const findings = [];
// Build a set of already-configured values to skip
const configured = new Set();
if (identity) {
for (const n of (identity.names || [])) {
if (n.real) configured.add(n.real.toLowerCase());
if (n.substitute) configured.add(n.substitute.toLowerCase());
}
for (const e of (identity.emails || [])) {
if (e.real) configured.add(e.real.toLowerCase());
if (e.substitute) configured.add(e.substitute.toLowerCase());
}
for (const u of (identity.usernames || [])) {
if (u.real) configured.add(u.real.toLowerCase());
if (u.substitute) configured.add(u.substitute.toLowerCase());
}
for (const h of (identity.hostnames || [])) {
if (h.real) configured.add(h.real.toLowerCase());
if (h.substitute) configured.add(h.substitute.toLowerCase());
}
for (const p of (identity.phones || [])) {
if (p.real) configured.add(p.real.toLowerCase());
if (p.substitute) configured.add(p.substitute.toLowerCase());
}
}
for (const pattern of PPI_PATTERNS) {
// Skip context-dependent patterns if no context words present
if (pattern.contextRequired && !hasContext) continue;
pattern.regex.lastIndex = 0;
let match;
while ((match = pattern.regex.exec(text)) !== null) {
const value = match[0];
// Skip if already configured
if (configured.has(value.toLowerCase())) continue;
// Skip excluded values (like 127.0.0.1)
if (pattern.exclude && pattern.exclude.test(value)) continue;
findings.push({
name: pattern.name,
value,
hint: pattern.hint,
category: pattern.category,
index: match.index,
});
}
}
// Deduplicate overlapping matches
findings.sort((a, b) => a.index - b.index);
const deduped = [];
let lastEnd = -1;
for (const f of findings) {
if (f.index >= lastEnd) {
deduped.push(f);
lastEnd = f.index + f.value.length;
}
}
return deduped;
},
};
if (typeof globalThis !== 'undefined') {
globalThis.AutoDetect = AutoDetect;
}
export default AutoDetect;
+92
View File
@@ -0,0 +1,92 @@
/**
* Silent Send - Crypto Module
*
* AES-256-GCM encryption with PBKDF2 key derivation.
* Used for encrypted export/import of user data.
*/
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const ITERATIONS = 100000;
async function deriveKey(password, salt) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: ITERATIONS,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
const SilentSendCrypto = {
/**
* Encrypt data with a password.
* Returns a base64 string containing salt + iv + ciphertext.
*/
async encrypt(data, password) {
const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const key = await deriveKey(password, salt);
const plaintext = encoder.encode(JSON.stringify(data));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintext
);
// Combine: salt (16) + iv (12) + ciphertext
const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(ciphertext), salt.length + iv.length);
// Base64 encode
return btoa(String.fromCharCode(...combined));
},
/**
* Decrypt data with a password.
* Takes the base64 string from encrypt().
*/
async decrypt(encryptedBase64, password) {
const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0));
const salt = combined.slice(0, SALT_LENGTH);
const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const ciphertext = combined.slice(SALT_LENGTH + IV_LENGTH);
const key = await deriveKey(password, salt);
try {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
const decoder = new TextDecoder();
return JSON.parse(decoder.decode(plaintext));
} catch (e) {
throw new Error('Wrong password or corrupted data');
}
},
};
export default SilentSendCrypto;
+3
View File
@@ -19,6 +19,9 @@ const DEFAULT_SETTINGS = {
showHighlights: false,
revealMode: false,
secretScanning: true,
autoDetect: true,
autoRedactDetected: true,
autoAddDetected: true,
maxLogEntries: 200,
customDomains: [],
categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'general'],
+43 -2
View File
@@ -57,6 +57,36 @@
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<div>
<label>Auto-detect unconfigured PPI</label>
<p class="setting-desc">Warn when potential personal data (IPs, addresses, paths) is detected that you haven't configured</p>
</div>
<label class="toggle">
<input type="checkbox" id="autoDetect" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<div>
<label>Auto-redact detected PPI on send</label>
<p class="setting-desc">Automatically replace detected PPI with generic placeholders (192.0.2.1, 123 Example Street, etc.) when sending</p>
</div>
<label class="toggle">
<input type="checkbox" id="autoRedactDetected" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<div>
<label>Offer to auto-add detected PPI</label>
<p class="setting-desc">Show a + button on detected PPI to instantly create a mapping with a suggested fake value</p>
</div>
<label class="toggle">
<input type="checkbox" id="autoAddDetected" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<div>
<label>Max log entries</label>
@@ -66,13 +96,24 @@
</div>
</section>
<section class="section">
<h2>Transfer Data</h2>
<p class="section-desc">Export all your identities, mappings, and settings to move between browsers. Encrypted exports require a password to decrypt.</p>
<div class="bulk-actions">
<button class="btn" id="btnExportAll">Export All (plain)</button>
<button class="btn" id="btnExportEncrypted">Export Encrypted</button>
<button class="btn" id="btnImportAll">Import</button>
<input type="file" id="fileImportAll" accept=".json,.ssbackup" hidden>
</div>
</section>
<section class="section">
<h2>Mappings</h2>
<p class="section-desc">Manage all your substitution rules. Longer matches take priority.</p>
<div class="bulk-actions">
<button class="btn" id="btnExport">Export JSON</button>
<button class="btn" id="btnImport">Import JSON</button>
<button class="btn" id="btnExport">Export Mappings</button>
<button class="btn" id="btnImport">Import Mappings</button>
<input type="file" id="fileImport" accept=".json" hidden>
<button class="btn btn-danger" id="btnClearAll">Clear All</button>
</div>
+122
View File
@@ -1,4 +1,5 @@
import Storage from '../lib/storage.js';
import SilentSendCrypto from '../lib/crypto.js';
import api from '../lib/browser-polyfill.js';
let mappings = [];
@@ -13,12 +14,21 @@ document.addEventListener('DOMContentLoaded', async () => {
// Apply settings to UI
$('#showHighlights').checked = settings.showHighlights || false;
$('#secretScanning').checked = settings.secretScanning !== false;
$('#autoDetect').checked = settings.autoDetect !== false;
$('#autoRedactDetected').checked = settings.autoRedactDetected !== false;
$('#autoAddDetected').checked = settings.autoAddDetected !== false;
$('#maxLogEntries').value = settings.maxLogEntries || 200;
renderMappings();
renderDomains();
renderLog();
// Transfer data
$('#btnExportAll').addEventListener('click', exportAllPlain);
$('#btnExportEncrypted').addEventListener('click', exportAllEncrypted);
$('#btnImportAll').addEventListener('click', () => $('#fileImportAll').click());
$('#fileImportAll').addEventListener('change', importAll);
// Custom domains
$('#btnAddDomain').addEventListener('click', addDomain);
$('#newDomain').addEventListener('keydown', (e) => {
@@ -34,6 +44,18 @@ document.addEventListener('DOMContentLoaded', async () => {
await Storage.saveSettings({ secretScanning: e.target.checked });
});
$('#autoDetect').addEventListener('change', async (e) => {
await Storage.saveSettings({ autoDetect: e.target.checked });
});
$('#autoRedactDetected').addEventListener('change', async (e) => {
await Storage.saveSettings({ autoRedactDetected: e.target.checked });
});
$('#autoAddDetected').addEventListener('change', async (e) => {
await Storage.saveSettings({ autoAddDetected: e.target.checked });
});
$('#maxLogEntries').addEventListener('change', async (e) => {
await Storage.saveSettings({ maxLogEntries: parseInt(e.target.value, 10) || 200 });
});
@@ -271,6 +293,106 @@ function renderDomains() {
});
}
// --- Transfer Data (Export/Import All) ---
async function getAllData() {
const result = await api.storage.local.get(null); // get everything
return {
version: '1',
exportedAt: new Date().toISOString(),
identity: result.ss_identity || {},
mappings: result.ss_mappings || [],
settings: result.ss_settings || {},
};
}
function downloadFile(content, filename) {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function exportAllPlain() {
const data = await getAllData();
downloadFile(JSON.stringify(data, null, 2), 'silent-send-backup.json');
}
async function exportAllEncrypted() {
const password = prompt('Set a password for this backup:');
if (!password) return;
const confirm = prompt('Confirm password:');
if (password !== confirm) {
alert('Passwords do not match.');
return;
}
const data = await getAllData();
try {
const encrypted = await SilentSendCrypto.encrypt(data, password);
const wrapper = JSON.stringify({ encrypted: true, data: encrypted });
downloadFile(wrapper, 'silent-send-backup.ssbackup');
alert('Encrypted backup saved. You will need the password to import it.');
} catch (e) {
alert('Encryption failed: ' + e.message);
}
}
async function importAll(e) {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
let data;
if (parsed.encrypted) {
// Encrypted backup
const password = prompt('Enter the password for this backup:');
if (!password) return;
try {
data = await SilentSendCrypto.decrypt(parsed.data, password);
} catch (err) {
alert('Wrong password or corrupted file.');
return;
}
} else {
// Plain backup
data = parsed;
}
if (!data.version) {
alert('Not a valid Silent Send backup file.');
return;
}
if (!confirm('This will replace all your current data. Continue?')) return;
// Restore
if (data.identity) await api.storage.local.set({ ss_identity: data.identity });
if (data.mappings) await api.storage.local.set({ ss_mappings: data.mappings });
if (data.settings) await api.storage.local.set({ ss_settings: data.settings });
// Refresh UI
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
renderDomains();
renderLog();
alert('Import complete. Reload the extension for changes to take effect.');
} catch (err) {
alert('Failed to import: ' + err.message);
}
// Reset file input
e.target.value = '';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
+14
View File
@@ -1,6 +1,7 @@
import SubstitutionEngine from '../lib/substitution-engine.js';
import SmartPatterns from '../lib/smart-patterns.js';
import SecretScanner from '../lib/secret-scanner.js';
import AutoDetect from '../lib/auto-detect.js';
import Storage from '../lib/storage.js';
import api from '../lib/browser-polyfill.js';
@@ -607,7 +608,20 @@ function renderTestDiff() {
if (explicitCount > 0) parts.push(`${explicitCount} explicit`);
if (secretCount > 0) parts.push(`${secretCount} secrets redacted`);
if (warnCount > 0) parts.push(`${warnCount} warnings`);
// Auto-detect unconfigured PPI in the final text
const ppiWarnings = AutoDetect.scan(finalText, identity);
if (ppiWarnings.length > 0) parts.push(`${ppiWarnings.length} PPI detected`);
stats.textContent = `${allReplacements.length} substitution${allReplacements.length !== 1 ? 's' : ''} (${parts.join(', ')})`;
// Show PPI warnings below stats
if (ppiWarnings.length > 0) {
stats.innerHTML += `<div style="margin-top:6px;padding:6px 8px;background:#fef3c7;border-radius:4px;color:#92400e;font-size:11px">
<strong>Unconfigured PPI detected:</strong>
${ppiWarnings.map(w => `<div style="margin-top:3px"><code style="background:#fff;padding:1px 4px;border-radius:2px;color:#b45309">${escapeHtml(w.value)}</code> — ${w.hint}</div>`).join('')}
</div>`;
}
}
// --- Reveal Diff (fake → real) ---