diff --git a/core/src/main/java/org/owasp/dependencycheck/analyzer/OssIndexAnalyzer.java b/core/src/main/java/org/owasp/dependencycheck/analyzer/OssIndexAnalyzer.java index def563e5cd3..dcd7e28a57b 100644 --- a/core/src/main/java/org/owasp/dependencycheck/analyzer/OssIndexAnalyzer.java +++ b/core/src/main/java/org/owasp/dependencycheck/analyzer/OssIndexAnalyzer.java @@ -26,6 +26,7 @@ import org.owasp.dependencycheck.analyzer.exception.AnalysisException; import org.owasp.dependencycheck.data.ossindex.OssIndexClientProvider; import org.owasp.dependencycheck.dependency.Dependency; +import org.owasp.dependencycheck.dependency.Reference; import org.owasp.dependencycheck.dependency.Vulnerability; import org.owasp.dependencycheck.dependency.VulnerableSoftware; import org.owasp.dependencycheck.dependency.VulnerableSoftwareBuilder; @@ -51,10 +52,12 @@ import javax.annotation.Nullable; import java.net.SocketTimeoutException; +import java.net.URI; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.function.Function; @@ -329,7 +332,8 @@ void enrich(final Dependency dependency) { } // expose the URL to the package details for report generation - id.setUrl(report.getReference().toString()); + Optional.ofNullable(report.getReference()) + .ifPresent(ref -> id.setUrl(ref.toString())); report.getVulnerabilities().stream() .map((vuln) -> transform(report, vuln)) @@ -425,11 +429,15 @@ private Vulnerability transform(final ComponentReport report, final ComponentRep result.setUnscoredSeverity(Double.toString(cvssScore)); } // generate a reference to the vulnerability details on OSS Index - result.addReference(REFERENCE_TYPE, source.getTitle(), source.getReference().toString()); + Optional.ofNullable(source.getReference()) + .ifPresent(ref -> result.addReference(REFERENCE_TYPE, source.getTitle(), ref.toString())); // generate references to other references reported by OSS Index - source.getExternalReferences().forEach(externalReference - -> result.addReference("OSSIndex", externalReference.toString(), externalReference.toString())); + source.getExternalReferences().stream() + .filter(Objects::nonNull) + .map(URI::toString) + .map(ref -> new Reference(REFERENCE_TYPE, ref, ref)) + .forEach(result::addReference); // attach vulnerable software details as best we can final PackageUrl purl = report.getCoordinates(); diff --git a/core/src/main/java/org/owasp/dependencycheck/data/ossindex/LenientGsonMarshaller.java b/core/src/main/java/org/owasp/dependencycheck/data/ossindex/LenientGsonMarshaller.java new file mode 100644 index 00000000000..95007ccc693 --- /dev/null +++ b/core/src/main/java/org/owasp/dependencycheck/data/ossindex/LenientGsonMarshaller.java @@ -0,0 +1,122 @@ +package org.owasp.dependencycheck.data.ossindex; + +import com.google.common.reflect.TypeToken; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonIOException; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.gson.TypeAdapter; +import com.google.gson.internal.bind.TypeAdapters; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import org.sonatype.goodies.packageurl.PackageUrl; +import org.sonatype.ossindex.service.client.marshal.Marshaller; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.lang.reflect.Type; +import java.net.URI; + +/** + * Unmarshaller which will unmarshal nulls for any URIs which are not strictly valid. + */ +class LenientGsonMarshaller implements Marshaller { + private final Gson lenientGson; + + public LenientGsonMarshaller() { + lenientGson = new GsonBuilder() + .registerTypeAdapter(PackageUrl.class, new PackageUrlAdapter()) + .registerTypeAdapter(URI.class, new LenientUriAdapter()) + .create(); + } + + @Override + public String marshal(final Object value) throws IOException { + try { + return lenientGson.toJson(value); + } catch (JsonParseException e) { + throw new IOException(e); + } + } + + @Override + public void marshal(final Object value, final Writer writer) throws IOException { + try { + lenientGson.toJson(value, writer); + writer.flush(); + } catch (JsonParseException e) { + throw new IOException(e); + } + } + + @Override + public T unmarshal(String value, Class type) throws IOException { + try { + return lenientGson.fromJson(value, type); + } catch (JsonParseException e) { + throw new IOException(e); + } + } + + @Override + public T unmarshal(Reader reader, Class type) throws IOException { + try { + return lenientGson.fromJson(reader, type); + } catch (JsonParseException e) { + throw new IOException(e); + } + } + + @Override + public T unmarshal(String value, TypeToken type) throws IOException { + try { + return lenientGson.fromJson(value, type.getType()); + } catch (JsonParseException e) { + throw new IOException(e); + } + } + + @Override + public T unmarshal(Reader reader, TypeToken type) throws IOException { + try { + return lenientGson.fromJson(reader, type.getType()); + } catch (JsonParseException e) { + throw new IOException(e); + } + } + + private static class PackageUrlAdapter implements JsonDeserializer, JsonSerializer { + @Override + public PackageUrl deserialize(final JsonElement element, final Type type, final JsonDeserializationContext context) { + return PackageUrl.parse(element.getAsString()); + } + + @Override + public JsonElement serialize(final PackageUrl value, final Type type, final JsonSerializationContext context) { + return new JsonPrimitive(value.toString()); + } + } + + private static class LenientUriAdapter extends TypeAdapter { + @Override + public URI read(JsonReader in) throws IOException { + try { + return TypeAdapters.URI.read(in); + } catch (JsonIOException e) { + return null; + } + } + + @Override + public void write(JsonWriter out, URI value) throws IOException { + TypeAdapters.URI.write(out, value); + } + } +} diff --git a/core/src/main/java/org/owasp/dependencycheck/data/ossindex/OssIndexClientProvider.java b/core/src/main/java/org/owasp/dependencycheck/data/ossindex/OssIndexClientProvider.java index 1efb07de676..ad3aa032155 100644 --- a/core/src/main/java/org/owasp/dependencycheck/data/ossindex/OssIndexClientProvider.java +++ b/core/src/main/java/org/owasp/dependencycheck/data/ossindex/OssIndexClientProvider.java @@ -27,7 +27,6 @@ import org.sonatype.ossindex.service.client.OssindexClientConfiguration; import org.sonatype.ossindex.service.client.cache.DirectoryCache; import org.sonatype.ossindex.service.client.internal.OssindexClientImpl; -import org.sonatype.ossindex.service.client.marshal.GsonMarshaller; import org.sonatype.ossindex.service.client.marshal.Marshaller; import org.sonatype.ossindex.service.client.transport.AuthConfiguration; import org.sonatype.ossindex.service.client.transport.Transport; @@ -110,7 +109,7 @@ public static OssindexClient create(final Settings settings) { final Transport transport = new ODCConnectionTransport(config, userAgent); - final Marshaller marshaller = new GsonMarshaller(); + final Marshaller marshaller = new LenientGsonMarshaller(); return newClientFor(config, transport, marshaller); } diff --git a/core/src/test/java/org/owasp/dependencycheck/data/ossindex/LenientGsonMarshallerTest.java b/core/src/test/java/org/owasp/dependencycheck/data/ossindex/LenientGsonMarshallerTest.java new file mode 100644 index 00000000000..693b080b419 --- /dev/null +++ b/core/src/test/java/org/owasp/dependencycheck/data/ossindex/LenientGsonMarshallerTest.java @@ -0,0 +1,154 @@ +package org.owasp.dependencycheck.data.ossindex; + +import com.google.common.reflect.TypeToken; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.sonatype.goodies.packageurl.PackageUrl; +import org.sonatype.ossindex.service.api.componentreport.ComponentReport; +import org.sonatype.ossindex.service.api.componentreport.ComponentReportVulnerability; + +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +class LenientGsonMarshallerTest { + + /** + * The same type the OSS Index client uses to unmarshal responses. + */ + private static final TypeToken> LIST_COMPONENT_REPORT = new TypeToken<>() {}; + + private static final String PURL = "pkg:maven/com.mysql/mysql-connector-j@26.7.0"; + private static final String VALID_URI = "https://example.com/component/" + PURL; + private static final String INVALID_URI = "https://example.com/path with spaces/{and}/braces"; + + private final LenientGsonMarshaller marshaller = new LenientGsonMarshaller(); + + private static String reportJson(String reportReference, String vulnReference, String externalReferences) { + return "{" + + "\"coordinates\":\"" + PURL + "\"," + + "\"description\":\"MySQL Connector/J\"," + + "\"reference\":" + reportReference + "," + + "\"vulnerabilities\":[{" + + " \"id\":\"CVE-2026-0001\"," + + " \"displayName\":\"CVE-2026-0001\"," + + " \"title\":\"[CVE-2026-0001] Something\"," + + " \"cvssScore\":7.5," + + " \"reference\":" + vulnReference + "," + + " \"externalReferences\":" + externalReferences + + "}]" + + "}"; + } + + private static String quoted(String value) { + return "\"" + value + "\""; + } + + private ComponentReport unmarshalSingleReport(String json) throws IOException { + List reports = marshaller.unmarshal("[" + json + "]", LIST_COMPONENT_REPORT); + assertThat(reports, hasSize(1)); + return reports.get(0); + } + + @Nested + class Uris { + + @Test + void validUrisAreParsedAsNormal() throws IOException { + String json = reportJson(quoted(VALID_URI), quoted(VALID_URI + "#vuln"), "[" + quoted(VALID_URI + "#ext") + "]"); + + ComponentReport report = unmarshalSingleReport(json); + + assertThat(report.getReference(), is(URI.create(VALID_URI))); + ComponentReportVulnerability vuln = report.getVulnerabilities().get(0); + assertThat(vuln.getReference(), is(URI.create(VALID_URI + "#vuln"))); + assertThat(vuln.getExternalReferences(), contains(URI.create(VALID_URI + "#ext"))); + } + + @Test + void invalidComponentReferenceBecomesNullAndRestOfReportSurvives() throws IOException { + String json = reportJson(quoted(INVALID_URI), quoted(VALID_URI), "[]"); + + ComponentReport report = unmarshalSingleReport(json); + + assertThat(report.getReference(), nullValue()); + assertThat(report.getCoordinates(), is(PackageUrl.parse(PURL))); + assertThat(report.getDescription(), is("MySQL Connector/J")); + assertThat(report.getVulnerabilities().get(0).getReference(), is(URI.create(VALID_URI))); + } + + @Test + void invalidVulnerabilityReferenceBecomesNullAndRestOfVulnerabilitySurvives() throws IOException { + String json = reportJson(quoted(VALID_URI), quoted(INVALID_URI), "[]"); + + ComponentReportVulnerability vuln = unmarshalSingleReport(json).getVulnerabilities().get(0); + + assertThat(vuln.getReference(), nullValue()); + assertThat(vuln.getId(), is("CVE-2026-0001")); + assertThat(vuln.getCvssScore(), is(7.5f)); + } + + @Test + void invalidExternalReferenceBecomesNullEntryWhileValidOnesAreKept() throws IOException { + String json = reportJson(quoted(VALID_URI), quoted(VALID_URI), + "[" + quoted(VALID_URI + "#one") + "," + quoted(INVALID_URI) + "," + quoted(VALID_URI + "#two") + "]"); + + ComponentReportVulnerability vuln = unmarshalSingleReport(json).getVulnerabilities().get(0); + + assertThat(vuln.getExternalReferences(), contains(URI.create(VALID_URI + "#one"), null, URI.create(VALID_URI + "#two"))); + } + + @Test + void jsonNullUrisRemainNull() throws IOException { + String json = reportJson("null", "null", "null"); + + ComponentReport report = unmarshalSingleReport(json); + + assertThat(report.getReference(), nullValue()); + assertThat(report.getVulnerabilities().get(0).getReference(), nullValue()); + // ComponentReportVulnerability exposes an empty list rather than null for missing external references + assertThat(report.getVulnerabilities().get(0).getExternalReferences(), is(empty())); + } + + @Test + void leniencyAppliesToEveryUnmarshalOverload() throws IOException { + String json = "{\"coordinates\":\"" + PURL + "\",\"reference\":" + quoted(INVALID_URI) + "}"; + String listJson = "[" + json + "]"; + + assertThat(marshaller.unmarshal(json, ComponentReport.class).getReference(), nullValue()); + assertThat(marshaller.unmarshal(new StringReader(json), ComponentReport.class).getReference(), nullValue()); + assertThat(marshaller.unmarshal(listJson, LIST_COMPONENT_REPORT).get(0).getReference(), nullValue()); + assertThat(marshaller.unmarshal(new StringReader(listJson), LIST_COMPONENT_REPORT).get(0).getReference(), nullValue()); + } + + @Test + void urisAreMarshalledAsStrings() throws IOException { + ComponentReport report = new ComponentReport(); + report.setReference(URI.create(VALID_URI)); + + String json = marshaller.marshal(report); + + assertThat(json, containsString("\"reference\":\"" + VALID_URI + "\"")); + assertThat(marshaller.unmarshal(json, ComponentReport.class).getReference(), is(URI.create(VALID_URI))); + } + } + + @Test + void packageUrlRoundTripsThroughMarshalAndUnmarshal() throws IOException { + ComponentReport original = new ComponentReport(); + original.setCoordinates(PackageUrl.parse(PURL)); + + ComponentReport copy = marshaller.unmarshal(marshaller.marshal(original), ComponentReport.class); + + assertThat(copy.getCoordinates(), is(original.getCoordinates())); + } +} diff --git a/core/src/test/java/org/owasp/dependencycheck/data/ossindex/OssIndexClientProviderTest.java b/core/src/test/java/org/owasp/dependencycheck/data/ossindex/OssIndexClientProviderTest.java index 9532629589f..bfd1ff71d71 100644 --- a/core/src/test/java/org/owasp/dependencycheck/data/ossindex/OssIndexClientProviderTest.java +++ b/core/src/test/java/org/owasp/dependencycheck/data/ossindex/OssIndexClientProviderTest.java @@ -12,7 +12,6 @@ import org.owasp.dependencycheck.utils.Settings; import org.sonatype.ossindex.service.client.OssindexClientConfiguration; import org.sonatype.ossindex.service.client.cache.DirectoryCache; -import org.sonatype.ossindex.service.client.marshal.GsonMarshaller; import org.sonatype.ossindex.service.client.marshal.Marshaller; import org.sonatype.ossindex.service.client.transport.Transport; @@ -58,7 +57,7 @@ void shouldUseDefaultConfiguration() { ODCConnectionTransport transport = assertTransportConfigured(); assertThat(transport.getUserAgent().get(), matchesPattern("dependency-check/.+ \\(.*\\)")); - assertThat(marshallerCaptor.getValue(), instanceOf(GsonMarshaller.class)); + assertThat(marshallerCaptor.getValue(), instanceOf(LenientGsonMarshaller.class)); } }