From 5d5f5c7e59254f9a2c4e260db44d9b5b95b09fdf Mon Sep 17 00:00:00 2001 From: Barry Gordon Date: Tue, 19 Oct 2021 11:52:52 +0100 Subject: [PATCH] Harden input gathering, Generate an absolute working directory --- .../events/blank_working_directory.json | 22 ++ __fixtures__/events/default.json | 3 +- __fixtures__/events/no_inputs.json | 15 + __tests__/inputs.test.ts | 278 ++++++++++++++++-- __tests__/main.test.ts | 42 +-- __tests__/updater-integration.test.ts | 3 +- src/api-client.ts | 13 +- src/inputs.ts | 96 +++++- src/main.ts | 14 +- 9 files changed, 415 insertions(+), 71 deletions(-) create mode 100644 __fixtures__/events/blank_working_directory.json create mode 100644 __fixtures__/events/no_inputs.json diff --git a/__fixtures__/events/blank_working_directory.json b/__fixtures__/events/blank_working_directory.json new file mode 100644 index 0000000..3236473 --- /dev/null +++ b/__fixtures__/events/blank_working_directory.json @@ -0,0 +1,22 @@ +{ + "inputs": { + "jobId": "1", + "jobToken": "xxx", + "credentialsToken": "yyy", + "dependabotApiUrl": "http://localhost:9000", + "workingDirectory": "" + }, + "ref": "main", + "repository": { + "owner": { + "login": "dependabot" + }, + "name": "dependabot-core" + }, + "sender": { + "type": "User" + }, + "installation": null, + "organization": null, + "workflow": "--workflow-yaml-goes-here--" +} diff --git a/__fixtures__/events/default.json b/__fixtures__/events/default.json index ba81c39..ed33374 100644 --- a/__fixtures__/events/default.json +++ b/__fixtures__/events/default.json @@ -3,7 +3,8 @@ "jobId": "1", "jobToken": "xxx", "credentialsToken": "yyy", - "dependabotApiUrl": "http://localhost:9000" + "dependabotApiUrl": "http://localhost:9000", + "workingDirectory": "./test_working_directory" }, "ref": "main", "repository": { diff --git a/__fixtures__/events/no_inputs.json b/__fixtures__/events/no_inputs.json new file mode 100644 index 0000000..06ef1d8 --- /dev/null +++ b/__fixtures__/events/no_inputs.json @@ -0,0 +1,15 @@ +{ + "ref": "main", + "repository": { + "owner": { + "login": "dependabot" + }, + "name": "dependabot-core" + }, + "sender": { + "type": "User" + }, + "installation": null, + "organization": null, + "workflow": "--workflow-yaml-goes-here--" +} diff --git a/__tests__/inputs.test.ts b/__tests__/inputs.test.ts index 5e9a339..85d09c5 100644 --- a/__tests__/inputs.test.ts +++ b/__tests__/inputs.test.ts @@ -1,27 +1,269 @@ +import crypto from 'crypto' +import fs from 'fs' +import path from 'path' import {Context} from '@actions/github/lib/context' import {getJobParameters} from '../src/inputs' +import {eventFixturePath} from './helpers' -test('returns null on issue_comment', () => { - const ctx = new Context() - ctx.eventName = 'issue_comment' - const params = getJobParameters(ctx) +let context: Context +const workspace = path.join(__dirname, '..', 'tmp') +const workingDirectory = path.join(workspace, './test_working_directory') - expect(params).toEqual(null) +beforeEach(() => { + fs.mkdirSync(workingDirectory) }) -test('loads dynamic', () => { - const ctx = new Context() - ctx.eventName = 'dynamic' - ctx.payload = { - inputs: { - jobId: 1, - jobToken: 'xxx', - credentialsToken: 'yyy' - } +afterEach(() => { + if (fs.existsSync(workingDirectory)) { + fs.rmdirSync(workingDirectory) } +}) + +describe('when there is a fully configured Actions environment', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = workspace + + context = new Context() + }) + + test('loads inputs from a dynamic event', () => { + const params = getJobParameters(context) + + expect(params?.jobId).toEqual(1) + expect(params?.jobToken).toEqual('xxx') + expect(params?.credentialsToken).toEqual('yyy') + expect(params?.dependabotApiUrl).toEqual('http://localhost:9000') + expect(params?.dependabotApiDockerUrl).toEqual('http://localhost:9000') + }) + + test('it returns an absolute path based on GITHUB_WORKSPACE and the workingDirectory input', () => { + const params = getJobParameters(context) + + expect(params?.workingDirectory).toEqual(workingDirectory) + }) +}) + +describe('when there is no GITHUB_EVENT_NAME defined', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + delete process.env.GITHUB_EVENT_NAME + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = workspace + + context = new Context() + }) + + test('it throws an error', () => { + expect(() => { + getJobParameters(context) + }).toThrow('Required Actions environment variables missing.') + }) +}) + +describe('when the GITHUB_EVENT_NAME is not "dynamic"', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'issue_comment' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = workspace + + context = new Context() + }) + + test('it returns null', () => { + const params = getJobParameters(context) + + expect(params).toEqual(null) + }) +}) + +describe('when there is no GITHUB_ACTOR defined', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'dynamic' + delete process.env.GITHUB_ACTOR + process.env.GITHUB_WORKSPACE = workspace + + context = new Context() + }) + + test('it throws an error', () => { + expect(() => { + getJobParameters(context) + }).toThrow('Required Actions environment variables missing.') + }) +}) + +describe('when the GITHUB_ACTOR is not "dependabot[bot]"', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'classic-rando' + process.env.GITHUB_WORKSPACE = workspace + + context = new Context() + }) + + test('it returns null', () => { + const params = getJobParameters(context) + + expect(params).toEqual(null) + }) +}) + +describe('when there is no GITHUB_WORKSPACE defined', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + delete process.env.GITHUB_WORKSPACE + + context = new Context() + }) + + test('it throws an error', () => { + expect(() => { + getJobParameters(context) + }).toThrow('Required Actions environment variables missing.') + }) +}) + +describe('when the GITHUB_WORKSPACE path does not exist', () => { + beforeEach(() => { + const randomFolderName = crypto.randomBytes(16).toString('hex') + + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = path.join(workspace, randomFolderName) + + context = new Context() + }) + + test('it throws an error', () => { + expect(() => { + getJobParameters(context) + }).toThrow('The GITHUB_WORKSPACE directory does not exist.') + }) +}) + +describe('when the GITHUB_WORKSPACE exists, but is a file', () => { + const randomFileName = path.join( + workspace, + crypto.randomBytes(16).toString('hex') + ) + + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = randomFileName + + fs.closeSync(fs.openSync(randomFileName, 'w')) + + context = new Context() + }) + + afterEach(() => { + fs.unlinkSync(randomFileName) + }) + + test('it throws an error', () => { + expect(() => { + getJobParameters(context) + }).toThrow('The GITHUB_WORKSPACE directory does not exist.') + }) +}) + +describe('when the workingDirectory is a blank value', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('blank_working_directory') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = workspace + + context = new Context() + + fs.rmdirSync(workingDirectory) + fs.closeSync(fs.openSync(workingDirectory, 'w')) + }) + + afterEach(() => { + fs.unlinkSync(workingDirectory) + }) + + test('it throws an error', () => { + expect(() => { + getJobParameters(context) + }).toThrow('The workingDirectory input must not be blank') + }) +}) + +describe('when the workingDirectory does not exist', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = workspace + + context = new Context() + + fs.rmdirSync(workingDirectory) + }) + + test('it throws an error', () => { + expect(() => { + getJobParameters(context) + }).toThrow( + `The workingDirectory './test_working_directory' does not exist in GITHUB_WORKSPACE` + ) + }) +}) + +describe('when the workingDirectory exists, but is a file', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = workspace + + context = new Context() + + fs.rmdirSync(workingDirectory) + fs.closeSync(fs.openSync(workingDirectory, 'w')) + }) + + afterEach(() => { + fs.unlinkSync(workingDirectory) + }) + + test('it throws an error', () => { + expect(() => { + getJobParameters(context) + }).toThrow( + `The workingDirectory './test_working_directory' does not exist in GITHUB_WORKSPACE` + ) + }) +}) + +describe('when the event inputs are empty', () => { + beforeEach(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('no_inputs') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = workspace + + context = new Context() + + fs.rmdirSync(workingDirectory) + }) - const params = getJobParameters(ctx) - expect(params?.jobId).toEqual(1) - expect(params?.jobToken).toEqual('xxx') - expect(params?.credentialsToken).toEqual('yyy') + test('it throws an error', () => { + expect(() => { + getJobParameters(context) + }).toThrow('Event payload has no inputs') + }) }) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 9df35ff..87cd4ed 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -1,3 +1,5 @@ +import fs from 'fs' +import path from 'path' import * as core from '@actions/core' import {Context} from '@actions/github/lib/context' import {ApiClient} from '../src/api-client' @@ -15,11 +17,18 @@ jest.mock('../src/updater') describe('run', () => { let context: Context + const workspace = path.join(__dirname, '..', 'tmp') + const workingDirectory = path.join(workspace, './test_working_directory') let markJobAsProcessedSpy: any let reportJobErrorSpy: any beforeEach(async () => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'dynamic' + process.env.GITHUB_ACTOR = 'dependabot[bot]' + process.env.GITHUB_WORKSPACE = workspace + markJobAsProcessedSpy = jest.spyOn( ApiClient.prototype, 'markJobAsProcessed' @@ -28,17 +37,17 @@ describe('run', () => { jest.spyOn(core, 'info').mockImplementation(jest.fn()) jest.spyOn(core, 'setFailed').mockImplementation(jest.fn()) + + fs.mkdirSync(workingDirectory) }) afterEach(async () => { jest.clearAllMocks() // Reset any mocked classes + fs.rmdirSync(workingDirectory) }) describe('when the run follows the happy path', () => { - beforeAll(() => { - process.env.GITHUB_EVENT_PATH = eventFixturePath('default') - process.env.GITHUB_EVENT_NAME = 'dynamic' - process.env.GITHUB_ACTOR = 'dependabot[bot]' + beforeEach(() => { context = new Context() }) @@ -59,10 +68,8 @@ describe('run', () => { }) }) - describe('when the action is triggered on by a different actor', () => { - beforeAll(() => { - process.env.GITHUB_EVENT_PATH = eventFixturePath('default') - process.env.GITHUB_EVENT_NAME = 'dynamic' + describe('when the action is triggered by a different actor', () => { + beforeEach(() => { process.env.GITHUB_ACTOR = 'classic-rando' context = new Context() }) @@ -85,10 +92,8 @@ describe('run', () => { }) describe('when the action is triggered on an unsupported event', () => { - beforeAll(() => { - process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + beforeEach(() => { process.env.GITHUB_EVENT_NAME = 'issue_created' - process.env.GITHUB_ACTOR = 'dependabot[bot]' context = new Context() }) @@ -117,9 +122,6 @@ describe('run', () => { }) ) - process.env.GITHUB_EVENT_PATH = eventFixturePath('default') - process.env.GITHUB_EVENT_NAME = 'dynamic' - process.env.GITHUB_ACTOR = 'dependabot[bot]' context = new Context() }) @@ -149,9 +151,6 @@ describe('run', () => { ) ) - process.env.GITHUB_EVENT_PATH = eventFixturePath('default') - process.env.GITHUB_EVENT_NAME = 'dynamic' - process.env.GITHUB_ACTOR = 'dependabot[bot]' context = new Context() }) @@ -181,9 +180,6 @@ describe('run', () => { ) ) - process.env.GITHUB_EVENT_PATH = eventFixturePath('default') - process.env.GITHUB_EVENT_NAME = 'dynamic' - process.env.GITHUB_ACTOR = 'dependabot[bot]' context = new Context() }) @@ -218,9 +214,6 @@ describe('run', () => { ) ) - process.env.GITHUB_EVENT_PATH = eventFixturePath('default') - process.env.GITHUB_EVENT_NAME = 'dynamic' - process.env.GITHUB_ACTOR = 'dependabot[bot]' context = new Context() }) @@ -255,9 +248,6 @@ describe('run', () => { ) ) - process.env.GITHUB_EVENT_PATH = eventFixturePath('default') - process.env.GITHUB_EVENT_NAME = 'dynamic' - process.env.GITHUB_ACTOR = 'dependabot[bot]' context = new Context() }) diff --git a/__tests__/updater-integration.test.ts b/__tests__/updater-integration.test.ts index 88e747a..64b5793 100644 --- a/__tests__/updater-integration.test.ts +++ b/__tests__/updater-integration.test.ts @@ -1,7 +1,8 @@ import axios from 'axios' -import {ApiClient, JobParameters} from '../src/api-client' +import {ApiClient} from '../src/api-client' import {ImageService} from '../src/image-service' +import {JobParameters} from '../src/inputs' import {UPDATER_IMAGE_NAME, PROXY_IMAGE_NAME} from '../src/main' import {Updater} from '../src/updater' diff --git a/src/api-client.ts b/src/api-client.ts index 762892f..34e4cdd 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -1,16 +1,5 @@ import {AxiosInstance} from 'axios' - -// JobParameters are the parameters to execute a job -export class JobParameters { - constructor( - readonly jobId: number, - readonly jobToken: string, - readonly credentialsToken: string, - readonly dependabotApiUrl: string, - readonly dependabotApiDockerUrl: string, - readonly workingDirectory: string - ) {} -} +import {JobParameters} from './inputs' // JobDetails are information about the repository and dependencies to be updated export type JobDetails = { diff --git a/src/inputs.ts b/src/inputs.ts index 28e9e34..dbb1d87 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -1,11 +1,32 @@ +import fs from 'fs' +import path from 'path' import * as core from '@actions/core' import {Context} from '@actions/github/lib/context' import {WorkflowDispatchEvent} from '@octokit/webhooks-types' -import {JobParameters} from './api-client' const DYNAMIC = 'dynamic' +const DEPENDABOT_ACTOR = 'dependabot[bot]' + +// JobParameters are the Action inputs required to execute the job +export class JobParameters { + constructor( + readonly jobId: number, + readonly jobToken: string, + readonly credentialsToken: string, + readonly dependabotApiUrl: string, + readonly dependabotApiDockerUrl: string, + readonly workingDirectory: string + ) {} +} export function getJobParameters(ctx: Context): JobParameters | null { + checkEnvironmentAndContext(ctx) + + if (ctx.actor !== DEPENDABOT_ACTOR) { + core.info('This workflow can only be triggered by Dependabot.') + return null + } + if (ctx.eventName === DYNAMIC) { return fromWorkflowInputs(ctx) } else { @@ -16,22 +37,91 @@ export function getJobParameters(ctx: Context): JobParameters | null { } } +function checkEnvironmentAndContext(ctx: Context): boolean { + let valid = true + + if (!ctx.actor) { + core.error('GITHUB_ACTOR is not defined') + valid = false + } + + if (!ctx.eventName) { + core.error('GITHUB_EVENT_NAME is not defined') + valid = false + } + + if (!process.env.GITHUB_WORKSPACE) { + core.error('GITHUB_WORKSPACE is not defined') + valid = false + } + + if (!valid) { + throw new Error('Required Actions environment variables missing.') + } else { + return valid + } +} + function fromWorkflowInputs(ctx: Context): JobParameters { const evt = ctx.payload as WorkflowDispatchEvent if (!evt.inputs) { - throw new Error('Missing inputs in WorkflowDispatchEvent') + throw new Error('Event payload has no inputs') } const dependabotApiDockerUrl = evt.inputs.dependabotApiDockerUrl || evt.inputs.dependabotApiUrl + const workingDirectory = absoluteWorkingDirectory( + evt.inputs.workingDirectory as string + ) + return new JobParameters( parseInt(evt.inputs.jobId as string, 10), evt.inputs.jobToken as string, evt.inputs.credentialsToken as string, evt.inputs.dependabotApiUrl as string, dependabotApiDockerUrl as string, - evt.inputs.workingDirectory as string + workingDirectory ) } + +function absoluteWorkingDirectory(workingDirectory: string): string { + const workspace = process.env.GITHUB_WORKSPACE as string + + if (!workingDirectory) { + throw new Error('The workingDirectory input must not be blank.') + } + + if (!directoryExistsSync(workspace)) { + throw new Error('The GITHUB_WORKSPACE directory does not exist.') + } + + const fullPath = path.join(workspace, workingDirectory) + + if (!directoryExistsSync(fullPath)) { + throw new Error( + `The workingDirectory '${workingDirectory}' does not exist in GITHUB_WORKSPACE` + ) + } + + return fullPath +} + +function directoryExistsSync(directoryPath: string): boolean { + let stats: fs.Stats + + try { + stats = fs.statSync(directoryPath) + } catch (error) { + if (error.code === 'ENOENT') { + return false + } + + throw new Error( + `Encountered an error when checking whether path '${directoryPath}' exists: ${error.message}` + ) + } + + return stats.isDirectory() +} diff --git a/src/main.ts b/src/main.ts index b41afa7..0b1d5f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,8 +7,6 @@ import {Updater} from './updater' import {ApiClient} from './api-client' import axios from 'axios' -const DEPENDABOT_ACTOR = 'dependabot[bot]' - export const UPDATER_IMAGE_NAME = 'docker.pkg.github.com/dependabot/dependabot-updater:v1' export const PROXY_IMAGE_NAME = @@ -24,16 +22,12 @@ export async function run(context: Context): Promise { try { core.info('🤖 ~ starting update ~') - if (context.actor !== DEPENDABOT_ACTOR) { - core.info('This workflow can only be triggered by Dependabot.') - core.info('🤖 ~ finished: nothing to do ~') - return // TODO: This should be setNeutral in future - } - - // Decode JobParameters + // Retrieve JobParameters from the Actions environment const params = getJobParameters(context) + + // The parameters will be null if the Action environment + // is not a valid Dependabot-triggered dynamic event. if (params === null) { - core.info('No job parameters') core.info('🤖 ~ finished: nothing to do ~') return // TODO: This should be setNeutral in future }