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 parts: string[] = [];2526 27 const describedBy = el.getAttribute("aria-describedby");28 if (describedBy) {29 for (const id of describedBy.trim().split(/\s+/)) {30 const desc = el.ownerDocument.getElementById(id);31 if (desc?.textContent) parts.push(desc.textContent);32 }33 }3435 36 if (el.title) parts.push(el.title);37 if (el.placeholder) parts.push(el.placeholder);38 const ariaLabel = el.getAttribute("aria-label");39 if (ariaLabel) parts.push(ariaLabel);4041 const HINT_SELECTOR =42 "p, span, li, small, label, div, .hint, .requirement, .password-rule, .password-hint, .password-rules, .field-hint, .helper-text, .input-hint";4344 let node: Element | null = el;45 for (let i = 0; i < 5; i++) {46 node = node?.parentElement ?? null;47 if (!node) break;48 const hints = node.querySelectorAll(HINT_SELECTOR);49 if (hints.length > 0) {50 parts.push(Array.from(hints).map((h) => h.textContent).join(" "));51 break;52 }53 }5455 return parts.filter(Boolean).join(" ");56}5758export function detectPasswordPolicy(doc: Document = document): PasswordPolicy {59 const policy: PasswordPolicy = {};6061 const passwordField = doc.querySelector<HTMLInputElement>(62 'input[autocomplete="new-password"], input[type="password"]'63 );64 const maxLen = passwordField?.maxLength ?? 0;65 if (maxLen > 0) policy.maxLength = maxLen;66 if (passwordField?.pattern) {67 const pat = passwordField.pattern;68 69 const rangeMatch = pat.match(/\{(\d+),(\d+)\}/);70 const minOnlyMatch = pat.match(/\{(\d+),\}/);71 const exactMatch = pat.match(/\{(\d+)\}/);72 if (rangeMatch) {73 policy.minLength = parseInt(rangeMatch[1]!);74 policy.maxLength = parseInt(rangeMatch[2]!);75 } else if (minOnlyMatch) {76 policy.minLength = parseInt(minOnlyMatch[1]!);77 } else if (exactMatch) {78 policy.minLength = parseInt(exactMatch[1]!);79 policy.maxLength = parseInt(exactMatch[1]!);80 }81 }8283 const text = extractNearbyText(passwordField);8485 86 const dashRangeMatch = text.match(/(\d+)\s*[-–—]\s*(\d+)\s*char/i);87 88 const betweenMatch = text.match(/between\D*?(\d+)\D+?(\d+)/i);89 const minMatch = text.match(/(?:at least|minimum|min(?:imum)?|must be (?:at least )?)(?:\D*?)(\d+)/i);90 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);91 const exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i);92 93 if (dashRangeMatch && !policy.minLength) policy.minLength = parseInt(dashRangeMatch[1]!);94 if (dashRangeMatch && !policy.maxLength) policy.maxLength = parseInt(dashRangeMatch[2]!);95 if (betweenMatch && !policy.minLength) policy.minLength = parseInt(betweenMatch[1]!);96 if (betweenMatch && !policy.maxLength) policy.maxLength = parseInt(betweenMatch[2]!);97 if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]!);98 if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]!);99 if (exactMatch2 && !policy.minLength) policy.minLength = parseInt(exactMatch2[1]!);100 if (exactMatch2 && !policy.maxLength) policy.maxLength = parseInt(exactMatch2[1]!);101102 if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;103 if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;104 if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true;105 if (/number|digit/i.test(text)) policy.requiresNumbers = true;106107 108 109 110 111 112 113 114 const negation = text.match(115 /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i116 );117 if (negation) {118 const window = negation[0].slice(0, NEGATION_WINDOW);119 const seen = new Set<string>();120 const tokenRegex = /(?:^|[\s`"',.([])([!-/:-@[-`{-~])(?=[\s`"',.)\]]|$)/g;121 let m: RegExpExecArray | null;122 123 while ((m = tokenRegex.exec(window)) !== null) {124 const ch = m[1]!;125 if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch);126 }127 if (seen.size > 0) policy.forbiddenChars = [...seen].join("");128 }129130 return policy;131}