diff --git a/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy b/src/io/stenic/jpipe/plugin/TrivyPlugin.groovy index b6ca88a..6e19b4d 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,24 @@ 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. 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. 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 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 + // 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); } public Map getSubscribedEvents() { @@ -38,9 +58,80 @@ class TrivyPlugin extends Plugin { return } + String image = "${this.containerImage}:${event.version}" + String imgName = this.containerImage.split('/').last() + // 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) + String markerText = '' + if (marker != '' && event.script.fileExists(marker)) { + markerText = event.script.readFile(marker).trim() + } + + 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" + } + 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 { + Boolean scanFailed = false + Boolean operationalError = false + 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, 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, 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") + } + } + // 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'}") + } + } + + this.publishReport(event, imgName, 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') { @@ -53,41 +144,89 @@ 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 { - throw e - } - } + String volume = this.resolveCacheVolume(event) + String cacheMount = volume != '' ? "-v ${volume}:/root/.cache/trivy" : '' + + 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} + """) + } - 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' - ) + // 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') { + 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 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 '' + } + 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 '' + } + // 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 config = "${this.severity.join(',')}|${this.ignoreUnfixed}|${this.extraFlags}|${this.report}|${this.trivyVersion}" + // 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._-]/, '_') + } + + 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..172366d --- /dev/null +++ b/test/io/stenic/jpipe/plugin/TrivyPluginSpec.groovy @@ -0,0 +1,363 @@ +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 = [] + List catchErrors = [] + Map env = [BUILD_TAG: 'jenkins-job-42', JOB_BASE_NAME: 'myjob'] + String imageId = 'sha256:abc123' + boolean inspectFails = 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) { + 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() + 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) { + // Like Jenkins: mark and continue. + catchErrors << opts + 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 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']) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + def runs = trivyRuns(script) + assert runs.size() == 1 + 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"() { + 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 with the scan outcome"() { + 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 + def markers = script.files.findAll { k, v -> k.startsWith('.trivy-scanned-') } + assert markers.size() == 1 + assert markers.values()[0] == '.trivy-report-repo_app|ok' + 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-repo_app/.' '.trivy-report-repo_app-mirror/'") } + assert script.published.size() == 2 + 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() + 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] 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() + 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, publishes, and records the failed verdict"() { + given: + def script = new FakeScript() + script.scanExitCode = 2 // vulnerabilities found + def plugin = new TrivyPlugin([containerImage: 'repo/app', report: 'html', allowFailure: true]) + + when: + plugin.doRunImageScan(newEvent(script)) + + 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-repo_app|failed' + } + + def "[TrivyPlugin] a failing scan without allowFailure propagates"() { + given: + def script = new FakeScript() + script.scanExitCode = 2 // vulnerabilities found + def plugin = new TrivyPlugin([containerImage: 'repo/app']) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + thrown(RuntimeException) + } + + def "[TrivyPlugin] a skipped duplicate of a failed scan re-signals the failure"() { + given: + def script = new FakeScript() + 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]) + + 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.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]) + + when: + first.doRunImageScan(newEvent(script)) + strictGate.doRunImageScan(newEvent(script)) + + then: + 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 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 + def plugin = new TrivyPlugin([containerImage: 'repo/app', report: 'html', allowFailure: true]) + + when: + plugin.doRunImageScan(newEvent(script)) + + then: + // 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' + } +}