diff --git a/.env.example b/.env.example index d2b965fa..a92ec2a4 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ LOCAL_GITHUB_ACCESS_TOKEN=XXXX +# INPUT_FETCH-DEPTH=0 diff --git a/action.yml b/action.yml index 61fde31d..46feb366 100644 --- a/action.yml +++ b/action.yml @@ -21,6 +21,10 @@ inputs: type: boolean description: 'If true, the action will not validate the user or the commit verification status' default: false + fetch-depth: + description: The number of vulnerability alerts to fetch, <= 0 for all + required: false + default: '0' outputs: dependency-names: description: 'A comma-separated list of all package names updated.' diff --git a/dist/index.js b/dist/index.js index 4938e9af..9157774e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -30707,7 +30707,7 @@ function calculateUpdateType(lastVersion, nextVersion) { /***/ }), /***/ 9180: -/***/ ((__unused_webpack_module, exports) => { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; @@ -30715,6 +30715,8 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.parseNwo = parseNwo; exports.getBranchNames = getBranchNames; exports.getBody = getBody; +exports.getNumberInput = getNumberInput; +const core_1 = __nccwpck_require__(7484); function parseNwo(nwo) { const [owner, name] = nwo.split('/'); if (!owner || !name) { @@ -30730,6 +30732,14 @@ function getBody(context) { const { pull_request: pr } = context.payload; return pr?.body || ''; } +function getNumberInput(inputName, defaultVal) { + const inputStr = (0, core_1.getInput)(inputName); + let num = Number.parseInt(inputStr); + if (Number.isNaN(num)) { + return defaultVal; + } + return num; +} /***/ }), @@ -30777,6 +30787,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getMessage = getMessage; +exports.createFetchVulnerabilityAlertsQuery = createFetchVulnerabilityAlertsQuery; exports.getAlert = getAlert; exports.trimSlashes = trimSlashes; exports.getCompatibility = getCompatibility; @@ -30819,35 +30830,82 @@ async function getMessage(client, context, skipCommitVerification = false, skipV } return commit.message; } -async function getAlert(name, version, directory, client, context) { - const alerts = await client.graphql(` - { - repository(owner: "${context.repo.owner}", name: "${context.repo.repo}") { - vulnerabilityAlerts(first: 100) { - nodes { - vulnerableManifestFilename - vulnerableManifestPath - vulnerableRequirements - state - securityVulnerability { - package { name } - } - securityAdvisory { +; +function createFetchVulnerabilityAlertsQuery(repoOwner, repoName, nResults = 100, endCursor) { + const first = nResults < 1 || nResults > 100 ? 100 : nResults; + return ` + { + repository(owner: "${repoOwner}", name: "${repoName}") { + vulnerabilityAlerts(first: ${first} ${endCursor ? ', after: "' + endCursor + '"' : ''}) { + nodes { + vulnerableManifestFilename + vulnerableManifestPath + vulnerableRequirements + state + securityVulnerability { + package { name } + } + securityAdvisory { cvss { score } - ghsaId - } - } - } - } - }`); - const nodes = alerts?.repository?.vulnerabilityAlerts?.nodes; - const found = nodes.find(a => (version === '' || a.vulnerableRequirements === `= ${version}`) && - trimSlashes(a.vulnerableManifestPath) === trimSlashes(`${directory}/${a.vulnerableManifestFilename}`) && - a.securityVulnerability.package.name === name); + ghsaId + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + }`; +} +function createFindAlertFunction(name, version, directory) { + return function (repoAlert) { + return ((version === "" || repoAlert.vulnerableRequirements === `${version}` || repoAlert.vulnerableRequirements === `= ${version}`) && + trimSlashes(repoAlert.vulnerableManifestPath) === trimSlashes(`${directory}/${repoAlert.vulnerableManifestFilename}`) && + repoAlert.securityVulnerability.package.name === name); + }; +} +async function fetchAndFilterVulnerabilityAlerts(client, repoOwner, repoName, fetchDepth, findFn, endCursor) { + let fetchedResults = 0; + while (true) { + core.debug(`Fetching vulnerability alerts for cursor ${endCursor ?? 'start'}`); + const query = createFetchVulnerabilityAlertsQuery(repoOwner, repoName, fetchDepth - fetchedResults, endCursor); + const result = await client.graphql(query); + const vulnerabilityAlerts = result.repository.vulnerabilityAlerts; + const nodes = vulnerabilityAlerts.nodes; + const found = nodes.find(findFn); + if (found) { + return found; + } + const pageInfo = vulnerabilityAlerts.pageInfo; + if (!pageInfo.hasNextPage) { + return undefined; + } + fetchedResults += nodes.length; + if (fetchDepth > 0 && fetchedResults >= fetchDepth) { + core.warning("Query has more results, but reached number of max results configured via fetch-depth"); + break; + } + endCursor = pageInfo.endCursor; + } + return undefined; +} +async function getAlert(name, version, directory, client, context, fetchDepth) { + const findFn = createFindAlertFunction(name, version, directory); + const repoAlert = await fetchAndFilterVulnerabilityAlerts(client, context.repo.owner, context.repo.repo, fetchDepth, findFn); + if (repoAlert) { + core.debug(`Found matching vulnerability alert`); + return { + alertState: repoAlert?.state ?? '', + ghsaId: repoAlert?.securityAdvisory.ghsaId ?? '', + cvss: repoAlert?.securityAdvisory.cvss.score ?? 0 + }; + } + core.debug(`Did not find matching vulnerability alert`); return { - alertState: found?.state ?? '', - ghsaId: found?.securityAdvisory.ghsaId ?? '', - cvss: found?.securityAdvisory.cvss.score ?? 0.0 + alertState: '', + ghsaId: '', + cvss: 0, }; } function trimSlashes(value) { @@ -30929,7 +30987,8 @@ async function run() { const body = util.getBody(github.context); let alertLookup; if (core.getInput('alert-lookup')) { - alertLookup = (name, version, directory) => verifiedCommits.getAlert(name, version, directory, githubClient, github.context); + const fetchDepth = util.getNumberInput('fetch-depth', 0); + alertLookup = (name, version, directory) => verifiedCommits.getAlert(name, version, directory, githubClient, github.context, fetchDepth); } const scoreLookup = core.getInput('compat-lookup') ? verifiedCommits.getCompatibility : undefined; if (commitMessage) { diff --git a/src/dependabot/util.ts b/src/dependabot/util.ts index 7a0a26e5..87fb324c 100644 --- a/src/dependabot/util.ts +++ b/src/dependabot/util.ts @@ -1,4 +1,5 @@ import { Context } from '@actions/github/lib/context' +import { getInput } from '@actions/core' export function parseNwo (nwo: string): {owner: string; repo: string} { const [owner, name] = nwo.split('/') @@ -24,3 +25,13 @@ export function getBody (context: Context): string { const { pull_request: pr } = context.payload return pr?.body || '' } + +export function getNumberInput (inputName: string, defaultVal: number): number; +export function getNumberInput (inputName: string, defaultVal?: number): number | undefined { + const inputStr = getInput(inputName); + let num = Number.parseInt(inputStr); + if (Number.isNaN(num)) { + return defaultVal; + } + return num; +} diff --git a/src/dependabot/verified_commits.test.ts b/src/dependabot/verified_commits.test.ts index 892dbf28..04469fbd 100644 --- a/src/dependabot/verified_commits.test.ts +++ b/src/dependabot/verified_commits.test.ts @@ -1,155 +1,331 @@ -import * as github from '@actions/github' -import * as core from '@actions/core' -import nock from 'nock' -import { Context } from '@actions/github/lib/context' -import { getAlert, getMessage, trimSlashes, getCompatibility } from './verified_commits' +import * as github from "@actions/github"; +import * as core from "@actions/core"; +import nock from "nock"; +import { Context } from "@actions/github/lib/context"; +import { + getAlert, + getMessage, + trimSlashes, + getCompatibility, + createFetchVulnerabilityAlertsQuery +} from "./verified_commits"; beforeAll(() => { - nock.disableNetConnect() -}) + nock.disableNetConnect(); +}); beforeEach(() => { - jest.restoreAllMocks() + jest.restoreAllMocks(); - jest.spyOn(core, 'debug').mockImplementation(jest.fn()) - jest.spyOn(core, 'warning').mockImplementation(jest.fn()) + jest.spyOn(core, "debug").mockImplementation(jest.fn()); + jest.spyOn(core, "warning").mockImplementation(jest.fn()); - process.env.GITHUB_REPOSITORY = 'dependabot/dependabot' -}) + process.env.GITHUB_REPOSITORY = "dependabot/dependabot"; +}); + +const defaultAlertFetchDepth = 0; -test('it returns false if the action is not invoked on a PullRequest', async () => { - expect(await getMessage(mockGitHubClient, mockGitHubOtherContext())).toBe(false) +test("it returns false if the action is not invoked on a PullRequest", async () => { + expect(await getMessage(mockGitHubClient, mockGitHubOtherContext())).toBe( + false + ); expect(core.warning).toHaveBeenCalledWith( - expect.stringContaining('Event payload missing `pull_request` key.') - ) -}) + expect.stringContaining("Event payload missing `pull_request` key.") + ); +}); -test('it returns false for an event triggered by someone other than Dependabot', async () => { - expect(await getMessage(mockGitHubClient, mockGitHubPullContext('jane-doe'))).toBe(false) +test("it returns false for an event triggered by someone other than Dependabot", async () => { + expect( + await getMessage(mockGitHubClient, mockGitHubPullContext("jane-doe")) + ).toBe(false); expect(core.debug).toHaveBeenCalledWith( expect.stringContaining("PR author 'jane-doe' is not Dependabot.") - ) -}) + ); +}); -test('it returns false if the commit was authored by someone other than Dependabot', async () => { - nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') +test("it returns false if the commit was authored by someone other than Dependabot", async () => { + nock("https://api.github.com") + .get("/repos/dependabot/dependabot/pulls/101/commits") .reply(200, [ { author: { - login: 'dependanot' + login: "dependanot", }, commit: { - message: 'Bump lodash from 1.0.0 to 2.0.0' - } - } - ]) + message: "Bump lodash from 1.0.0 to 2.0.0", + }, + }, + ]); - expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe(false) + expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe( + false + ); expect(core.warning).toHaveBeenCalledWith( - expect.stringContaining('It looks like this PR was not created by Dependabot, refusing to proceed.') - ) -}) - -test('it returns false if the commit is has no verification payload', async () => { - nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') + expect.stringContaining( + "It looks like this PR was not created by Dependabot, refusing to proceed." + ) + ); +}); + +test("it returns false if the commit is has no verification payload", async () => { + nock("https://api.github.com") + .get("/repos/dependabot/dependabot/pulls/101/commits") .reply(200, [ { author: { - login: 'dependabot[bot]' + login: "dependabot[bot]", }, commit: { - message: 'Bump lodash from 1.0.0 to 2.0.0', - verification: null - } - } - ]) + message: "Bump lodash from 1.0.0 to 2.0.0", + verification: null, + }, + }, + ]); - expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe(false) -}) + expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe( + false + ); +}); -test('it returns the message if the commit is has no verification payload but verification is skipped', async () => { - nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') +test("it returns the message if the commit is has no verification payload but verification is skipped", async () => { + nock("https://api.github.com") + .get("/repos/dependabot/dependabot/pulls/101/commits") .reply(200, [ { author: { - login: 'dependabot[bot]' + login: "dependabot[bot]", }, commit: { - message: 'Bump lodash from 1.0.0 to 2.0.0', - verification: null - } - } - ]) + message: "Bump lodash from 1.0.0 to 2.0.0", + verification: null, + }, + }, + ]); - expect(await getMessage(mockGitHubClient, mockGitHubPullContext(), true)).toEqual('Bump lodash from 1.0.0 to 2.0.0') -}) + expect( + await getMessage(mockGitHubClient, mockGitHubPullContext(), true) + ).toEqual("Bump lodash from 1.0.0 to 2.0.0"); +}); -test('it returns the message when skip-verification is enabled', async () => { - jest.spyOn(core, 'getInput').mockReturnValue('true') +test("it returns the message when skip-verification is enabled", async () => { + jest.spyOn(core, "getInput").mockReturnValue("true"); - nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') + nock("https://api.github.com") + .get("/repos/dependabot/dependabot/pulls/101/commits") .reply(200, [ { author: { - login: 'myUser' + login: "myUser", }, commit: { - message: 'Bump lodash from 1.0.0 to 2.0.0', - verification: false - } - } - ]) + message: "Bump lodash from 1.0.0 to 2.0.0", + verification: false, + }, + }, + ]); - expect(await getMessage(mockGitHubClient, mockGitHubPullContext(), false, true)).toEqual('Bump lodash from 1.0.0 to 2.0.0') -}) + expect( + await getMessage(mockGitHubClient, mockGitHubPullContext(), false, true) + ).toEqual("Bump lodash from 1.0.0 to 2.0.0"); +}); -test('it returns false if the commit is not verified', async () => { - nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') +test("it returns false if the commit is not verified", async () => { + nock("https://api.github.com") + .get("/repos/dependabot/dependabot/pulls/101/commits") .reply(200, [ { author: { - login: 'dependabot[bot]' + login: "dependabot[bot]", }, commit: { - message: 'Bump lodash from 1.0.0 to 2.0.0', + message: "Bump lodash from 1.0.0 to 2.0.0", verification: { - verified: false - } - } - } - ]) + verified: false, + }, + }, + }, + ]); - expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe(false) -}) + expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe( + false + ); +}); -test('it returns the commit message for a PR authored exclusively by Dependabot with verified commits', async () => { - nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') +test("it returns the commit message for a PR authored exclusively by Dependabot with verified commits", async () => { + nock("https://api.github.com") + .get("/repos/dependabot/dependabot/pulls/101/commits") .reply(200, [ { author: { - login: 'dependabot[bot]' + login: "dependabot[bot]", }, commit: { - message: 'Bump lodash from 1.0.0 to 2.0.0', + message: "Bump lodash from 1.0.0 to 2.0.0", verification: { - verified: true - } - } + verified: true, + }, + }, }, { commit: { - message: 'Add some more things.' + message: "Add some more things.", + }, + }, + ]); + + expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toEqual( + "Bump lodash from 1.0.0 to 2.0.0" + ); +}); + +test('createFetchVulnerabilityAlertsQuery', () => { + expect(createFetchVulnerabilityAlertsQuery("foo", "bar")).toEqual(` + { + repository(owner: "foo", name: "bar") { + vulnerabilityAlerts(first: 100 ) { + nodes { + vulnerableManifestFilename + vulnerableManifestPath + vulnerableRequirements + state + securityVulnerability { + package { name } + } + securityAdvisory { + cvss { score } + ghsaId + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + }`); +}) + +test('createFetchVulnerabilityAlertsQuery with maxResults', () => { + expect(createFetchVulnerabilityAlertsQuery("foo", "bar", 0)).toEqual(` + { + repository(owner: "foo", name: "bar") { + vulnerabilityAlerts(first: 100 ) { + nodes { + vulnerableManifestFilename + vulnerableManifestPath + vulnerableRequirements + state + securityVulnerability { + package { name } + } + securityAdvisory { + cvss { score } + ghsaId + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + }`); + + expect(createFetchVulnerabilityAlertsQuery("foo", "bar", 25)).toEqual(` + { + repository(owner: "foo", name: "bar") { + vulnerabilityAlerts(first: 25 ) { + nodes { + vulnerableManifestFilename + vulnerableManifestPath + vulnerableRequirements + state + securityVulnerability { + package { name } + } + securityAdvisory { + cvss { score } + ghsaId + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + }`); + + expect(createFetchVulnerabilityAlertsQuery("foo", "bar", 150)).toEqual(` + { + repository(owner: "foo", name: "bar") { + vulnerabilityAlerts(first: 100 ) { + nodes { + vulnerableManifestFilename + vulnerableManifestPath + vulnerableRequirements + state + securityVulnerability { + package { name } + } + securityAdvisory { + cvss { score } + ghsaId + } + } + pageInfo { + hasNextPage + endCursor + } } } - ]) + }`); +}) - expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toEqual('Bump lodash from 1.0.0 to 2.0.0') +test('createFetchVulnerabilityAlertsQuery with endCursor', () => { + expect(createFetchVulnerabilityAlertsQuery("foo", "bar", 0, "c123")).toEqual(` + { + repository(owner: "foo", name: "bar") { + vulnerabilityAlerts(first: 100 , after: "c123") { + nodes { + vulnerableManifestFilename + vulnerableManifestPath + vulnerableRequirements + state + securityVulnerability { + package { name } + } + securityAdvisory { + cvss { score } + ghsaId + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + }`); }) -const query = '{"query":"\\n {\\n repository(owner: \\"dependabot\\", name: \\"dependabot\\") { \\n vulnerabilityAlerts(first: 100) {\\n nodes {\\n vulnerableManifestFilename\\n vulnerableManifestPath\\n vulnerableRequirements\\n state\\n securityVulnerability { \\n package { name } \\n }\\n securityAdvisory { \\n cvss { score }\\n ghsaId \\n }\\n }\\n }\\n }\\n }"}' +/** + * Wraps the GraphQL query in a json object which would be sent over the wire. + * + * To get something readable from nock unmatched query error, you can do the opposite steps + * in order to get something readable, e.g. via Node REPL: + * let s = "unexpected_query" + * console.log(JSON.parse(s).query) + */ +function createGraphQlJsonBody(maxResults = 100, endCursor?: string): string { + const query = createFetchVulnerabilityAlertsQuery("dependabot", "dependabot", maxResults, endCursor); + return JSON.stringify({query}); +} + +const query = createGraphQlJsonBody(); const response = { data: { @@ -157,18 +333,21 @@ const response = { vulnerabilityAlerts: { nodes: [ { - vulnerableManifestFilename: 'package.json', - vulnerableManifestPath: 'wwwroot/package.json', - vulnerableRequirements: '= 4.0.1', - state: 'DISMISSED', - securityVulnerability: { package: { name: 'coffee-script' } }, - securityAdvisory: { cvss: { score: 4.5 }, ghsaId: 'FOO' } - } - ] - } - } - } -} + vulnerableManifestFilename: "package.json", + vulnerableManifestPath: "wwwroot/package.json", + vulnerableRequirements: "= 4.0.1", + state: "DISMISSED", + securityVulnerability: { package: { name: "coffee-script" } }, + securityAdvisory: { cvss: { score: 4.5 }, ghsaId: "FOO" }, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + }, +}; const responseWithManifestFileAtRoot = { data: { @@ -176,86 +355,389 @@ const responseWithManifestFileAtRoot = { vulnerabilityAlerts: { nodes: [ { - vulnerableManifestFilename: 'package.json', - vulnerableManifestPath: 'package.json', - vulnerableRequirements: '= 4.0.1', - state: 'DISMISSED', - securityVulnerability: { package: { name: 'coffee-script' } }, - securityAdvisory: { cvss: { score: 4.5 }, ghsaId: 'FOO' } - } - ] - } - } - } -} - -test('it returns the alert state if it matches all 3', async () => { - nock('https://api.github.com').post('/graphql', query) - .reply(200, response) - - expect(await getAlert('coffee-script', '4.0.1', '/wwwroot', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: 'DISMISSED', cvss: 4.5, ghsaId: 'FOO' }) - - nock('https://api.github.com').post('/graphql', query) - .reply(200, responseWithManifestFileAtRoot) - - expect(await getAlert('coffee-script', '4.0.1', '/', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: 'DISMISSED', cvss: 4.5, ghsaId: 'FOO' }) -}) - -test('it returns the alert state if it matches 2 and the version is blank', async () => { - nock('https://api.github.com').post('/graphql', query) - .reply(200, response) - - expect(await getAlert('coffee-script', '', '/wwwroot', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: 'DISMISSED', cvss: 4.5, ghsaId: 'FOO' }) - - nock('https://api.github.com').post('/graphql', query) - .reply(200, responseWithManifestFileAtRoot) - - expect(await getAlert('coffee-script', '', '/', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: 'DISMISSED', cvss: 4.5, ghsaId: 'FOO' }) -}) - -test('it returns default if it does not match the version', async () => { - nock('https://api.github.com').post('/graphql', query) - .reply(200, response) - - expect(await getAlert('coffee-script', '4.0.2', '/wwwroot', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: '', cvss: 0, ghsaId: '' }) - - nock('https://api.github.com').post('/graphql', query) - .reply(200, responseWithManifestFileAtRoot) - - expect(await getAlert('coffee-script', '4.0.2', '/', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: '', cvss: 0, ghsaId: '' }) -}) - -test('it returns default if it does not match the directory', async () => { - nock('https://api.github.com').post('/graphql', query) - .reply(200, response) - - expect(await getAlert('coffee-script', '4.0.1', '/', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: '', cvss: 0, ghsaId: '' }) - - nock('https://api.github.com').post('/graphql', query) - .reply(200, responseWithManifestFileAtRoot) - - expect(await getAlert('coffee-script', '4.0.1', '/wwwroot', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: '', cvss: 0, ghsaId: '' }) -}) - -test('it returns default if it does not match the name', async () => { - nock('https://api.github.com').post('/graphql', query) - .reply(200, response) - - expect(await getAlert('coffee', '4.0.1', '/wwwroot', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: '', cvss: 0, ghsaId: '' }) - - nock('https://api.github.com').post('/graphql', query) - .reply(200, responseWithManifestFileAtRoot) + vulnerableManifestFilename: "package.json", + vulnerableManifestPath: "package.json", + vulnerableRequirements: "= 4.0.1", + state: "DISMISSED", + securityVulnerability: { package: { name: "coffee-script" } }, + securityAdvisory: { cvss: { score: 4.5 }, ghsaId: "FOO" }, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + }, +}; + +test("it returns the alert state if it matches all 3", async () => { + nock("https://api.github.com").post("/graphql", query).reply(200, response); + + expect( + await getAlert( + "coffee-script", + "4.0.1", + "/wwwroot", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "DISMISSED", cvss: 4.5, ghsaId: "FOO" }); + + nock("https://api.github.com") + .post("/graphql", query) + .reply(200, responseWithManifestFileAtRoot); + + expect( + await getAlert( + "coffee-script", + "4.0.1", + "/", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "DISMISSED", cvss: 4.5, ghsaId: "FOO" }); +}); + +test("it returns the alert state if it matches 2 and the version is blank", async () => { + nock("https://api.github.com").post("/graphql", query).reply(200, response); + + expect( + await getAlert( + "coffee-script", + "", + "/wwwroot", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "DISMISSED", cvss: 4.5, ghsaId: "FOO" }); + + nock("https://api.github.com") + .post("/graphql", query) + .reply(200, responseWithManifestFileAtRoot); + + expect( + await getAlert( + "coffee-script", + "", + "/", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "DISMISSED", cvss: 4.5, ghsaId: "FOO" }); +}); + +test("it returns default if it does not match the version", async () => { + nock("https://api.github.com").post("/graphql", query).reply(200, response); + + expect( + await getAlert( + "coffee-script", + "4.0.2", + "/wwwroot", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "", cvss: 0, ghsaId: "" }); + + nock("https://api.github.com") + .post("/graphql", query) + .reply(200, responseWithManifestFileAtRoot); + + expect( + await getAlert( + "coffee-script", + "4.0.2", + "/", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "", cvss: 0, ghsaId: "" }); +}); + +test("it returns default if it does not match the directory", async () => { + nock("https://api.github.com").post("/graphql", query).reply(200, response); + + expect( + await getAlert( + "coffee-script", + "4.0.1", + "/", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "", cvss: 0, ghsaId: "" }); + + nock("https://api.github.com") + .post("/graphql", query) + .reply(200, responseWithManifestFileAtRoot); + + expect( + await getAlert( + "coffee-script", + "4.0.1", + "/wwwroot", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "", cvss: 0, ghsaId: "" }); +}); + +test("it returns default if it does not match the name", async () => { + nock("https://api.github.com").post("/graphql", query).reply(200, response); + + expect( + await getAlert( + "coffee", + "4.0.1", + "/wwwroot", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "", cvss: 0, ghsaId: "" }); + + nock("https://api.github.com") + .post("/graphql", query) + .reply(200, responseWithManifestFileAtRoot); + + expect( + await getAlert( + "coffee", + "4.0.1", + "/", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "", cvss: 0, ghsaId: "" }); +}); + +const responseFetchAllPage1 = { + data: { + repository: { + vulnerabilityAlerts: { + nodes: [ + { + vulnerableManifestFilename: "yarn.lock", + vulnerableManifestPath: "yarn.lock", + vulnerableRequirements: "= 4.17.11", + state: "FIXED", + securityVulnerability: { + package: { + name: "lodash", + }, + }, + securityAdvisory: { + cvss: { + score: 9.1, + }, + ghsaId: "GHSA-jf85-cpcp-j695", + }, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: "Y3Vyc29yOnYyOpHPAAAAAUU_eqA=", + }, + }, + }, + }, +}; - expect(await getAlert('coffee', '4.0.1', '/', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: '', cvss: 0, ghsaId: '' }) -}) +const queryFetchAllPage2 = createGraphQlJsonBody(defaultAlertFetchDepth, responseFetchAllPage1.data.repository.vulnerabilityAlerts.pageInfo.endCursor); -test('trimSlashes should only trim slashes from both ends', () => { - expect(trimSlashes('')).toEqual('') - expect(trimSlashes('///')).toEqual('') - expect(trimSlashes('/abc/')).toEqual('abc') - expect(trimSlashes('/a/b/c/')).toEqual('a/b/c') - expect(trimSlashes('//a//b//c//')).toEqual('a//b//c') -}) +const responseFetchAllPage2 = { + data: { + repository: { + vulnerabilityAlerts: { + nodes: [ + { + vulnerableManifestFilename: "yarn.lock", + vulnerableManifestPath: "yarn.lock", + vulnerableRequirements: "= 3.12.0", + state: "FIXED", + securityVulnerability: { package: { name: "js-yaml" } }, + securityAdvisory: { + cvss: { score: 0 }, + ghsaId: "GHSA-8j8c-7jfh-h6hx", + }, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: "Y3Vyc29yOnYyOpHOLxj2uQ==", + }, + }, + }, + }, +}; + +const queryFetchAllPage3 = createGraphQlJsonBody(defaultAlertFetchDepth, responseFetchAllPage2.data.repository.vulnerabilityAlerts.pageInfo.endCursor); + +test("fetch all vulnerability alert pages", async () => { + const queryFetchAllPage1 = query; + const responseFetchAllPage3 = response; + + nock("https://api.github.com") + .post("/graphql", queryFetchAllPage1) + .reply(200, responseFetchAllPage1) + .post("/graphql", queryFetchAllPage2) + .reply(200, responseFetchAllPage2) + .post("/graphql", queryFetchAllPage3) + .reply(200, responseFetchAllPage3); + + expect( + await getAlert( + "coffee-script", + "4.0.1", + "/wwwroot", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "DISMISSED", cvss: 4.5, ghsaId: "FOO" }); +}); + +test("fetch all vulnerability alert pages, match on page 2", async () => { + const queryFetchAllPage1 = query; + + nock("https://api.github.com") + .post("/graphql", queryFetchAllPage1) + .reply(200, responseFetchAllPage1) + .post("/graphql", queryFetchAllPage2) + .reply(200, responseFetchAllPage2) + .post("/graphql", queryFetchAllPage3) + .replyWithError("impl should not continue fetching this page"); + + expect( + await getAlert( + "js-yaml", + "3.12.0", + "/", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "FIXED", cvss: 0, ghsaId: "GHSA-8j8c-7jfh-h6hx" }); +}); + +test("fetch all vulnerability alerts, 3 pages, fetch-depth 2", async () => { + const queryFetch1 = createGraphQlJsonBody(2); + const queryFetch2 = createGraphQlJsonBody(1, responseFetchAllPage1.data.repository.vulnerabilityAlerts.pageInfo.endCursor); + const queryFetch3 = createGraphQlJsonBody(100, responseFetchAllPage2.data.repository.vulnerabilityAlerts.pageInfo.endCursor); + + nock("https://api.github.com") + .post("/graphql", queryFetch1) + .reply(200, responseFetchAllPage1) + .post("/graphql", queryFetch2) + .reply(200, responseFetchAllPage2) + .post("/graphql", queryFetch3) + .replyWithError("impl should not continue fetching this page"); + + expect( + await getAlert( + "coffee-script", + "4.0.1", + "/wwwroot", + mockGitHubClient, + mockGitHubPullContext(), + 2 + ) + ).toEqual({ alertState: "", cvss: 0, ghsaId: "" }); + + expect(core.warning).toHaveBeenCalledWith("Query has more results, but reached number of max results configured via fetch-depth"); +}); + +test("fetch all vulnerability alerts, 3 pages, fetch-depth 3", async () => { + const queryFetch1 = createGraphQlJsonBody(3); + const queryFetch2 = createGraphQlJsonBody(2, responseFetchAllPage1.data.repository.vulnerabilityAlerts.pageInfo.endCursor); + const queryFetch3 = createGraphQlJsonBody(1, responseFetchAllPage2.data.repository.vulnerabilityAlerts.pageInfo.endCursor); + const responseFetchAllPage3 = response; + + nock("https://api.github.com") + .post("/graphql", queryFetch1) + .reply(200, responseFetchAllPage1) + .post("/graphql", queryFetch2) + .reply(200, responseFetchAllPage2) + .post("/graphql", queryFetch3) + .reply(200, responseFetchAllPage3); + + expect( + await getAlert( + "coffee-script", + "4.0.1", + "/wwwroot", + mockGitHubClient, + mockGitHubPullContext(), + 3 + ) + ).toEqual({ alertState: "DISMISSED", cvss: 4.5, ghsaId: "FOO" }); +}); + +const responseWithoutEqInFrontOfVulnerableRequirements = { + data: { + repository: { + vulnerabilityAlerts: { + nodes: [ + { + vulnerableManifestFilename: "yarn.lock", + vulnerableManifestPath: "cypress/yarn.lock", + vulnerableRequirements: "4.4.0", + state: "OPEN", + securityVulnerability: { + package: { + name: "terser", + }, + }, + securityAdvisory: { + cvss: { + score: 7.5, + }, + ghsaId: "GHSA-4wf5-vphf-c2xc", + }, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + }, +}; + +test("it returns alert without eq in front of vulnerableRequirements", async () => { + nock("https://api.github.com") + .post("/graphql", query) + .reply(200, responseWithoutEqInFrontOfVulnerableRequirements); + + expect( + await getAlert( + "terser", + "4.4.0", + "/cypress", + mockGitHubClient, + mockGitHubPullContext(), + defaultAlertFetchDepth + ) + ).toEqual({ alertState: "OPEN", cvss: 7.5, ghsaId: "GHSA-4wf5-vphf-c2xc" }); +}); + +test("trimSlashes should only trim slashes from both ends", () => { + expect(trimSlashes("")).toEqual(""); + expect(trimSlashes("///")).toEqual(""); + expect(trimSlashes("/abc/")).toEqual("abc"); + expect(trimSlashes("/a/b/c/")).toEqual("a/b/c"); + expect(trimSlashes("//a//b//c//")).toEqual("a//b//c"); +}); const svgContents = ` compatibility: 75% @@ -276,58 +758,71 @@ const svgContents = `75% -` - -test('getCompatibility pulls out the score', async () => { - nock('https://dependabot-badges.githubapp.com').get('/badges/compatibility_score?dependency-name=coffee-script&package-manager=npm_and_yarn&previous-version=2.1.3&new-version=2.2.0') - .reply(200, svgContents) - - expect(await getCompatibility('coffee-script', '2.1.3', '2.2.0', 'npm_and_yarn')).toEqual(75) -}) - -test('getCompatibility fails gracefully', async () => { - nock('https://dependabot-badges.githubapp.com').get('/badges/compatibility_score?dependency-name=coffee-script&package-manager=npm_and_yarn&previous-version=2.1.3&new-version=2.2.0') - .reply(200, '') - - expect(await getCompatibility('coffee-script', '2.1.3', '2.2.0', 'npm_and_yarn')).toEqual(0) -}) - -test('getCompatibility handles errors', async () => { - nock('https://dependabot-badges.githubapp.com').get('/badges/compatibility_score?dependency-name=coffee-script&package-manager=npm_and_yarn&previous-version=2.1.3&new-version=2.2.0') - .reply(500, '') - - expect(await getCompatibility('coffee-script', '2.1.3', '2.2.0', 'npm_and_yarn')).toEqual(0) -}) - -// Use fetch request settings to ensure nock mocks are respected -// @see https://github.com/actions/toolkit/issues/1115#issuecomment-1826196208 -const mockGitHubClient = github.getOctokit('mock-token', { request: fetch }) - -function mockGitHubOtherContext (): Context { - const ctx = new Context() +`; + +test("getCompatibility pulls out the score", async () => { + nock("https://dependabot-badges.githubapp.com") + .get( + "/badges/compatibility_score?dependency-name=coffee-script&package-manager=npm_and_yarn&previous-version=2.1.3&new-version=2.2.0" + ) + .reply(200, svgContents); + + expect( + await getCompatibility("coffee-script", "2.1.3", "2.2.0", "npm_and_yarn") + ).toEqual(75); +}); + +test("getCompatibility fails gracefully", async () => { + nock("https://dependabot-badges.githubapp.com") + .get( + "/badges/compatibility_score?dependency-name=coffee-script&package-manager=npm_and_yarn&previous-version=2.1.3&new-version=2.2.0" + ) + .reply(200, ""); + + expect( + await getCompatibility("coffee-script", "2.1.3", "2.2.0", "npm_and_yarn") + ).toEqual(0); +}); + +test("getCompatibility handles errors", async () => { + nock("https://dependabot-badges.githubapp.com") + .get( + "/badges/compatibility_score?dependency-name=coffee-script&package-manager=npm_and_yarn&previous-version=2.1.3&new-version=2.2.0" + ) + .reply(500, ""); + + expect( + await getCompatibility("coffee-script", "2.1.3", "2.2.0", "npm_and_yarn") + ).toEqual(0); +}); + +const mockGitHubClient = github.getOctokit("mock-token"); + +function mockGitHubOtherContext(): Context { + const ctx = new Context(); ctx.payload = { issue: { - number: 100 - } - } - return ctx + number: 100, + }, + }; + return ctx; } -function mockGitHubPullContext (author = 'dependabot[bot]'): Context { - const ctx = new Context() +function mockGitHubPullContext(author = "dependabot[bot]"): Context { + const ctx = new Context(); ctx.payload = { pull_request: { number: 101, user: { - login: author - } + login: author, + }, }, repository: { - name: 'dependabot', + name: "dependabot", owner: { - login: 'dependabot' - } - } - } - return ctx + login: "dependabot", + }, + }, + }; + return ctx; } diff --git a/src/dependabot/verified_commits.ts b/src/dependabot/verified_commits.ts index 5cb33b8c..365394f5 100644 --- a/src/dependabot/verified_commits.ts +++ b/src/dependabot/verified_commits.ts @@ -56,37 +56,140 @@ export async function getMessage (client: InstanceType, context: return commit.message } -export async function getAlert (name: string, version: string, directory: string, client: InstanceType, context: Context): Promise { - const alerts: any = await client.graphql(` - { - repository(owner: "${context.repo.owner}", name: "${context.repo.repo}") { - vulnerabilityAlerts(first: 100) { - nodes { - vulnerableManifestFilename - vulnerableManifestPath - vulnerableRequirements - state - securityVulnerability { - package { name } - } - securityAdvisory { +/** + * @see https://docs.github.com/en/graphql/reference/objects#repositoryvulnerabilityalert + */ +interface RepositoryVulnerabilityAlert { + vulnerableManifestFilename: string; + vulnerableManifestPath: string; + vulnerableRequirements: string; + state: "OPEN" | "FIXED" | "DISMISSED"; + securityVulnerability: { + package: { + name: string; + }; + }; + securityAdvisory: { + cvss: { + score: number; + }; + ghsaId: string; + }; +} + +type CursorValue = string | null | undefined; + +interface PageInfoForward { + hasNextPage: boolean; + endCursor: CursorValue; +}; + +interface RepositoryVulnerabilityAlertsResult { + repository: { + vulnerabilityAlerts: { + nodes: RepositoryVulnerabilityAlert[]; + pageInfo: PageInfoForward + } + } +} + +export function createFetchVulnerabilityAlertsQuery(repoOwner: string, repoName: string, nResults: number = 100, endCursor?: CursorValue): string { + const first = nResults < 1 || nResults > 100 ? 100 : nResults; + return ` + { + repository(owner: "${repoOwner}", name: "${repoName}") { + vulnerabilityAlerts(first: ${first} ${endCursor ? ', after: "' + endCursor + '"' : ''}) { + nodes { + vulnerableManifestFilename + vulnerableManifestPath + vulnerableRequirements + state + securityVulnerability { + package { name } + } + securityAdvisory { cvss { score } - ghsaId - } - } - } - } - }`) - - const nodes = alerts?.repository?.vulnerabilityAlerts?.nodes - const found = nodes.find(a => (version === '' || a.vulnerableRequirements === `= ${version}`) && - trimSlashes(a.vulnerableManifestPath) === trimSlashes(`${directory}/${a.vulnerableManifestFilename}`) && - a.securityVulnerability.package.name === name) + ghsaId + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + }` +} + +type FindAlertFunction = (element: RepositoryVulnerabilityAlert) => boolean; + +function createFindAlertFunction(name: string, version: string, directory: string): FindAlertFunction { + return function (repoAlert: RepositoryVulnerabilityAlert) { + return ( + (version === "" || repoAlert.vulnerableRequirements === `${version}` || repoAlert.vulnerableRequirements === `= ${version}`) && + trimSlashes(repoAlert.vulnerableManifestPath) === trimSlashes(`${directory}/${repoAlert.vulnerableManifestFilename}`) && + repoAlert.securityVulnerability.package.name === name + ); + }; +} + +async function fetchAndFilterVulnerabilityAlerts( + client: InstanceType, + repoOwner: string, + repoName: string, + fetchDepth: number, + findFn: FindAlertFunction, + endCursor?: CursorValue +): Promise { + let fetchedResults = 0; + while (true) { + core.debug(`Fetching vulnerability alerts for cursor ${endCursor ?? 'start'}`); + const query = createFetchVulnerabilityAlertsQuery(repoOwner, repoName, fetchDepth - fetchedResults, endCursor); + const result: RepositoryVulnerabilityAlertsResult = await client.graphql(query); + + const vulnerabilityAlerts = result.repository.vulnerabilityAlerts; + const nodes = vulnerabilityAlerts.nodes; + const found = nodes.find(findFn); + + if (found) { + return found; + } + const pageInfo = vulnerabilityAlerts.pageInfo; + if (!pageInfo.hasNextPage) { + return undefined; + } + + fetchedResults += nodes.length; + if (fetchDepth > 0 && fetchedResults >= fetchDepth) { + core.warning("Query has more results, but reached number of max results configured via fetch-depth"); + break; + } + + endCursor = pageInfo.endCursor; + } + + return undefined; +} + +export async function getAlert (name: string, version: string, directory: string, client: InstanceType, context: Context, fetchDepth: number): Promise { + const findFn = createFindAlertFunction(name, version, directory); + + const repoAlert = await fetchAndFilterVulnerabilityAlerts(client, context.repo.owner, context.repo.repo, fetchDepth, findFn); + + if (repoAlert) { + core.debug(`Found matching vulnerability alert`); + return { + alertState: repoAlert?.state ?? '', + ghsaId: repoAlert?.securityAdvisory.ghsaId ?? '', + cvss: repoAlert?.securityAdvisory.cvss.score ?? 0 + } + } + core.debug(`Did not find matching vulnerability alert`); return { - alertState: found?.state ?? '', - ghsaId: found?.securityAdvisory.ghsaId ?? '', - cvss: found?.securityAdvisory.cvss.score ?? 0.0 + alertState: '', + ghsaId: '', + cvss: 0, } } diff --git a/src/dry-run.ts b/src/dry-run.ts index 1ff65d99..9022444a 100755 --- a/src/dry-run.ts +++ b/src/dry-run.ts @@ -7,7 +7,7 @@ import { hideBin } from 'yargs/helpers' import { getMessage, getAlert, getCompatibility } from './dependabot/verified_commits' import { parse } from './dependabot/update_metadata' -import { getBranchNames, parseNwo } from './dependabot/util' +import { getBranchNames, parseNwo, getNumberInput } from './dependabot/util' async function check (args: any): Promise { try { @@ -51,7 +51,8 @@ async function check (args: any): Promise { if (commitMessage) { console.log('This appears to be a valid Dependabot Pull Request.') const branchNames = getBranchNames(newContext) - const lookupFn = (name, version, directory) => getAlert(name, version, directory, githubClient, actionContext) + const fetchDepth = getNumberInput('fetch-depth', 0); + const lookupFn = (name, version, directory) => getAlert(name, version, directory, githubClient, actionContext, fetchDepth) const updatedDependencies = await parse(commitMessage, pullRequest.body, branchNames.headName, branchNames.baseName, lookupFn, getCompatibility) diff --git a/src/main.ts b/src/main.ts index 4cd488fa..2be1e0dd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,7 +27,8 @@ export async function run (): Promise { const body = util.getBody(github.context) let alertLookup: updateMetadata.alertLookup | undefined if (core.getInput('alert-lookup')) { - alertLookup = (name, version, directory) => verifiedCommits.getAlert(name, version, directory, githubClient, github.context) + const fetchDepth = util.getNumberInput('fetch-depth', 0); + alertLookup = (name, version, directory) => verifiedCommits.getAlert(name, version, directory, githubClient, github.context, fetchDepth) } const scoreLookup = core.getInput('compat-lookup') ? verifiedCommits.getCompatibility : undefined diff --git a/tsconfig.json b/tsconfig.json index e33ba755..badae245 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "rootDir": "./src", "strict": true, "noImplicitAny": false, - "esModuleInterop": true + "esModuleInterop": true, + "sourceMap": true }, "exclude": ["node_modules"] }