diff --git a/.funcignore b/.funcignore index f8ea552..0bb8c81 100644 --- a/.funcignore +++ b/.funcignore @@ -1,7 +1,9 @@ *.js.map *.ts -.git* -.vscode +.gitignore +.git/ +.github/ +.vscode/ __azurite_db*__.json __blobstorage__ __queuestorage__ @@ -10,5 +12,11 @@ test tsconfig.json *.sh *.zip -README.md -node_modules/.bin \ No newline at end of file +node_modules/.bin +filter.yml.example +LICENSE +CODEOWNERS +.eslint* +*.md +jest.config.js +test/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index bf7b922..a09d085 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,8 @@ __azurite_db*__.json # local scripts *.sh -*.zip \ No newline at end of file +*.zip + +# filter settings - don't commit this, just commit filter.yml.example +# if you fork this repo, you can uncomment this and commit your own filter.yml +filter.yml diff --git a/INSTALL.md b/INSTALL.md index 21aacfd..894713f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -12,6 +12,36 @@ Once that is done, you can create an Actions workflow on any repository that the You need to create an Azure Function App, and deploy the Azure Function to it. +Before you deploy, you can choose to set a declarative filter for the GitHub events you want to listen for. + +This is done in the `fiter.yml` file, with the format shown in `filter.yml.example` and below: + +```yaml +# Path: filter.yml + +# filter webhook events by type and payload, declaratively + +include: + secret_scanning_alert: + action: [created, dismissed, resolved, reopened] + +exclude: + secret_scanning_alert: + action: reopened + secret_scanning_alert_location: + +``` + +The corresponding exclude filter for an event name is applied after the include filter. + +This example will include any event named `secret_scanning_alert` with an action of `created`, `dismissed`, or `resolved`, `reopened` and will exclude any event named `secret_scanning_alert` with an action of `reopened`. It will also exclude any event named `secret_scanning_alert_location`. + +The presence of an include filter here means that excluding `secret_scanning_alert_location` is redundant, as it will never be included in the first place, but it is included to show the syntax. + +If you do not want to use a filter, you can delete the `filter.yml` file, or leave it empty. + +You do not need to provide both an `include` and `exclude` key. + ### Creating the Functions App You can use the Azure Portal, the Azure CLI, or the VSCode Azure Functions extension to do this. diff --git a/filter.yml b/filter.yml index 91a12d9..72c4260 100644 --- a/filter.yml +++ b/filter.yml @@ -1,5 +1,11 @@ +# Path: filter.yml + # filter webhook events by type and payload, declaratively include: secret_scanning_alert: - action: [created, dismissed, resolved, reopened] + action: [created, dismissed, resolved] + +exclude: + secret_scanning_alert: + action: reopened diff --git a/filter.yml.example b/filter.yml.example new file mode 100644 index 0000000..72c4260 --- /dev/null +++ b/filter.yml.example @@ -0,0 +1,11 @@ +# Path: filter.yml + +# filter webhook events by type and payload, declaratively + +include: + secret_scanning_alert: + action: [created, dismissed, resolved] + +exclude: + secret_scanning_alert: + action: reopened diff --git a/src/functions/app.ts b/src/functions/app.ts index ab6fdaf..1956264 100644 --- a/src/functions/app.ts +++ b/src/functions/app.ts @@ -1,19 +1,7 @@ import { Probot } from "probot"; import { EmitterWebhookEvent } from "@octokit/webhooks"; import { GitHubAppWebHookPayload } from "./types"; - -// import * as fs from 'fs'; -// import * as yaml from 'yaml'; - -// // read in filter config, from a YAML file called "filter.yml" in the root of the repo -// // if the file doesn't exist, or is invalid YAML, we handle that gracefully -// let filter_config: any; - -// try { -// yaml.parse(fs.readFileSync('filter.yml', 'utf8')); -// } catch (error) { -// console.log(error); -// } +import { eventFilter } from "./filter"; const setupApp = (app: Probot) => { app.onAny(async (event: EmitterWebhookEvent) => { @@ -21,58 +9,15 @@ const setupApp = (app: Probot) => { const octokit = await app.auth(payload.installation.id); - // if (filter(context)) { - await octokit.repos.createDispatchEvent({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - event_type: event.name, - client_payload: payload as unknown as {[key:string]: unknown}, - }); - // } + if (eventFilter(event)) { + await octokit.repos.createDispatchEvent({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + event_type: event.name, + client_payload: payload as unknown as { [key: string]: unknown }, + }); + } }); }; -// const filter = (event: EmitterWebhookEvent): boolean => { -// if (filter_config === undefined) return true; - -// const event_name = event.name; - -// // check if the event type is allowed by the filter config -// // if there is an include list, and this type isn't on it, return false -// // if there is a matching entry in the excludes, and there are no more details under that entry, return false -// if (filter_config.include !== undefined && !(event_name in filter_config.include) -// || filter_config.exclude !== undefined && event_name in filter_config.exclude && Object.keys(filter_config.exclude).length === 0 -// ) return false; - -// // check the event payload against the filter config's include rule, if it exists -// const include_filter = filter_config.include[event_name]; -// const payload = event.payload; - -// if (include_filter !== undefined) { - -// const matches = Object.keys(include_filter).every(key => { -// if (typeof include_filter[key] === typeof (String)) return payload[key] === include_filter[key]; -// if (typeof include_filter[key] === typeof (Array)) return payload[key] in include_filter[key]; -// return false; -// }); - -// if (!matches) return false; -// } - -// // same for the exclude rule -// const exclude_filter = filter_config.exclude[event_name]; - -// if (exclude_filter !== undefined) { -// const matches = Object.keys(exclude_filter).every(key => { -// if (typeof exclude_filter[key] === typeof (String)) return payload[key] === exclude_filter[key]; -// if (typeof exclude_filter[key] === typeof (Array)) return payload[key] in exclude_filter[key]; -// return false; -// }); - -// if (matches) return false; -// } - -// return true; -// } - export default setupApp; diff --git a/src/functions/filter.ts b/src/functions/filter.ts new file mode 100644 index 0000000..38c3c60 --- /dev/null +++ b/src/functions/filter.ts @@ -0,0 +1,58 @@ +import * as fs from 'fs'; +import * as yaml from 'yaml'; +import { EmitterWebhookEvent } from '@octokit/webhooks'; + +// read in filter config, from a YAML file called "filter.yml" in the root of the repo +// if the file doesn't exist, or is invalid YAML, we handle that gracefully +let filter_config: any; + +try { + filter_config = yaml.parse(fs.readFileSync('filter.yml', 'utf8')); +} catch (error) { + // if the file doesn't exist, or can't be parsed, that's fine, we'll just use the default filter, which is to let everything through + // we do log the error + console.log(error); +} + +export const eventFilter = (event: EmitterWebhookEvent): boolean => { + if (filter_config === undefined) return true; + + const event_name = event.name; + + // check if the event type is allowed by the filter config + // if there is an include list, and this type isn't on it, return false + // if there is a matching entry in the excludes, and there are no more details under that entry, return false + if (filter_config.include !== undefined && !(event_name in filter_config.include) + || filter_config.exclude !== undefined && event_name in filter_config.exclude && Object.keys(filter_config.exclude).length === 0 + ) return false; + + // check the event payload against the filter config's include and exclude rules + // we can include or exclude by any string-valued key in the payload, and we can match a single string, or an array of options + const payload = event.payload as unknown as { [key: string]: unknown }; + + const include_filter = filter_config.include[event_name]; + if (!applyFilter(payload, include_filter, true)) return false; + + const exclude_filter = filter_config.exclude[event_name]; + if (applyFilter(payload, exclude_filter, false)) return false; + + return true; +} + +function applyFilter(payload: { [key: string]: unknown }, filter: { [key: string]: String | Array }, sense: boolean): boolean { + if (filter === undefined) return sense; + + const matches: boolean = Object.keys(filter).every(key => { + const payload_value = payload[key] as string; + + if (typeof filter[key] === "string") { + return payload_value === filter[key]; + } else if (Array.isArray(filter[key])) { + return filter[key].includes(payload_value); + } + console.warn(`Filter is of unexpected type: ${typeof filter[key]}`); + return false; + }); + + return matches; +} diff --git a/test/filter.test.ts b/test/filter.test.ts new file mode 100644 index 0000000..22c3625 --- /dev/null +++ b/test/filter.test.ts @@ -0,0 +1,16 @@ +import { EmitterWebhookEvent } from '@octokit/webhooks'; +import { eventFilter } from '../src/functions/filter'; + +describe('eventFilter', () => { + it ('includes an event correctly', () => { + const mockEvent = { name: 'secret_scanning_alert', payload: { action: 'created' } } as unknown as EmitterWebhookEvent; + const allowed = eventFilter(mockEvent); + expect(allowed).toBe(true); + }); + + it ('excludes an event correctly', () => { + const mockEvent = { name: 'secret_scanning_alert_location', payload: { action: 'created' } } as unknown as EmitterWebhookEvent; + const allowed = eventFilter(mockEvent); + expect(allowed).toBe(false); + }); +})