From 648ed91a5476c027a697d607baae25f04072f610 Mon Sep 17 00:00:00 2001 From: Erwan Le Forestier Date: Thu, 5 Nov 2026 15:43:48 +0000 Subject: [PATCH 1/6] ACOMMONS-231 Add IpAddressCandidateExtractor.extractAll to find embedded IP address candidates `extract` interprets the whole literal as one address, endpoint, URL, or list and returns only the first candidate from its beginning, so addresses in path segments or surrounding text, such as "/registry/ipaddresses/192.168.2.3", are missed. `extractAll` scans the whole content for address-shaped tokens and returns every candidate in order of appearance. It strips brackets and ports, preserves zones and bare CIDR suffixes, treats URL hosts' slash suffixes as paths, and skips tokens embedded in words, dotted sequences (OIDs), hyphenated names, user information, and User-Agent product versions such as "Chrome/120.0.0.0". `extract` keeps its existing behavior. Co-Authored-By: Claude Opus 5.5 --- commons/README.md | 2 +- .../appsec/IpAddressCandidateExtractor.java | 171 +++++++++++++++++- .../IpAddressCandidateExtractorTest.java | 160 ++++++++++++++++ 3 files changed, 331 insertions(+), 2 deletions(-) diff --git a/commons/README.md b/commons/README.md index c82f072a..1939096d 100644 --- a/commons/README.md +++ b/commons/README.md @@ -11,7 +11,7 @@ Logic useful for an average language plugin * [`ExternalReportProvider`](./src/main/java/org/sonarsource/analyzer/commons/ExternalReportProvider.java) - to get the list of io.File with external reports * [`ProgressReport`](./src/main/java/org/sonarsource/analyzer/commons/ProgressReport.java) - to produce logs with number of analyzed files * [`FileProvider`](./src/main/java/org/sonarsource/analyzer/commons/FileProvider.java) - to get files matching given pattern in the given directory -* [`IpAddressCandidateExtractor`](./src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java) - to extract IP address candidates from bare addresses, endpoints, and URLs +* [`IpAddressCandidateExtractor`](./src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java) - to extract IP address candidates from bare addresses, endpoints, and URLs, or all candidates embedded anywhere in a string * [`IpAddressClassifier`](./src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressClassifier.java) - to parse numeric IP addresses and CIDRs and query their classification ### License diff --git a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java index 1e2814a1..75a0e675 100644 --- a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java +++ b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java @@ -16,11 +16,22 @@ */ package org.sonarsource.analyzer.commons.appsec; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** Heuristically extracts an IP address candidate string from a string literal's content. */ +/** + * Heuristically extracts IP address candidate strings from a string literal's content. + * + */ public final class IpAddressCandidateExtractor { private static final Pattern URL_PREFIX = Pattern.compile( @@ -28,6 +39,17 @@ public final class IpAddressCandidateExtractor { private static final Pattern ADDRESS_DELIMITER = Pattern.compile("[?#,\\s]", Pattern.UNICODE_CHARACTER_CLASS); private static final Pattern ASCII_DIGITS = Pattern.compile("\\d+"); + // Address-shaped tokens not embedded in a word, a dotted number such as an OID, or a hyphenated name. + // IPv6 tokens are tried first so that the IPv4 tail of a mixed IPv6 address is not reported separately. + // A trailing dot followed by a non-word character, such as a full stop, is not part of the token. + private static final Pattern EMBEDDED_ADDRESS = Pattern.compile( + "(?[0-9A-Fa-f]*+:[0-9A-Fa-f:]*+(?:\\.[0-9]++)*+)(?:%[\\w-]*+)?(?![\\w:%-]|\\.\\w)" + + "|(?[0-9]{1,3}(?:\\.[0-9]{1,3}){3})(?![\\w-]|\\.\\w)", + Pattern.UNICODE_CHARACTER_CLASS); + private static final Pattern IPV4_SHAPE = Pattern.compile("[0-9]{1,3}(?:\\.[0-9]{1,3}){3}"); + private static final Pattern HEX_GROUP = Pattern.compile("[0-9A-Fa-f]+"); + private static final int IPV6_GROUPS = 8; + private IpAddressCandidateExtractor() { } @@ -92,6 +114,153 @@ public static Optional extract(String content) { (stripped.portRemoved() ? "" : cidr)); } + /** + * Heuristically extracts all IP address candidates embedded anywhere in the given string, in order of appearance. + * Duplicates are retained. Returns an empty list if no candidate is found. + * + *

Unlike {@link #extract(String)}, the content is not interpreted as a single address, endpoint, or URL. + * Instead, it is scanned for address-shaped tokens, so candidates are also found in path segments + * ({@code /registry/ipaddresses/192.168.2.3}), surrounding text ({@code connect to 192.0.2.1 now}), + * and lists separated by any delimiter ({@code 192.0.2.1;192.0.2.2}). + * + * More specifically: + * + *

    + *
  • A candidate is either four dot-separated groups of one to three ASCII digits, or an IPv6 address shape: + * hexadecimal groups separated by colons, with at most one {@code ::}, eight groups (or seven when {@code ::} compresses some), + * and an optional dotted IPv4 tail counting as two groups. An optional IPv6 zone such as {@code %eth0} is preserved.
  • + *
  • A token adjacent to a letter, digit, underscore, or hyphen, or that is part of a longer dotted or colon-separated sequence, + * is not a candidate. For example, {@code v1.2.3.4}, {@code 1.3.6.1.4.1}, {@code host-192.0.2.1}, and {@code 12:30:45} + * yield no candidate. A dot followed by a non-word character, such as a full stop, ends the candidate.
  • + *
  • Enclosing brackets and ports are not part of the candidate, for example in {@code [2001:db8::1]:8080} or {@code 192.0.2.1:8080}.
  • + *
  • A slash suffix of one to three ASCII digits is preserved as a CIDR prefix, unless it follows a port + * or the address is a URL host or user information host (preceded by {@code //} or {@code @}), + * where it is a path. As with {@link #extract(String)}, out-of-range prefixes such as {@code /33} are preserved.
  • + *
  • An address directly followed by {@code @} is skipped as user information, as in {@code http://192.0.2.1@example.org}.
  • + *
  • An IPv4 address that is the version of a product token, such as {@code Chrome/120.0.0.0} in a User-Agent string, + * is skipped. A product token is a single name starting with an ASCII letter at the beginning of the content + * or after whitespace, an opening parenthesis, a comma, or a semicolon, directly followed by {@code /} and the address. + * A slash-separated path such as {@code /registry/192.168.2.3} is not a product token.
  • + *
+ * + *

Candidates are syntactically shaped like IP addresses but not validated: + * octets such as {@code 999}, leading zeros, or CIDR prefixes may be out of range, and other dotted quads, such as + * four-part version numbers, may be returned. Use {@link IpAddressClassifier#parse(String)} to validate and parse each candidate. + * Use {@link #extract(String)} when the whole content is expected to be a single address and malformed content should + * remain observable as an unparseable candidate. + */ + public static List extractAll(String content) { + List candidates = new ArrayList<>(); + Matcher matcher = EMBEDDED_ADDRESS.matcher(content); + int from = 0; + while (from < content.length() && matcher.find(from)) { + from = matcher.end(); + String ipv6 = matcher.group("ipv6"); + if (ipv6 != null && !isIpv6Shaped(ipv6)) { + // Retry inside the rejected token, for example for the IPv4 address in "dc:192.0.2.1". + from = matcher.start() + 1; + continue; + } + embeddedCandidate(content, matcher).ifPresent(candidates::add); + } + return Collections.unmodifiableList(candidates); + } + + private static Optional embeddedCandidate(String content, Matcher matcher) { + int start = matcher.start(); + int end = matcher.end(); + boolean bracketed = start > 0 && content.charAt(start - 1) == '[' && end < content.length() && content.charAt(end) == ']'; + int before = bracketed ? (start - 1) : start; + int after = bracketed ? (end + 1) : end; + + if (charAt(content, after) == '@' || (matcher.group("ipv4") != null && isProductVersion(content, start))) { + return Optional.empty(); + } + + String candidate = matcher.group(); + // A port (":") right after the address excludes a CIDR suffix, and URL hosts are followed by paths. + boolean urlHost = charAt(content, before - 1) == '@' || (charAt(content, before - 1) == '/' && charAt(content, before - 2) == '/'); + if (!urlHost && charAt(content, after) == '/') { + int digitsEnd = after + 1; + while (digitsEnd < content.length() && content.charAt(digitsEnd) >= '0' && content.charAt(digitsEnd) <= '9') { + digitsEnd++; + } + // Three digits cover both IPv4 and IPv6 prefixes. + int digits = digitsEnd - after - 1; + if (digits >= 1 && digits <= 3 && !isWordCharacter(content, digitsEnd)) { + candidate += content.substring(after, digitsEnd); + } + } + return Optional.of(candidate); + } + + private static boolean isIpv6Shaped(String token) { + String hexPart = token; + int groups = 0; + if (token.indexOf('.') >= 0) { + int lastColon = token.lastIndexOf(':'); + if (!IPV4_SHAPE.matcher(token.substring(lastColon + 1)).matches()) { + return false; + } + // The IPv4 tail takes the place of the last two groups. Keep its colon so a preceding "::" stays intact. + hexPart = token.substring(0, lastColon + 1) + "0"; + groups = 1; + } + int compression = hexPart.indexOf("::"); + if (compression >= 0 && hexPart.indexOf("::", compression + 1) >= 0) { + return false; + } + String head = compression >= 0 ? hexPart.substring(0, compression) : hexPart; + String tail = compression >= 0 ? hexPart.substring(compression + 2) : ""; + for (String part : new String[] {head, tail}) { + if (!part.isEmpty()) { + for (String group : part.split(":", -1)) { + if (!HEX_GROUP.matcher(group).matches()) { + return false; + } + groups++; + } + } + } + return compression >= 0 ? (groups < IPV6_GROUPS) : (groups == IPV6_GROUPS); + } + + private static boolean isProductVersion(String content, int addressStart) { + int slash = addressStart - 1; + if (charAt(content, slash) != '/') { + return false; + } + int nameStart = slash; + while (nameStart > 0 && isProductNameCharacter(content.charAt(nameStart - 1))) { + nameStart--; + } + char first = charAt(content, nameStart); + char preceding = charAt(content, nameStart - 1); + return nameStart < slash && isAsciiLetter(first) + && (nameStart == 0 || Character.isWhitespace(preceding) || "(,;".indexOf(preceding) >= 0); + } + + private static boolean isProductNameCharacter(char c) { + return isAsciiLetter(c) || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == '-'; + } + + private static boolean isAsciiLetter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + private static boolean isWordCharacter(String content, int index) { + if (index >= content.length()) { + return false; + } + int codePoint = content.codePointAt(index); + return Character.isLetterOrDigit(codePoint) || codePoint == '_'; + } + + /** Returns the character at the given index, or {@code '\0'} if the index is out of bounds. */ + private static char charAt(String content, int index) { + return (index >= 0 && index < content.length()) ? content.charAt(index) : '\0'; + } + private static Optional stripBracketsAndPort(String candidate) { // Brackets distinguish IPv6 address colons from the port separator. String address = candidate; diff --git a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java index 74c3b547..eb38ae65 100644 --- a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java +++ b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java @@ -16,8 +16,14 @@ */ package org.sonarsource.analyzer.commons.appsec; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; @@ -170,4 +176,158 @@ void couldProduceNonIpAddressResult(String content, String expected) { void shouldRejectMissingCandidateOrMalformedEndpoint(String content) { assertThat(IpAddressCandidateExtractor.extract(content)).isEmpty(); } + + @ParameterizedTest + @MethodSource + void shouldExtractAllCandidates(String content, List expected) { + assertThat(IpAddressCandidateExtractor.extractAll(content)).containsExactlyElementsOf(expected); + } + + static Stream shouldExtractAllCandidates() { + return Stream.of( + // Path segments and surrounding text + candidates("/registry/ipaddresses/192.168.2.3", "192.168.2.3"), + candidates("registry/ipaddresses/192.168.2.3/status", "192.168.2.3"), + candidates("connect to 192.0.2.1 now", "192.0.2.1"), + candidates("Connect to 192.0.2.1.", "192.0.2.1"), // Full stop after the address + candidates("Connect to ::1.", "::1"), + candidates("host=192.0.2.1&port=80", "192.0.2.1"), + candidates("host:192.0.2.1", "192.0.2.1"), + candidates("dc:192.0.2.1", "192.0.2.1"), // Not an IPv6 address; the IPv4 address is still found + candidates("12:30:192.0.2.1", "192.0.2.1"), + candidates("1:2:3:4:5:6:7:192.0.2.1", "192.0.2.1"), // Too many groups for IPv6 + candidates("1:2.3.4.5", "2.3.4.5"), + candidates("'192.0.2.1'", "192.0.2.1"), + candidates("(192.0.2.1)", "192.0.2.1"), + candidates("\"2001:db8::1\"", "2001:db8::1"), + candidates(" 192.0.2.1", "192.0.2.1"), // Leading whitespace + candidates("[192.0.2.1]", "192.0.2.1"), + candidates("Server 10.0.0.1: connection refused", "10.0.0.1"), + candidates("ip 192.0.2.1", "192.0.2.1"), + // Lists with any delimiter + candidates("192.0.2.1,192.0.2.2", "192.0.2.1", "192.0.2.2"), + candidates("192.0.2.1;192.0.2.2", "192.0.2.1", "192.0.2.2"), + candidates("192.0.2.1 | 2001:db8::1 | ::ffff:192.0.2.3", "192.0.2.1", "2001:db8::1", "::ffff:192.0.2.3"), + candidates("[\"10.0.0.1\", \"10.0.0.2\", \"10.0.0.1\"]", "10.0.0.1", "10.0.0.2", "10.0.0.1"), // Duplicates are retained + candidates("http://192.0.2.1:8080;https://192.0.2.2/other", "192.0.2.1", "192.0.2.2"), + candidates("http://192.0.2.1,https://[2001:db8::1]:8443/path", "192.0.2.1", "2001:db8::1"), + candidates("from 10.0.0.1 to 10.0.0.255", "10.0.0.1", "10.0.0.255"), + // URLs: user information, paths, queries + candidates("http://user:pass@192.0.2.1:8080/path", "192.0.2.1"), + candidates("http://192.0.2.1/path@192.0.2.2", "192.0.2.1", "192.0.2.2"), + candidates("http://example.org/?next=http://192.0.2.1/7", "192.0.2.1"), + candidates("https://example.org/proxy/192.0.2.1/status?ip=198.51.100.1#203.0.113.1", "192.0.2.1", "198.51.100.1", "203.0.113.1"), + candidates("mailto:admin@192.0.2.1", "192.0.2.1"), + // Ports and brackets are removed + candidates("192.0.2.1:8080", "192.0.2.1"), + candidates("[2001:db8::1]:8080", "2001:db8::1"), + candidates("http://[2001:db8::1%eth0]:8080/path", "2001:db8::1%eth0"), + candidates("http://[fe80::1%25en0]/", "fe80::1%25en0"), + candidates("2001:db8::1%", "2001:db8::1%"), + // CIDR suffixes + candidates("allow 10.0.0.0/8 and 2001:db8::/32", "10.0.0.0/8", "2001:db8::/32"), + candidates("/registry/10.0.0.0/8", "10.0.0.0/8"), + candidates("[2001:db8::1]/64", "2001:db8::1/64"), + candidates("[2001:db8::1%接口]/64", "2001:db8::1%接口/64"), + candidates("10.0.0.0/8.", "10.0.0.0/8"), + candidates("192.0.2.1/33", "192.0.2.1/33"), // Out-of-range prefixes are preserved, like extract + candidates("192.0.2.1/1000", "192.0.2.1"), // Overlong suffixes are paths + candidates("192.0.2.1/24abc", "192.0.2.1"), + candidates("192.0.2.1/33", "192.0.2.1"), // Non-ASCII digits + candidates("192.0.2.1/", "192.0.2.1"), + candidates("192.0.2.1:8080/24", "192.0.2.1"), // Slash after a port starts a path + candidates("[2001:db8::1]:8080/64", "2001:db8::1"), + candidates("http://127.0.0.1/7", "127.0.0.1"), // URL hosts are followed by paths + candidates("//[2001:db8::1]/64", "2001:db8::1"), + candidates("http://user@192.0.2.1/24", "192.0.2.1"), + // IPv6 shapes + candidates("::", "::"), + candidates("::00000:2", "::00000:2"), + candidates("1:2:3:4:5:6:7:8", "1:2:3:4:5:6:7:8"), + candidates("1:2:3:4:5:6:192.0.2.1", "1:2:3:4:5:6:192.0.2.1"), + candidates("1:2:3:4:5:6:7::", "1:2:3:4:5:6:7::"), + candidates("::FFFF:7f00:1", "::FFFF:7f00:1"), + // Candidates are not validated + candidates("999.2.3.4", "999.2.3.4"), + candidates("01.02.03.04", "01.02.03.04"), + candidates("1.0.0.0", "1.0.0.0") // Possibly a version number + ); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "no address here", + "https://example.com/path", + "v1.2.3.4", // Version prefix + "1.2.3.4.5", // Too many groups + "1.3.6.1.4.1.311", // OID + "1.2.3", // Too few groups + "1234.2.3.4", // Too many digits + "192.0.2.1234", + "prefix192.0.2.1", + "192.0.2.1suffix", + "host-192.0.2.1", + "192.0.2.1-SNAPSHOT", + "my_192.0.2.1", + "接口192.0.2.1", + "192.0.2.1接口", + "http://192.0.2.1@example.org/path", // User information + "user.192.0.2.1", + // Product tokens + "Chrome/120.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "(Agent/1.2.3.4)", + "a,Agent_1.0/1.2.3.4", + // Colon-separated tokens that are not IPv6 addresses + "12:30:45", + "00:1a:2b:3c:4d:5e", // MAC address + "1:2:3:4:5:6:7:8:9", + "1::2::3", + ":::1", + "1:2:3:4:5:6:7::8", + "::ffff:1.2.3", + "::ffff:192.0.2.1.5", + ":1:2:3:4:5:6:7", + "1:2:3:4:5:6:7:", + "std::vector", + "Foo::bar", + "::std", + "x2001:db8::1", + "2001:db8::1x", + "2001:db8::g" + }) + void shouldExtractNoCandidates(String content) { + assertThat(IpAddressCandidateExtractor.extractAll(content)).isEmpty(); + } + + @Test + void shouldReturnUnmodifiableList() { + List candidates = IpAddressCandidateExtractor.extractAll("192.0.2.1"); + assertThat(candidates).isUnmodifiable(); + } + + @ParameterizedTest + @ValueSource(strings = { + "192.0.2.1:65535", + "[2001:db8::1]:65535", + "http://user:pass@[2001:db8::1]:8080/path", + "http://192.0.2.1,user:pass@192.0.2.2/path", + "https://[2001:db8::192.0.2.1]:8080/path", + "192.0.2.1/032", + "2001:db8::1/128", + "[::ffff:192.0.2.1]", + "http://192.0.2.1?redirect=https://user@example.org/path?a=1&b=2", + "192.0.2.1\u00a0rest", + "[2001:db8::1%接口]/64" + }) + void extractAllShouldIncludeParseableExtractResult(String content) { + String extracted = IpAddressCandidateExtractor.extract(content).orElseThrow(); + assertThat(IpAddressClassifier.parse(extracted)).isPresent(); + assertThat(IpAddressCandidateExtractor.extractAll(content)).contains(extracted); + } + + private static Arguments candidates(String content, String... expected) { + return Arguments.of(content, Arrays.asList(expected)); + } } From 8e88ca2dc18cdc7f741f674481abf8364a989cda Mon Sep 17 00:00:00 2001 From: Erwan Le Forestier Date: Thu, 5 Nov 2026 15:47:45 +0000 Subject: [PATCH 2/6] ACOMMONS-231 Skip URL user information in extractAll Addresses inside "//userinfo@" spans, such as "192.0.2.8" in "http://192.0.2.8:password@example.org/path/10.0.0.1", are credentials rather than hosts. The URL host, path, query, and fragment are still searched. Co-Authored-By: Claude Opus 5.5 --- .../appsec/IpAddressCandidateExtractor.java | 21 +++++++++++++++++-- .../IpAddressCandidateExtractorTest.java | 10 +++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java index 75a0e675..d2e0a542 100644 --- a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java +++ b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java @@ -17,6 +17,7 @@ package org.sonarsource.analyzer.commons.appsec; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -46,6 +47,8 @@ public final class IpAddressCandidateExtractor { "(?[0-9A-Fa-f]*+:[0-9A-Fa-f:]*+(?:\\.[0-9]++)*+)(?:%[\\w-]*+)?(?![\\w:%-]|\\.\\w)" + "|(?[0-9]{1,3}(?:\\.[0-9]{1,3}){3})(?![\\w-]|\\.\\w)", Pattern.UNICODE_CHARACTER_CLASS); + // URL authority user information, such as "user:pass" in "http://user:pass@host", using the same delimiters as URL_PREFIX. + private static final Pattern URL_USER_INFO = Pattern.compile("//([^/?#,@\\s]*)@", Pattern.UNICODE_CHARACTER_CLASS); private static final Pattern IPV4_SHAPE = Pattern.compile("[0-9]{1,3}(?:\\.[0-9]{1,3}){3}"); private static final Pattern HEX_GROUP = Pattern.compile("[0-9A-Fa-f]+"); private static final int IPV6_GROUPS = 8; @@ -136,7 +139,9 @@ public static Optional extract(String content) { *

  • A slash suffix of one to three ASCII digits is preserved as a CIDR prefix, unless it follows a port * or the address is a URL host or user information host (preceded by {@code //} or {@code @}), * where it is a path. As with {@link #extract(String)}, out-of-range prefixes such as {@code /33} are preserved.
  • - *
  • An address directly followed by {@code @} is skipped as user information, as in {@code http://192.0.2.1@example.org}.
  • + *
  • Addresses in URL user information are skipped, as in {@code http://192.0.2.1:pass@example.org} or + * {@code http://user:192.0.2.1@example.org}, while the URL host, path, query, and fragment are still searched. + * An address directly followed by {@code @} is also skipped as user information, as in {@code 192.0.2.1@example.org}.
  • *
  • An IPv4 address that is the version of a product token, such as {@code Chrome/120.0.0.0} in a User-Agent string, * is skipped. A product token is a single name starting with an ASCII letter at the beginning of the content * or after whitespace, an opening parenthesis, a comma, or a semicolon, directly followed by {@code /} and the address. @@ -151,6 +156,7 @@ public static Optional extract(String content) { */ public static List extractAll(String content) { List candidates = new ArrayList<>(); + boolean[] userInfo = userInfoCharacters(content); Matcher matcher = EMBEDDED_ADDRESS.matcher(content); int from = 0; while (from < content.length() && matcher.find(from)) { @@ -161,11 +167,22 @@ public static List extractAll(String content) { from = matcher.start() + 1; continue; } - embeddedCandidate(content, matcher).ifPresent(candidates::add); + if (!userInfo[matcher.start()]) { + embeddedCandidate(content, matcher).ifPresent(candidates::add); + } } return Collections.unmodifiableList(candidates); } + private static boolean[] userInfoCharacters(String content) { + boolean[] userInfo = new boolean[content.length()]; + Matcher matcher = URL_USER_INFO.matcher(content); + while (matcher.find()) { + Arrays.fill(userInfo, matcher.start(1), matcher.end(1), true); + } + return userInfo; + } + private static Optional embeddedCandidate(String content, Matcher matcher) { int start = matcher.start(); int end = matcher.end(); diff --git a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java index eb38ae65..f1f1a02c 100644 --- a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java +++ b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java @@ -215,6 +215,16 @@ static Stream shouldExtractAllCandidates() { // URLs: user information, paths, queries candidates("http://user:pass@192.0.2.1:8080/path", "192.0.2.1"), candidates("http://192.0.2.1/path@192.0.2.2", "192.0.2.1", "192.0.2.2"), + // URL user information is skipped; the host, path, query, and fragment are still searched + candidates("http://192.0.2.8:password@example.org/path/10.0.0.1", "10.0.0.1"), + candidates("http://user:192.0.2.8@example.org/path"), + candidates("http://192.0.2.8:pass@192.0.2.9:8080/10.0.0.1?ip=10.0.0.2#10.0.0.3", "192.0.2.9", "10.0.0.1", "10.0.0.2", "10.0.0.3"), + candidates("//192.0.2.8:pass@[2001:db8::1]/path", "2001:db8::1"), + candidates("ftp://2001:db8::8:pass@example.org/192.0.2.1", "192.0.2.1"), + candidates("http://a@b@192.0.2.1", "192.0.2.1"), // The last @ ends the user information + candidates("http://example.org/path?u=//192.0.2.8:p@host"), + candidates("http://192.0.2.1,user:pass@192.0.2.2/path", "192.0.2.1", "192.0.2.2"), // Comma ends a URL list entry + candidates("http://192.0.2.1:8080 user:pass@192.0.2.2", "192.0.2.1", "192.0.2.2"), candidates("http://example.org/?next=http://192.0.2.1/7", "192.0.2.1"), candidates("https://example.org/proxy/192.0.2.1/status?ip=198.51.100.1#203.0.113.1", "192.0.2.1", "198.51.100.1", "203.0.113.1"), candidates("mailto:admin@192.0.2.1", "192.0.2.1"), From 66116524064c6b0b4f8858b2de5cbf23a69aa2e7 Mon Sep 17 00:00:00 2001 From: Erwan Le Forestier Date: Thu, 5 Nov 2026 15:57:37 +0000 Subject: [PATCH 3/6] ACOMMONS-231 Simplify extractAll scanning to address Sonar findings Replace the lookaround-heavy regular expression (complexity 46) with a scan of hex/colon/dot runs whose boundaries and shapes are checked in small helpers, and split complex methods. Reverse DNS names such as "1.2.0.192.in-addr.arpa" remain excluded, and the jar stays within the enforced size limit. Co-Authored-By: Claude Opus 5.5 --- .../appsec/IpAddressCandidateExtractor.java | 169 +++++++++++------- .../IpAddressCandidateExtractorTest.java | 6 + 2 files changed, 113 insertions(+), 62 deletions(-) diff --git a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java index d2e0a542..df539f70 100644 --- a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java +++ b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java @@ -40,16 +40,11 @@ public final class IpAddressCandidateExtractor { private static final Pattern ADDRESS_DELIMITER = Pattern.compile("[?#,\\s]", Pattern.UNICODE_CHARACTER_CLASS); private static final Pattern ASCII_DIGITS = Pattern.compile("\\d+"); - // Address-shaped tokens not embedded in a word, a dotted number such as an OID, or a hyphenated name. - // IPv6 tokens are tried first so that the IPv4 tail of a mixed IPv6 address is not reported separately. - // A trailing dot followed by a non-word character, such as a full stop, is not part of the token. - private static final Pattern EMBEDDED_ADDRESS = Pattern.compile( - "(?[0-9A-Fa-f]*+:[0-9A-Fa-f:]*+(?:\\.[0-9]++)*+)(?:%[\\w-]*+)?(?![\\w:%-]|\\.\\w)" + - "|(?[0-9]{1,3}(?:\\.[0-9]{1,3}){3})(?![\\w-]|\\.\\w)", - Pattern.UNICODE_CHARACTER_CLASS); + // Runs of characters that can form an IPv4 or IPv6 address. Boundaries and shapes are checked separately. + private static final Pattern ADDRESS_RUN = Pattern.compile("[0-9A-Fa-f:.]+"); // URL authority user information, such as "user:pass" in "http://user:pass@host", using the same delimiters as URL_PREFIX. private static final Pattern URL_USER_INFO = Pattern.compile("//([^/?#,@\\s]*)@", Pattern.UNICODE_CHARACTER_CLASS); - private static final Pattern IPV4_SHAPE = Pattern.compile("[0-9]{1,3}(?:\\.[0-9]{1,3}){3}"); + private static final Pattern IPV4_SHAPE = Pattern.compile("\\d{1,3}(?:\\.\\d{1,3}){3}"); private static final Pattern HEX_GROUP = Pattern.compile("[0-9A-Fa-f]+"); private static final int IPV6_GROUPS = 8; @@ -134,7 +129,8 @@ public static Optional extract(String content) { * and an optional dotted IPv4 tail counting as two groups. An optional IPv6 zone such as {@code %eth0} is preserved.
  • *
  • A token adjacent to a letter, digit, underscore, or hyphen, or that is part of a longer dotted or colon-separated sequence, * is not a candidate. For example, {@code v1.2.3.4}, {@code 1.3.6.1.4.1}, {@code host-192.0.2.1}, and {@code 12:30:45} - * yield no candidate. A dot followed by a non-word character, such as a full stop, ends the candidate.
  • + * yield no candidate, nor does a dotted name such as {@code 1.2.0.192.in-addr.arpa}. + * A dot followed by a non-word character, such as a full stop, ends the candidate. *
  • Enclosing brackets and ports are not part of the candidate, for example in {@code [2001:db8::1]:8080} or {@code 192.0.2.1:8080}.
  • *
  • A slash suffix of one to three ASCII digits is preserved as a CIDR prefix, unless it follows a port * or the address is a URL host or user information host (preceded by {@code //} or {@code @}), @@ -157,23 +153,75 @@ public static Optional extract(String content) { public static List extractAll(String content) { List candidates = new ArrayList<>(); boolean[] userInfo = userInfoCharacters(content); - Matcher matcher = EMBEDDED_ADDRESS.matcher(content); + Matcher run = ADDRESS_RUN.matcher(content); int from = 0; - while (from < content.length() && matcher.find(from)) { - from = matcher.end(); - String ipv6 = matcher.group("ipv6"); - if (ipv6 != null && !isIpv6Shaped(ipv6)) { - // Retry inside the rejected token, for example for the IPv4 address in "dc:192.0.2.1". - from = matcher.start() + 1; - continue; + while (from < content.length() && run.find(from)) { + from = run.end(); + int start = run.start(); + int end = run.end(); + // Trailing dots, such as a full stop, are not part of an address. + while (end - 1 > start && content.charAt(end - 1) == '.') { + end--; } - if (!userInfo[matcher.start()]) { - embeddedCandidate(content, matcher).ifPresent(candidates::add); + // An opaque IPv6 zone identifier extends the address. Otherwise, the character after any trailing dots bounds the run. + boolean zoned = charAt(content, end) == '%'; + int addressEnd = zoned ? zoneEnd(content, end + 1) : end; + int runEnd = zoned ? addressEnd : run.end(); + boolean leftBounded = isBoundary(content, start - 1); + boolean rightBounded = isBoundary(content, runEnd); + if (leftBounded && rightBounded && isIpv6Shaped(content.substring(start, end))) { + from = runEnd; + addCandidate(content, start, addressEnd, userInfo, candidates); + } else { + addIpv4Candidates(content, start, end, leftBounded, rightBounded, userInfo, candidates); } } return Collections.unmodifiableList(candidates); } + /** + * Adds the IPv4 addresses among the colon-separated segments of a run, such as "192.0.2.1" in "192.0.2.1:8080" or "host:192.0.2.1". + * The first and last segments are only candidates if the run is bounded on that side. + */ + private static void addIpv4Candidates(String content, int runStart, int runEnd, boolean leftBounded, boolean rightBounded, + boolean[] userInfo, List candidates) { + int segmentStart = runStart; + while (segmentStart <= runEnd) { + int colon = content.indexOf(':', segmentStart); + int segmentEnd = (colon < 0 || colon > runEnd) ? runEnd : colon; + boolean bounded = (leftBounded || segmentStart > runStart) && (rightBounded || segmentEnd < runEnd); + if (bounded && IPV4_SHAPE.matcher(content.substring(segmentStart, segmentEnd)).matches() && !isProductVersion(content, segmentStart)) { + addCandidate(content, segmentStart, segmentEnd, userInfo, candidates); + } + segmentStart = segmentEnd + 1; + } + } + + private static void addCandidate(String content, int start, int end, boolean[] userInfo, List candidates) { + boolean bracketed = charAt(content, start - 1) == '[' && charAt(content, end) == ']'; + int before = bracketed ? (start - 1) : start; + int after = bracketed ? (end + 1) : end; + if (userInfo[start] || charAt(content, after) == '@') { + return; + } + // A port (":") right after the address excludes a CIDR suffix, and URL hosts are followed by paths. + boolean urlHost = charAt(content, before - 1) == '@' || (charAt(content, before - 1) == '/' && charAt(content, before - 2) == '/'); + String cidr = (urlHost || charAt(content, after) != '/') ? "" : cidrSuffix(content, after + 1); + candidates.add(content.substring(start, end) + cidr); + } + + /** Returns a slash and the CIDR prefix starting at the given index, or an empty string if there is no prefix. */ + private static String cidrSuffix(String content, int prefixStart) { + int prefixEnd = prefixStart; + while (prefixEnd < content.length() && isAsciiDigit(content.charAt(prefixEnd))) { + prefixEnd++; + } + // Three digits cover both IPv4 and IPv6 prefixes. + int digits = prefixEnd - prefixStart; + boolean isPrefix = digits >= 1 && digits <= 3 && !isWordCharacter(content, prefixEnd); + return isPrefix ? content.substring(prefixStart - 1, prefixEnd) : ""; + } + private static boolean[] userInfoCharacters(String content) { boolean[] userInfo = new boolean[content.length()]; Matcher matcher = URL_USER_INFO.matcher(content); @@ -183,37 +231,18 @@ private static boolean[] userInfoCharacters(String content) { return userInfo; } - private static Optional embeddedCandidate(String content, Matcher matcher) { - int start = matcher.start(); - int end = matcher.end(); - boolean bracketed = start > 0 && content.charAt(start - 1) == '[' && end < content.length() && content.charAt(end) == ']'; - int before = bracketed ? (start - 1) : start; - int after = bracketed ? (end + 1) : end; - - if (charAt(content, after) == '@' || (matcher.group("ipv4") != null && isProductVersion(content, start))) { - return Optional.empty(); - } - - String candidate = matcher.group(); - // A port (":") right after the address excludes a CIDR suffix, and URL hosts are followed by paths. - boolean urlHost = charAt(content, before - 1) == '@' || (charAt(content, before - 1) == '/' && charAt(content, before - 2) == '/'); - if (!urlHost && charAt(content, after) == '/') { - int digitsEnd = after + 1; - while (digitsEnd < content.length() && content.charAt(digitsEnd) >= '0' && content.charAt(digitsEnd) <= '9') { - digitsEnd++; - } - // Three digits cover both IPv4 and IPv6 prefixes. - int digits = digitsEnd - after - 1; - if (digits >= 1 && digits <= 3 && !isWordCharacter(content, digitsEnd)) { - candidate += content.substring(after, digitsEnd); - } + /** Returns the end of an opaque IPv6 zone identifier starting at the given index. */ + private static int zoneEnd(String content, int zoneStart) { + int end = zoneStart; + while (isWordCharacter(content, end) || charAt(content, end) == '-') { + end += Character.charCount(content.codePointAt(end)); } - return Optional.of(candidate); + return end; } private static boolean isIpv6Shaped(String token) { String hexPart = token; - int groups = 0; + int tailGroups = 0; if (token.indexOf('.') >= 0) { int lastColon = token.lastIndexOf(':'); if (!IPV4_SHAPE.matcher(token.substring(lastColon + 1)).matches()) { @@ -221,25 +250,33 @@ private static boolean isIpv6Shaped(String token) { } // The IPv4 tail takes the place of the last two groups. Keep its colon so a preceding "::" stays intact. hexPart = token.substring(0, lastColon + 1) + "0"; - groups = 1; + tailGroups = 1; } int compression = hexPart.indexOf("::"); - if (compression >= 0 && hexPart.indexOf("::", compression + 1) >= 0) { + if (compression < 0) { + int groups = countHexGroups(hexPart); + return groups >= 0 && groups + tailGroups == IPV6_GROUPS; + } + if (hexPart.indexOf("::", compression + 1) >= 0) { return false; } - String head = compression >= 0 ? hexPart.substring(0, compression) : hexPart; - String tail = compression >= 0 ? hexPart.substring(compression + 2) : ""; - for (String part : new String[] {head, tail}) { - if (!part.isEmpty()) { - for (String group : part.split(":", -1)) { - if (!HEX_GROUP.matcher(group).matches()) { - return false; - } - groups++; - } + int head = countHexGroups(hexPart.substring(0, compression)); + int tail = countHexGroups(hexPart.substring(compression + 2)); + return head >= 0 && tail >= 0 && head + tail + tailGroups < IPV6_GROUPS; + } + + /** Returns the number of colon-separated hexadecimal groups, or -1 if a group is empty or not hexadecimal. */ + private static int countHexGroups(String groups) { + if (groups.isEmpty()) { + return 0; + } + String[] split = groups.split(":", -1); + for (String group : split) { + if (!HEX_GROUP.matcher(group).matches()) { + return -1; } } - return compression >= 0 ? (groups < IPV6_GROUPS) : (groups == IPV6_GROUPS); + return split.length; } private static boolean isProductVersion(String content, int addressStart) { @@ -251,22 +288,30 @@ private static boolean isProductVersion(String content, int addressStart) { while (nameStart > 0 && isProductNameCharacter(content.charAt(nameStart - 1))) { nameStart--; } - char first = charAt(content, nameStart); char preceding = charAt(content, nameStart - 1); - return nameStart < slash && isAsciiLetter(first) + return nameStart < slash && isAsciiLetter(content.charAt(nameStart)) && (nameStart == 0 || Character.isWhitespace(preceding) || "(,;".indexOf(preceding) >= 0); } private static boolean isProductNameCharacter(char c) { - return isAsciiLetter(c) || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == '-'; + return isAsciiLetter(c) || isAsciiDigit(c) || c == '_' || c == '.' || c == '-'; } private static boolean isAsciiLetter(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); } + private static boolean isAsciiDigit(char c) { + return c >= '0' && c <= '9'; + } + + /** True if the index is out of bounds or the character there cannot be part of an address token's word. */ + private static boolean isBoundary(String content, int index) { + return !isWordCharacter(content, index) && charAt(content, index) != '-'; + } + private static boolean isWordCharacter(String content, int index) { - if (index >= content.length()) { + if (index < 0 || index >= content.length()) { return false; } int codePoint = content.codePointAt(index); diff --git a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java index f1f1a02c..9c7478bc 100644 --- a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java +++ b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java @@ -191,6 +191,8 @@ static Stream shouldExtractAllCandidates() { candidates("connect to 192.0.2.1 now", "192.0.2.1"), candidates("Connect to 192.0.2.1.", "192.0.2.1"), // Full stop after the address candidates("Connect to ::1.", "::1"), + candidates("Use fe80::1%eth0.", "fe80::1%eth0"), + candidates("Use 192.0.2.1...", "192.0.2.1"), candidates("host=192.0.2.1&port=80", "192.0.2.1"), candidates("host:192.0.2.1", "192.0.2.1"), candidates("dc:192.0.2.1", "192.0.2.1"), // Not an IPv6 address; the IPv4 address is still found @@ -284,6 +286,10 @@ static Stream shouldExtractAllCandidates() { "192.0.2.1接口", "http://192.0.2.1@example.org/path", // User information "user.192.0.2.1", + "1.2.0.192.in-addr.arpa", // Reverse DNS name + "192.0.2.1.example.org", + "cafe.192.0.2.1", + "Connect to 192.0.2.1.x", // Product tokens "Chrome/120.0.0.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", From d62aa609b276b0184695b536380790bb5c0c2acb Mon Sep 17 00:00:00 2001 From: Erwan Le Forestier Date: Thu, 5 Nov 2026 15:59:56 +0000 Subject: [PATCH 4/6] ACOMMONS-231 Bound colon scanning in extractAll to the current run addIpv4Candidates searched the rest of the content for the next colon for every address-shaped run, making extraction quadratic for many colon-free addresses. Scan each run's characters once instead. Co-Authored-By: Claude Opus 5.5 --- .../commons/appsec/IpAddressCandidateExtractor.java | 8 +++++--- .../appsec/IpAddressCandidateExtractorTest.java | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java index df539f70..6200cf89 100644 --- a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java +++ b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java @@ -185,10 +185,12 @@ public static List extractAll(String content) { */ private static void addIpv4Candidates(String content, int runStart, int runEnd, boolean leftBounded, boolean rightBounded, boolean[] userInfo, List candidates) { + // Scan only the run itself, keeping the overall extraction linear in the content length. int segmentStart = runStart; - while (segmentStart <= runEnd) { - int colon = content.indexOf(':', segmentStart); - int segmentEnd = (colon < 0 || colon > runEnd) ? runEnd : colon; + for (int segmentEnd = runStart; segmentEnd <= runEnd; segmentEnd++) { + if (segmentEnd < runEnd && content.charAt(segmentEnd) != ':') { + continue; + } boolean bounded = (leftBounded || segmentStart > runStart) && (rightBounded || segmentEnd < runEnd); if (bounded && IPV4_SHAPE.matcher(content.substring(segmentStart, segmentEnd)).matches() && !isProductVersion(content, segmentStart)) { addCandidate(content, segmentStart, segmentEnd, userInfo, candidates); diff --git a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java index 9c7478bc..74596d3d 100644 --- a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java +++ b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java @@ -214,6 +214,7 @@ static Stream shouldExtractAllCandidates() { candidates("http://192.0.2.1:8080;https://192.0.2.2/other", "192.0.2.1", "192.0.2.2"), candidates("http://192.0.2.1,https://[2001:db8::1]:8443/path", "192.0.2.1", "2001:db8::1"), candidates("from 10.0.0.1 to 10.0.0.255", "10.0.0.1", "10.0.0.255"), + candidates("192.0.2.1 then 192.0.2.2:8080 then host:192.0.2.3", "192.0.2.1", "192.0.2.2", "192.0.2.3"), // Colons only split their own run // URLs: user information, paths, queries candidates("http://user:pass@192.0.2.1:8080/path", "192.0.2.1"), candidates("http://192.0.2.1/path@192.0.2.2", "192.0.2.1", "192.0.2.2"), @@ -317,6 +318,15 @@ void shouldExtractNoCandidates(String content) { assertThat(IpAddressCandidateExtractor.extractAll(content)).isEmpty(); } + @Test + void shouldScanManyAddressesFollowedByDistantColon() { + // Every run is segmented at its own colons only. Searching the rest of the content for each run would make this quadratic. + int count = 100_000; + String content = "10.0.0.1 ".repeat(count) + "end:"; + List candidates = IpAddressCandidateExtractor.extractAll(content); + assertThat(candidates).hasSize(count).containsOnly("10.0.0.1"); + } + @Test void shouldReturnUnmodifiableList() { List candidates = IpAddressCandidateExtractor.extractAll("192.0.2.1"); From 252cf9694e241ffb2a0aeac69a7ac67855c9cc5a Mon Sep 17 00:00:00 2001 From: Erwan Le Forestier Date: Thu, 5 Nov 2026 16:10:11 +0000 Subject: [PATCH 5/6] ACOMMONS-231 Handle punctuation colons after IPv6 and supplementary letters before runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An IPv6 address followed by a single colon used as punctuation, as in "Server ::1: connection refused", is now found, like its IPv4 counterpart. The left boundary decodes the full code point before a run, so a supplementary-plane letter, as in "𠀋192.0.2.1", embeds the address in a word. Co-Authored-By: Claude Opus 5.5 --- .../appsec/IpAddressCandidateExtractor.java | 16 ++++++++++++++-- .../appsec/IpAddressCandidateExtractorTest.java | 12 ++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java index 6200cf89..a8fae7c5 100644 --- a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java +++ b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java @@ -167,9 +167,16 @@ public static List extractAll(String content) { boolean zoned = charAt(content, end) == '%'; int addressEnd = zoned ? zoneEnd(content, end + 1) : end; int runEnd = zoned ? addressEnd : run.end(); - boolean leftBounded = isBoundary(content, start - 1); + boolean leftBounded = isBoundaryBefore(content, start); boolean rightBounded = isBoundary(content, runEnd); - if (leftBounded && rightBounded && isIpv6Shaped(content.substring(start, end))) { + String token = content.substring(start, end); + // A single trailing colon is punctuation, as in "Server ::1: connection refused". + boolean punctuated = !zoned && token.endsWith(":") && !token.endsWith("::"); + if (punctuated && !isIpv6Shaped(token)) { + token = token.substring(0, token.length() - 1); + addressEnd--; + } + if (leftBounded && rightBounded && isIpv6Shaped(token)) { from = runEnd; addCandidate(content, start, addressEnd, userInfo, candidates); } else { @@ -312,6 +319,11 @@ private static boolean isBoundary(String content, int index) { return !isWordCharacter(content, index) && charAt(content, index) != '-'; } + /** Like {@link #isBoundary(String, int)} for the code point before the index, including supplementary characters. */ + private static boolean isBoundaryBefore(String content, int index) { + return index <= 0 || isBoundary(content, index - Character.charCount(content.codePointBefore(index))); + } + private static boolean isWordCharacter(String content, int index) { if (index < 0 || index >= content.length()) { return false; diff --git a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java index 74596d3d..31e46663 100644 --- a/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java +++ b/commons/src/test/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractorTest.java @@ -205,6 +205,13 @@ static Stream shouldExtractAllCandidates() { candidates(" 192.0.2.1", "192.0.2.1"), // Leading whitespace candidates("[192.0.2.1]", "192.0.2.1"), candidates("Server 10.0.0.1: connection refused", "10.0.0.1"), + candidates("Server 2001:db8::1: connection refused", "2001:db8::1"), // Trailing colon as punctuation + candidates("Server ::1:", "::1"), + candidates("Server ::ffff:192.0.2.1:", "::ffff:192.0.2.1"), + candidates("Server fe80::1%eth0: down", "fe80::1%eth0"), + candidates("Server [2001:db8::1]: down", "2001:db8::1"), + candidates("1:2:3:4:5:6:7:8:", "1:2:3:4:5:6:7:8"), + candidates("𠀋 192.0.2.1", "192.0.2.1"), candidates("ip 192.0.2.1", "192.0.2.1"), // Lists with any delimiter candidates("192.0.2.1,192.0.2.2", "192.0.2.1", "192.0.2.2"), @@ -284,6 +291,11 @@ static Stream shouldExtractAllCandidates() { "192.0.2.1-SNAPSHOT", "my_192.0.2.1", "接口192.0.2.1", + "𠀋192.0.2.1", // Supplementary-plane letter before the address + "192.0.2.1𠀋", + "𠀋::1", + "fe80:::", // Trailing colon after "::" is not punctuation + "Server 2001:db8::1:x", "192.0.2.1接口", "http://192.0.2.1@example.org/path", // User information "user.192.0.2.1", From b57c6a9a23d2b611c7ba7443ab10f4c27769e549 Mon Sep 17 00:00:00 2001 From: Erwan Le Forestier Date: Thu, 5 Nov 2026 16:17:21 +0000 Subject: [PATCH 6/6] ACOMMONS-231 Extract per-run handling from extractAll to reduce cognitive complexity Co-Authored-By: Claude Opus 5.5 --- .../appsec/IpAddressCandidateExtractor.java | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java index a8fae7c5..1f458f9e 100644 --- a/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java +++ b/commons/src/main/java/org/sonarsource/analyzer/commons/appsec/IpAddressCandidateExtractor.java @@ -156,36 +156,39 @@ public static List extractAll(String content) { Matcher run = ADDRESS_RUN.matcher(content); int from = 0; while (from < content.length() && run.find(from)) { - from = run.end(); - int start = run.start(); - int end = run.end(); - // Trailing dots, such as a full stop, are not part of an address. - while (end - 1 > start && content.charAt(end - 1) == '.') { - end--; - } - // An opaque IPv6 zone identifier extends the address. Otherwise, the character after any trailing dots bounds the run. - boolean zoned = charAt(content, end) == '%'; - int addressEnd = zoned ? zoneEnd(content, end + 1) : end; - int runEnd = zoned ? addressEnd : run.end(); - boolean leftBounded = isBoundaryBefore(content, start); - boolean rightBounded = isBoundary(content, runEnd); - String token = content.substring(start, end); - // A single trailing colon is punctuation, as in "Server ::1: connection refused". - boolean punctuated = !zoned && token.endsWith(":") && !token.endsWith("::"); - if (punctuated && !isIpv6Shaped(token)) { - token = token.substring(0, token.length() - 1); - addressEnd--; - } - if (leftBounded && rightBounded && isIpv6Shaped(token)) { - from = runEnd; - addCandidate(content, start, addressEnd, userInfo, candidates); - } else { - addIpv4Candidates(content, start, end, leftBounded, rightBounded, userInfo, candidates); - } + from = addRunCandidates(content, run.start(), run.end(), userInfo, candidates); } return Collections.unmodifiableList(candidates); } + /** Adds the candidates of an address-shaped run and returns the index where scanning continues. */ + private static int addRunCandidates(String content, int start, int runEnd, boolean[] userInfo, List candidates) { + int end = runEnd; + // Trailing dots, such as a full stop, are not part of an address. + while (end - 1 > start && content.charAt(end - 1) == '.') { + end--; + } + // An opaque IPv6 zone identifier extends the address. Otherwise, the character after any trailing dots bounds the run. + boolean zoned = charAt(content, end) == '%'; + int addressEnd = zoned ? zoneEnd(content, end + 1) : end; + int boundedEnd = zoned ? addressEnd : runEnd; + boolean leftBounded = isBoundaryBefore(content, start); + boolean rightBounded = isBoundary(content, boundedEnd); + String token = content.substring(start, end); + // A single trailing colon is punctuation, as in "Server ::1: connection refused". + boolean punctuated = !zoned && token.endsWith(":") && !token.endsWith("::"); + if (punctuated && !isIpv6Shaped(token)) { + token = token.substring(0, token.length() - 1); + addressEnd--; + } + if (leftBounded && rightBounded && isIpv6Shaped(token)) { + addCandidate(content, start, addressEnd, userInfo, candidates); + return boundedEnd; + } + addIpv4Candidates(content, start, end, leftBounded, rightBounded, userInfo, candidates); + return runEnd; + } + /** * Adds the IPv4 addresses among the colon-separated segments of a run, such as "192.0.2.1" in "192.0.2.1:8080" or "host:192.0.2.1". * The first and last segments are only candidates if the run is bounded on that side.