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. + * + */ public final class IpAddressCandidateExtractor { private static final Pattern URL_PREFIX = Pattern.compile( @@ -28,6 +40,14 @@ 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+"); + // 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("\\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; + private IpAddressCandidateExtractor() { } @@ -92,6 +112,234 @@ 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: + * + *

+ * + *

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)); + } }