From 0977bb5d75329fc5025b742cb415dafa83369d80 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Sat, 10 Oct 2026 21:31:12 -0300 Subject: [PATCH] AsyncAPI 3.x: Jackson reads from bytes, for message payloads A message payload reaches the service as bytes, not as a String. Spring Kafka's JsonDeserializer reads every record value with ObjectReader.readValue(byte[]), and the message converters of spring-messaging and spring-kafka call ObjectMapper.readValue(byte[], JavaType). None of these had a method replacement, so a Kafka listener that deserializes its payload registered no DTO schema and produced no JSON taint specialization. The same applies to any SUT, REST included, that reads JSON from bytes. - ObjectMapper: readValue(byte[], Class), readValue(byte[], JavaType), readValue(byte[], TypeReference) and readValue(InputStream, TypeReference), next to the String and InputStream ones. - ObjectReader: a new replacement class for readValue(byte[]) and readValue(String). The target type is the one configured in the reader, read with getValueType() (Jackson 2.10+) or the _valueType field before that. A reader without a type is not analyzed, and the call goes through unchanged. Worth flagging for review: - Replacements match the exact owner class written in the bytecode, and third-party code is instrumented too, so the ObjectReader replacement fires inside JsonDeserializer. A test loads a class that only calls JsonDeserializer.deserialize through the instrumenting class loader and checks that the DTO gets registered and the taint specialized; without the replacement those assertions fail. - initMethods resolves every replacement of a class eagerly, so every replaced method must exist in every Jackson 2.x. All of them do (checked with javap from 2.6 to 2.17). getValueType() does not, which is why it is only called by reflection, with the field as fallback. - None of the replaced methods delegates to another replaced one inside Jackson, so a call is analyzed once. In recent 2.x versions (2.11 at the latest), ObjectReader.readValue(byte[]|String, Class) is implemented as forType(type).readValue(content), so those calls are analyzed too, with the type given as input. readValue(byte[], int, int) is left alone. - The schema is always registered. The bytes are decoded as UTF-8 only for the taint check, and only when they could be a tainted value: they start with the taint prefix, or are not longer than the longest input variable once encoded. So a large payload is not decoded on every read. Jackson always gets the original bytes, so its own encoding detection is not affected. - An InputStream is read once, here, and Jackson gets a new stream over the very same bytes whose close() closes the caller's stream, so the source is closed when, and only when, AUTO_CLOSE_SOURCE says so. A failed read closes the source under the same condition, and rethrows. - A type that cannot be resolved by reflection does not break the SUT: the analysis is skipped, and the cause logged once. - Both target names keep the trimmed-string trick, so the shade plugin does not relocate them in the controller jar. spring-kafka 2.7.6 (the version Spring Boot 2.5.4 manages, Spring 5, class version 52) is added in test scope to client-java/instrumentation, with its version in the client-java dependencyManagement. It brings kafka-clients in test scope, which the root pom comment on kafka-clients says. The existing ObjectMapper replacements behave as before: the TypeReference resolution is moved into a helper shared with the new methods, and an array type is still analyzed as a whole, with a schema of type array. ExecutionTracer keeps the length of the longest input variable, reset with the rest of the per-test state, for the could-be-taint check. --- .../shared/TaintInputName.java | 22 ++ .../shared/TaintInputNameTest.java | 15 + client-java/instrumentation/pom.xml | 6 + .../coverage/methodreplacement/JsonUtils.java | 43 +++ .../methodreplacement/ReplacementList.java | 1 + .../JacksonObjectMapperClassReplacement.java | 289 ++++++++++++++-- .../JacksonObjectReaderClassReplacement.java | 142 ++++++++ .../staticstate/ExecutionTracer.java | 26 ++ .../jackson/ReadBytesWithJacksonImpl.java | 29 ++ .../JacksonObjectMapperTest.java | 312 +++++++++++++++++- ...cksonObjectReaderClassReplacementTest.java | 206 ++++++++++++ .../example/jackson/ReadBytesWithJackson.java | 16 + .../jackson/ReadBytesWithJacksonTest.java | 124 +++++++ .../staticstate/ExecutionTracerTest.java | 61 ++++ client-java/pom.xml | 13 + 15 files changed, 1283 insertions(+), 22 deletions(-) create mode 100644 client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectReaderClassReplacement.java create mode 100644 client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/jackson/ReadBytesWithJacksonImpl.java create mode 100644 client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectReaderClassReplacementTest.java create mode 100644 client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/example/jackson/ReadBytesWithJackson.java create mode 100644 client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/example/jackson/ReadBytesWithJacksonTest.java diff --git a/client-java/instrumentation-shared/src/main/java/org/evomaster/client/java/instrumentation/shared/TaintInputName.java b/client-java/instrumentation-shared/src/main/java/org/evomaster/client/java/instrumentation/shared/TaintInputName.java index 7b4474c2cc..4b73fc4588 100644 --- a/client-java/instrumentation-shared/src/main/java/org/evomaster/client/java/instrumentation/shared/TaintInputName.java +++ b/client-java/instrumentation-shared/src/main/java/org/evomaster/client/java/instrumentation/shared/TaintInputName.java @@ -52,6 +52,28 @@ public static boolean isTaintInput(String value){ } + /** + * Check whether UTF-8 encoded bytes start with the prefix of a tainted value, ignoring case as + * {@link #isTaintInput(String)} does. Bytes that do not cannot decode into a tainted value, which + * this tells without decoding them. + */ + public static boolean startsWithTaintPrefix(byte[] utf8){ + if(utf8 == null || utf8.length < PREFIX.length()){ + return false; + } + for(int i=0; i TaintInputName.getTaintName(-1)); diff --git a/client-java/instrumentation/pom.xml b/client-java/instrumentation/pom.xml index b8d800fb0c..6b011d9bf8 100644 --- a/client-java/instrumentation/pom.xml +++ b/client-java/instrumentation/pom.xml @@ -198,6 +198,12 @@ jackson-jaxrs-base test + + + org.springframework.kafka + spring-kafka + test + mysql mysql-connector-java diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/JsonUtils.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/JsonUtils.java index ead8bd6b8e..009dee887b 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/JsonUtils.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/JsonUtils.java @@ -2,13 +2,20 @@ import org.evomaster.client.java.instrumentation.object.ClassToSchema; import org.evomaster.client.java.instrumentation.object.JsonTaint; +import org.evomaster.client.java.instrumentation.staticstate.ExecutionTracer; import java.io.*; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.stream.Collectors; public class JsonUtils { + /** + * Size of the chunks a stream is read in + */ + private static final int READ_BUFFER_SIZE = 4096; + public static String readStream(InputStream src){ String content = new BufferedReader( new InputStreamReader(src, Charset.defaultCharset())) @@ -18,6 +25,42 @@ public static String readStream(InputStream src){ return content; } + /** + * Consume the whole stream, keeping its bytes as they are (no decoding, no change of line separators). + * Java 8 has no InputStream.readAllBytes. + */ + public static byte[] readAllBytes(InputStream src) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[READ_BUFFER_SIZE]; + int read; + while ((read = src.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + + /** + * Decode raw JSON bytes as UTF-8, which is the encoding of JSON (RFC 8259), and the one tainted + * values are written in. + */ + public static String decodeUtf8(byte[] content){ + return new String(content, StandardCharsets.UTF_8); + } + + /** + * Same as {@link #decodeUtf8(byte[])}, but only when the bytes could decode into a tainted value + * (see {@link ExecutionTracer#couldBeTaintInput(byte[])}), so that large payloads are not decoded on + * every read for nothing. + * + * @return the decoded text, or null if it cannot be a tainted value + */ + public static String decodeUtf8IfCouldBeTaint(byte[] content){ + if(!ExecutionTracer.couldBeTaintInput(content)){ + return null; + } + return decodeUtf8(content); + } + public static InputStream toStream(String content){ return new ByteArrayInputStream(content.getBytes(Charset.defaultCharset())); } diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/ReplacementList.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/ReplacementList.java index 7c615cdfb2..a51d8c9b9b 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/ReplacementList.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/ReplacementList.java @@ -50,6 +50,7 @@ public static List getList() { new InetSocketAddressClassReplacement(), new KotlinIntrinsicsClassReplacement(), new JacksonObjectMapperClassReplacement(), + new JacksonObjectReaderClassReplacement(), new LocalDateClassReplacement(), new LocalDateTimeClassReplacement(), new LocalTimeClassReplacement(), diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectMapperClassReplacement.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectMapperClassReplacement.java index 6eae429e8d..09957cb789 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectMapperClassReplacement.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectMapperClassReplacement.java @@ -18,6 +18,31 @@ public class JacksonObjectMapperClassReplacement extends ThirdPartyMethodReplace private static final JacksonObjectMapperClassReplacement singleton = new JacksonObjectMapperClassReplacement(); + /* + Jackson JavaType subclasses analyzed through the type of their elements. Not ArrayType: like a + Foo[] class, it gets a schema of type array. + */ + private static final String COLLECTION_LIKE_TYPE = "CollectionLikeType"; + private static final String COLLECTION_TYPE = "CollectionType"; + + // JavaType methods, called by reflection + private static final String GET_CONTENT_TYPE = "getContentType"; + private static final String GET_RAW_CLASS = "getRawClass"; + + private static final String READ_BYTES_CLASS_ID = "Jackson_ObjectMapper_readValue_byte_array_Generic_class"; + private static final String READ_BYTES_JAVA_TYPE_ID = "Jackson_ObjectMapper_readValue_byte_array_JavaType_class"; + private static final String READ_BYTES_TYPE_REFERENCE_ID = "Jackson_ObjectMapper_readValue_byte_array_TypeReference_class"; + private static final String READ_STREAM_TYPE_REFERENCE_ID = "Jackson_ObjectMapper_readValue_InputStream_TypeReference_class"; + + /* + To read the mapper's AUTO_CLOSE_SOURCE setting by reflection. The parameter type is matched by the end + of its name only, so the shade plugin has no package name to relocate. + */ + private static final String GET_FACTORY = "getFactory"; + private static final String IS_ENABLED = "isEnabled"; + private static final String PARSER_FEATURE_CLASS_SUFFIX = "JsonParser$Feature"; + private static final String AUTO_CLOSE_SOURCE = "AUTO_CLOSE_SOURCE"; + @Override protected String getNameOfThirdPartyTargetClass() { /* @@ -29,9 +54,19 @@ protected String getNameOfThirdPartyTargetClass() { } - private static void analyzeClass(Class valueType, String content, boolean isArray){ + /** + * Register the schema of the type a JSON document is read into, and check whether the document + * is a tainted value. Also used by {@link JacksonObjectReaderClassReplacement}. + * + * @param content the document, or null if it is known not to be a tainted value, in which case only the + * schema is registered + * @param isArray if true, valueType is the type of the elements, and not of the collection itself + */ + static void analyzeClass(Class valueType, String content, boolean isArray){ ClassToSchema.registerSchemaIfNeeded(valueType); - JsonTaint.handlePossibleJsonTaint(content, valueType, isArray); + if (content != null) { + JsonTaint.handlePossibleJsonTaint(content, valueType, isArray); + } } @@ -117,14 +152,19 @@ public static T readValue_EM_0(Object caller, String content, } } - private static void analyzeJavaType(String content, Object valueType) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { + /** + * Same as {@link #analyzeClass(Class, String, boolean)}, but for a Jackson JavaType, given as Object + * as Jackson is not on the classpath of the instrumentation. For collections, the type of the elements + * is analyzed. Also used by {@link JacksonObjectReaderClassReplacement}. + */ + static void analyzeJavaType(String content, Object valueType) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { String typeName = valueType.getClass().getSimpleName(); - if(typeName.equals("CollectionLikeType") || typeName.equals("CollectionType")){ - Object contentType = valueType.getClass().getMethod("getContentType").invoke(valueType); - Class typeClass = (Class) contentType.getClass().getMethod("getRawClass").invoke(contentType); + if(typeName.equals(COLLECTION_LIKE_TYPE) || typeName.equals(COLLECTION_TYPE)){ + Object contentType = valueType.getClass().getMethod(GET_CONTENT_TYPE).invoke(valueType); + Class typeClass = (Class) contentType.getClass().getMethod(GET_RAW_CLASS).invoke(contentType); analyzeClass(typeClass, content, true); } else { - Class typeClass = (Class) valueType.getClass().getMethod("getRawClass").invoke(valueType); + Class typeClass = (Class) valueType.getClass().getMethod(GET_RAW_CLASS).invoke(valueType); analyzeClass(typeClass, content, false); } } @@ -140,7 +180,29 @@ public static T readValue_EM_1(Object caller, String content, throws Throwable { Objects.requireNonNull(caller); - //_typeFactory.constructType(valueTypeRef) + + Object javaType = constructJavaType(caller, valueTypeRef); + if(javaType == null){ + Method original = getOriginal(singleton, "Jackson_ObjectMapper_readValue_String_TypeReference_class", caller); + + try { + return (T) original.invoke(caller, content, valueTypeRef); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + return readValue_EM_0(caller, content, javaType); + } + + /** + * Resolve a TypeReference into a JavaType the way Jackson does, ie, {@code _typeFactory.constructType(valueTypeRef)}. + * + * @return the JavaType, or null if the mapper has no _typeFactory field to read it from + */ + private static Object constructJavaType(Object caller, Object valueTypeRef) throws IllegalAccessException, InvocationTargetException { Class classObjectMapper = caller.getClass(); while(!classObjectMapper.getSimpleName().equals("ObjectMapper")) { classObjectMapper = classObjectMapper.getSuperclass(); @@ -159,15 +221,7 @@ public static T readValue_EM_1(Object caller, String content, } } if(_typeFactoryField == null){ - Method original = getOriginal(singleton, "Jackson_ObjectMapper_readValue_String_TypeReference_class", caller); - - try { - return (T) original.invoke(caller, content, valueTypeRef); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } catch (InvocationTargetException e) { - throw e.getCause(); - } + return null; } _typeFactoryField.setAccessible(true); @@ -177,8 +231,7 @@ public static T readValue_EM_1(Object caller, String content, .filter(m -> m.getParameterTypes().length == 1) .filter(m-> m.getParameterTypes()[0].getName().endsWith("TypeReference")) .findFirst().orElse(null); - Object javaType = constructType.invoke(_typeFactory, valueTypeRef); - return readValue_EM_0(caller, content, javaType); + return constructType.invoke(_typeFactory, valueTypeRef); } @@ -237,4 +290,202 @@ public static T convertValue(Object caller, Object fromValue, Class toVal } } + + /* + The byte[] variants are what message converters use (eg spring-kafka's JsonMessageConverter), as a + message payload arrives as bytes. None of them delegates to another public readValue in Jackson 2.x, + so a call is analyzed once. + The schema of the target type is always registered; the bytes are decoded only for the taint check, + see JsonUtils.decodeUtf8IfCouldBeTaint. Jackson always gets the original input, so its own encoding + detection is unaffected. A type that cannot be resolved by reflection is logged, and the analysis + skipped. + */ + + @Replacement(replacingStatic = false, + type = ReplacementType.TRACKER, + id = READ_BYTES_CLASS_ID, + usageFilter = UsageFilter.ANY, + category = ReplacementCategory.EXT_0) + public static T readValue(Object caller, byte[] src, Class valueType) throws Throwable { + Objects.requireNonNull(caller); + + if (src != null && valueType != null) { + analyzeClass(valueType, JsonUtils.decodeUtf8IfCouldBeTaint(src), false); + } + + return (T) invokeOriginal(READ_BYTES_CLASS_ID, caller, src, valueType); + } + + @Replacement(replacingStatic = false, + type = ReplacementType.TRACKER, + id = READ_BYTES_JAVA_TYPE_ID, + usageFilter = UsageFilter.ANY, + category = ReplacementCategory.EXT_0) + public static T readValue_EM_2(Object caller, byte[] src, + @ThirdPartyCast(actualType = " com.fasterxml.jackson.databind.JavaType") Object valueType) + throws Throwable { + Objects.requireNonNull(caller); + + if (src != null && valueType != null) { + try { + analyzeJavaType(JsonUtils.decodeUtf8IfCouldBeTaint(src), valueType); + } catch (ReflectiveOperationException | RuntimeException e) { + logAnalysisFailure(caller, e); + } + } + + return (T) invokeOriginal(READ_BYTES_JAVA_TYPE_ID, caller, src, valueType); + } + + @Replacement(replacingStatic = false, + type = ReplacementType.TRACKER, + id = READ_BYTES_TYPE_REFERENCE_ID, + usageFilter = UsageFilter.ANY, + category = ReplacementCategory.EXT_0) + public static T readValue_EM_3(Object caller, byte[] src, + @ThirdPartyCast(actualType = " com.fasterxml.jackson.core.type.TypeReference") Object valueTypeRef) + throws Throwable { + Objects.requireNonNull(caller); + + if (src != null && valueTypeRef != null) { + try { + // resolved with the same type factory Jackson then uses for the read + Object javaType = constructJavaType(caller, valueTypeRef); + if (javaType != null) { + analyzeJavaType(JsonUtils.decodeUtf8IfCouldBeTaint(src), javaType); + } + } catch (ReflectiveOperationException | RuntimeException e) { + logAnalysisFailure(caller, e); + } + } + + return (T) invokeOriginal(READ_BYTES_TYPE_REFERENCE_ID, caller, src, valueTypeRef); + } + + @Replacement(replacingStatic = false, + type = ReplacementType.TRACKER, + id = READ_STREAM_TYPE_REFERENCE_ID, + usageFilter = UsageFilter.ANY, + category = ReplacementCategory.EXT_0) + public static T readValue_EM_4(Object caller, InputStream src, + @ThirdPartyCast(actualType = " com.fasterxml.jackson.core.type.TypeReference") Object valueTypeRef) + throws Throwable { + Objects.requireNonNull(caller); + + Object javaType = null; + if (src != null && valueTypeRef != null) { + try { + javaType = constructJavaType(caller, valueTypeRef); + } catch (ReflectiveOperationException | RuntimeException e) { + logAnalysisFailure(caller, e); + } + } + if (javaType == null) { + return (T) invokeOriginal(READ_STREAM_TYPE_REFERENCE_ID, caller, src, valueTypeRef); + } + + /* + The stream can be read only once, so Jackson gets a new one over the same bytes. Closing it + closes the original, so AUTO_CLOSE_SOURCE still applies to the caller's stream. + */ + byte[] content; + try { + content = JsonUtils.readAllBytes(src); + } catch (IOException | RuntimeException e) { + // Jackson would close the source here too, as its parser does on a failed read + if (isAutoCloseSource(caller)) { + try { + src.close(); + } catch (IOException closeFailure) { + e.addSuppressed(closeFailure); + } + } + throw e; + } + try { + analyzeJavaType(JsonUtils.decodeUtf8IfCouldBeTaint(content), javaType); + } catch (ReflectiveOperationException | RuntimeException e) { + logAnalysisFailure(caller, e); + } + + return (T) invokeOriginal(READ_STREAM_TYPE_REFERENCE_ID, caller, new SourceClosingStream(content, src), valueTypeRef); + } + + /** + * Log, once per distinct message, that the target type of a read could not be resolved. + * Also used by {@link JacksonObjectReaderClassReplacement}. + */ + static void logAnalysisFailure(Object caller, Exception e) { + // a reflective call wraps the real failure, and has no message of its own + Throwable cause = e instanceof InvocationTargetException ? ((InvocationTargetException) e).getTargetException() : e; + SimpleLogger.uniqueWarn("Failed to resolve the target type of a read with " + caller.getClass().getName() + ": " + cause); + } + + /** + * @return whether the mapper's factory has AUTO_CLOSE_SOURCE enabled. If that cannot be read, true, + * which is Jackson's default. + */ + private static boolean isAutoCloseSource(Object mapper) { + try { + Object factory = mapper.getClass().getMethod(GET_FACTORY).invoke(mapper); + for (Method m : factory.getClass().getMethods()) { + if (m.getName().equals(IS_ENABLED) + && m.getParameterTypes().length == 1 + && m.getParameterTypes()[0].isEnum() + && m.getParameterTypes()[0].getName().endsWith(PARSER_FEATURE_CLASS_SUFFIX)) { + Object feature = enumConstant(m.getParameterTypes()[0], AUTO_CLOSE_SOURCE); + return (Boolean) m.invoke(factory, feature); + } + } + } catch (ReflectiveOperationException | RuntimeException e) { + logAnalysisFailure(mapper, e); + } + return true; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object enumConstant(Class enumType, String name) { + return Enum.valueOf((Class) enumType, name); + } + + /** + * A stream over bytes already read from another stream, which closes that other stream when closed. + */ + private static class SourceClosingStream extends ByteArrayInputStream { + + /** + * The stream the bytes were read from + */ + private final InputStream source; + + SourceClosingStream(byte[] content, InputStream source) { + super(content); + this.source = Objects.requireNonNull(source); + } + + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + source.close(); + } + } + } + + /** + * Call the replaced Jackson method, rethrowing whatever it throws as it is. + */ + private static Object invokeOriginal(String id, Object caller, Object... inputs) throws Throwable { + Method original = getOriginal(singleton, id, caller); + + try { + return original.invoke(caller, inputs); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + } diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectReaderClassReplacement.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectReaderClassReplacement.java new file mode 100644 index 0000000000..0e8edd1a52 --- /dev/null +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectReaderClassReplacement.java @@ -0,0 +1,142 @@ +package org.evomaster.client.java.instrumentation.coverage.methodreplacement.thirdpartyclasses; + +import org.evomaster.client.java.instrumentation.coverage.methodreplacement.JsonUtils; +import org.evomaster.client.java.instrumentation.coverage.methodreplacement.Replacement; +import org.evomaster.client.java.instrumentation.coverage.methodreplacement.ThirdPartyMethodReplacementClass; +import org.evomaster.client.java.instrumentation.coverage.methodreplacement.UsageFilter; +import org.evomaster.client.java.instrumentation.shared.ReplacementCategory; +import org.evomaster.client.java.instrumentation.shared.ReplacementType; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Objects; + +/** + * Replacements for reading JSON with a Jackson ObjectReader, eg one built with + * {@code objectMapper.readerFor(type)}, as {@link JacksonObjectMapperClassReplacement} does for + * ObjectMapper. This is how spring-kafka's JsonDeserializer reads every record value, and library + * code is instrumented too. + *

+ * Only the target type configured in the reader is known: a reader without one is not analyzed. + */ +public class JacksonObjectReaderClassReplacement extends ThirdPartyMethodReplacementClass { + + private static final JacksonObjectReaderClassReplacement singleton = new JacksonObjectReaderClassReplacement(); + + /** + * Public getter of the reader's target type, added in Jackson 2.10 + */ + private static final String GET_VALUE_TYPE = "getValueType"; + + /** + * Field holding the reader's target type in every Jackson 2.x version, read when the getter is missing + */ + private static final String VALUE_TYPE_FIELD = "_valueType"; + + private static final String READ_BYTES_ID = "Jackson_ObjectReader_readValue_byte_array"; + private static final String READ_STRING_ID = "Jackson_ObjectReader_readValue_String"; + + @Override + protected String getNameOfThirdPartyTargetClass() { + /* + this is to avoid a 2013 bug in shade plugin: + https://issues.apache.org/jira/browse/MSHADE-156 + note that Jackson is shaded in pom of controller + */ + return " com.fasterxml.jackson.databind.ObjectReader".trim(); + } + + /* + Neither readValue(byte[]) nor readValue(String) delegates to another public readValue in Jackson 2.x, + so a call is analyzed once. Since 2.11 at the latest, readValue(byte[], Class) and readValue(String, Class) + call forType(valueType).readValue(content), so they are analyzed here too. + readValue(byte[], int, int) is deliberately left alone. + */ + + @Replacement(replacingStatic = false, + type = ReplacementType.TRACKER, + id = READ_BYTES_ID, + usageFilter = UsageFilter.ANY, + category = ReplacementCategory.EXT_0) + public static T readValue(Object caller, byte[] content) throws Throwable { + Objects.requireNonNull(caller); + + if (content != null) { + //as for the byte[] reads in JacksonObjectMapperClassReplacement + + analyzeTargetType(caller, JsonUtils.decodeUtf8IfCouldBeTaint(content)); + } + + return (T) invokeOriginal(READ_BYTES_ID, caller, content); + } + + @Replacement(replacingStatic = false, + type = ReplacementType.TRACKER, + id = READ_STRING_ID, + usageFilter = UsageFilter.ANY, + category = ReplacementCategory.EXT_0) + public static T readValue(Object caller, String content) throws Throwable { + Objects.requireNonNull(caller); + + if (content != null) { + analyzeTargetType(caller, content); + } + + return (T) invokeOriginal(READ_STRING_ID, caller, content); + } + + /** + * Analyze the content against the target type configured in the reader, if any. A null content only + * registers the schema. A type that cannot be read is logged, and the analysis skipped. + */ + private static void analyzeTargetType(Object reader, String content) { + try { + Object valueType = getValueType(reader); + if (valueType == null) { + return; + } + JacksonObjectMapperClassReplacement.analyzeJavaType(content, valueType); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + JacksonObjectMapperClassReplacement.logAnalysisFailure(reader, e); + } + } + + /** + * @return the JavaType the reader reads into, or null if it was built without one + */ + private static Object getValueType(Object reader) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { + try { + Method getter = reader.getClass().getMethod(GET_VALUE_TYPE); + return getter.invoke(reader); + } catch (NoSuchMethodException e) { + // Jackson before 2.10. The field can be declared in a superclass, if the reader is a subclass. + Class klass = reader.getClass(); + while (klass != null) { + try { + Field field = klass.getDeclaredField(VALUE_TYPE_FIELD); + field.setAccessible(true); + return field.get(reader); + } catch (NoSuchFieldException notHere) { + klass = klass.getSuperclass(); + } + } + throw e; + } + } + + /** + * Call the replaced Jackson method, rethrowing whatever it throws as it is. + */ + private static Object invokeOriginal(String id, Object caller, Object... inputs) throws Throwable { + Method original = getOriginal(singleton, id, caller); + + try { + return original.invoke(caller, inputs); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } +} diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracer.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracer.java index 5ca110ae9e..b635211936 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracer.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracer.java @@ -93,6 +93,16 @@ public class ExecutionTracer { */ private static Set inputVariables = new HashSet<>(); + /** + * Most bytes a single Java char takes in UTF-8 (a surrogate pair takes 4 bytes for 2 chars). + */ + private static final int UTF8_MAX_BYTES_PER_CHAR = 3; + + /** + * Length, in chars, of the longest string in inputVariables. Volatile, as it is read without the lock. + */ + private static volatile int maxInputVariableLength = 0; + /** * Map of external services mapping. Key is the WireMock signature * (use protocol, remote hostname, and port), and value contains information @@ -153,6 +163,7 @@ public static void reset() { additionalInfoList.clear(); additionalInfoList.add(new AdditionalInfo()); inputVariables = new HashSet<>(); + maxInputVariableLength = 0; killSwitch = false; expensiveOperation = 0; executingAction = false; @@ -258,6 +269,10 @@ public static void setAction(Action action) { if (action.getInputVariables() != null && !action.getInputVariables().isEmpty()) { inputVariables = action.getInputVariables(); + maxInputVariableLength = inputVariables.stream() + .filter(Objects::nonNull) + .mapToInt(String::length) + .max().orElse(0); } if (action.getIndex() == 0) { @@ -287,6 +302,17 @@ public static boolean isTaintInput(String input) { return TaintInputName.isTaintInput(input) || inputVariables.contains(input); } + /** + * Check, without decoding them, whether UTF-8 encoded bytes could decode into a value for which + * {@link #isTaintInput(String)} is true: they start like a TaintInputName value, or are not longer + * than the longest input variable can be once encoded. + */ + public static boolean couldBeTaintInput(byte[] utf8) { + Objects.requireNonNull(utf8); + return TaintInputName.startsWithTaintPrefix(utf8) + || utf8.length <= UTF8_MAX_BYTES_PER_CHAR * maxInputVariableLength; + } + public static void handleExtraParamTaint(String left, String right) { if (left == null || left.isEmpty() || right == null || right.isEmpty()) { diff --git a/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/jackson/ReadBytesWithJacksonImpl.java b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/jackson/ReadBytesWithJacksonImpl.java new file mode 100644 index 0000000000..ce2074a202 --- /dev/null +++ b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/jackson/ReadBytesWithJacksonImpl.java @@ -0,0 +1,29 @@ +package com.foo.somedifferentpackage.examples.jackson; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.evomaster.client.java.instrumentation.example.jackson.FooBaz; +import org.evomaster.client.java.instrumentation.example.jackson.ReadBytesWithJackson; +import org.springframework.kafka.support.serializer.JsonDeserializer; + +public class ReadBytesWithJacksonImpl implements ReadBytesWithJackson { + + private static final String TOPIC = "foo"; + + @Override + public Object readWithObjectReader(byte[] json) throws Exception { + return new ObjectMapper().readerFor(FooBaz.class).readValue(json); + } + + @Override + public Object readWithObjectMapper(byte[] json) throws Exception { + return new ObjectMapper().readValue(json, FooBaz.class); + } + + @Override + public Object readWithSpringKafkaJsonDeserializer(byte[] json) throws Exception { + // no type headers: the target type is the one given here, as with a typed @KafkaListener + try (JsonDeserializer deserializer = new JsonDeserializer<>(FooBaz.class, false)) { + return deserializer.deserialize(TOPIC, json); + } + } +} diff --git a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectMapperTest.java b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectMapperTest.java index d7c254e0df..25820dcdad 100644 --- a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectMapperTest.java +++ b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectMapperTest.java @@ -1,8 +1,11 @@ package org.evomaster.client.java.instrumentation.coverage.methodreplacement.thirdpartyclasses; +import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import org.evomaster.client.java.instrumentation.Action; import org.evomaster.client.java.instrumentation.AdditionalInfo; +import org.evomaster.client.java.instrumentation.coverage.methodreplacement.JsonUtils; import org.evomaster.client.java.instrumentation.shared.StringSpecialization; import org.evomaster.client.java.instrumentation.shared.StringSpecializationInfo; import org.evomaster.client.java.instrumentation.shared.TaintInputName; @@ -12,19 +15,45 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.*; import static org.evomaster.client.java.instrumentation.shared.StringSpecialization.JSON_ARRAY; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.evomaster.client.java.instrumentation.shared.StringSpecialization.JSON_OBJECT; +import static org.junit.jupiter.api.Assertions.*; public class JacksonObjectMapperTest { + private static final String DTO_JSON = "{\n\"count\": 10\n}"; + + /** + * Not ASCII, to check that the bytes Jackson reads are the original ones + */ + private static final String NON_ASCII_JSON = "{\"count\": \"ñandú €\"}"; + + private static final String NON_ASCII_VALUE = "ñandú €"; + + private static final int TAINT_ID = 42; + + private static final String ARRAY_SCHEMA = "\"type\":\"array\""; + + /** + * Length of a JSON string value that makes a document too long to be a tainted value + */ + private static final int LARGE_VALUE_LENGTH = 10_000; + + private static final String ACTION_NAME = "JacksonObjectMapperTest_action"; + @BeforeEach public void init(){ ObjectiveRecorder.reset(false); ObjectiveRecorder.setBooting(false); ExecutionTracer.reset(); + // otherwise a schema registered by an earlier test would make the parsed DTO checks pass on their own + UnitsInfoRecorder.reset(); // force the state as executing action ExecutionTracer.setExecutingAction(true); assertEquals(0 , ExecutionTracer.getNumberOfObjectives()); @@ -126,4 +155,281 @@ public void testReadDtoList() throws Throwable { assertEquals(JSON_ARRAY, info.getStringSpecialization()); assertTrue(info.getValue().contains("JacksonTestDto"), "Invalid type: " + info.getValue()); } -} \ No newline at end of file + + + private static void assertParsed(Class dto){ + Map parsedDto = UnitsInfoRecorder.getInstance().getParsedDtos(); + Set infoList = new HashSet<>(); + ExecutionTracer.exposeAdditionalInfoList().forEach(info -> infoList.addAll(info.getParsedDtoNamesView())); + assertTrue(parsedDto.containsKey(dto.getName()), "No schema registered for " + dto.getName()); + assertTrue(infoList.contains(dto.getName()), dto.getName() + " not recorded as parsed in this action"); + } + + /** + * @return the only string specialization recorded for the given taint + */ + private static StringSpecializationInfo singleSpecialization(String taint){ + Set ss = ExecutionTracer.exposeAdditionalInfoList().get(0) + .getStringSpecializationsView().get(taint); + assertNotNull(ss, "No string specialization for " + taint); + assertEquals(1, ss.size()); + return ss.iterator().next(); + } + + private static byte[] utf8(String s){ + return s.getBytes(StandardCharsets.UTF_8); + } + + @Test + public void testReadValueBytesClass() throws Throwable { + ObjectMapper objectMapper = new ObjectMapper(); + JacksonTestDto dto = JacksonObjectMapperClassReplacement.readValue(objectMapper, utf8(DTO_JSON), JacksonTestDto.class); + + assertEquals("10", dto.count); + assertParsed(JacksonTestDto.class); + } + + @Test + public void testReadValueBytesJavaType() throws Throwable { + ObjectMapper objectMapper = new ObjectMapper(); + JacksonTestDto dto = JacksonObjectMapperClassReplacement.readValue_EM_2(objectMapper, utf8(DTO_JSON), + objectMapper.constructType(JacksonTestDto.class)); + + assertEquals("10", dto.count); + assertParsed(JacksonTestDto.class); + } + + @Test + public void testReadValueBytesTypeReference() throws Throwable { + ObjectMapper objectMapper = new ObjectMapper(); + JacksonTestDto dto = JacksonObjectMapperClassReplacement.readValue_EM_3(objectMapper, utf8(DTO_JSON), + new TypeReference() { }); + + assertEquals("10", dto.count); + assertParsed(JacksonTestDto.class); + } + + @Test + public void testReadValueInputStreamTypeReference() throws Throwable { + ObjectMapper objectMapper = new ObjectMapper(); + InputStream src = new ByteArrayInputStream(utf8(DTO_JSON)); + JacksonTestDto dto = JacksonObjectMapperClassReplacement.readValue_EM_4(objectMapper, src, + new TypeReference() { }); + + assertEquals("10", dto.count); + assertParsed(JacksonTestDto.class); + } + + @Test + public void testReadValueBytesPassesTheOriginalBytes() throws Throwable { + ObjectMapper objectMapper = new ObjectMapper(); + + JacksonTestDto fromClass = JacksonObjectMapperClassReplacement.readValue(objectMapper, utf8(NON_ASCII_JSON), JacksonTestDto.class); + JacksonTestDto fromJavaType = JacksonObjectMapperClassReplacement.readValue_EM_2(objectMapper, utf8(NON_ASCII_JSON), + objectMapper.constructType(JacksonTestDto.class)); + JacksonTestDto fromTypeReference = JacksonObjectMapperClassReplacement.readValue_EM_3(objectMapper, utf8(NON_ASCII_JSON), + new TypeReference() { }); + JacksonTestDto fromStream = JacksonObjectMapperClassReplacement.readValue_EM_4(objectMapper, + new ByteArrayInputStream(utf8(NON_ASCII_JSON)), new TypeReference() { }); + + assertEquals(NON_ASCII_VALUE, fromClass.count); + assertEquals(NON_ASCII_VALUE, fromJavaType.count); + assertEquals(NON_ASCII_VALUE, fromTypeReference.count); + assertEquals(NON_ASCII_VALUE, fromStream.count); + } + + @Test + public void testTaintedBytes() throws Throwable { + String taint = TaintInputName.getTaintName(TAINT_ID); + ObjectMapper objectMapper = new ObjectMapper(); + + // a taint is not valid JSON, so Jackson fails after the analysis + assertThrows(Exception.class, () -> + JacksonObjectMapperClassReplacement.readValue(objectMapper, utf8(taint), JacksonTestDto.class)); + + StringSpecializationInfo info = singleSpecialization(taint); + assertEquals(JSON_OBJECT, info.getStringSpecialization()); + assertTrue(info.getValue().contains("JacksonTestDto"), "Invalid type: " + info.getValue()); + } + + @Test + public void testNonTaintedBytes() throws Throwable { + ObjectMapper objectMapper = new ObjectMapper(); + JacksonObjectMapperClassReplacement.readValue(objectMapper, utf8(DTO_JSON), JacksonTestDto.class); + + assertTrue(ExecutionTracer.exposeAdditionalInfoList().get(0).getStringSpecializationsView().isEmpty(), + "A document that is not a taint must not be specialized"); + } + + @Test + public void testReadDtoListFromBytes() throws Throwable { + String taint = TaintInputName.getTaintName(TAINT_ID); + ObjectMapper objectMapper = new ObjectMapper(); + + assertThrows(Exception.class, () -> + JacksonObjectMapperClassReplacement.readValue_EM_3(objectMapper, utf8(taint), + new TypeReference>() {})); + + StringSpecializationInfo info = singleSpecialization(taint); + assertEquals(JSON_ARRAY, info.getStringSpecialization()); + assertTrue(info.getValue().contains("JacksonTestDto"), "Invalid type: " + info.getValue()); + } + + @Test + public void testReadDtoArrayFromBytes() throws Throwable { + String taint = TaintInputName.getTaintName(TAINT_ID); + ObjectMapper objectMapper = new ObjectMapper(); + + assertThrows(Exception.class, () -> + JacksonObjectMapperClassReplacement.readValue_EM_2(objectMapper, utf8(taint), + objectMapper.constructType(JacksonTestDto[].class))); + + /* + As for a JacksonTestDto[] class given directly: a JSON object specialization whose schema + is an array of JacksonTestDto + */ + StringSpecializationInfo info = singleSpecialization(taint); + assertEquals(JSON_OBJECT, info.getStringSpecialization()); + assertTrue(info.getValue().contains(ARRAY_SCHEMA), "Invalid type: " + info.getValue()); + assertTrue(info.getValue().contains("JacksonTestDto"), "Invalid type: " + info.getValue()); + } + + @Test + public void testReadDtoListFromStream() throws Throwable { + String taint = TaintInputName.getTaintName(TAINT_ID); + ObjectMapper objectMapper = new ObjectMapper(); + + assertThrows(Exception.class, () -> + JacksonObjectMapperClassReplacement.readValue_EM_4(objectMapper, new ByteArrayInputStream(utf8(taint)), + new TypeReference>() {})); + + StringSpecializationInfo info = singleSpecialization(taint); + assertEquals(JSON_ARRAY, info.getStringSpecialization()); + assertTrue(info.getValue().contains("JacksonTestDto"), "Invalid type: " + info.getValue()); + } + + @Test + public void testNullBytes() { + ObjectMapper objectMapper = new ObjectMapper(); + + // whatever Jackson does with a null input, it is not the replacement that fails on it + assertThrows(IllegalArgumentException.class, () -> + JacksonObjectMapperClassReplacement.readValue(objectMapper, (byte[]) null, JacksonTestDto.class)); + assertThrows(IllegalArgumentException.class, () -> + JacksonObjectMapperClassReplacement.readValue_EM_2(objectMapper, null, objectMapper.constructType(JacksonTestDto.class))); + } + + @Test + public void testReadValueInputStreamTypeReferenceClosesTheSource() throws Throwable { + ObjectMapper objectMapper = new ObjectMapper(); + CloseTrackingStream src = new CloseTrackingStream(utf8(DTO_JSON)); + + JacksonObjectMapperClassReplacement.readValue_EM_4(objectMapper, src, new TypeReference() { }); + + assertTrue(src.closed, "AUTO_CLOSE_SOURCE is on by default, so the caller's stream must be closed"); + } + + @Test + public void testReadValueInputStreamTypeReferenceLeavesTheSourceOpen() throws Throwable { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, false); + CloseTrackingStream src = new CloseTrackingStream(utf8(DTO_JSON)); + + JacksonTestDto dto = JacksonObjectMapperClassReplacement.readValue_EM_4(objectMapper, src, + new TypeReference() { }); + + assertEquals("10", dto.count); + assertFalse(src.closed, "With AUTO_CLOSE_SOURCE off, the caller's stream must stay open"); + } + + @Test + public void testReadValueInputStreamTypeReferenceClosesTheSourceOnAFailedRead() { + ObjectMapper objectMapper = new ObjectMapper(); + FailingStream src = new FailingStream(); + + assertThrows(IOException.class, () -> JacksonObjectMapperClassReplacement.readValue_EM_4(objectMapper, src, + new TypeReference() { })); + + assertTrue(src.closed, "Jackson closes the source when a read fails and AUTO_CLOSE_SOURCE is on"); + } + + @Test + public void testReadValueInputStreamTypeReferenceLeavesTheSourceOpenOnAFailedRead() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, false); + FailingStream src = new FailingStream(); + + assertThrows(IOException.class, () -> JacksonObjectMapperClassReplacement.readValue_EM_4(objectMapper, src, + new TypeReference() { })); + + assertFalse(src.closed, "With AUTO_CLOSE_SOURCE off, the caller's stream must stay open"); + } + + @Test + public void testLargeBytesAreNotDecodedButRegisterTheSchema() throws Throwable { + // far longer than any taint name, and there are no input variables + String json = "{\"count\": \"" + new String(new char[LARGE_VALUE_LENGTH]).replace('\0', 'a') + "\"}"; + ObjectMapper objectMapper = new ObjectMapper(); + + // what the replacements call to get the text to check: null means no decoding + assertNull(JsonUtils.decodeUtf8IfCouldBeTaint(utf8(json))); + + JacksonObjectMapperClassReplacement.readValue(objectMapper, utf8(json), JacksonTestDto.class); + JacksonObjectMapperClassReplacement.readValue_EM_2(objectMapper, utf8(json), objectMapper.constructType(JacksonTestDto.class)); + JacksonObjectMapperClassReplacement.readValue_EM_3(objectMapper, utf8(json), new TypeReference() { }); + + assertParsed(JacksonTestDto.class); + assertTrue(ExecutionTracer.exposeAdditionalInfoList().get(0).getStringSpecializationsView().isEmpty()); + } + + @Test + public void testBytesMatchingAnInputVariable() throws Throwable { + // an input variable is any value the test registers, not necessarily a TaintInputName one + String variable = NON_ASCII_VALUE; + ExecutionTracer.setAction(new Action(0, ACTION_NAME, Collections.singleton(variable), + Collections.emptyMap(), Collections.emptyMap(), Collections.emptyList())); + ObjectMapper objectMapper = new ObjectMapper(); + + assertThrows(Exception.class, () -> + JacksonObjectMapperClassReplacement.readValue(objectMapper, utf8(variable), JacksonTestDto.class)); + + StringSpecializationInfo info = singleSpecialization(variable); + assertEquals(JSON_OBJECT, info.getStringSpecialization()); + } + + /** + * A stream that fails on every read, and remembers whether it was closed + */ + private static class FailingStream extends InputStream { + + private boolean closed = false; + + @Override + public int read() throws IOException { + throw new IOException("the stream cannot be read"); + } + + @Override + public void close() { + closed = true; + } + } + + /** + * A stream that remembers whether it was closed + */ + private static class CloseTrackingStream extends ByteArrayInputStream { + + private boolean closed = false; + + CloseTrackingStream(byte[] content) { + super(content); + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + } +} diff --git a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectReaderClassReplacementTest.java b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectReaderClassReplacementTest.java new file mode 100644 index 0000000000..0b20537666 --- /dev/null +++ b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/JacksonObjectReaderClassReplacementTest.java @@ -0,0 +1,206 @@ +package org.evomaster.client.java.instrumentation.coverage.methodreplacement.thirdpartyclasses; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; +import org.evomaster.client.java.instrumentation.AdditionalInfo; +import org.evomaster.client.java.instrumentation.shared.StringSpecialization; +import org.evomaster.client.java.instrumentation.shared.StringSpecializationInfo; +import org.evomaster.client.java.instrumentation.shared.TaintInputName; +import org.evomaster.client.java.instrumentation.staticstate.ExecutionTracer; +import org.evomaster.client.java.instrumentation.staticstate.ObjectiveRecorder; +import org.evomaster.client.java.instrumentation.staticstate.UnitsInfoRecorder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class JacksonObjectReaderClassReplacementTest { + + private static final String JSON = "{\"count\": \"10\"}"; + + /** + * Not ASCII, to check that the bytes Jackson reads are the original ones + */ + private static final String NON_ASCII_VALUE = "ñandú €"; + + private static final int TAINT_ID = 42; + + private static final String ARRAY_SCHEMA = "\"type\":\"array\""; + + @BeforeEach + public void init(){ + ObjectiveRecorder.reset(false); + ObjectiveRecorder.setBooting(false); + ExecutionTracer.reset(); + // otherwise a schema registered by an earlier test would make the parsed DTO checks pass on their own + UnitsInfoRecorder.reset(); + // force the state as executing action + ExecutionTracer.setExecutingAction(true); + assertEquals(0 , ExecutionTracer.getNumberOfObjectives()); + } + + private static Set parsedDtoNames(){ + Set names = new HashSet<>(); + ExecutionTracer.exposeAdditionalInfoList().forEach(info -> names.addAll(info.getParsedDtoNamesView())); + return names; + } + + private static void assertParsed(Class dto){ + Map parsedDto = UnitsInfoRecorder.getInstance().getParsedDtos(); + assertTrue(parsedDto.containsKey(dto.getName()), "No schema registered for " + dto.getName()); + assertTrue(parsedDtoNames().contains(dto.getName()), dto.getName() + " not recorded as parsed in this action"); + } + + /** + * @return the only string specialization recorded for the given taint + */ + private static StringSpecializationInfo singleSpecialization(String taint){ + List additionalInfoList = ExecutionTracer.exposeAdditionalInfoList(); + Set ss = additionalInfoList.get(0).getStringSpecializationsView().get(taint); + assertNotNull(ss, "No string specialization for " + taint); + assertEquals(1, ss.size()); + return ss.iterator().next(); + } + + private static int countSpecializations(){ + int count = 0; + for (AdditionalInfo info : ExecutionTracer.exposeAdditionalInfoList()) { + count += info.getStringSpecializationsView().size(); + } + return count; + } + + @Test + public void testReadValueBytes() throws Throwable { + ObjectReader reader = new ObjectMapper().readerFor(JacksonTestDto.class); + + JacksonTestDto dto = JacksonObjectReaderClassReplacement.readValue(reader, JSON.getBytes(StandardCharsets.UTF_8)); + + assertEquals("10", dto.count); + assertParsed(JacksonTestDto.class); + } + + @Test + public void testReadValueString() throws Throwable { + ObjectReader reader = new ObjectMapper().readerFor(JacksonTestDto.class); + + JacksonTestDto dto = JacksonObjectReaderClassReplacement.readValue(reader, JSON); + + assertEquals("10", dto.count); + assertParsed(JacksonTestDto.class); + } + + @Test + public void testReadValueBytesPassesTheOriginalBytes() throws Throwable { + ObjectReader reader = new ObjectMapper().readerFor(JacksonTestDto.class); + byte[] content = ("{\"count\": \"" + NON_ASCII_VALUE + "\"}").getBytes(StandardCharsets.UTF_8); + + JacksonTestDto dto = JacksonObjectReaderClassReplacement.readValue(reader, content); + + assertEquals(NON_ASCII_VALUE, dto.count); + } + + @Test + public void testTaintedBytes() throws Throwable { + String taint = TaintInputName.getTaintName(TAINT_ID); + ObjectReader reader = new ObjectMapper().readerFor(JacksonTestDto.class); + + // a taint is not valid JSON, so Jackson fails after the analysis + assertThrows(Exception.class, () -> + JacksonObjectReaderClassReplacement.readValue(reader, taint.getBytes(StandardCharsets.UTF_8))); + + StringSpecializationInfo info = singleSpecialization(taint); + assertEquals(StringSpecialization.JSON_OBJECT, info.getStringSpecialization()); + assertTrue(info.getValue().contains(JacksonTestDto.class.getSimpleName()), "Invalid type: " + info.getValue()); + } + + @Test + public void testNonTaintedBytes() throws Throwable { + ObjectReader reader = new ObjectMapper().readerFor(JacksonTestDto.class); + + JacksonObjectReaderClassReplacement.readValue(reader, JSON.getBytes(StandardCharsets.UTF_8)); + + assertEquals(0, countSpecializations(), "A document that is not a taint must not be specialized"); + } + + @Test + public void testTaintedBytesIntoList() throws Throwable { + String taint = TaintInputName.getTaintName(TAINT_ID); + ObjectReader reader = new ObjectMapper().readerFor(new TypeReference>() {}); + + assertThrows(Exception.class, () -> + JacksonObjectReaderClassReplacement.readValue(reader, taint.getBytes(StandardCharsets.UTF_8))); + + StringSpecializationInfo info = singleSpecialization(taint); + assertEquals(StringSpecialization.JSON_ARRAY, info.getStringSpecialization()); + assertTrue(info.getValue().contains(JacksonTestDto.class.getSimpleName()), "Invalid type: " + info.getValue()); + assertParsed(JacksonTestDto.class); + } + + @Test + public void testTaintedBytesIntoArray() throws Throwable { + String taint = TaintInputName.getTaintName(TAINT_ID); + ObjectReader reader = new ObjectMapper().readerFor(JacksonTestDto[].class); + + assertThrows(Exception.class, () -> + JacksonObjectReaderClassReplacement.readValue(reader, taint.getBytes(StandardCharsets.UTF_8))); + + /* + As in JacksonObjectMapperClassReplacement for a JacksonTestDto[] type: a JSON object specialization + whose schema is an array of JacksonTestDto + */ + StringSpecializationInfo info = singleSpecialization(taint); + assertEquals(StringSpecialization.JSON_OBJECT, info.getStringSpecialization()); + assertTrue(info.getValue().contains(ARRAY_SCHEMA), "Invalid type: " + info.getValue()); + assertTrue(info.getValue().contains(JacksonTestDto.class.getSimpleName()), "Invalid type: " + info.getValue()); + assertParsed(JacksonTestDto[].class); + } + + @Test + public void testReadListFromBytes() throws Throwable { + ObjectReader reader = new ObjectMapper().readerFor(new TypeReference>() {}); + + List dtos = JacksonObjectReaderClassReplacement.readValue(reader, + ("[" + JSON + "," + JSON + "]").getBytes(StandardCharsets.UTF_8)); + + assertEquals(2, dtos.size()); + assertParsed(JacksonTestDto.class); + } + + @Test + public void testReaderWithoutValueType() throws Throwable { + String taint = TaintInputName.getTaintName(TAINT_ID); + ObjectReader reader = new ObjectMapper().reader(); + + /* + Nothing to analyze, and Jackson itself refuses to read without a type: the replacement must + let that very same exception through. + */ + assertThrows(InvalidDefinitionException.class, () -> + JacksonObjectReaderClassReplacement.readValue(reader, JSON.getBytes(StandardCharsets.UTF_8))); + // a taint fails even earlier, as it is not JSON + assertThrows(Exception.class, () -> + JacksonObjectReaderClassReplacement.readValue(reader, taint.getBytes(StandardCharsets.UTF_8))); + + assertTrue(parsedDtoNames().isEmpty()); + assertEquals(0, countSpecializations()); + } + + @Test + public void testNullBytes() { + ObjectReader reader = new ObjectMapper().readerFor(JacksonTestDto.class); + + // whatever Jackson does with a null input, it is not the replacement that fails on it + assertThrows(IllegalArgumentException.class, () -> + JacksonObjectReaderClassReplacement.readValue(reader, (byte[]) null)); + assertTrue(parsedDtoNames().isEmpty()); + } +} diff --git a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/example/jackson/ReadBytesWithJackson.java b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/example/jackson/ReadBytesWithJackson.java new file mode 100644 index 0000000000..9d53baa20d --- /dev/null +++ b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/example/jackson/ReadBytesWithJackson.java @@ -0,0 +1,16 @@ +package org.evomaster.client.java.instrumentation.example.jackson; + +/** + * Reading a JSON payload that arrives as bytes, as a message listener does, into {@link FooBaz}. + */ +public interface ReadBytesWithJackson { + + Object readWithObjectReader(byte[] json) throws Exception; + + Object readWithObjectMapper(byte[] json) throws Exception; + + /** + * The Jackson call happens inside spring-kafka, and not in the code of the SUT + */ + Object readWithSpringKafkaJsonDeserializer(byte[] json) throws Exception; +} diff --git a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/example/jackson/ReadBytesWithJacksonTest.java b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/example/jackson/ReadBytesWithJacksonTest.java new file mode 100644 index 0000000000..f149e1815a --- /dev/null +++ b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/example/jackson/ReadBytesWithJacksonTest.java @@ -0,0 +1,124 @@ +package org.evomaster.client.java.instrumentation.example.jackson; + +import com.foo.somedifferentpackage.examples.jackson.ReadBytesWithJacksonImpl; +import org.evomaster.client.java.instrumentation.AdditionalInfo; +import org.evomaster.client.java.instrumentation.InputProperties; +import org.evomaster.client.java.instrumentation.InstrumentingClassLoader; +import org.evomaster.client.java.instrumentation.shared.StringSpecialization; +import org.evomaster.client.java.instrumentation.shared.StringSpecializationInfo; +import org.evomaster.client.java.instrumentation.shared.TaintInputName; +import org.evomaster.client.java.instrumentation.staticstate.ExecutionTracer; +import org.evomaster.client.java.instrumentation.staticstate.ObjectiveRecorder; +import org.evomaster.client.java.instrumentation.staticstate.UnitsInfoRecorder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The Jackson replacements for reads from bytes, applied by the instrumentation to the SUT code and, + * for spring-kafka's JsonDeserializer, to library code. + */ +public class ReadBytesWithJacksonTest { + + private static final String JSON = "{\"foo\":\"hello\", \"baz\":87878}"; + + private static final int TAINT_ID = 42; + + /** + * One way to read bytes with Jackson + */ + @FunctionalInterface + private interface Read { + Object apply(ReadBytesWithJackson sut, byte[] json) throws Exception; + } + + @BeforeEach + public void initTest() { + UnitsInfoRecorder.reset(); + ObjectiveRecorder.reset(true); + ExecutionTracer.reset(); + } + + private static ReadBytesWithJackson getInstance() throws Exception { + + System.setProperty(InputProperties.REPLACEMENT_CATEGORIES, "BASE,EXT_0"); + + InstrumentingClassLoader cl = new InstrumentingClassLoader("com.foo"); + + return (ReadBytesWithJackson) cl.loadClass(ReadBytesWithJacksonImpl.class.getName()).newInstance(); + } + + private static void checkDtoIsParsed(Read read) throws Exception { + ReadBytesWithJackson sut = getInstance(); + + Object obj = read.apply(sut, JSON.getBytes(StandardCharsets.UTF_8)); + FooBaz fooBaz = (FooBaz) obj; + assertEquals("hello", fooBaz.foo); + assertEquals(87878, fooBaz.baz); + + AdditionalInfo info = ExecutionTracer.exposeAdditionalInfoList().get(0); + Set names = info.getParsedDtoNamesView(); + assertEquals(1, names.size(), "Parsed DTOs: " + names); + assertEquals(FooBaz.class.getName(), names.iterator().next()); + + String schema = UnitsInfoRecorder.getInstance().getParsedDtos().get(FooBaz.class.getName()); + assertNotNull(schema); + assertTrue(schema.contains("foo")); + assertTrue(schema.contains("baz")); + assertTrue(info.getStringSpecializationsView().isEmpty(), "A document that is not a taint must not be specialized"); + } + + private static void checkTaintIsSpecialized(Read read) throws Exception { + ReadBytesWithJackson sut = getInstance(); + String taint = TaintInputName.getTaintName(TAINT_ID); + + // a taint is not valid JSON, so the read fails after the analysis + assertThrows(Exception.class, () -> read.apply(sut, taint.getBytes(StandardCharsets.UTF_8))); + + AdditionalInfo info = ExecutionTracer.exposeAdditionalInfoList().get(0); + Set ss = info.getStringSpecializationsView().get(taint); + assertNotNull(ss, "No string specialization for the taint"); + assertEquals(1, ss.size()); + StringSpecializationInfo ssi = ss.iterator().next(); + assertEquals(StringSpecialization.JSON_OBJECT, ssi.getStringSpecialization()); + assertTrue(ssi.getValue().contains(FooBaz.class.getSimpleName()), "Invalid type: " + ssi.getValue()); + } + + @Test + public void testObjectReader() throws Exception { + checkDtoIsParsed(ReadBytesWithJackson::readWithObjectReader); + } + + @Test + public void testObjectReaderTaint() throws Exception { + checkTaintIsSpecialized(ReadBytesWithJackson::readWithObjectReader); + } + + @Test + public void testObjectMapper() throws Exception { + checkDtoIsParsed(ReadBytesWithJackson::readWithObjectMapper); + } + + @Test + public void testObjectMapperTaint() throws Exception { + checkTaintIsSpecialized(ReadBytesWithJackson::readWithObjectMapper); + } + + @Test + public void testSpringKafkaJsonDeserializer() throws Exception { + /* + The SUT calls no Jackson method itself, so FooBaz can only be registered by the replacement + applied inside JsonDeserializer.deserialize + */ + checkDtoIsParsed(ReadBytesWithJackson::readWithSpringKafkaJsonDeserializer); + } + + @Test + public void testSpringKafkaJsonDeserializerTaint() throws Exception { + checkTaintIsSpecialized(ReadBytesWithJackson::readWithSpringKafkaJsonDeserializer); + } +} diff --git a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracerTest.java b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracerTest.java index 5aa54eef95..62a1700700 100644 --- a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracerTest.java +++ b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/staticstate/ExecutionTracerTest.java @@ -1,5 +1,11 @@ package org.evomaster.client.java.instrumentation.staticstate; +import org.evomaster.client.java.instrumentation.Action; +import org.evomaster.client.java.instrumentation.shared.TaintInputName; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,6 +23,17 @@ public class ExecutionTracerTest { private static final String OTHER_COMPARISON = "ExecutionTracerTest_other"; + private static final String ACTION_NAME = "ExecutionTracerTest_action"; + + /** + * An input variable of 3 chars: 9 bytes at most once encoded in UTF-8 + */ + private static final String VARIABLE = "abc"; + + private static final String NINE_BYTES = "123456789"; + + private static final String TEN_BYTES = "1234567890"; + @BeforeEach @AfterEach public void reset() { @@ -72,6 +89,50 @@ public void testOnlyABetterValueCounts() { assertEquals(first + 1, ExecutionTracer.getObjectiveUpdateCount()); } + @Test + public void testATaintPrefixCouldBeTaintWhateverTheLength() { + String taint = TaintInputName.getTaintName(0); + + assertCouldBeTaint(true, taint); + assertCouldBeTaint(true, taint + TEN_BYTES + TEN_BYTES); + } + + @Test + public void testWithoutInputVariablesOtherBytesCannotBeTaint() { + assertCouldBeTaint(false, VARIABLE); + assertCouldBeTaint(false, TEN_BYTES); + } + + @Test + public void testTheLongestInputVariableBoundsTheLength() { + setInputVariable(VARIABLE); + + // any 9 bytes, not only the variable itself: the check does not decode + assertCouldBeTaint(true, VARIABLE); + assertCouldBeTaint(true, NINE_BYTES); + assertCouldBeTaint(false, TEN_BYTES); + } + + @Test + public void testResetForgetsTheInputVariables() { + setInputVariable(VARIABLE); + assertCouldBeTaint(true, VARIABLE); + + ExecutionTracer.reset(); + + assertCouldBeTaint(false, VARIABLE); + } + + private static void setInputVariable(String variable) { + ExecutionTracer.setAction(new Action(0, ACTION_NAME, Collections.singleton(variable), + Collections.emptyMap(), Collections.emptyMap(), Collections.emptyList())); + } + + private static void assertCouldBeTaint(boolean expected, String value) { + assertEquals(expected, ExecutionTracer.couldBeTaintInput(value.getBytes(StandardCharsets.UTF_8)), + "Wrong answer for \"" + value + "\""); + } + @Test public void testEachTestStartsFromZero() { diff --git a/client-java/pom.xml b/client-java/pom.xml index f7dabbae3a..978c382859 100644 --- a/client-java/pom.xml +++ b/client-java/pom.xml @@ -45,6 +45,8 @@ 2.48 2.5.4 + + 2.7.6 @@ -102,6 +104,17 @@ ${jersey.version} + + + org.springframework.kafka + spring-kafka + ${spring-kafka.version} + test + +