Regex Anchor Precedence
ID |
kotlin.regex_anchor_precedence |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Reliability |
Language |
Kotlin |
Tags |
code-style |
Description
Reports an alternation whose first branch starts with ^/\A or whose last branch ends with $/\Z/\z, while at least one other branch of the same alternation carries no such anchor.
The pattern text is read from the Regex(…) constructor, from the receiver of String.toRegex(), and from Pattern.compile(…) / Pattern.matches(…) reached through Java interop. A pattern built at run time — a concatenation, an interpolated string, a variable — is not reported, since the string the engine compiles is not known here.
Rationale
Anchors bind tighter than |, so ^a|b|c$ is not "match a, b or c, anchored at both ends" — it parses as (^a)|b|(c$):
val re = Regex("^a|b|c$")
// "a" only matches at the very start, "c" only at the very end,
// but "b" matches anywhere in the input
The layout suggests every branch is bounded; only the outermost ones actually are. For a validation regex that means it accepts input it was written to reject.
Kotlin’s Regex compiles to the same engine as java.util.regex, so \A, \Z and \z are input-boundary assertions here and count as anchors alongside ^ and $.
Remediation
Wrap the alternatives in a non-capturing group so the anchors apply to the whole thing, or repeat the anchors on every branch if each was really meant to be anchored on its own:
val re = Regex("^(?:a|b|c)$")
// or
val re = Regex("^a$|^b$|^c$")
What is not reported
An alternation where every branch carries the same anchor on its own (a$|b$|^c$) is not reported — there is no gap between branches to be misled by. An alternation already wrapped in a group (^(?:a|b|c)$), or one whose anchored branches are each explicitly scoped in their own group ((?:^a)|b|(?:c$)), reads exactly as intended and is not reported either. An alternation with no anchor at either end is out of scope.
Two idioms that look like the defect are also exempt: a branch consisting of nothing but the anchor — Regex("(?:,|$)"), the deliberate "this or the boundary" form — and the two-branch trim shape ^A|B$, as in text.replace(Regex("^,|,$"), ""), where each branch is already anchored at the edge it is meant to strip.
Finally, String.replace("^a|b$", "-") is not reported: that overload takes a plain string and replaces it literally, so no regular expression is involved. The same holds for String.split(vararg String) and String.replaceFirst(String, String), and for Regex.fromLiteral(…), which matches its argument as plain text.