cyclonedx-core-java icon indicating copy to clipboard operation
cyclonedx-core-java copied to clipboard

Xml SBOM parsing not thread safe

Open Lajcik opened this issue 10 months ago • 1 comments

Parsing xml sboms is not done in a thread-safe manner. When running parsing concurrently in multiple threads there is a chance to randomly crash with an exception like this:

Caused by: com.fasterxml.jackson.databind.JsonMappingException: For input string: "20242024202420242024" (through reference chain: org.cyclonedx.model.Bom["metadata"])
	at com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:401) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:360) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.wrapAndThrow(BeanDeserializerBase.java:1964) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:312) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:177) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.dataformat.xml.deser.WrapperHandlingDeserializer.deserialize(WrapperHandlingDeserializer.java:122) ~[jackson-dataformat-xml-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.dataformat.xml.deser.XmlDeserializationContext.readRootValue(XmlDeserializationContext.java:104) ~[jackson-dataformat-xml-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4917) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3725) ~[jackson-databind-2.18.2.jar:2.18.2]
	at org.cyclonedx.parsers.XmlParser.parse(XmlParser.java:86) ~[cyclonedx-core-java-10.1.0.jar:na]
	... 7 common frames omitted
Caused by: java.lang.NumberFormatException: For input string: "20242024202420242024"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67) ~[na:na]
	at java.base/java.lang.Long.parseLong(Long.java:709) ~[na:na]
	at java.base/java.lang.Long.parseLong(Long.java:832) ~[na:na]
	at java.base/java.text.DigitList.getLong(DigitList.java:196) ~[na:na]
	at java.base/java.text.DecimalFormat.parse(DecimalFormat.java:2228) ~[na:na]
	at java.base/java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1937) ~[na:na]
	at java.base/java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1545) ~[na:na]
	at java.base/java.text.DateFormat.parse(DateFormat.java:397) ~[na:na]
	at org.cyclonedx.util.TimestampUtils.parseTimestamp(TimestampUtils.java:33) ~[cyclonedx-core-java-10.1.0.jar:na]
	at org.cyclonedx.util.deserializer.MetadataDeserializer.setTimestamp(MetadataDeserializer.java:136) ~[cyclonedx-core-java-10.1.0.jar:na]
	at org.cyclonedx.util.deserializer.MetadataDeserializer.deserialize(MetadataDeserializer.java:79) ~[cyclonedx-core-java-10.1.0.jar:na]
	at org.cyclonedx.util.deserializer.MetadataDeserializer.deserialize(MetadataDeserializer.java:22) ~[cyclonedx-core-java-10.1.0.jar:na]
	at com.fasterxml.jackson.databind.deser.impl.MethodProperty.deserializeAndSet(MethodProperty.java:129) ~[jackson-databind-2.18.2.jar:2.18.2]
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:310) ~[jackson-databind-2.18.2.jar:2.18.2]
	... 13 common frames omitted

The culprit seems to be the org.cyclonedx.util.TimestampUtils.parseTimestamp(TimestampUtils.java:33) method which uses a static singleton instance of SimpleDateFormat which is not thread safe. SDF relies on an instance of Calendar which is the actual culprit here.

A quick fix would be to use the new DateTimeFormatter class that is thread safe. Though you'd have to convert the parsing result to java.util.Date or rewrite the whole code to use the new time classes (like LocalDateTime) instead of java.util.Date.

A even quicker fix would be to simply make an instance of SDF (or TimestampUtils) per instance of MetadataDeserializer instead of a single static instance.

Code to reproduce the error

Below is a bit of code to reproduce this (randomly), obviously you need a bunch of sbom files in the inputdir

class Scratch {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(20);
        List<File> files = List.of(new File("inputdir").listFiles())
        List<Future<?>> futures = new ArrayList<>();
        for (File input : files) {
            futures.add(executorService.submit(new ParseTask(input)));
        }
        // wait for all tasks to finish
        futures.forEach(f -> {
            try {
                f.get();
            } catch (InterruptedException | ExecutionException e) {
                //
            }
        });
        executorService.shutdown();
    }

    private static class ParseTask implements Runnable {
        private final File input;

        public ParseTask(File input) {
            this.input = input;
        }

        public void run() {
            try {
                Parser parser = BomParserFactory.createParser(input);
                Bom bom = parser.parse(input);
            } catch (ParseException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

Workaround

The quickest workaround for now is to make a of copy the MetadataDeserializer class, replace the setTimestamp() method with a thread-safe implementation and use mixins to override the serialisation (by manipulating the mapper field on XmlParser through reflection)

        XmlParser xmlParser = new XmlParser();
        try {
            Field mapperField = XmlParser.class.getDeclaredField("mapper");
            mapperField.setAccessible(true);
            ObjectMapper mapper = (ObjectMapper) mapperField.get(xmlParser);
            mapper.registerModule(new CycloneDxMetadataThreadSafetyFixModule());
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
public class CycloneDxMetadataThreadSafetyFixModule extends SimpleModule {
    @Override
    public void setupModule(SetupContext context) {
        context.setMixInAnnotations(Metadata.class, MetadataMixin.class);
    }

    @JsonDeserialize(using = ThreadSafeMetadataDeserializer.class)
    public abstract static class MetadataMixin {
    }
}

// the ThreadSafeMetadataDeserializer is a vanilla copy of MetadataDeserializer that calls ThreadSafeTimestampUtils instead of TimestampUtils

public final class ThreadSafeTimestampUtils {
    public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX");

    private ThreadSafeTimestampUtils() {
    }

    public static Date parseTimestamp(String text) {
        try {
            TemporalAccessor parsed = FORMATTER.parse(text);
            LocalDateTime localDateTime = LocalDateTime.from(parsed);
            return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
        } catch (NullPointerException e) {
            return null;
        }
    }
}

This is obviously a pretty hacky way to fix this, but it solves the problem locally :)

Lajcik avatar Apr 09 '25 10:04 Lajcik

hey @Lajcik what version of the library are you using? there was a fix with this class and it's not using DateTimeFormatter anymore

mr-zepol avatar Apr 24 '25 13:04 mr-zepol