Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5b971718e | |||
| f51df6d455 | |||
| cffae74507 | |||
| 2031cfc080 | |||
| d02fa39f79 | |||
| 4038a34c4b | |||
| a632b8386b | |||
| 57a3d46a7b | |||
| 5ecdc4b578 | |||
| e8c2f9a12c | |||
| 0e129e113c | |||
| aa60746a92 | |||
| e404798400 | |||
| 24398f008e | |||
| 7863651912 | |||
| 17d14c08d9 | |||
| a6c34d8785 | |||
| 539c79be65 | |||
| ee66ea100d | |||
| 2af9bac14d | |||
| 1d60e0d095 |
@@ -0,0 +1,111 @@
|
||||
# Copilot Coding Agent Instructions
|
||||
|
||||
Trust these instructions. Only search the codebase if information here is incomplete or found to be in error.
|
||||
|
||||
## Repository Overview
|
||||
|
||||
**dependency-review-action** is a GitHub Action (TypeScript/Node.js 20) that scans pull requests for dependency changes, raising errors for vulnerabilities or invalid licenses. It queries the GitHub Dependency Review API, evaluates changes against configured rules, and produces job summaries and PR comments. The action entry point is `dist/index.js` (bundled via `ncc`). The repo is small (~15 source files, ~15 test files).
|
||||
|
||||
## Build & Validation Commands
|
||||
|
||||
For CI-parity installs and local validation, run `npm ci --ignore-scripts` before other commands. This is the install step used in CI; release workflows may follow different install instructions (see CONTRIBUTING).
|
||||
|
||||
| Task | Command | Notes |
|
||||
| ------------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Install | `npm ci --ignore-scripts` | ~45s. Use `--ignore-scripts` for CI-parity installs; release workflows may use `npm i` per CONTRIBUTING. |
|
||||
| Build | `npm run build` | Compiles `src/*.ts` → `lib/*.js` via `tsc -p tsconfig.build.json`. ~5s. |
|
||||
| Test | `npm test` | Runs Jest. ~8s. All tests should pass. |
|
||||
| Lint | `npm run lint` | ESLint on `src/**/*.ts`. Ignore the TS version warning—it still passes. |
|
||||
| Format check | `npm run format-check` | Prettier check on `**/*.ts`. |
|
||||
| Format fix | `npm run format` | Auto-fix formatting with Prettier. |
|
||||
| Package | `npm run package` | Bundles the action entrypoint (`package.json#main`) → `dist/index.js` via `ncc`. ~7s. Do NOT include `dist/` changes in non-release PRs. |
|
||||
| All | `npm run all` | Runs: build → format → lint → package → test (in that order). |
|
||||
|
||||
### Validation Sequence After Making Changes
|
||||
|
||||
Always run these commands in this order to validate changes:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run format-check
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
If format-check fails, run `npm run format` to auto-fix, then re-check.
|
||||
|
||||
### CI Checks (`.github/workflows/ci.yml`)
|
||||
|
||||
CI runs on PRs (excluding `**.md` changes) with Node 20:
|
||||
|
||||
1. **test** job: `npm ci --ignore-scripts` → `npm test`
|
||||
2. **lint** job: `npm ci --ignore-scripts` → `npm run format-check` → `npm run lint`
|
||||
|
||||
Additional workflows: `dependency-review.yml` (self-test), `codeql.yml` (CodeQL analysis), `stale.yaml` (stale issues).
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
src/ # TypeScript source (edit these files)
|
||||
main.ts # Entry point — orchestrates the action (532 lines)
|
||||
schemas.ts # Zod schemas & TypeScript types for all data structures
|
||||
config.ts # Reads action inputs + external YAML config
|
||||
dependency-graph.ts # GitHub API client for dependency diff
|
||||
filter.ts # Filters changes by severity, scope, allowed advisories
|
||||
licenses.ts # License validation against allow/deny lists
|
||||
deny.ts # Package/group deny-listing logic
|
||||
purl.ts # Package URL (PURL) parser
|
||||
spdx.ts # SPDX license expression handling
|
||||
scorecard.ts # OpenSSF Scorecard integration
|
||||
summary.ts # Summary/report generation (736 lines, largest module)
|
||||
comment-pr.ts # Posts/updates PR comments with results
|
||||
git-refs.ts # Resolves base/head git refs from event payload
|
||||
utils.ts # Shared utilities (Octokit client, grouping helpers)
|
||||
lib/ # Compiled JS output (from `npm run build`). Gitignored.
|
||||
dist/ # Bundled action (from `npm run package`). Committed but do NOT include changes in normal PRs - only pull requests which are creating new releases should have these files changed.
|
||||
__tests__/ # Jest test files (*.test.ts)
|
||||
test-helpers.ts # setInput()/clearInputs() helpers for test env vars
|
||||
fixtures/ # YAML config samples and factory helpers
|
||||
create-test-change.ts # Factory for mock Change objects
|
||||
create-test-vulnerability.ts # Factory for mock vulnerability objects
|
||||
scripts/ # Dev/debug utilities (scan_pr for manual testing, create_summary.ts for preview)
|
||||
action.yml # Action metadata — inputs, outputs, and `runs.main: dist/index.js`
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | ---------------------------------------------------------------------------- |
|
||||
| `tsconfig.json` | Base TypeScript config (ES6 target, CommonJS, strict mode) |
|
||||
| `tsconfig.build.json` | Build config — extends base, includes only `src/`, outputs to `lib/` |
|
||||
| `jest.config.js` | Jest config — uses `ts-jest`, matches `**/*.test.ts` |
|
||||
| `.eslintrc.json` | ESLint — `plugin:github/recommended`, strict TS rules, no semicolons |
|
||||
| `.prettierrc.json` | Prettier — no semis, single quotes, no bracket spacing, trailing comma: none |
|
||||
| `.prettierignore` | Ignores `dist/`, `lib/`, `node_modules/` |
|
||||
|
||||
### Key TypeScript/Style Rules
|
||||
|
||||
- No semicolons (enforced by ESLint and Prettier)
|
||||
- Single quotes, no trailing commas
|
||||
- `@typescript-eslint/no-explicit-any: error` — never use `any`
|
||||
- `@typescript-eslint/explicit-function-return-type: error` — all functions need return types (expressions exempt)
|
||||
- Unused function parameters/args must be prefixed with `_` (e.g. `_unused`); unused variables should be removed
|
||||
- Use Zod schemas (in `src/schemas.ts`) for all data validation and type definitions
|
||||
- Config option defaults belong in Zod schemas, NOT in `action.yml`
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
- Tests use Jest with `ts-jest` transform — no build step needed before running tests
|
||||
- Use `__tests__/test-helpers.ts` `setInput(name, value)` to mock action inputs (sets `INPUT_*` env vars)
|
||||
- Use `__tests__/fixtures/create-test-change.ts` and `create-test-vulnerability.ts` for test data factories
|
||||
- Test files follow `__tests__/<module>.test.ts` naming convention
|
||||
- Tests run directly against TypeScript source (not compiled JS)
|
||||
|
||||
### Important Notes
|
||||
|
||||
- The action runs on `node20` (declared in `action.yml`)
|
||||
- Source imports often use relative `../src/` paths (e.g. `import {readConfig} from '../src/config'`)
|
||||
- Adding a new action input requires changes in: `action.yml` (input definition), `src/schemas.ts` (Zod schema with default), `src/config.ts` (reading the input), and relevant source/test files
|
||||
- `dist/index.js` is committed for GitHub Actions but PR contributors should NOT include `dist/` changes — maintainers handle rebuilding
|
||||
- The `lib/` directory is gitignored
|
||||
- Scorecard tests make real HTTP calls to `api.securityscorecards.dev` and `deps.dev`
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ./
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
+29328
-506
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Generated
+133
-101
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user