[Core] Container Vulnerability PR Report #1182
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: "[Core] Container Vulnerability PR Report" | |
| # Runs with PR write permission only after the unprivileged Core CI workflow | |
| # completes. Never check out or execute PR code here; scan artifacts are treated | |
| # as bounded, untrusted JSON data. | |
| on: | |
| workflow_run: | |
| # Workflow names are glob patterns, so literal brackets must be escaped. | |
| # https://github.com/github/docs/issues/12572#issuecomment-1014035978 | |
| workflows: ['\[Core\] CI'] | |
| types: [completed] | |
| permissions: | |
| actions: read | |
| contents: read | |
| pull-requests: write | |
| concurrency: | |
| group: >- | |
| container-vulnerability-pr-report-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }} | |
| cancel-in-progress: true | |
| jobs: | |
| report: | |
| name: "💬 Report container vulnerabilities" | |
| if: ${{ github.event.workflow_run.event == 'pull_request' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Resolve pull request and reports | |
| id: report-context | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const run = context.payload.workflow_run; | |
| let pullRequest; | |
| const payloadPrNumber = run.pull_requests?.[0]?.number; | |
| if (Number.isSafeInteger(payloadPrNumber)) { | |
| const response = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: payloadPrNumber, | |
| }); | |
| pullRequest = response.data; | |
| } else { | |
| const headOwner = run.head_repository?.owner?.login; | |
| if (headOwner && run.head_branch) { | |
| const response = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| head: `${headOwner}:${run.head_branch}`, | |
| per_page: 100, | |
| }); | |
| pullRequest = response.data.find(candidate => | |
| candidate.head.repo?.full_name === run.head_repository.full_name | |
| && candidate.head.ref === run.head_branch | |
| ); | |
| } | |
| } | |
| if (!pullRequest) { | |
| core.notice(`No open pull request found for workflow run ${run.id}`); | |
| return; | |
| } | |
| const recentRuns = await github.rest.actions.listWorkflowRuns({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| workflow_id: run.workflow_id, | |
| event: 'pull_request', | |
| per_page: 100, | |
| }); | |
| const newerRun = recentRuns.data.workflow_runs.find(candidate => | |
| candidate.id !== run.id | |
| && candidate.head_repository?.id === run.head_repository?.id | |
| && candidate.head_branch === run.head_branch | |
| && ( | |
| Date.parse(candidate.created_at) > Date.parse(run.created_at) | |
| || ( | |
| candidate.created_at === run.created_at | |
| && candidate.id > run.id | |
| ) | |
| ) | |
| ); | |
| if (newerRun) { | |
| core.notice(`Skipping stale run ${run.id}; run ${newerRun.id} is newer`); | |
| return; | |
| } | |
| const artifacts = await github.paginate( | |
| github.rest.actions.listWorkflowRunArtifacts, | |
| { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: run.id, | |
| per_page: 100, | |
| }, | |
| ); | |
| const maxArchiveBytes = 20 * 1024 * 1024; | |
| function findReport(name) { | |
| const artifact = artifacts.find(candidate => | |
| candidate.name === name && !candidate.expired | |
| ); | |
| if (artifact && artifact.size_in_bytes > maxArchiveBytes) { | |
| core.warning(`${name} exceeds the 20 MiB archive limit`); | |
| return undefined; | |
| } | |
| return artifact; | |
| } | |
| const standard = findReport('container-vulnerability-report-standard'); | |
| const ubi9 = findReport('container-vulnerability-report-ubi9'); | |
| core.setOutput('pr-number', String(pullRequest.number)); | |
| core.setOutput('standard-artifact-id', standard ? String(standard.id) : ''); | |
| core.setOutput('ubi9-artifact-id', ubi9 ? String(ubi9.id) : ''); | |
| - name: Download standard vulnerability report | |
| if: ${{ steps.report-context.outputs.standard-artifact-id != '' }} | |
| uses: actions/download-artifact@v8 | |
| with: | |
| artifact-ids: ${{ steps.report-context.outputs.standard-artifact-id }} | |
| path: ${{ runner.temp }}/container-vulnerability-reports/standard | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ github.token }} | |
| - name: Download UBI9 vulnerability report | |
| if: ${{ steps.report-context.outputs.ubi9-artifact-id != '' }} | |
| uses: actions/download-artifact@v8 | |
| with: | |
| artifact-ids: ${{ steps.report-context.outputs.ubi9-artifact-id }} | |
| path: ${{ runner.temp }}/container-vulnerability-reports/ubi9 | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ github.token }} | |
| - name: Update pull request report | |
| if: ${{ always() && steps.report-context.outputs.pr-number != '' }} | |
| uses: actions/github-script@v9 | |
| env: | |
| PR_NUMBER: ${{ steps.report-context.outputs.pr-number }} | |
| REPORT_ROOT: ${{ runner.temp }}/container-vulnerability-reports | |
| STANDARD_ARTIFACT_ID: ${{ steps.report-context.outputs.standard-artifact-id }} | |
| UBI9_ARTIFACT_ID: ${{ steps.report-context.outputs.ubi9-artifact-id }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const marker = '<!-- openaev-container-vulnerability-report -->'; | |
| const maxReportBytes = 25 * 1024 * 1024; | |
| const pullNumber = Number(process.env.PR_NUMBER); | |
| if (!Number.isSafeInteger(pullNumber) || pullNumber < 1) { | |
| throw new Error(`Invalid pull request number: ${process.env.PR_NUMBER}`); | |
| } | |
| function readReport(label, directory, fileName, artifactId) { | |
| if (!/^\d+$/.test(artifactId)) { | |
| return { label, state: 'unavailable', critical: 0, high: 0, total: 0 }; | |
| } | |
| const reportPath = path.join(process.env.REPORT_ROOT, directory, fileName); | |
| if (!fs.existsSync(reportPath)) { | |
| core.warning(`${label} report artifact was not downloaded`); | |
| return { label, state: 'unavailable', critical: 0, high: 0, total: 0 }; | |
| } | |
| try { | |
| const reportSize = fs.statSync(reportPath).size; | |
| if (reportSize > maxReportBytes) { | |
| throw new Error('report exceeds the 25 MiB JSON limit'); | |
| } | |
| const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); | |
| const results = report.Results == null ? [] : report.Results; | |
| if (!Array.isArray(results)) { | |
| throw new Error('report does not contain a Results array'); | |
| } | |
| let critical = 0; | |
| let high = 0; | |
| let total = 0; | |
| for (const result of results) { | |
| if (!Array.isArray(result?.Vulnerabilities)) continue; | |
| for (const vulnerability of result.Vulnerabilities) { | |
| total += 1; | |
| switch (String(vulnerability?.Severity || '').toUpperCase()) { | |
| case 'CRITICAL': critical += 1; break; | |
| case 'HIGH': high += 1; break; | |
| default: break; | |
| } | |
| } | |
| } | |
| return { label, state: 'available', artifactId, critical, high, total }; | |
| } catch (error) { | |
| core.warning(`${label} report could not be read: ${error.message}`); | |
| return { label, state: 'invalid', artifactId, critical: 0, high: 0, total: 0 }; | |
| } | |
| } | |
| const reports = [ | |
| readReport( | |
| 'Standard', | |
| 'standard', | |
| 'container-vulnerability-standard.json', | |
| process.env.STANDARD_ARTIFACT_ID || '', | |
| ), | |
| readReport( | |
| 'UBI9', | |
| 'ubi9', | |
| 'container-vulnerability-ubi9.json', | |
| process.env.UBI9_ARTIFACT_ID || '', | |
| ), | |
| ]; | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pullNumber, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find(comment => | |
| comment.user?.type === 'Bot' | |
| && comment.body?.includes(marker) | |
| ); | |
| const findingCount = reports.reduce((sum, report) => sum + report.total, 0); | |
| const complete = reports.every(report => report.state === 'available'); | |
| if (complete && findingCount === 0 && !existing) { | |
| core.info('No vulnerabilities found; no PR comment is needed'); | |
| return; | |
| } | |
| let heading; | |
| let explanation; | |
| if (findingCount > 0) { | |
| heading = `⚠️ **Container vulnerability scan** — ${findingCount} finding${findingCount === 1 ? '' : 's'}`; | |
| explanation = 'Core CI reports these findings in advisory mode. Review the JSON reports before merging.'; | |
| } else if (complete) { | |
| heading = '✅ **Container vulnerability scan** — Passed'; | |
| explanation = 'Previously reported findings are no longer present.'; | |
| } else { | |
| heading = '❔ **Container vulnerability scan** — Report incomplete'; | |
| explanation = 'The latest run did not produce both readable reports. Check the workflow run for details.'; | |
| } | |
| function status(report) { | |
| if (report.state === 'unavailable') return '❔ Unavailable'; | |
| if (report.state === 'invalid') return '❌ Invalid report'; | |
| return report.total > 0 ? '⚠️ Findings' : '✅ Clear'; | |
| } | |
| function value(report, key) { | |
| return report.state === 'available' ? String(report[key]) : '—'; | |
| } | |
| const rows = reports.map(report => | |
| `| ${report.label} | ${value(report, 'critical')} | ${value(report, 'high')} | ${value(report, 'total')} | ${status(report)} |` | |
| ); | |
| const run = context.payload.workflow_run; | |
| const links = [`[View workflow run](${run.html_url})`]; | |
| for (const report of reports) { | |
| if (/^\d+$/.test(report.artifactId || '')) { | |
| links.push( | |
| `[${report.label} JSON report](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${run.id}/artifacts/${report.artifactId})`, | |
| ); | |
| } | |
| } | |
| const body = [ | |
| marker, | |
| heading, | |
| '', | |
| explanation, | |
| '', | |
| '| Image | Critical | High | Total | Status |', | |
| '|---|---:|---:|---:|---|', | |
| ...rows, | |
| '', | |
| links.join(' · '), | |
| '', | |
| `<sub>Updated from CI run attempt ${run.run_attempt}.</sub>`, | |
| ].join('\n'); | |
| const params = { owner: context.repo.owner, repo: context.repo.repo }; | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| ...params, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| ...params, | |
| issue_number: pullNumber, | |
| body, | |
| }); | |
| } |