Add 'show-patched-versions' option to configuration and update summary handling

- Introduced 'show-patched-versions' input in action.yml to control visibility of patched versions in vulnerability summaries.
- Updated default configuration and related functions to handle the new option.
- Enhanced tests to verify behavior with and without the patched version column.
This commit is contained in:
Chad Bentz
2026-02-27 14:58:54 -05:00
parent e404798400
commit aa60746a92
11 changed files with 166 additions and 57 deletions

View File

@@ -4,8 +4,8 @@
- [Overview](#overview)
- [Viewing the results](#viewing-the-results)
- [Installation](#installation)
- [Installation (standard)](#installation-standard)
- [Installation (GitHub Enterprise Server)](#installation-github-enterprise-server)
- [Installation (standard)](#installation-standard)
- [Installation (GitHub Enterprise Server)](#installation-github-enterprise-server)
- [Configuration](#configuration)
- [Configuration options](#configuration-options)
- [Configuration methods](#configuration-methods)
@@ -130,6 +130,7 @@ All configuration options are optional.
| `warn-only`+ | When set to `true`, the action will log all vulnerabilities as warnings regardless of the severity, and the action will complete with a `success` status. This overrides the `fail-on-severity` option. | `true`, `false` | `false` |
| `show-openssf-scorecard` | When set to `true`, the action will output information about all the known OpenSSF Scorecard scores for the dependencies changed in this pull request. | `true`, `false` | `true` |
| `warn-on-openssf-scorecard-level` | When `show-openssf-scorecard-levels` is set to `true`, this option lets you configure the threshold for when a score is considered too low and gets a :warning: warning in the CI. | Any positive integer | 3 |
| `show-patched-versions`\* | When set to `true`, the vulnerability summary table will include an additional column showing the first patched version for each vulnerability. This requires additional API calls to fetch advisory data. | `true`, `false` | `false` |
> [!NOTE]
>
@@ -215,6 +216,7 @@ You can use an external configuration file to specify settings for this action.
3. Create the configuration file in the path you specified for `config-file`.
4. In the configuration file, specify your chosen settings.
```yaml
fail-on-severity: 'critical'
allow-licenses:

View File

@@ -47,7 +47,8 @@ const defaultConfig: ConfigurationOptions = {
retry_on_snapshot_warnings_timeout: 120,
warn_only: false,
warn_on_openssf_scorecard_level: 3,
show_openssf_scorecard: false
show_openssf_scorecard: false,
show_patched_versions: false
}
const changesWithEmptyManifests: Changes = [
@@ -407,15 +408,70 @@ test('addChangeVulnerabilitiesToSummary() - does not print severity statement if
expect(text).not.toContain('Only included vulnerabilities')
})
test('addChangeVulnerabilitiesToSummary() - includes patched version column', async () => {
test('addChangeVulnerabilitiesToSummary() - does not include patched version column by default', async () => {
const changes = [createTestChange()]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text).not.toContain('Patched Version')
})
test('addChangeVulnerabilitiesToSummary() - includes patched version column when enabled', async () => {
const changes = [createTestChange()]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
expect(text).toContain('Patched Version')
})
test('addChangeVulnerabilitiesToSummary() - skips patched version on GHES even when enabled', async () => {
const originalUrl = process.env.GITHUB_SERVER_URL
process.env.GITHUB_SERVER_URL = 'https://ghes.example.com'
const warnSpy = jest.spyOn(core, 'warning')
const changes = [createTestChange()]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
expect(text).not.toContain('Patched Version')
expect(warnSpy).toHaveBeenCalledWith(
'show-patched-versions is not supported on GitHub Enterprise Server. The Patched Version column will be omitted.'
)
expect(mockOctokitRequest).not.toHaveBeenCalled()
process.env.GITHUB_SERVER_URL = originalUrl
})
test('addChangeVulnerabilitiesToSummary() - works normally on GHES when patched versions disabled', async () => {
const originalUrl = process.env.GITHUB_SERVER_URL
process.env.GITHUB_SERVER_URL = 'https://ghes.example.com'
const changes = [createTestChange()]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', false)
const text = core.summary.stringify()
expect(text).not.toContain('Patched Version')
expect(mockOctokitRequest).not.toHaveBeenCalled()
process.env.GITHUB_SERVER_URL = originalUrl
})
test('addChangeVulnerabilitiesToSummary() - works normally on GHES with default (no third arg)', async () => {
const originalUrl = process.env.GITHUB_SERVER_URL
process.env.GITHUB_SERVER_URL = 'https://ghes.example.com'
const changes = [createTestChange()]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text).not.toContain('Patched Version')
expect(mockOctokitRequest).not.toHaveBeenCalled()
process.env.GITHUB_SERVER_URL = originalUrl
})
test('addLicensesToSummary() - does not include entire section if no license issues found', () => {
summary.addLicensesToSummary(emptyInvalidLicenseChanges, defaultConfig)
const text = core.summary.stringify()
@@ -584,7 +640,7 @@ test('addChangeVulnerabilitiesToSummary() - handles multiple version ranges for
})
const changes = [pkg8, pkg9]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
@@ -628,7 +684,7 @@ test('addChangeVulnerabilitiesToSummary() - handles RestSharp GHSA-4rr6-2v9v-wcp
})
const changes = [pkg]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
@@ -672,7 +728,7 @@ test('addChangeVulnerabilitiesToSummary() - handles version coercion for non-str
})
const changes = [pkg]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
@@ -714,7 +770,7 @@ test('addChangeVulnerabilitiesToSummary() - handles invalid versions in fail-ope
})
const changes = [pkg]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
@@ -765,7 +821,7 @@ test('addChangeVulnerabilitiesToSummary() - respects concurrency limit for API c
}
})
await summary.addChangeVulnerabilitiesToSummary(packages, 'low')
await summary.addChangeVulnerabilitiesToSummary(packages, 'low', true)
// Verify that concurrency limit (10) was respected
expect(maxConcurrent).toBeLessThanOrEqual(10)
@@ -814,7 +870,7 @@ test('addChangeVulnerabilitiesToSummary() - completes all tasks even with varyin
}
)
await summary.addChangeVulnerabilitiesToSummary(packages, 'low')
await summary.addChangeVulnerabilitiesToSummary(packages, 'low', true)
// Verify all 20 unique advisories were fetched and completed
expect(completedAdvisories.size).toBe(20)

View File

@@ -76,6 +76,9 @@ inputs:
warn-on-openssf-scorecard-level:
description: Numeric threshold for the OpenSSF Scorecard score. If the score is below this threshold, the action will warn you.
required: false
show-patched-versions:
description: When set to `true`, the vulnerability summary table will include a column showing the first patched version for each vulnerability.
required: false
outputs:
comment-content:
description: Prepared dependency report comment

67
dist/index.js generated vendored
View File

@@ -786,7 +786,7 @@ function run() {
let issueFound = false;
if (config.vulnerability_check) {
core.setOutput('vulnerable-changes', JSON.stringify(vulnerableChanges));
yield summary.addChangeVulnerabilitiesToSummary(vulnerableChanges, minSeverity);
yield summary.addChangeVulnerabilitiesToSummary(vulnerableChanges, minSeverity, config.show_patched_versions);
issueFound || (issueFound = yield printVulnerabilitiesBlock(vulnerableChanges, minSeverity, warnOnly));
}
if (config.license_check) {
@@ -1311,6 +1311,7 @@ exports.ConfigurationOptionsSchema = z
retry_on_snapshot_warnings_timeout: z.number().default(120),
show_openssf_scorecard: z.boolean().optional().default(true),
warn_on_openssf_scorecard_level: z.number().default(3),
show_patched_versions: z.boolean().default(false),
comment_summary_in_pr: z
.union([
z.preprocess(val => (val === 'true' ? true : val === 'false' ? false : val), z.boolean()),
@@ -1878,17 +1879,25 @@ function promisePool(tasks, limit) {
yield Promise.all(executing);
});
}
function addChangeVulnerabilitiesToSummary(vulnerableChanges, severity) {
return __awaiter(this, void 0, void 0, function* () {
function addChangeVulnerabilitiesToSummary(vulnerableChanges_1, severity_1) {
return __awaiter(this, arguments, void 0, function* (vulnerableChanges, severity, showPatchedVersions = false) {
if (vulnerableChanges.length === 0) {
return;
}
const manifests = (0, utils_1.getManifestsSet)(vulnerableChanges);
// Build set of unique advisories to query
const advisorySet = new Set();
for (const pkg of vulnerableChanges) {
for (const vuln of pkg.vulnerabilities) {
advisorySet.add(vuln.advisory_ghsa_id);
if (showPatchedVersions) {
if ((0, utils_1.isEnterprise)()) {
core.warning('show-patched-versions is not supported on GitHub Enterprise Server. The Patched Version column will be omitted.');
showPatchedVersions = false;
}
else {
for (const pkg of vulnerableChanges) {
for (const vuln of pkg.vulnerabilities) {
advisorySet.add(vuln.advisory_ghsa_id);
}
}
}
}
// Query GitHub API for patch info with concurrency limiting
@@ -1992,34 +2001,43 @@ function addChangeVulnerabilitiesToSummary(vulnerableChanges, severity) {
core.debug(`No advisory data available for ${vuln.advisory_ghsa_id}`);
}
if (!sameAsPrevious) {
rows.push([
const row = [
(0, utils_1.renderUrl)(change.source_repository_url, change.name),
change.version,
(0, utils_1.renderUrl)(vuln.advisory_url, vuln.advisory_summary),
vuln.severity,
patchVer
]);
vuln.severity
];
if (showPatchedVersions) {
row.push(patchVer);
}
rows.push(row);
}
else {
rows.push([
const row = [
{ data: '', colspan: '2' },
(0, utils_1.renderUrl)(vuln.advisory_url, vuln.advisory_summary),
vuln.severity,
patchVer
]);
vuln.severity
];
if (showPatchedVersions) {
row.push(patchVer);
}
rows.push(row);
}
previous_package = change.name;
previous_version = change.version;
}
}
const headerRow = [
{ data: 'Name', header: true },
{ data: 'Version', header: true },
{ data: 'Vulnerability', header: true },
{ data: 'Severity', header: true }
];
if (showPatchedVersions) {
headerRow.push({ data: 'Patched Version', header: true });
}
core.summary.addHeading(`<em>${manifest}</em>`, 4).addTable([
[
{ data: 'Name', header: true },
{ data: 'Version', header: true },
{ data: 'Vulnerability', header: true },
{ data: 'Severity', header: true },
{ data: 'Patched Version', header: true }
],
headerRow,
...rows
]);
}
@@ -2251,6 +2269,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.groupDependenciesByManifest = groupDependenciesByManifest;
exports.getManifestsSet = getManifestsSet;
exports.renderUrl = renderUrl;
exports.isEnterprise = isEnterprise;
exports.octokitClient = octokitClient;
const core = __importStar(__nccwpck_require__(37484));
const octokit_1 = __nccwpck_require__(42373);
@@ -98859,6 +98878,7 @@ function readInlineConfig() {
const warn_only = getOptionalBoolean('warn-only');
const show_openssf_scorecard = getOptionalBoolean('show-openssf-scorecard');
const warn_on_openssf_scorecard_level = getOptionalNumber('warn-on-openssf-scorecard-level');
const show_patched_versions = getOptionalBoolean('show-patched-versions');
validateLicenses('allow-licenses', allow_licenses);
validateLicenses('deny-licenses', deny_licenses);
const keys = {
@@ -98879,7 +98899,8 @@ function readInlineConfig() {
retry_on_snapshot_warnings_timeout,
warn_only,
show_openssf_scorecard,
warn_on_openssf_scorecard_level
warn_on_openssf_scorecard_level,
show_patched_versions
};
return Object.fromEntries(Object.entries(keys).filter(([_, value]) => value !== undefined));
}
@@ -99369,6 +99390,7 @@ exports.ConfigurationOptionsSchema = z
retry_on_snapshot_warnings_timeout: z.number().default(120),
show_openssf_scorecard: z.boolean().optional().default(true),
warn_on_openssf_scorecard_level: z.number().default(3),
show_patched_versions: z.boolean().default(false),
comment_summary_in_pr: z
.union([
z.preprocess(val => (val === 'true' ? true : val === 'false' ? false : val), z.boolean()),
@@ -99600,6 +99622,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.groupDependenciesByManifest = groupDependenciesByManifest;
exports.getManifestsSet = getManifestsSet;
exports.renderUrl = renderUrl;
exports.isEnterprise = isEnterprise;
exports.octokitClient = octokitClient;
const core = __importStar(__nccwpck_require__(37484));
const octokit_1 = __nccwpck_require__(42373);

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -35,7 +35,8 @@ const defaultConfig: ConfigurationOptions = {
retry_on_snapshot_warnings_timeout: 120,
warn_only: false,
warn_on_openssf_scorecard_level: 3,
show_openssf_scorecard: true
show_openssf_scorecard: true,
show_patched_versions: false
}
const scorecard: Scorecard = {

View File

@@ -52,6 +52,7 @@ function readInlineConfig(): ConfigurationOptionsPartial {
const warn_on_openssf_scorecard_level = getOptionalNumber(
'warn-on-openssf-scorecard-level'
)
const show_patched_versions = getOptionalBoolean('show-patched-versions')
validateLicenses('allow-licenses', allow_licenses)
validateLicenses('deny-licenses', deny_licenses)
@@ -74,7 +75,8 @@ function readInlineConfig(): ConfigurationOptionsPartial {
retry_on_snapshot_warnings_timeout,
warn_only,
show_openssf_scorecard,
warn_on_openssf_scorecard_level
warn_on_openssf_scorecard_level,
show_patched_versions
}
return Object.fromEntries(

View File

@@ -207,7 +207,8 @@ async function run(): Promise<void> {
core.setOutput('vulnerable-changes', JSON.stringify(vulnerableChanges))
await summary.addChangeVulnerabilitiesToSummary(
vulnerableChanges,
minSeverity
minSeverity,
config.show_patched_versions
)
issueFound ||= await printVulnerabilitiesBlock(
vulnerableChanges,

View File

@@ -115,6 +115,7 @@ export const ConfigurationOptionsSchema = z
retry_on_snapshot_warnings_timeout: z.number().default(120),
show_openssf_scorecard: z.boolean().optional().default(true),
warn_on_openssf_scorecard_level: z.number().default(3),
show_patched_versions: z.boolean().default(false),
comment_summary_in_pr: z
.union([
z.preprocess(

View File

@@ -6,7 +6,8 @@ import {
groupDependenciesByManifest,
getManifestsSet,
renderUrl,
octokitClient
octokitClient,
isEnterprise
} from './utils'
import * as semver from 'semver'
@@ -277,7 +278,8 @@ async function promisePool(
export async function addChangeVulnerabilitiesToSummary(
vulnerableChanges: Changes,
severity: string
severity: string,
showPatchedVersions: boolean = false
): Promise<void> {
if (vulnerableChanges.length === 0) {
return
@@ -287,9 +289,18 @@ export async function addChangeVulnerabilitiesToSummary(
// Build set of unique advisories to query
const advisorySet = new Set<string>()
for (const pkg of vulnerableChanges) {
for (const vuln of pkg.vulnerabilities) {
advisorySet.add(vuln.advisory_ghsa_id)
if (showPatchedVersions) {
if (isEnterprise()) {
core.warning(
'show-patched-versions is not supported on GitHub Enterprise Server. The Patched Version column will be omitted.'
)
showPatchedVersions = false
} else {
for (const pkg of vulnerableChanges) {
for (const vuln of pkg.vulnerabilities) {
advisorySet.add(vuln.advisory_ghsa_id)
}
}
}
}
@@ -434,33 +445,42 @@ export async function addChangeVulnerabilitiesToSummary(
}
if (!sameAsPrevious) {
rows.push([
const row: SummaryTableRow = [
renderUrl(change.source_repository_url, change.name),
change.version,
renderUrl(vuln.advisory_url, vuln.advisory_summary),
vuln.severity,
patchVer
])
vuln.severity
]
if (showPatchedVersions) {
row.push(patchVer)
}
rows.push(row)
} else {
rows.push([
const row: SummaryTableRow = [
{data: '', colspan: '2'},
renderUrl(vuln.advisory_url, vuln.advisory_summary),
vuln.severity,
patchVer
])
vuln.severity
]
if (showPatchedVersions) {
row.push(patchVer)
}
rows.push(row)
}
previous_package = change.name
previous_version = change.version
}
}
const headerRow: SummaryTableRow = [
{data: 'Name', header: true},
{data: 'Version', header: true},
{data: 'Vulnerability', header: true},
{data: 'Severity', header: true}
]
if (showPatchedVersions) {
headerRow.push({data: 'Patched Version', header: true})
}
core.summary.addHeading(`<em>${manifest}</em>`, 4).addTable([
[
{data: 'Name', header: true},
{data: 'Version', header: true},
{data: 'Vulnerability', header: true},
{data: 'Severity', header: true},
{data: 'Patched Version', header: true}
],
headerRow,
...rows
])
}

View File

@@ -33,7 +33,7 @@ export function renderUrl(url: string | null, text: string): string {
}
}
function isEnterprise(): boolean {
export function isEnterprise(): boolean {
const serverUrl = new URL(
process.env['GITHUB_SERVER_URL'] ?? 'https://github.com'
)