12345678910111213141516import type {17 AdvancedHealthEntryReport,18 AdvancedHealthReport,19 VaultEntry,20} from "../shared/types";21import { extractDomain } from "../shared/domain";2223const SENSITIVE_DOMAIN_PATTERNS =24 /bank|pay|finance|invest|mail|gmail|outlook|yahoo|icloud|instagram|twitter|facebook|linkedin/i;2526const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;27const ONE_EIGHTY_DAYS_MS = 180 * 24 * 60 * 60 * 1000;2829const SCORE_BREACHED = 60;30const SCORE_REUSED = 25;31const SCORE_AGED = 15;32const SCORE_WEAK = 20;33const SCORE_NO_TOTP_SENSITIVE = 10;3435async function sha256Hex(plain: string): Promise<string> {36 const buf = await crypto.subtle.digest(37 "SHA-256",38 new TextEncoder().encode(plain)39 );40 return Array.from(new Uint8Array(buf))41 .map((b) => b.toString(16).padStart(2, "0"))42 .join("");43}4445function isSensitiveDomain(domain: string): boolean {46 return SENSITIVE_DOMAIN_PATTERNS.test(domain);47}4849function ageThresholdMs(domain: string): number {50 return isSensitiveDomain(domain) ? NINETY_DAYS_MS : ONE_EIGHTY_DAYS_MS;51}5253545556575859function isWeakHeuristic(password: string): boolean {60 if (password.length < 10) return true;61 let classes = 0;62 if (/[a-z]/.test(password)) classes += 1;63 if (/[A-Z]/.test(password)) classes += 1;64 if (/[0-9]/.test(password)) classes += 1;65 if (/[^A-Za-z0-9]/.test(password)) classes += 1;66 return classes < 3;67}6869export async function buildAdvancedHealthReport(70 entries: VaultEntry[]71): Promise<AdvancedHealthReport> {72 const now = Date.now();73 const live = entries.filter(74 (e) => !e.deletedAt && (e.type === undefined || e.type === "login")75 );7677 const hashGroups = new Map<string, string[]>();78 for (const entry of live) {79 if (!entry.password) continue;80 const hash = await sha256Hex(entry.password);81 const ids = hashGroups.get(hash) ?? [];82 ids.push(entry.id);83 hashGroups.set(hash, ids);84 }85 const reusedIds = new Set<string>();86 let reusedGroupCount = 0;87 for (const ids of hashGroups.values()) {88 if (ids.length > 1) {89 reusedGroupCount += 1;90 for (const id of ids) reusedIds.add(id);91 }92 }9394 const reports: AdvancedHealthEntryReport[] = [];95 let agedCount = 0;96 let weightedSum = 0;97 let weightSum = 0;9899 for (const entry of live) {100 const domain = extractDomain(entry.url ?? "") ?? "";101 const sensitive = domain ? isSensitiveDomain(domain) : false;102 const passwordChanged =103 entry.passwordChangedAt ?? entry.createdAt ?? now;104 const ageMs = now - passwordChanged;105 const aged = entry.password106 ? ageMs > ageThresholdMs(domain)107 : false;108 if (aged) agedCount += 1;109110 const breached = (entry.breach ?? 0) > 0;111 const reused = reusedIds.has(entry.id);112 const weak = entry.password ? isWeakHeuristic(entry.password) : false;113 const missingTotp = sensitive && !entry.totp;114115 let score = 0;116 if (breached) score += SCORE_BREACHED;117 if (reused) score += SCORE_REUSED;118 if (aged) score += SCORE_AGED;119 if (weak) score += SCORE_WEAK;120 if (missingTotp) score += SCORE_NO_TOTP_SENSITIVE;121 if (score > 100) score = 100;122123 const reasons: string[] = [];124 if (breached) reasons.push(`Breached (${entry.breach} exposures)`);125 if (reused) reasons.push("Reused");126 if (aged) {127 const days = Math.floor(ageMs / 86_400_000);128 reasons.push(`${days} days old`);129 }130 if (weak) reasons.push("Weak");131 if (missingTotp) reasons.push("No 2FA on sensitive site");132133 const weight = sensitive ? 2 : 1;134 weightedSum += score * weight;135 weightSum += weight;136137 if (score > 0) {138 reports.push({139 id: entry.id,140 title: entry.title,141 domain,142 url: entry.url,143 score,144 ageDays: Math.floor(ageMs / 86_400_000),145 reasons,146 factors: {147 breached,148 breachCount: entry.breach ?? 0,149 reused,150 aged,151 weak,152 missingTotpOnSensitive: missingTotp,153 },154 });155 }156 }157158 reports.sort((a, b) => b.score - a.score);159160 const vaultScore = weightSum > 0 ? Math.round(weightedSum / weightSum) : 0;161162 return {163 vaultScore,164 generatedAt: now,165 entryCount: live.length,166 reusedGroups: reusedGroupCount,167 agedCount,168 remediation: reports,169 };170}