Merge pull request #144 from pangaeatech/flag-security-alerts

Flag security alerts and pass versions through
This commit is contained in:
Barry Gordon
2022-02-22 19:47:39 +00:00
committed by GitHub
12 changed files with 460 additions and 91 deletions

View File

@@ -25,8 +25,17 @@ jobs:
uses: dependabot/fetch-metadata@v1.1.1
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
alert-lookup: true
```
Supported inputs are:
- `github-token` (REQUIRED string)
- The `GITHUB_TOKEN` secret
- `alert-lookup` (boolean)
- If `true`, then call populate the `alert-state`, `ghsa-id` and `cvss` outputs.
- Defaults to `false`
Subsequent actions will have access to the following outputs:
- `steps.dependabot-metadata.outputs.dependency-names`
@@ -43,6 +52,16 @@ Subsequent actions will have access to the following outputs:
- The `package-ecosystem` configuration that was used by dependabot for this updated Dependency.
- `steps.dependabot-metadata.outputs.target-branch`
- The `target-branch` configuration that was used by dependabot for this updated Dependency.
- `steps.dependabot-metadata.outputs.previous-version`
- The version that this PR updates the dependency from.
- `steps.dependabot-metadata.outputs.new-version`
- The version that this PR updates the dependency to.
- `steps.dependabot-metadata.outputs.alert-state`
- If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the current state of that alert (OPEN, FIXED or DISMISSED).
- `steps.dependabot-metadata.outputs.ghsa-id`
- If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the GHSA-ID of that alert.
- `steps.dependabot-metadata.outputs.cvss`
- If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the CVSS value of that alert (otherwise it contains 0).
**Note:** These outputs will only be populated if the target Pull Request was opened by Dependabot and contains
**only** Dependabot-created commits.

View File

@@ -4,6 +4,9 @@ branding:
icon: 'search'
color: 'blue'
inputs:
alert-lookup:
type: boolean
description: 'If true, then call populate the `alert-state`, `ghsa-id` and `cvss` outputs'
github-token:
description: 'The GITHUB_TOKEN secret'
required: true
@@ -22,6 +25,16 @@ outputs:
description: 'The `package-ecosystem` configuration that was used by dependabot for this updated Dependency.'
target-branch:
description: 'The `target-branch` configuration that was used by dependabot for this updated Dependency.'
previous-version:
description: 'The version that this PR updates the dependency from.'
new-version:
description: 'The version that this PR updates the dependency to.'
alert-state:
description: 'If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the current state of that alert (OPEN, FIXED or DISMISSED).'
ghsa-id:
description: 'If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the GHSA-ID of that alert.'
cvss:
description: 'If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the CVSS value of that alert (otherwise it contains 0).'
runs:
using: 'node12'
main: 'dist/index.js'

113
dist/index.js generated vendored
View File

@@ -8956,6 +8956,11 @@ function set(updatedDependencies) {
const directory = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.directory;
const ecosystem = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.packageEcosystem;
const target = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.targetBranch;
const prevVersion = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.prevVersion;
const newVersion = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.newVersion;
const alertState = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.alertState;
const ghsaId = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.ghsaId;
const cvss = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.cvss;
core.startGroup(`Outputting metadata for ${(0, pluralize_1.default)('updated dependency', updatedDependencies.length, true)}`);
core.info(`outputs.dependency-names: ${dependencyNames}`);
core.info(`outputs.dependency-type: ${dependencyType}`);
@@ -8963,6 +8968,11 @@ function set(updatedDependencies) {
core.info(`outputs.directory: ${directory}`);
core.info(`outputs.package-ecosystem: ${ecosystem}`);
core.info(`outputs.target-branch: ${target}`);
core.info(`outputs.previous-version: ${prevVersion}`);
core.info(`outputs.new-version: ${newVersion}`);
core.info(`outputs.alert-state: ${alertState}`);
core.info(`outputs.ghsa-id: ${ghsaId}`);
core.info(`outputs.cvss: ${cvss}`);
core.endGroup();
core.setOutput('updated-dependencies-json', updatedDependencies);
core.setOutput('dependency-names', dependencyNames);
@@ -8971,6 +8981,11 @@ function set(updatedDependencies) {
core.setOutput('directory', directory);
core.setOutput('package-ecosystem', ecosystem);
core.setOutput('target-branch', target);
core.setOutput('previous-version', prevVersion);
core.setOutput('new-version', newVersion);
core.setOutput('alert-state', alertState);
core.setOutput('ghsa-id', ghsaId);
core.setOutput('cvss', cvss);
}
exports.set = set;
function maxDependencyTypes(updatedDependencies) {
@@ -9015,31 +9030,40 @@ var __importStar = (this && this.__importStar) || function (mod) {
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.parse = void 0;
const YAML = __importStar(__nccwpck_require__(4603));
function parse(commitMessage, branchName, mainBranch) {
const yamlFragment = commitMessage.match(/^-{3}\n(?<dependencies>[\S|\s]*?)\n^\.{3}\n/m);
if ((yamlFragment === null || yamlFragment === void 0 ? void 0 : yamlFragment.groups) && branchName.startsWith('dependabot')) {
const data = YAML.parse(yamlFragment.groups.dependencies);
// Since we are on the `dependabot` branch (9 letters), the 10th letter in the branch name is the delimiter
const delim = branchName[10];
const chunks = branchName.split(delim);
const dirname = chunks.slice(2, -1).join(delim) || '/';
if (data['updated-dependencies']) {
return data['updated-dependencies'].map(dependency => {
return {
dependencyName: dependency['dependency-name'],
dependencyType: dependency['dependency-type'],
updateType: dependency['update-type'],
directory: dirname,
packageEcosystem: chunks[1],
targetBranch: mainBranch
};
});
function parse(commitMessage, branchName, mainBranch, lookup) {
var _a, _b, _c, _d;
return __awaiter(this, void 0, void 0, function* () {
const bumpFragment = commitMessage.match(/^Bumps .* from (?<from>\d[^ ]*) to (?<to>\d[^ ]*)\.$/m);
const yamlFragment = commitMessage.match(/^-{3}\n(?<dependencies>[\S|\s]*?)\n^\.{3}\n/m);
const lookupFn = lookup !== null && lookup !== void 0 ? lookup : (() => Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 }));
if ((yamlFragment === null || yamlFragment === void 0 ? void 0 : yamlFragment.groups) && branchName.startsWith('dependabot')) {
const data = YAML.parse(yamlFragment.groups.dependencies);
// Since we are on the `dependabot` branch (9 letters), the 10th letter in the branch name is the delimiter
const delim = branchName[10];
const chunks = branchName.split(delim);
const prev = (_b = (_a = bumpFragment === null || bumpFragment === void 0 ? void 0 : bumpFragment.groups) === null || _a === void 0 ? void 0 : _a.from) !== null && _b !== void 0 ? _b : '';
const next = (_d = (_c = bumpFragment === null || bumpFragment === void 0 ? void 0 : bumpFragment.groups) === null || _c === void 0 ? void 0 : _c.to) !== null && _d !== void 0 ? _d : '';
if (data['updated-dependencies']) {
return yield Promise.all(data['updated-dependencies'].map((dependency, index) => __awaiter(this, void 0, void 0, function* () {
const dirname = `/${chunks.slice(2, -1 * (1 + (dependency['dependency-name'].match(/\//g) || []).length)).join(delim) || ''}`;
return Object.assign({ dependencyName: dependency['dependency-name'], dependencyType: dependency['dependency-type'], updateType: dependency['update-type'], directory: dirname, packageEcosystem: chunks[1], targetBranch: mainBranch, prevVersion: index === 0 ? prev : '', newVersion: index === 0 ? next : '' }, yield lookupFn(dependency['dependency-name'], index === 0 ? prev : '', dirname));
})));
}
}
}
return [];
return Promise.resolve([]);
});
}
exports.parse = parse;
@@ -9104,7 +9128,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
});
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.getMessage = void 0;
exports.trimSlashes = exports.getAlert = exports.getMessage = void 0;
const core = __importStar(__nccwpck_require__(2186));
const DEPENDABOT_LOGIN = 'dependabot[bot]';
function getMessage(client, context) {
@@ -9151,6 +9175,45 @@ function warnOtherCommits() {
"Try using '@dependabot rebase' to remove merge commits or '@dependabot recreate' to remove " +
'any non-Dependabot changes.');
}
function getAlert(name, version, directory, client, context) {
var _a, _b, _c, _d, _e;
return __awaiter(this, void 0, void 0, function* () {
const alerts = yield client.graphql(`
{
repository(owner: "${context.repo.owner}", name: "${context.repo.repo}") {
vulnerabilityAlerts(first: 100) {
nodes {
vulnerableManifestFilename
vulnerableManifestPath
vulnerableRequirements
state
securityVulnerability {
package { name }
}
securityAdvisory {
cvss { score }
ghsaId
}
}
}
}
}`);
const nodes = (_b = (_a = alerts === null || alerts === void 0 ? void 0 : alerts.repository) === null || _a === void 0 ? void 0 : _a.vulnerabilityAlerts) === null || _b === void 0 ? void 0 : _b.nodes;
const found = nodes.find(a => (version === '' || a.vulnerableRequirements === `= ${version}`) &&
trimSlashes(a.vulnerableManifestPath) === `${trimSlashes(directory)}/${a.vulnerableManifestFilename}` &&
a.securityVulnerability.package.name === name);
return {
alertState: (_c = found === null || found === void 0 ? void 0 : found.state) !== null && _c !== void 0 ? _c : '',
ghsaId: (_d = found === null || found === void 0 ? void 0 : found.securityAdvisory.ghsaId) !== null && _d !== void 0 ? _d : '',
cvss: (_e = found === null || found === void 0 ? void 0 : found.securityAdvisory.cvss.score) !== null && _e !== void 0 ? _e : 0.0
};
});
}
exports.getAlert = getAlert;
function trimSlashes(value) {
return value.replace(/^\/+/, '').replace(/\/+$/, '');
}
exports.trimSlashes = trimSlashes;
/***/ }),
@@ -9211,10 +9274,14 @@ function run() {
// Validate the job
const commitMessage = yield verifiedCommits.getMessage(githubClient, github.context);
const branchNames = util.getBranchNames(github.context);
let alertLookup;
if (core.getInput('alert-lookup')) {
alertLookup = (name, version, directory) => verifiedCommits.getAlert(name, version, directory, githubClient, github.context);
}
if (commitMessage) {
// Parse metadata
core.info('Parsing Dependabot metadata');
const updatedDependencies = updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName);
const updatedDependencies = yield updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName, alertLookup);
if (updatedDependencies.length > 0) {
output.set(updatedDependencies);
}

View File

@@ -9,6 +9,20 @@ beforeEach(() => {
jest.spyOn(core, 'startGroup').mockImplementation(jest.fn())
})
const baseDependency = {
dependencyName: '',
dependencyType: '',
updateType: '',
directory: '',
packageEcosystem: '',
targetBranch: '',
prevVersion: '',
newVersion: '',
alertState: '',
ghsaId: '',
cvss: 0
}
test('when given a single dependency it sets its values', async () => {
const updatedDependencies = [
{
@@ -17,7 +31,12 @@ test('when given a single dependency it sets its values', async () => {
updateType: 'version-update:semver-minor',
directory: 'wwwroot',
packageEcosystem: 'nuget',
targetBranch: 'main'
targetBranch: 'main',
prevVersion: '1.0.2',
newVersion: '1.1.3-beta',
alertState: 'FIXED',
ghsaId: 'VERY_LONG_ID',
cvss: 4.6
}
]
@@ -35,41 +54,38 @@ test('when given a single dependency it sets its values', async () => {
expect(core.setOutput).toBeCalledWith('directory', 'wwwroot')
expect(core.setOutput).toBeCalledWith('package-ecosystem', 'nuget')
expect(core.setOutput).toBeCalledWith('target-branch', 'main')
expect(core.setOutput).toBeCalledWith('previous-version', '1.0.2')
expect(core.setOutput).toBeCalledWith('new-version', '1.1.3-beta')
expect(core.setOutput).toBeCalledWith('alert-state', 'FIXED')
expect(core.setOutput).toBeCalledWith('ghsa-id', 'VERY_LONG_ID')
expect(core.setOutput).toBeCalledWith('cvss', 4.6)
})
test('when given a multiple dependencies, it uses the highest values for types', async () => {
const updatedDependencies = [
{
...baseDependency,
dependencyName: 'rspec',
dependencyType: 'direct:development',
updateType: 'version-update:semver-minor',
directory: '',
packageEcosystem: '',
targetBranch: ''
updateType: 'version-update:semver-minor'
},
{
...baseDependency,
dependencyName: 'coffee-rails',
dependencyType: 'indirect',
updateType: 'version-update:semver-minor',
directory: '',
packageEcosystem: '',
targetBranch: ''
updateType: 'version-update:semver-minor'
},
{
...baseDependency,
dependencyName: 'coffeescript',
dependencyType: 'indirect',
updateType: 'version-update:semver-major',
directory: '',
packageEcosystem: '',
targetBranch: ''
updateType: 'version-update:semver-major'
},
{
...baseDependency,
dependencyName: 'rspec-coffeescript',
dependencyType: 'indirect',
updateType: 'version-update:semver-patch',
directory: '',
packageEcosystem: '',
targetBranch: ''
updateType: 'version-update:semver-patch'
}
]
@@ -83,17 +99,19 @@ test('when given a multiple dependencies, it uses the highest values for types',
expect(core.setOutput).toBeCalledWith('directory', '')
expect(core.setOutput).toBeCalledWith('package-ecosystem', '')
expect(core.setOutput).toBeCalledWith('target-branch', '')
expect(core.setOutput).toBeCalledWith('previous-version', '')
expect(core.setOutput).toBeCalledWith('new-version', '')
expect(core.setOutput).toBeCalledWith('alert-state', '')
expect(core.setOutput).toBeCalledWith('ghsa-id', '')
expect(core.setOutput).toBeCalledWith('cvss', 0)
})
test('when the dependency has no update type', async () => {
const updatedDependencies = [
{
...baseDependency,
dependencyName: 'coffee-rails',
dependencyType: 'direct:production',
updateType: '',
directory: '',
packageEcosystem: '',
targetBranch: ''
dependencyType: 'direct:production'
}
]
@@ -111,41 +129,36 @@ test('when the dependency has no update type', async () => {
expect(core.setOutput).toBeCalledWith('directory', '')
expect(core.setOutput).toBeCalledWith('package-ecosystem', '')
expect(core.setOutput).toBeCalledWith('target-branch', '')
expect(core.setOutput).toBeCalledWith('previous-version', '')
expect(core.setOutput).toBeCalledWith('new-version', '')
expect(core.setOutput).toBeCalledWith('alert-state', '')
expect(core.setOutput).toBeCalledWith('ghsa-id', '')
expect(core.setOutput).toBeCalledWith('cvss', 0)
})
test('when given a multiple dependencies, and some do not have update types', async () => {
const updatedDependencies = [
{
...baseDependency,
dependencyName: 'rspec',
dependencyType: 'direct:development',
updateType: '',
directory: '',
packageEcosystem: '',
targetBranch: ''
dependencyType: 'direct:development'
},
{
...baseDependency,
dependencyName: 'coffee-rails',
dependencyType: 'indirect',
updateType: 'version-update:semver-minor',
directory: '',
packageEcosystem: '',
targetBranch: ''
updateType: 'version-update:semver-minor'
},
{
...baseDependency,
dependencyName: 'coffeescript',
dependencyType: 'indirect',
updateType: '',
directory: '',
packageEcosystem: '',
targetBranch: ''
dependencyType: 'indirect'
},
{
...baseDependency,
dependencyName: 'rspec-coffeescript',
dependencyType: 'indirect',
updateType: 'version-update:semver-patch',
directory: '',
packageEcosystem: '',
targetBranch: ''
updateType: 'version-update:semver-patch'
}
]
@@ -159,4 +172,9 @@ test('when given a multiple dependencies, and some do not have update types', as
expect(core.setOutput).toBeCalledWith('directory', '')
expect(core.setOutput).toBeCalledWith('package-ecosystem', '')
expect(core.setOutput).toBeCalledWith('target-branch', '')
expect(core.setOutput).toBeCalledWith('previous-version', '')
expect(core.setOutput).toBeCalledWith('new-version', '')
expect(core.setOutput).toBeCalledWith('alert-state', '')
expect(core.setOutput).toBeCalledWith('ghsa-id', '')
expect(core.setOutput).toBeCalledWith('cvss', 0)
})

View File

@@ -24,6 +24,11 @@ export function set (updatedDependencies: Array<updatedDependency>): void {
const directory = firstDependency?.directory
const ecosystem = firstDependency?.packageEcosystem
const target = firstDependency?.targetBranch
const prevVersion = firstDependency?.prevVersion
const newVersion = firstDependency?.newVersion
const alertState = firstDependency?.alertState
const ghsaId = firstDependency?.ghsaId
const cvss = firstDependency?.cvss
core.startGroup(`Outputting metadata for ${Pluralize('updated dependency', updatedDependencies.length, true)}`)
core.info(`outputs.dependency-names: ${dependencyNames}`)
@@ -32,6 +37,11 @@ export function set (updatedDependencies: Array<updatedDependency>): void {
core.info(`outputs.directory: ${directory}`)
core.info(`outputs.package-ecosystem: ${ecosystem}`)
core.info(`outputs.target-branch: ${target}`)
core.info(`outputs.previous-version: ${prevVersion}`)
core.info(`outputs.new-version: ${newVersion}`)
core.info(`outputs.alert-state: ${alertState}`)
core.info(`outputs.ghsa-id: ${ghsaId}`)
core.info(`outputs.cvss: ${cvss}`)
core.endGroup()
core.setOutput('updated-dependencies-json', updatedDependencies)
@@ -41,6 +51,11 @@ export function set (updatedDependencies: Array<updatedDependency>): void {
core.setOutput('directory', directory)
core.setOutput('package-ecosystem', ecosystem)
core.setOutput('target-branch', target)
core.setOutput('previous-version', prevVersion)
core.setOutput('new-version', newVersion)
core.setOutput('alert-state', alertState)
core.setOutput('ghsa-id', ghsaId)
core.setOutput('cvss', cvss)
}
function maxDependencyTypes (updatedDependencies: Array<updatedDependency>): string {

View File

@@ -1,7 +1,8 @@
import * as updateMetadata from './update_metadata'
test('it returns an empty array for a blank string', async () => {
expect(updateMetadata.parse('', 'dependabot/nuget/feature1', 'main')).toEqual([])
const getAlert = async () => Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 })
expect(updateMetadata.parse('', 'dependabot/nuget/coffee-rails', 'main', getAlert)).resolves.toEqual([])
})
test('it returns an empty array for commit message with no dependabot yaml fragment', async () => {
@@ -12,7 +13,8 @@ test('it returns an empty array for commit message with no dependabot yaml fragm
Signed-off-by: dependabot[bot] <support@github.com>`
expect(updateMetadata.parse(commitMessage, 'dependabot/nuget/feature1', 'main')).toEqual([])
const getAlert = async () => Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 })
expect(updateMetadata.parse(commitMessage, 'dependabot/nuget/coffee-rails', 'main', getAlert)).resolves.toEqual([])
})
test('it returns the updated dependency information when there is a yaml fragment', async () => {
@@ -31,7 +33,8 @@ test('it returns the updated dependency information when there is a yaml fragmen
'\n' +
'Signed-off-by: dependabot[bot] <support@github.com>'
const updatedDependencies = updateMetadata.parse(commitMessage, 'dependabot/nuget/feature1', 'main')
const getAlert = async () => Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 })
const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/coffee-rails', 'main', getAlert)
expect(updatedDependencies).toHaveLength(1)
@@ -41,6 +44,11 @@ test('it returns the updated dependency information when there is a yaml fragmen
expect(updatedDependencies[0].directory).toEqual('/')
expect(updatedDependencies[0].packageEcosystem).toEqual('nuget')
expect(updatedDependencies[0].targetBranch).toEqual('main')
expect(updatedDependencies[0].prevVersion).toEqual('4.0.1')
expect(updatedDependencies[0].newVersion).toEqual('4.2.2')
expect(updatedDependencies[0].alertState).toEqual('DISMISSED')
expect(updatedDependencies[0].ghsaId).toEqual('GHSA-III-BBB')
expect(updatedDependencies[0].cvss).toEqual(4.6)
})
test('it supports multiple dependencies within a single fragment', async () => {
@@ -62,28 +70,45 @@ test('it supports multiple dependencies within a single fragment', async () => {
'\n' +
'Signed-off-by: dependabot[bot] <support@github.com>'
const updatedDependencies = updateMetadata.parse(commitMessage, 'dependabot/nuget/api/main/feature1', 'main')
const getAlert = async (name: string) => {
if (name === 'coffee-rails') {
return Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 })
}
return Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 })
}
const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/api/main/coffee-rails', 'main', getAlert)
expect(updatedDependencies).toHaveLength(2)
expect(updatedDependencies[0].dependencyName).toEqual('coffee-rails')
expect(updatedDependencies[0].dependencyType).toEqual('direct:production')
expect(updatedDependencies[0].updateType).toEqual('version-update:semver-minor')
expect(updatedDependencies[0].directory).toEqual('api/main')
expect(updatedDependencies[0].directory).toEqual('/api/main')
expect(updatedDependencies[0].packageEcosystem).toEqual('nuget')
expect(updatedDependencies[0].targetBranch).toEqual('main')
expect(updatedDependencies[0].prevVersion).toEqual('4.0.1')
expect(updatedDependencies[0].newVersion).toEqual('4.2.2')
expect(updatedDependencies[0].alertState).toEqual('DISMISSED')
expect(updatedDependencies[0].ghsaId).toEqual('GHSA-III-BBB')
expect(updatedDependencies[0].cvss).toEqual(4.6)
expect(updatedDependencies[1].dependencyName).toEqual('coffeescript')
expect(updatedDependencies[1].dependencyType).toEqual('indirect')
expect(updatedDependencies[1].updateType).toEqual('version-update:semver-patch')
expect(updatedDependencies[1].directory).toEqual('api/main')
expect(updatedDependencies[1].directory).toEqual('/api/main')
expect(updatedDependencies[1].packageEcosystem).toEqual('nuget')
expect(updatedDependencies[1].targetBranch).toEqual('main')
expect(updatedDependencies[1].prevVersion).toEqual('')
expect(updatedDependencies[1].newVersion).toEqual('')
expect(updatedDependencies[1].alertState).toEqual('')
expect(updatedDependencies[1].ghsaId).toEqual('')
expect(updatedDependencies[1].cvss).toEqual(0)
})
test('it only returns information within the first fragment if there are multiple yaml documents', async () => {
const commitMessage =
'Bumps [coffee-rails](https://github.com/rails/coffee-rails) from 4.0.1 to 4.2.2.\n' +
'- [Release notes](https://github.com/rails/coffee-rails/releases)\n' +
'- [Changelog](https://github.com/rails/coffee-rails/blob/master/CHANGELOG.md)\n' +
'- [Commits](rails/coffee-rails@v4.0.1...v4.2.2)\n' +
@@ -104,14 +129,52 @@ test('it only returns information within the first fragment if there are multipl
'\n' +
'Signed-off-by: dependabot[bot] <support@github.com>'
const updatedDependencies = updateMetadata.parse(commitMessage, 'dependabot|nuget|api|feature1', 'main')
const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot|nuget|coffee-rails', 'main', undefined)
expect(updatedDependencies).toHaveLength(1)
expect(updatedDependencies[0].dependencyName).toEqual('coffee-rails')
expect(updatedDependencies[0].dependencyType).toEqual('direct:production')
expect(updatedDependencies[0].updateType).toEqual('version-update:semver-minor')
expect(updatedDependencies[0].directory).toEqual('api')
expect(updatedDependencies[0].directory).toEqual('/')
expect(updatedDependencies[0].packageEcosystem).toEqual('nuget')
expect(updatedDependencies[0].targetBranch).toEqual('main')
expect(updatedDependencies[0].prevVersion).toEqual('')
expect(updatedDependencies[0].newVersion).toEqual('')
expect(updatedDependencies[0].alertState).toEqual('')
expect(updatedDependencies[0].ghsaId).toEqual('')
expect(updatedDependencies[0].cvss).toEqual(0)
})
test('it properly handles dependencies which contain slashes', async () => {
const commitMessage =
'- [Release notes](https://github.com/rails/coffee/releases)\n' +
'- [Changelog](https://github.com/rails/coffee/blob/master/CHANGELOG.md)\n' +
'- [Commits](rails/coffee@v4.0.1...v4.2.2)\n' +
'\n' +
'---\n' +
'updated-dependencies:\n' +
'- dependency-name: rails/coffee\n' +
' dependency-type: direct:production\n' +
' update-type: version-update:semver-minor\n' +
'...\n' +
'\n' +
'Signed-off-by: dependabot[bot] <support@github.com>'
const getAlert = async () => Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 })
const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/api/rails/coffee', 'main', getAlert)
expect(updatedDependencies).toHaveLength(1)
expect(updatedDependencies[0].dependencyName).toEqual('rails/coffee')
expect(updatedDependencies[0].dependencyType).toEqual('direct:production')
expect(updatedDependencies[0].updateType).toEqual('version-update:semver-minor')
expect(updatedDependencies[0].directory).toEqual('/api')
expect(updatedDependencies[0].packageEcosystem).toEqual('nuget')
expect(updatedDependencies[0].targetBranch).toEqual('main')
expect(updatedDependencies[0].prevVersion).toEqual('')
expect(updatedDependencies[0].newVersion).toEqual('')
expect(updatedDependencies[0].alertState).toEqual('')
expect(updatedDependencies[0].ghsaId).toEqual('')
expect(updatedDependencies[0].cvss).toEqual(0)
})

View File

@@ -1,16 +1,30 @@
import * as YAML from 'yaml'
export interface updatedDependency {
export interface dependencyAlert {
alertState: string,
ghsaId: string,
cvss: number
}
export interface updatedDependency extends dependencyAlert {
dependencyName: string,
dependencyType: string,
updateType: string,
directory: string,
packageEcosystem: string,
targetBranch: string
targetBranch: string,
prevVersion: string,
newVersion: string
}
export function parse (commitMessage: string, branchName: string, mainBranch: string): Array<updatedDependency> {
export interface alertLookup {
(dependencyName: string, dependencyVersion: string, directory: string): Promise<dependencyAlert>;
}
export async function parse (commitMessage: string, branchName: string, mainBranch: string, lookup?: alertLookup): Promise<Array<updatedDependency>> {
const bumpFragment = commitMessage.match(/^Bumps .* from (?<from>\d[^ ]*) to (?<to>\d[^ ]*)\.$/m)
const yamlFragment = commitMessage.match(/^-{3}\n(?<dependencies>[\S|\s]*?)\n^\.{3}\n/m)
const lookupFn = lookup ?? (() => Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 }))
if (yamlFragment?.groups && branchName.startsWith('dependabot')) {
const data = YAML.parse(yamlFragment.groups.dependencies)
@@ -18,21 +32,26 @@ export function parse (commitMessage: string, branchName: string, mainBranch: st
// Since we are on the `dependabot` branch (9 letters), the 10th letter in the branch name is the delimiter
const delim = branchName[10]
const chunks = branchName.split(delim)
const dirname = chunks.slice(2, -1).join(delim) || '/'
const prev = bumpFragment?.groups?.from ?? ''
const next = bumpFragment?.groups?.to ?? ''
if (data['updated-dependencies']) {
return data['updated-dependencies'].map(dependency => {
return await Promise.all(data['updated-dependencies'].map(async (dependency, index) => {
const dirname = `/${chunks.slice(2, -1 * (1 + (dependency['dependency-name'].match(/\//g) || []).length)).join(delim) || ''}`
return {
dependencyName: dependency['dependency-name'],
dependencyType: dependency['dependency-type'],
updateType: dependency['update-type'],
directory: dirname,
packageEcosystem: chunks[1],
targetBranch: mainBranch
targetBranch: mainBranch,
prevVersion: index === 0 ? prev : '',
newVersion: index === 0 ? next : '',
...await lookupFn(dependency['dependency-name'], index === 0 ? prev : '', dirname)
}
})
}))
}
}
return []
return Promise.resolve([])
}

View File

@@ -2,7 +2,7 @@ import * as github from '@actions/github'
import * as core from '@actions/core'
import nock from 'nock'
import { Context } from '@actions/github/lib/context'
import { getMessage } from './verified_commits'
import { getAlert, getMessage, trimSlashes } from './verified_commits'
beforeAll(() => {
nock.disableNetConnect()
@@ -130,6 +130,70 @@ test('it returns the commit message for a PR authored exclusively by Dependabot
expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toEqual('Bump lodash from 1.0.0 to 2.0.0')
})
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 }"}'
const response = {
data: {
repository: {
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' }
}
]
}
}
}
}
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' })
})
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' })
})
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: '' })
})
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: '' })
})
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: '' })
})
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 mockGitHubClient = github.getOctokit('mock-token')
function mockGitHubOtherContext (): Context {

View File

@@ -1,6 +1,7 @@
import * as core from '@actions/core'
import { GitHub } from '@actions/github/lib/utils'
import { Context } from '@actions/github/lib/context'
import type { dependencyAlert } from './update_metadata'
const DEPENDABOT_LOGIN = 'dependabot[bot]'
@@ -61,3 +62,41 @@ function warnOtherCommits (): void {
'any non-Dependabot changes.'
)
}
export async function getAlert (name: string, version: string, directory: string, client: InstanceType<typeof GitHub>, context: Context): Promise<dependencyAlert> {
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 {
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)
return {
alertState: found?.state ?? '',
ghsaId: found?.securityAdvisory.ghsaId ?? '',
cvss: found?.securityAdvisory.cvss.score ?? 0.0
}
}
export function trimSlashes (value: string): string {
return value.replace(/^\/+/, '').replace(/\/+$/, '')
}

View File

@@ -5,7 +5,7 @@ import * as dotenv from 'dotenv'
import { Argv } from 'yargs'
import { hideBin } from 'yargs/helpers'
import { getMessage } from './dependabot/verified_commits'
import { getMessage, getAlert } from './dependabot/verified_commits'
import { parse } from './dependabot/update_metadata'
import { getBranchNames, parseNwo } from './dependabot/util'
@@ -51,8 +51,9 @@ async function check (args: any): Promise<void> {
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 updatedDependencies = parse(commitMessage, branchNames.headName, branchNames.baseName)
const updatedDependencies = await parse(commitMessage, branchNames.headName, branchNames.baseName, lookupFn)
if (updatedDependencies.length > 0) {
console.log('Updated dependencies:')

View File

@@ -23,6 +23,7 @@ test('it early exits with an error if github-token is not set', async () => {
)
/* eslint-disable no-unused-expressions */
expect(dependabotCommits.getMessage).not.toHaveBeenCalled
expect(dependabotCommits.getAlert).not.toHaveBeenCalled
/* eslint-enable no-unused-expressions */
})
@@ -38,6 +39,9 @@ test('it does nothing if the PR is not verified as from Dependabot', async () =>
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('PR is not from Dependabot, nothing to do.')
)
/* eslint-disable no-unused-expressions */
expect(dependabotCommits.getAlert).not.toHaveBeenCalled
/* eslint-enable no-unused-expressions */
})
test('it does nothing if there is no metadata in the commit', async () => {
@@ -52,6 +56,9 @@ test('it does nothing if there is no metadata in the commit', async () => {
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('PR does not contain metadata, nothing to do.')
)
/* eslint-disable no-unused-expressions */
expect(dependabotCommits.getAlert).not.toHaveBeenCalled
/* eslint-enable no-unused-expressions */
})
test('it sets the updated dependency as an output for subsequent actions', async () => {
@@ -69,12 +76,16 @@ test('it sets the updated dependency as an output for subsequent actions', async
'...\n' +
'\n' +
'Signed-off-by: dependabot[bot] <support@github.com>'
const mockAlert = { alertState: 'FIXED', ghsaId: 'GSHA', cvss: 3.4 }
jest.spyOn(core, 'getInput').mockReturnValue('mock-token')
jest.spyOn(core, 'getInput').mockImplementation(jest.fn((name) => { return name === 'github-token' ? 'mock-token' : '' }))
jest.spyOn(util, 'getBranchNames').mockReturnValue({ headName: 'dependabot|nuget|feature1', baseName: 'main' })
jest.spyOn(dependabotCommits, 'getMessage').mockImplementation(jest.fn(
() => Promise.resolve(mockCommitMessage)
))
jest.spyOn(dependabotCommits, 'getAlert').mockImplementation(jest.fn(
() => Promise.resolve(mockAlert)
))
jest.spyOn(core, 'setOutput').mockImplementation(jest.fn())
await run()
@@ -92,7 +103,12 @@ test('it sets the updated dependency as an output for subsequent actions', async
updateType: 'version-update:semver-minor',
directory: '/',
packageEcosystem: 'nuget',
targetBranch: 'main'
targetBranch: 'main',
prevVersion: '4.0.1',
newVersion: '4.2.2',
alertState: '',
ghsaId: '',
cvss: 0
}
]
)
@@ -103,10 +119,16 @@ test('it sets the updated dependency as an output for subsequent actions', async
expect(core.setOutput).toBeCalledWith('directory', '/')
expect(core.setOutput).toBeCalledWith('package-ecosystem', 'nuget')
expect(core.setOutput).toBeCalledWith('target-branch', 'main')
expect(core.setOutput).toBeCalledWith('previous-version', '4.0.1')
expect(core.setOutput).toBeCalledWith('new-version', '4.2.2')
expect(core.setOutput).toBeCalledWith('alert-state', '')
expect(core.setOutput).toBeCalledWith('ghsa-id', '')
expect(core.setOutput).toBeCalledWith('cvss', 0)
})
test('if there are multiple dependencies, it summarizes them', async () => {
const mockCommitMessage =
'Bump coffee-rails from 4.0.1 to 4.2.2 in api/main\n' +
'Bumps [coffee-rails](https://github.com/rails/coffee-rails) from 4.0.1 to 4.2.2.\n' +
'- [Release notes](https://github.com/rails/coffee-rails/releases)\n' +
'- [Changelog](https://github.com/rails/coffee-rails/blob/master/CHANGELOG.md)\n' +
@@ -123,12 +145,16 @@ test('if there are multiple dependencies, it summarizes them', async () => {
'...\n' +
'\n' +
'Signed-off-by: dependabot[bot] <support@github.com>'
const mockAlert = { alertState: '', ghsaId: '', cvss: 0 }
jest.spyOn(core, 'getInput').mockReturnValue('mock-token')
jest.spyOn(util, 'getBranchNames').mockReturnValue({ headName: 'dependabot/npm_and_yarn/api/main/feature1', baseName: 'trunk' })
jest.spyOn(dependabotCommits, 'getMessage').mockImplementation(jest.fn(
() => Promise.resolve(mockCommitMessage)
))
jest.spyOn(dependabotCommits, 'getAlert').mockImplementation(jest.fn(
() => Promise.resolve(mockAlert)
))
jest.spyOn(core, 'setOutput').mockImplementation(jest.fn())
await run()
@@ -144,17 +170,27 @@ test('if there are multiple dependencies, it summarizes them', async () => {
dependencyName: 'coffee-rails',
dependencyType: 'direct:production',
updateType: 'version-update:semver-minor',
directory: 'api/main',
directory: '/api/main',
packageEcosystem: 'npm_and_yarn',
targetBranch: 'trunk'
targetBranch: 'trunk',
prevVersion: '4.0.1',
newVersion: '4.2.2',
alertState: '',
ghsaId: '',
cvss: 0
},
{
dependencyName: 'coffeescript',
dependencyType: 'indirect',
updateType: 'version-update:semver-major',
directory: 'api/main',
directory: '/api/main',
packageEcosystem: 'npm_and_yarn',
targetBranch: 'trunk'
targetBranch: 'trunk',
prevVersion: '',
newVersion: '',
alertState: '',
ghsaId: '',
cvss: 0
}
]
)
@@ -162,9 +198,14 @@ test('if there are multiple dependencies, it summarizes them', async () => {
expect(core.setOutput).toBeCalledWith('dependency-names', 'coffee-rails, coffeescript')
expect(core.setOutput).toBeCalledWith('dependency-type', 'direct:production')
expect(core.setOutput).toBeCalledWith('update-type', 'version-update:semver-major')
expect(core.setOutput).toBeCalledWith('directory', 'api/main')
expect(core.setOutput).toBeCalledWith('directory', '/api/main')
expect(core.setOutput).toBeCalledWith('package-ecosystem', 'npm_and_yarn')
expect(core.setOutput).toBeCalledWith('target-branch', 'trunk')
expect(core.setOutput).toBeCalledWith('previous-version', '4.0.1')
expect(core.setOutput).toBeCalledWith('new-version', '4.2.2')
expect(core.setOutput).toBeCalledWith('alert-state', '')
expect(core.setOutput).toBeCalledWith('ghsa-id', '')
expect(core.setOutput).toBeCalledWith('cvss', 0)
})
test('it sets the action to failed if there is an unexpected exception', async () => {
@@ -179,6 +220,9 @@ test('it sets the action to failed if there is an unexpected exception', async (
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('Something bad happened!')
)
/* eslint-disable no-unused-expressions */
expect(dependabotCommits.getAlert).not.toHaveBeenCalled
/* eslint-enable no-unused-expressions */
})
test('it sets the action to failed if there is a request error', async () => {
@@ -202,4 +246,7 @@ test('it sets the action to failed if there is a request error', async () => {
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('(500) Something bad happened!')
)
/* eslint-disable no-unused-expressions */
expect(dependabotCommits.getAlert).not.toHaveBeenCalled
/* eslint-enable no-unused-expressions */
})

View File

@@ -24,12 +24,16 @@ export async function run (): Promise<void> {
// Validate the job
const commitMessage = await verifiedCommits.getMessage(githubClient, github.context)
const branchNames = util.getBranchNames(github.context)
let alertLookup: updateMetadata.alertLookup | undefined
if (core.getInput('alert-lookup')) {
alertLookup = (name, version, directory) => verifiedCommits.getAlert(name, version, directory, githubClient, github.context)
}
if (commitMessage) {
// Parse metadata
core.info('Parsing Dependabot metadata')
const updatedDependencies = updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName)
const updatedDependencies = await updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName, alertLookup)
if (updatedDependencies.length > 0) {
output.set(updatedDependencies)