/** * policy.ts — page-scraping helpers for password requirements. * * Content-side only; no chrome.* API access. Called by both inject.ts * (for the inline create form) and index.ts (for the popup's smart * GET_SITE_PASSWORD_POLICY request). */ import type { PasswordPolicy } from "../shared/types"; const NEGATION_WINDOW = 220; // Characters that plausibly appear in a site's forbidden-character list. // Restricted to ASCII punctuation a user could actually type. Anything else // (letters, digits, whitespace) is ignored so descriptive prose like // "cannot contain the users first name, last name" does not leak the comma. const FORBIDDEN_CHAR_SET = new Set([ "`", "~", "<", ">", "\\", "|", "@", "#", "$", "%", "^", "&", "*", "(", ")", "[", "]", "{", "}", "+", "=", "?", "!", ";", ":", "/", '"', "'", ]); export function extractNearbyText(el: HTMLInputElement | null): string { if (!el) return ""; const parts: string[] = []; // aria-describedby may be a space-separated list of IDs const describedBy = el.getAttribute("aria-describedby"); if (describedBy) { for (const id of describedBy.trim().split(/\s+/)) { const desc = el.ownerDocument.getElementById(id); if (desc?.textContent) parts.push(desc.textContent); } } // Field-level attributes that sometimes carry requirements if (el.title) parts.push(el.title); if (el.placeholder) parts.push(el.placeholder); const ariaLabel = el.getAttribute("aria-label"); if (ariaLabel) parts.push(ariaLabel); const HINT_SELECTOR = "p, span, li, small, label, div, .hint, .requirement, .password-rule, .password-hint, .password-rules, .field-hint, .helper-text, .input-hint"; let node: Element | null = el; for (let i = 0; i < 5; i++) { node = node?.parentElement ?? null; if (!node) break; const hints = node.querySelectorAll(HINT_SELECTOR); if (hints.length > 0) { parts.push(Array.from(hints).map((h) => h.textContent).join(" ")); break; } } return parts.filter(Boolean).join(" "); } export function detectPasswordPolicy(doc: Document = document): PasswordPolicy { const policy: PasswordPolicy = {}; const passwordField = doc.querySelector( 'input[autocomplete="new-password"], input[type="password"]' ); const maxLen = passwordField?.maxLength ?? 0; if (maxLen > 0) policy.maxLength = maxLen; if (passwordField?.pattern) { const pat = passwordField.pattern; // .{N,M} — range; .{N,} — min only; .{N} — exact const rangeMatch = pat.match(/\{(\d+),(\d+)\}/); const minOnlyMatch = pat.match(/\{(\d+),\}/); const exactMatch = pat.match(/\{(\d+)\}/); if (rangeMatch) { policy.minLength = Number.parseInt(rangeMatch[1]!, 10); policy.maxLength = Number.parseInt(rangeMatch[2]!, 10); } else if (minOnlyMatch) { policy.minLength = Number.parseInt(minOnlyMatch[1]!, 10); } else if (exactMatch) { policy.minLength = Number.parseInt(exactMatch[1]!, 10); policy.maxLength = Number.parseInt(exactMatch[1]!, 10); } } const text = extractNearbyText(passwordField); // "8-20 characters", "8–20 characters", "8 - 20 chars" — most common real-world format const dashRangeMatch = text.match(/(\d+)\s*[-–—]\s*(\d+)\s*char/i); // "between 8 and 20", "between 8-20" const betweenMatch = text.match(/between\D*?(\d+)\D+?(\d+)/i); const minMatch = text.match(/(?:at least|minimum|min(?:imum)?|must be (?:at least )?)(?:\D*?)(\d+)/i); 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); const exactMatch2 = text.match(/(?:exactly|must be)\D*?(\d+)\s*characters?/i); // dash range takes priority as the most unambiguous format if (dashRangeMatch && !policy.minLength) policy.minLength = Number.parseInt(dashRangeMatch[1]!, 10); if (dashRangeMatch && !policy.maxLength) policy.maxLength = Number.parseInt(dashRangeMatch[2]!, 10); if (betweenMatch && !policy.minLength) policy.minLength = Number.parseInt(betweenMatch[1]!, 10); if (betweenMatch && !policy.maxLength) policy.maxLength = Number.parseInt(betweenMatch[2]!, 10); if (minMatch && !policy.minLength) policy.minLength = Number.parseInt(minMatch[1]!, 10); if (maxMatch && !policy.maxLength) policy.maxLength = Number.parseInt(maxMatch[1]!, 10); if (exactMatch2 && !policy.minLength) policy.minLength = Number.parseInt(exactMatch2[1]!, 10); if (exactMatch2 && !policy.maxLength) policy.maxLength = Number.parseInt(exactMatch2[1]!, 10); if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true; if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true; if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true; if (/number|digit/i.test(text)) policy.requiresNumbers = true; // Forbidden characters — scan a ~220-char window after the first negation // keyword and collect single-symbol tokens bounded by whitespace, commas, // backticks, or quotes. This tolerates the phrasings actually seen in the // wild ("cannot contain ` ~ < > \ characters", backtick-formatted code, // comma lists). Descriptive prose ("cannot contain the user's email") // does not contribute symbols because the only punctuation it includes // (comma, period, apostrophe) is boundary, not content. const negation = text.match( /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i ); if (negation) { const window = negation[0].slice(0, NEGATION_WINDOW); const seen = new Set(); const tokenRegex = /(?:^|[\s`"',.([])([!-/:-@[-`{-~])(?=[\s`"',.)\]]|$)/g; let m: RegExpExecArray | null; // biome-ignore lint/suspicious/noAssignInExpressions: standard RegExp.exec loop idiom while ((m = tokenRegex.exec(window)) !== null) { const ch = m[1]!; if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch); } if (seen.size > 0) policy.forbiddenChars = [...seen].join(""); } return policy; }