field-scoring.ts

6.7 KB · raw

1export interface FieldScore {2  element: HTMLInputElement;3  score: number;4  fieldType: "username" | "password" | "email" | "totp" | "unknown";5  factors: {6    autocompleteMatch: number;7    typeMatch: number;8    namePattern: number;9    idPattern: number;10    placeholderPattern: number;11    ariaLabelPattern: number;12    siblingContext: number;13  };14}1516export interface FormFields {17  username: HTMLInputElement | null;18  password: HTMLInputElement | null;19  email: HTMLInputElement | null;20  totp: HTMLInputElement | null;21}2223const USERNAME_PATTERNS = [24  /user/i,25  /login/i,26  /usr/i,27  /uname/i,28  /account/i,29  /acct/i,30  /signin/i,31  /sign-in/i,32  /session/i,33  /member/i,34  /alias/i,35];3637const EMAIL_PATTERNS = [/email/i, /e-mail/i, /mail/i];3839const PASSWORD_PATTERNS = [40  /pass/i,41  /password/i,42  /pwd/i,43  /secret/i,44  /passphrase/i,45  /key/i,46  /credential/i,47];4849const TOTP_PATTERNS = [50  /totp/i,51  /otp/i,52  /2fa/i,53  /mfa/i,54  /two-factor/i,55  /twofactor/i,56  /authenticat/i,57  /verification/i,58  /code/i,59  /pin/i,60  /token/i,61];6263const AUTOCOMPLETE_VALUES: Record<64  "username" | "email" | "password" | "totp",65  string[]66> = {67  username: ["username", "login", "user"],68  email: ["email", "username"],69  password: ["current-password", "password", "new-password"],70  totp: ["one-time-code", "totp"],71};7273function matchesPattern(value: string, patterns: RegExp[]): boolean {74  return patterns.some((pattern) => pattern.test(value));75}7677function getPatternsForType(78  type: "username" | "password" | "email" | "totp"79): RegExp[] {80  switch (type) {81    case "username":82      return USERNAME_PATTERNS;83    case "password":84      return PASSWORD_PATTERNS;85    case "email":86      return EMAIL_PATTERNS;87    case "totp":88      return TOTP_PATTERNS;89  }90}9192function getLabelText(labelledById: string): string {93  const label = document.getElementById(labelledById);94  return label?.textContent || "";95}9697function analyzeSiblingContext(input: HTMLInputElement, patterns: RegExp[]): number {98  const parent = input.parentElement;99  if (!parent) return 0;100101  for (const sibling of Array.from(parent.children)) {102    if (sibling === input) continue;103    if (matchesPattern(sibling.textContent || "", patterns)) return 5;104  }105106  const grandparent = parent.parentElement;107  if (!grandparent) return 0;108  for (const sibling of Array.from(grandparent.children)) {109    if (sibling.contains(input)) continue;110    if (matchesPattern(sibling.textContent || "", patterns)) return 5;111  }112113  return 0;114}115116function calculateFieldScore(117  input: HTMLInputElement,118  type: "username" | "password" | "email" | "totp"119): number {120  const patterns = getPatternsForType(type);121  let score = 0;122  const autocomplete = input.getAttribute("autocomplete") || "";123  const expected = AUTOCOMPLETE_VALUES[type] || [];124125  if (expected.some((value) => autocomplete.includes(value))) score += 10;126  if (type === "password" && input.type === "password") score += 8;127  if (type === "email" && input.type === "email") score += 8;128  if (type === "totp" && (input.type === "tel" || input.inputMode === "numeric")) {129    score += 6;130  }131132  const name = input.name || "";133  const id = input.id || "";134  const placeholder = input.placeholder || "";135  const ariaLabel = input.getAttribute("aria-label") || "";136  const ariaLabelledBy = input.getAttribute("aria-labelledby") || "";137138  if (matchesPattern(name, patterns)) score += 6;139  if (matchesPattern(id, patterns)) score += 6;140  if (matchesPattern(placeholder, patterns)) score += 4;141  if (matchesPattern(ariaLabel, patterns)) score += 4;142  if (ariaLabelledBy && matchesPattern(getLabelText(ariaLabelledBy), patterns)) {143    score += 4;144  }145146  score += analyzeSiblingContext(input, patterns);147  return score;148}149150export function scoreInputField(input: HTMLInputElement): FieldScore {151  const types: Array<"username" | "password" | "email" | "totp"> = [152    "username",153    "password",154    "email",155    "totp",156  ];157158  const scores = types.map((type) => {159    const factors = {160      autocompleteMatch: 0,161      typeMatch: 0,162      namePattern: 0,163      idPattern: 0,164      placeholderPattern: 0,165      ariaLabelPattern: 0,166      siblingContext: 0,167    };168    const autocomplete = input.getAttribute("autocomplete") || "";169    const patterns = getPatternsForType(type);170    const name = input.name || "";171    const id = input.id || "";172    const placeholder = input.placeholder || "";173    const ariaLabel = input.getAttribute("aria-label") || "";174    if (AUTOCOMPLETE_VALUES[type]?.some((value) => autocomplete.includes(value))) {175      factors.autocompleteMatch = 10;176    }177    if (type === "password" && input.type === "password") factors.typeMatch = 8;178    if (type === "email" && input.type === "email") factors.typeMatch = 8;179    if (type === "totp" && (input.type === "tel" || input.inputMode === "numeric")) {180      factors.typeMatch = 6;181    }182    if (matchesPattern(name, patterns)) factors.namePattern = 6;183    if (matchesPattern(id, patterns)) factors.idPattern = 6;184    if (matchesPattern(placeholder, patterns)) factors.placeholderPattern = 4;185    if (matchesPattern(ariaLabel, patterns)) factors.ariaLabelPattern = 4;186    factors.siblingContext = analyzeSiblingContext(input, patterns);187188    return {189      type,190      score: calculateFieldScore(input, type),191      factors,192    };193  });194195  scores.sort((a, b) => b.score - a.score);196  const best = scores[0];197  return {198    element: input,199    score: best.score,200    fieldType: best.score > 5 ? best.type : "unknown",201    factors: best.factors,202  };203}204205export function identifyLoginFields(form: HTMLFormElement | null): FormFields {206  const inputs = Array.from(207    (form || document).querySelectorAll<HTMLInputElement>(208      'input:not([type="hidden"]):not([type="submit"]):not([type="button"])'209    )210  );211212  const scoredFields = inputs.map(scoreInputField).sort((a, b) => b.score - a.score);213  const result: FormFields = {214    username: null,215    password: null,216    email: null,217    totp: null,218  };219  const used = new Set<HTMLInputElement>();220221  for (const field of scoredFields) {222    if (used.has(field.element) || field.score < 3) continue;223    switch (field.fieldType) {224      case "username":225        if (!result.username) {226          result.username = field.element;227          used.add(field.element);228        }229        break;230      case "password":231        if (!result.password) {232          result.password = field.element;233          used.add(field.element);234        }235        break;236      case "email":237        if (!result.email && !result.username) {238          result.email = field.element;239          used.add(field.element);240        }241        break;242      case "totp":243        if (!result.totp) {244          result.totp = field.element;245          used.add(field.element);246        }247        break;248    }249  }250251  return result;252}