Add Trivy Scanning (#521) #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Trivy Repository Scan | |
| on: | |
| pull_request: | |
| push: | |
| branches: | |
| - main | |
| - master | |
| schedule: | |
| - cron: '20 6 * * 1' | |
| workflow_dispatch: | |
| permissions: | |
| actions: read | |
| contents: read | |
| security-events: write | |
| concurrency: | |
| group: trivy-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| trivy: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| outputs: | |
| report_artifact: ${{ steps.report-outputs.outputs.report_artifact }} | |
| json_report: ${{ steps.report-outputs.outputs.json_report }} | |
| sarif_report: ${{ steps.report-outputs.outputs.sarif_report }} | |
| text_report: ${{ steps.report-outputs.outputs.text_report }} | |
| readable_report: ${{ steps.report-outputs.outputs.readable_report }} | |
| java_json_report: ${{ steps.report-outputs.outputs.java_json_report }} | |
| java_sarif_report: ${{ steps.report-outputs.outputs.java_sarif_report }} | |
| java_text_report: ${{ steps.report-outputs.outputs.java_text_report }} | |
| java_readable_report: ${{ steps.report-outputs.outputs.java_readable_report }} | |
| java_sarif_html_report: ${{ steps.report-outputs.outputs.java_sarif_html_report }} | |
| source_json_report: ${{ steps.report-outputs.outputs.source_json_report }} | |
| source_sarif_report: ${{ steps.report-outputs.outputs.source_sarif_report }} | |
| source_text_report: ${{ steps.report-outputs.outputs.source_text_report }} | |
| source_sarif_html_report: ${{ steps.report-outputs.outputs.source_sarif_html_report }} | |
| total_vulnerabilities: ${{ steps.report-outputs.outputs.total_vulnerabilities }} | |
| critical_vulnerabilities: ${{ steps.report-outputs.outputs.critical_vulnerabilities }} | |
| high_vulnerabilities: ${{ steps.report-outputs.outputs.high_vulnerabilities }} | |
| medium_vulnerabilities: ${{ steps.report-outputs.outputs.medium_vulnerabilities }} | |
| low_vulnerabilities: ${{ steps.report-outputs.outputs.low_vulnerabilities }} | |
| unknown_vulnerabilities: ${{ steps.report-outputs.outputs.unknown_vulnerabilities }} | |
| source_misconfigurations: ${{ steps.report-outputs.outputs.source_misconfigurations }} | |
| source_secrets: ${{ steps.report-outputs.outputs.source_secrets }} | |
| source_license_findings: ${{ steps.report-outputs.outputs.source_license_findings }} | |
| env: | |
| TRIVY_CACHE_DIR: .trivy-cache | |
| TRIVY_ARTIFACT_SCAN_DIR: build/trivy-artifacts | |
| TRIVY_REPORT_DIR: build/reports/trivy | |
| TRIVY_FAIL_SEVERITIES: HIGH,CRITICAL | |
| TRIVY_SOURCE_SKIP_DIRS: archived,apps/netty-server,testing,build,**/build | |
| TRIVY_SEVERITY: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL | |
| TRIVY_SKIP_VERSION_CHECK: true | |
| TRIVY_UPLOAD_CODE_SCANNING: ${{ vars.TRIVY_UPLOAD_CODE_SCANNING == 'true' }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Set up Java | |
| uses: actions/setup-java@v5 | |
| with: | |
| java-version: '25' | |
| distribution: 'temurin' | |
| - name: Set up Gradle | |
| uses: gradle/actions/setup-gradle@v6 | |
| - name: Set up Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.x' | |
| - name: Build and collect Java artifacts for Trivy | |
| run: | | |
| cat > "${RUNNER_TEMP}/trivy-runtime-classpath.gradle" <<'EOF' | |
| allprojects { | |
| plugins.withType(JavaPlugin).configureEach { | |
| if (project.path == ':adapters:aws:xray' || project.path == ':apps:netty-server' | |
| || project.path.startsWith(':testing')) { | |
| return | |
| } | |
| def runtimeClasspath = configurations.findByName('runtimeClasspath') | |
| if (runtimeClasspath != null) { | |
| tasks.register('copyTrivyRuntimeClasspath', Copy) { | |
| from(runtimeClasspath) | |
| into(rootProject.layout.buildDirectory.dir("trivy-artifacts/${project.path.replace(':', '/').replaceFirst('^/', '')}/runtimeClasspath")) | |
| } | |
| } | |
| } | |
| } | |
| EOF | |
| ./gradlew clean jar copyTrivyRuntimeClasspath --no-daemon --console=plain --init-script "${RUNNER_TEMP}/trivy-runtime-classpath.gradle" | |
| - name: Set Trivy cache key | |
| id: trivy-cache-key | |
| run: echo "date=$(date -u +%Y-%m-%d)" >> "${GITHUB_OUTPUT}" | |
| - name: Restore Trivy cache | |
| uses: actions/cache@v5 | |
| with: | |
| path: ${{ env.TRIVY_CACHE_DIR }} | |
| key: trivy-${{ runner.os }}-${{ steps.trivy-cache-key.outputs.date }} | |
| restore-keys: | | |
| trivy-${{ runner.os }}- | |
| - name: Create report directory | |
| run: mkdir -p "${TRIVY_REPORT_DIR}" | |
| - name: Scan built Java artifacts | |
| uses: aquasecurity/trivy-action@v0.36.0 | |
| with: | |
| scan-type: rootfs | |
| scan-ref: ${{ env.TRIVY_ARTIFACT_SCAN_DIR }} | |
| scanners: vuln | |
| format: json | |
| output: ${{ env.TRIVY_REPORT_DIR }}/java-vuln-results.json | |
| severity: ${{ env.TRIVY_SEVERITY }} | |
| ignore-unfixed: true | |
| exit-code: 0 | |
| - name: Scan source, IaC, secrets, and licenses | |
| run: | | |
| trivy fs . \ | |
| --scanners secret,misconfig,license \ | |
| --skip-dirs "${TRIVY_SOURCE_SKIP_DIRS}" \ | |
| --severity "${TRIVY_SEVERITY}" \ | |
| --ignore-unfixed \ | |
| --format json \ | |
| --output "${TRIVY_REPORT_DIR}/source-results.json" \ | |
| --exit-code 0 | |
| - name: Convert Java reports | |
| run: | | |
| trivy convert --format sarif --output "${TRIVY_REPORT_DIR}/java-vuln-results.sarif" --scanners vuln "${TRIVY_REPORT_DIR}/java-vuln-results.json" | |
| trivy convert --format table --output "${TRIVY_REPORT_DIR}/java-vuln-results.txt" --scanners vuln "${TRIVY_REPORT_DIR}/java-vuln-results.json" | |
| - name: Convert source reports | |
| run: | | |
| trivy convert --format sarif --output "${TRIVY_REPORT_DIR}/source-results.sarif" --scanners secret,misconfig,license "${TRIVY_REPORT_DIR}/source-results.json" | |
| trivy convert --format table --output "${TRIVY_REPORT_DIR}/source-results.txt" --scanners secret,misconfig,license "${TRIVY_REPORT_DIR}/source-results.json" | |
| - name: Generate readable Java report | |
| run: node scripts/trivy-readable-report.mjs --input "${TRIVY_REPORT_DIR}/java-vuln-results.json" --output "${TRIVY_REPORT_DIR}/java-readable-report.md" --root . | |
| - name: Generate SARIF HTML reports | |
| run: | | |
| python -m pip install --disable-pip-version-check sarif-tools | |
| sarif html "${TRIVY_REPORT_DIR}/java-vuln-results.sarif" --output "${TRIVY_REPORT_DIR}/java-sarif-report.html" | |
| sarif html "${TRIVY_REPORT_DIR}/source-results.sarif" --output "${TRIVY_REPORT_DIR}/source-sarif-report.html" | |
| - name: Expose Trivy report outputs | |
| id: report-outputs | |
| run: | | |
| node <<'EOF' | |
| const fs = require('node:fs'); | |
| const reportDir = process.env.TRIVY_REPORT_DIR; | |
| const output = process.env.GITHUB_OUTPUT; | |
| const summary = process.env.GITHUB_STEP_SUMMARY; | |
| const javaReport = JSON.parse(fs.readFileSync(`${reportDir}/java-vuln-results.json`, 'utf8')); | |
| const sourceReport = JSON.parse(fs.readFileSync(`${reportDir}/source-results.json`, 'utf8')); | |
| const counts = { | |
| CRITICAL: 0, | |
| HIGH: 0, | |
| MEDIUM: 0, | |
| LOW: 0, | |
| UNKNOWN: 0 | |
| }; | |
| for (const result of javaReport.Results ?? []) { | |
| for (const vulnerability of result.Vulnerabilities ?? []) { | |
| const severity = vulnerability.Severity ?? 'UNKNOWN'; | |
| counts[severity] = (counts[severity] ?? 0) + 1; | |
| } | |
| } | |
| const sourceCounts = { | |
| misconfigurations: 0, | |
| secrets: 0, | |
| licenses: 0 | |
| }; | |
| for (const result of sourceReport.Results ?? []) { | |
| sourceCounts.misconfigurations += (result.Misconfigurations ?? []).length; | |
| sourceCounts.secrets += (result.Secrets ?? []).length; | |
| sourceCounts.licenses += (result.Licenses ?? []).length; | |
| } | |
| const total = Object.values(counts).reduce((sum, count) => sum + count, 0); | |
| const outputs = { | |
| report_artifact: 'trivy-reports', | |
| json_report: `${reportDir}/java-vuln-results.json`, | |
| sarif_report: `${reportDir}/java-vuln-results.sarif`, | |
| text_report: `${reportDir}/java-vuln-results.txt`, | |
| readable_report: `${reportDir}/java-readable-report.md`, | |
| java_json_report: `${reportDir}/java-vuln-results.json`, | |
| java_sarif_report: `${reportDir}/java-vuln-results.sarif`, | |
| java_text_report: `${reportDir}/java-vuln-results.txt`, | |
| java_readable_report: `${reportDir}/java-readable-report.md`, | |
| java_sarif_html_report: `${reportDir}/java-sarif-report.html`, | |
| source_json_report: `${reportDir}/source-results.json`, | |
| source_sarif_report: `${reportDir}/source-results.sarif`, | |
| source_text_report: `${reportDir}/source-results.txt`, | |
| source_sarif_html_report: `${reportDir}/source-sarif-report.html`, | |
| total_vulnerabilities: total, | |
| critical_vulnerabilities: counts.CRITICAL, | |
| high_vulnerabilities: counts.HIGH, | |
| medium_vulnerabilities: counts.MEDIUM, | |
| low_vulnerabilities: counts.LOW, | |
| unknown_vulnerabilities: counts.UNKNOWN, | |
| source_misconfigurations: sourceCounts.misconfigurations, | |
| source_secrets: sourceCounts.secrets, | |
| source_license_findings: sourceCounts.licenses | |
| }; | |
| fs.appendFileSync(output, Object.entries(outputs).map(([key, value]) => `${key}=${value}`).join('\n') + '\n'); | |
| fs.appendFileSync(summary, [ | |
| '## Trivy Report Outputs', | |
| '', | |
| '| Output | Value |', | |
| '| --- | --- |', | |
| ...Object.entries(outputs).map(([key, value]) => `| \`${key}\` | \`${value}\` |`), | |
| '' | |
| ].join('\n')); | |
| EOF | |
| - name: Upload Java SARIF to code scanning | |
| uses: github/codeql-action/upload-sarif@v4 | |
| if: always() && env.TRIVY_UPLOAD_CODE_SCANNING == 'true' && hashFiles('build/reports/trivy/java-vuln-results.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| with: | |
| sarif_file: ${{ env.TRIVY_REPORT_DIR }}/java-vuln-results.sarif | |
| category: trivy-java-artifacts | |
| - name: Upload source SARIF to code scanning | |
| uses: github/codeql-action/upload-sarif@v4 | |
| if: always() && env.TRIVY_UPLOAD_CODE_SCANNING == 'true' && hashFiles('build/reports/trivy/source-results.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| with: | |
| sarif_file: ${{ env.TRIVY_REPORT_DIR }}/source-results.sarif | |
| category: trivy-source | |
| - name: Upload Trivy reports | |
| uses: actions/upload-artifact@v6 | |
| if: always() | |
| with: | |
| name: trivy-reports | |
| path: ${{ env.TRIVY_REPORT_DIR }} | |
| retention-days: 14 | |
| - name: Enforce Trivy policy | |
| if: always() | |
| run: | | |
| node <<'EOF' | |
| const fs = require('node:fs'); | |
| const reportDir = process.env.TRIVY_REPORT_DIR; | |
| const summary = process.env.GITHUB_STEP_SUMMARY; | |
| const failSeverities = new Set( | |
| (process.env.TRIVY_FAIL_SEVERITIES ?? 'HIGH,CRITICAL') | |
| .split(',') | |
| .map((severity) => severity.trim().toUpperCase()) | |
| .filter(Boolean) | |
| ); | |
| const javaReport = readReport(`${reportDir}/java-vuln-results.json`); | |
| const blocking = []; | |
| for (const result of javaReport.Results ?? []) { | |
| for (const vulnerability of result.Vulnerabilities ?? []) { | |
| const severity = vulnerability.Severity ?? 'UNKNOWN'; | |
| if (failSeverities.has(severity)) { | |
| blocking.push({ | |
| type: 'vulnerability', | |
| severity, | |
| id: vulnerability.VulnerabilityID ?? '', | |
| packageName: vulnerability.PkgName ?? '', | |
| target: vulnerability.PkgPath ?? result.Target ?? '' | |
| }); | |
| } | |
| } | |
| } | |
| fs.appendFileSync(summary, [ | |
| '', | |
| '## Trivy Policy Gate', | |
| '', | |
| 'Only Java artifact vulnerabilities are blocking. Source, IaC, secret, and license findings are informational for this workflow.', | |
| `Blocking severities: \`${[...failSeverities].join(',')}\``, | |
| `Blocking findings: \`${blocking.length}\``, | |
| '' | |
| ].join('\n')); | |
| if (blocking.length > 0) { | |
| fs.appendFileSync(summary, [ | |
| '| Type | Severity | ID | Package/Title | Target |', | |
| '| --- | --- | --- | --- | --- |', | |
| ...blocking.slice(0, 50).map((finding) => [ | |
| finding.type, | |
| finding.severity, | |
| code(finding.id), | |
| code(finding.packageName), | |
| code(finding.target) | |
| ].join(' | ').replace(/^/, '| ').replace(/$/, ' |')), | |
| '' | |
| ].join('\n')); | |
| console.error(`Trivy policy failed with ${blocking.length} blocking finding(s).`); | |
| process.exit(1); | |
| } | |
| console.log('Trivy policy passed.'); | |
| function readReport(file) { | |
| if (!fs.existsSync(file)) { | |
| throw new Error(`Expected Trivy report does not exist: ${file}`); | |
| } | |
| return JSON.parse(fs.readFileSync(file, 'utf8')); | |
| } | |
| function code(value) { | |
| return `\`${String(value ?? '').replace(/\|/g, '\\|')}\``; | |
| } | |
| EOF |