From 6bbc00891ea56921176d30110df5d774e3845469 Mon Sep 17 00:00:00 2001 From: Steven Gerrits Date: Mon, 6 Jul 2026 08:34:11 +0200 Subject: [PATCH 1/5] feat: cache the trivy db and skip duplicate image scans Two efficiency improvements for pipelines that scan several images per build: - Mount a named docker volume (default: jpipe-trivy-cache) at trivy's cache path, so the vulnerability DB is downloaded once per agent instead of once per scan. The DB download typically costs more time than the scan itself. Opt out with cacheVolume: ''. - Skip scanning an image whose docker image ID was already scanned in the same build (e.g. one built image published under several names) and reuse the first report for publishing. Opt out with skipDuplicates: false. Behaviour is otherwise unchanged: same flags, same report publishing, same allowFailure semantics. --- src/io/stenic/jpipe/plugin/TrivyPlugin.groovy | 125 ++++++++--- .../jpipe/plugin/TrivyPluginSpec.groovy | 207 ++++++++++++++++++ 2 files changed, 301 insertions(+), 31 deletions(-) create mode 100644 test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy diff --git a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy index b6ca88a..6af6809 100644 --- a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy +++ b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy @@ -12,6 +12,8 @@ class TrivyPlugin extends Plugin { private List severity; private String trivyVersion; private String report; + private String cacheVolume; + private Boolean skipDuplicates; TrivyPlugin(Map opts = [:]) { this.report = opts.get('report', 'table'); @@ -22,6 +24,14 @@ class TrivyPlugin extends Plugin { this.ignoreUnfixed = opts.get('ignoreUnfixed', true); this.severity = opts.get('severity', ['HIGH', 'CRITICAL', 'MEDIUM', 'LOW', 'UNKNOWN']); this.eventWeight = opts.get('eventWeight', 20); + // Named docker volume holding the Trivy cache (vulnerability DB). The DB + // is re-downloaded on every scan otherwise, which typically costs more + // time than the scan itself. Set to '' to disable the mount. + this.cacheVolume = opts.get('cacheVolume', 'jpipe-trivy-cache'); + // Pipelines that publish the same built image under several names would + // scan identical bits once per name. When enabled, images whose docker + // image ID was already scanned in this build reuse the first report. + this.skipDuplicates = opts.get('skipDuplicates', true); } public Map getSubscribedEvents() { @@ -38,6 +48,34 @@ class TrivyPlugin extends Plugin { return } + String image = "${this.containerImage}:${event.version}" + String imgName = this.containerImage.split('/').last() + String reportDir = ".trivy-report-${imgName}" + + event.script.dir(event.script.pwd(tmp: true)) { + String marker = this.duplicateMarker(event, image) + String priorReportDir = '' + if (marker != '' && event.script.fileExists(marker)) { + priorReportDir = event.script.readFile(marker).trim() + } + + if (priorReportDir != '') { + event.script.println("Skipping Trivy scan for ${image}: an identical image (same image ID) was already scanned in this build") + if (this.report == 'html' || this.report == 'json') { + event.script.sh "mkdir -p ${reportDir} && if [ -d '${priorReportDir}' ]; then cp -r '${priorReportDir}/.' '${reportDir}/'; fi" + } + } else { + this.runScan(event, image, reportDir) + if (marker != '') { + event.script.writeFile(file: marker, text: reportDir) + } + } + + this.publishReport(event, imgName, reportDir) + } + } + + private void runScan(Event event, String image, String reportDir) { List args = [ '--no-progress', '--exit-code=1', @@ -53,41 +91,66 @@ class TrivyPlugin extends Plugin { } args.add(this.extraFlags) - event.script.dir(event.script.pwd(tmp: true)) { - String imgName = this.containerImage.split('/').last() - try { - event.script.sh """ - docker run \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v \$(pwd)/.trivy-report-${imgName}:/report \ - ghcr.io/aquasecurity/trivy:${this.trivyVersion} \ - image ${args.join(' ')} ${this.containerImage}:${event.version} - """ - } catch (Exception e) { - if (this.allowFailure) { - event.script.catchError(buildResult: 'SUCCESS', stageResult: 'UNSTABLE') { - throw e - } - } else { + String cacheMount = this.cacheVolume != '' ? "-v ${this.cacheVolume}:/root/.cache/trivy" : '' + + try { + event.script.sh """ + docker run \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v \$(pwd)/${reportDir}:/report \ + ${cacheMount} \ + ghcr.io/aquasecurity/trivy:${this.trivyVersion} \ + image ${args.join(' ')} ${image} + """ + } catch (Exception e) { + if (this.allowFailure) { + event.script.catchError(buildResult: 'SUCCESS', stageResult: 'UNSTABLE') { throw e } + } else { + throw e } + } + } - if (this.report == 'html') { - event.script.publishHTML(target: [ - allowMissing: true, - alwaysLinkToLastBuild: true, - keepAll: true, - reportDir: ".trivy-report-${imgName}", - reportFiles: 'report.html', - reportName: "Trivy - ${imgName}", - ]) - } else if (this.report == 'json') { - event.script.recordIssues tool: event.script.trivy( - pattern: ".trivy-report-${imgName}/report.json", - reportEncoding: 'UTF-8' - ) - } + // A per-build, per-image-ID marker file in the shared tmp workspace. Two + // plugin instances that resolve to the same image ID (identical layers + // published under different names) produce the same marker, so the second + // one can tell a scan already happened. Returns '' when duplicate + // detection is disabled or the image ID cannot be resolved. + private String duplicateMarker(Event event, String image) { + if (!this.skipDuplicates) { + return '' + } + String imageId = '' + try { + imageId = event.script.sh(script: "docker inspect -f '{{.Id}}' ${image}", returnStdout: true).trim() + } catch (Exception e) { + event.script.println("TrivyPlugin: could not resolve the image ID of ${image}; scanning without duplicate detection") + return '' + } + // Markers from previous builds are stale; clean them up as we go. + event.script.sh "find . -maxdepth 1 -name '.trivy-scanned-*' -mtime +1 -delete || true" + String buildTag = event.script.env.BUILD_TAG ?: 'build' + String marker = ".trivy-scanned-${buildTag}-${event.version}-${imageId}" + return marker.replaceAll(/[^A-Za-z0-9._-]/, '_') + } + + private void publishReport(Event event, String imgName, String reportDir) { + if (this.report == 'html') { + event.script.publishHTML(target: [ + allowMissing: true, + alwaysLinkToLastBuild: true, + keepAll: true, + reportDir: reportDir, + reportFiles: 'report.html', + reportName: "Trivy - ${imgName}", + ]) + } else if (this.report == 'json') { + event.script.recordIssues tool: event.script.trivy( + pattern: "${reportDir}/report.json", + reportEncoding: 'UTF-8' + ) } } } diff --git a/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy b/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy new file mode 100644 index 0000000..c621ee4 --- /dev/null +++ b/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy @@ -0,0 +1,207 @@ +import spock.lang.Specification +import io.stenic.jpipe.event.Event +import io.stenic.jpipe.plugin.TrivyPlugin + +class TrivyPluginSpec extends Specification { + + // Minimal stand-in for the pipeline script. Records sh invocations and + // simulates the shared tmp workspace via an in-memory file map, so two + // plugin instances in the same "build" can see each other's markers. + static class FakeScript { + List shCommands = [] + Map files = [:] + List published = [] + List printed = [] + Map env = [BUILD_TAG: 'jenkins-job-42'] + String imageId = 'sha256:abc123' + boolean inspectFails = false + boolean scanFails = false + + def sh(def arg) { + if (arg instanceof Map) { + shCommands << arg.script.toString() + if (arg.script.toString().contains('docker inspect')) { + if (inspectFails) { + throw new RuntimeException('no such image') + } + return imageId + '\n' + } + return '' + } + shCommands << arg.toString() + if (arg.toString().contains('aquasecurity/trivy') && scanFails) { + throw new RuntimeException('script returned exit code 1') + } + return null + } + def dir(String d, Closure c) { c() } + def pwd(Map opts) { '/workspace@tmp' } + def fileExists(String f) { files.containsKey(f) } + def readFile(String f) { files[f] } + def writeFile(Map opts) { files[opts.file.toString()] = opts.text.toString() } + def println(def s) { printed << s.toString() } + def publishHTML(Map target) { published << target.target } + def catchError(Map opts, Closure c) { + try { c() } catch (Exception ignored) { } + } + def trivy(Map opts) { return opts } + def recordIssues(Map opts) { published << opts } + } + + def newEvent(FakeScript script) { + def event = new Event() + event.script = script + event.version = '1.2.3' + return event + } + + List trivyRuns(FakeScript script) { + return script.shCommands.findAll { it.contains('aquasecurity/trivy') } + } + + def "[TrivyPlugin] skips when no containerImage is defined"() { + given: + def script = new FakeScript() + def plugin = new TrivyPlugin() + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + assert script.shCommands == [] + assert script.printed.any { it.contains('no containerImage') } + } + + def "[TrivyPlugin] mounts the cache volume so the vulnerability DB persists between scans"() { + given: + def script = new FakeScript() + def plugin = new TrivyPlugin([containerImage: 'repo/app', report: 'html']) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + def runs = trivyRuns(script) + assert runs.size() == 1 + assert runs[0].contains('-v jpipe-trivy-cache:/root/.cache/trivy') + } + + def "[TrivyPlugin] cacheVolume '' disables the cache mount"() { + given: + def script = new FakeScript() + def plugin = new TrivyPlugin([containerImage: 'repo/app', cacheVolume: '']) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + def runs = trivyRuns(script) + assert runs.size() == 1 + assert !runs[0].contains(':/root/.cache/trivy') + } + + def "[TrivyPlugin] scans an image once and records a marker for the build"() { + given: + def script = new FakeScript() + def plugin = new TrivyPlugin([containerImage: 'repo/app', report: 'html']) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script).size() == 1 + assert script.files.keySet().any { it.startsWith('.trivy-scanned-') } + assert script.published.size() == 1 + assert script.published[0].reportName == 'Trivy - app' + } + + def "[TrivyPlugin] skips the scan of an identical image and reuses the first report"() { + given: + def script = new FakeScript() + def first = new TrivyPlugin([containerImage: 'repo/app', report: 'html']) + def mirror = new TrivyPlugin([containerImage: 'repo/app-mirror', report: 'html']) + + when: + first.doRunImageScan(newEvent(script)) + mirror.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script).size() == 1 + assert script.printed.any { it.contains('identical image') } + assert script.shCommands.any { it.contains("cp -r '.trivy-report-app/.' '.trivy-report-app-mirror/'") } + assert script.published.size() == 2 + assert script.published[1].reportName == 'Trivy - app-mirror' + } + + def "[TrivyPlugin] scans both images when their image IDs differ"() { + given: + def script = new FakeScript() + def first = new TrivyPlugin([containerImage: 'repo/app']) + def other = new TrivyPlugin([containerImage: 'repo/other']) + + when: + first.doRunImageScan(newEvent(script)) + script.imageId = 'sha256:def456' + other.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script).size() == 2 + } + + def "[TrivyPlugin] skipDuplicates false always scans"() { + given: + def script = new FakeScript() + def first = new TrivyPlugin([containerImage: 'repo/app', skipDuplicates: false]) + def mirror = new TrivyPlugin([containerImage: 'repo/app-mirror', skipDuplicates: false]) + + when: + first.doRunImageScan(newEvent(script)) + mirror.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script).size() == 2 + assert !script.shCommands.any { it.contains('docker inspect') } + } + + def "[TrivyPlugin] falls back to a plain scan when the image ID cannot be resolved"() { + given: + def script = new FakeScript() + script.inspectFails = true + def plugin = new TrivyPlugin([containerImage: 'repo/app']) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script).size() == 1 + assert !script.files.keySet().any { it.startsWith('.trivy-scanned-') } + assert script.printed.any { it.contains('could not resolve') } + } + + def "[TrivyPlugin] allowFailure swallows a failing scan and still publishes"() { + given: + def script = new FakeScript() + script.scanFails = true + def plugin = new TrivyPlugin([containerImage: 'repo/app', report: 'html', allowFailure: true]) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + noExceptionThrown() + assert script.published.size() == 1 + } + + def "[TrivyPlugin] a failing scan without allowFailure propagates"() { + given: + def script = new FakeScript() + script.scanFails = true + def plugin = new TrivyPlugin([containerImage: 'repo/app']) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + thrown(RuntimeException) + } +} From 6b7e8656c8e06d20f05cb0a7462c454cff7f4271 Mon Sep 17 00:00:00 2001 From: Steven Gerrits Date: Mon, 6 Jul 2026 08:52:48 +0200 Subject: [PATCH 2/5] fix: scope duplicate detection to the scan config and re-signal failed gates Review follow-ups on the trivy efficiency changes: - The duplicate marker now includes the scan-relevant configuration (severity, ignoreUnfixed, extraFlags, report, trivyVersion), so a second instance with a stricter or differently-formatted scan of the same image still runs instead of silently inheriting the first verdict. - Markers record the scan outcome; a skipped duplicate of a failed scan now re-signals the failure through its own allowFailure semantics instead of showing clean. - The cache volume defaults to a per-job name instead of one shared volume: trivy's cache DB takes an exclusive file lock, so a cluster-wide volume could make concurrent builds of unrelated jobs contend. Explicit names and '' (disable) behave as before. - Quote the report dirs in the copy command; document that the stale- marker cleanup is hygiene only and that trivy's exit code cannot distinguish vulnerabilities from other scan errors. --- src/io/stenic/jpipe/plugin/TrivyPlugin.groovy | 104 ++++++++++++------ .../jpipe/plugin/TrivyPluginSpec.groovy | 95 +++++++++++++++- 2 files changed, 159 insertions(+), 40 deletions(-) diff --git a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy index 6af6809..9c11996 100644 --- a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy +++ b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy @@ -26,11 +26,17 @@ class TrivyPlugin extends Plugin { this.eventWeight = opts.get('eventWeight', 20); // Named docker volume holding the Trivy cache (vulnerability DB). The DB // is re-downloaded on every scan otherwise, which typically costs more - // time than the scan itself. Set to '' to disable the mount. - this.cacheVolume = opts.get('cacheVolume', 'jpipe-trivy-cache'); + // time than the scan itself. Defaults to a per-job volume (null => + // "jpipe-trivy-cache-"): trivy's cache DB takes an exclusive file + // lock, so one cluster-wide volume would let concurrent builds of + // unrelated jobs contend ("database is locked"). Scans within one build + // run sequentially and are safe. Set to '' to disable the mount, or to + // a fixed name to deliberately share a volume. + this.cacheVolume = opts.get('cacheVolume', null); // Pipelines that publish the same built image under several names would // scan identical bits once per name. When enabled, images whose docker - // image ID was already scanned in this build reuse the first report. + // image ID was already scanned in this build WITH THE SAME scan + // configuration reuse the first scan's report and verdict. this.skipDuplicates = opts.get('skipDuplicates', true); } @@ -54,20 +60,34 @@ class TrivyPlugin extends Plugin { event.script.dir(event.script.pwd(tmp: true)) { String marker = this.duplicateMarker(event, image) - String priorReportDir = '' + String markerText = '' if (marker != '' && event.script.fileExists(marker)) { - priorReportDir = event.script.readFile(marker).trim() + markerText = event.script.readFile(marker).trim() } - if (priorReportDir != '') { - event.script.println("Skipping Trivy scan for ${image}: an identical image (same image ID) was already scanned in this build") + if (markerText != '') { + String priorReportDir = markerText.split(/\|/)[0] + Boolean priorFailed = markerText.endsWith('|failed') + event.script.println("Skipping Trivy scan for ${image}: an identical image (same image ID) was already scanned with the same configuration in this build") if (this.report == 'html' || this.report == 'json') { - event.script.sh "mkdir -p ${reportDir} && if [ -d '${priorReportDir}' ]; then cp -r '${priorReportDir}/.' '${reportDir}/'; fi" + event.script.sh "mkdir -p '${reportDir}' && if [ -d '${priorReportDir}' ]; then cp -r '${priorReportDir}/.' '${reportDir}/'; fi" + } + if (priorFailed) { + // Keep the verdict truthful on the skip path: the same + // image failed its scan earlier in this build, so this + // instance must fail (or go UNSTABLE) the same way. + this.handleScanFailure(event, new Exception("Trivy reported issues for an identical image scanned earlier in this build (${image})")) } } else { - this.runScan(event, image, reportDir) + Boolean scanFailed = false + try { + this.execScan(event, image, reportDir) + } catch (Exception e) { + scanFailed = true + this.handleScanFailure(event, e) + } if (marker != '') { - event.script.writeFile(file: marker, text: reportDir) + event.script.writeFile(file: marker, text: "${reportDir}|${scanFailed ? 'failed' : 'ok'}") } } @@ -75,7 +95,7 @@ class TrivyPlugin extends Plugin { } } - private void runScan(Event event, String image, String reportDir) { + private void execScan(Event event, String image, String reportDir) { List args = [ '--no-progress', '--exit-code=1', @@ -91,33 +111,46 @@ class TrivyPlugin extends Plugin { } args.add(this.extraFlags) - String cacheMount = this.cacheVolume != '' ? "-v ${this.cacheVolume}:/root/.cache/trivy" : '' + String volume = this.resolveCacheVolume(event) + String cacheMount = volume != '' ? "-v ${volume}:/root/.cache/trivy" : '' - try { - event.script.sh """ - docker run \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v \$(pwd)/${reportDir}:/report \ - ${cacheMount} \ - ghcr.io/aquasecurity/trivy:${this.trivyVersion} \ - image ${args.join(' ')} ${image} - """ - } catch (Exception e) { - if (this.allowFailure) { - event.script.catchError(buildResult: 'SUCCESS', stageResult: 'UNSTABLE') { - throw e - } - } else { + event.script.sh """ + docker run \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v \$(pwd)/${reportDir}:/report \ + ${cacheMount} \ + ghcr.io/aquasecurity/trivy:${this.trivyVersion} \ + image ${args.join(' ')} ${image} + """ + } + + // Note: a non-zero exit can mean "vulnerabilities found" but also any other + // scan error (e.g. cache contention); trivy's exit code does not + // distinguish them, so neither can allowFailure. + private void handleScanFailure(Event event, Exception e) { + if (this.allowFailure) { + event.script.catchError(buildResult: 'SUCCESS', stageResult: 'UNSTABLE') { throw e } + } else { + throw e + } + } + + private String resolveCacheVolume(Event event) { + if (this.cacheVolume == null) { + String job = (event.script.env.JOB_BASE_NAME ?: 'default').toString().replaceAll(/[^A-Za-z0-9._-]/, '_') + return "jpipe-trivy-cache-${job}" } + return this.cacheVolume } - // A per-build, per-image-ID marker file in the shared tmp workspace. Two - // plugin instances that resolve to the same image ID (identical layers - // published under different names) produce the same marker, so the second - // one can tell a scan already happened. Returns '' when duplicate - // detection is disabled or the image ID cannot be resolved. + // A per-build marker file in the shared tmp workspace, keyed by image ID + // AND the scan-relevant configuration. Two plugin instances only share a + // marker when they would run the exact same scan on the exact same bits — + // a stricter or differently-formatted second instance still scans. + // Returns '' when duplicate detection is disabled or the image ID cannot + // be resolved. private String duplicateMarker(Event event, String image) { if (!this.skipDuplicates) { return '' @@ -129,10 +162,13 @@ class TrivyPlugin extends Plugin { event.script.println("TrivyPlugin: could not resolve the image ID of ${image}; scanning without duplicate detection") return '' } - // Markers from previous builds are stale; clean them up as we go. + // Pure hygiene, not correctness: markers embed the unique BUILD_TAG so + // stale ones can never match a later build — this only stops them from + // accumulating in the tmp workspace. event.script.sh "find . -maxdepth 1 -name '.trivy-scanned-*' -mtime +1 -delete || true" String buildTag = event.script.env.BUILD_TAG ?: 'build' - String marker = ".trivy-scanned-${buildTag}-${event.version}-${imageId}" + String config = "${this.severity.join(',')}|${this.ignoreUnfixed}|${this.extraFlags}|${this.report}|${this.trivyVersion}" + String marker = ".trivy-scanned-${buildTag}-${event.version}-${imageId}-${config.hashCode()}" return marker.replaceAll(/[^A-Za-z0-9._-]/, '_') } diff --git a/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy b/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy index c621ee4..2270341 100644 --- a/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy +++ b/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy @@ -12,7 +12,8 @@ class TrivyPluginSpec extends Specification { Map files = [:] List published = [] List printed = [] - Map env = [BUILD_TAG: 'jenkins-job-42'] + List catchErrors = [] + Map env = [BUILD_TAG: 'jenkins-job-42', JOB_BASE_NAME: 'myjob'] String imageId = 'sha256:abc123' boolean inspectFails = false boolean scanFails = false @@ -42,6 +43,8 @@ class TrivyPluginSpec extends Specification { def println(def s) { printed << s.toString() } def publishHTML(Map target) { published << target.target } def catchError(Map opts, Closure c) { + // Like Jenkins: mark and continue. + catchErrors << opts try { c() } catch (Exception ignored) { } } def trivy(Map opts) { return opts } @@ -72,7 +75,7 @@ class TrivyPluginSpec extends Specification { assert script.printed.any { it.contains('no containerImage') } } - def "[TrivyPlugin] mounts the cache volume so the vulnerability DB persists between scans"() { + def "[TrivyPlugin] mounts a per-job cache volume so the vulnerability DB persists between builds"() { given: def script = new FakeScript() def plugin = new TrivyPlugin([containerImage: 'repo/app', report: 'html']) @@ -83,7 +86,19 @@ class TrivyPluginSpec extends Specification { then: def runs = trivyRuns(script) assert runs.size() == 1 - assert runs[0].contains('-v jpipe-trivy-cache:/root/.cache/trivy') + assert runs[0].contains('-v jpipe-trivy-cache-myjob:/root/.cache/trivy') + } + + def "[TrivyPlugin] an explicit cacheVolume name is used as-is"() { + given: + def script = new FakeScript() + def plugin = new TrivyPlugin([containerImage: 'repo/app', cacheVolume: 'shared-cache']) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script)[0].contains('-v shared-cache:/root/.cache/trivy') } def "[TrivyPlugin] cacheVolume '' disables the cache mount"() { @@ -100,7 +115,7 @@ class TrivyPluginSpec extends Specification { assert !runs[0].contains(':/root/.cache/trivy') } - def "[TrivyPlugin] scans an image once and records a marker for the build"() { + def "[TrivyPlugin] scans an image once and records a marker with the scan outcome"() { given: def script = new FakeScript() def plugin = new TrivyPlugin([containerImage: 'repo/app', report: 'html']) @@ -110,7 +125,9 @@ class TrivyPluginSpec extends Specification { then: assert trivyRuns(script).size() == 1 - assert script.files.keySet().any { it.startsWith('.trivy-scanned-') } + def markers = script.files.findAll { k, v -> k.startsWith('.trivy-scanned-') } + assert markers.size() == 1 + assert markers.values()[0] == '.trivy-report-app|ok' assert script.published.size() == 1 assert script.published[0].reportName == 'Trivy - app' } @@ -133,6 +150,35 @@ class TrivyPluginSpec extends Specification { assert script.published[1].reportName == 'Trivy - app-mirror' } + def "[TrivyPlugin] scans again when the second instance has a different scan configuration"() { + given: + def script = new FakeScript() + def relaxed = new TrivyPlugin([containerImage: 'repo/app', severity: ['LOW']]) + def strictGate = new TrivyPlugin([containerImage: 'repo/app-mirror', severity: ['CRITICAL']]) + + when: + relaxed.doRunImageScan(newEvent(script)) + strictGate.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script).size() == 2 + } + + def "[TrivyPlugin] scans again when the report format differs"() { + given: + def script = new FakeScript() + def table = new TrivyPlugin([containerImage: 'repo/app', report: 'table']) + def html = new TrivyPlugin([containerImage: 'repo/app-mirror', report: 'html']) + + when: + table.doRunImageScan(newEvent(script)) + html.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script).size() == 2 + assert script.published.size() == 1 + } + def "[TrivyPlugin] scans both images when their image IDs differ"() { given: def script = new FakeScript() @@ -178,7 +224,7 @@ class TrivyPluginSpec extends Specification { assert script.printed.any { it.contains('could not resolve') } } - def "[TrivyPlugin] allowFailure swallows a failing scan and still publishes"() { + def "[TrivyPlugin] allowFailure swallows a failing scan, publishes, and records the failed verdict"() { given: def script = new FakeScript() script.scanFails = true @@ -189,7 +235,10 @@ class TrivyPluginSpec extends Specification { then: noExceptionThrown() + assert script.catchErrors.size() == 1 assert script.published.size() == 1 + def markers = script.files.findAll { k, v -> k.startsWith('.trivy-scanned-') } + assert markers.values()[0] == '.trivy-report-app|failed' } def "[TrivyPlugin] a failing scan without allowFailure propagates"() { @@ -204,4 +253,38 @@ class TrivyPluginSpec extends Specification { then: thrown(RuntimeException) } + + def "[TrivyPlugin] a skipped duplicate of a failed scan re-signals the failure"() { + given: + def script = new FakeScript() + script.scanFails = true + def first = new TrivyPlugin([containerImage: 'repo/app', report: 'html', allowFailure: true]) + def mirror = new TrivyPlugin([containerImage: 'repo/app-mirror', report: 'html', allowFailure: true]) + + when: + first.doRunImageScan(newEvent(script)) + mirror.doRunImageScan(newEvent(script)) + + then: + noExceptionThrown() + assert trivyRuns(script).size() == 1 + assert script.catchErrors.size() == 2 + assert script.published.size() == 2 + } + + def "[TrivyPlugin] a strict duplicate of a failed scan fails hard without allowFailure"() { + given: + def script = new FakeScript() + script.scanFails = true + def first = new TrivyPlugin([containerImage: 'repo/app', report: 'html', allowFailure: true]) + def strictGate = new TrivyPlugin([containerImage: 'repo/app-mirror', report: 'html', allowFailure: false]) + + when: + first.doRunImageScan(newEvent(script)) + strictGate.doRunImageScan(newEvent(script)) + + then: + thrown(Exception) + assert trivyRuns(script).size() == 1 + } } From b0562f9d7123facccefaf2b6da514d8dde088199 Mon Sep 17 00:00:00 2001 From: Steven Gerrits Date: Mon, 6 Jul 2026 09:34:42 +0200 Subject: [PATCH 3/5] fix: distinguish findings from operational scan errors and harden dedup keys - Run trivy with --exit-code=2 and returnStatus: vulnerabilities exit 2 and stay subject to allowFailure, while any other non-zero exit (cache DB lock, image pull failure, ...) now fails the build outright instead of masquerading as a benign UNSTABLE. Operational failures write no marker, so a retry re-scans from scratch instead of inheriting a transient error as a verdict. - Key the duplicate marker on a SHA-256 digest of the scan config instead of String.hashCode(), whose 32-bit collisions could silently skip a stricter second scan. - Key the report directory on the full sanitized image path so distinct images sharing a basename no longer overwrite each other's reports. - Document that the per-job cache volume does not isolate concurrent builds of the same job, and that such contention now surfaces loudly; note why no marker is written on a hard findings failure. --- src/io/stenic/jpipe/plugin/TrivyPlugin.groovy | 62 +++++++++++---- .../jpipe/plugin/TrivyPluginSpec.groovy | 78 +++++++++++++++---- 2 files changed, 112 insertions(+), 28 deletions(-) diff --git a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy index 9c11996..09dfdb6 100644 --- a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy +++ b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy @@ -30,8 +30,12 @@ class TrivyPlugin extends Plugin { // "jpipe-trivy-cache-"): trivy's cache DB takes an exclusive file // lock, so one cluster-wide volume would let concurrent builds of // unrelated jobs contend ("database is locked"). Scans within one build - // run sequentially and are safe. Set to '' to disable the mount, or to - // a fixed name to deliberately share a volume. + // run sequentially and are safe. Note the per-job volume does NOT + // isolate two concurrent builds of the SAME job (identical + // JOB_BASE_NAME) — those can still contend; such contention now surfaces + // as a build failure (operational error, see doRunImageScan) rather than + // a silent UNSTABLE. Set to '' to disable the mount, or to a fixed name + // to deliberately share a volume. this.cacheVolume = opts.get('cacheVolume', null); // Pipelines that publish the same built image under several names would // scan identical bits once per name. When enabled, images whose docker @@ -56,7 +60,10 @@ class TrivyPlugin extends Plugin { String image = "${this.containerImage}:${event.version}" String imgName = this.containerImage.split('/').last() - String reportDir = ".trivy-report-${imgName}" + // Keyed on the full image path, not the basename: distinct images that + // share a basename (repo/app vs other/app) must not overwrite each + // other's report directory. + String reportDir = ".trivy-report-${this.containerImage.replaceAll(/[^A-Za-z0-9._-]/, '_')}" event.script.dir(event.script.pwd(tmp: true)) { String marker = this.duplicateMarker(event, image) @@ -80,12 +87,24 @@ class TrivyPlugin extends Plugin { } } else { Boolean scanFailed = false - try { - this.execScan(event, image, reportDir) - } catch (Exception e) { + int status = this.execScan(event, image, reportDir) + if (status == 2) { + // Vulnerabilities found (trivy's dedicated --exit-code) — + // subject to the allowFailure gate. scanFailed = true - this.handleScanFailure(event, e) + this.handleScanFailure(event, new RuntimeException("Trivy found vulnerabilities in ${image}")) + } else if (status != 0) { + // Any other non-zero code is an operational error (cache DB + // lock, image pull failure, ...), not a vulnerability verdict. + // Fail the build outright: allowFailure only tolerates + // findings, and masking a transient error as a benign + // UNSTABLE would make it indistinguishable from a clean scan. + // No marker is written, so a retry re-scans from scratch. + throw new RuntimeException("Trivy scan of ${image} failed with a non-vulnerability error (exit code ${status})") } + // Written only when execution continues past the verdict: on a + // findings failure without allowFailure the build aborts above, + // so duplicates never get to consult a marker anyway. if (marker != '') { event.script.writeFile(file: marker, text: "${reportDir}|${scanFailed ? 'failed' : 'ok'}") } @@ -95,10 +114,17 @@ class TrivyPlugin extends Plugin { } } - private void execScan(Event event, String image, String reportDir) { + // Returns the trivy exit code: 0 = clean, 2 = vulnerabilities found, + // any other non-zero = operational error (see doRunImageScan). Uses + // returnStatus so an operational error is not swallowed by the shell step + // before we can tell it apart from a findings verdict. + private int execScan(Event event, String image, String reportDir) { List args = [ '--no-progress', - '--exit-code=1', + // Vulnerabilities exit 2 so a findings verdict can be told apart + // from trivy's operational errors (exit 1: cache DB lock, image + // pull failure, ...). + '--exit-code=2', "--severity ${this.severity.join(',')}", ] if (this.report == 'html') { @@ -114,19 +140,20 @@ class TrivyPlugin extends Plugin { String volume = this.resolveCacheVolume(event) String cacheMount = volume != '' ? "-v ${volume}:/root/.cache/trivy" : '' - event.script.sh """ + return event.script.sh(returnStatus: true, script: """ docker run \ -v /var/run/docker.sock:/var/run/docker.sock \ -v \$(pwd)/${reportDir}:/report \ ${cacheMount} \ ghcr.io/aquasecurity/trivy:${this.trivyVersion} \ image ${args.join(' ')} ${image} - """ + """) } - // Note: a non-zero exit can mean "vulnerabilities found" but also any other - // scan error (e.g. cache contention); trivy's exit code does not - // distinguish them, so neither can allowFailure. + // Only reached for a vulnerability-findings verdict (trivy exit 2): + // allowFailure keeps the build green with an UNSTABLE stage. Operational + // errors (exit 1) never reach here — doRunImageScan fails the build on them + // so a transient error can't masquerade as a clean/permissive pass. private void handleScanFailure(Event event, Exception e) { if (this.allowFailure) { event.script.catchError(buildResult: 'SUCCESS', stageResult: 'UNSTABLE') { @@ -168,7 +195,12 @@ class TrivyPlugin extends Plugin { event.script.sh "find . -maxdepth 1 -name '.trivy-scanned-*' -mtime +1 -delete || true" String buildTag = event.script.env.BUILD_TAG ?: 'build' String config = "${this.severity.join(',')}|${this.ignoreUnfixed}|${this.extraFlags}|${this.report}|${this.trivyVersion}" - String marker = ".trivy-scanned-${buildTag}-${event.version}-${imageId}-${config.hashCode()}" + // A real digest, not String.hashCode(): a 32-bit hash collision between + // two different configs would silently skip a stricter second scan — + // the exact failure mode the config-in-key design exists to prevent. + String configDigest = java.security.MessageDigest.getInstance('SHA-256') + .digest(config.getBytes('UTF-8')).encodeHex().toString().take(16) + String marker = ".trivy-scanned-${buildTag}-${event.version}-${imageId}-${configDigest}" return marker.replaceAll(/[^A-Za-z0-9._-]/, '_') } diff --git a/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy b/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy index 2270341..c5b0a8f 100644 --- a/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy +++ b/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy @@ -16,23 +16,27 @@ class TrivyPluginSpec extends Specification { Map env = [BUILD_TAG: 'jenkins-job-42', JOB_BASE_NAME: 'myjob'] String imageId = 'sha256:abc123' boolean inspectFails = false - boolean scanFails = false + // trivy exit code the scan reports: 0 = clean, 2 = vulnerabilities + // found, any other non-zero = operational error (e.g. cache DB lock). + int scanExitCode = 0 def sh(def arg) { if (arg instanceof Map) { - shCommands << arg.script.toString() - if (arg.script.toString().contains('docker inspect')) { + String s = arg.script.toString() + shCommands << s + if (s.contains('docker inspect')) { if (inspectFails) { throw new RuntimeException('no such image') } return imageId + '\n' } + if (s.contains('aquasecurity/trivy')) { + // Scan runs with returnStatus:true — hand back the exit code. + return scanExitCode + } return '' } shCommands << arg.toString() - if (arg.toString().contains('aquasecurity/trivy') && scanFails) { - throw new RuntimeException('script returned exit code 1') - } return null } def dir(String d, Closure c) { c() } @@ -127,7 +131,7 @@ class TrivyPluginSpec extends Specification { assert trivyRuns(script).size() == 1 def markers = script.files.findAll { k, v -> k.startsWith('.trivy-scanned-') } assert markers.size() == 1 - assert markers.values()[0] == '.trivy-report-app|ok' + assert markers.values()[0] == '.trivy-report-repo_app|ok' assert script.published.size() == 1 assert script.published[0].reportName == 'Trivy - app' } @@ -145,7 +149,7 @@ class TrivyPluginSpec extends Specification { then: assert trivyRuns(script).size() == 1 assert script.printed.any { it.contains('identical image') } - assert script.shCommands.any { it.contains("cp -r '.trivy-report-app/.' '.trivy-report-app-mirror/'") } + assert script.shCommands.any { it.contains("cp -r '.trivy-report-repo_app/.' '.trivy-report-repo_app-mirror/'") } assert script.published.size() == 2 assert script.published[1].reportName == 'Trivy - app-mirror' } @@ -194,6 +198,23 @@ class TrivyPluginSpec extends Specification { assert trivyRuns(script).size() == 2 } + def "[TrivyPlugin] distinct images sharing a basename get distinct report dirs"() { + given: + def script = new FakeScript() + def first = new TrivyPlugin([containerImage: 'repo/app', report: 'html']) + def other = new TrivyPlugin([containerImage: 'other/app', report: 'html']) + + when: + first.doRunImageScan(newEvent(script)) + script.imageId = 'sha256:def456' + other.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script).size() == 2 + assert trivyRuns(script)[0].contains('.trivy-report-repo_app:/report') + assert trivyRuns(script)[1].contains('.trivy-report-other_app:/report') + } + def "[TrivyPlugin] skipDuplicates false always scans"() { given: def script = new FakeScript() @@ -227,7 +248,7 @@ class TrivyPluginSpec extends Specification { def "[TrivyPlugin] allowFailure swallows a failing scan, publishes, and records the failed verdict"() { given: def script = new FakeScript() - script.scanFails = true + script.scanExitCode = 2 // vulnerabilities found def plugin = new TrivyPlugin([containerImage: 'repo/app', report: 'html', allowFailure: true]) when: @@ -238,13 +259,13 @@ class TrivyPluginSpec extends Specification { assert script.catchErrors.size() == 1 assert script.published.size() == 1 def markers = script.files.findAll { k, v -> k.startsWith('.trivy-scanned-') } - assert markers.values()[0] == '.trivy-report-app|failed' + assert markers.values()[0] == '.trivy-report-repo_app|failed' } def "[TrivyPlugin] a failing scan without allowFailure propagates"() { given: def script = new FakeScript() - script.scanFails = true + script.scanExitCode = 2 // vulnerabilities found def plugin = new TrivyPlugin([containerImage: 'repo/app']) when: @@ -257,7 +278,7 @@ class TrivyPluginSpec extends Specification { def "[TrivyPlugin] a skipped duplicate of a failed scan re-signals the failure"() { given: def script = new FakeScript() - script.scanFails = true + script.scanExitCode = 2 // vulnerabilities found def first = new TrivyPlugin([containerImage: 'repo/app', report: 'html', allowFailure: true]) def mirror = new TrivyPlugin([containerImage: 'repo/app-mirror', report: 'html', allowFailure: true]) @@ -275,7 +296,7 @@ class TrivyPluginSpec extends Specification { def "[TrivyPlugin] a strict duplicate of a failed scan fails hard without allowFailure"() { given: def script = new FakeScript() - script.scanFails = true + script.scanExitCode = 2 // vulnerabilities found def first = new TrivyPlugin([containerImage: 'repo/app', report: 'html', allowFailure: true]) def strictGate = new TrivyPlugin([containerImage: 'repo/app-mirror', report: 'html', allowFailure: false]) @@ -287,4 +308,35 @@ class TrivyPluginSpec extends Specification { thrown(Exception) assert trivyRuns(script).size() == 1 } + + def "[TrivyPlugin] vulnerabilities use a distinct exit code so operational errors can be told apart"() { + given: + def script = new FakeScript() + def plugin = new TrivyPlugin([containerImage: 'repo/app']) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + assert trivyRuns(script)[0].contains('--exit-code=2') + } + + def "[TrivyPlugin] an operational scan error fails the build even with allowFailure"() { + given: + def script = new FakeScript() + script.scanExitCode = 1 // operational error (e.g. cache DB lock), not a vuln finding + def plugin = new TrivyPlugin([containerImage: 'repo/app', report: 'html', allowFailure: true]) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + // allowFailure only tolerates vulnerability findings, never a broken + // scan: the error propagates and is not swallowed as UNSTABLE. + thrown(RuntimeException) + assert script.catchErrors.isEmpty() + // No marker is written, so a retry re-scans instead of reusing a + // transient failure verdict. + assert !script.files.keySet().any { it.startsWith('.trivy-scanned-') } + } } From df37b59017bbc71c9e9c5328ff5b4f78fc69ad5d Mon Sep 17 00:00:00 2001 From: Steven Gerrits Date: Mon, 6 Jul 2026 09:48:54 +0200 Subject: [PATCH 4/5] fix: quote the image reference passed to docker inspect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stray shell metacharacter in the tag (versions are often derived from git describe) would otherwise break the inspect and be masked by the "could not resolve" fallback. BREAKING CHANGE: with allowFailure: true, a trivy operational error (image pull failure, cache DB lock — any non-zero exit other than the findings code) now fails the build instead of being swallowed as an UNSTABLE stage. allowFailure only tolerates vulnerability findings; a scan that never ran can no longer pass a gate. Pipelines that relied on allowFailure to ride out transient scan errors will now fail loudly and should retry instead. --- src/io/stenic/jpipe/plugin/TrivyPlugin.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy index 09dfdb6..4ef88e5 100644 --- a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy +++ b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy @@ -184,7 +184,7 @@ class TrivyPlugin extends Plugin { } String imageId = '' try { - imageId = event.script.sh(script: "docker inspect -f '{{.Id}}' ${image}", returnStdout: true).trim() + imageId = event.script.sh(script: "docker inspect -f '{{.Id}}' '${image}'", returnStdout: true).trim() } catch (Exception e) { event.script.println("TrivyPlugin: could not resolve the image ID of ${image}; scanning without duplicate detection") return '' From 2baeb39e68b52ed70432eb337f9ec6c4699ea96b Mon Sep 17 00:00:00 2001 From: Steven Gerrits Date: Mon, 6 Jul 2026 12:22:42 +0200 Subject: [PATCH 5/5] fix: never block the build on a trivy operational error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product decision: a broken scan (cache DB lock, image pull failure, DB mirror down — any non-zero trivy exit other than the findings code) must not hold a developer's build hostage to scan-infrastructure flakiness. Such errors are now downgraded to an UNSTABLE stage with a distinct message instead of failing the build, independent of allowFailure (which governs vulnerability findings only). No dedup marker is written for a broken scan, so a retry re-scans and duplicates never inherit a transient error. This supersedes the earlier "fail hard on operational errors" behaviour; there is no longer a breaking change to allowFailure semantics. --- src/io/stenic/jpipe/plugin/TrivyPlugin.groovy | 44 +++++++++++-------- .../jpipe/plugin/TrivyPluginSpec.groovy | 31 ++++++++++--- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy index 4ef88e5..6e19b4d 100644 --- a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy +++ b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy @@ -32,10 +32,10 @@ class TrivyPlugin extends Plugin { // unrelated jobs contend ("database is locked"). Scans within one build // run sequentially and are safe. Note the per-job volume does NOT // isolate two concurrent builds of the SAME job (identical - // JOB_BASE_NAME) — those can still contend; such contention now surfaces - // as a build failure (operational error, see doRunImageScan) rather than - // a silent UNSTABLE. Set to '' to disable the mount, or to a fixed name - // to deliberately share a volume. + // JOB_BASE_NAME) — those can still contend; such contention surfaces as + // an UNSTABLE stage (operational error, see doRunImageScan), never a + // blocked build. Set to '' to disable the mount, or to a fixed name to + // deliberately share a volume. this.cacheVolume = opts.get('cacheVolume', null); // Pipelines that publish the same built image under several names would // scan identical bits once per name. When enabled, images whose docker @@ -87,6 +87,7 @@ class TrivyPlugin extends Plugin { } } else { Boolean scanFailed = false + Boolean operationalError = false int status = this.execScan(event, image, reportDir) if (status == 2) { // Vulnerabilities found (trivy's dedicated --exit-code) — @@ -95,17 +96,23 @@ class TrivyPlugin extends Plugin { this.handleScanFailure(event, new RuntimeException("Trivy found vulnerabilities in ${image}")) } else if (status != 0) { // Any other non-zero code is an operational error (cache DB - // lock, image pull failure, ...), not a vulnerability verdict. - // Fail the build outright: allowFailure only tolerates - // findings, and masking a transient error as a benign - // UNSTABLE would make it indistinguishable from a clean scan. - // No marker is written, so a retry re-scans from scratch. - throw new RuntimeException("Trivy scan of ${image} failed with a non-vulnerability error (exit code ${status})") + // lock, image pull failure, DB mirror down, ...), not a + // vulnerability verdict. DELIBERATE PRODUCT DECISION: never + // block the build on it — a developer's build must not be + // held hostage to trivy infrastructure flakiness. But do not + // let it pass silently either: mark the stage UNSTABLE with a + // distinct message (so it is never indistinguishable from a + // clean scan) and write NO marker (so a retry re-scans and + // duplicates never inherit a transient error). This is + // independent of allowFailure, which governs findings only. + operationalError = true + event.script.catchError(buildResult: 'SUCCESS', stageResult: 'UNSTABLE') { + throw new RuntimeException("Trivy scan of ${image} failed with a non-vulnerability error (exit code ${status}); not blocking the build, retry once the scan infrastructure recovers") + } } - // Written only when execution continues past the verdict: on a - // findings failure without allowFailure the build aborts above, - // so duplicates never get to consult a marker anyway. - if (marker != '') { + // No marker on an operational error: it produced no verdict, so a + // retry must re-scan rather than reuse a broken run. + if (marker != '' && !operationalError) { event.script.writeFile(file: marker, text: "${reportDir}|${scanFailed ? 'failed' : 'ok'}") } } @@ -150,10 +157,11 @@ class TrivyPlugin extends Plugin { """) } - // Only reached for a vulnerability-findings verdict (trivy exit 2): - // allowFailure keeps the build green with an UNSTABLE stage. Operational - // errors (exit 1) never reach here — doRunImageScan fails the build on them - // so a transient error can't masquerade as a clean/permissive pass. + // Reached for a vulnerability-findings verdict (trivy exit 2): allowFailure + // decides whether findings keep the build green with an UNSTABLE stage or + // fail it. Operational errors (any other non-zero exit) do NOT come here — + // doRunImageScan always downgrades those to UNSTABLE without blocking, since + // allowFailure is about tolerating findings, not infrastructure flakiness. private void handleScanFailure(Event event, Exception e) { if (this.allowFailure) { event.script.catchError(buildResult: 'SUCCESS', stageResult: 'UNSTABLE') { diff --git a/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy b/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy index c5b0a8f..172366d 100644 --- a/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy +++ b/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy @@ -321,7 +321,7 @@ class TrivyPluginSpec extends Specification { assert trivyRuns(script)[0].contains('--exit-code=2') } - def "[TrivyPlugin] an operational scan error fails the build even with allowFailure"() { + def "[TrivyPlugin] an operational scan error goes UNSTABLE and never blocks the build"() { given: def script = new FakeScript() script.scanExitCode = 1 // operational error (e.g. cache DB lock), not a vuln finding @@ -331,12 +331,33 @@ class TrivyPluginSpec extends Specification { plugin.doRunImageScan(newEvent(script)) then: - // allowFailure only tolerates vulnerability findings, never a broken - // scan: the error propagates and is not swallowed as UNSTABLE. - thrown(RuntimeException) - assert script.catchErrors.isEmpty() + // Product decision: a broken scan must never block a developer's + // build. It is downgraded to UNSTABLE (visible, not silent) rather + // than failing. + noExceptionThrown() + assert script.catchErrors.size() == 1 + assert script.catchErrors[0].stageResult == 'UNSTABLE' // No marker is written, so a retry re-scans instead of reusing a // transient failure verdict. assert !script.files.keySet().any { it.startsWith('.trivy-scanned-') } + // The report is still published for the operator to inspect. + assert script.published.size() == 1 + } + + def "[TrivyPlugin] an operational scan error is non-blocking even without allowFailure"() { + given: + def script = new FakeScript() + script.scanExitCode = 1 // operational error, not a vuln finding + def plugin = new TrivyPlugin([containerImage: 'repo/app', allowFailure: false]) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + // allowFailure governs findings only; operational errors are always + // non-blocking, independent of it. + noExceptionThrown() + assert script.catchErrors.size() == 1 + assert script.catchErrors[0].stageResult == 'UNSTABLE' } }