diff --git a/.gitignore b/.gitignore index 0b14f1fd..5a29379a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /*.iml /.idea/ .mvn/** +**/trivyReport.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 038b0815..845e0f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Add Trivy class for scanning container images with Trivy + - Combines the functionality of the findVulnerabilitiesWithTrivy function and the Trivy class of the dogu-build-lib + +### Deprecated +- findVulnerabilitiesWithTrivy function is deprecated now. Please use the new Trivy class. + ### Changed - [#140] Update Maven-Build-Dependencies - JUnit 5 diff --git a/Jenkinsfile b/Jenkinsfile index 19107473..2917e5e6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -11,7 +11,7 @@ node('docker') { def cesBuildLib = libraryFromLocalRepo().com.cloudogu.ces.cesbuildlib - def mvn = cesBuildLib.MavenWrapperInDocker.new(this, 'adoptopenjdk/openjdk11:jdk-11.0.10_9-alpine') + def mvn = cesBuildLib.MavenWrapperInDocker.new(this, 'eclipse-temurin:11.0.25_9-jdk-alpine') mvn.useLocalRepoFromJenkins = true def git = cesBuildLib.Git.new(this) @@ -40,7 +40,7 @@ node('docker') { } stage('Unit Test') { - mvn 'test -Dmaven.test.failure.ignore=true' + mvn 'test' // Archive Unit and integration test results, if any junit allowEmptyResults: true, testResults: '**/target/failsafe-reports/TEST-*.xml,**/target/surefire-reports/TEST-*.xml' } @@ -70,4 +70,4 @@ def libraryFromLocalRepo() { // Checks out to workspace local folder named like the identifier. // We have to pass an identifier with version (which is ignored). Otherwise the build fails. library(identifier: 'ces-build-lib@snapshot', retriever: legacySCM(scm)) -} \ No newline at end of file +} diff --git a/README.md b/README.md index 052010eb..29ac16a3 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Jenkins Pipeline Shared library, that contains additional features for Git, Mave - [Markdown](#markdown) - [DockerLint (Deprecated)](#dockerlint-deprecated) - [ShellCheck](#shellcheck) +- [Trivy](#trivy) - [Steps](#steps) - [mailIfStatusChanged](#mailifstatuschanged) - [isPullRequest](#ispullrequest) @@ -1240,6 +1241,179 @@ shellCheck(fileList) // fileList="a.sh b.sh" execute shellcheck on a custom list See [shellCheck](vars/shellCheck.groovy) +# Trivy + +Scan container images for vulnerabilities with Trivy. + +## Create a Trivy object + +```groovy +Trivy trivy = new Trivy(this) +// With specific Trivy version +Trivy trivy = new Trivy(this, "0.57.1") +// With specific Trivy image +Trivy trivy = new Trivy(this, "0.57.1", "images.mycompany.test/trivy") +// With explicit Docker registry +Docker docker = new Docker(this) +docker.withRegistry("https://my.registry.invalid", myRegistryCredentialsID) +Trivy trivy = new Trivy(this, "0.57.1", "aquasec/trivy", docker) +``` + +## Scan image with Trivy + +Scan an image with Trivy by calling the `scanImage` function. + +```groovy +Trivy trivy = new Trivy(this) +boolean imageIsSafe = trivy.scanImage("ubuntu:24.04") +if (!imageIsSafe){ + echo "This image has vulnerabilities!" +} +``` + +### Set the severity level for the scan + +You can set the severity levels of the vulnerabilities Trivy should scan for as a parameter of the scan method: + +```groovy +Trivy trivy = new Trivy(this) +trivy.scanImage("ubuntu:24.04", TrivySeverityLevel.ALL) +trivy.scanImage("ubuntu:24.04", "CRITICAL,LOW") +``` + +For the available pre-defined severity levels see [TrivySeverityLevel.groovy](src/com/cloudogu/ces/cesbuildlib/TrivySeverityLevel.groovy) + +### Set the pipeline strategy + +To define how the Jenkins pipeline should behave if vulnerabilities are found, you can set certain strategies: +- TrivyScanStrategy.IGNORE: Ignore the vulnerabilities and continue +- TrivyScanStrategy.UNSTABLE: Mark the job as "unstable" and continue +- TrivyScanStrategy.FAIL: Mark the job as failed + +```groovy +Trivy trivy = new Trivy(this) +trivy.scanImage("ubuntu:24.04", TrivySeverityLevel.ALL, TrivyScanStrategy.UNSTABLE) +``` + +### Set additional Trivy flags + +To set additional Trivy command flags, use the `additionalFlags` parameter: + +```groovy +Trivy trivy = new Trivy(this) +trivy.scanImage("ubuntu:24.04", TrivySeverityLevel.ALL, TrivyScanStrategy.UNSTABLE, "--db-repository public.ecr.aws/aquasecurity/trivy-db") +``` + +Note that the flags `--db-repository public.ecr.aws/aquasecurity/trivy-db --java-db-repository public.ecr.aws/aquasecurity/trivy-java-db` +are set by default to avoid rate limiting of Trivy database downloads. If you set `additionalFlags` by yourself, you are overwriting +these default flags and have to make sure to include them in your set of additional flags, if needed. + +### Set the Trivy report file name + +If you want to run multiple image scans in one pipeline, you can set distinct file names for the report files: + +```groovy +Trivy trivy = new Trivy(this) +trivy.scanImage("ubuntu:20.04", TrivySeverityLevel.ALL, TrivyScanStrategy.UNSTABLE, "", "trivy/ubuntu20.json") +trivy.scanImage("ubuntu:24.04", TrivySeverityLevel.ALL, TrivyScanStrategy.UNSTABLE, "", "trivy/ubuntu24.json") +// Save report by using the same file name (last parameter) +trivy.saveFormattedTrivyReport(TrivyScanFormat.HTML, "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL", "ubuntu20.04report", "trivy/ubuntu20.json") +``` + +## Save Trivy report in another file format + +After calling the `scanImage` function you can save the scan report as JSON, HTML or table files. + +```groovy +Trivy trivy = new Trivy(this) +trivy.scanImage("ubuntu:24.04") +trivy.saveFormattedTrivyReport(TrivyScanFormat.TABLE) +trivy.saveFormattedTrivyReport(TrivyScanFormat.JSON) +trivy.saveFormattedTrivyReport(TrivyScanFormat.HTML) +``` + +You may filter the output to show only specific severity levels (default: `"UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL"`): + +```groovy +Trivy trivy = new Trivy(this) +trivy.scanImage("ubuntu:24.04") +trivy.saveFormattedTrivyReport(TrivyScanFormat.TABLE, "CRITICAL") +trivy.saveFormattedTrivyReport(TrivyScanFormat.JSON, "UNKNOWN,LOW,MEDIUM") +``` + +You may also use any other supported [Trivy format](https://trivy.dev/v0.57/docs/references/configuration/cli/trivy_convert/) or a custom template from a file in your workspace. + +```groovy +Trivy trivy = new Trivy(this) +trivy.scanImage("ubuntu:24.04") +trivy.saveFormattedTrivyReport("cosign-vuln", "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL", "ubuntu24.04cosign.txt") +trivy.saveFormattedTrivyReport("template --template @myTemplateFile.xyz", "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL", "ubuntu24.04myTemplate.txt") +``` + +## Scan Dogu image with Trivy + +This section describes how to get a Dogu image from the testing CES instance and scan it with Trivy. + +### Get Dogu image from CES instance + +Make sure to have a `build` stage in your Dogu test pipeline which builds the Dogu image, e.g. via +the `ecoSystem.build("/dogu")` command. +After the build stage you will be able to copy the Dogu image to your local Jenkins worker via +the `ecoSystem.copyDoguImageToJenkinsWorker("/dogu")` command. + +### Scan Dogu image + +The `scanDogu()` function lets you scan a Dogu image without typing its full name. The method reads the image name +and version from the dogu.json inside the directory you point it to via its first argument. +The default directory is the current directory. + +```groovy +// Preparation +ecoSystem.copyDoguImageToJenkinsWorker("/dogu") +Trivy trivy = new Trivy(this) + +// Scan the Dogu image +trivy.scanDogu() +// Explicitly set directory that contains the dogu code (dogu.json) +trivy.scanDogu("subfolder/test1/jenkins") +// Set scan options just like in the scanImage method +trivy.scanDogu(".", TrivySeverityLevel.ALL, TrivyScanStrategy.UNSTABLE, "", "trivy/mydogu.json") +trivy.saveFormattedTrivyReport(TrivyScanFormat.TABLE) +trivy.saveFormattedTrivyReport(TrivyScanFormat.JSON) +trivy.saveFormattedTrivyReport(TrivyScanFormat.HTML) +``` + +## Ignore / allowlist + +If you want to ignore / allow certain vulnerabilities, please use a `.trivyignore` file. + +Provide the file in your repo `/` directory where you run your job, e.g.: + +```shell +.gitignore +Jenkinsfile +.trivyignore +``` + +[Offical documentation](https://trivy.dev/v0.57/docs/configuration/filtering/#by-finding-ids) +```ignorelang +# Accept the risk +CVE-2018-14618 + +# Accept the risk until 2023-01-01 +CVE-2019-14697 exp:2023-01-01 + +# No impact in our settings +CVE-2019-1543 + +# Ignore misconfigurations +AVD-DS-0002 + +# Ignore secrets +generic-unwanted-rule +aws-account-id +``` + # Steps ## mailIfStatusChanged @@ -1293,7 +1467,9 @@ For example, if running on `http(s)://server:port/jenkins`, `server` is returned Returns true if the build is successful, i.e. not failed or unstable (yet). -## findVulnerabilitiesWithTrivy +## findVulnerabilitiesWithTrivy (Deprecated) + +This function is deprecated. Use [Trivy](#trivy) functionality instead. Returns a list of vulnerabilities or an empty list if there are no vulnerabilities for the given severity. @@ -1330,36 +1506,7 @@ node { } ``` -### Ignore / allowlist - -If you want to ignore / allow certain vulnerabilities please use a .trivyignore file -Provide the file in your repo / directory where you run your job -e.g.: -```shell -.gitignore -Jenkinsfile -.trivyignore -``` - -[Offical documentation](https://aquasecurity.github.io/trivy/v0.41/docs/configuration/filtering/#by-finding-ids) -```ignorelang -# Accept the risk -CVE-2018-14618 -# Accept the risk until 2023-01-01 -CVE-2019-14697 exp:2023-01-01 - -# No impact in our settings -CVE-2019-1543 - -# Ignore misconfigurations -AVD-DS-0002 - -# Ignore secrets -generic-unwanted-rule -aws-account-id - -``` If there are vulnerabilities the output looks as follows. diff --git a/pom.xml b/pom.xml index 2cecc83b..c7189b12 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,12 @@ groovy-all 2.5.23 pom + + + org.codehaus.groovy + groovy-test + + @@ -61,7 +67,14 @@ org.mockito mockito-junit-jupiter - 5.14.2 + 3.6.28 + test + + + + org.mockito + mockito-core + 3.6.28 test @@ -75,7 +88,14 @@ org.junit.jupiter junit-jupiter - 5.11.3 + 5.4.2 + test + + + + org.junit.vintage + junit-vintage-engine + 5.4.2 test @@ -86,6 +106,19 @@ test + + + commons-io + commons-io + 2.18.0 + test + + + org.apache.commons + commons-compress + 1.27.1 + test + diff --git a/src/com/cloudogu/ces/cesbuildlib/Docker.groovy b/src/com/cloudogu/ces/cesbuildlib/Docker.groovy index 9937d91d..aeacce2f 100644 --- a/src/com/cloudogu/ces/cesbuildlib/Docker.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/Docker.groovy @@ -401,7 +401,7 @@ class Docker implements Serializable { def userName = sh.returnStdOut('whoami') String jenkinsUserFromEtcPasswd = sh.returnStdOut "cat /etc/passwd | grep $userName" - if (jenkinsUserFromEtcPasswd.isEmpty()) { + if (jenkinsUserFromEtcPasswd == null || jenkinsUserFromEtcPasswd.isEmpty()) { script.error 'Unable to parse user jenkins from /etc/passwd.' } return jenkinsUserFromEtcPasswd diff --git a/src/com/cloudogu/ces/cesbuildlib/Sh.groovy b/src/com/cloudogu/ces/cesbuildlib/Sh.groovy index d9521c37..14f2e98c 100644 --- a/src/com/cloudogu/ces/cesbuildlib/Sh.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/Sh.groovy @@ -18,6 +18,6 @@ class Sh implements Serializable { // Trim to remove trailing line breaks, which result in unwanted behavior in Jenkinsfiles: // E.g. when using output in other sh() calls leading to executing the sh command after the line breaks, // possibly discarding additional arguments - .trim() + ?.trim() } } diff --git a/src/com/cloudogu/ces/cesbuildlib/Trivy.groovy b/src/com/cloudogu/ces/cesbuildlib/Trivy.groovy new file mode 100644 index 00000000..3e52fed5 --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/Trivy.groovy @@ -0,0 +1,162 @@ +package com.cloudogu.ces.cesbuildlib + +import com.cloudbees.groovy.cps.NonCPS + +class Trivy implements Serializable { + static final String DEFAULT_TRIVY_VERSION = "0.57.1" + static final String DEFAULT_TRIVY_IMAGE = "aquasec/trivy" + private script + private Docker docker + private String trivyVersion + private String trivyImage + private String trivyDirectory = "trivy" + + // Do not use DEFAULT_TRIVY_VERSION or DEFAULT_TRIVY_IMAGE here, as it will lead to java.lang.VerifyError + Trivy(script, String trivyVersion = "0.57.1", String trivyImage = "aquasec/trivy", Docker docker = new Docker(script)) { + this.script = script + this.trivyVersion = trivyVersion + this.trivyImage = trivyImage + this.docker = docker + } + + /** + * Scans an image for vulnerabilities. + * Notes: + * - Use a .trivyignore file for allowed CVEs + * - This function will generate a JSON formatted report file which can be converted to other formats via saveFormattedTrivyReport() + * + * @param imageName The name of the image to be scanned; may include a version tag + * @param severityLevel The vulnerability level to scan. Can be a member of TrivySeverityLevel or a custom String (e.g. 'CRITICAL,LOW') + * @param strategy The strategy to follow after the scan. Should the build become unstable or failed? Or Should any vulnerability be ignored? (@see TrivyScanStrategy) + * @param additionalFlags Additional Trivy command flags + * @param trivyReportFile Location of Trivy report file. Should be set individually when scanning multiple images in the same pipeline + * @return Returns true if the scan was ok (no vulnerability found); returns false if any vulnerability was found + */ + boolean scanImage( + String imageName, + String severityLevel = TrivySeverityLevel.CRITICAL, + String strategy = TrivyScanStrategy.UNSTABLE, + // Avoid rate limits of default Trivy database source + String additionalFlags = "--db-repository public.ecr.aws/aquasecurity/trivy-db --java-db-repository public.ecr.aws/aquasecurity/trivy-java-db", + String trivyReportFile = "trivy/trivyReport.json" + ) { + Integer exitCode = docker.image("${trivyImage}:${trivyVersion}") + .mountJenkinsUser() + .mountDockerSocket() + .inside("-v ${script.env.WORKSPACE}/.trivy/.cache:/root/.cache/") { + // Write result to $trivyReportFile in json format (--format json), which can be converted in the saveFormattedTrivyReport function + // Exit with exit code 10 if vulnerabilities are found or OS is so old that Trivy has no records for it anymore + script.sh("mkdir -p " + trivyDirectory) + script.sh(script: "trivy image --exit-code 10 --exit-on-eol 10 --format ${TrivyScanFormat.JSON} -o ${trivyReportFile} --severity ${severityLevel} ${additionalFlags} ${imageName}", returnStatus: true) + } + switch (exitCode) { + case 0: + // Everything all right, no vulnerabilities + return true + case 10: + // Found vulnerabilities + // Set build status according to strategy + switch (strategy) { + case TrivyScanStrategy.IGNORE: + break + case TrivyScanStrategy.UNSTABLE: + script.archiveArtifacts artifacts: "${trivyReportFile}", allowEmptyArchive: true + script.unstable("Trivy has found vulnerabilities in image " + imageName + ". See " + trivyReportFile) + break + case TrivyScanStrategy.FAIL: + script.archiveArtifacts artifacts: "${trivyReportFile}", allowEmptyArchive: true + script.error("Trivy has found vulnerabilities in image " + imageName + ". See " + trivyReportFile) + break + } + return false + default: + script.error("Error during trivy scan; exit code: " + exitCode) + } + } + + /** + * Scans a dogu image for vulnerabilities. + * Notes: + * - Use a .trivyignore file for allowed CVEs + * - This function will generate a JSON formatted report file which can be converted to other formats via saveFormattedTrivyReport() + * + * @param doguDir The directory the dogu code (dogu.json) is located + * @param severityLevel The vulnerability level to scan. Can be a member of TrivySeverityLevel or a custom String (e.g. 'CRITICAL,LOW') + * @param strategy The strategy to follow after the scan. Should the build become unstable or failed? Or Should any vulnerability be ignored? (@see TrivyScanStrategy) + * @param additionalFlags Additional Trivy command flags + * @param trivyReportFile Location of Trivy report file. Should be set individually when scanning multiple images in the same pipeline + * @return Returns true if the scan was ok (no vulnerability found); returns false if any vulnerability was found + */ + boolean scanDogu( + String doguDir = ".", + String severityLevel = TrivySeverityLevel.CRITICAL, + String strategy = TrivyScanStrategy.UNSTABLE, + // Avoid rate limits of default Trivy database source + String additionalFlags = "--db-repository public.ecr.aws/aquasecurity/trivy-db --java-db-repository public.ecr.aws/aquasecurity/trivy-java-db", + String trivyReportFile = "trivy/trivyReport.json" + ) { + String image = script.sh(script: "jq .Image ${doguDir}/dogu.json", returnStdout: true).trim() + String version = script.sh(script: "jq .Version ${doguDir}/dogu.json", returnStdout: true).trim() + return scanImage(image + ":" + version, severityLevel, strategy, additionalFlags, trivyReportFile) + } + + /** + * Save the Trivy scan results as a file with a specific format + * + * @param format The format of the output file {@link TrivyScanFormat}. + * You may enter supported formats (sarif, cyclonedx, spdx, spdx-json, github, cosign-vuln, table or json) + * or your own template ("template --template @FILENAME"). + * If you want to convert to a format that requires a list of packages, such as SBOM, you need to add + * the `--list-all-pkgs` flag to the {@link Trivy#scanImage} call, when outputting in JSON + * (See trivy docs). + * @param severity Severities of security issues to be added (taken from UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL) + * @param formattedTrivyReportFilename The file name your report files should get, with file extension. E.g. "ubuntu24report.html" + * @param trivyReportFile The "trivyReportFile" parameter you used in the "scanImage" function, if it was set + */ + void saveFormattedTrivyReport(String format = TrivyScanFormat.HTML, + String severity = TrivySeverityLevel.ALL, + String formattedTrivyReportFilename = null, + String trivyReportFile = "trivy/trivyReport.json") { + + // set default report filename depending on the chosen format + if (formattedTrivyReportFilename == null) { + formattedTrivyReportFilename = "formattedTrivyReport" + getFileExtension(format) + } + + String formatString + switch (format) { + // TrivyScanFormat.JSON and TrivyScanFormat.TABLE are handled by the default case, too + case TrivyScanFormat.HTML: + formatString = "template --template \"@/contrib/html.tpl\"" + break + default: + // You may enter supported formats (sarif, cyclonedx, spdx, spdx-json, github, cosign-vuln, table or json) + // or your own template ("template --template @FILENAME") + List trivyFormats = ['sarif', 'cyclonedx', 'spdx', 'spdx-json', 'github', 'cosign-vuln', 'table', 'json'] + // Check if "format" is a custom template from a file + boolean isTemplateFormat = format ==~ /^template --template @\S+$/ + // Check if "format" is one of the trivyFormats or a template + if (trivyFormats.any { (format == it) } || isTemplateFormat) { + formatString = format + break + } else { + script.error("This format did not match the supported formats: " + format) + return + } + } + // Validate severity input parameter to prevent injection of additional parameters + if (!severity.split(',').every { it.trim() in ["UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"] }) { + script.error("The severity levels provided ($severity) do not match the applicable levels (UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL).") + } + + docker.image("${trivyImage}:${trivyVersion}") + .inside("-v ${script.env.WORKSPACE}/.trivy/.cache:/root/.cache/") { + script.sh(script: "trivy convert --format ${formatString} --severity ${severity} --output ${trivyDirectory}/${formattedTrivyReportFilename} ${trivyReportFile}") + } + script.archiveArtifacts artifacts: "${trivyDirectory}/${formattedTrivyReportFilename}.*", allowEmptyArchive: true + } + + private static String getFileExtension(String format) { + return TrivyScanFormat.isStandardScanFormat(format) ? "." + format : ".txt" + } +} diff --git a/src/com/cloudogu/ces/cesbuildlib/TrivyScanException.groovy b/src/com/cloudogu/ces/cesbuildlib/TrivyScanException.groovy new file mode 100644 index 00000000..94e6fece --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/TrivyScanException.groovy @@ -0,0 +1,10 @@ +package com.cloudogu.ces.cesbuildlib + +/** + * This exception is thrown whenever a vulnerability was found. + */ +class TrivyScanException extends RuntimeException { + TrivyScanException(String error) { + super(error) + } +} diff --git a/src/com/cloudogu/ces/cesbuildlib/TrivyScanFormat.groovy b/src/com/cloudogu/ces/cesbuildlib/TrivyScanFormat.groovy new file mode 100644 index 00000000..d91c184b --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/TrivyScanFormat.groovy @@ -0,0 +1,25 @@ +package com.cloudogu.ces.cesbuildlib + +/** + * Defines the output format for the trivy report. + */ +class TrivyScanFormat { + /** + * Output as HTML file. + */ + static String HTML = "html" + + /** + * Output as JSON file. + */ + static String JSON = "json" + + /** + * Output as table. + */ + static String TABLE = "table" + + static boolean isStandardScanFormat(String format) { + return format == HTML || format == JSON || format == TABLE + } +} diff --git a/src/com/cloudogu/ces/cesbuildlib/TrivyScanStrategy.groovy b/src/com/cloudogu/ces/cesbuildlib/TrivyScanStrategy.groovy new file mode 100644 index 00000000..f9db8951 --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/TrivyScanStrategy.groovy @@ -0,0 +1,18 @@ +package com.cloudogu.ces.cesbuildlib + +class TrivyScanStrategy { + /** + * Strategy: Fail if any vulnerability was found. + */ + static String FAIL = "fail" + + /** + * Strategy: Make build unstable if any vulnerability was found. + */ + static String UNSTABLE = "unstable" + + /** + * Strategy: Ignore any found vulnerability. + */ + static String IGNORE = "ignore" +} diff --git a/src/com/cloudogu/ces/cesbuildlib/TrivySeverityLevel.groovy b/src/com/cloudogu/ces/cesbuildlib/TrivySeverityLevel.groovy new file mode 100644 index 00000000..8759bd0d --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/TrivySeverityLevel.groovy @@ -0,0 +1,26 @@ +package com.cloudogu.ces.cesbuildlib + +/** + * Defines aggregated vulnerability levels + */ +class TrivySeverityLevel { + /** + * Only critical vulnerabilities. + */ + static String CRITICAL = "CRITICAL" + + /** + * High or critical vulnerabilities. + */ + static String HIGH_AND_ABOVE = "CRITICAL,HIGH" + + /** + * Medium or higher vulnerabilities. + */ + static String MEDIUM_AND_ABOVE = "CRITICAL,HIGH,MEDIUM" + + /** + * All vulnerabilities. + */ + static String ALL = "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL" +} diff --git a/test/com/cloudogu/ces/cesbuildlib/GradleTest.groovy b/test/com/cloudogu/ces/cesbuildlib/GradleTest.groovy index 8f7173eb..6aff2339 100644 --- a/test/com/cloudogu/ces/cesbuildlib/GradleTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/GradleTest.groovy @@ -4,7 +4,7 @@ package com.cloudogu.ces.cesbuildlib import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test -import static org.junit.Assert.assertEquals +import static org.junit.jupiter.api.Assertions.assertEquals class GradleTest { def scriptMock = new ScriptMock() diff --git a/test/com/cloudogu/ces/cesbuildlib/MavenInDockerBaseTest.groovy b/test/com/cloudogu/ces/cesbuildlib/MavenInDockerBaseTest.groovy index a0741513..337c340e 100644 --- a/test/com/cloudogu/ces/cesbuildlib/MavenInDockerBaseTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/MavenInDockerBaseTest.groovy @@ -4,7 +4,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import static org.assertj.core.api.Assertions.assertThat -import static org.junit.Assert.assertEquals +import static org.junit.jupiter.api.Assertions.assertEquals import static org.mockito.ArgumentMatchers.any import static org.mockito.ArgumentMatchers.eq import static org.mockito.Mockito.verify diff --git a/test/com/cloudogu/ces/cesbuildlib/MavenTest.groovy b/test/com/cloudogu/ces/cesbuildlib/MavenTest.groovy index 139eebc0..03d3d3dd 100644 --- a/test/com/cloudogu/ces/cesbuildlib/MavenTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/MavenTest.groovy @@ -6,14 +6,14 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import static groovy.test.GroovyAssert.shouldFail -import static org.junit.Assert.assertEquals +import static org.junit.jupiter.api.Assertions.assertEquals class MavenTest { private static final String EOL = System.getProperty("line.separator") static final EXPECTED_PWD = "/home/jenkins/workspaces/NAME" def expectedDeploymentGoalWithStaging = - 'org.sonatype.plugins:nexus-staging-maven-plugin:deploy -Dmaven.deploy.skip=true ' + + 'org.sonatype.plugins:nexus-staging-maven-plugin:deploy -Dmaven.deploy.skip=true ' + '-DserverId=expectedId -DnexusUrl=https://expected.url -DautoReleaseAfterClose=true ' def scriptMock = new ScriptMock() @@ -36,23 +36,23 @@ class MavenTest { def result = mvn "test" assertEquals("test", result) } - + @Test void testWithoutSettingsXml() throws Exception { def result = mvn "test" - assert !(mvn.createCommandLineArgs ('dont care')).contains('settings.xml') + assert !(mvn.createCommandLineArgs('dont care')).contains('settings.xml') assertEquals("test", result) } @Test void testCallWithMirrors() throws Exception { - mvn.useMirrors([name: 'n1', mirrorOf: 'm1', url: 'u1'], - [name: 'n2', mirrorOf: 'm2', url: 'u2']) + mvn.useMirrors([name: 'n1', mirrorOf: 'm1', url: 'u1'], + [name: 'n2', mirrorOf: 'm2', url: 'u2']) + + assert (mvn.createCommandLineArgs('dont care')).contains('settings.xml') - assert (mvn.createCommandLineArgs ('dont care')).contains('settings.xml') - def result = mvn "test" - + assertEquals("test", result) assert scriptMock.writeFileParams.size() == 1 @@ -84,7 +84,7 @@ class MavenTest { @Test void testCallWithMultipleCredentials() throws Exception { mvn.useRepositoryCredentials([id: 'number0', credentialsId: 'creds0'], - [id: 'number1', credentialsId: 'creds1']) + [id: 'number1', credentialsId: 'creds1']) def result = mvn "test" assertEquals("test", result) @@ -102,16 +102,14 @@ class MavenTest { @Test void testGetVersion() { Maven mvn = new MavenForTest() - assertEquals("Unexpected version returned", - "org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.version -q -DforceStdout", mvn.getVersion()) + assertEquals("org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.version -q -DforceStdout", mvn.getVersion(), "Unexpected version returned") } - + @Test void testGetVersionWithCredentials() { mvn.useRepositoryCredentials([id: 'number0', credentialsId: 'creds0']) - assertEquals("Unexpected version returned", - "org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.version -q -DforceStdout", mvn.getVersion()) - + assertEquals("org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.version -q -DforceStdout", mvn.getVersion(), "Unexpected version returned") + assert 'creds0' == scriptMock.actualUsernamePasswordArgs[0]['credentialsId'] assert "NEXUS_REPO_CREDENTIALS_PASSWORD_0" == scriptMock.actualUsernamePasswordArgs[0]['passwordVariable'] assert "NEXUS_REPO_CREDENTIALS_USERNAME_0" == scriptMock.actualUsernamePasswordArgs[0]['usernameVariable'] @@ -120,29 +118,25 @@ class MavenTest { @Test void testGetArtifactId() { Maven mvn = new MavenForTest() - assertEquals("Unexpected artifact returned", - "org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.artifactId -q -DforceStdout", mvn.getArtifactId()) + assertEquals("org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.artifactId -q -DforceStdout", mvn.getArtifactId(), "Unexpected artifact returned") } @Test void testGetGroupId() { Maven mvn = new MavenForTest() - assertEquals("Unexpected group returned", - "org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.groupId -q -DforceStdout", mvn.getGroupId()) + assertEquals("org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.groupId -q -DforceStdout", mvn.getGroupId(), "Unexpected group returned") } @Test void testGetName() { Maven mvn = new MavenForTest() - assertEquals("Unexpected name returned", - "org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.name -q -DforceStdout", mvn.getName()) + assertEquals("org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=project.name -q -DforceStdout", mvn.getName(), "Unexpected name returned") } @Test void testGetMavenProperty() { Maven mvn = new MavenForTest() - assertEquals("Unexpected name returned", - "org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=key -q -DforceStdout", mvn.getMavenProperty('key')) + assertEquals("org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=key -q -DforceStdout", mvn.getMavenProperty('key'), "Unexpected name returned") } @Test @@ -162,16 +156,14 @@ class MavenTest { @Test void testGetMavenPropertyWithMavenWrapper() { Maven mvn = new MavenWrapperForTest() - assertEquals("Unexpected property returned", - "org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=key -q -DforceStdout", mvn.getMavenProperty('key')) + assertEquals("org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=key -q -DforceStdout", mvn.getMavenProperty('key'), "Unexpected property returned") } @Test void testGetMavenPropertyWithMavenWrapperNotYetDownloaded() { Maven mvn = new MavenWrapperForTest() mvn.downloaded = false - assertEquals("Unexpected property returned", - "org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=key -q -DforceStdout", mvn.getMavenProperty('key')) + assertEquals("org.apache.maven.plugins:maven-help-plugin:3.2.0:evaluate -Dexpression=key -q -DforceStdout", mvn.getMavenProperty('key'), "Unexpected property returned") } @Test @@ -185,13 +177,13 @@ class MavenTest { @Test void testUseRepositoryCredentialsMissingRequiredFieldsId() { assertMissingRepositoryParameter('id', - { mvn.useRepositoryCredentials([url: 'url', credentialsId: 'creds']) } ) + { mvn.useRepositoryCredentials([url: 'url', credentialsId: 'creds']) }) } @Test void testUseRepositoryCredentialsMissingRequiredFields() { assertMissingRepositoryParameter('credentialsIdUsernameAndPassword', - { mvn.useRepositoryCredentials([url: 'url', id: 'id']) } ) + { mvn.useRepositoryCredentials([url: 'url', id: 'id']) }) } @Test @@ -206,16 +198,16 @@ class MavenTest { def expectedAdditionalArgs = 'expectedAdditionalArgs' def actualAdditionalArgs = 'expectedAdditionalArgs' deployToNexusRepository(actualAdditionalArgs, 'site:deploy', - [[id: 'expectedId', credentialsId: 'expectedCredentials'], - [id: 'id', url: 'https://expected.url', credentialsId: 'creds', type: 'Nexus2']], - { mvn.deploySiteToNexus(expectedAdditionalArgs) }) + [[id: 'expectedId', credentialsId: 'expectedCredentials'], + [id: 'id', url: 'https://expected.url', credentialsId: 'creds', type: 'Nexus2']], + { mvn.deploySiteToNexus(expectedAdditionalArgs) }) } @Test void testDeployToNexusRepositoryWithMultipleUrls() { def exception = shouldFail { mvn.useRepositoryCredentials([id: 'id', credentialsId: 'creds', url: '1'], - [id: '2', credentialsId: 'creds2', url: '2']) + [id: '2', credentialsId: 'creds2', url: '2']) } assert "Multiple repositories with URL passed. Maven CLI only allows for passing one alt deployment repo." == exception.getMessage() @@ -240,7 +232,7 @@ class MavenTest { def expectedAdditionalArgs = 'expectedAdditionalArgs' def actualAdditionalArgs = 'expectedAdditionalArgs' deployToNexusRepository(DeployGoal.NEXUS_STAGING, expectedAdditionalArgs, actualAdditionalArgs, - expectedDeploymentGoalWithStaging, 'source:jar javadoc:jar package') + expectedDeploymentGoalWithStaging, 'source:jar javadoc:jar package') } @@ -263,8 +255,8 @@ class MavenTest { def expectedAdditionalArgs = 'expectedAdditionalArgs' def actualAdditionalArgs = 'expectedAdditionalArgs' deployToNexusRepository(actualAdditionalArgs, 'site:deploy', - [[id: 'expectedId', credentialsId: 'expectedCredentials', type: 'Nexus2']], - { mvn.deploySiteToNexus(expectedAdditionalArgs) }) + [[id: 'expectedId', credentialsId: 'expectedCredentials', type: 'Nexus2']], + { mvn.deploySiteToNexus(expectedAdditionalArgs) }) } @Test @@ -272,8 +264,8 @@ class MavenTest { def expectedAdditionalArgs = 'expectedAdditionalArgs' def actualAdditionalArgs = 'expectedAdditionalArgs' deployToNexusRepository(actualAdditionalArgs, 'site:deploy', - [[id: 'expectedId', credentialsId: 'expectedCredentials', type: 'Nexus2']], - { mvn.deploySiteToNexus(expectedAdditionalArgs) }) + [[id: 'expectedId', credentialsId: 'expectedCredentials', type: 'Nexus2']], + { mvn.deploySiteToNexus(expectedAdditionalArgs) }) } void deployToNexusRepositoryWithSignature(DeployGoal goal, String expectedDeploymentGoal, String beforeAdditionalArgs = '') { @@ -298,9 +290,9 @@ class MavenTest { private deployToNexusRepository(DeployGoal goal, String expectedAdditionalArgs, String actualAdditionalArgs, String expectedDeploymentGoal, String beforeAdditionalArgs = '') { deployToNexusRepository(actualAdditionalArgs, expectedDeploymentGoal, - [[id : 'expectedId', url: 'https://expected.url', credentialsId: 'expectedCredentials', type: 'Nexus2']], - { mvn.deployToNexusRepository(goal, expectedAdditionalArgs)}, - beforeAdditionalArgs + [[id: 'expectedId', url: 'https://expected.url', credentialsId: 'expectedCredentials', type: 'Nexus2']], + { mvn.deployToNexusRepository(goal, expectedAdditionalArgs) }, + beforeAdditionalArgs ) } @@ -314,13 +306,12 @@ class MavenTest { } String deploymentRepoId = deploymentRepo.id - def expectedCredentials = deploymentRepo.credentialsId def expectedUrl = deploymentRepo.url mvn.useRepositoryCredentials(repos.toArray(new Map[0])) methodUnderTest.call() - - assert (mvn.createCommandLineArgs ('dont care')).contains('settings.xml') + + assert (mvn.createCommandLineArgs('dont care')).contains('settings.xml') def repoIds = [] for (int i = 0; i < repos.size(); i++) { @@ -349,13 +340,13 @@ class MavenTest { private void assertSettingsXmlRepos(String... deploymentRepoIds) { assert scriptMock.writeFileParams.size() == 1 def actualSettingsXml = scriptMock.writeFileParams.get(0)['text'] - + for (int i = 0; i < deploymentRepoIds.size(); i++) { def deploymentRepoId = deploymentRepoIds[i] def string = "${deploymentRepoId}" + - "\${env.NEXUS_REPO_CREDENTIALS_USERNAME_${i}}" + - "\${env.NEXUS_REPO_CREDENTIALS_PASSWORD_${i}}" + "\${env.NEXUS_REPO_CREDENTIALS_USERNAME_${i}}" + + "\${env.NEXUS_REPO_CREDENTIALS_PASSWORD_${i}}" assert actualSettingsXml.contains(string) } } diff --git a/test/com/cloudogu/ces/cesbuildlib/SCMManagerTest.groovy b/test/com/cloudogu/ces/cesbuildlib/SCMManagerTest.groovy index eb4aef46..66da7253 100644 --- a/test/com/cloudogu/ces/cesbuildlib/SCMManagerTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/SCMManagerTest.groovy @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Test import static org.assertj.core.api.Assertions.assertThat import static org.mockito.ArgumentMatchers.any import static org.mockito.Mockito.mock -import static org.mockito.Mockito.when +import static org.mockito.Mockito.when class SCMManagerTest { @@ -19,7 +19,7 @@ class SCMManagerTest { HttpClient httpMock def slurper = new JsonSlurper() - + def jsonTwoPrs = JsonOutput.toJson([ _embedded: [ pullRequests: [ @@ -34,7 +34,7 @@ class SCMManagerTest { ] ] ]) - + @BeforeEach void init() { httpMock = mock(HttpClient.class) @@ -52,7 +52,7 @@ class SCMManagerTest { body : jsonTwoPrs.toString() ] }) - + def prs = scmm.searchPullRequestIdByTitle(repo, "one") assertThat(prs).isEqualTo('1') } diff --git a/test/com/cloudogu/ces/cesbuildlib/SonarCloudTest.groovy b/test/com/cloudogu/ces/cesbuildlib/SonarCloudTest.groovy index e497fa75..b93c1ba6 100644 --- a/test/com/cloudogu/ces/cesbuildlib/SonarCloudTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/SonarCloudTest.groovy @@ -4,7 +4,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import static groovy.test.GroovyAssert.shouldFail -import static org.junit.Assert.assertEquals +import static org.junit.jupiter.api.Assertions.assertEquals class SonarCloudTest { diff --git a/test/com/cloudogu/ces/cesbuildlib/TrivyExecutor.groovy b/test/com/cloudogu/ces/cesbuildlib/TrivyExecutor.groovy new file mode 100644 index 00000000..45dc3bc3 --- /dev/null +++ b/test/com/cloudogu/ces/cesbuildlib/TrivyExecutor.groovy @@ -0,0 +1,101 @@ +package com.cloudogu.ces.cesbuildlib + +import org.apache.commons.compress.archivers.ArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream + +import java.nio.channels.Channels +import java.nio.channels.FileChannel +import java.nio.channels.ReadableByteChannel +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.logging.Logger + +class TrivyExecutor { + + private static final Logger logger = Logger.getLogger(TrivyExecutor.class.getName()) + private Path installDir + + TrivyExecutor(Path installDir = Paths.get("trivyInstallation")) { + this.installDir = installDir + } + + Process exec(String version, String argumentstring, Path workDir) { + Path trivyPath = installTrivy(version) + if (workDir.getParent() != null) { + Files.createDirectories(workDir.getParent()) + } + + List arguments = new ArrayList() + arguments.add(trivyPath.toAbsolutePath().toString()) + arguments.addAll(argumentstring.split(" ")) + logger.info("start trivy: ${arguments.join(" ")}") + return new ProcessBuilder(arguments) + .directory(workDir.toAbsolutePath().toFile()) + .inheritIO() + .start() + } + + /** + * downloads, extracts and installs trivy as an executable file. + * Trivy is not downloaded again if the given version is already present. + * Each trivy version is installed into its own subdirectory to distinguish them. + * @param version trivy version + * @return the path to the trivy executable + */ + private Path installTrivy(String version) { + Path pathToExtractedArchive = installDir.resolve("v${version}") + Path pathToTrivyExecutable = pathToExtractedArchive.resolve("trivy") + if (!pathToExtractedArchive.toFile().exists()) { + installDir.toFile().mkdirs() + File archive = downloadTrivy(version, installDir) + untar(archive, pathToExtractedArchive) + logger.info("delete trivy download archive $pathToExtractedArchive") + if (!archive.delete()) { + throw new RuntimeException("cannot delete trivy download archive: $pathToExtractedArchive") + } + + logger.fine("make $pathToTrivyExecutable an executable") + if (pathToTrivyExecutable.toFile().setExecutable(true)) { + return pathToTrivyExecutable + } else { + throw new RuntimeException("cannot make trivy executable: ${pathToTrivyExecutable}") + } + } else { + logger.info("trivy v${version} already installed") + } + + return pathToTrivyExecutable + } + + private static File downloadTrivy(String version, Path downloadDir) { + URL url = new URL("https://github.com/aquasecurity/trivy/releases/download/v${version}/trivy_${version}_Linux-64bit.tar.gz") + File archive = downloadDir.resolve("trivy.tar.gz").toFile() + archive.createNewFile() + logger.info("download trivy v${version} from $url to $archive") + + ReadableByteChannel readableByteChannel = Channels.newChannel(url.openStream()) + FileOutputStream fileOutputStream = new FileOutputStream(archive) + FileChannel fileChannel = fileOutputStream.getChannel() + fileChannel.transferFrom(readableByteChannel, 0, Long.MAX_VALUE) + return archive + } + + private static void untar(File archive, Path destination) throws IOException { + BufferedInputStream inputStream = new BufferedInputStream(archive.newInputStream()) + TarArchiveInputStream tar = new TarArchiveInputStream(new GzipCompressorInputStream(inputStream)) + logger.info("untar $archive to $destination") + try { + ArchiveEntry entry + while ((entry = tar.getNextEntry()) != null) { + Path extractTo = entry.resolveIn(destination) + logger.info("untar: extract entry to ${extractTo}") + Files.createDirectories(extractTo.getParent()) + Files.copy(tar, extractTo) + } + } finally { + inputStream.close() + } + } +} diff --git a/test/com/cloudogu/ces/cesbuildlib/TrivyTest.groovy b/test/com/cloudogu/ces/cesbuildlib/TrivyTest.groovy new file mode 100644 index 00000000..2557766f --- /dev/null +++ b/test/com/cloudogu/ces/cesbuildlib/TrivyTest.groovy @@ -0,0 +1,235 @@ +package com.cloudogu.ces.cesbuildlib + +import org.junit.jupiter.api.Test +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer +import org.opentest4j.AssertionFailedError + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.TimeUnit + +import static org.junit.jupiter.api.Assertions.* +import static org.mockito.ArgumentMatchers.any +import static org.mockito.ArgumentMatchers.matches +import static org.mockito.Mockito.mock +import static org.mockito.Mockito.when + +class TrivyTest { + + String additionalFlags = "--db-repository public.ecr.aws/aquasecurity/trivy-db --java-db-repository public.ecr.aws/aquasecurity/trivy-java-db" + Path installDir = Paths.get("target/trivyInstalls") + Path workDir = Paths.get("") + TrivyExecutor trivyExec = new TrivyExecutor(installDir) + String trivyImage = "aquasec/trivy:" + Trivy.DEFAULT_TRIVY_VERSION + + + ScriptMock doTestScan(String imageName, String severityLevel, String strategy, int expectedStatusCode) { + File trivyReportFile = new File("trivy/trivyReport.json") + Path trivyDir = Paths.get(trivyReportFile.getParent()) + String trivyArguments = "image --exit-code 10 --exit-on-eol 10 --format ${TrivyScanFormat.JSON} -o ${trivyReportFile} --severity ${severityLevel} ${additionalFlags} ${imageName}" + String expectedTrivyCommand = "trivy $trivyArguments" + + def scriptMock = new ScriptMock() + scriptMock.env.WORKSPACE = "/test" + Docker dockerMock = mock(Docker.class) + Docker.Image imageMock = mock(Docker.Image.class) + when(dockerMock.image(trivyImage)).thenReturn(imageMock) + when(imageMock.mountJenkinsUser()).thenReturn(imageMock) + when(imageMock.mountDockerSocket()).thenReturn(imageMock) + when(imageMock.inside(matches("-v /test/.trivy/.cache:/root/.cache/"), any())).thenAnswer(new Answer() { + @Override + Integer answer(InvocationOnMock invocation) throws Throwable { + // mock "sh trivy" so that it returns the expected status code and check trivy arguments + Closure closure = invocation.getArgument(1) + scriptMock.expectedShRetValueForScript.put(expectedTrivyCommand, expectedStatusCode) + Integer statusCode = closure.call() as Integer + assertEquals(expectedStatusCode, statusCode) + assertEquals(expectedTrivyCommand, scriptMock.getActualShMapArgs().getLast()) + + // emulate trivy call with local trivy installation and check that it has the same behavior + Files.createDirectories(trivyDir) + Process process = trivyExec.exec(Trivy.DEFAULT_TRIVY_VERSION, trivyArguments, workDir) + if (process.waitFor(2, TimeUnit.MINUTES)) { + assertEquals(expectedStatusCode, process.exitValue()) + } else { + process.destroyForcibly() + fail("terminate trivy due to timeout") + } + + return expectedStatusCode + } + }) + Trivy trivy = new Trivy(scriptMock, Trivy.DEFAULT_TRIVY_VERSION, Trivy.DEFAULT_TRIVY_IMAGE, dockerMock) + + trivy.scanImage(imageName, severityLevel, strategy) + + return scriptMock + } + + @Test + void testScanImage_successfulTrivyExecution() { + // with hopes that this image will never have CVEs + String imageName = "hello-world" + String severityLevel = TrivySeverityLevel.CRITICAL + + def scriptMock = doTestScan(imageName, severityLevel, TrivyScanStrategy.UNSTABLE, 0) + + assertEquals(false, scriptMock.getUnstable()) + } + + @Test + void testScanImage_unstableBecauseOfCVEs() { + // with hopes that this image will always have CVEs + String imageName = "alpine:3.18.7" + String severityLevel = TrivySeverityLevel.ALL + + def scriptMock = doTestScan(imageName, severityLevel, TrivyScanStrategy.UNSTABLE, 10) + + assertEquals(true, scriptMock.getUnstable()) + } + + @Test + void testScanImage_failBecauseOfCVEs() { + // with hopes that this image will always have CVEs + String imageName = "alpine:3.18.7" + String severityLevel = TrivySeverityLevel.ALL + + def gotException = false + try { + doTestScan(imageName, severityLevel, TrivyScanStrategy.FAIL, 10) + } catch (AssertionFailedError e) { + // exception could also be a junit assertion exception. This means a previous assertion failed + throw e + } catch (Exception e) { + assertTrue(e.getMessage().contains("Trivy has found vulnerabilities in image"), "exception is: ${e.getMessage()}") + gotException = true + } + assertTrue(gotException) + } + + @Test + void testScanImage_unsuccessfulTrivyExecution() { + String imageName = "inval!d:::///1.1...1.1." + String severityLevel = TrivySeverityLevel.ALL + + def gotException = false + try { + doTestScan(imageName, severityLevel, TrivyScanStrategy.FAIL, 1) + } catch (AssertionFailedError e) { + // exception could also be a junit assertion exception. This means a previous assertion failed + throw e + } catch (Exception e) { + assertTrue(e.getMessage().contains("Error during trivy scan; exit code: 1"), "exception is: ${e.getMessage()}") + gotException = true + } + assertTrue(gotException) + } + + @Test + void testSaveFormattedTrivyReport_HtmlAllSeverities() { + Trivy trivy = mockTrivy( + "template --template \"@/contrib/html.tpl\"", + "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL", + "trivy/formattedTrivyReport.html") + trivy.saveFormattedTrivyReport() + } + + @Test + void testSaveFormattedTrivyReport_JsonCriticalSeverity() { + Trivy trivy = mockTrivy( + "json", + "CRITICAL", + "trivy/formattedTrivyReport.json") + trivy.saveFormattedTrivyReport(TrivyScanFormat.JSON, TrivySeverityLevel.CRITICAL) + } + + @Test + void testSaveFormattedTrivyReport_TableHighAndUpSeverity() { + Trivy trivy = mockTrivy( + "table", + "CRITICAL,HIGH", + "trivy/formattedTrivyReport.table") + trivy.saveFormattedTrivyReport(TrivyScanFormat.TABLE, TrivySeverityLevel.HIGH_AND_ABOVE) + } + + @Test + void testSaveFormattedTrivyReport_MediumAndUpSeverity() { + Trivy trivy = mockTrivy( + "sarif", + "CRITICAL,HIGH,MEDIUM", + "trivy/formattedTrivyReport.txt") + trivy.saveFormattedTrivyReport("sarif", TrivySeverityLevel.MEDIUM_AND_ABOVE) + } + + @Test + void testSaveFormattedTrivyReport_CustomFilename() { + Trivy trivy = mockTrivy( + "json", + "CRITICAL,HIGH,MEDIUM", + "trivy/myOutput.custom") + trivy.saveFormattedTrivyReport(TrivyScanFormat.JSON, TrivySeverityLevel.MEDIUM_AND_ABOVE, "myOutput.custom") + } + + @Test + void testSaveFormattedTrivyReport_UnsupportedFormat() { + def scriptMock = new ScriptMock() + scriptMock.env.WORKSPACE = "/test" + Trivy trivy = new Trivy(scriptMock, Trivy.DEFAULT_TRIVY_VERSION, Trivy.DEFAULT_TRIVY_IMAGE, mock(Docker.class)) + def gotException = false + try { + trivy.saveFormattedTrivyReport("UnsupportedFormat", TrivySeverityLevel.MEDIUM_AND_ABOVE) + } catch (AssertionFailedError e) { + // exception could also be a junit assertion exception. This means a previous assertion failed + throw e + } catch (Exception e) { + assertTrue(e.getMessage().contains("This format did not match the supported formats"), "exception is: ${e.getMessage()}") + gotException = true + } + assertTrue(gotException) + } + + @Test + void testSaveFormattedTrivyReport_UnsupportedSeverity() { + def scriptMock = new ScriptMock() + scriptMock.env.WORKSPACE = "/test" + Trivy trivy = new Trivy(scriptMock, Trivy.DEFAULT_TRIVY_VERSION, Trivy.DEFAULT_TRIVY_IMAGE, mock(Docker.class)) + def gotException = false + try { + trivy.saveFormattedTrivyReport(TrivyScanFormat.JSON, "UnsupportedSeverity") + } catch (AssertionFailedError e) { + // exception could also be a junit assertion exception. This means a previous assertion failed + throw e + } catch (Exception e) { + assertTrue(e.getMessage().contains("The severity levels provided (UnsupportedSeverity) do not match the " + + "applicable levels (UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL)."), "exception is: ${e.getMessage()}") + gotException = true + } + assertTrue(gotException) + } + + Trivy mockTrivy(String expectedFormat, String expectedSeverity, String expectedOutput) { + String trivyArguments = "convert --format ${expectedFormat} --severity ${expectedSeverity} --output ${expectedOutput} trivy/trivyReport.json" + String expectedTrivyCommand = "trivy $trivyArguments" + + def scriptMock = new ScriptMock() + scriptMock.env.WORKSPACE = "/test" + Docker dockerMock = mock(Docker.class) + Docker.Image imageMock = mock(Docker.Image.class) + when(dockerMock.image(trivyImage)).thenReturn(imageMock) + when(imageMock.inside(matches("-v /test/.trivy/.cache:/root/.cache/"), any())).thenAnswer(new Answer() { + @Override + Integer answer(InvocationOnMock invocation) throws Throwable { + // mock "sh trivy" so that it returns the expected status code and check trivy arguments + Closure closure = invocation.getArgument(1) + scriptMock.expectedShRetValueForScript.put(expectedTrivyCommand, 0) + closure.call() + assertEquals(expectedTrivyCommand, scriptMock.getActualShMapArgs().getLast()) + return 0 + } + }) + Trivy trivy = new Trivy(scriptMock, Trivy.DEFAULT_TRIVY_VERSION, Trivy.DEFAULT_TRIVY_IMAGE, dockerMock) + return trivy + } +} diff --git a/vars/findVulnerabilitiesWithTrivy.groovy b/vars/findVulnerabilitiesWithTrivy.groovy index 60241fb9..7d7813dd 100644 --- a/vars/findVulnerabilitiesWithTrivy.groovy +++ b/vars/findVulnerabilitiesWithTrivy.groovy @@ -10,8 +10,8 @@ ArrayList call (Map args) { if(args.containsKey('allowList')) error "Arg allowList is deprecated, please use .trivyignore file" def imageName = args.imageName - def trivyVersion = args.trivyVersion ? args.trivyVersion : '0.55.2' - def severityFlag = args.severity ? "--severity=${args.severity.join(',')}" : '' + def trivyVersion = args.trivyVersion ? args.trivyVersion : '0.57.1' + def severityFlag = args.severity ? "${args.severity.join(',')}" : '' def additionalFlags = args.additionalFlags ? args.additionalFlags : '' println(severityFlag) @@ -27,7 +27,8 @@ ArrayList call (Map args) { ArrayList getVulnerabilities(String trivyVersion, String severityFlag, String additionalFlags,String imageName) { // this runs trivy and creates an output file with found vulnerabilities - runTrivyInDocker(trivyVersion, severityFlag, additionalFlags, imageName) + Trivy trivy = new Trivy(this, trivyVersion) + trivy.scanImage(imageName, severityFlag, TrivyScanStrategy.UNSTABLE, additionalFlags, "${env.WORKSPACE}/.trivy/trivyOutput.json") def trivyOutput = readJSON file: "${env.WORKSPACE}/.trivy/trivyOutput.json" @@ -42,21 +43,6 @@ ArrayList getVulnerabilities(String trivyVersion, String severityFlag, String ad } - - - -def runTrivyInDocker(String trivyVersion, severityFlag, additionalFlags, imageName) { - new Docker(this).image("aquasec/trivy:${trivyVersion}") - .mountJenkinsUser() - .mountDockerSocket() - .inside("-v ${env.WORKSPACE}/.trivy/.cache:/root/.cache/") { - - sh "trivy image -f json -o .trivy/trivyOutput.json ${severityFlag} ${additionalFlags} ${imageName}" - } -} - - - static boolean validateArgs(Map args) { return !(args == null || args.imageName == null || args.imageName == '') }