Skip to content

[Core] Container Vulnerability PR Report #1182

[Core] Container Vulnerability PR Report

[Core] Container Vulnerability PR Report #1182

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,
});
}