export interface FieldScore { element: HTMLInputElement; score: number; fieldType: "username" | "password" | "email" | "totp" | "unknown"; factors: { autocompleteMatch: number; typeMatch: number; namePattern: number; idPattern: number; placeholderPattern: number; ariaLabelPattern: number; siblingContext: number; }; } export interface FormFields { username: HTMLInputElement | null; password: HTMLInputElement | null; email: HTMLInputElement | null; totp: HTMLInputElement | null; } const USERNAME_PATTERNS = [ /user/i, /login/i, /usr/i, /uname/i, /account/i, /acct/i, /signin/i, /sign-in/i, /session/i, /member/i, /alias/i, ]; const EMAIL_PATTERNS = [/email/i, /e-mail/i, /mail/i]; const PASSWORD_PATTERNS = [ /pass/i, /password/i, /pwd/i, /secret/i, /passphrase/i, /key/i, /credential/i, ]; const TOTP_PATTERNS = [ /totp/i, /otp/i, /2fa/i, /mfa/i, /two-factor/i, /twofactor/i, /authenticat/i, /verification/i, /code/i, /pin/i, /token/i, ]; const AUTOCOMPLETE_VALUES: Record< "username" | "email" | "password" | "totp", string[] > = { username: ["username", "login", "user"], email: ["email", "username"], password: ["current-password", "password", "new-password"], totp: ["one-time-code", "totp"], }; function matchesPattern(value: string, patterns: RegExp[]): boolean { return patterns.some((pattern) => pattern.test(value)); } function getPatternsForType( type: "username" | "password" | "email" | "totp" ): RegExp[] { switch (type) { case "username": return USERNAME_PATTERNS; case "password": return PASSWORD_PATTERNS; case "email": return EMAIL_PATTERNS; case "totp": return TOTP_PATTERNS; } } function getLabelText(labelledById: string): string { const label = document.getElementById(labelledById); return label?.textContent || ""; } function analyzeSiblingContext(input: HTMLInputElement, patterns: RegExp[]): number { const parent = input.parentElement; if (!parent) return 0; for (const sibling of Array.from(parent.children)) { if (sibling === input) continue; if (matchesPattern(sibling.textContent || "", patterns)) return 5; } const grandparent = parent.parentElement; if (!grandparent) return 0; for (const sibling of Array.from(grandparent.children)) { if (sibling.contains(input)) continue; if (matchesPattern(sibling.textContent || "", patterns)) return 5; } return 0; } function calculateFieldScore( input: HTMLInputElement, type: "username" | "password" | "email" | "totp" ): number { const patterns = getPatternsForType(type); let score = 0; const autocomplete = input.getAttribute("autocomplete") || ""; const expected = AUTOCOMPLETE_VALUES[type] || []; if (expected.some((value) => autocomplete.includes(value))) score += 10; if (type === "password" && input.type === "password") score += 8; if (type === "email" && input.type === "email") score += 8; if (type === "totp" && (input.type === "tel" || input.inputMode === "numeric")) { score += 6; } const name = input.name || ""; const id = input.id || ""; const placeholder = input.placeholder || ""; const ariaLabel = input.getAttribute("aria-label") || ""; const ariaLabelledBy = input.getAttribute("aria-labelledby") || ""; if (matchesPattern(name, patterns)) score += 6; if (matchesPattern(id, patterns)) score += 6; if (matchesPattern(placeholder, patterns)) score += 4; if (matchesPattern(ariaLabel, patterns)) score += 4; if (ariaLabelledBy && matchesPattern(getLabelText(ariaLabelledBy), patterns)) { score += 4; } score += analyzeSiblingContext(input, patterns); return score; } export function scoreInputField(input: HTMLInputElement): FieldScore { const types: Array<"username" | "password" | "email" | "totp"> = [ "username", "password", "email", "totp", ]; const scores = types.map((type) => { const factors = { autocompleteMatch: 0, typeMatch: 0, namePattern: 0, idPattern: 0, placeholderPattern: 0, ariaLabelPattern: 0, siblingContext: 0, }; const autocomplete = input.getAttribute("autocomplete") || ""; const patterns = getPatternsForType(type); const name = input.name || ""; const id = input.id || ""; const placeholder = input.placeholder || ""; const ariaLabel = input.getAttribute("aria-label") || ""; if (AUTOCOMPLETE_VALUES[type]?.some((value) => autocomplete.includes(value))) { factors.autocompleteMatch = 10; } if (type === "password" && input.type === "password") factors.typeMatch = 8; if (type === "email" && input.type === "email") factors.typeMatch = 8; if (type === "totp" && (input.type === "tel" || input.inputMode === "numeric")) { factors.typeMatch = 6; } if (matchesPattern(name, patterns)) factors.namePattern = 6; if (matchesPattern(id, patterns)) factors.idPattern = 6; if (matchesPattern(placeholder, patterns)) factors.placeholderPattern = 4; if (matchesPattern(ariaLabel, patterns)) factors.ariaLabelPattern = 4; factors.siblingContext = analyzeSiblingContext(input, patterns); return { type, score: calculateFieldScore(input, type), factors, }; }); scores.sort((a, b) => b.score - a.score); const best = scores[0]; return { element: input, score: best.score, fieldType: best.score > 5 ? best.type : "unknown", factors: best.factors, }; } export function identifyLoginFields(form: HTMLFormElement | null): FormFields { const inputs = Array.from( (form || document).querySelectorAll( 'input:not([type="hidden"]):not([type="submit"]):not([type="button"])' ) ); const scoredFields = inputs.map(scoreInputField).sort((a, b) => b.score - a.score); const result: FormFields = { username: null, password: null, email: null, totp: null, }; const used = new Set(); for (const field of scoredFields) { if (used.has(field.element) || field.score < 3) continue; switch (field.fieldType) { case "username": if (!result.username) { result.username = field.element; used.add(field.element); } break; case "password": if (!result.password) { result.password = field.element; used.add(field.element); } break; case "email": if (!result.email && !result.username) { result.email = field.element; used.add(field.element); } break; case "totp": if (!result.totp) { result.totp = field.element; used.add(field.element); } break; } } return result; }