915 lines
34 KiB
Svelte
915 lines
34 KiB
Svelte
<script lang="ts">
|
|
import Navbar from '$lib/components/Navbar.svelte';
|
|
import Footer from '$lib/components/Footer.svelte';
|
|
|
|
// API Base - uses env var for local dev, defaults to cockpit for production
|
|
const API_BASE = (import.meta.env.VITE_API_URL || 'https://cockpit.valuecurve.co') + '/api/v1';
|
|
|
|
// ============================================
|
|
// TRUE CLIENT-SIDE PII DETECTION (Browser-Only)
|
|
// ============================================
|
|
// These regex patterns are ported from backend - text never leaves browser
|
|
// Base confidence values aligned with backend after context adjustment
|
|
// Backend adjusts: +0.10 per context keyword, -0.30 for test data, range 0.3-0.99
|
|
const PII_PATTERNS: Record<string, { pattern: RegExp; description: string; category: string; confidence: number }> = {
|
|
EMAIL: {
|
|
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi,
|
|
description: "Email addresses",
|
|
category: "pii",
|
|
confidence: 0.75 // Backend base ~0.65-0.85 after context
|
|
},
|
|
EMAIL_OBFUSCATED: {
|
|
pattern: /[A-Za-z0-9](?:[-\s]*[A-Za-z0-9]){2,}\s*(?:\[at\]|\(at\))\s*[A-Za-z0-9](?:[-\s]*[A-Za-z0-9]){2,}\s*(?:\[dot\]|\(dot\)|\s+dot\s+)\s*[A-Za-z]{2,}/gi,
|
|
description: "Obfuscated email addresses",
|
|
category: "pii",
|
|
confidence: 0.85
|
|
},
|
|
PHONE_US: {
|
|
pattern: /\b(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g,
|
|
description: "US Phone numbers",
|
|
category: "pii",
|
|
confidence: 0.65 // Backend returns ~0.65 for phones
|
|
},
|
|
PHONE_INTL: {
|
|
pattern: /\+(?:49|44|33|39|34|31|32|43|41|48|351|353|358|47|46|45|420|36|40|359|385|386|421|370|371|372|352|356|357|30|55|52|54|56|57|51|81|82|86|91|61|64|65|852)\s?[0-9]{1,4}[\s-]?[0-9]{3,4}[\s-]?[0-9]{3,6}\b/g,
|
|
description: "International Phone numbers",
|
|
category: "pii",
|
|
confidence: 0.65
|
|
},
|
|
SSN: {
|
|
pattern: /\b\d{3}[-.\s_]\d{2}[-.\s_]\d{4}\b/g,
|
|
description: "Social Security Numbers",
|
|
category: "pii",
|
|
confidence: 0.75 // Backend returns ~0.75 for SSN
|
|
},
|
|
CREDIT_CARD: {
|
|
pattern: /\b(?:4[0-9]{3}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}|5[1-5][0-9]{2}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}|3[47][0-9]{2}[-\s]?[0-9]{6}[-\s]?[0-9]{5}|6(?:011|5[0-9]{2})[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4})\b/g,
|
|
description: "Credit card numbers",
|
|
category: "financial",
|
|
confidence: 0.99 // Backend boosts valid Luhn to 0.99
|
|
},
|
|
IP_ADDRESS: {
|
|
pattern: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g,
|
|
description: "IP addresses",
|
|
category: "pii",
|
|
confidence: 0.65 // Backend returns ~0.65 for IPs
|
|
},
|
|
DATE_OF_BIRTH: {
|
|
pattern: /\b(?:(?:0?[1-9]|1[0-2])[/-](?:0?[1-9]|[12][0-9]|3[01])[/-](?:19|20)\d{2}|(?:19|20)\d{2}[-/](?:0?[1-9]|1[0-2])[-/](?:0?[1-9]|[12][0-9]|3[01]))\b/g,
|
|
description: "Dates",
|
|
category: "pii",
|
|
confidence: 0.65
|
|
},
|
|
US_ADDRESS: {
|
|
pattern: /\b\d{1,5}\s+(?:[A-Za-z]+\s+){1,4}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Court|Ct|Way|Place|Pl)\.?/gi,
|
|
description: "US addresses",
|
|
category: "pii",
|
|
confidence: 0.75
|
|
},
|
|
IBAN: {
|
|
pattern: /\b(?:DE|GB|FR|ES|IT|NL|BE|AT|CH|PL|PT|IE|FI|NO|SE|DK)\d{2}[\s]?[A-Z0-9]{4}[\s]?[A-Z0-9]{4}[\s]?[A-Z0-9]{4}[\s]?[A-Z0-9]{0,18}\b/g,
|
|
description: "IBAN",
|
|
category: "financial",
|
|
confidence: 0.90
|
|
},
|
|
INDIA_AADHAAR: {
|
|
pattern: /\b[2-9]\d{3}[\s-]?\d{4}[\s-]?\d{4}\b/g,
|
|
description: "India Aadhaar numbers",
|
|
category: "pii",
|
|
confidence: 0.75
|
|
},
|
|
AWS_ACCESS_KEY: {
|
|
pattern: /\b(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}\b/g,
|
|
description: "AWS Access Key IDs",
|
|
category: "secret",
|
|
confidence: 0.99
|
|
},
|
|
GITHUB_TOKEN: {
|
|
pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g,
|
|
description: "GitHub Tokens",
|
|
category: "secret",
|
|
confidence: 0.99
|
|
},
|
|
SLACK_TOKEN: {
|
|
pattern: /\bxox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*\b/g,
|
|
description: "Slack Tokens",
|
|
category: "secret",
|
|
confidence: 0.99
|
|
},
|
|
STRIPE_KEY: {
|
|
pattern: /\b(?:sk|pk)_(?:test|live)_[A-Za-z0-9]{8,}\b/g,
|
|
description: "Stripe API Keys",
|
|
category: "secret",
|
|
confidence: 0.99
|
|
},
|
|
GOOGLE_API_KEY: {
|
|
pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g,
|
|
description: "Google API Keys",
|
|
category: "secret",
|
|
confidence: 0.99
|
|
},
|
|
JWT_TOKEN: {
|
|
pattern: /\beyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*\b/g,
|
|
description: "JWT Tokens",
|
|
category: "secret",
|
|
confidence: 0.90
|
|
},
|
|
PRIVATE_KEY: {
|
|
pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g,
|
|
description: "Private key headers",
|
|
category: "secret",
|
|
confidence: 0.99
|
|
},
|
|
PASSWORD_IN_URL: {
|
|
pattern: /(?:password|passwd|pwd|pass)["']?\s*(?:[:=])\s*["']?([^\s"'&,]{6,})["']?/gi,
|
|
description: "Passwords in plaintext",
|
|
category: "secret",
|
|
confidence: 0.85
|
|
}
|
|
};
|
|
|
|
interface DetectedEntity {
|
|
type: string;
|
|
start: number;
|
|
end: number;
|
|
value: string;
|
|
masked_value: string;
|
|
confidence: number;
|
|
length: number;
|
|
}
|
|
|
|
function detectPIIClientSide(text: string, options: {
|
|
detectEmails: boolean;
|
|
detectPhones: boolean;
|
|
detectSSN: boolean;
|
|
detectCreditCards: boolean;
|
|
detectIPAddresses: boolean;
|
|
detectDates: boolean;
|
|
detectAddresses: boolean;
|
|
detectIBAN: boolean;
|
|
detectSecrets: boolean;
|
|
}): DetectedEntity[] {
|
|
const entities: DetectedEntity[] = [];
|
|
|
|
// Map options to pattern types
|
|
const enabledPatterns: string[] = [];
|
|
if (options.detectEmails) enabledPatterns.push('EMAIL', 'EMAIL_OBFUSCATED');
|
|
if (options.detectPhones) enabledPatterns.push('PHONE_US', 'PHONE_INTL');
|
|
if (options.detectSSN) enabledPatterns.push('SSN', 'INDIA_AADHAAR');
|
|
if (options.detectCreditCards) enabledPatterns.push('CREDIT_CARD');
|
|
if (options.detectIPAddresses) enabledPatterns.push('IP_ADDRESS');
|
|
if (options.detectDates) enabledPatterns.push('DATE_OF_BIRTH');
|
|
if (options.detectAddresses) enabledPatterns.push('US_ADDRESS');
|
|
if (options.detectIBAN) enabledPatterns.push('IBAN');
|
|
if (options.detectSecrets) {
|
|
enabledPatterns.push('AWS_ACCESS_KEY', 'GITHUB_TOKEN', 'SLACK_TOKEN',
|
|
'STRIPE_KEY', 'GOOGLE_API_KEY', 'JWT_TOKEN', 'PRIVATE_KEY', 'PASSWORD_IN_URL');
|
|
}
|
|
|
|
for (const patternName of enabledPatterns) {
|
|
const patternDef = PII_PATTERNS[patternName];
|
|
if (!patternDef) continue;
|
|
|
|
// Create new regex to reset lastIndex
|
|
const regex = new RegExp(patternDef.pattern.source, patternDef.pattern.flags);
|
|
let match;
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
const value = match[0];
|
|
const maxLen = 35;
|
|
entities.push({
|
|
type: patternName,
|
|
start: match.index,
|
|
end: match.index + value.length,
|
|
value: value.length > maxLen ? value.slice(0, maxLen) + '...' : value,
|
|
masked_value: ('*'.repeat(value.length)).slice(0, maxLen) + (value.length > maxLen ? '...' : ''),
|
|
confidence: patternDef.confidence,
|
|
length: value.length
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by confidence (highest first), then by length (longest first) for overlap resolution
|
|
entities.sort((a, b) => {
|
|
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
|
|
return b.length - a.length;
|
|
});
|
|
|
|
// Remove overlaps - keep highest confidence/longest matches
|
|
const filtered: DetectedEntity[] = [];
|
|
for (const entity of entities) {
|
|
const overlaps = filtered.some(e =>
|
|
(entity.start >= e.start && entity.start < e.end) ||
|
|
(entity.end > e.start && entity.end <= e.end) ||
|
|
(entity.start <= e.start && entity.end >= e.end)
|
|
);
|
|
if (!overlaps) {
|
|
filtered.push(entity);
|
|
}
|
|
}
|
|
|
|
// Re-sort by position for display
|
|
filtered.sort((a, b) => a.start - b.start);
|
|
|
|
return filtered;
|
|
}
|
|
// ============================================
|
|
|
|
let inputMode: 'text' | 'file' = $state('text');
|
|
let textInput = $state('');
|
|
let file: File | null = $state(null);
|
|
let fileName = $state('');
|
|
let loading = $state(false);
|
|
let error = $state('');
|
|
let result: any = $state(null);
|
|
let activeTab: 'overview' | 'entities' | 'redacted' = $state('overview');
|
|
|
|
// Detection options
|
|
let detectEmails = $state(true);
|
|
let detectPhones = $state(true);
|
|
let detectSSN = $state(true);
|
|
let detectCreditCards = $state(true);
|
|
let detectIPAddresses = $state(true);
|
|
let detectDates = $state(true);
|
|
let detectAddresses = $state(true);
|
|
let detectIBAN = $state(true);
|
|
let detectSecrets = $state(true);
|
|
|
|
// Security option: Client-side redaction mode
|
|
let coordinatesOnly = $state(false);
|
|
|
|
function clientSideRedact(text: string, entities: Array<{start: number, end: number, type: string, length: number}>): string {
|
|
if (!entities || entities.length === 0) return text;
|
|
const sorted = [...entities].sort((a, b) => b.start - a.start);
|
|
let resultText = text;
|
|
for (const entity of sorted) {
|
|
const masked = '*'.repeat(entity.length);
|
|
resultText = resultText.slice(0, entity.start) + masked + resultText.slice(entity.end);
|
|
}
|
|
return resultText;
|
|
}
|
|
|
|
function handleFileSelect(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
if (input.files && input.files[0]) {
|
|
file = input.files[0];
|
|
fileName = file.name;
|
|
result = null;
|
|
error = '';
|
|
}
|
|
}
|
|
|
|
function handleDrop(event: DragEvent) {
|
|
event.preventDefault();
|
|
if (event.dataTransfer?.files && event.dataTransfer.files[0]) {
|
|
file = event.dataTransfer.files[0];
|
|
fileName = file.name;
|
|
result = null;
|
|
error = '';
|
|
}
|
|
}
|
|
|
|
function handleDragOver(event: DragEvent) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
async function scanForPII() {
|
|
if (inputMode === 'text' && !textInput.trim()) {
|
|
error = 'Please enter some text to scan';
|
|
return;
|
|
}
|
|
if (inputMode === 'file' && !file) {
|
|
error = 'Please select a file first';
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
error = '';
|
|
result = null;
|
|
|
|
try {
|
|
// ============================================
|
|
// TRUE CLIENT-SIDE MODE: No network request!
|
|
// ============================================
|
|
if (coordinatesOnly && inputMode === 'text') {
|
|
console.log('%c[Privacy Scanner] Browser-Only Mode: All detection running locally. ZERO network requests.', 'color: #22c55e; font-weight: bold;');
|
|
const normalizedText = textInput.replace(/\r\n/g, '\n');
|
|
|
|
const entities = detectPIIClientSide(normalizedText, {
|
|
detectEmails, detectPhones, detectSSN, detectCreditCards,
|
|
detectIPAddresses, detectDates, detectAddresses, detectIBAN, detectSecrets
|
|
});
|
|
|
|
// Build result object matching backend format
|
|
const entitiesByType: Record<string, number> = {};
|
|
for (const e of entities) {
|
|
entitiesByType[e.type] = (entitiesByType[e.type] || 0) + 1;
|
|
}
|
|
|
|
// Calculate risk score (matches backend formula)
|
|
const riskWeights: Record<string, number> = {
|
|
SSN: 100, CREDIT_CARD: 95, PRIVATE_KEY: 100, INDIA_AADHAAR: 90,
|
|
AWS_ACCESS_KEY: 100, GITHUB_TOKEN: 95,
|
|
STRIPE_KEY: 95, PASSWORD_IN_URL: 90, JWT_TOKEN: 85,
|
|
IBAN: 85, EMAIL: 40, EMAIL_OBFUSCATED: 40, PHONE_US: 35, PHONE_INTL: 35,
|
|
IP_ADDRESS: 30, DATE_OF_BIRTH: 50, US_ADDRESS: 55,
|
|
SLACK_TOKEN: 90, GOOGLE_API_KEY: 85
|
|
};
|
|
|
|
let totalScore = 0;
|
|
for (const e of entities) {
|
|
const weight = riskWeights[e.type] || 25;
|
|
totalScore += weight * e.confidence;
|
|
}
|
|
// Backend formula: (average score) + (entity count * 5)
|
|
const riskScore = Math.min(100, Math.floor(totalScore / Math.max(1, entities.length) + entities.length * 5));
|
|
|
|
// Backend thresholds: 70=CRITICAL, 50=HIGH, 30=MEDIUM
|
|
const riskLevel = riskScore >= 70 ? 'CRITICAL'
|
|
: riskScore >= 50 ? 'HIGH'
|
|
: riskScore >= 30 ? 'MEDIUM'
|
|
: 'LOW';
|
|
|
|
result = {
|
|
total_entities: entities.length,
|
|
entities_by_type: entitiesByType,
|
|
entities: entities,
|
|
risk_score: riskScore,
|
|
risk_level: riskLevel,
|
|
redacted_preview: clientSideRedact(normalizedText, entities),
|
|
coordinates_only: true,
|
|
client_side_only: true // Flag to indicate true client-side
|
|
};
|
|
} else {
|
|
// ============================================
|
|
// BACKEND MODE: Send to server
|
|
// ============================================
|
|
let response;
|
|
|
|
if (inputMode === 'text') {
|
|
const normalizedText = textInput.replace(/\r\n/g, '\n');
|
|
|
|
const formData = new FormData();
|
|
formData.append('text', normalizedText);
|
|
formData.append('detect_emails', String(detectEmails));
|
|
formData.append('detect_phones', String(detectPhones));
|
|
formData.append('detect_ssn', String(detectSSN));
|
|
formData.append('detect_credit_cards', String(detectCreditCards));
|
|
formData.append('detect_ip_addresses', String(detectIPAddresses));
|
|
formData.append('detect_dates', String(detectDates));
|
|
formData.append('detect_addresses', String(detectAddresses));
|
|
formData.append('detect_iban', String(detectIBAN));
|
|
formData.append('detect_secrets', String(detectSecrets));
|
|
formData.append('coordinates_only', 'false');
|
|
|
|
response = await fetch(`${API_BASE}/privacy/scan-text`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
} else {
|
|
const formData = new FormData();
|
|
formData.append('file', file!);
|
|
|
|
response = await fetch(`${API_BASE}/privacy/scan-file`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errData = await response.json();
|
|
throw new Error(errData.detail || 'Scan failed');
|
|
}
|
|
|
|
result = await response.json();
|
|
}
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Failed to scan for PII';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function getRiskColor(level: string): string {
|
|
switch (level) {
|
|
case 'CRITICAL':
|
|
return 'text-red-700 bg-red-50 border-red-200';
|
|
case 'HIGH':
|
|
return 'text-orange-700 bg-orange-50 border-orange-200';
|
|
case 'MEDIUM':
|
|
return 'text-yellow-700 bg-yellow-50 border-yellow-200';
|
|
default:
|
|
return 'text-green-700 bg-green-50 border-green-200';
|
|
}
|
|
}
|
|
|
|
function getPIITypeIcon(type: string): string {
|
|
switch (type) {
|
|
case 'EMAIL': return '@';
|
|
case 'PHONE_US':
|
|
case 'PHONE_INTL': return '#';
|
|
case 'SSN': return 'SSN';
|
|
case 'CREDIT_CARD': return '$';
|
|
case 'IP_ADDRESS': return 'IP';
|
|
case 'DATE_OF_BIRTH': return 'DOB';
|
|
case 'US_ADDRESS':
|
|
case 'UK_ADDRESS':
|
|
case 'EU_ADDRESS': return 'ADR';
|
|
case 'IBAN':
|
|
case 'BANK_ACCOUNT': return 'BNK';
|
|
case 'AWS_ACCESS_KEY':
|
|
case 'AWS_SECRET_KEY': return 'AWS';
|
|
case 'GITHUB_TOKEN': return 'GH';
|
|
case 'STRIPE_KEY': return 'STR';
|
|
case 'GOOGLE_API_KEY': return 'GCP';
|
|
case 'PASSWORD_IN_URL': return 'PWD';
|
|
case 'PRIVATE_KEY': return 'KEY';
|
|
case 'JWT_TOKEN': return 'JWT';
|
|
case 'SLACK_TOKEN': return 'SLK';
|
|
case 'GENERIC_API_KEY': return 'API';
|
|
default: return '?';
|
|
}
|
|
}
|
|
|
|
function getPIITypeColor(type: string): string {
|
|
switch (type) {
|
|
case 'SSN':
|
|
case 'CREDIT_CARD':
|
|
case 'IBAN':
|
|
case 'BANK_ACCOUNT':
|
|
return 'bg-red-100 text-red-700 border-red-200';
|
|
case 'AWS_ACCESS_KEY':
|
|
case 'AWS_SECRET_KEY':
|
|
case 'PRIVATE_KEY':
|
|
case 'PASSWORD_IN_URL':
|
|
case 'GITHUB_TOKEN':
|
|
case 'STRIPE_KEY':
|
|
return 'bg-purple-100 text-purple-700 border-purple-200';
|
|
case 'GOOGLE_API_KEY':
|
|
case 'SLACK_TOKEN':
|
|
case 'JWT_TOKEN':
|
|
case 'GENERIC_API_KEY':
|
|
return 'bg-orange-100 text-orange-700 border-orange-200';
|
|
case 'EMAIL':
|
|
case 'PHONE_US':
|
|
case 'PHONE_INTL':
|
|
return 'bg-yellow-100 text-yellow-700 border-yellow-200';
|
|
case 'US_ADDRESS':
|
|
case 'UK_ADDRESS':
|
|
case 'EU_ADDRESS':
|
|
return 'bg-amber-100 text-amber-700 border-amber-200';
|
|
default:
|
|
return 'bg-blue-100 text-blue-700 border-blue-200';
|
|
}
|
|
}
|
|
|
|
let copySuccess = $state(false);
|
|
async function copyToClipboard() {
|
|
if (!result || !result.entities || result.entities.length === 0) return;
|
|
const header = 'Type\tOriginal Value\tMasked Value\tConfidence';
|
|
const rows = result.entities.map((e: any) =>
|
|
`${e.type}\t${e.value}\t${e.masked_value}\t${Math.round(e.confidence * 100)}%`
|
|
);
|
|
const text = [header, ...rows].join('\n');
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
copySuccess = true;
|
|
setTimeout(() => copySuccess = false, 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
}
|
|
|
|
let copyRedactedSuccess = $state(false);
|
|
async function copyRedactedToClipboard() {
|
|
if (!result || !result.redacted_preview) return;
|
|
try {
|
|
await navigator.clipboard.writeText(result.redacted_preview);
|
|
copyRedactedSuccess = true;
|
|
setTimeout(() => copyRedactedSuccess = false, 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
}
|
|
|
|
function loadSampleText() {
|
|
textInput = `Customer Record:
|
|
Name: John Smith
|
|
Email: john.smith@example.com
|
|
US Phone: (555) 123-4567
|
|
SSN: 123-45-6789
|
|
Credit Card: 4532 0151 1283 0366
|
|
IP Address: 192.168.1.100
|
|
|
|
--- CLOUD SECRETS ---
|
|
AWS Key: AKIAIOSFODNN7EXAMPLE
|
|
GitHub Token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
|
|
Please contact support@company.org for assistance.`;
|
|
}
|
|
|
|
function clearAll() {
|
|
textInput = '';
|
|
file = null;
|
|
fileName = '';
|
|
result = null;
|
|
error = '';
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Privacy Scanner | Build with AI</title>
|
|
<meta name="description" content="Detect and redact personally identifiable information (PII) from text and files." />
|
|
</svelte:head>
|
|
|
|
<div class="min-h-screen bg-gradient-to-b from-slate-50 to-white">
|
|
<Navbar />
|
|
|
|
<main class="pt-20 pb-10 px-5">
|
|
<div class="max-w-6xl mx-auto">
|
|
<!-- Header -->
|
|
<div class="mb-5 flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<a href="/tools/" class="text-sm text-primary-600 hover:text-primary-700">← Tools</a>
|
|
<h1 class="text-2xl font-bold text-gray-900">Privacy Scanner</h1>
|
|
<span class="text-gray-400 text-sm hidden sm:inline">|</span>
|
|
<p class="text-gray-500 text-sm hidden sm:inline">Detect & redact PII from text and files</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
|
<!-- Input Section -->
|
|
<div class="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
|
<h2 class="text-base font-bold text-gray-900 mb-3">Input</h2>
|
|
|
|
<!-- Mode Toggle -->
|
|
<div class="flex gap-1 mb-3 p-1 bg-gray-100 rounded-lg">
|
|
<button
|
|
class="flex-1 py-2 px-3 text-sm font-medium rounded-md transition-all {inputMode === 'text'
|
|
? 'bg-white text-primary-600 shadow-sm'
|
|
: 'text-gray-500 hover:text-gray-700'}"
|
|
onclick={() => (inputMode = 'text')}
|
|
>
|
|
Text
|
|
</button>
|
|
<button
|
|
class="flex-1 py-2 px-3 text-sm font-medium rounded-md transition-all {inputMode === 'file'
|
|
? 'bg-white text-primary-600 shadow-sm'
|
|
: 'text-gray-500 hover:text-gray-700'}"
|
|
onclick={() => (inputMode = 'file')}
|
|
>
|
|
File
|
|
</button>
|
|
</div>
|
|
|
|
{#if inputMode === 'text'}
|
|
<div class="space-y-3">
|
|
<div class="flex justify-between items-center">
|
|
<label for="text-input" class="text-sm font-medium text-gray-700">Text to Scan</label>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="text-xs text-primary-600 hover:text-primary-700"
|
|
onclick={loadSampleText}
|
|
>
|
|
Load Sample
|
|
</button>
|
|
{#if textInput || result}
|
|
<span class="text-gray-300">|</span>
|
|
<button
|
|
class="text-xs text-gray-500 hover:text-red-600"
|
|
onclick={clearAll}
|
|
>
|
|
Clear
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
id="text-input"
|
|
bind:value={textInput}
|
|
placeholder="Paste text containing potential PII here..."
|
|
class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 min-h-[150px] resize-none font-mono text-xs"
|
|
></textarea>
|
|
</div>
|
|
{:else}
|
|
<!-- Drop Zone -->
|
|
<div
|
|
class="border-2 border-dashed border-gray-300 rounded-xl p-6 text-center hover:border-primary-400 transition-colors cursor-pointer"
|
|
ondrop={handleDrop}
|
|
ondragover={handleDragOver}
|
|
role="button"
|
|
tabindex="0"
|
|
>
|
|
<input
|
|
type="file"
|
|
accept=".csv,.txt,.json"
|
|
onchange={handleFileSelect}
|
|
class="hidden"
|
|
id="file-input"
|
|
/>
|
|
<label for="file-input" class="cursor-pointer">
|
|
<div class="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center mx-auto mb-3">
|
|
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
</div>
|
|
<p class="text-sm text-gray-600">
|
|
<span class="text-primary-600 font-medium">Click to upload</span> or drag and drop
|
|
</p>
|
|
<p class="text-xs text-gray-400 mt-1">CSV, TXT, or JSON files</p>
|
|
</label>
|
|
</div>
|
|
|
|
{#if fileName}
|
|
<div class="mt-3 flex items-center gap-2 p-2 bg-gray-50 rounded-lg">
|
|
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span class="text-sm text-gray-700 truncate flex-1">{fileName}</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Detection Options -->
|
|
<div class="mt-4 pt-3 border-t border-gray-200">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h3 class="text-xs font-semibold text-gray-500 uppercase">PII Detection</h3>
|
|
<label class="flex items-center gap-1.5 cursor-pointer text-xs">
|
|
<input type="checkbox" bind:checked={detectSecrets} class="rounded text-primary-600" />
|
|
<span class="text-gray-600">Secrets</span>
|
|
</label>
|
|
</div>
|
|
<div class="grid grid-cols-4 gap-1.5 text-xs">
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" bind:checked={detectEmails} class="rounded text-primary-600" />
|
|
<span class="text-gray-700">Email</span>
|
|
</label>
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" bind:checked={detectPhones} class="rounded text-primary-600" />
|
|
<span class="text-gray-700">Phone</span>
|
|
</label>
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" bind:checked={detectSSN} class="rounded text-primary-600" />
|
|
<span class="text-gray-700">SSN</span>
|
|
</label>
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" bind:checked={detectCreditCards} class="rounded text-primary-600" />
|
|
<span class="text-gray-700">Cards</span>
|
|
</label>
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" bind:checked={detectIPAddresses} class="rounded text-primary-600" />
|
|
<span class="text-gray-700">IP</span>
|
|
</label>
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" bind:checked={detectDates} class="rounded text-primary-600" />
|
|
<span class="text-gray-700">Dates</span>
|
|
</label>
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" bind:checked={detectAddresses} class="rounded text-primary-600" />
|
|
<span class="text-gray-700">Addr</span>
|
|
</label>
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" bind:checked={detectIBAN} class="rounded text-primary-600" />
|
|
<span class="text-gray-700">IBAN</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Security Mode - Inline -->
|
|
<label class="flex items-center gap-2 cursor-pointer text-xs mt-2.5 pt-2.5 border-t border-gray-100" title="All detection runs in your browser - no data sent to server">
|
|
<svg class="w-3.5 h-3.5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
<input type="checkbox" bind:checked={coordinatesOnly} class="rounded text-green-600" disabled={inputMode === 'file'} />
|
|
<span class="text-gray-600">Browser-Only Mode <span class="text-green-600 font-medium">(Zero Network)</span></span>
|
|
</label>
|
|
</div>
|
|
|
|
<button
|
|
onclick={scanForPII}
|
|
disabled={loading || (inputMode === 'text' ? !textInput.trim() : !file)}
|
|
class="w-full mt-4 px-5 py-2.5 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 transition-all shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{loading ? 'Scanning...' : 'Scan for PII'}
|
|
</button>
|
|
|
|
{#if error}
|
|
<div class="mt-3 p-3 bg-red-50 text-red-700 text-sm rounded-lg border border-red-100">
|
|
{error}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Results Section -->
|
|
<div class="lg:col-span-2 bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
|
<h2 class="text-base font-bold text-gray-900 mb-4">Scan Results</h2>
|
|
|
|
{#if result}
|
|
<!-- Risk Summary -->
|
|
<div class="grid grid-cols-4 gap-3 mb-5">
|
|
<div class="bg-gray-50 rounded-lg p-2.5 text-center border border-gray-100">
|
|
<p class="text-xs text-gray-500">PII Found</p>
|
|
<p class="text-xl font-bold {result.total_entities > 0 ? 'text-red-600' : 'text-green-600'}">
|
|
{result.total_entities}
|
|
</p>
|
|
</div>
|
|
<div class="bg-gray-50 rounded-lg p-2.5 text-center border border-gray-100">
|
|
<p class="text-xs text-gray-500">Types</p>
|
|
<p class="text-xl font-bold text-gray-800">
|
|
{Object.keys(result.entities_by_type || {}).length}
|
|
</p>
|
|
</div>
|
|
<div class="bg-gray-50 rounded-lg p-2.5 text-center border border-gray-100">
|
|
<p class="text-xs text-gray-500">Risk Score</p>
|
|
<p class="text-xl font-bold text-gray-800">{result.risk_score}</p>
|
|
</div>
|
|
<div class="rounded-lg p-2.5 text-center border {getRiskColor(result.risk_level)}">
|
|
<p class="text-xs opacity-80">Risk Level</p>
|
|
<p class="text-xl font-bold">{result.risk_level}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex gap-1 mb-4 border-b border-gray-200">
|
|
<button
|
|
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'overview'
|
|
? 'text-primary-600 border-b-2 border-primary-600'
|
|
: 'text-gray-500 hover:text-gray-700'}"
|
|
onclick={() => (activeTab = 'overview')}
|
|
>
|
|
Overview
|
|
</button>
|
|
<button
|
|
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'entities'
|
|
? 'text-primary-600 border-b-2 border-primary-600'
|
|
: 'text-gray-500 hover:text-gray-700'}"
|
|
onclick={() => (activeTab = 'entities')}
|
|
>
|
|
Entities ({result.total_entities})
|
|
</button>
|
|
<button
|
|
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'redacted'
|
|
? 'text-primary-600 border-b-2 border-primary-600'
|
|
: 'text-gray-500 hover:text-gray-700'}"
|
|
onclick={() => (activeTab = 'redacted')}
|
|
>
|
|
Redacted Preview
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tab Content -->
|
|
{#if activeTab === 'overview'}
|
|
<div class="space-y-4">
|
|
{#if Object.keys(result.entities_by_type || {}).length > 0}
|
|
<div class="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
|
<h3 class="text-sm font-semibold text-gray-700 mb-3">PII by Type</h3>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
{#each Object.entries(result.entities_by_type) as [type, count]}
|
|
<div class="flex items-center justify-between p-2 rounded-lg border {getPIITypeColor(type)}">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs font-bold w-6 h-6 rounded flex items-center justify-center bg-white/50">
|
|
{getPIITypeIcon(type)}
|
|
</span>
|
|
<span class="text-sm font-medium">{type}</span>
|
|
</div>
|
|
<span class="text-sm font-bold">{count}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="bg-green-50 rounded-lg p-4 border border-green-200">
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span class="text-sm text-green-700 font-medium">No PII detected in the input</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="rounded-lg p-4 border {getRiskColor(result.risk_level)}">
|
|
<h3 class="text-sm font-semibold mb-2">Risk Assessment</h3>
|
|
<p class="text-sm opacity-90">
|
|
{#if result.risk_level === 'CRITICAL'}
|
|
Critical risk! Highly sensitive PII (SSN, Credit Cards) detected. Immediate action required.
|
|
{:else if result.risk_level === 'HIGH'}
|
|
High risk! Multiple sensitive PII elements found. Consider redaction before sharing.
|
|
{:else if result.risk_level === 'MEDIUM'}
|
|
Medium risk. Some PII detected that may require attention.
|
|
{:else}
|
|
Low risk. Minimal or no PII detected.
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{:else if activeTab === 'entities'}
|
|
<div class="overflow-x-auto">
|
|
{#if result.entities && result.entities.length > 0}
|
|
<div class="flex justify-end mb-3">
|
|
<button
|
|
onclick={copyToClipboard}
|
|
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all
|
|
{copySuccess
|
|
? 'bg-green-100 text-green-700 border border-green-300'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
|
|
>
|
|
{#if copySuccess}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
Copied!
|
|
{:else}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
</svg>
|
|
Copy
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-gray-200">
|
|
<th class="text-left py-2 px-3 font-medium text-gray-700">Type</th>
|
|
<th class="text-left py-2 px-3 font-medium text-gray-700">Original Value</th>
|
|
<th class="text-left py-2 px-3 font-medium text-gray-700">Masked Value</th>
|
|
<th class="text-right py-2 px-3 font-medium text-gray-700">Confidence</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each result.entities as entity}
|
|
<tr class="border-b border-gray-100">
|
|
<td class="py-2 px-3">
|
|
<span class="text-xs px-2 py-1 rounded border {getPIITypeColor(entity.type)}">
|
|
{entity.type}
|
|
</span>
|
|
</td>
|
|
<td class="py-2 px-3 font-mono text-xs text-red-600">{entity.value}</td>
|
|
<td class="py-2 px-3 font-mono text-xs text-green-600">{entity.masked_value}</td>
|
|
<td class="py-2 px-3 text-right">
|
|
<span class="text-xs px-2 py-0.5 rounded {entity.confidence >= 0.9
|
|
? 'bg-green-100 text-green-700'
|
|
: entity.confidence >= 0.7
|
|
? 'bg-yellow-100 text-yellow-700'
|
|
: 'bg-red-100 text-red-700'}">
|
|
{Math.round(entity.confidence * 100)}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{:else}
|
|
<div class="text-center py-8 text-gray-500">
|
|
No PII entities found
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if activeTab === 'redacted'}
|
|
<div class="space-y-3">
|
|
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<h3 class="text-sm font-semibold text-gray-700">Redacted Text Preview</h3>
|
|
<button
|
|
onclick={copyRedactedToClipboard}
|
|
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all
|
|
{copyRedactedSuccess
|
|
? 'bg-green-100 text-green-700 border border-green-300'
|
|
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'}"
|
|
>
|
|
{#if copyRedactedSuccess}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
Copied!
|
|
{:else}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
</svg>
|
|
Copy
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
<pre class="text-xs font-mono whitespace-pre-wrap text-gray-600 bg-white p-3 rounded-lg border border-gray-100 max-h-[300px] overflow-auto">{result.redacted_preview || 'No preview available'}</pre>
|
|
</div>
|
|
<p class="text-xs text-gray-500">
|
|
This preview shows PII values masked for safe sharing.
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<div class="text-center py-12">
|
|
<div class="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mx-auto mb-4">
|
|
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
</div>
|
|
<p class="text-gray-500">Enter text or upload a file to scan for PII</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<Footer />
|
|
</div>
|