diff --git a/libraries/grype/README.md b/libraries/grype/README.md index 8d741fc1..d3fc5fa8 100644 --- a/libraries/grype/README.md +++ b/libraries/grype/README.md @@ -14,20 +14,32 @@ Uses the [Grype CLI](https://github.com/anchore/grype) to scan container images ## Configuration -| Library Configuration | Description | Type | Default Value | Options | -|-----------------------|----------------------------------------------------------|--------|---------------|---------------------------------------------------| -| `grype_container` | The container image to execute the scan within | String | grype:0.38.0 | | -| `report_format` | The output format of the generated report | String | json | `json`, `table`, `cyclonedx`, `template` | -| `fail_on_severity` | The severity level threshold that will fail the pipeline | String | high | `none`, `negligible`, `low`, `medium`, `high`, `critical` | -| `grype_config` | A custom path to a grype configuration file | String | `null` | | +| Library Configuration | Description | Type | Default Value | Options | +|-----------------------|----------------------------------------------------------|---------|---------------|-----------------------------------------------------------| +| `grype_container` | The container image to execute the scan within | String | grype:0.38.0 | | +| `report_format` | The output format of the generated report | String | json | `json`, `table`, `cyclonedx`, `template` | +| `fail_on_severity` | The severity level threshold that will fail the pipeline | String | high | `none`, `negligible`, `low`, `medium`, `high`, `critical` | +| `grype_config` | A custom path to a grype configuration file | String | `null` | | +| `scan_sbom` | Boolean to turn on SBOM scanning | Boolean | false | true, false | + +``` groovy title='pipeline_config.groovy' +libraries { + grype { + grype_container = "grype:0.38.0" + report_format = "json" + fail_on_severity = "high" + grype_config = "Path/to/Grype.yaml" + scan_sbom = false + } +} +``` ## Grype Configuration File If `grype_config` isn't provided, the default locations for an application are `.grype.yaml`, `.grype/config.yaml`. -!!! note "Learn More About Grype Configuration" - Read [the grype docs](https://github.com/anchore/grype#configuration) to learn more about the Grype configuration file +Read [the grype docs](https://github.com/anchore/grype#configuration) to learn more about the Grype configuration file ## Dependencies diff --git a/libraries/grype/library_config.groovy b/libraries/grype/library_config.groovy index dc6a1389..f83ae157 100644 --- a/libraries/grype/library_config.groovy +++ b/libraries/grype/library_config.groovy @@ -4,5 +4,6 @@ fields{ report_format = ["json", "table", "cyclonedx", "template"] fail_on_severity = ["none", "negligible", "low", "medium", "high", "critical"] grype_config = String + scan_sbom = Boolean } } diff --git a/libraries/grype/steps/container_image_scan.groovy b/libraries/grype/steps/container_image_scan.groovy index a690e9a7..57125a3f 100644 --- a/libraries/grype/steps/container_image_scan.groovy +++ b/libraries/grype/steps/container_image_scan.groovy @@ -6,6 +6,8 @@ void call() { String outputFormat = config?.report_format ?: 'json' String severityThreshold = config?.fail_on_severity ?: 'high' String grypeConfig = config?.grype_config + Boolean scanSbom = config?.scan_sbom ?: false + ArrayList syftSbom = [] String resultsFileFormat = ".txt" String ARGS = "" // is flipped to True if an image scan fails @@ -66,6 +68,16 @@ void call() { def images = get_images_to_build() images.each { img -> + if (scanSbom) { + String reportBase = "${img.repo}-${img.tag}".replaceAll("/","-") + syftSbom = findFiles(glob: "${reportBase}-*-json.json", excludes: "${reportBase}-*-*dx-json.json") + if (syftSbom.size() == 0) { + syftSbom = findFiles(glob: "${reportBase}-*-cyclonedx*") + if (syftSbom.size() == 0) { + syftSbom = findFiles(glob: "${reportBase}-*-spdx*") + } + } + } // Use $img.repo to help name our results uniquely. Checks to see if a forward slash exists and splits the string at that location. String rawResultsFile, transformedResultsFile if (img.repo.contains("/")) { @@ -80,7 +92,14 @@ void call() { // perform the grype scan try { - sh "grype ${img.registry}/${img.repo}:${img.tag} ${ARGS} >> ${rawResultsFile}" + if (scanSbom && syftSbom) { + echo "Scanning provided SBOM artifact" + sh "grype sbom:${syftSbom[0]} ${ARGS} > ${rawResultsFile}" + } + else { + echo "An SBOM artifact was not provided. Scanning registry image." + sh "grype ${img.registry}/${img.repo}:${img.tag} ${ARGS} > ${rawResultsFile}" + } } // Catch the error on quality gate failure catch(Exception err) { diff --git a/libraries/grype/test/ContainerImageScanSpec.groovy b/libraries/grype/test/ContainerImageScanSpec.groovy index 9757ced7..7368e791 100644 --- a/libraries/grype/test/ContainerImageScanSpec.groovy +++ b/libraries/grype/test/ContainerImageScanSpec.groovy @@ -20,7 +20,6 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { explicitlyMockPipelineStep("get_images_to_build") getPipelineMock("sh")([script: 'echo $HOME', returnStdout: true]) >> "/home" getPipelineMock("sh")([script: 'echo $XDG_CONFIG_HOME', returnStdout: true]) >> "/xdg" - getPipelineMock("get_images_to_build")() >> { def images = [] images << [registry: "test_registry", repo: "image1_repo", context: "image1", tag: "4321dcba"] @@ -61,7 +60,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { ContainerImageScan() then: 1 * getPipelineMock("echo")("Grype file explicitly specified in pipeline_config.groovy") - (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config \/testPath\/grype.yaml >> .*/}) + (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config \/testPath\/grype.yaml > .*/}) } def "Grype config is found at current dir .grype.yaml" () { @@ -76,7 +75,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { 1 * getPipelineMock("fileExists")(".grype.yaml") >> true 1 * getPipelineMock("echo")("Found .grype.yaml") then: - (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config .grype.yaml >> .*/}) + (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config .grype.yaml > .*/}) } def "Grype config is found at .grype/config.yaml" () { @@ -91,7 +90,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { 1 * getPipelineMock("fileExists")(".grype/config.yaml") >> true 1 * getPipelineMock("echo")("Found .grype/config.yaml") then: - (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config .grype\/config.yaml >> .*/}) + (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config .grype\/config.yaml > .*/}) } def "Grype config is found at user Home path/.grype.yaml" () { @@ -106,7 +105,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { 1 * getPipelineMock("fileExists")("/home/.grype.yaml") >> true 1 * getPipelineMock("echo")("Found ~/.grype.yaml") then: - (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config \/home\/.grype.yaml >> .*/}) + (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config \/home\/.grype.yaml > .*/}) } def "Grype config found at /grype/config.yaml" () { @@ -121,16 +120,16 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { 1 * getPipelineMock("fileExists")("/xdg/grype/config.yaml") >> true 1 * getPipelineMock("echo")("Found /grype/config.yaml") then: - (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config \/xdg\/grype\/config.yaml >> .*/}) + (1.._) * getPipelineMock("sh")({it =~ /^grype .* --config \/xdg\/grype\/config.yaml > .*/}) } def "Check each image is scanned as expected when no extra config is present" () { when: ContainerImageScan() then: - 1 * getPipelineMock("sh")("grype test_registry/image1_repo:4321dcba -o json --fail-on high >> image1_repo-grype-scan-results.json") - 1 * getPipelineMock("sh")("grype test_registry/image2_repo:4321dcbb -o json --fail-on high >> image2_repo-grype-scan-results.json") - 1 * getPipelineMock("sh")("grype test_registry/image3_repo/qwerty:4321dcbc -o json --fail-on high >> qwerty-grype-scan-results.json") + 1 * getPipelineMock("sh")("grype test_registry/image1_repo:4321dcba -o json --fail-on high > image1_repo-grype-scan-results.json") + 1 * getPipelineMock("sh")("grype test_registry/image2_repo:4321dcbb -o json --fail-on high > image2_repo-grype-scan-results.json") + 1 * getPipelineMock("sh")("grype test_registry/image3_repo/qwerty:4321dcbc -o json --fail-on high > qwerty-grype-scan-results.json") } def "Test json format and negligible severity" () { @@ -139,7 +138,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { when: ContainerImageScan() then: - (1.._) * getPipelineMock("sh")({it =~ /^grype .* -o json --fail-on negligible >> .*/}) + (1.._) * getPipelineMock("sh")({it =~ /^grype .* -o json --fail-on negligible > .*/}) } def "Test table format and low severity" () { @@ -148,7 +147,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { when: ContainerImageScan() then: - (1.._ ) * getPipelineMock("sh")({it =~ /^grype .* -o table --fail-on low >> .*/}) + (1.._ ) * getPipelineMock("sh")({it =~ /^grype .* -o table --fail-on low > .*/}) } def "Test cyclonedx format and medium severity" () { @@ -157,7 +156,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { when: ContainerImageScan() then: - (1.._) * getPipelineMock("sh")({it =~ /^grype .* -o cyclonedx --fail-on medium >> .*/}) + (1.._) * getPipelineMock("sh")({it =~ /^grype .* -o cyclonedx --fail-on medium > .*/}) } def "Test table format and high severity" () { @@ -166,7 +165,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { when: ContainerImageScan() then: - (1.._) * getPipelineMock("sh")({it =~ /^grype .* -o table --fail-on high >> .*/}) + (1.._) * getPipelineMock("sh")({it =~ /^grype .* -o table --fail-on high > .*/}) } def "Test cyclonedx format and critical severity" () { @@ -175,7 +174,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { when: ContainerImageScan() then: - (1.._) * getPipelineMock("sh")({it =~ /^grype .* -o cyclonedx --fail-on critical >> .*/}) + (1.._) * getPipelineMock("sh")({it =~ /^grype .* -o cyclonedx --fail-on critical > .*/}) } def "Test Archive artifacts works as expected for json format and not null grype config" () { @@ -197,7 +196,7 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { def "Test that error handling works as expected" () { given: explicitlyMockPipelineStep("Exception")//("Failed: java.lang.Exception: test") - getPipelineMock("sh")("grype test_registry/image1_repo:4321dcba -o json --fail-on high >> image1_repo-grype-scan-results.json") >> {throw new Exception("test")} + getPipelineMock("sh")("grype test_registry/image1_repo:4321dcba -o json --fail-on high > image1_repo-grype-scan-results.json") >> {throw new Exception("test")} when: ContainerImageScan() then: @@ -206,7 +205,58 @@ public class ContainerImageScanSpec extends JTEPipelineSpecification { 1 * getPipelineMock("stash")("workspace") 1 * getPipelineMock("error")(_) } -} + def "Test scanning syft JSON SBOM artifact" () { + given: + ContainerImageScan.getBinding().setVariable("config", [scan_sbom: true]) + getPipelineMock("findFiles")([glob:'image1_repo-4321dcba-*-json.json', excludes:'image1_repo-4321dcba-*-*dx-json.json']) >> ['image1_repo-4321dcba-test-json.json'] + getPipelineMock("findFiles")([glob:'image2_repo-4321dcbb-*-json.json', excludes:'image2_repo-4321dcbb-*-*dx-json.json']) >> ['image2_repo-4321dcbb-test-json.json'] + getPipelineMock("findFiles")([glob:'image3_repo-qwerty-4321dcbc-*-json.json', excludes:'image3_repo-qwerty-4321dcbc-*-*dx-json.json']) >> ['image3_repo-qwerty-4321dcbc-json.json'] + explicitlyMockPipelineVariable("syftSbom") + + when: + ContainerImageScan() + + then: + (1..3) * getPipelineMock("sh")({it =~ /^grype sbom:image.*/}) + } + + def "Test scanning syft Cyclonedx SBOM artifact" () { + given: + ContainerImageScan.getBinding().setVariable("config", [scan_sbom: true]) + getPipelineMock("findFiles")([glob:'image1_repo-4321dcba-*-json.json', excludes:'image1_repo-4321dcba-*-*dx-json.json']) >> [] + getPipelineMock("findFiles")([glob:'image2_repo-4321dcbb-*-json.json', excludes:'image2_repo-4321dcbb-*-*dx-json.json']) >> [] + getPipelineMock("findFiles")([glob:'image3_repo-qwerty-4321dcbc-*-json.json', excludes:'image3_repo-qwerty-4321dcbc-*-*dx-json.json']) >> [] + getPipelineMock("findFiles")([glob:'image1_repo-4321dcba-*-cyclonedx*']) >> ['image1_repo-4321dcba-test-cyclonedx-xml.xml'] + getPipelineMock("findFiles")([glob:'image2_repo-4321dcbb-*-cyclonedx*']) >> ['image2_repo-4321dcbb-test-cyclonedx-json.json'] + getPipelineMock("findFiles")([glob:'image3_repo-qwerty-4321dcbc-*-cyclonedx*']) >> ['image3_repo-qwerty-4321dcbc-cyclonedx-json.json'] + explicitlyMockPipelineVariable("syftSbom") + + when: + ContainerImageScan() + + then: + (1..3) * getPipelineMock("sh")({it =~ /^grype sbom:.*cyclonedx*/}) + } + def "Test scanning syft SPDX SBOM artifact" () { + given: + ContainerImageScan.getBinding().setVariable("config", [scan_sbom: true]) + getPipelineMock("findFiles")([glob:'image1_repo-4321dcba-*-json.json', excludes:'image1_repo-4321dcba-*-*dx-json.json']) >> [] + getPipelineMock("findFiles")([glob:'image2_repo-4321dcbb-*-json.json', excludes:'image2_repo-4321dcbb-*-*dx-json.json']) >> [] + getPipelineMock("findFiles")([glob:'image3_repo-qwerty-4321dcbc-*-json.json', excludes:'image3_repo-qwerty-4321dcbc-*-*dx-json.json']) >> [] + getPipelineMock("findFiles")([glob:'image1_repo-4321dcba-*-cyclonedx*']) >> [] + getPipelineMock("findFiles")([glob:'image2_repo-4321dcbb-*-cyclonedx*']) >> [] + getPipelineMock("findFiles")([glob:'image3_repo-qwerty-4321dcbc-*-cyclonedx*']) >> [] + getPipelineMock("findFiles")([glob:'image1_repo-4321dcba-*-spdx*']) >> ['image1_repo-4321dcba-test-spdx-json.json'] + getPipelineMock("findFiles")([glob:'image2_repo-4321dcbb-*-spdx*']) >> ['image2_repo-4321dcbb-test-spdx-tag-value.txt'] + getPipelineMock("findFiles")([glob:'image3_repo-qwerty-4321dcbc-*-spdx*']) >> ['image3_repo-qwerty-4321dcbc-spdx-json.json'] + explicitlyMockPipelineVariable("syftSbom") + + when: + ContainerImageScan() + then: + (1..3) * getPipelineMock("sh")({it =~ /^grype sbom:.*spdx*/}) + } +}