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 = ``;
+
+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"]
}