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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">×</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">×</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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user