Newer
Older
gnexus-creds / extensions / extension / src / field-detect.js
/**
 * Login-field detection: pure scoring logic over "input descriptors".
 *
 * A descriptor is a plain object produced in content.js:
 *   {
 *     index,          // scan order among all inputs
 *     el,             // the actual <input> (DOM object; only structure is used)
 *     type, name, id, placeholder, autocomplete, ariaLabel, labelText,
 *     visible, disabled, readonly,
 *     formEl          // owning <form> or null
 *   }
 *
 * Everything here is string/number logic — no DOM API access beyond `contains`
 * — so tools/test-field-detect.js can exercise it with plain node.
 *
 * The haystack (name/id/placeholder/aria-label/label text) is normalized so
 * that any non-alphanumeric run becomes "_": "E-mail address" -> "e_mail",
 * "User Name" -> "user_name". Keywords then match on underscore boundaries.
 */
(function (root, factory) {
	const api = factory();
	if (typeof module !== "undefined" && module.exports) {
		module.exports = api;
	} else {
		root.GnCredsDetect = api;
	}
})(typeof self !== "undefined" ? self : globalThis, function () {
	"use strict";

	// Input types allowed as username carriers.
	const USERNAME_TYPES = new Set(["text", "", "email", "tel"]);
	// Input types allowed as password carriers (besides type="password").
	const PASSWORD_TEXT_TYPES = new Set(["text", "", "tel"]);

	// [keyword, weight] — best hits give up to ~6; forms usually label the
	// field 2-3 ways at once (name + label + placeholder), so one strong word
	// is enough to beat noise.
	const USERNAME_KEYWORDS = [
		["username", 6], ["user_name", 6], ["user_id", 4], ["userid", 4],
		["login", 5], ["log_in", 3], ["logon", 3], ["signin", 2],
		["email", 5], ["e_mail", 5], ["mail", 3], ["correo", 4], ["courriel", 4],
		["phone", 4], ["tel", 2], ["telefon", 4], ["mobile", 2],
		["account", 2], ["nick", 3], ["handle", 2], ["member", 2],
		["benutzer", 5], ["benutzername", 6], ["kennung", 3],
		["логин", 6], ["пользователь", 5], ["юзер", 4], ["почта", 5], ["телефон", 4],
		["usuario", 5], ["utilisateur", 4],
	];

	const PASSWORD_KEYWORDS = [
		["password", 8], ["passwd", 8], ["pwd", 8], ["pass", 7], ["pw", 5],
		["passwort", 8], ["motdepasse", 8], ["mot_de_passe", 8],
		["contrasena", 8], ["contrasenia", 8],["senha", 8], ["haslo", 6],
		["geslo", 6], ["пароль", 8], ["пассворд", 6],
	];

	// Word is present but the field is almost certainly not a login field.
	// -8 per hit beats one medium keyword (+8/password or +6/username).
	// The confirm/repeat group exists so that on a signup form the *real*
	// password wins over "confirm_password": we only autofill existing
	// accounts, so a repeat field should never become the fill target.
	const ANTI_KEYWORDS = [
		"cc", "card", "cvc", "cvv", "security_code", "search", "query", "filter",
		"coupon", "promo", "newsletter", "subscribe", "subject", "message",
		"comment", "first_name", "lastname", "last_name", "fname", "lname",
		"birth", "dob", "zip", "postal", "amount", "price", "total", "qty",
		"country", "city", "street", "address",
		"otp", "one_time", "onetime", "one_time_code", "verification",
		"confirm", "repeat", "retype", "again",
	];

	// Autocomplete attribute (normalized), exact token: [token, usernameKindWeight, passwordKindWeight]
	const AUTOCOMPLETE_TOKENS = {
		username: { username: 9, password: 0 },
		current_password: { username: 0, password: 9 },
		new_password: { username: 0, password: 7 },
		email: { username: 7, password: 0 },
		tel: { username: 3, password: 0 },
	};

	function normalize(text) {
		return String(text == null ? "" : text)
			.toLowerCase()
			.replace(/[^a-zа-яё0-9]+/g, "_");
	}

	// "(^|_)" — keyword boundaries survive normalization because separators
	// became underscores and tokens keep their own letters.
	function keywordRegex(keyword) {
		return new RegExp("(?:^|_)" + keyword + "(?:_|$)");
	}

	// Sum of the two strongest distinct keyword hits in the field's haystack.
	function keywordScore(haystack, keywords) {
		const hits = [];
		for (const [keyword, weight] of keywords) {
			if (keywordRegex(keyword).test(haystack)) hits.push(weight);
		}
		hits.sort((a, b) => b - a);
		return (hits[0] || 0) + (hits[1] || 0) * 0.5;
	}

	function autocompletePenalty(ac) {
		// one-time codes and card fields are never login credentials
		if (ac === "one_time_code" || ac.startsWith("cc_")) return -1000;
		return 0;
	}

	function autocompleteBonus(ac, kind) {
		const token = AUTOCOMPLETE_TOKENS[ac];
		return token ? token[kind] : 0;
	}

	function scoreUsername(field) {
		let score = 0;
		switch (field.type) {
			case "email": score += 5; break;
			case "tel": score += 2; break;
			case "text": case "": break;
			case "search": score -= 4; break;
			default: return -1000;
		}
		const ac = normalize(field.autocomplete);
		const penalty = autocompletePenalty(ac);
		if (penalty < 0) return penalty;
		score += autocompleteBonus(ac, "username");
		score += keywordScore(normalize(
			`${field.name} ${field.id} ${field.placeholder} ${field.ariaLabel} ${field.labelText}`
		), USERNAME_KEYWORDS);
		for (const anti of ANTI_KEYWORDS) {
			if (keywordRegex(anti).test(normalize(`${field.name} ${field.id}`))) score -= 8;
			// placeholder/labels mention search and coupons even on login pages,
			// so anti-words only count in name/id
		}
		if (!field.visible) score -= 40;
		if (field.disabled) score -= 100;
		if (field.readonly) score -= 15;
		return score;
	}

	function scorePassword(field) {
		let score = 0;
		if (field.type === "password") score += 12;
		else if (PASSWORD_TEXT_TYPES.has(field.type)) { /* text/tel with a password hint */ }
		else return -1000;
		const ac = normalize(field.autocomplete);
		const penalty = autocompletePenalty(ac);
		if (penalty < 0) return penalty;
		score += autocompleteBonus(ac, "password");
		score += keywordScore(normalize(
			`${field.name} ${field.id} ${field.placeholder} ${field.ariaLabel} ${field.labelText}`
		), PASSWORD_KEYWORDS);
		for (const anti of ANTI_KEYWORDS) {
			if (keywordRegex(anti).test(normalize(`${field.name} ${field.id}`))) score -= 8;
		}
		if (!field.visible) score -= 50;
		if (field.disabled) score -= 100;
		if (field.readonly) score -= 15;
		return score;
	}

	// Passwords qualify unconditionally on type="password" (the strongest
	// signal there is) or, for text/tel fields, on a single strong keyword
	// (e.g. type="text" with name="pass" — JS-revealed password inputs).
	function isPasswordCandidate(field) {
		if (field.type === "password") return scorePassword(field) > -1000;
		if (!PASSWORD_TEXT_TYPES.has(field.type)) return false;
		return scorePassword(field) >= 7;
	}

	// The username is searched inside the form; for SPA inputs without a
	// <form>, walk up to three ancestor levels before giving up.
	function findContextFor(field, fields) {
		if (field.formEl) return field.formEl;
		let current = field.el.parentElement;
		for (let depth = 0; current && depth < 3; depth++) {
			const hasUsername = fields.some(
				(f) => f !== field && current.contains(f.el) && scoreUsername(f) > 0
			);
			if (hasUsername) return current;
			current = current.parentElement;
		}
		return null;
	}

	function pickUsername(candidates, password) {
		const eligible = candidates.filter(
			(f) =>
				f !== password &&
				f.usernameScore > 0 &&
				USERNAME_TYPES.has(f.type)
		);
		if (eligible.length) {
			// strongest signal wins; ties break toward DOM order (above password)
			return eligible.sort(
				(a, b) => b.usernameScore - a.usernameScore || a.index - b.index
			)[0];
		}
		// Form has no identifying hints: fall back to the closest text-like
		// visible input preceding the password field.
		const fallback = candidates
			.filter(
				(f) =>
					f !== password &&
					USERNAME_TYPES.has(f.type) &&
					f.visible &&
					!f.disabled &&
					f.index < password.index
			)
			.sort((a, b) => b.index - a.index)[0];
		return fallback || null;
	}

	function findLoginTargets(fields) {
		const scored = fields.map((field) => ({
			...field,
			usernameScore: scoreUsername(field),
			passwordScore: scorePassword(field),
		}));
		const candidates = scored
			.filter((f) => isPasswordCandidate(f))
			.sort((a, b) => b.passwordScore - a.passwordScore || a.index - b.index);

		const usedContexts = new Set(); // one card per form
		const usedUsernames = new Set();
		const targets = [];

		for (const password of candidates) {
			const context = findContextFor(password, scored);
			const contextKey = context || password.el;
			if (usedContexts.has(contextKey)) continue; // merged into the first password of the form
			usedContexts.add(contextKey);

			const inside = context ? scored.filter((f) => context.contains(f.el)) : [password];
			const username = pickUsername(inside.filter((f) => !usedUsernames.has(f.el)), password);
			if (username) usedUsernames.add(username.el);
			targets.push({ password, username: username || null, context: context || null });
		}

		// DOM order for stable card stacking
		return targets.sort((a, b) => a.password.index - b.password.index);
	}

	return {
		scoreUsername,
		scorePassword,
		isPasswordCandidate,
		findContextFor,
		pickUsername,
		findLoginTargets,
	};
});