Improve and refactor Autofill heuristics (#905)

* Add support for `AUTOFILL_HINT_NEW_PASSWORD` and
  `AUTOFILL_HINT_NEW_USERNAME`. This allows apps to trigger a
  `ClassifiedScenario` with only a generate password action and is the
  analogue of the W3C new-password hint for websites.
* Do not consider HTML password fields without hints to be certain
  password fields (they could contain e.g. bank account numbers,
  API secrets,...).
* Reduce OTP field false positives by excluding the term "postal" as well
  as fields that match the "code" heuristic term but have HTML maxLength
  less than 6 or larger than 8.
* Add German heuristic term "einmal" ("one-time") for OTP fields
* Also exclude fields based on their HTML name (e.g. for terms such as
  "search").
* Extract fieldId, hint and htmlName matches into an extension property.
* Reduce warnings and remove unnecessary suppression annotations.
This commit is contained in:
Fabian Henneke 2020-07-01 09:22:41 +02:00 committed by GitHub
parent 82a9a61254
commit eaaa3eeea8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 61 additions and 37 deletions

View file

@ -30,18 +30,23 @@ val autofillStrategy = strategy {
// TODO: Introduce a custom fill/generate/update flow for this scenario // TODO: Introduce a custom fill/generate/update flow for this scenario
rule { rule {
newPassword { newPassword {
takePair { all { hasAutocompleteHintNewPassword } } takePair { all { hasHintNewPassword } }
breakTieOnPair { any { isFocused } } breakTieOnPair { any { isFocused } }
} }
currentPassword(optional = true) { currentPassword(optional = true) {
takeSingle { alreadyMatched -> takeSingle { alreadyMatched ->
val adjacentToNewPasswords = val adjacentToNewPasswords =
directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched) directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
hasAutocompleteHintCurrentPassword && adjacentToNewPasswords // The Autofill framework has not hint that applies to current passwords only.
// In this scenario, we have already matched fields a pair of fields with a specific
// new password hint, so we take a generic Autofill password hint to mean a current
// password.
(hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) &&
adjacentToNewPasswords
} }
} }
username(optional = true) { username(optional = true) {
takeSingle { hasAutocompleteHintUsername } takeSingle { hasHintUsername }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused } breakTieOnSingle { isFocused }
} }
@ -73,7 +78,7 @@ val autofillStrategy = strategy {
breakTieOnSingle { isFocused } breakTieOnSingle { isFocused }
} }
username(optional = true) { username(optional = true) {
takeSingle { hasAutocompleteHintUsername } takeSingle { hasHintUsername }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) } breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused } breakTieOnSingle { isFocused }
} }
@ -115,7 +120,7 @@ val autofillStrategy = strategy {
// field. // field.
rule(applyInSingleOriginMode = true) { rule(applyInSingleOriginMode = true) {
newPassword { newPassword {
takeSingle { hasAutocompleteHintNewPassword && isFocused } takeSingle { hasHintNewPassword && isFocused }
} }
username(optional = true) { username(optional = true) {
takeSingle { alreadyMatched -> takeSingle { alreadyMatched ->
@ -157,7 +162,7 @@ val autofillStrategy = strategy {
// filling of hidden password fields to scenarios where this is clearly warranted. // filling of hidden password fields to scenarios where this is clearly warranted.
rule { rule {
username { username {
takeSingle { hasAutocompleteHintUsername && isFocused } takeSingle { hasHintUsername && isFocused }
} }
currentPassword(matchHidden = true) { currentPassword(matchHidden = true) {
takeSingle { alreadyMatched -> takeSingle { alreadyMatched ->
@ -178,7 +183,7 @@ val autofillStrategy = strategy {
username { username {
takeSingle { usernameCertainty >= Likely && isFocused } takeSingle { usernameCertainty >= Likely && isFocused }
breakTieOnSingle { usernameCertainty >= Certain } breakTieOnSingle { usernameCertainty >= Certain }
breakTieOnSingle { hasAutocompleteHintUsername } breakTieOnSingle { hasHintUsername }
} }
} }

View file

@ -31,16 +31,24 @@ class FormField(
companion object { companion object {
@RequiresApi(Build.VERSION_CODES.O) private val HINTS_USERNAME = listOf(
private val HINTS_USERNAME = listOf(HintConstants.AUTOFILL_HINT_USERNAME) HintConstants.AUTOFILL_HINT_USERNAME,
HintConstants.AUTOFILL_HINT_NEW_USERNAME
)
@RequiresApi(Build.VERSION_CODES.O) private val HINTS_NEW_PASSWORD = listOf(
private val HINTS_PASSWORD = listOf(HintConstants.AUTOFILL_HINT_PASSWORD) HintConstants.AUTOFILL_HINT_NEW_PASSWORD
)
@RequiresApi(Build.VERSION_CODES.O) private val HINTS_PASSWORD = HINTS_NEW_PASSWORD + listOf(
private val HINTS_OTP = listOf(HintConstants.AUTOFILL_HINT_SMS_OTP) HintConstants.AUTOFILL_HINT_PASSWORD
)
@RequiresApi(Build.VERSION_CODES.O) private val HINTS_OTP = listOf(
HintConstants.AUTOFILL_HINT_SMS_OTP
)
@Suppress("DEPRECATION")
private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf( private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf(
HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS, HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS,
HintConstants.AUTOFILL_HINT_NAME, HintConstants.AUTOFILL_HINT_NAME,
@ -86,7 +94,9 @@ class FormField(
"url_bar", // Chrome/Edge/Firefox address bar "url_bar", // Chrome/Edge/Firefox address bar
"url_field", // Opera address bar "url_field", // Opera address bar
"location_bar_edit_text", // Samsung address bar "location_bar_edit_text", // Samsung address bar
"search", "find", "captcha" "search", "find", "captcha",
"postal" // Prevent postal code fields from being mistaken for OTP fields
) )
private val PASSWORD_HEURISTIC_TERMS = listOf( private val PASSWORD_HEURISTIC_TERMS = listOf(
"pass", "pswd", "pwd" "pass", "pswd", "pwd"
@ -95,10 +105,18 @@ class FormField(
"alias", "e-mail", "email", "login", "user" "alias", "e-mail", "email", "login", "user"
) )
private val OTP_HEURISTIC_TERMS = listOf( private val OTP_HEURISTIC_TERMS = listOf(
"code", "otp" "einmal", "otp"
)
private val OTP_WEAK_HEURISTIC_TERMS = listOf(
"code"
) )
} }
private val List<String>.anyMatchesFieldInfo
get() = any {
fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
}
val autofillId: AutofillId = node.autofillId!! val autofillId: AutofillId = node.autofillId!!
// Information for heuristics and exclusion rules based only on the current field // Information for heuristics and exclusion rules based only on the current field
@ -151,7 +169,8 @@ class FormField(
private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList() private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList()
private val excludedByAutofillHints = private val excludedByAutofillHints =
if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty() if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty()
private val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty() val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty()
private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty()
private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty() private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty()
private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty() private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty()
@ -160,12 +179,18 @@ class FormField(
// Ignored for now, see excludedByHints // Ignored for now, see excludedByHints
private val excludedByAutocompleteHint = htmlAutocomplete == "off" private val excludedByAutocompleteHint = htmlAutocomplete == "off"
val hasAutocompleteHintUsername = htmlAutocomplete == "username" private val hasAutocompleteHintUsername = htmlAutocomplete == "username"
val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password" val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password"
val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password" private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
private val hasAutocompleteHintPassword = private val hasAutocompleteHintPassword =
hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code" private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code"
// Results of hint-based field type detection
val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername
val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword
val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword
val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp
// Basic autofill exclusion checks // Basic autofill exclusion checks
private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT
@ -191,40 +216,34 @@ class FormField(
val relevantField = isTextField && hasAutofillTypeText && !excludedByHints val relevantField = isTextField && hasAutofillTypeText && !excludedByHints
// Exclude fields based on hint and resource ID // Exclude fields based on hint, resource ID or HTML name.
// Note: We still report excluded fields as relevant since they count for adjacency heuristics, // Note: We still report excluded fields as relevant since they count for adjacency heuristics,
// but ensure that they are never detected as password or username fields. // but ensure that they are never detected as password or username fields.
private val hasExcludedTerm = EXCLUDED_TERMS.any { fieldId.contains(it) || hint.contains(it) } private val hasExcludedTerm = EXCLUDED_TERMS.anyMatchesFieldInfo
private val notExcluded = relevantField && !hasExcludedTerm private val notExcluded = relevantField && !hasExcludedTerm
// Password field heuristics (based only on the current field) // Password field heuristics (based only on the current field)
private val isPossiblePasswordField = private val isPossiblePasswordField =
notExcluded && (isAndroidPasswordField || isHtmlPasswordField) notExcluded && (isAndroidPasswordField || isHtmlPasswordField)
private val isCertainPasswordField = private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword
isPossiblePasswordField && (isHtmlPasswordField || hasAutofillHintPassword || hasAutocompleteHintPassword) private val isLikelyPasswordField = isPossiblePasswordField &&
private val isLikelyPasswordField = isPossiblePasswordField && (isCertainPasswordField || (PASSWORD_HEURISTIC_TERMS.any { (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo)
fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
}))
val passwordCertainty = val passwordCertainty =
if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible
// OTP field heuristics (based only on the current field) // OTP field heuristics (based only on the current field)
private val isPossibleOtpField = notExcluded && !isPossiblePasswordField && isTextField private val isPossibleOtpField = notExcluded && !isPossiblePasswordField && isTextField
private val isCertainOtpField = private val isCertainOtpField = isPossibleOtpField && hasHintOtp
isPossibleOtpField && (hasAutofillHintOtp || hasAutocompleteHintOtp || htmlMaxLength in 6..8) private val isLikelyOtpField = isPossibleOtpField && (
private val isLikelyOtpField = isPossibleOtpField && (isCertainOtpField || OTP_HEURISTIC_TERMS.any { isCertainOtpField || OTP_HEURISTIC_TERMS.anyMatchesFieldInfo ||
fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo))
})
val otpCertainty = val otpCertainty =
if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible
// Username field heuristics (based only on the current field) // Username field heuristics (based only on the current field)
private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField && isTextField private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField && isTextField
private val isCertainUsernameField = private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername
isPossibleUsernameField && (hasAutofillHintUsername || hasAutocompleteHintUsername) private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo))
private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.any {
fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
}))
val usernameCertainty = val usernameCertainty =
if (isCertainUsernameField) CertaintyLevel.Certain else if (isLikelyUsernameField) CertaintyLevel.Likely else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible if (isCertainUsernameField) CertaintyLevel.Certain else if (isLikelyUsernameField) CertaintyLevel.Likely else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible