diff --git a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifact.java b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifact.java index 1c2be049..18cae943 100644 --- a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifact.java +++ b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifact.java @@ -20,6 +20,7 @@ * #L% */ +import com.contrastsecurity.sdk.internal.Nullable; import java.time.Instant; /** @@ -40,6 +41,10 @@ public interface CodeArtifact { /** @return filename */ String filename(); + /** @return metadata filename */ + @Nullable + String metadata(); + /** @return time at which the code artifact was uploaded to Contrast Scan */ Instant createdTime(); } diff --git a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactClient.java b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactClient.java index 597f7ded..e5393365 100644 --- a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactClient.java +++ b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactClient.java @@ -38,7 +38,7 @@ interface CodeArtifactClient { /** - * Transfers a file from the file system to Contrast Scan to create a new code artifact for + * Transfers an artifact from the file system to Contrast Scan to create a new code artifact for * analysis. * * @param projectId ID of the project to which the code artifact belongs @@ -51,4 +51,22 @@ interface CodeArtifactClient { * @throws ServerResponseException when Contrast API returns a response that cannot be understood */ CodeArtifactInner upload(String projectId, Path file) throws IOException; + + /** + * Transfers artifact and prescan metadata from the file system to Contrast Scan to create a new + * code artifact for analysis. + * + *

Prescan metadata will allow the scanner to produce more detailed finding reports. + * + * @param projectId ID of the project to which the code artifact belongs + * @param file the file to upload + * @param metadata the prescan metadata to upload with the file artifact. + * @return new {@link CodeArtifactInner} from Contrast API + * @throws IOException when an IO error occurs while making the request to the Contrast API + * @throws UnauthorizedException when Contrast rejects the credentials used to send the request + * @throws ResourceNotFoundException when the requested resource does not exist + * @throws HttpResponseException when Contrast rejects this request with an error code + * @throws ServerResponseException when Contrast API returns a response that cannot be understood + */ + CodeArtifactInner upload(String projectId, Path file, Path metadata) throws IOException; } diff --git a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactClientImpl.java b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactClientImpl.java index 27ed847b..ec2e6ff2 100644 --- a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactClientImpl.java +++ b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactClientImpl.java @@ -54,6 +54,17 @@ final class CodeArtifactClientImpl implements CodeArtifactClient { @Override public CodeArtifactInner upload(final String projectId, final Path file) throws IOException { + return sendRequest(projectId, file, null); + } + + @Override + public CodeArtifactInner upload(final String projectId, final Path file, final Path metadata) + throws IOException { + return sendRequest(projectId, file, Objects.requireNonNull(metadata)); + } + + private CodeArtifactInner sendRequest( + final String projectId, final Path file, final Path metadata) throws IOException { final String uri = contrast.getRestApiURL() + new URIBuilder() @@ -66,9 +77,9 @@ public CodeArtifactInner upload(final String projectId, final Path file) throws "code-artifacts") .toURIString(); final String boundary = "ContrastFormBoundary" + ThreadLocalRandom.current().nextLong(); - final String header = - "--" - + boundary + final String boundaryMarker = CRLF + "--" + boundary; + final String filenameSection = + boundaryMarker + CRLF + "Content-Disposition: form-data; name=\"filename\"; filename=\"" + file.getFileName().toString() @@ -80,8 +91,28 @@ public CodeArtifactInner upload(final String projectId, final Path file) throws + "Content-Transfer-Encoding: binary" + CRLF + CRLF; - final String footer = CRLF + "--" + boundary + "--" + CRLF; - final long contentLength = header.length() + Files.size(file) + footer.length(); + final String metadataSection = + metadata != null + ? boundaryMarker + + CRLF + + "Content-Disposition: form-data; name=\"metadata\"; filename=\"" + + metadata.getFileName().toString() + + '"' + + CRLF + + "Content-Type: " + + determineMime(metadata) + + CRLF + + "Content-Transfer-Encoding: binary" + + CRLF + + CRLF + : ""; + + final String footer = boundaryMarker + "--" + CRLF; + long contentLength = filenameSection.length() + Files.size(file); + if (metadata != null) { + contentLength += metadataSection.length() + Files.size(metadata); + } + contentLength += footer.length(); final HttpURLConnection connection = contrast.makeConnection(uri, "POST"); connection.setDoOutput(true); @@ -91,9 +122,14 @@ public CodeArtifactInner upload(final String projectId, final Path file) throws try (OutputStream os = connection.getOutputStream(); PrintWriter writer = new PrintWriter(new OutputStreamWriter(os, StandardCharsets.US_ASCII), true)) { - writer.append(header).flush(); + writer.append(filenameSection).flush(); Files.copy(file, os); os.flush(); + if (metadata != null) { + writer.append(metadataSection).flush(); + Files.copy(metadata, os); + os.flush(); + } writer.append(footer).flush(); } final int code = connection.getResponseCode(); diff --git a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactImpl.java b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactImpl.java index 5514b898..41830120 100644 --- a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactImpl.java +++ b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactImpl.java @@ -52,6 +52,11 @@ public String filename() { return inner.filename(); } + @Override + public String metadata() { + return inner.metadata(); + } + @Override public Instant createdTime() { return inner.createdTime(); diff --git a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactInner.java b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactInner.java index 9ceebb09..99689490 100644 --- a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactInner.java +++ b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactInner.java @@ -20,6 +20,7 @@ * #L% */ +import com.contrastsecurity.sdk.internal.Nullable; import com.google.auto.value.AutoValue; import java.time.Instant; @@ -44,6 +45,10 @@ static Builder builder() { /** @return filename */ abstract String filename(); + /** @return metadata filename */ + @Nullable + abstract String metadata(); + /** @return time at which the code artifact was uploaded to Contrast Scan */ abstract Instant createdTime(); @@ -63,6 +68,9 @@ abstract static class Builder { /** @see CodeArtifactInner#filename() */ abstract Builder filename(String value); + /** @see CodeArtifactInner#metadata() */ + abstract Builder metadata(String value); + /** @see CodeArtifactInner#createdTime() */ abstract Builder createdTime(Instant value); diff --git a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifacts.java b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifacts.java index ea91267e..1c962926 100644 --- a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifacts.java +++ b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifacts.java @@ -62,6 +62,23 @@ interface Factory { */ CodeArtifact upload(Path file, String name) throws IOException; + /** + * Transfers a file from the file system to Contrast Scan to create a new code artifact for static + * analysis. + * + * @param file the code artifact to upload + * @param name the name of the code artifact + * @param metadata the path of the prescan data file to upload + * @param metaname the name of the prescan data file + * @return new {@link CodeArtifact} from Contrast + * @throws IOException when an IO error occurs while making the request to the Contrast API + * @throws UnauthorizedException when Contrast rejects the credentials used to send the request + * @throws ResourceNotFoundException when the requested resource does not exist + * @throws HttpResponseException when Contrast rejects this request with an error code + * @throws ServerResponseException when Contrast API returns a response that cannot be understood + */ + CodeArtifact upload(Path file, String name, Path metadata, String metaname) throws IOException; + /** * Transfers a file from the file system to Contrast Scan to create a new code artifact for static * analysis. @@ -75,4 +92,19 @@ interface Factory { * @throws ServerResponseException when Contrast API returns a response that cannot be understood */ CodeArtifact upload(Path file) throws IOException; + + /** + * Transfers a file from the file system to Contrast Scan to create a new code artifact for static + * analysis. + * + * @param file the code artifact to upload + * @param metadata the path of the prescan data file to upload + * @return new {@link CodeArtifact} from Contrast + * @throws IOException when an IO error occurs while making the request to the Contrast API + * @throws UnauthorizedException when Contrast rejects the credentials used to send the request + * @throws ResourceNotFoundException when the requested resource does not exist + * @throws HttpResponseException when Contrast rejects this request with an error code + * @throws ServerResponseException when Contrast API returns a response that cannot be understood + */ + CodeArtifact upload(Path file, Path metadata) throws IOException; } diff --git a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactsImpl.java b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactsImpl.java index 99f9c596..23f9a48a 100644 --- a/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactsImpl.java +++ b/src/main/java/com/contrastsecurity/sdk/scan/CodeArtifactsImpl.java @@ -50,6 +50,14 @@ public CodeArtifacts create(final String projectId) { this.projectId = projectId; } + @Override + public CodeArtifact upload( + final Path file, final String name, final Path metadata, final String metaname) + throws IOException { + final CodeArtifactInner inner = client.upload(projectId, file, metadata); + return new CodeArtifactImpl(inner); + } + @Override public CodeArtifact upload(final Path file, final String name) throws IOException { final CodeArtifactInner inner = client.upload(projectId, file); @@ -60,4 +68,9 @@ public CodeArtifact upload(final Path file, final String name) throws IOExceptio public CodeArtifact upload(final Path file) throws IOException { return upload(file, file.getFileName().toString()); } + + @Override + public CodeArtifact upload(final Path file, final Path metadata) throws IOException { + return upload(file, file.getFileName().toString(), metadata, metadata.getFileName().toString()); + } } diff --git a/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactAssert.java b/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactAssert.java index 0132f38c..4badb971 100644 --- a/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactAssert.java +++ b/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactAssert.java @@ -49,6 +49,7 @@ public CodeArtifactAssert hasSameValuesAsInner(final CodeArtifactInner inner) { Assertions.assertThat(actual.projectId()).isEqualTo(inner.projectId()); Assertions.assertThat(actual.organizationId()).isEqualTo(inner.organizationId()); Assertions.assertThat(actual.filename()).isEqualTo(inner.filename()); + Assertions.assertThat(actual.metadata()).isEqualTo(inner.metadata()); Assertions.assertThat(actual.createdTime()).isEqualTo(inner.createdTime()); return this; } diff --git a/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactsImplTest.java b/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactsImplTest.java index aecb93d3..269dd4bc 100644 --- a/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactsImplTest.java +++ b/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactsImplTest.java @@ -53,6 +53,23 @@ void upload(@TempDir final Path tmp) throws IOException { assertThat(codeArtifact).hasSameValuesAsInner(inner); } + @Test + void upload_with_metadata(@TempDir final Path tmp) throws IOException { + // GIVEN stubbed code artifacts client + final CodeArtifactClient client = mock(CodeArtifactClient.class); + final CodeArtifactInner inner = builder().metadata("prescan.json").build(); + final Path file = tmp.resolve(inner.filename()); + final Path meta = tmp.resolve(inner.metadata()); + when(client.upload(inner.projectId(), file, meta)).thenReturn(inner); + + // WHEN upload file,meta + final CodeArtifacts codeArtifacts = new CodeArtifactsImpl(client, inner.projectId()); + final CodeArtifact codeArtifact = codeArtifacts.upload(file, meta); + + // THEN returns expected code artifact + assertThat(codeArtifact).hasSameValuesAsInner(inner); + } + @Test void upload_custom_filename(@TempDir final Path tmp) throws IOException { // GIVEN stubbed code artifacts client @@ -69,6 +86,24 @@ void upload_custom_filename(@TempDir final Path tmp) throws IOException { assertThat(codeArtifact).hasSameValuesAsInner(inner); } + @Test + void upload_custom_metaname(@TempDir final Path tmp) throws IOException { + // GIVEN stubbed code artifacts client + final CodeArtifactClient client = mock(CodeArtifactClient.class); + final CodeArtifactInner inner = builder().metadata("prescan.json").build(); + final Path file = tmp.resolve(inner.filename()); + final Path meta = tmp.resolve("other-prescan.json"); + when(client.upload(inner.projectId(), file, meta)).thenReturn(inner); + + // WHEN upload file,meta + final CodeArtifacts codeArtifacts = new CodeArtifactsImpl(client, inner.projectId()); + final CodeArtifact codeArtifact = + codeArtifacts.upload(file, inner.filename(), meta, inner.metadata()); + + // THEN returns expected code artifact + assertThat(codeArtifact).hasSameValuesAsInner(inner); + } + @Test void delegates_to_inner() { final CodeArtifactInner inner = builder().build(); diff --git a/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactsPactTest.java b/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactsPactTest.java index 393073d3..acec3197 100644 --- a/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactsPactTest.java +++ b/src/test/java/com/contrastsecurity/sdk/scan/CodeArtifactsPactTest.java @@ -36,12 +36,15 @@ import com.google.gson.Gson; import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.jar.JarOutputStream; import java.util.zip.ZipEntry; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -53,6 +56,7 @@ final class CodeArtifactsPactTest { private Path jar; + private Path metadataJson; /** * Creates a test jar for the test to upload as a code artifact @@ -65,6 +69,12 @@ void before(@TempDir final Path tmp) throws IOException { try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar.toFile()))) { jos.putNextEntry(new ZipEntry("HelloWorld.class")); } + metadataJson = tmp.resolve("prescan.json"); + try (OutputStreamWriter os = + new OutputStreamWriter( + new FileOutputStream(metadataJson.toFile()), StandardCharsets.UTF_8)) { + os.write("{\"test\": \"data\" }"); + } } /** Verifies the code artifact upload behavior. */ @@ -128,4 +138,80 @@ void upload_code_artifact(final MockServer server) throws IOException { assertThat(codeArtifact).isEqualTo(expected); } } + /** Verifies the code artifact upload with metadata behavior. */ + @Nested + final class UploadCodeArtifactWithMetadata { + + @Disabled("https://github.com/pact-foundation/pact-jvm/issues/668") + @Pact(consumer = "contrast-sdk") + RequestResponsePact pact(final PactDslWithProvider builder) throws IOException { + + final HashMap params = new HashMap<>(); + params.put("id", "project-id"); + params.put("organizationId", "organization-id"); + return builder + .given("Projects Exist", params) + .uponReceiving("upload new code artifact") + .method("POST") + .pathFromProviderState( + "/sast/organizations/${organizationId}/projects/${projectId}/code-artifacts", + "/sast/organizations/organization-id/projects/project-id/code-artifacts") + .withFileUpload( + "filename", + jar.getFileName().toString(), + "application/java-archive", + Files.readAllBytes(jar)) + // BUG: https://github.com/pact-foundation/pact-jvm/issues/668. Unable to define a PACT + // request matcher that + // has multiple multipart sections. + // Consumer interface definition is: + // https://github.com/Contrast-Security-Inc/sast-api-documentation/blob/master/sast-code-artifacts.yaml#L83 + .withFileUpload( + "metadata", + metadataJson.getFileName().toString(), + "application/json", + Files.readAllBytes(metadataJson)) + .willRespondWith() + .status(201) + .body( + newJsonBody( + o -> { + o.stringType("id", "code-artifact-id"); + o.valueFromProviderState("projectId", "${projectId}", "project-id"); + o.valueFromProviderState( + "organizationId", "${organizationId}", "organization-id"); + o.stringType("filename", jar.getFileName().toString()); + o.stringType("metadata", metadataJson.getFileName().toString()); + o.datetime( + "createdTime", + PactConstants.DATETIME_FORMAT, + TestDataConstants.TIMESTAMP_EXAMPLE); + }) + .build()) + .toPact(); + } + + @Disabled("https://github.com/pact-foundation/pact-jvm/issues/668") + @Test + void upload_code_artifact_with_metadata(final MockServer server) throws IOException { + final ContrastSDK contrast = + new ContrastSDK.Builder("test-user", "test-service-key", "test-api-key") + .withApiUrl(server.getUrl()) + .build(); + final Gson gson = GsonFactory.create(); + CodeArtifactClient client = new CodeArtifactClientImpl(contrast, gson, "organization-id"); + final CodeArtifactInner codeArtifact = client.upload("project-id", jar, metadataJson); + + final CodeArtifactInner expected = + CodeArtifactInner.builder() + .id("code-artifact-id") + .projectId("project-id") + .organizationId("organization-id") + .filename(jar.getFileName().toString()) + .metadata(metadataJson.getFileName().toString()) + .createdTime(TestDataConstants.TIMESTAMP_EXAMPLE) + .build(); + assertThat(codeArtifact).isEqualTo(expected); + } + } }