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..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
@@ -16,11 +16,23 @@
*/
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;
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.
+ *
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, 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 @}),
+ * where it is a path. As with {@link #extract(String)}, out-of-range prefixes such as {@code /33} are preserved.
+ * - 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.
+ * 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<>();
+ boolean[] userInfo = userInfoCharacters(content);
+ Matcher run = ADDRESS_RUN.matcher(content);
+ int from = 0;
+ while (from < content.length() && run.find(from)) {
+ 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.
+ */
+ 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;
+ 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);
+ }
+ 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);
+ while (matcher.find()) {
+ Arrays.fill(userInfo, matcher.start(1), matcher.end(1), true);
+ }
+ return userInfo;
+ }
+
+ /** 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 end;
+ }
+
+ private static boolean isIpv6Shaped(String token) {
+ String hexPart = token;
+ int tailGroups = 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";
+ tailGroups = 1;
+ }
+ int compression = hexPart.indexOf("::");
+ if (compression < 0) {
+ int groups = countHexGroups(hexPart);
+ return groups >= 0 && groups + tailGroups == IPV6_GROUPS;
+ }
+ if (hexPart.indexOf("::", compression + 1) >= 0) {
+ return false;
+ }
+ 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 split.length;
+ }
+
+ 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 preceding = charAt(content, nameStart - 1);
+ return nameStart < slash && isAsciiLetter(content.charAt(nameStart))
+ && (nameStart == 0 || Character.isWhitespace(preceding) || "(,;".indexOf(preceding) >= 0);
+ }
+
+ private static boolean isProductNameCharacter(char 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) != '-';
+ }
+
+ /** 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;
+ }
+ 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..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
@@ -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,196 @@ 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("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
+ 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("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"),
+ 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"),
+ 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"),
+ // 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"),
+ // 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", // 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",
+ "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",
+ "(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 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");
+ 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));
+ }
}