Compare commits

...

18 Commits

Author SHA1 Message Date
Eric Sorenson 2031cfc080 Merge pull request #1064 from actions/ahpook/release-4.9.0
Updates for release 4.9.0
2026-03-03 14:08:16 -08:00
Eric Sorenson d02fa39f79 Updates for release 4.9.0
- Bumps dependencies to fix vulnerabilities, supersedes dependabot PRs
- New version in package.json
- Slight correction to the release process in CONTRIBUTING.md
- Rebuilds dist/ packaged files

Closes #1062 #1063 #1028 #972 #971 #970
2026-03-02 16:15:13 -08:00
Eric Sorenson 4038a34c4b Merge pull request #1021 from actions/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 4 to 6
2026-03-02 16:00:21 -08:00
Eric Sorenson a632b8386b Merge pull request #1058 from actions/dependabot/github_actions/actions/stale-10.2.0
Bump actions/stale from 10.1.0 to 10.2.0
2026-03-02 15:59:31 -08:00
Eric Sorenson 57a3d46a7b Merge pull request #1060 from jantiebot/main
fix: only get scorecard levels if user wants to see the OpenSSF scorecard
2026-02-27 15:05:18 -08:00
Eric Sorenson 5ecdc4b578 Merge pull request #1045 from forks-felickz/main
Feat: Add `Patched Version` to `Vulnerabilities` summary
2026-02-27 15:03:52 -08:00
Chad Bentz e8c2f9a12c fix: remove inferrable type annotation to pass eslint 2026-02-27 22:58:04 +00:00
Chad Bentz 0e129e113c Prettier - Refactor summary table rendering for improved readability 2026-02-27 22:30:03 +00:00
Chad Bentz aa60746a92 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.
2026-02-27 14:58:54 -05:00
Chad Bentz e404798400 Merge upstream actions/dependency-review-action main
Syncs fork with upstream, resolving conflicts in package.json
(keeping semver + upgrading spdx-expression-parse to ^4.0.0),
regenerating package-lock.json and dist/ folder.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 14:04:27 -05:00
jantiebot 24398f008e chore: revert dist changes 2026-02-27 12:41:22 +01:00
jantiebot 7863651912 fix: only get scorecard levels if user wants to see the OpenSSF scorecard 2026-02-26 18:16:44 +01:00
dependabot[bot] 17d14c08d9 Bump actions/stale from 10.1.0 to 10.2.0
Bumps [actions/stale](https://github.com/actions/stale) from 10.1.0 to 10.2.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10.1.0...v10.2.0)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 01:42:33 +00:00
Copilot a6c34d8785 Address review feedback: deterministic tests, cached normalization, simplified promisePool (#9)
* Initial plan

* Apply PR review comments: deterministic delays, cached normalization, simplified promisePool

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Improve comment clarity for ecoLower field

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>
2026-02-18 06:33:39 -05:00
Copilot 539c79be65 Implement review feedback: concurrency limiting, semver coercion, logging improvements, and test coverage (#8)
* Initial plan

* Implement PR review comments: concurrency limiting, semver coerce, improved logging, test fixes

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Fix promise pool race condition and remove .then() usage

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Add tests for semver coercion and promise pool concurrency, simplify Map to Set

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>
2026-02-09 14:28:09 -05:00
Copilot ee66ea100d Implement review fixes: semver library, scoping, case-insensitive matching, error logging, and configurable fail behavior (#7)
* Initial plan

* Implement PR review comment fixes: semver library, error handling, case-insensitive matching, and rows scoping

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Fix formatting and rebuild dist folder

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Fix fail-closed logic and remove redundant @types/semver

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Apply review feedback: fix empty range handling, add trimming, implement range check caching

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Apply review feedback: align fail-closed behavior for empty version, fix TypeScript typing, normalize cache keys

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Fix linter errors, optimize cache keys, and improve trimming logic

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Add fail-open option for patch selection and optimize with preTrimmed flag

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Enforce fail-closed with explicit validation, fix debug messages, normalize cache keys

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Fix unreachable ternary in debug message and eliminate duplicate trim operation

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Normalize eco comparison and add preNormalized option to avoid duplicate range conversion

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Remove unnecessary cache, fix function signature, and correct semver comment

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Make includePrerelease conditional based on version type to preserve range semantics

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Improve debug message to report both invalid version and range when applicable

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Convert to JSDoc, add explicit type annotation, and remove redundant initializer

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>
2026-02-08 16:05:04 -05:00
Copilot 2af9bac14d Add patched version column to vulnerability summary with multi-range support (#5)
* Initial plan

* Initial plan for adding patched versions to vulnerability summary

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Add patched version column to vulnerability summary table

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Optimize API calls to use Set and Promise.all for better performance

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Remove type assertions and optimize ecosystem lookups with normalization

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Extract patch version type checking into helper function for clarity

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Support multiple version ranges per package in advisory lookup

Handle scenarios where the same package has multiple vulnerable version ranges
with different patched versions (e.g., GHSA-gwq6-fmvp-qp68 with .NET packages).

- Store all vulnerability entries with version ranges, not just one per ecosystem
- Implement version range matching to select correct patch version
- Match package by ecosystem, name, AND version range
- Add comprehensive test for multi-range scenario

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Fix first_patched_version extraction to handle string format

The GitHub Advisory API returns first_patched_version as a simple string
(e.g., "112.0.0"), not an object with an identifier field. Updated
extractPatchVersionId to handle string format primarily while maintaining
backward compatibility with object format.

- Fixed extractPatchVersionId to check for string first
- Updated test mocks to match actual API response format
- Added specific test for RestSharp GHSA-4rr6-2v9v-wcpc case
- Verified with actual API responses from multiple advisories

Fixes issue where patched versions were showing as "N/A" instead of
the actual version number.

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Rebuild dist with patched version fix

The previous fix to extractPatchVersionId wasn't deployed because the dist/
folder (compiled JavaScript) wasn't rebuilt. This commit rebuilds and packages
the action with npm run build && npm run package to include the fix.

Changes in dist/:
- Updated extractPatchVersionId to handle string format first
- Includes all async vulnerability summary logic with API calls
- Properly extracts patched versions from GitHub Advisory API

This should resolve the issue where patched versions showed as "N/A" in
actual GitHub Actions runs.

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Add comprehensive debug logging for patch version lookup

Added detailed debug logging to help troubleshoot patch version issues:
- Log when fetching advisory data from API
- Log number of vulnerability entries found
- Log each patch info entry added with details
- Log when no patch version is found
- Log during lookup phase with package details
- Log when patch version is found vs not found
- Log available entries when no match is found

This will make it much easier to diagnose issues in GitHub Actions debug mode.

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>
2026-02-06 18:12:20 -05:00
dependabot[bot] 1d60e0d095 Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-27 18:20:43 +00:00
18 changed files with 30196 additions and 658 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
@@ -30,7 +30,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Dependency Review
uses: ./
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10.1.0
- uses: actions/stale@v10.2.0
name: Clean up stale PRs and Issues
with:
stale-pr-message: "👋 This pull request has been marked as stale because it has been open with no activity for 180 days. You can: comment on the PR or remove the stale label to hold stalebot off for a while, add the `Keep` label to hold stale off permanently, or do nothing. If you do nothing, this pull request will be closed eventually by the stalebot. Please see CONTRIBUTING.md for more policy details."
+2 -1
View File
@@ -108,7 +108,8 @@ _Note: these instructions are for maintainers_
- Create a local branch based on the `main` of the upstream repo.
- Update the version number in [package.json](https://github.com/actions/dependency-review-action/blob/main/package.json) and run `npm i` to update the lockfile.
- Update the dist files by running `npm run build` and `npm run package`
- Go to [Draft a new release](https://github.com/actions/dependency-review-action/releases/new) in the Releases page.
- Submit a PR based on your branch and have another maintainer review/approve it.
- Once merged, go to [Draft a new release](https://github.com/actions/dependency-review-action/releases/new) in the Releases page.
- Make sure that the `Publish this Action to the GitHub Marketplace` checkbox is enabled
<img width="481" alt="Screen showing Release Action with Publish this Action to the GitHub Marketplace checked" src="https://user-images.githubusercontent.com/2161/173822484-4b60d8b4-c674-4bff-b5ff-b0c4a3650ab7.png">
+4 -2
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:
+383 -15
View File
@@ -1,12 +1,25 @@
import {expect, jest, test} from '@jest/globals'
import {expect, jest, test, beforeEach} from '@jest/globals'
import {Changes, ConfigurationOptions, Scorecard} from '../src/schemas'
import * as summary from '../src/summary'
import * as core from '@actions/core'
import {createTestChange} from './fixtures/create-test-change'
import {createTestVulnerability} from './fixtures/create-test-vulnerability'
import * as utils from '../src/utils'
const mockOctokitRequest = jest.fn<any>()
beforeEach(() => {
jest.spyOn(utils, 'octokitClient').mockReturnValue({
request: mockOctokitRequest
} as any)
mockOctokitRequest.mockResolvedValue({
data: {vulnerabilities: []}
})
})
afterEach(() => {
jest.clearAllMocks()
jest.restoreAllMocks()
core.summary.emptyBuffer()
})
@@ -34,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 = [
@@ -315,19 +329,19 @@ test('uses checkmarks for vulnerabilities if only license issues were found', ()
expect(text).toContain('✅ 0 package(s) with unknown licenses')
})
test('addChangeVulnerabilitiesToSummary() - only includes section if any vulnerabilities found', () => {
summary.addChangeVulnerabilitiesToSummary(emptyChanges, 'low')
test('addChangeVulnerabilitiesToSummary() - only includes section if any vulnerabilities found', async () => {
await summary.addChangeVulnerabilitiesToSummary(emptyChanges, 'low')
const text = core.summary.stringify()
expect(text).toEqual('')
})
test('addChangeVulnerabilitiesToSummary() - includes all vulnerabilities', () => {
test('addChangeVulnerabilitiesToSummary() - includes all vulnerabilities', async () => {
const changes = [
createTestChange({name: 'lodash'}),
createTestChange({name: 'underscore', package_url: 'test-url'})
]
summary.addChangeVulnerabilitiesToSummary(changes, 'low')
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text).toContain('<h2>Vulnerabilities</h2>')
@@ -335,7 +349,7 @@ test('addChangeVulnerabilitiesToSummary() - includes all vulnerabilities', () =>
expect(text).toContain('underscore')
})
test('addChangeVulnerabilitiesToSummary() - includes advisory url if available', () => {
test('addChangeVulnerabilitiesToSummary() - includes advisory url if available', async () => {
const changes = [
createTestChange({
name: 'underscore',
@@ -348,14 +362,14 @@ test('addChangeVulnerabilitiesToSummary() - includes advisory url if available',
})
]
summary.addChangeVulnerabilitiesToSummary(changes, 'low')
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text).toContain('lodash')
expect(text).toContain('<a href="test-url">test-summary</a>')
})
test('addChangeVulnerabilitiesToSummary() - groups vulnerabilities of a single package', () => {
test('addChangeVulnerabilitiesToSummary() - groups vulnerabilities of a single package', async () => {
const changes = [
createTestChange({
name: 'package-with-multiple-vulnerabilities',
@@ -366,7 +380,7 @@ test('addChangeVulnerabilitiesToSummary() - groups vulnerabilities of a single p
})
]
summary.addChangeVulnerabilitiesToSummary(changes, 'low')
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text.match('package-with-multiple-vulnerabilities')).toHaveLength(1)
@@ -374,10 +388,10 @@ test('addChangeVulnerabilitiesToSummary() - groups vulnerabilities of a single p
expect(text).toContain('test-summary-2')
})
test('addChangeVulnerabilitiesToSummary() - prints severity statement if above low', () => {
test('addChangeVulnerabilitiesToSummary() - prints severity statement if above low', async () => {
const changes = [createTestChange()]
summary.addChangeVulnerabilitiesToSummary(changes, 'medium')
await summary.addChangeVulnerabilitiesToSummary(changes, 'medium')
const text = core.summary.stringify()
expect(text).toContain(
@@ -385,15 +399,79 @@ test('addChangeVulnerabilitiesToSummary() - prints severity statement if above l
)
})
test('addChangeVulnerabilitiesToSummary() - does not print severity statement if it is set to "low"', () => {
test('addChangeVulnerabilitiesToSummary() - does not print severity statement if it is set to "low"', async () => {
const changes = [createTestChange()]
summary.addChangeVulnerabilitiesToSummary(changes, 'low')
await summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text).not.toContain('Only included vulnerabilities')
})
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()
@@ -508,3 +586,293 @@ test('addLicensesToSummary() - includes allowed dependency licences', () => {
'<details><summary><strong>Excluded from license check</strong>:</summary> MIT, Apache-2.0</details>'
)
})
test('addChangeVulnerabilitiesToSummary() - handles multiple version ranges for same package', async () => {
// Simulates GHSA-gwq6-fmvp-qp68 scenario with multiple version ranges
const pkg8 = createTestChange({
ecosystem: 'nuget',
name: 'Microsoft.NetCore.App.Runtime.linux-arm',
version: '8.0.1',
vulnerabilities: [
createTestVulnerability({
advisory_ghsa_id: 'GHSA-test-multi',
advisory_summary: 'Test Multi-Range Advisory',
severity: 'high'
})
]
})
const pkg9 = createTestChange({
ecosystem: 'nuget',
name: 'Microsoft.NetCore.App.Runtime.linux-arm',
version: '9.0.1',
vulnerabilities: [
createTestVulnerability({
advisory_ghsa_id: 'GHSA-test-multi',
advisory_summary: 'Test Multi-Range Advisory',
severity: 'high'
})
]
})
// Mock API response with multiple version ranges for same package
mockOctokitRequest.mockResolvedValueOnce({
data: {
vulnerabilities: [
{
package: {
ecosystem: 'NuGet',
name: 'Microsoft.NetCore.App.Runtime.linux-arm'
},
vulnerable_version_range: '>= 8.0.0, <= 8.0.20',
first_patched_version: '8.0.21'
},
{
package: {
ecosystem: 'NuGet',
name: 'Microsoft.NetCore.App.Runtime.linux-arm'
},
vulnerable_version_range: '>= 9.0.0, <= 9.0.9',
first_patched_version: '9.0.10'
}
]
}
})
const changes = [pkg8, pkg9]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
// Both packages should have correct patched versions based on their version ranges
expect(text).toContain('8.0.21')
expect(text).toContain('9.0.10')
expect(mockOctokitRequest).toHaveBeenCalledWith('GET /advisories/{ghsa_id}', {
ghsa_id: 'GHSA-test-multi'
})
})
test('addChangeVulnerabilitiesToSummary() - handles RestSharp GHSA-4rr6-2v9v-wcpc case', async () => {
const pkg = createTestChange({
ecosystem: 'nuget',
name: 'RestSharp',
version: '111.4.1',
vulnerabilities: [
createTestVulnerability({
advisory_ghsa_id: 'GHSA-4rr6-2v9v-wcpc',
advisory_summary:
"CRLF Injection in RestSharp's `RestRequest.AddHeader` method",
severity: 'moderate'
})
]
})
// Mock API response matching actual GitHub Advisory Database response
mockOctokitRequest.mockResolvedValueOnce({
data: {
vulnerabilities: [
{
package: {
ecosystem: 'nuget',
name: 'RestSharp'
},
vulnerable_version_range: '>= 107.0.0-preview.1, < 112.0.0',
first_patched_version: '112.0.0'
}
]
}
})
const changes = [pkg]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
// Should show the correct patched version
expect(text).toContain('112.0.0')
expect(text).not.toContain('N/A')
expect(mockOctokitRequest).toHaveBeenCalledWith('GET /advisories/{ghsa_id}', {
ghsa_id: 'GHSA-4rr6-2v9v-wcpc'
})
})
test('addChangeVulnerabilitiesToSummary() - handles version coercion for non-strict semver versions', async () => {
// Test that versions like "8.0" (without patch version) can be coerced to "8.0.0"
// for successful range matching in fail-open mode (patch selection)
const pkg = createTestChange({
ecosystem: 'npm',
name: 'test-package',
version: '8.0', // Non-strict semver version
vulnerabilities: [
createTestVulnerability({
advisory_ghsa_id: 'GHSA-test-1234',
advisory_summary: 'Test vulnerability',
severity: 'high'
})
]
})
mockOctokitRequest.mockResolvedValueOnce({
data: {
vulnerabilities: [
{
package: {
ecosystem: 'npm',
name: 'test-package'
},
vulnerable_version_range: '>= 8.0.0, < 9.0.0',
first_patched_version: '9.0.0'
}
]
}
})
const changes = [pkg]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
// Should coerce "8.0" to "8.0.0" and successfully match the range,
// showing the patched version instead of N/A
expect(text).toContain('9.0.0')
expect(text).not.toContain('N/A')
})
test('addChangeVulnerabilitiesToSummary() - handles invalid versions in fail-open mode', async () => {
// Test that completely invalid versions that can't be coerced
// still return N/A gracefully in fail-open mode
const pkg = createTestChange({
ecosystem: 'npm',
name: 'test-package',
version: 'invalid-version-string',
vulnerabilities: [
createTestVulnerability({
advisory_ghsa_id: 'GHSA-test-5678',
advisory_summary: 'Test vulnerability',
severity: 'high'
})
]
})
mockOctokitRequest.mockResolvedValueOnce({
data: {
vulnerabilities: [
{
package: {
ecosystem: 'npm',
name: 'test-package'
},
vulnerable_version_range: '>= 1.0.0, < 2.0.0',
first_patched_version: '2.0.0'
}
]
}
})
const changes = [pkg]
await summary.addChangeVulnerabilitiesToSummary(changes, 'low', true)
const text = core.summary.stringify()
// Should show N/A since version can't be coerced or matched
expect(text).toContain('N/A')
})
test('addChangeVulnerabilitiesToSummary() - respects concurrency limit for API calls', async () => {
// Create 15 packages with different vulnerabilities to test concurrency limiting
const packages = Array.from({length: 15}, (_, i) =>
createTestChange({
ecosystem: 'npm',
name: `package-${i}`,
version: '1.0.0',
vulnerabilities: [
createTestVulnerability({
advisory_ghsa_id: `GHSA-test-${i.toString().padStart(4, '0')}`,
advisory_summary: `Vulnerability ${i}`,
severity: 'high'
})
]
})
)
// Track concurrent calls
let maxConcurrent = 0
let currentConcurrent = 0
mockOctokitRequest.mockImplementation(async () => {
currentConcurrent++
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
// Simulate async API call with a small deterministic delay
await new Promise(resolve => setTimeout(resolve, 5))
currentConcurrent--
return {
data: {
vulnerabilities: [
{
package: {ecosystem: 'npm', name: 'test'},
vulnerable_version_range: '>= 1.0.0, < 2.0.0',
first_patched_version: '2.0.0'
}
]
}
}
})
await summary.addChangeVulnerabilitiesToSummary(packages, 'low', true)
// Verify that concurrency limit (10) was respected
expect(maxConcurrent).toBeLessThanOrEqual(10)
// Verify all 15 unique advisories were fetched
expect(mockOctokitRequest).toHaveBeenCalledTimes(15)
})
test('addChangeVulnerabilitiesToSummary() - completes all tasks even with varying durations', async () => {
// Test that promise pool doesn't lose tasks when some complete faster than others
const packages = Array.from({length: 20}, (_, i) =>
createTestChange({
ecosystem: 'npm',
name: `package-${i}`,
version: '1.0.0',
vulnerabilities: [
createTestVulnerability({
advisory_ghsa_id: `GHSA-vary-${i.toString().padStart(4, '0')}`,
advisory_summary: `Vulnerability ${i}`,
severity: 'high'
})
]
})
)
const completedAdvisories = new Set<string>()
mockOctokitRequest.mockImplementation(
async (path: string, params: {ghsa_id: string}) => {
// Variable delay to simulate real-world API response times
const delay = Math.random() * 50
await new Promise(resolve => setTimeout(resolve, delay))
completedAdvisories.add(params.ghsa_id)
return {
data: {
vulnerabilities: [
{
package: {ecosystem: 'npm', name: 'test'},
vulnerable_version_range: '>= 1.0.0, < 2.0.0',
first_patched_version: '2.0.0'
}
]
}
}
}
)
await summary.addChangeVulnerabilitiesToSummary(packages, 'low', true)
// Verify all 20 unique advisories were fetched and completed
expect(completedAdvisories.size).toBe(20)
expect(mockOctokitRequest).toHaveBeenCalledTimes(20)
})
+3
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
Generated Vendored
+29328 -506
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+133 -101
View File
@@ -1,12 +1,12 @@
{
"name": "dependency-review-action",
"version": "4.8.3",
"version": "4.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dependency-review-action",
"version": "4.8.3",
"version": "4.9.0",
"license": "MIT",
"dependencies": {
"@actions/artifact": "^5.0.1",
@@ -20,6 +20,7 @@
"got": "^14.4.7",
"jest": "^29.7.0",
"octokit": "^3.1.2",
"semver": "^7.7.4",
"spdx-expression-parse": "^4.0.0",
"spdx-satisfies": "^6.0.0",
"ts-jest": "^29.4.1",
@@ -54,14 +55,14 @@
}
},
"node_modules/@actions/artifact": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@actions/artifact/-/artifact-5.0.1.tgz",
"integrity": "sha512-dHJ5rHduhCKUikKTT9eXeWoUvfKia3IjR1sO/VTAV3DVAL4yMTRnl2iO5mcfiBjySHLwPNezwENAVskKYU5ymw==",
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@actions/artifact/-/artifact-5.0.3.tgz",
"integrity": "sha512-FIEG8Kum0wABZnktJvFi1xuVPc31xrunhZwLCvjrCGISQOm0ifyo7cjqf6PHiEeqoWMa5HIGOsB+lGM4aKCseA==",
"license": "MIT",
"dependencies": {
"@actions/core": "^2.0.0",
"@actions/github": "^6.0.1",
"@actions/http-client": "^3.0.0",
"@actions/http-client": "^3.0.2",
"@azure/storage-blob": "^12.29.1",
"@octokit/core": "^5.2.1",
"@octokit/plugin-request-log": "^1.0.4",
@@ -94,13 +95,13 @@
}
},
"node_modules/@actions/artifact/node_modules/@actions/http-client": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.0.tgz",
"integrity": "sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.2.tgz",
"integrity": "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==",
"license": "MIT",
"dependencies": {
"tunnel": "^0.0.6",
"undici": "^5.28.5"
"undici": "^6.23.0"
}
},
"node_modules/@actions/artifact/node_modules/@actions/io": {
@@ -134,6 +135,15 @@
"@octokit/openapi-types": "^12.11.0"
}
},
"node_modules/@actions/artifact/node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/@actions/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
@@ -169,9 +179,10 @@
}
},
"node_modules/@actions/http-client": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.0.tgz",
"integrity": "sha512-q+epW0trjVUUHboliPb4UF9g2msf+w61b32tAkFEwL/IwP0DQWgbCMM0Hbe3e3WXSKz5VcUXbzJQgy8Hkra/Lg==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz",
"integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==",
"license": "MIT",
"dependencies": {
"tunnel": "^0.0.6",
"undici": "^5.25.4"
@@ -3026,10 +3037,11 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -3163,12 +3175,12 @@
}
},
"node_modules/archiver-utils/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -4634,22 +4646,21 @@
}
},
"node_modules/eslint-plugin-github/node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz",
"integrity": "sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw==",
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
"integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.4.0",
"@typescript-eslint/type-utils": "7.4.0",
"@typescript-eslint/utils": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0",
"debug": "^4.3.4",
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/type-utils": "7.18.0",
"@typescript-eslint/utils": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
"ts-api-utils": "^1.3.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -4669,15 +4680,16 @@
}
},
"node_modules/eslint-plugin-github/node_modules/@typescript-eslint/parser": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.4.0.tgz",
"integrity": "sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ==",
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz",
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "7.4.0",
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/typescript-estree": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0",
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/typescript-estree": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0",
"debug": "^4.3.4"
},
"engines": {
@@ -4697,13 +4709,14 @@
}
},
"node_modules/eslint-plugin-github/node_modules/@typescript-eslint/scope-manager": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz",
"integrity": "sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==",
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz",
"integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0"
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -4714,15 +4727,16 @@
}
},
"node_modules/eslint-plugin-github/node_modules/@typescript-eslint/type-utils": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.4.0.tgz",
"integrity": "sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw==",
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz",
"integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "7.4.0",
"@typescript-eslint/utils": "7.4.0",
"@typescript-eslint/typescript-estree": "7.18.0",
"@typescript-eslint/utils": "7.18.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
"ts-api-utils": "^1.3.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -4741,10 +4755,11 @@
}
},
"node_modules/eslint-plugin-github/node_modules/@typescript-eslint/types": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.4.0.tgz",
"integrity": "sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==",
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz",
"integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
@@ -4754,19 +4769,20 @@
}
},
"node_modules/eslint-plugin-github/node_modules/@typescript-eslint/typescript-estree": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz",
"integrity": "sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==",
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz",
"integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0",
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"minimatch": "9.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^1.3.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -4782,18 +4798,16 @@
}
},
"node_modules/eslint-plugin-github/node_modules/@typescript-eslint/utils": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.4.0.tgz",
"integrity": "sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg==",
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz",
"integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.4.0",
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/typescript-estree": "7.4.0",
"semver": "^7.5.4"
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/typescript-estree": "7.18.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -4807,13 +4821,14 @@
}
},
"node_modules/eslint-plugin-github/node_modules/@typescript-eslint/visitor-keys": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz",
"integrity": "sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==",
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz",
"integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.4.0",
"eslint-visitor-keys": "^3.4.1"
"@typescript-eslint/types": "7.18.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -4834,12 +4849,13 @@
}
},
"node_modules/eslint-plugin-github/node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -5251,10 +5267,22 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-xml-builder": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz",
"integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==",
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
"integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
"funding": [
{
"type": "github",
@@ -5263,6 +5291,7 @@
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.0.0",
"strnum": "^2.1.2"
},
"bin": {
@@ -5826,10 +5855,11 @@
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
"integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
@@ -7151,9 +7181,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
@@ -7319,9 +7349,10 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -8046,9 +8077,9 @@
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -8285,9 +8316,9 @@
}
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -8799,12 +8830,13 @@
}
},
"node_modules/ts-api-utils": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
"integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==",
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
"integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.13.0"
"node": ">=16"
},
"peerDependencies": {
"typescript": ">=4.2.0"
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dependency-review-action",
"version": "4.8.3",
"version": "4.9.0",
"private": true,
"description": "A GitHub Action for Dependency Review",
"main": "lib/main.js",
@@ -36,6 +36,7 @@
"got": "^14.4.7",
"jest": "^29.7.0",
"octokit": "^3.1.2",
"semver": "^7.7.4",
"spdx-expression-parse": "^4.0.0",
"spdx-satisfies": "^6.0.0",
"ts-jest": "^29.4.1",
+3 -2
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 = {
@@ -130,7 +131,7 @@ async function createSummary(
scorecard,
config
)
summary.addChangeVulnerabilitiesToSummary(
await summary.addChangeVulnerabilitiesToSummary(
vulnerabilities,
config.fail_on_severity
)
+3 -1
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(
+10 -3
View File
@@ -186,8 +186,11 @@ async function run(): Promise<void> {
)
// generate informational scorecard entries for all added changes in the PR
const scorecardChanges = getScorecardChanges(changes)
const scorecard = await getScorecardLevels(scorecardChanges)
let scorecard: Scorecard = {dependencies: []}
if (config.show_openssf_scorecard) {
const scorecardChanges = getScorecardChanges(changes)
scorecard = await getScorecardLevels(scorecardChanges)
}
const minSummary = summary.addSummaryToSummary(
vulnerableChanges,
@@ -205,7 +208,11 @@ async function run(): Promise<void> {
if (config.vulnerability_check) {
core.setOutput('vulnerable-changes', JSON.stringify(vulnerableChanges))
summary.addChangeVulnerabilitiesToSummary(vulnerableChanges, minSeverity)
await summary.addChangeVulnerabilitiesToSummary(
vulnerableChanges,
minSeverity,
config.show_patched_versions
)
issueFound ||= await printVulnerabilitiesBlock(
vulnerableChanges,
minSeverity,
+1
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(
+317 -19
View File
@@ -2,7 +2,14 @@ import * as core from '@actions/core'
import {SummaryTableRow} from '@actions/core/lib/summary'
import {InvalidLicenseChanges, InvalidLicenseChangeTypes} from './licenses'
import {Change, Changes, ConfigurationOptions, Scorecard} from './schemas'
import {groupDependenciesByManifest, getManifestsSet, renderUrl} from './utils'
import {
groupDependenciesByManifest,
getManifestsSet,
renderUrl,
octokitClient,
isEnterprise
} from './utils'
import * as semver from 'semver'
const icons = {
check: '✅',
@@ -11,6 +18,109 @@ const icons = {
}
const MAX_SCANNED_FILES_BYTES = 1048576
const API_CONCURRENCY_LIMIT = 10 // Limit concurrent API requests to avoid rate limiting
/**
* Helper to check if a version falls within a vulnerable range.
* Uses the `semver` library for proper prerelease handling and range parsing.
*
* @param version - The version to check (can be pre-trimmed).
* @param range - The version range to check against (can be pre-trimmed and/or pre-normalized).
* @param options - Configuration options.
* @param options.preTrimmed - If true, assumes inputs are already trimmed (optimization).
* @param options.preNormalized - If true, assumes range is already normalized (comma-to-space conversion done).
* @param options.failClosed - If true, returns true (vulnerable) on errors; if false, returns false (no match).
* @returns `true` if the version is considered within the vulnerable range (or on fail-closed), otherwise `false`.
*/
function versionInRange(
version: string | undefined,
range: string | undefined,
options: {
preTrimmed?: boolean
preNormalized?: boolean
failClosed?: boolean
} = {}
): boolean {
const {preTrimmed = false, preNormalized = false, failClosed = true} = options
// Trim inputs if not pre-trimmed
const trimmedVersion = preTrimmed ? version : version?.trim() || ''
const trimmedRange = preTrimmed ? range : range?.trim() || ''
if (!trimmedVersion) {
if (failClosed) {
core.debug(
`Empty or missing version for range "${range}". Treating as vulnerable (fail closed).`
)
}
return failClosed
}
if (!trimmedRange) {
if (failClosed) {
core.debug(
`Empty or missing version range for version "${version}". Treating as vulnerable (fail closed).`
)
}
return failClosed
}
// Convert GitHub API range format to semver-compatible format if not already normalized
// GitHub uses: ">= 8.0.0, <= 8.0.20"
// Semver accepts: ">= 8.0.0 <= 8.0.20" (operators may be followed by a space)
const semverRange = preNormalized
? trimmedRange
: trimmedRange.replace(/,\s*/g, ' ')
// Validate version and range explicitly to enforce fail-closed semantics
// semver.satisfies() typically returns false for invalid inputs without throwing
let validVersion = semver.valid(trimmedVersion)
const validRange = semver.validRange(semverRange)
// For fail-open mode (patch selection), try coercing invalid versions
// to handle common real-world formats like "8.0", date-based versions, etc.
if (!validVersion && !failClosed) {
const coerced = semver.coerce(trimmedVersion)
if (coerced) {
validVersion = coerced.version
core.debug(
`Coerced version "${trimmedVersion}" to "${validVersion}" for range matching`
)
}
}
if (!validVersion || !validRange) {
if (failClosed) {
const issues: string[] = []
if (!validVersion) issues.push('version')
if (!validRange) issues.push('version range')
core.debug(
`Invalid ${issues.join(' and ')}: version="${version}", range="${range}". Treating as vulnerable (fail closed).`
)
}
return failClosed
}
// Both version and range are valid; perform the satisfies check
// Only include prereleases when the version being checked is itself a prerelease
// to avoid changing range semantics globally
const isPrerelease = semver.prerelease(validVersion) !== null
return semver.satisfies(validVersion, validRange, {
includePrerelease: isPrerelease
})
}
function extractPatchVersionId(patchData: unknown): string | null {
// Handle string format (current API response)
if (typeof patchData === 'string') return patchData
// Handle object format with identifier field (for backward compatibility)
if (patchData && typeof patchData === 'object' && 'identifier' in patchData) {
const id = (patchData as {identifier: unknown}).identifier
return typeof id === 'string' ? id : null
}
return null
}
// generates the DR report summary and caches it to the Action's core.summary.
// returns the DR summary string, ready to be posted as a PR comment if the
@@ -132,21 +242,142 @@ function countScorecardWarnings(
)
}
export function addChangeVulnerabilitiesToSummary(
/**
* Execute promises with a concurrency limit to avoid overwhelming APIs.
* @param tasks - Array of functions that return promises
* @param limit - Maximum number of concurrent promises
*/
async function promisePool(
tasks: (() => Promise<void>)[],
limit: number
): Promise<void> {
const executing: Set<Promise<void>> = new Set()
for (const task of tasks) {
// Execute task and clean up
const wrappedPromise = (async () => {
await task()
})()
executing.add(wrappedPromise)
// When promise completes, remove it from the executing set
wrappedPromise.finally(() => {
executing.delete(wrappedPromise)
})
// Wait if we've hit the concurrency limit
if (executing.size >= limit) {
await Promise.race(executing)
}
}
// Wait for all remaining promises
await Promise.all(executing)
}
export async function addChangeVulnerabilitiesToSummary(
vulnerableChanges: Changes,
severity: string
): void {
severity: string,
showPatchedVersions = false
): Promise<void> {
if (vulnerableChanges.length === 0) {
return
}
const rows: SummaryTableRow[] = []
const manifests = getManifestsSet(vulnerableChanges)
// Build set of unique advisories to query
const advisorySet = new Set<string>()
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)
}
}
}
}
// Query GitHub API for patch info with concurrency limiting
// Store all vulnerability entries (may be multiple per package with different ranges)
// Pre-normalize ecosystem, package name, and range to avoid repeated work in rendering
const patchInfo: Record<
string,
{
eco: string
pkg: string
range: string
patch: string
ecoLower: string
pkgLower: string
normalizedRange: string
}[]
> = {}
const apiClient = octokitClient()
// Create tasks for promise pool
const tasks = Array.from(advisorySet).map(advId => async () => {
try {
core.debug(`Fetching advisory data for ${advId}`)
const apiResult = await apiClient.request('GET /advisories/{ghsa_id}', {
ghsa_id: advId
})
patchInfo[advId] = []
const vulnList = apiResult.data.vulnerabilities || []
core.debug(`Found ${vulnList.length} vulnerability entries for ${advId}`)
for (const v of vulnList) {
if (v.package && v.package.ecosystem) {
const normalizedEco = v.package.ecosystem.toLowerCase()
const pkgName = v.package.name || ''
const vulnRange = v.vulnerable_version_range || ''
const patchVerId = extractPatchVersionId(v.first_patched_version)
if (patchVerId) {
// Pre-normalize and cache values to avoid repeated work in rendering loop
const trimmedRange = vulnRange.trim()
const normalizedRange = trimmedRange.replace(/,\s*/g, ' ')
patchInfo[advId].push({
eco: normalizedEco,
pkg: pkgName,
range: vulnRange,
patch: patchVerId,
ecoLower: normalizedEco, // Ecosystem already normalized to lowercase
pkgLower: pkgName.toLowerCase(),
normalizedRange
})
core.debug(
`Added patch info for ${pkgName} (${normalizedEco}): ${patchVerId} for range ${vulnRange}`
)
} else {
core.debug(
`No patch version found for ${pkgName} (${normalizedEco}) in ${advId}`
)
}
}
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e)
core.debug(`API call failed for ${advId}: ${errorMessage}`)
patchInfo[advId] = []
}
})
// Execute API calls with concurrency limit
await promisePool(tasks, API_CONCURRENCY_LIMIT)
core.summary.addHeading('Vulnerabilities', 2)
for (const manifest of manifests) {
// Create fresh rows array for each manifest to avoid accumulation
const rows: SummaryTableRow[] = []
for (const change of vulnerableChanges.filter(
pkg => pkg.manifest === manifest
)) {
@@ -157,33 +388,100 @@ export function addChangeVulnerabilitiesToSummary(
previous_package === change.name &&
previous_version === change.version
// Look up patch version by matching package name, ecosystem, and version range
let patchVer = 'N/A'
const advisoryEntries = patchInfo[vuln.advisory_ghsa_id]
if (advisoryEntries && advisoryEntries.length > 0) {
const ecoLowercase = change.ecosystem.toLowerCase()
const packageLowercase = change.name.toLowerCase()
const normalizedChangeVersion = change.version.trim()
core.debug(
`Looking up patch for ${change.name}@${change.version} (${ecoLowercase}) in ${vuln.advisory_ghsa_id}`
)
// Find matching entry by ecosystem, package name (case-insensitive), and version range
// Use pre-normalized values from cache to avoid repeated lowercasing and range conversion
let foundEntry:
| {eco: string; pkg: string; range: string; patch: string}
| undefined
for (const vulnEntry of advisoryEntries) {
if (vulnEntry.ecoLower !== ecoLowercase) continue
if (vulnEntry.pkgLower !== packageLowercase) continue
// Use fail-open (failClosed: false) for patch selection to avoid
// incorrectly matching on invalid ranges
// Use preTrimmed and preNormalized optimizations since we've done both
const isInRange = versionInRange(
normalizedChangeVersion,
vulnEntry.normalizedRange,
{preTrimmed: true, preNormalized: true, failClosed: false}
)
if (isInRange) {
foundEntry = vulnEntry
break
}
}
if (foundEntry) {
patchVer = foundEntry.patch
core.debug(
`Found patch version ${patchVer} for ${change.name}@${change.version}`
)
} else {
const maxLoggedEntries = 5
const entriesPreview = advisoryEntries
.slice(0, maxLoggedEntries)
.map(
entry =>
`${entry.eco}:${entry.pkg} ${entry.range} -> ${entry.patch}`
)
core.debug(
`No matching patch found for ${change.name}@${change.version}. Available entries (showing up to ${Math.min(advisoryEntries.length, maxLoggedEntries)} of ${advisoryEntries.length}): ${entriesPreview.join('; ')}`
)
}
} else {
core.debug(`No advisory data available for ${vuln.advisory_ghsa_id}`)
}
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
])
]
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
])
]
if (showPatchedVersions) {
row.push(patchVer)
}
rows.push(row)
}
previous_package = change.name
previous_version = change.version
}
}
core.summary.addHeading(`<em>${manifest}</em>`, 4).addTable([
[
{data: 'Name', header: true},
{data: 'Version', header: true},
{data: 'Vulnerability', header: true},
{data: 'Severity', header: true}
],
...rows
])
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([headerRow, ...rows])
}
if (severity !== 'low') {
+1 -1
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'
)