diff --git a/sdk/src/main/java/com/contrastsecurity/sdk/ContrastSDK.java b/sdk/src/main/java/com/contrastsecurity/sdk/ContrastSDK.java
index 6f516270..bd1e714c 100755
--- a/sdk/src/main/java/com/contrastsecurity/sdk/ContrastSDK.java
+++ b/sdk/src/main/java/com/contrastsecurity/sdk/ContrastSDK.java
@@ -81,6 +81,8 @@
import com.contrastsecurity.models.VulnerabilityTrend;
import com.contrastsecurity.models.dtm.ApplicationCreateRequest;
import com.contrastsecurity.models.dtm.AttestationCreateRequest;
+import com.contrastsecurity.sdk.graph.ContrastGraphApi;
+import com.contrastsecurity.sdk.graph.ContrastGraphApiImpl;
import com.contrastsecurity.sdk.internal.GsonFactory;
import com.contrastsecurity.sdk.scan.ScanManager;
import com.contrastsecurity.sdk.scan.ScanManagerImpl;
@@ -221,6 +223,10 @@ public ScanManager scan(final String organizationId) {
return new ScanManagerImpl(this, gson, organizationId);
}
+ public ContrastGraphApi graphApi() {
+ return new ContrastGraphApiImpl(this, this.gson);
+ }
+
/**
* Gets the global properties from TeamServer.
*
@@ -1587,6 +1593,40 @@ public InputStream makeRequest(HttpMethod method, String path)
return makeRequestWithResponse(method, path).is;
}
+ public InputStream makeRequestToUrl(HttpMethod method, String url)
+ throws IOException, UnauthorizedException {
+ HttpURLConnection connection = makeConnection(url, method.toString());
+ int rc = connection.getResponseCode();
+ if (rc >= HttpURLConnection.HTTP_BAD_REQUEST) {
+ throw HttpResponseException.fromConnection(
+ connection, "Received unexpected status code from Contrast");
+ }
+ return connection.getInputStream();
+ }
+
+ public InputStream makeRequestWithBodyToUrl(
+ HttpMethod method, String url, String body, MediaType mediaType)
+ throws IOException, UnauthorizedException {
+ HttpURLConnection connection = makeConnection(url, method.toString());
+ if (mediaType != null
+ && body != null
+ && (method.equals(HttpMethod.PUT)
+ || method.equals(HttpMethod.POST)
+ || method.equals(HttpMethod.DELETE))) {
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type", mediaType.getType());
+ try (OutputStream os = connection.getOutputStream()) {
+ os.write(body.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ int rc = connection.getResponseCode();
+ if (rc >= HttpURLConnection.HTTP_BAD_REQUEST) {
+ throw HttpResponseException.fromConnection(
+ connection, "Received unexpected status code from Contrast");
+ }
+ return connection.getInputStream();
+ }
+
public MakeRequestResponse makeRequestWithResponse(HttpMethod method, String path)
throws IOException, UnauthorizedException {
String url = restApiURL + path;
diff --git a/sdk/src/main/java/com/contrastsecurity/sdk/JSON.java b/sdk/src/main/java/com/contrastsecurity/sdk/JSON.java
new file mode 100644
index 00000000..6cf8c4f2
--- /dev/null
+++ b/sdk/src/main/java/com/contrastsecurity/sdk/JSON.java
@@ -0,0 +1,39 @@
+/*-
+ * #%L
+ * Contrast Java SDK
+ * %%
+ * Copyright (C) 2022 - 2026 Contrast Security, Inc.
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.contrastsecurity.sdk;
+
+import com.contrastsecurity.sdk.internal.GsonFactory;
+import com.google.gson.Gson;
+
+/**
+ * Gson utility required by generated graph model classes (openapi-generator okhttp-gson output).
+ *
+ * Delegates to {@link GsonFactory} so generated models share the same Gson configuration as the
+ * rest of the SDK.
+ */
+public final class JSON {
+
+ private JSON() {}
+
+ public static Gson getGson() {
+ return GsonFactory.create();
+ }
+}
diff --git a/sdk/src/main/java/com/contrastsecurity/sdk/graph/ContrastGraphApi.java b/sdk/src/main/java/com/contrastsecurity/sdk/graph/ContrastGraphApi.java
new file mode 100644
index 00000000..c08b7666
--- /dev/null
+++ b/sdk/src/main/java/com/contrastsecurity/sdk/graph/ContrastGraphApi.java
@@ -0,0 +1,45 @@
+/*-
+ * #%L
+ * Contrast Java SDK
+ * %%
+ * Copyright (C) 2022 - 2026 Contrast Security, Inc.
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.contrastsecurity.sdk.graph;
+
+import java.io.IOException;
+
+public interface ContrastGraphApi {
+
+ ContrastGraphResponse searchGraph(String organizationId, ContrastGraphRequest request)
+ throws IOException;
+
+ ContrastGraphResponse getIncidentGraph(String organizationId, String incidentId)
+ throws IOException;
+
+ FacetsResponse getFacets(String organizationId, String filterName, RequestFilters filters)
+ throws IOException;
+
+ ApplicationLibrariesResponse getApplicationLibraries(
+ String organizationId,
+ String applicationId,
+ String agentReportingInstanceId,
+ ApplicationLibrariesRequest request)
+ throws IOException;
+
+ LibraryDetailsResponse getApplicationLibraryDetails(
+ String organizationId, String applicationId, String libraryHash) throws IOException;
+}
diff --git a/sdk/src/main/java/com/contrastsecurity/sdk/graph/ContrastGraphApiImpl.java b/sdk/src/main/java/com/contrastsecurity/sdk/graph/ContrastGraphApiImpl.java
new file mode 100644
index 00000000..cc960239
--- /dev/null
+++ b/sdk/src/main/java/com/contrastsecurity/sdk/graph/ContrastGraphApiImpl.java
@@ -0,0 +1,153 @@
+/*-
+ * #%L
+ * Contrast Java SDK
+ * %%
+ * Copyright (C) 2022 - 2026 Contrast Security, Inc.
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.contrastsecurity.sdk.graph;
+
+import com.contrastsecurity.exceptions.ServerResponseException;
+import com.contrastsecurity.http.HttpMethod;
+import com.contrastsecurity.http.MediaType;
+import com.contrastsecurity.sdk.ContrastSDK;
+import com.contrastsecurity.sdk.internal.URIBuilder;
+import com.contrastsecurity.utils.ContrastSDKUtils;
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+
+public final class ContrastGraphApiImpl implements ContrastGraphApi {
+
+ private final ContrastSDK contrast;
+ private final Gson gson;
+ private final String graphApiBase;
+
+ public ContrastGraphApiImpl(final ContrastSDK contrast, final Gson gson) {
+ this.contrast = contrast;
+ this.gson = gson;
+ this.graphApiBase = ContrastSDKUtils.getServerUrl(contrast.getRestApiURL()) + "/api";
+ }
+
+ @Override
+ public ContrastGraphResponse searchGraph(
+ final String organizationId, final ContrastGraphRequest request) throws IOException {
+ final String url =
+ graphApiBase
+ + new URIBuilder()
+ .appendPathSegments("v2", "organizations", organizationId, "contrast-graph")
+ .toURIString();
+ return post(url, gson.toJson(request), ContrastGraphResponse.class);
+ }
+
+ @Override
+ public ContrastGraphResponse getIncidentGraph(
+ final String organizationId, final String incidentId) throws IOException {
+ final String url =
+ graphApiBase
+ + new URIBuilder()
+ .appendPathSegments(
+ "v2",
+ "organizations",
+ organizationId,
+ "contrast-graph",
+ "incidents",
+ incidentId)
+ .toURIString();
+ return get(url, ContrastGraphResponse.class);
+ }
+
+ @Override
+ public FacetsResponse getFacets(
+ final String organizationId, final String filterName, final RequestFilters filters)
+ throws IOException {
+ final String url =
+ graphApiBase
+ + new URIBuilder()
+ .appendPathSegments(
+ "v2", "organizations", organizationId, "contrast-graph", "facets", filterName)
+ .toURIString();
+ return post(url, gson.toJson(filters), FacetsResponse.class);
+ }
+
+ @Override
+ public ApplicationLibrariesResponse getApplicationLibraries(
+ final String organizationId,
+ final String applicationId,
+ final String agentReportingInstanceId,
+ final ApplicationLibrariesRequest request)
+ throws IOException {
+ final String url =
+ graphApiBase
+ + new URIBuilder()
+ .appendPathSegments(
+ "v2",
+ "organizations",
+ organizationId,
+ "contrast-graph",
+ "applications",
+ applicationId,
+ "libraries")
+ .appendQueryParam("agentReportingInstanceId", agentReportingInstanceId)
+ .toURIString();
+ return post(
+ url, request != null ? gson.toJson(request) : null, ApplicationLibrariesResponse.class);
+ }
+
+ @Override
+ public LibraryDetailsResponse getApplicationLibraryDetails(
+ final String organizationId, final String applicationId, final String libraryHash)
+ throws IOException {
+ final String url =
+ graphApiBase
+ + new URIBuilder()
+ .appendPathSegments(
+ "v2",
+ "organizations",
+ organizationId,
+ "contrast-graph",
+ "applications",
+ applicationId,
+ "libraries",
+ libraryHash)
+ .toURIString();
+ return get(url, LibraryDetailsResponse.class);
+ }
+
+ private T get(final String url, final Class type) throws IOException {
+ try (InputStream is = contrast.makeRequestToUrl(HttpMethod.GET, url)) {
+ return parse(is, type);
+ }
+ }
+
+ private T post(final String url, final String body, final Class type) throws IOException {
+ try (InputStream is =
+ contrast.makeRequestWithBodyToUrl(HttpMethod.POST, url, body, MediaType.JSON)) {
+ return parse(is, type);
+ }
+ }
+
+ private T parse(final InputStream is, final Class type) throws IOException {
+ try (Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
+ return gson.fromJson(reader, type);
+ } catch (JsonParseException e) {
+ throw new ServerResponseException("Failed to parse Contrast Graph API response", e);
+ }
+ }
+}
diff --git a/sdk/src/main/java/com/contrastsecurity/utils/ContrastSDKUtils.java b/sdk/src/main/java/com/contrastsecurity/utils/ContrastSDKUtils.java
index 061a187c..a2bd6c80 100644
--- a/sdk/src/main/java/com/contrastsecurity/utils/ContrastSDKUtils.java
+++ b/sdk/src/main/java/com/contrastsecurity/utils/ContrastSDKUtils.java
@@ -65,6 +65,19 @@ public static String ensureApi(String url) {
return url;
}
+ public static String getServerUrl(String url) {
+ if (url != null) {
+ if (url.endsWith("/Contrast/api")) {
+ return url.substring(0, url.length() - "/Contrast/api".length());
+ } else if (url.endsWith("/Contrast/")) {
+ return url.substring(0, url.length() - "/Contrast/".length());
+ } else if (url.endsWith("/Contrast")) {
+ return url.substring(0, url.length() - "/Contrast".length());
+ }
+ }
+ return url;
+ }
+
public static String buildExpand(String... values) {
if (values == null || values.length == 0) {
return "";
diff --git a/sdk/src/main/openapi/contrast-graph.yaml b/sdk/src/main/openapi/contrast-graph.yaml
new file mode 100644
index 00000000..3de3b915
--- /dev/null
+++ b/sdk/src/main/openapi/contrast-graph.yaml
@@ -0,0 +1,480 @@
+openapi: "3.0.3"
+info:
+ title: Contrast Graph API
+ description: >
+ SDK-focused spec for v2 Contrast Graph endpoints served by adr-explorer-aggregator.
+ TypedNode oneOf discriminator is intentionally flattened to GraphNode.
+ All enum fields use type:string — validation happens at the MCP tool layer.
+ version: "2.0.0"
+paths:
+ /v2/organizations/{organizationId}/contrast-graph:
+ post:
+ operationId: searchGraph
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ContrastGraphRequest"
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ContrastGraphResponse"
+ /v2/organizations/{organizationId}/contrast-graph/incidents/{incidentId}:
+ get:
+ operationId: getIncidentGraph
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: incidentId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ContrastGraphResponse"
+ /v2/organizations/{organizationId}/contrast-graph/facets/{filterName}:
+ post:
+ operationId: getFacets
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: filterName
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RequestFilters"
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/FacetsResponse"
+ /v2/organizations/{organizationId}/contrast-graph/applications/{applicationId}/libraries:
+ post:
+ operationId: getApplicationLibraries
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: applicationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: agentReportingInstanceId
+ in: query
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApplicationLibrariesRequest"
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApplicationLibrariesResponse"
+ /v2/organizations/{organizationId}/contrast-graph/applications/{applicationId}/libraries/{libraryHash}:
+ get:
+ operationId: getApplicationLibraryDetails
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: applicationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: libraryHash
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LibraryDetailsResponse"
+components:
+ schemas:
+ ContrastGraphRequest:
+ type: object
+ properties:
+ filters:
+ $ref: "#/components/schemas/RequestFilters"
+ RequestFilters:
+ type: object
+ properties:
+ nodeTypes:
+ type: array
+ items:
+ type: string
+ environments:
+ type: array
+ items:
+ type: string
+ languages:
+ type: array
+ items:
+ type: string
+ openIssueSeverities:
+ type: array
+ items:
+ type: string
+ openIncidentSeverities:
+ type: array
+ items:
+ type: string
+ applications:
+ type: array
+ items:
+ type: string
+ repositories:
+ type: array
+ items:
+ type: string
+ naturalSearch:
+ type: string
+ includeStaticOnly:
+ type: boolean
+ hasAiUsage:
+ type: boolean
+ ContrastGraphResponse:
+ type: object
+ properties:
+ nodes:
+ type: array
+ items:
+ $ref: "#/components/schemas/GraphNode"
+ edges:
+ type: array
+ items:
+ $ref: "#/components/schemas/GraphEdge"
+ graphAssets:
+ type: array
+ items:
+ $ref: "#/components/schemas/GraphAsset"
+ GraphNode:
+ type: object
+ required:
+ - id
+ - nodeType
+ properties:
+ id:
+ type: string
+ nodeType:
+ type: string
+ name:
+ type: string
+ attributes:
+ type: object
+ additionalProperties:
+ type: string
+ url:
+ type: string
+ hash:
+ type: string
+ language:
+ type: string
+ applicationId:
+ type: string
+ lastSeenTime:
+ type: string
+ format: date-time
+ issuesSeverityCount:
+ $ref: "#/components/schemas/SeverityCount"
+ incidentsSeverityCount:
+ $ref: "#/components/schemas/SeverityCount"
+ postureScore:
+ $ref: "#/components/schemas/PostureScore"
+ criticality:
+ type: string
+ assetId:
+ type: string
+ attackValue:
+ type: string
+ environment:
+ type: string
+ isSource:
+ type: boolean
+ actionType:
+ type: string
+ actionSinkResult:
+ type: string
+ serverName:
+ type: string
+ agentVersion:
+ type: string
+ GraphEdge:
+ type: object
+ properties:
+ sourceId:
+ type: string
+ targetId:
+ type: string
+ edgeType:
+ type: string
+ GraphAsset:
+ type: object
+ required:
+ - assetType
+ - nodeId
+ - assetName
+ - assetId
+ properties:
+ assetType:
+ type: string
+ nodeId:
+ type: string
+ assetName:
+ type: string
+ assetId:
+ type: string
+ criticality:
+ type: string
+ SeverityCount:
+ type: object
+ properties:
+ critical:
+ type: integer
+ format: int32
+ high:
+ type: integer
+ format: int32
+ medium:
+ type: integer
+ format: int32
+ low:
+ type: integer
+ format: int32
+ note:
+ type: integer
+ format: int32
+ PostureScore:
+ type: object
+ properties:
+ score:
+ type: number
+ format: double
+ severity:
+ type: string
+ FacetsResponse:
+ type: object
+ properties:
+ nodeTypes:
+ type: array
+ items:
+ $ref: "#/components/schemas/Facet"
+ environments:
+ type: array
+ items:
+ $ref: "#/components/schemas/Facet"
+ languages:
+ type: array
+ items:
+ $ref: "#/components/schemas/Facet"
+ openIssueSeverities:
+ type: array
+ items:
+ $ref: "#/components/schemas/Facet"
+ openIncidentSeverities:
+ type: array
+ items:
+ $ref: "#/components/schemas/Facet"
+ applications:
+ type: array
+ items:
+ $ref: "#/components/schemas/Facet"
+ Facet:
+ type: object
+ required:
+ - value
+ properties:
+ label:
+ type: string
+ value:
+ type: string
+ count:
+ type: integer
+ format: int32
+ ApplicationLibrariesRequest:
+ type: object
+ properties:
+ libraryHashes:
+ type: array
+ items:
+ type: string
+ ApplicationLibrariesResponse:
+ type: object
+ properties:
+ dependencies:
+ type: array
+ items:
+ $ref: "#/components/schemas/ExtendedDependency"
+ cves:
+ type: array
+ items:
+ $ref: "#/components/schemas/LibraryCve"
+ Dependency:
+ type: object
+ required:
+ - name
+ - version
+ - hash
+ properties:
+ name:
+ type: string
+ version:
+ type: string
+ hash:
+ type: string
+ ExtendedDependency:
+ type: object
+ required:
+ - name
+ - version
+ - hash
+ properties:
+ name:
+ type: string
+ version:
+ type: string
+ hash:
+ type: string
+ dependencies:
+ type: array
+ items:
+ $ref: "#/components/schemas/ExtendedDependency"
+ matchedInSearch:
+ type: boolean
+ LibraryCve:
+ type: object
+ required:
+ - cveId
+ - cisa
+ - description
+ properties:
+ cveId:
+ type: string
+ dependencyPath:
+ type: array
+ items:
+ $ref: "#/components/schemas/Dependency"
+ cvssScore:
+ type: number
+ format: double
+ nullable: true
+ cvssVector:
+ type: string
+ nullable: true
+ epssScore:
+ type: number
+ format: double
+ nullable: true
+ epssPercentile:
+ type: number
+ format: double
+ nullable: true
+ nvdModified:
+ type: string
+ format: date-time
+ nullable: true
+ nvdPublished:
+ type: string
+ format: date-time
+ nullable: true
+ cisa:
+ type: boolean
+ description:
+ type: string
+ matchedInSearch:
+ type: boolean
+ LibraryDetailsResponse:
+ type: object
+ required:
+ - name
+ - language
+ - version
+ - fileName
+ - hash
+ properties:
+ name:
+ type: string
+ group:
+ type: string
+ language:
+ type: string
+ version:
+ type: string
+ fileName:
+ type: string
+ hash:
+ type: string
+ releaseDate:
+ type: string
+ format: date-time
+ nullable: true
+ licenses:
+ type: array
+ items:
+ type: string
+ cves:
+ type: array
+ items:
+ $ref: "#/components/schemas/LibraryCve"
+ issues:
+ type: array
+ items:
+ $ref: "#/components/schemas/GraphIssue"
+ closestStableVersion:
+ $ref: "#/components/schemas/LibraryDetailsResponse"
+ latestStableVersion:
+ $ref: "#/components/schemas/LibraryDetailsResponse"
+ GraphIssue:
+ type: object
+ required:
+ - issueId
+ - title
+ properties:
+ issueId:
+ type: string
+ title:
+ type: string
diff --git a/sdk/src/test/java/com/contrastsecurity/sdk/ContrastSDKTest.java b/sdk/src/test/java/com/contrastsecurity/sdk/ContrastSDKTest.java
index e8c27c5d..45c58d8c 100644
--- a/sdk/src/test/java/com/contrastsecurity/sdk/ContrastSDKTest.java
+++ b/sdk/src/test/java/com/contrastsecurity/sdk/ContrastSDKTest.java
@@ -21,12 +21,24 @@
*/
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import com.contrastsecurity.exceptions.HttpResponseException;
import com.contrastsecurity.http.HttpMethod;
+import com.contrastsecurity.http.MediaType;
+import com.sun.net.httpserver.HttpServer;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.Executors;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
final class ContrastSDKTest {
@@ -116,4 +128,146 @@ void sets_user_agent_header() throws IOException {
.matches(
"INTELLIJ_INTEGRATION/1.0.0 contrast-sdk-java/\\d\\.\\d(\\.\\d)?(-SNAPSHOT)? Java/\\d+.*");
}
+
+ private static String readString(final InputStream is) throws IOException {
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ final byte[] buf = new byte[1024];
+ int n;
+ while ((n = is.read(buf)) != -1) {
+ baos.write(buf, 0, n);
+ }
+ return baos.toString(StandardCharsets.UTF_8.name());
+ }
+
+ @Nested
+ final class MakeRequestToUrl {
+
+ private HttpServer server;
+ private String baseUrl;
+
+ @BeforeEach
+ void before() throws IOException {
+ server = HttpServer.create();
+ server.setExecutor(Executors.newSingleThreadExecutor());
+ server.bind(new InetSocketAddress("localhost", 0), 0);
+ server.start();
+ baseUrl = "http://localhost:" + server.getAddress().getPort();
+ }
+
+ @AfterEach
+ void after() {
+ server.stop(0);
+ }
+
+ @Test
+ void returns_response_body_on_success() throws IOException {
+ server.createContext(
+ "/data",
+ exchange -> {
+ final byte[] body = "response-body".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, body.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(body);
+ }
+ });
+
+ try (InputStream is = contrastSDK.makeRequestToUrl(HttpMethod.GET, baseUrl + "/data")) {
+ assertThat(readString(is)).isEqualTo("response-body");
+ }
+ }
+
+ @Test
+ void throws_on_error_response() {
+ server.createContext(
+ "/data",
+ exchange -> {
+ exchange.sendResponseHeaders(400, -1);
+ exchange.close();
+ });
+
+ assertThatThrownBy(() -> contrastSDK.makeRequestToUrl(HttpMethod.GET, baseUrl + "/data"))
+ .isInstanceOf(HttpResponseException.class);
+ }
+ }
+
+ @Nested
+ final class MakeRequestWithBodyToUrl {
+
+ private HttpServer server;
+ private String baseUrl;
+
+ @BeforeEach
+ void before() throws IOException {
+ server = HttpServer.create();
+ server.setExecutor(Executors.newSingleThreadExecutor());
+ server.bind(new InetSocketAddress("localhost", 0), 0);
+ server.start();
+ baseUrl = "http://localhost:" + server.getAddress().getPort();
+ }
+
+ @AfterEach
+ void after() {
+ server.stop(0);
+ }
+
+ @Test
+ void sends_body_and_returns_response() throws IOException {
+ final String[] receivedBody = {null};
+ server.createContext(
+ "/submit",
+ exchange -> {
+ receivedBody[0] = readString(exchange.getRequestBody());
+ final byte[] response = "ok".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, response.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(response);
+ }
+ });
+
+ try (InputStream is =
+ contrastSDK.makeRequestWithBodyToUrl(
+ HttpMethod.POST, baseUrl + "/submit", "{\"key\":\"value\"}", MediaType.JSON)) {
+ assertThat(readString(is)).isEqualTo("ok");
+ }
+ assertThat(receivedBody[0]).isEqualTo("{\"key\":\"value\"}");
+ }
+
+ @Test
+ void omits_body_when_null() throws IOException {
+ final String[] receivedBody = {null};
+ server.createContext(
+ "/submit",
+ exchange -> {
+ receivedBody[0] = readString(exchange.getRequestBody());
+ final byte[] response = "ok".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, response.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(response);
+ }
+ });
+
+ try (InputStream is =
+ contrastSDK.makeRequestWithBodyToUrl(
+ HttpMethod.POST, baseUrl + "/submit", null, MediaType.JSON)) {
+ assertThat(readString(is)).isEqualTo("ok");
+ }
+ assertThat(receivedBody[0]).isEmpty();
+ }
+
+ @Test
+ void throws_on_error_response() {
+ server.createContext(
+ "/submit",
+ exchange -> {
+ exchange.sendResponseHeaders(400, -1);
+ exchange.close();
+ });
+
+ assertThatThrownBy(
+ () ->
+ contrastSDK.makeRequestWithBodyToUrl(
+ HttpMethod.POST, baseUrl + "/submit", "{}", MediaType.JSON))
+ .isInstanceOf(HttpResponseException.class);
+ }
+ }
}
diff --git a/sdk/src/test/java/com/contrastsecurity/sdk/graph/ContrastGraphApiFactoryTest.java b/sdk/src/test/java/com/contrastsecurity/sdk/graph/ContrastGraphApiFactoryTest.java
new file mode 100644
index 00000000..234da616
--- /dev/null
+++ b/sdk/src/test/java/com/contrastsecurity/sdk/graph/ContrastGraphApiFactoryTest.java
@@ -0,0 +1,39 @@
+/*-
+ * #%L
+ * Contrast Java SDK
+ * %%
+ * Copyright (C) 2022 - 2026 Contrast Security, Inc.
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.contrastsecurity.sdk.graph;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.contrastsecurity.sdk.ContrastSDK;
+import org.junit.jupiter.api.Test;
+
+class ContrastGraphApiFactoryTest {
+
+ @Test
+ void graphApi_returns_non_null_instance() {
+ ContrastSDK sdk =
+ new ContrastSDK.Builder("user", "serviceKey", "apiKey")
+ .withApiUrl("http://localhost:8080/Contrast/api")
+ .build();
+ ContrastGraphApi api = sdk.graphApi();
+ assertThat(api).isNotNull().isInstanceOf(ContrastGraphApiImpl.class);
+ }
+}
diff --git a/sdk/src/test/java/com/contrastsecurity/sdk/graph/ContrastGraphApiImplPactTest.java b/sdk/src/test/java/com/contrastsecurity/sdk/graph/ContrastGraphApiImplPactTest.java
new file mode 100644
index 00000000..e40734a0
--- /dev/null
+++ b/sdk/src/test/java/com/contrastsecurity/sdk/graph/ContrastGraphApiImplPactTest.java
@@ -0,0 +1,280 @@
+/*-
+ * #%L
+ * Contrast Java SDK
+ * %%
+ * Copyright (C) 2022 - 2026 Contrast Security, Inc.
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.contrastsecurity.sdk.graph;
+
+import static au.com.dius.pact.consumer.dsl.LambdaDsl.newJsonBody;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import au.com.dius.pact.consumer.MockServer;
+import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
+import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
+import au.com.dius.pact.consumer.junit5.PactTestFor;
+import au.com.dius.pact.core.model.RequestResponsePact;
+import au.com.dius.pact.core.model.annotations.Pact;
+import com.contrastsecurity.sdk.ContrastSDK;
+import com.contrastsecurity.sdk.JSON;
+import java.io.IOException;
+import java.util.Collections;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(PactConsumerTestExt.class)
+@PactTestFor(providerName = "adr-explorer-aggregator")
+final class ContrastGraphApiImplPactTest {
+
+ private static final String ORG_ID = "119844af-42ff-4293-b06b-81d426e9a4a9";
+ private static final String APP_ID = "123e4567-e89b-12d3-a456-426614174000";
+ private static final String INCIDENT_ID = "INC-2025-1";
+ private static final String LIBRARY_HASH =
+ "3a42c503953909637f78dd8c99b3b85ddde362415585afc11901bdefe8349102";
+ private static final String AGENT_INSTANCE_ID = "abc1234";
+
+ private ContrastGraphApiImpl client(final MockServer server) {
+ ContrastSDK sdk =
+ new ContrastSDK.Builder("user", "serviceKey", "apiKey")
+ .withApiUrl(server.getUrl() + "/Contrast/api")
+ .build();
+ return new ContrastGraphApiImpl(sdk, JSON.getGson());
+ }
+
+ @Nested
+ final class SearchGraph {
+
+ @Pact(consumer = "contrast-sdk")
+ RequestResponsePact pact(final PactDslWithProvider builder) {
+ return builder
+ .given("graph data exists for organization")
+ .uponReceiving("search graph with filters")
+ .method("POST")
+ .path("/api/v2/organizations/" + ORG_ID + "/contrast-graph")
+ .willRespondWith()
+ .status(200)
+ .body(
+ newJsonBody(
+ body -> {
+ body.array(
+ "nodes",
+ nodes ->
+ nodes.object(
+ node -> {
+ node.stringValue("id", "node-1");
+ node.stringValue("nodeType", "APPLICATION");
+ node.stringValue("name", "test-app");
+ }));
+ body.array("edges", edges -> {});
+ body.array(
+ "graphAssets",
+ assets ->
+ assets.object(
+ asset -> {
+ asset.stringValue("assetType", "APPLICATION");
+ asset.stringValue("nodeId", "node-1");
+ asset.stringValue("assetName", "test-app");
+ asset.stringValue("assetId", APP_ID);
+ }));
+ })
+ .build())
+ .toPact();
+ }
+
+ @Test
+ void searchGraph(final MockServer server) throws IOException {
+ ContrastGraphRequest request = new ContrastGraphRequest();
+ request.setFilters(new RequestFilters());
+
+ ContrastGraphResponse response = client(server).searchGraph(ORG_ID, request);
+
+ assertThat(response.getNodes()).isNotEmpty();
+ assertThat(response.getNodes().get(0).getId()).isEqualTo("node-1");
+ assertThat(response.getNodes().get(0).getNodeType()).isEqualTo("APPLICATION");
+ assertThat(response.getGraphAssets()).isNotEmpty();
+ assertThat(response.getGraphAssets().get(0).getAssetId()).isEqualTo(APP_ID);
+ }
+ }
+
+ @Nested
+ final class GetIncidentGraph {
+
+ @Pact(consumer = "contrast-sdk")
+ RequestResponsePact pact(final PactDslWithProvider builder) {
+ return builder
+ .given("incident exists", Collections.singletonMap("incidentId", INCIDENT_ID))
+ .uponReceiving("get incident graph")
+ .method("GET")
+ .path("/api/v2/organizations/" + ORG_ID + "/contrast-graph/incidents/" + INCIDENT_ID)
+ .willRespondWith()
+ .status(200)
+ .body(
+ newJsonBody(
+ body -> {
+ body.array(
+ "nodes",
+ nodes ->
+ nodes.object(
+ node -> {
+ node.stringValue("id", "incident-node-1");
+ node.stringValue("nodeType", "ACTION");
+ }));
+ body.array("edges", edges -> {});
+ })
+ .build())
+ .toPact();
+ }
+
+ @Test
+ void getIncidentGraph(final MockServer server) throws IOException {
+ ContrastGraphResponse response = client(server).getIncidentGraph(ORG_ID, INCIDENT_ID);
+
+ assertThat(response.getNodes()).isNotEmpty();
+ assertThat(response.getNodes().get(0).getId()).isEqualTo("incident-node-1");
+ assertThat(response.getNodes().get(0).getNodeType()).isEqualTo("ACTION");
+ }
+ }
+
+ @Nested
+ final class GetFacets {
+
+ @Pact(consumer = "contrast-sdk")
+ RequestResponsePact pact(final PactDslWithProvider builder) {
+ return builder
+ .given("graph data exists for organization")
+ .uponReceiving("get nodeTypes facets")
+ .method("POST")
+ .path("/api/v2/organizations/" + ORG_ID + "/contrast-graph/facets/nodeTypes")
+ .willRespondWith()
+ .status(200)
+ .body(
+ newJsonBody(
+ body ->
+ body.array(
+ "nodeTypes",
+ facets ->
+ facets.object(
+ facet -> {
+ facet.stringValue("value", "APPLICATION");
+ facet.numberValue("count", 5);
+ })))
+ .build())
+ .toPact();
+ }
+
+ @Test
+ void getFacets(final MockServer server) throws IOException {
+ FacetsResponse response = client(server).getFacets(ORG_ID, "nodeTypes", new RequestFilters());
+
+ assertThat(response.getNodeTypes()).isNotEmpty();
+ assertThat(response.getNodeTypes().get(0).getValue()).isEqualTo("APPLICATION");
+ assertThat(response.getNodeTypes().get(0).getCount()).isEqualTo(5);
+ }
+ }
+
+ @Nested
+ final class GetApplicationLibraries {
+
+ @Pact(consumer = "contrast-sdk")
+ RequestResponsePact pact(final PactDslWithProvider builder) {
+ return builder
+ .given("application libraries exist")
+ .uponReceiving("get application libraries without filter")
+ .method("POST")
+ .path(
+ "/api/v2/organizations/"
+ + ORG_ID
+ + "/contrast-graph/applications/"
+ + APP_ID
+ + "/libraries")
+ .query("agentReportingInstanceId=" + AGENT_INSTANCE_ID)
+ .willRespondWith()
+ .status(200)
+ .body(
+ newJsonBody(
+ body -> {
+ body.array(
+ "dependencies",
+ deps ->
+ deps.object(
+ dep -> {
+ dep.stringValue("name", "express");
+ dep.stringValue("version", "4.18.2");
+ dep.stringValue("hash", LIBRARY_HASH);
+ }));
+ body.array("cves", cves -> {});
+ })
+ .build())
+ .toPact();
+ }
+
+ @Test
+ void getApplicationLibraries(final MockServer server) throws IOException {
+ ApplicationLibrariesResponse response =
+ client(server).getApplicationLibraries(ORG_ID, APP_ID, AGENT_INSTANCE_ID, null);
+
+ assertThat(response.getDependencies()).isNotEmpty();
+ assertThat(response.getDependencies().get(0).getName()).isEqualTo("express");
+ assertThat(response.getDependencies().get(0).getVersion()).isEqualTo("4.18.2");
+ assertThat(response.getDependencies().get(0).getHash()).isEqualTo(LIBRARY_HASH);
+ }
+ }
+
+ @Nested
+ final class GetApplicationLibraryDetails {
+
+ @Pact(consumer = "contrast-sdk")
+ RequestResponsePact pact(final PactDslWithProvider builder) {
+ return builder
+ .given("library exists")
+ .uponReceiving("get library details by hash")
+ .method("GET")
+ .path(
+ "/api/v2/organizations/"
+ + ORG_ID
+ + "/contrast-graph/applications/"
+ + APP_ID
+ + "/libraries/"
+ + LIBRARY_HASH)
+ .willRespondWith()
+ .status(200)
+ .body(
+ newJsonBody(
+ body -> {
+ body.stringValue("name", "express");
+ body.stringValue("language", "node");
+ body.stringValue("version", "4.18.2");
+ body.stringValue("fileName", "express-4.18.2.tgz");
+ body.stringValue("hash", LIBRARY_HASH);
+ })
+ .build())
+ .toPact();
+ }
+
+ @Test
+ void getApplicationLibraryDetails(final MockServer server) throws IOException {
+ LibraryDetailsResponse response =
+ client(server).getApplicationLibraryDetails(ORG_ID, APP_ID, LIBRARY_HASH);
+
+ assertThat(response.getName()).isEqualTo("express");
+ assertThat(response.getVersion()).isEqualTo("4.18.2");
+ assertThat(response.getHash()).isEqualTo(LIBRARY_HASH);
+ assertThat(response.getLanguage()).isEqualTo("node");
+ }
+ }
+}
diff --git a/sdk/src/test/java/com/contrastsecurity/utils/ContrastSDKUtilsTest.java b/sdk/src/test/java/com/contrastsecurity/utils/ContrastSDKUtilsTest.java
index 7bb93622..91c5364c 100644
--- a/sdk/src/test/java/com/contrastsecurity/utils/ContrastSDKUtilsTest.java
+++ b/sdk/src/test/java/com/contrastsecurity/utils/ContrastSDKUtilsTest.java
@@ -62,4 +62,26 @@ public void ensure_api_handles_blank_string() {
final String ensureBlank = "";
assertThat(blankUrl).isEqualTo(ensureBlank);
}
+
+ @Test
+ public void get_server_url_strips_contrast_path_variants() {
+ final String expected = "http://localhost:19080";
+ assertThat(ContrastSDKUtils.getServerUrl("http://localhost:19080/Contrast/api"))
+ .isEqualTo(expected);
+ assertThat(ContrastSDKUtils.getServerUrl("http://localhost:19080/Contrast/"))
+ .isEqualTo(expected);
+ assertThat(ContrastSDKUtils.getServerUrl("http://localhost:19080/Contrast"))
+ .isEqualTo(expected);
+ }
+
+ @Test
+ public void get_server_url_leaves_plain_host_unchanged() {
+ final String plainHost = "http://localhost:19080";
+ assertThat(ContrastSDKUtils.getServerUrl(plainHost)).isEqualTo(plainHost);
+ }
+
+ @Test
+ public void get_server_url_ignores_null() {
+ assertThat(ContrastSDKUtils.getServerUrl(null)).isNull();
+ }
}