build-valuecurve/apps/homepage/src/routes/tools/privacy-scanner/+page.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">&larr; 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>