123456789import type { PasswordPolicy } from "../shared/types";1011const NEGATION_WINDOW = 220;121314151617const FORBIDDEN_CHAR_SET = new Set([18 "`", "~", "<", ">", "\\", "|", "@", "#", "$", "%", "^", "&", "*",19 "(", ")", "[", "]", "{", "}", "+", "=", "?", "!", ";", ":", "/", '"', "'",20]);2122export function extractNearbyText(el: HTMLInputElement | null): string {23 if (!el) return "";24 const describedBy = el.getAttribute("aria-describedby");25 if (describedBy) {26 const desc = el.ownerDocument.getElementById(describedBy);27 if (desc) return desc.textContent ?? "";28 }29 let node: Element | null = el;30 for (let i = 0; i < 5; i++) {31 node = node?.parentElement ?? null;32 if (!node) break;33 const hints = node.querySelectorAll(34 "p, span, li, small, label, div, .hint, .requirement, .password-rule, .password-hint, .password-rules, .field-hint, .helper-text, .input-hint"35 );36 if (hints.length > 0) {37 return Array.from(hints).map((h) => h.textContent).join(" ");38 }39 }40 return "";41}4243export function detectPasswordPolicy(doc: Document = document): PasswordPolicy {44 const policy: PasswordPolicy = {};4546 const passwordField = doc.querySelector<HTMLInputElement>(47 'input[autocomplete="new-password"], input[type="password"]'48 );49 const maxLen = passwordField?.maxLength ?? 0;50 if (maxLen > 0) policy.maxLength = maxLen;51 if (passwordField?.pattern) {52 const pat = passwordField.pattern;53 54 const rangeMatch = pat.match(/\{(\d+),(\d+)\}/);55 const minOnlyMatch = pat.match(/\{(\d+),\}/);56 const exactMatch = pat.match(/\{(\d+)\}/);57 if (rangeMatch) {58 policy.minLength = parseInt(rangeMatch[1]!);59 policy.maxLength = parseInt(rangeMatch[2]!);60 } else if (minOnlyMatch) {61 policy.minLength = parseInt(minOnlyMatch[1]!);62 } else if (exactMatch) {63 policy.minLength = parseInt(exactMatch[1]!);64 policy.maxLength = parseInt(exactMatch[1]!);65 }66 }6768 const text = extractNearbyText(passwordField);6970 const minMatch = text.match(/(?:at least|minimum|min(?:imum)?|must be (?:at least )?)(?:\D*?)(\d+)/i);71 const maxMatch = text.match(/(?:at most|maximum|max(?:imum)?|up to|cannot exceed|must not exceed|no more than|must be (?:no more than |at most )?)(?:\D*?)(\d+)/i);72 const betweenMatch = text.match(/between\D*?(\d+)\D+?(\d+)/i);73 const exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i);74 if (betweenMatch && !policy.minLength) policy.minLength = parseInt(betweenMatch[1]!);75 if (betweenMatch && !policy.maxLength) policy.maxLength = parseInt(betweenMatch[2]!);76 if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]!);77 if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]!);78 if (exactMatch2 && !policy.minLength) policy.minLength = parseInt(exactMatch2[1]!);79 if (exactMatch2 && !policy.maxLength) policy.maxLength = parseInt(exactMatch2[1]!);8081 if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;82 if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;83 if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true;84 if (/number|digit/i.test(text)) policy.requiresNumbers = true;8586 87 88 89 90 91 92 93 const negation = text.match(94 /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i95 );96 if (negation) {97 const window = negation[0].slice(0, NEGATION_WINDOW);98 const seen = new Set<string>();99 const tokenRegex = /(?:^|[\s`"',.(\[])([!-/:-@\[-`{-~])(?=[\s`"',.)\]]|$)/g;100 let m: RegExpExecArray | null;101 while ((m = tokenRegex.exec(window)) !== null) {102 const ch = m[1]!;103 if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch);104 }105 if (seen.size > 0) policy.forbiddenChars = [...seen].join("");106 }107108 return policy;109}