diff --git a/sdk/pom.xml b/sdk/pom.xml index 11004540..0c8004af 100644 --- a/sdk/pom.xml +++ b/sdk/pom.xml @@ -51,8 +51,9 @@ http://localhost:19080/Contrast/api 5.7.1 1.8.2 - 1.18.18 + 1.18.30 2022 + 7.9.0 @@ -109,6 +110,18 @@ ${versions.auto-value} provided + + com.google.code.findbugs + jsr305 + 3.0.2 + true + + + javax.annotation + javax.annotation-api + 1.3.2 + true + @@ -219,6 +232,36 @@ + + org.openapitools + openapi-generator-maven-plugin + ${versions.openapi-generator} + + + generate-contrast-graph-models + + generate + + + ${project.basedir}/src/main/openapi/contrast-graph.yaml + java + okhttp-gson + false + false + false + false + com.contrastsecurity.sdk.graph + + false + true + true + false + string + + + + + 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(); + } }