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 < 3; i++) {31 node = node?.parentElement ?? null;32 if (!node) break;33 const hints = node.querySelectorAll(34 "p, span, li, small, .hint, .requirement, .password-rule"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 const minMatch = pat.match(/\{(\d+),/);54 const maxMatch = pat.match(/,(\d+)\}/);55 if (minMatch) policy.minLength = parseInt(minMatch[1]!);56 if (maxMatch) policy.maxLength = parseInt(maxMatch[1]!);57 }5859 const text = extractNearbyText(passwordField);6061 const minMatch = text.match(/(?:at least|minimum|min)[^\d]*(\d+)/i);62 const maxMatch = text.match(/(?:at most|maximum|max|up to|cannot exceed|must not exceed|no more than)[^\d]*(\d+)/i);63 if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]!);64 if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]!);6566 if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;67 if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;68 if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true;69 if (/number|digit/i.test(text)) policy.requiresNumbers = true;7071 72 73 74 75 76 77 78 const negation = text.match(79 /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i80 );81 if (negation) {82 const window = negation[0].slice(0, NEGATION_WINDOW);83 const seen = new Set<string>();84 const tokenRegex = /(?:^|[\s`"',.(\[])([!-/:-@\[-`{-~])(?=[\s`"',.)\]]|$)/g;85 let m: RegExpExecArray | null;86 while ((m = tokenRegex.exec(window)) !== null) {87 const ch = m[1]!;88 if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch);89 }90 if (seen.size > 0) policy.forbiddenChars = [...seen].join("");91 }9293 return policy;94}