diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/GradleProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/GradleProvider.java index 6a90acdf..3632dbfd 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/GradleProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/GradleProvider.java @@ -59,6 +59,9 @@ public final class GradleProvider extends BaseJavaProvider { private static final Logger log = LoggersFactory.getLogger(GradleProvider.class.getName()); + private static final long TIMEOUT = + Long.parseLong(System.getProperty("trustify.gradle.timeout.seconds", "120")); + private final String gradleExecutable = Operations.getExecutable("gradle", "--version"); public GradleProvider(Path manifest) { @@ -73,20 +76,24 @@ public String readLicenseFromManifest() { @Override public Content provideStack() throws IOException { Path tempFile = getDependencies(manifest); - if (debugLoggingIsNeeded()) { - String stackAnalysisDependencyTree = Files.readString(tempFile); - log.info( - String.format( - "Package Manager Gradle Stack Analysis Dependency Tree Output: %s %s", - System.lineSeparator(), stackAnalysisDependencyTree)); - } - Map propertiesMap = extractProperties(manifest); + try { + if (debugLoggingIsNeeded()) { + String stackAnalysisDependencyTree = Files.readString(tempFile); + log.info( + String.format( + "Package Manager Gradle Stack Analysis Dependency Tree Output: %s %s", + System.lineSeparator(), stackAnalysisDependencyTree)); + } + Map propertiesMap = extractProperties(manifest); - var sbom = buildSbomFromTextFormat(tempFile, propertiesMap, AnalysisType.STACK); - var ignored = getIgnoredDeps(manifest); + var sbom = buildSbomFromTextFormat(tempFile, propertiesMap, AnalysisType.STACK); + var ignored = getIgnoredDeps(manifest); - return new Content( - sbom.filterIgnoredDeps(ignored).getAsJsonString().getBytes(), Api.CYCLONEDX_MEDIA_TYPE); + return new Content( + sbom.filterIgnoredDeps(ignored).getAsJsonString().getBytes(), Api.CYCLONEDX_MEDIA_TYPE); + } finally { + Files.deleteIfExists(tempFile); + } } private List getIgnoredDeps(Path manifestPath) throws IOException { @@ -228,28 +235,42 @@ private String extractPackageName(String line) { } private Path getDependencies(Path manifestPath) throws IOException { - // create a temp file for storing the dependency tree in - var tempFile = Files.createTempFile("TRUSTIFY_DA_graph_", null); - // the command will create the dependency tree in the temp file String gradleCommand = gradleExecutable + " dependencies"; - String[] cmdList = gradleCommand.split("\\s+"); - String gradleOutput = - Operations.runProcessGetOutput(Path.of(manifestPath.getParent().toString()), cmdList); - Files.writeString(tempFile, gradleOutput); + var result = + Operations.runProcessGetFullOutput( + Path.of(manifestPath.getParent().toString()), cmdList, null, TIMEOUT); + + if (result.getExitCode() != 0) { + throw new RuntimeException( + String.format( + "gradle dependencies command failed with exit code %d for manifest '%s': %s", + result.getExitCode(), manifestPath, result.getError())); + } + + var tempFile = Files.createTempFile("TRUSTIFY_DA_graph_", null); + Files.writeString(tempFile, result.getOutput()); return tempFile; } private Path getProperties(Path manifestPath) throws IOException { - Path propsTempFile = Files.createTempFile("propsfile", ".txt"); String propCmd = gradleExecutable + " properties"; String[] propCmdList = propCmd.split("\\s+"); - String properties = - Operations.runProcessGetOutput(Path.of(manifestPath.getParent().toString()), propCmdList); - // Create a temporary file - Files.writeString(propsTempFile, properties); + var result = + Operations.runProcessGetFullOutput( + Path.of(manifestPath.getParent().toString()), propCmdList, null, TIMEOUT); + + if (result.getExitCode() != 0) { + throw new RuntimeException( + String.format( + "gradle properties command failed with exit code %d for manifest '%s': %s", + result.getExitCode(), manifestPath, result.getError())); + } + + Path propsTempFile = Files.createTempFile("propsfile", ".txt"); + Files.writeString(propsTempFile, result.getOutput()); return propsTempFile; } @@ -509,9 +530,12 @@ private boolean containsVersion(String line) { private String getRoot(Path textFormatFile, Map propertiesMap) throws IOException { - String group = propertiesMap.get("group"); - String version = propertiesMap.get("version"); + String group = propertiesMap.getOrDefault("group", "unknown"); + String version = propertiesMap.getOrDefault("version", "0.0.0"); String rootName = extractRootProjectValue(textFormatFile); + if (rootName == null || rootName.isEmpty()) { + rootName = "unknown"; + } return group + ':' + rootName + ':' + "jar" + ':' + version; } @@ -531,24 +555,28 @@ private String extractRootProjectValue(Path inputFilePath) throws IOException { private Map extractProperties(Path manifestPath) throws IOException { Path propsTempFile = getProperties(manifestPath); - String content = Files.readString(propsTempFile); - // Define the regular expression pattern for key-value pairs - Pattern pattern = Pattern.compile("([^:]+):\\s+(.+)"); - Matcher matcher = pattern.matcher(content); - // Create a Map to store key-value pairs - Map keyValueMap = new HashMap<>(); - - // Iterate through matches and add them to the map - while (matcher.find()) { - String key = matcher.group(1).trim(); - String value = matcher.group(2).trim(); - keyValueMap.put(key, value); - } - // Check if any key-value pairs were found - if (!keyValueMap.isEmpty()) { - return keyValueMap; - } else { - return Collections.emptyMap(); + try { + String content = Files.readString(propsTempFile); + // Define the regular expression pattern for key-value pairs + Pattern pattern = Pattern.compile("([^:]+):\\s+(.+)"); + Matcher matcher = pattern.matcher(content); + // Create a Map to store key-value pairs + Map keyValueMap = new HashMap<>(); + + // Iterate through matches and add them to the map + while (matcher.find()) { + String key = matcher.group(1).trim(); + String value = matcher.group(2).trim(); + keyValueMap.put(key, value); + } + // Check if any key-value pairs were found + if (!keyValueMap.isEmpty()) { + return keyValueMap; + } else { + return Collections.emptyMap(); + } + } finally { + Files.deleteIfExists(propsTempFile); } } @@ -577,14 +605,17 @@ private List extractLines(Path inputFilePath, String startMarker) throws @Override public Content provideComponent() throws IOException { - Path tempFile = getDependencies(manifest); - Map propertiesMap = extractProperties(manifest); + try { + Map propertiesMap = extractProperties(manifest); - Sbom sbom = buildSbomFromTextFormat(tempFile, propertiesMap, AnalysisType.COMPONENT); - var ignored = getIgnoredDeps(manifest); + Sbom sbom = buildSbomFromTextFormat(tempFile, propertiesMap, AnalysisType.COMPONENT); + var ignored = getIgnoredDeps(manifest); - return new Content( - sbom.filterIgnoredDeps(ignored).getAsJsonString().getBytes(), Api.CYCLONEDX_MEDIA_TYPE); + return new Content( + sbom.filterIgnoredDeps(ignored).getAsJsonString().getBytes(), Api.CYCLONEDX_MEDIA_TYPE); + } finally { + Files.deleteIfExists(tempFile); + } } } diff --git a/src/main/java/io/github/guacsec/trustifyda/tools/Operations.java b/src/main/java/io/github/guacsec/trustifyda/tools/Operations.java index 9b1dd932..26981128 100644 --- a/src/main/java/io/github/guacsec/trustifyda/tools/Operations.java +++ b/src/main/java/io/github/guacsec/trustifyda/tools/Operations.java @@ -29,6 +29,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -192,8 +194,15 @@ public static String runProcessGetOutput(Path dir, final String[] cmdList, Strin } } + private static final long DEFAULT_PROCESS_TIMEOUT_SECONDS = 120L; + public static ProcessExecOutput runProcessGetFullOutput( Path dir, final String[] cmdList, String[] envList) { + return runProcessGetFullOutput(dir, cmdList, envList, DEFAULT_PROCESS_TIMEOUT_SECONDS); + } + + public static ProcessExecOutput runProcessGetFullOutput( + Path dir, final String[] cmdList, String[] envList, long timeoutSeconds) { try { Process process; if (dir == null) { @@ -210,34 +219,46 @@ public static ProcessExecOutput runProcessGetFullOutput( } } - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - StringBuilder output = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - output.append(line); - if (!line.endsWith(System.lineSeparator())) { - output.append("\n"); - } - } + CompletableFuture stdoutFuture = + CompletableFuture.supplyAsync(() -> drainStream(process.getInputStream())); + CompletableFuture stderrFuture = + CompletableFuture.supplyAsync(() -> drainStream(process.getErrorStream())); - reader = new BufferedReader(new InputStreamReader(process.getErrorStream())); - StringBuilder error = new StringBuilder(); - while ((line = reader.readLine()) != null) { - error.append(line); - if (!line.endsWith(System.lineSeparator())) { - error.append("\n"); - } + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException( + String.format( + "Command '%s' timed out after %d seconds", join(" ", cmdList), timeoutSeconds)); } - process.waitFor(30L, TimeUnit.SECONDS); - - return new ProcessExecOutput(output.toString(), error.toString(), process.exitValue()); - } catch (IOException | InterruptedException e) { + return new ProcessExecOutput(stdoutFuture.get(), stderrFuture.get(), process.exitValue()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + String.format("Command '%s' was interrupted", join(" ", cmdList)), e); + } catch (ExecutionException e) { + throw new RuntimeException( + String.format("Failed reading output of command '%s'", join(" ", cmdList)), e); + } catch (IOException e) { throw new RuntimeException( String.format("Failed to execute command '%s' ", join(" ", cmdList)), e); } } + private static String drainStream(java.io.InputStream inputStream) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + return sb.toString(); + } catch (IOException e) { + throw new RuntimeException("Failed to read process stream", e); + } + } + public static class ProcessExecOutput { private final String output; private final String error; diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/Gradle_Provider_Test.java b/src/test/java/io/github/guacsec/trustifyda/providers/Gradle_Provider_Test.java index ecd5c23e..0a78e8e9 100644 --- a/src/test/java/io/github/guacsec/trustifyda/providers/Gradle_Provider_Test.java +++ b/src/test/java/io/github/guacsec/trustifyda/providers/Gradle_Provider_Test.java @@ -17,8 +17,11 @@ package io.github.guacsec.trustifyda.providers; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mockStatic; import io.github.guacsec.trustifyda.Api; @@ -27,7 +30,9 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import java.util.stream.Stream; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentMatcher; @@ -127,9 +132,10 @@ void test_the_provideStack(String testFolder) throws IOException { gradleProperties = new String(is.readAllBytes()); } - ArgumentMatcher gradle = string -> string.equals("gradle"); - ArgumentMatcher dependencies = string -> string.equals("dependencies"); - ArgumentMatcher properties = string -> string.equals("properties"); + ArgumentMatcher containsDependencies = + cmd -> cmd != null && Arrays.asList(cmd).contains("dependencies"); + ArgumentMatcher containsProperties = + cmd -> cmd != null && Arrays.asList(cmd).contains("properties"); try (MockedStatic mockedOperations = mockStatic(Operations.class)) { mockedOperations.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); mockedOperations @@ -138,15 +144,15 @@ void test_the_provideStack(String testFolder) throws IOException { mockedOperations .when( () -> - Operations.runProcessGetOutput( - any(Path.class), argThat(gradle), argThat(dependencies))) - .thenReturn(depTree); + Operations.runProcessGetFullOutput( + any(Path.class), argThat(containsDependencies), isNull(), anyLong())) + .thenReturn(new Operations.ProcessExecOutput(depTree, "", 0)); mockedOperations .when( () -> - Operations.runProcessGetOutput( - any(Path.class), argThat(gradle), argThat(properties))) - .thenReturn(gradleProperties); + Operations.runProcessGetFullOutput( + any(Path.class), argThat(containsProperties), isNull(), anyLong())) + .thenReturn(new Operations.ProcessExecOutput(gradleProperties, "", 0)); // when providing stack content for our pom var content = new GradleProvider(tmpGradleFile).provideStack(); @@ -231,9 +237,10 @@ void test_the_provideComponent(String testFolder) throws IOException { } try (MockedStatic mockedOperations = mockStatic(Operations.class)) { - ArgumentMatcher gradle = string -> string.equals("gradle"); - ArgumentMatcher dependencies = string -> string.equals("dependencies"); - ArgumentMatcher properties = string -> string.equals("properties"); + ArgumentMatcher containsDependencies = + cmd -> cmd != null && Arrays.asList(cmd).contains("dependencies"); + ArgumentMatcher containsProperties = + cmd -> cmd != null && Arrays.asList(cmd).contains("properties"); mockedOperations.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); mockedOperations .when(() -> Operations.getExecutable("gradle", "--version")) @@ -241,15 +248,15 @@ void test_the_provideComponent(String testFolder) throws IOException { mockedOperations .when( () -> - Operations.runProcessGetOutput( - any(Path.class), argThat(gradle), argThat(dependencies))) - .thenReturn(depTree); + Operations.runProcessGetFullOutput( + any(Path.class), argThat(containsDependencies), isNull(), anyLong())) + .thenReturn(new Operations.ProcessExecOutput(depTree, "", 0)); mockedOperations .when( () -> - Operations.runProcessGetOutput( - any(Path.class), argThat(gradle), argThat(properties))) - .thenReturn(gradleProperties); + Operations.runProcessGetFullOutput( + any(Path.class), argThat(containsProperties), isNull(), anyLong())) + .thenReturn(new Operations.ProcessExecOutput(gradleProperties, "", 0)); // when providing component content for our pom var content = new GradleProvider(tmpGradleFile).provideComponent(); @@ -261,6 +268,126 @@ void test_the_provideComponent(String testFolder) throws IOException { } } + @Test + void test_provideStack_throws_on_gradle_failure() throws IOException { + var tmpGradleDir = Files.createTempDirectory("TRUSTIFY_DA_test_"); + var tmpGradleFile = Files.createFile(tmpGradleDir.resolve(getManifestName())); + try (var is = + getClass() + .getClassLoader() + .getResourceAsStream( + String.join( + "/", "tst_manifests", getProviderFolder(), "empty", getManifestName()))) { + Files.write(tmpGradleFile, is.readAllBytes()); + } + var settingsFile = Files.createFile(tmpGradleDir.resolve(getSettingsName())); + try (var is = + getClass() + .getClassLoader() + .getResourceAsStream( + String.join( + "/", "tst_manifests", getProviderFolder(), "empty", getSettingsName()))) { + Files.write(settingsFile, is.readAllBytes()); + } + + String gradleErrorOutput = + "FAILURE: Build failed with an exception.\n" + + "* What went wrong:\n" + + "A problem occurred evaluating root project.\n" + + "> No such property: io for class: java.lang.String\n" + + "BUILD FAILED in 761ms\n"; + + ArgumentMatcher containsDependencies = + cmd -> cmd != null && Arrays.asList(cmd).contains("dependencies"); + ArgumentMatcher containsProperties = + cmd -> cmd != null && Arrays.asList(cmd).contains("properties"); + try (MockedStatic mockedOperations = mockStatic(Operations.class)) { + mockedOperations.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + mockedOperations + .when(() -> Operations.getExecutable("gradle", "--version")) + .thenReturn("gradle"); + mockedOperations + .when( + () -> + Operations.runProcessGetFullOutput( + any(Path.class), argThat(containsDependencies), isNull(), anyLong())) + .thenReturn(new Operations.ProcessExecOutput("", gradleErrorOutput, 1)); + mockedOperations + .when( + () -> + Operations.runProcessGetFullOutput( + any(Path.class), argThat(containsProperties), isNull(), anyLong())) + .thenReturn(new Operations.ProcessExecOutput("", gradleErrorOutput, 1)); + + assertThatThrownBy(() -> new GradleProvider(tmpGradleFile).provideStack()) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("gradle dependencies command failed with exit code 1") + .hasMessageContaining("No such property: io for class: java.lang.String"); + + Files.deleteIfExists(tmpGradleFile); + } + } + + @Test + void test_provideComponent_throws_on_gradle_failure() throws IOException { + var tmpGradleDir = Files.createTempDirectory("TRUSTIFY_DA_test_"); + var tmpGradleFile = Files.createFile(tmpGradleDir.resolve(getManifestName())); + try (var is = + getClass() + .getClassLoader() + .getResourceAsStream( + String.join( + "/", "tst_manifests", getProviderFolder(), "empty", getManifestName()))) { + Files.write(tmpGradleFile, is.readAllBytes()); + } + var settingsFile = Files.createFile(tmpGradleDir.resolve(getSettingsName())); + try (var is = + getClass() + .getClassLoader() + .getResourceAsStream( + String.join( + "/", "tst_manifests", getProviderFolder(), "empty", getSettingsName()))) { + Files.write(settingsFile, is.readAllBytes()); + } + + String gradleErrorOutput = + "FAILURE: Build failed with an exception.\n" + + "* What went wrong:\n" + + "A problem occurred evaluating root project.\n" + + "> No such property: io for class: java.lang.String\n" + + "BUILD FAILED in 761ms\n"; + + ArgumentMatcher containsDependencies = + cmd -> cmd != null && Arrays.asList(cmd).contains("dependencies"); + ArgumentMatcher containsProperties = + cmd -> cmd != null && Arrays.asList(cmd).contains("properties"); + try (MockedStatic mockedOperations = mockStatic(Operations.class)) { + mockedOperations.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + mockedOperations + .when(() -> Operations.getExecutable("gradle", "--version")) + .thenReturn("gradle"); + mockedOperations + .when( + () -> + Operations.runProcessGetFullOutput( + any(Path.class), argThat(containsDependencies), isNull(), anyLong())) + .thenReturn(new Operations.ProcessExecOutput("", gradleErrorOutput, 1)); + mockedOperations + .when( + () -> + Operations.runProcessGetFullOutput( + any(Path.class), argThat(containsProperties), isNull(), anyLong())) + .thenReturn(new Operations.ProcessExecOutput("", gradleErrorOutput, 1)); + + assertThatThrownBy(() -> new GradleProvider(tmpGradleFile).provideComponent()) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("gradle dependencies command failed with exit code 1") + .hasMessageContaining("No such property: io for class: java.lang.String"); + + Files.deleteIfExists(tmpGradleFile); + } + } + private String dropIgnored(String s) { return s.replaceAll("\\s+", "").replaceAll("\"timestamp\":\"[a-zA-Z0-9\\-\\:]+\",", ""); }