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 463a26b131..2c6cbf31a9 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 + +